diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f06b0eb..15905f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,8 +301,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install ripgrep - run: sudo apt-get update && sudo apt-get install -y ripgrep - name: Ban globals shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 751a9df6..d3d7f6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ obstruction receipts from counterfactual retention: refusal is a causal event but not admission, and counterfactuals begin only after a rewrite is legally admitted and then left unselected at the scheduler boundary. +- `warp-core` now attaches an `ObstructionReceipt` to capability grant intent + refusals. The receipt records causal refusal context and remains explicitly + `RewriteDisposition::Obstructed`, not an admission ticket, law witness, or + counterfactual candidate. It also carries the authority policy id/posture used + to classify the refusal when that policy context is present. Receipt input + bytes are rebuilt on demand for digest verification instead of being stored on + every refusal receipt. - `docs/design/transaction-optic-atomicity-model.md` defines Echo's doctrine for atomic composite optics: one basis, one admission surface, transaction-local execution, one committed delta, and receipt-emitting refusal or admission. It @@ -98,6 +105,9 @@ ### Fixed +- `Determinism Guards` no longer runs `apt-get install ripgrep`; static guard + scripts now fall back to Perl regex scanning when `rg` is unavailable, so mirror stalls + cannot hang the determinism gate. - Stack Witness 0001 fixture observations now require the fixture `createBuffer` and `replaceRange("hello")` history to be admitted and materialized before `textWindow` can return `QueryBytes("hello")`. diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index d4ae77c4..859e7292 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -250,11 +250,12 @@ pub use optic::{ pub use optic_artifact::{ AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantIntent, CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, CapabilityGrantIntentOutcome, - CapabilityGrantIntentPosture, OpticAdmissionRequirements, OpticAdmissionTicketPosture, - OpticApertureRequest, OpticArtifact, OpticArtifactHandle, OpticArtifactOperation, - OpticArtifactRegistrationError, OpticArtifactRegistry, OpticBasisRequest, - OpticCapabilityPresentation, OpticInvocation, OpticInvocationAdmissionOutcome, - OpticInvocationObstruction, OpticRegistrationDescriptor, PrincipalRef, RegisteredOpticArtifact, + CapabilityGrantIntentPosture, ObstructionReceipt, OpticAdmissionRequirements, + OpticAdmissionTicketPosture, OpticApertureRequest, OpticArtifact, OpticArtifactHandle, + OpticArtifactOperation, OpticArtifactRegistrationError, OpticArtifactRegistry, + OpticBasisRequest, OpticCapabilityPresentation, OpticInvocation, + OpticInvocationAdmissionOutcome, OpticInvocationObstruction, OpticRegistrationDescriptor, + PrincipalRef, RegisteredOpticArtifact, RewriteDisposition, OBSTRUCTION_RECEIPT_KIND, OPTIC_ADMISSION_TICKET_POSTURE_KIND, OPTIC_ARTIFACT_HANDLE_KIND, }; pub use playback::{CursorReceipt, TruthFrame, TruthSink}; diff --git a/crates/warp-core/src/optic_artifact.rs b/crates/warp-core/src/optic_artifact.rs index 02693c51..b1d6fe05 100644 --- a/crates/warp-core/src/optic_artifact.rs +++ b/crates/warp-core/src/optic_artifact.rs @@ -20,6 +20,9 @@ pub const OPTIC_ARTIFACT_HANDLE_KIND: &str = "optic-artifact-handle"; /// Echo-owned kind for a ticket-shaped pre-admission obstruction posture. pub const OPTIC_ADMISSION_TICKET_POSTURE_KIND: &str = "optic-admission-ticket-posture"; +/// Echo-owned kind for a causal refusal receipt. +pub const OBSTRUCTION_RECEIPT_KIND: &str = "obstruction-receipt"; + const OPTIC_ARTIFACT_HANDLE_ID_PREFIX: &str = "optic-artifact-handle:"; /// Opaque Echo-owned runtime handle for a registered optic artifact. @@ -147,6 +150,147 @@ pub struct PrincipalRef { pub id: String, } +/// Disposition for submitted rewrite material after Echo evaluates it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RewriteDisposition { + /// Echo selected and committed the rewrite. + Committed, + /// Echo admitted the rewrite as legal, but the scheduler did not select it. + LegalUnselectedCounterfactual, + /// Echo refused the intent before admission. + Obstructed, +} + +impl RewriteDisposition { + fn receipt_label(self) -> &'static str { + match self { + Self::Committed => "rewrite-disposition.committed", + Self::LegalUnselectedCounterfactual => { + "rewrite-disposition.legal-unselected-counterfactual" + } + Self::Obstructed => "rewrite-disposition.obstructed", + } + } +} + +/// Causal receipt for a refused intent. +/// +/// Refusal is causal evidence, not an unrealized legal world. An +/// [`ObstructionReceipt`] is not an admission ticket, not a law witness, and not +/// a counterfactual candidate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObstructionReceipt { + /// Stable discriminator for callers and wire adapters. + pub kind: String, + /// Intent id named by the refused intent. + pub intent_id: String, + /// Principal that proposed the refused intent. + pub proposed_by: PrincipalRef, + /// Subject named by the refused intent. + pub subject: PrincipalRef, + /// Artifact hash named by the refused intent. + pub artifact_hash: String, + /// Operation id named by the refused intent. + pub operation_id: String, + /// Requirements digest named by the refused intent. + pub requirements_digest: String, + /// Authority policy id supplied with refusal context, if any. + pub policy_id: Option, + /// Obstruction-only policy evaluation posture. + pub policy_posture: String, + /// Structured obstruction kind encoded for receipt consumers. + pub obstruction_kind: String, + /// Rewrite disposition. Obstruction receipts must remain obstructed. + pub disposition: RewriteDisposition, + /// BLAKE3 digest of [`ObstructionReceipt::build_receipt_input_bytes`]. + pub receipt_digest: [u8; 32], +} + +impl ObstructionReceipt { + /// Creates a causal refusal receipt for a capability grant intent. + #[must_use] + pub fn for_capability_grant_intent( + intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, + obstruction: CapabilityGrantIntentObstruction, + ) -> Self { + let obstruction_kind = obstruction.receipt_label().to_owned(); + let policy_id = authority_context + .policy + .as_ref() + .map(|policy| policy.policy_id.clone()); + let policy_posture = authority_context + .policy_evaluation + .receipt_label() + .to_owned(); + let mut receipt = Self { + kind: OBSTRUCTION_RECEIPT_KIND.to_owned(), + intent_id: intent.intent_id.clone(), + proposed_by: intent.proposed_by.clone(), + subject: intent.subject.clone(), + artifact_hash: intent.artifact_hash.clone(), + operation_id: intent.operation_id.clone(), + requirements_digest: intent.requirements_digest.clone(), + policy_id, + policy_posture, + obstruction_kind, + disposition: RewriteDisposition::Obstructed, + receipt_digest: [0_u8; 32], + }; + receipt.receipt_digest = *blake3::hash(&receipt.build_receipt_input_bytes()).as_bytes(); + receipt + } + + /// Rebuilds the deterministic receipt input bytes represented by this + /// receipt. + /// + /// The input bytes are intentionally not stored on the receipt. Consumers + /// that need to verify [`ObstructionReceipt::receipt_digest`] can rebuild + /// the exact digest input on demand. + #[must_use] + pub fn build_receipt_input_bytes(&self) -> Vec { + let disposition = self.disposition.receipt_label(); + let policy_id = self.policy_id.as_deref(); + let mut bytes = Vec::with_capacity(self.receipt_input_capacity(disposition)); + + push_receipt_field(&mut bytes, self.kind.as_bytes()); + push_receipt_field(&mut bytes, disposition.as_bytes()); + push_receipt_field(&mut bytes, self.intent_id.as_bytes()); + push_receipt_field(&mut bytes, self.proposed_by.id.as_bytes()); + push_receipt_field(&mut bytes, self.subject.id.as_bytes()); + push_receipt_field(&mut bytes, self.artifact_hash.as_bytes()); + push_receipt_field(&mut bytes, self.operation_id.as_bytes()); + push_receipt_field(&mut bytes, self.requirements_digest.as_bytes()); + push_optional_receipt_field(&mut bytes, policy_id.map(str::as_bytes)); + push_receipt_field(&mut bytes, self.policy_posture.as_bytes()); + push_receipt_field(&mut bytes, self.obstruction_kind.as_bytes()); + bytes + } + + fn receipt_input_capacity(&self, disposition: &str) -> usize { + const LENGTH_PREFIX_BYTES: usize = 8; + const OPTIONAL_TAG_BYTES: usize = 1; + const PLAIN_FIELD_COUNT: usize = 10; + + (PLAIN_FIELD_COUNT * LENGTH_PREFIX_BYTES) + + OPTIONAL_TAG_BYTES + + self.kind.len() + + disposition.len() + + self.intent_id.len() + + self.proposed_by.id.len() + + self.subject.id.len() + + self.artifact_hash.len() + + self.operation_id.len() + + self.requirements_digest.len() + + self + .policy_id + .as_ref() + .map_or(0, |policy_id| LENGTH_PREFIX_BYTES + policy_id.len()) + + self.policy_posture.len() + + self.obstruction_kind.len() + } +} + /// Authority policy selected for grant-intent evaluation. /// /// No policy is implemented in this slice. The shape exists so Echo can name @@ -172,6 +316,16 @@ pub enum AuthorityPolicyEvaluation { Unsupported, } +impl AuthorityPolicyEvaluation { + fn receipt_label(self) -> &'static str { + match self { + Self::InvalidDelegation => "authority-policy.invalid-delegation", + Self::ScopeEscalation => "authority-policy.scope-escalation", + Self::Unsupported => "authority-policy.unsupported", + } + } +} + /// Authority context supplied when proposing a capability grant intent. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthorityContext { @@ -230,6 +384,21 @@ pub enum CapabilityGrantIntentObstruction { UnsupportedAuthorityPolicy, } +impl CapabilityGrantIntentObstruction { + fn receipt_label(self) -> &'static str { + match self { + Self::MissingIssuerAuthority => "capability-grant-intent.missing-issuer-authority", + Self::MalformedGrantIntent => "capability-grant-intent.malformed-grant-intent", + Self::InvalidDelegation => "capability-grant-intent.invalid-delegation", + Self::ScopeEscalation => "capability-grant-intent.scope-escalation", + Self::ReplayOrDuplicateIntent => "capability-grant-intent.replay-or-duplicate-intent", + Self::UnsupportedAuthorityPolicy => { + "capability-grant-intent.unsupported-authority-policy" + } + } + } +} + /// Obstructed posture for a submitted capability grant intent. /// /// This is not an admitted grant receipt and does not make the grant authority. @@ -248,6 +417,9 @@ pub struct CapabilityGrantIntentPosture { pub subject: PrincipalRef, /// Structured reason Echo obstructed before admitting the grant. pub obstruction: CapabilityGrantIntentObstruction, + /// Causal refusal receipt. This is not an admission ticket, law witness, or + /// counterfactual candidate. + pub receipt: ObstructionReceipt, } /// Submission outcome for a capability grant intent skeleton. @@ -383,7 +555,7 @@ impl CapabilityGrantIntentGate { .insert(intent.intent_id.clone(), intent.clone()); } - Self::obstructed_grant_intent(&intent, obstruction) + Self::obstructed_grant_intent(&intent, &authority_context, obstruction) } /// Returns the number of submitted grant intents. @@ -463,6 +635,7 @@ impl CapabilityGrantIntentGate { fn obstructed_grant_intent( intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, obstruction: CapabilityGrantIntentObstruction, ) -> CapabilityGrantIntentOutcome { CapabilityGrantIntentOutcome::Obstructed(CapabilityGrantIntentPosture { @@ -471,10 +644,30 @@ impl CapabilityGrantIntentGate { proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, + receipt: ObstructionReceipt::for_capability_grant_intent( + intent, + authority_context, + obstruction, + ), }) } } +fn push_receipt_field(bytes: &mut Vec, field: &[u8]) { + bytes.extend_from_slice(&(field.len() as u64).to_be_bytes()); + bytes.extend_from_slice(field); +} + +fn push_optional_receipt_field(bytes: &mut Vec, field: Option<&[u8]>) { + match field { + Some(field) => { + bytes.push(1); + push_receipt_field(bytes, field); + } + None => bytes.push(0), + } +} + /// Echo-owned runtime-local registry for Wesley-compiled optic artifacts. #[derive(Clone, Debug, Default)] pub struct OpticArtifactRegistry { diff --git a/crates/warp-core/tests/capability_grant_intent_tests.rs b/crates/warp-core/tests/capability_grant_intent_tests.rs index cbc77453..22dd2818 100644 --- a/crates/warp-core/tests/capability_grant_intent_tests.rs +++ b/crates/warp-core/tests/capability_grant_intent_tests.rs @@ -5,7 +5,8 @@ use warp_core::{ AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantIntent, CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, CapabilityGrantIntentOutcome, - CapabilityGrantIntentPosture, PrincipalRef, + CapabilityGrantIntentPosture, ObstructionReceipt, PrincipalRef, RewriteDisposition, + OBSTRUCTION_RECEIPT_KIND, }; fn principal(id: &str) -> PrincipalRef { @@ -39,6 +40,7 @@ fn fixture_authority_context() -> AuthorityContext { fn expected_obstructed_posture( intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, obstruction: CapabilityGrantIntentObstruction, ) -> CapabilityGrantIntentOutcome { CapabilityGrantIntentOutcome::Obstructed(CapabilityGrantIntentPosture { @@ -47,6 +49,11 @@ fn expected_obstructed_posture( proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, + receipt: ObstructionReceipt::for_capability_grant_intent( + intent, + authority_context, + obstruction, + ), }) } @@ -56,18 +63,26 @@ fn obstruction_for(outcome: &CapabilityGrantIntentOutcome) -> CapabilityGrantInt } } +fn receipt_for(outcome: &CapabilityGrantIntentOutcome) -> &ObstructionReceipt { + match outcome { + CapabilityGrantIntentOutcome::Obstructed(posture) => &posture.receipt, + } +} + #[test] fn capability_grant_intent_obstructs_malformed_grant_intent() { let mut registry = CapabilityGrantIntentGate::new(); let mut intent = fixture_intent("intent:malformed"); intent.artifact_hash.clear(); - let outcome = registry.submit_grant_intent(intent.clone(), fixture_authority_context()); + let authority_context = fixture_authority_context(); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, expected_obstructed_posture( &intent, + &authority_context, CapabilityGrantIntentObstruction::MalformedGrantIntent ) ); @@ -98,12 +113,14 @@ fn capability_grant_intent_obstructs_missing_required_identity_as_malformed() { for intent in malformed_intents { let mut registry = CapabilityGrantIntentGate::new(); - let outcome = registry.submit_grant_intent(intent.clone(), fixture_authority_context()); + let authority_context = fixture_authority_context(); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, expected_obstructed_posture( &intent, + &authority_context, CapabilityGrantIntentObstruction::MalformedGrantIntent ) ); @@ -128,6 +145,7 @@ fn capability_grant_intent_obstructs_replay_or_duplicate_grant_intent() { replay_outcome, expected_obstructed_posture( &replay_intent, + &fixture_authority_context(), CapabilityGrantIntentObstruction::ReplayOrDuplicateIntent ) ); @@ -145,12 +163,13 @@ fn capability_grant_intent_obstructs_missing_issuer_authority() { policy_evaluation: AuthorityPolicyEvaluation::Unsupported, }; - let outcome = registry.submit_grant_intent(intent.clone(), authority_context); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, expected_obstructed_posture( &intent, + &authority_context, CapabilityGrantIntentObstruction::MissingIssuerAuthority ) ); @@ -168,11 +187,15 @@ fn capability_grant_intent_obstructs_invalid_delegation() { policy_evaluation: AuthorityPolicyEvaluation::InvalidDelegation, }; - let outcome = registry.submit_grant_intent(intent.clone(), authority_context); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, - expected_obstructed_posture(&intent, CapabilityGrantIntentObstruction::InvalidDelegation) + expected_obstructed_posture( + &intent, + &authority_context, + CapabilityGrantIntentObstruction::InvalidDelegation + ) ); } @@ -188,11 +211,15 @@ fn capability_grant_intent_obstructs_scope_escalation() { policy_evaluation: AuthorityPolicyEvaluation::ScopeEscalation, }; - let outcome = registry.submit_grant_intent(intent.clone(), authority_context); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, - expected_obstructed_posture(&intent, CapabilityGrantIntentObstruction::ScopeEscalation) + expected_obstructed_posture( + &intent, + &authority_context, + CapabilityGrantIntentObstruction::ScopeEscalation + ) ); } @@ -208,12 +235,13 @@ fn capability_grant_intent_obstructs_missing_policy_identity_as_unsupported_poli policy_evaluation: AuthorityPolicyEvaluation::InvalidDelegation, }; - let outcome = registry.submit_grant_intent(intent.clone(), authority_context); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, expected_obstructed_posture( &intent, + &authority_context, CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy ) ); @@ -224,17 +252,110 @@ fn capability_grant_intent_obstructs_unsupported_authority_policy() { let mut registry = CapabilityGrantIntentGate::new(); let intent = fixture_intent("intent:unsupported-policy"); - let outcome = registry.submit_grant_intent(intent.clone(), fixture_authority_context()); + let authority_context = fixture_authority_context(); + let outcome = registry.submit_grant_intent(intent.clone(), authority_context.clone()); assert_eq!( outcome, expected_obstructed_posture( &intent, + &authority_context, CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy ) ); } +#[test] +fn capability_grant_intent_obstruction_receipt_echoes_refusal_context() { + let mut registry = CapabilityGrantIntentGate::new(); + let intent = fixture_intent("intent:obstruction-receipt"); + let authority_context = fixture_authority_context(); + + let outcome = registry.submit_grant_intent(intent.clone(), authority_context); + let receipt = receipt_for(&outcome); + + assert_eq!(receipt.intent_id, intent.intent_id); + assert_eq!(receipt.proposed_by, intent.proposed_by); + assert_eq!(receipt.subject, intent.subject); + assert_eq!(receipt.artifact_hash, intent.artifact_hash); + assert_eq!(receipt.operation_id, intent.operation_id); + assert_eq!(receipt.requirements_digest, intent.requirements_digest); + assert_eq!( + receipt.policy_id, + Some("authority-policy:fixture".to_owned()) + ); + assert_eq!(receipt.policy_posture, "authority-policy.unsupported"); + assert_eq!( + receipt.obstruction_kind, + "capability-grant-intent.unsupported-authority-policy" + ); + assert_eq!(receipt.disposition, RewriteDisposition::Obstructed); +} + +#[test] +fn capability_grant_intent_obstruction_receipt_is_deterministic() { + let intent = fixture_intent("intent:deterministic-obstruction-receipt"); + let authority_context = fixture_authority_context(); + let mut first_registry = CapabilityGrantIntentGate::new(); + let mut second_registry = CapabilityGrantIntentGate::new(); + + let first = first_registry.submit_grant_intent(intent.clone(), authority_context.clone()); + let second = second_registry.submit_grant_intent(intent, authority_context); + + assert_eq!(first, second); +} + +#[test] +fn capability_grant_intent_obstruction_receipt_rebuilds_digest_input_bytes() { + let mut registry = CapabilityGrantIntentGate::new(); + let intent = fixture_intent("intent:rebuild-receipt-input"); + let outcome = registry.submit_grant_intent(intent, fixture_authority_context()); + let receipt = receipt_for(&outcome); + let rebuilt_input = receipt.build_receipt_input_bytes(); + + assert_eq!( + receipt.receipt_digest, + *blake3::hash(&rebuilt_input).as_bytes() + ); +} + +#[test] +fn capability_grant_intent_obstruction_receipt_distinguishes_absent_and_empty_policy_id() { + let intent = fixture_intent("intent:policy-presence"); + let no_policy_context = AuthorityContext { + issuer: Some(principal("principal:issuer")), + policy: None, + policy_evaluation: AuthorityPolicyEvaluation::Unsupported, + }; + let empty_policy_context = AuthorityContext { + issuer: Some(principal("principal:issuer")), + policy: Some(AuthorityPolicy { + policy_id: String::new(), + }), + policy_evaluation: AuthorityPolicyEvaluation::Unsupported, + }; + + let no_policy_receipt = ObstructionReceipt::for_capability_grant_intent( + &intent, + &no_policy_context, + CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy, + ); + let empty_policy_receipt = ObstructionReceipt::for_capability_grant_intent( + &intent, + &empty_policy_context, + CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy, + ); + + assert_ne!( + no_policy_receipt.build_receipt_input_bytes(), + empty_policy_receipt.build_receipt_input_bytes() + ); + assert_ne!( + no_policy_receipt.receipt_digest, + empty_policy_receipt.receipt_digest + ); +} + #[test] fn capability_grant_intent_never_makes_grant_authority() { let mut malformed = fixture_intent("intent:malformed-empty-rights"); @@ -320,3 +441,38 @@ fn capability_grant_intent_never_makes_grant_authority() { CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy ); } + +#[test] +fn obstructed_intent_does_not_create_counterfactual_candidate() { + let mut registry = CapabilityGrantIntentGate::new(); + let mut intent = fixture_intent("intent:not-counterfactual"); + intent.rights.clear(); + + let outcome = registry.submit_grant_intent(intent.clone(), fixture_authority_context()); + let receipt = receipt_for(&outcome); + + assert_eq!(registry.len(), 0); + assert_eq!(receipt.kind, OBSTRUCTION_RECEIPT_KIND); + assert_eq!(receipt.intent_id, intent.intent_id); + assert_eq!(receipt.proposed_by, intent.proposed_by); + assert_eq!(receipt.subject, intent.subject); + assert_eq!(receipt.artifact_hash, intent.artifact_hash); + assert_eq!(receipt.operation_id, intent.operation_id); + assert_eq!(receipt.requirements_digest, intent.requirements_digest); + assert_eq!( + receipt.policy_id, + Some("authority-policy:fixture".to_owned()) + ); + assert_eq!(receipt.policy_posture, "authority-policy.unsupported"); + assert_eq!( + receipt.obstruction_kind, + "capability-grant-intent.malformed-grant-intent" + ); + assert_eq!(receipt.disposition, RewriteDisposition::Obstructed); + assert_ne!( + receipt.disposition, + RewriteDisposition::LegalUnselectedCounterfactual + ); + assert!(!receipt.build_receipt_input_bytes().is_empty()); + assert_ne!(receipt.receipt_digest, [0_u8; 32]); +} diff --git a/docs/design/obstruction-receipt-boundary.md b/docs/design/obstruction-receipt-boundary.md index ccb921f6..97bfd9eb 100644 --- a/docs/design/obstruction-receipt-boundary.md +++ b/docs/design/obstruction-receipt-boundary.md @@ -254,6 +254,13 @@ A future legal grant rewrite that is admitted and then not selected by a scheduler may become a counterfactual candidate. That is different from a refused grant intent. +The current `warp-core` grant-intent skeleton attaches an `ObstructionReceipt` +to `CapabilityGrantIntentPosture`. That receipt carries the refused intent +context, authority policy id/posture when present, and a digest of deterministic +receipt input bytes that can be rebuilt on demand. It remains +`RewriteDisposition::Obstructed`; it is not an admission ticket, not a law +witness, and not a counterfactual candidate. + ## Operating rules - Record refusals as causal obstruction receipts when the refusal matters to diff --git a/docs/design/optic-capability-grant-intent-boundary.md b/docs/design/optic-capability-grant-intent-boundary.md index 64c19a52..9affe968 100644 --- a/docs/design/optic-capability-grant-intent-boundary.md +++ b/docs/design/optic-capability-grant-intent-boundary.md @@ -20,6 +20,8 @@ is a basis-bound, aperture-bound, receipt-emitting atomic phase as described in Grant intent refusals are causal obstruction records, not counterfactual grant worlds, as described in [`obstruction-receipt-boundary.md`](obstruction-receipt-boundary.md). +The generic `ObstructionReceipt` echoes refusal context, includes policy +id/posture when present, and remains `RewriteDisposition::Obstructed`. This slice only adds the shape and obstruction boundary. It does not implement a real authority policy and therefore every grant intent remains obstructed. @@ -40,8 +42,8 @@ The lawful optic path is converging through small boundaries: 2. Echo registers the artifact and returns an `OpticArtifactHandle`. 3. An authority layer proposes bounded authority as `CapabilityGrantIntent`. 4. Echo evaluates the intent through an authority context and policy shape. -5. Echo returns `CapabilityGrantIntentPosture::Obstructed(...)` for every v0 - intent. +5. Echo returns `CapabilityGrantIntentPosture::Obstructed(...)` with an + `ObstructionReceipt` for every v0 intent. 6. A caller may later present an invocation with an artifact handle and presentation. 7. Current Echo invocation admission still obstructs every presentation. @@ -58,6 +60,7 @@ flowchart LR Context[AuthorityContext] Policy[AuthorityPolicy] Gate[CapabilityGrantIntentGate] + Receipt[ObstructionReceipt] Invocation[OpticInvocation] Admission[Invocation admission] FutureGrant[Future admitted grant] @@ -72,7 +75,8 @@ flowchart LR Context --> Policy Intent --> Gate Context --> Gate - Gate -->|Obstructed posture| Authority + Gate -->|Obstructed posture| Receipt + Receipt -->|durable refusal| Authority Gate -. future witnessed admission .-> FutureGrant App -->|handle + vars + presentation| Invocation Invocation --> Admission @@ -98,25 +102,25 @@ sequenceDiagram P->>E: submit_grant_intent(intent, authority_context) E->>G: classify intent + authority context alt malformed intent - G-->>E: Obstructed(MalformedGrantIntent) + G-->>E: Obstructed(MalformedGrantIntent + ObstructionReceipt) E-->>P: not authority else replay or duplicate intent id - G-->>E: Obstructed(ReplayOrDuplicateIntent) + G-->>E: Obstructed(ReplayOrDuplicateIntent + ObstructionReceipt) E-->>P: not authority else missing issuer authority - G-->>E: Obstructed(MissingIssuerAuthority) + G-->>E: Obstructed(MissingIssuerAuthority + ObstructionReceipt) E-->>P: not authority else invalid delegation G->>G: record submitted intent id for replay/duplicate obstruction - G-->>E: Obstructed(InvalidDelegation) + G-->>E: Obstructed(InvalidDelegation + ObstructionReceipt) E-->>P: not authority else scope escalation G->>G: record submitted intent id for replay/duplicate obstruction - G-->>E: Obstructed(ScopeEscalation) + G-->>E: Obstructed(ScopeEscalation + ObstructionReceipt) E-->>P: not authority else no supported policy exists G->>G: record submitted intent id for replay/duplicate obstruction - G-->>E: Obstructed(UnsupportedAuthorityPolicy) + G-->>E: Obstructed(UnsupportedAuthorityPolicy + ObstructionReceipt) E-->>P: not authority end @@ -204,6 +208,23 @@ classDiagram +proposed_by +subject +obstruction + +receipt + } + + class ObstructionReceipt { + +kind + +intent_id + +proposed_by + +subject + +artifact_hash + +operation_id + +requirements_digest + +policy_id + +policy_posture + +obstruction_kind + +disposition + +receipt_input_bytes + +receipt_digest } class CapabilityGrantIntentObstruction { @@ -231,6 +252,7 @@ classDiagram CapabilityGrantIntentGate --> CapabilityGrantIntentOutcome : returns CapabilityGrantIntentOutcome --> CapabilityGrantIntentPosture : carries CapabilityGrantIntentPosture --> CapabilityGrantIntentObstruction : explains + CapabilityGrantIntentPosture --> ObstructionReceipt : receipts ``` ## Entity relationship @@ -243,6 +265,7 @@ erDiagram CAPABILITY_GRANT_INTENT_GATE ||--o{ CAPABILITY_GRANT_INTENT : records_submitted OPTIC_ARTIFACT ||--o{ CAPABILITY_GRANT_INTENT : scoped_by CAPABILITY_GRANT_INTENT ||--|| GRANT_INTENT_POSTURE : obstructs_as + GRANT_INTENT_POSTURE ||--|| OBSTRUCTION_RECEIPT : carries CAPABILITY_PRESENTATION }o--o| CAPABILITY_GRANT_INTENT : claims OPTIC_INVOCATION }o--o| CAPABILITY_PRESENTATION : carries @@ -296,6 +319,22 @@ erDiagram string kind string obstruction } + + OBSTRUCTION_RECEIPT { + string kind + string intent_id + string proposed_by + string subject + string artifact_hash + string operation_id + string requirements_digest + string policy_id + string policy_posture + string obstruction_kind + string disposition + bytes receipt_input_bytes + bytes receipt_digest + } ``` ## Current grant intent shape @@ -318,18 +357,25 @@ The current `CapabilityGrantIntent` shape carries proposed authority material: evaluation field is policy-shaped evidence only; no trusted governance policy is implemented in this slice. +`ObstructionReceipt` echoes the refusal context and carries a BLAKE3 receipt +digest over deterministic length-prefixed receipt input bytes that can be +rebuilt on demand. It is not an admission receipt, not a `LawWitness`, and not +accepted authority. + ## This slice does - defines `PrincipalRef`; - defines `AuthorityPolicy` and `AuthorityContext`; - defines `CapabilityGrantIntent`; - defines `CapabilityGrantIntentPosture`; +- attaches `ObstructionReceipt` to obstructed grant intent postures; - classifies malformed grant intents; - classifies replay/duplicate grant intents as `ReplayOrDuplicateIntent`; - classifies missing issuer authority; - classifies invalid delegation; - classifies scope escalation; - classifies unsupported authority policy; +- receipts every obstructed grant intent as durable refusal; - records well-formed unique submitted intent ids deterministically; - keeps all grant intent submissions obstructed. @@ -339,6 +385,7 @@ implemented in this slice. - admit grant intents into witnessed history; - make any grant authority; - issue successful `AdmissionTicket` values; +- issue admission receipts; - emit `LawWitness` values; - verify signatures; - implement expiry semantics; diff --git a/scripts/ban-globals.sh b/scripts/ban-globals.sh index e76bed81..27c4aa73 100755 --- a/scripts/ban-globals.sh +++ b/scripts/ban-globals.sh @@ -29,12 +29,6 @@ PATHS="${BAN_GLOBALS_PATHS:-$PATHS_DEFAULT}" ALLOWLIST="${BAN_GLOBALS_ALLOWLIST:-.ban-globals-allowlist}" -# ripgrep is fast and consistent -if ! command -v rg >/dev/null 2>&1; then - echo "ERROR: ripgrep (rg) is required." >&2 - exit 2 -fi - # Patterns are conservative on purpose. # If you truly need an exception, add an allowlist line with a justification. PATTERNS=( @@ -51,7 +45,6 @@ echo "ban-globals: scanning paths:" for p in $PATHS; do echo " - $p"; done echo -# Build rg args RG_ARGS=(--hidden --no-ignore --glob '!.git/*' --glob '!target/*' --glob '!**/node_modules/*') # Apply allowlist as inverted matches (each line is a regex or fixed substring) @@ -60,6 +53,7 @@ RG_ARGS=(--hidden --no-ignore --glob '!.git/*' --glob '!target/*' --glob '!**/no # or: # ALLOW_RG_EXCLUDES=() +ALLOW_PATH_PATTERNS=() if [[ -f "$ALLOWLIST" ]]; then # Read first column (pattern) per line, ignore comments while IFS= read -r line; do @@ -70,16 +64,87 @@ if [[ -f "$ALLOWLIST" ]]; then [[ -z "$pat" ]] && continue # Exclude lines matching allowlisted pattern ALLOW_RG_EXCLUDES+=(--glob "!$pat") + ALLOW_PATH_PATTERNS+=("$pat") done < "$ALLOWLIST" fi violations=0 +is_allowlisted_path() { + local file="$1" + + for pat in "${ALLOW_PATH_PATTERNS[@]}"; do + if [[ "$file" == $pat ]]; then + return 0 + fi + done + + return 1 +} + +search_pattern_with_perl() { + local pat="$1" + local found=1 + local status=0 + + if ! command -v perl >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or perl is required." >&2 + return 2 + fi + + while IFS= read -r -d '' file; do + if is_allowlisted_path "$file"; then + continue + fi + + if SEARCH_PATTERN="$pat" perl -ne ' + BEGIN { $found = 0; $pattern = $ENV{"SEARCH_PATTERN"}; } + if (/$pattern/) { print "$ARGV:$.:$_"; $found = 1; } + END { exit($found ? 0 : 1); } + ' "$file"; then + found=0 + else + status=$? + if [[ $status -gt 1 ]]; then + return "$status" + fi + fi + done < <(find $PATHS -type f \ + \( -name '*.rs' \ + -o -name '*.toml' \ + -o -name '*.sh' \ + -o -name '*.mjs' \ + -o -name '*.js' \ + -o -name '*.ts' \ + -o -name '*.md' \ + -o -name '*.graphql' \ + -o -name '*.json' \ + -o -name '*.yaml' \ + -o -name '*.yml' \) \ + -not -path '*/.git/*' \ + -not -path '*/target/*' \ + -not -path '*/node_modules/*' \ + -print0) + + return "$found" +} + +search_pattern() { + local pat="$1" + + if command -v rg >/dev/null 2>&1; then + rg "${RG_ARGS[@]}" "${ALLOW_RG_EXCLUDES[@]}" -n -S "$pat" $PATHS + return $? + fi + + search_pattern_with_perl "$pat" +} + for pat in "${PATTERNS[@]}"; do echo "Checking: $pat" # We can't "glob exclude by line"; allowlist is file-level. Keep it simple: # If you need surgical exceptions, prefer moving code or refactoring. - if rg "${RG_ARGS[@]}" "${ALLOW_RG_EXCLUDES[@]}" -n -S "$pat" $PATHS; then + if search_pattern "$pat"; then echo violations=$((violations+1)) else diff --git a/scripts/ban-nondeterminism.sh b/scripts/ban-nondeterminism.sh index a39521b4..a8b37dc6 100755 --- a/scripts/ban-nondeterminism.sh +++ b/scripts/ban-nondeterminism.sh @@ -18,11 +18,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -if ! command -v rg >/dev/null 2>&1; then - echo "ERROR: ripgrep (rg) is required." >&2 - exit 2 -fi - PATHS_DEFAULT="crates/warp-core crates/warp-wasm crates/echo-wasm-abi" PATHS="${DETERMINISM_PATHS:-$PATHS_DEFAULT}" @@ -39,6 +34,7 @@ RG_ARGS=( # You can allow file-level exceptions via allowlist (keep it tiny). ALLOW_GLOBS=() +ALLOW_PATH_PATTERNS=() if [[ -f "$ALLOWLIST" ]]; then while IFS= read -r line; do [[ -z "$line" ]] && continue @@ -47,9 +43,81 @@ if [[ -f "$ALLOWLIST" ]]; then pat="${pat%% *}" [[ -z "$pat" ]] && continue ALLOW_GLOBS+=(--glob "!$pat") + ALLOW_PATH_PATTERNS+=("$pat") done < "$ALLOWLIST" fi +is_allowlisted_path() { + local file="$1" + + for pat in "${ALLOW_PATH_PATTERNS[@]}"; do + if [[ "$file" == $pat ]]; then + return 0 + fi + done + + return 1 +} + +search_pattern_with_perl() { + local pat="$1" + local found=1 + local status=0 + + if ! command -v perl >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or perl is required." >&2 + return 2 + fi + + while IFS= read -r -d '' file; do + if is_allowlisted_path "$file"; then + continue + fi + + if SEARCH_PATTERN="$pat" perl -ne ' + BEGIN { $found = 0; $pattern = $ENV{"SEARCH_PATTERN"}; } + if (/$pattern/) { print "$ARGV:$.:$_"; $found = 1; } + END { exit($found ? 0 : 1); } + ' "$file"; then + found=0 + else + status=$? + if [[ $status -gt 1 ]]; then + return "$status" + fi + fi + done < <(find $PATHS -type f \ + \( -name '*.rs' \ + -o -name '*.toml' \ + -o -name '*.sh' \ + -o -name '*.mjs' \ + -o -name '*.js' \ + -o -name '*.ts' \ + -o -name '*.md' \ + -o -name '*.graphql' \ + -o -name '*.json' \ + -o -name '*.yaml' \ + -o -name '*.yml' \) \ + -not -path '*/.git/*' \ + -not -path '*/target/*' \ + -not -path '*/node_modules/*' \ + -not -name '.clippy.toml' \ + -print0) + + return "$found" +} + +search_pattern() { + local pat="$1" + + if command -v rg >/dev/null 2>&1; then + rg "${RG_ARGS[@]}" "${ALLOW_GLOBS[@]}" -n -S "$pat" $PATHS + return $? + fi + + search_pattern_with_perl "$pat" +} + # Patterns: conservative and intentionally annoying. # If you hit a false positive, refactor; don't immediately allowlist. PATTERNS=( @@ -112,7 +180,7 @@ echo violations=0 for pat in "${PATTERNS[@]}"; do echo "Checking: $pat" - if rg "${RG_ARGS[@]}" "${ALLOW_GLOBS[@]}" -n -S "$pat" $PATHS; then + if search_pattern "$pat"; then echo violations=$((violations+1)) else diff --git a/scripts/ban-unordered-abi.sh b/scripts/ban-unordered-abi.sh index 1fa96838..9da0ab64 100755 --- a/scripts/ban-unordered-abi.sh +++ b/scripts/ban-unordered-abi.sh @@ -6,11 +6,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -if ! command -v rg >/dev/null 2>&1; then - echo "ERROR: ripgrep (rg) is required." >&2 - exit 2 -fi - # Adjust these to your repo conventions ABI_HINTS=( "abi" @@ -50,8 +45,36 @@ fi # Build pattern and trim trailing '|' to avoid matching everything pattern="$(printf '%s|' "${ABI_HINTS[@]}")" pattern="${pattern%|}" -mapfile -t files < <(rg "${RG_ARGS[@]}" -l -g'*.rs' "$pattern" crates/ || true) -shopt -s globstar +files=() +if command -v rg >/dev/null 2>&1; then + while IFS= read -r file; do + files+=("$file") + done < <(rg "${RG_ARGS[@]}" -l -g'*.rs' "$pattern" crates/ || true) +else + if ! command -v perl >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or perl is required." >&2 + exit 2 + fi + while IFS= read -r file; do + files+=("$file") + done < <( + find crates -type f -name '*.rs' \ + -not -path '*/.git/*' \ + -not -path '*/target/*' \ + -not -path '*/node_modules/*' \ + -print | + while IFS= read -r file; do + if SEARCH_PATTERN="$pattern" perl -ne ' + BEGIN { $found = 0; $pattern = $ENV{"SEARCH_PATTERN"}; } + if (/$pattern/) { $found = 1; } + END { exit($found ? 0 : 1); } + ' "$file"; then + printf '%s\n' "$file" + fi + done + ) +fi +shopt -s globstar 2>/dev/null || true filtered=() for f in "${files[@]}"; do allowed=false @@ -76,8 +99,37 @@ echo "ban-unordered-abi: scanning ABI-ish Rust files..." violations=0 # HashMap/HashSet are not allowed in ABI-ish types. Use Vec<(K,V)> sorted, BTreeMap, IndexMap with explicit canonicalization, etc. -if rg "${RG_ARGS[@]}" -n -S '\b(HashMap|HashSet)\b' "${files[@]}"; then +if command -v rg >/dev/null 2>&1; then + search_result=0 + rg "${RG_ARGS[@]}" -n -S '\b(HashMap|HashSet)\b' "${files[@]}" || search_result=$? +else + if ! command -v perl >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or perl is required." >&2 + exit 2 + fi + search_result=0 + for file in "${files[@]}"; do + if SEARCH_PATTERN='\b(HashMap|HashSet)\b' perl -ne ' + BEGIN { $found = 0; $pattern = $ENV{"SEARCH_PATTERN"}; } + if (/$pattern/) { print "$ARGV:$.:$_"; $found = 1; } + END { exit($found ? 0 : 1); } + ' "$file"; then + search_result=0 + break + else + status=$? + if [[ $status -gt 1 ]]; then + exit "$status" + fi + search_result=1 + fi + done +fi + +if [[ $search_result -eq 0 ]]; then violations=$((violations+1)) +elif [[ $search_result -gt 1 ]]; then + exit "$search_result" fi if [[ $violations -ne 0 ]]; then