From 3bb6a4e5e14888ad7108082a9e9a78f56e0e38b7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 07:09:05 -0700 Subject: [PATCH 1/6] feat(core): add obstruction receipt skeleton --- CHANGELOG.md | 4 + crates/warp-core/src/lib.rs | 11 +- crates/warp-core/src/optic_artifact.rs | 135 ++++++++++++++++++ .../tests/capability_grant_intent_tests.rs | 40 +++++- docs/design/obstruction-receipt-boundary.md | 5 + 5 files changed, 189 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 751a9df6..c7566268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ 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. - `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 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..84b8e710 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,97 @@ 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, +} + +/// 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, + /// Structured obstruction kind encoded for receipt consumers. + pub obstruction_kind: String, + /// Rewrite disposition. Obstruction receipts must remain obstructed. + pub disposition: RewriteDisposition, + /// Deterministic receipt input bytes. + pub receipt_input_bytes: Vec, + /// BLAKE3 digest of `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, + obstruction: CapabilityGrantIntentObstruction, + ) -> Self { + let obstruction_kind = obstruction.receipt_label().to_owned(); + let receipt_input_bytes = + Self::capability_grant_intent_receipt_input(intent, &obstruction_kind); + let receipt_digest = *blake3::hash(&receipt_input_bytes).as_bytes(); + + 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(), + obstruction_kind, + disposition: RewriteDisposition::Obstructed, + receipt_input_bytes, + receipt_digest, + } + } + + fn capability_grant_intent_receipt_input( + intent: &CapabilityGrantIntent, + obstruction_kind: &str, + ) -> Vec { + let mut bytes = Vec::new(); + push_receipt_field(&mut bytes, OBSTRUCTION_RECEIPT_KIND.as_bytes()); + push_receipt_field(&mut bytes, b"rewrite-disposition.obstructed"); + push_receipt_field(&mut bytes, intent.intent_id.as_bytes()); + push_receipt_field(&mut bytes, intent.proposed_by.id.as_bytes()); + push_receipt_field(&mut bytes, intent.subject.id.as_bytes()); + push_receipt_field(&mut bytes, intent.artifact_hash.as_bytes()); + push_receipt_field(&mut bytes, intent.operation_id.as_bytes()); + push_receipt_field(&mut bytes, intent.requirements_digest.as_bytes()); + push_receipt_field(&mut bytes, obstruction_kind.as_bytes()); + push_receipt_field_list(&mut bytes, &intent.rights); + push_receipt_field(&mut bytes, &intent.scope_bytes); + push_optional_receipt_field(&mut bytes, intent.expiry_bytes.as_deref()); + push_optional_receipt_field(&mut bytes, intent.delegation_basis_bytes.as_deref()); + bytes + } +} + /// Authority policy selected for grant-intent evaluation. /// /// No policy is implemented in this slice. The shape exists so Echo can name @@ -230,6 +324,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 +357,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. @@ -471,10 +583,33 @@ impl CapabilityGrantIntentGate { proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, + receipt: ObstructionReceipt::for_capability_grant_intent(intent, 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_receipt_field_list(bytes: &mut Vec, fields: &[String]) { + bytes.extend_from_slice(&(fields.len() as u64).to_be_bytes()); + for field in fields { + push_receipt_field(bytes, field.as_bytes()); + } +} + +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..1da83cea 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 { @@ -47,6 +48,7 @@ fn expected_obstructed_posture( proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, + receipt: ObstructionReceipt::for_capability_grant_intent(intent, obstruction), }) } @@ -56,6 +58,12 @@ 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(); @@ -320,3 +328,33 @@ 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.obstruction_kind, + "capability-grant-intent.malformed-grant-intent" + ); + assert_eq!(receipt.disposition, RewriteDisposition::Obstructed); + assert_ne!( + receipt.disposition, + RewriteDisposition::LegalUnselectedCounterfactual + ); + assert!(!receipt.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..6258bc68 100644 --- a/docs/design/obstruction-receipt-boundary.md +++ b/docs/design/obstruction-receipt-boundary.md @@ -254,6 +254,11 @@ 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 and 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 From 0e4de2e2e7943f2cf5a5be1d37e0ba578bb372fd Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 08:27:03 -0700 Subject: [PATCH 2/6] test(core): reconcile grant obstruction receipts --- CHANGELOG.md | 3 +- crates/warp-core/src/optic_artifact.rs | 46 +++++++++- .../tests/capability_grant_intent_tests.rs | 87 ++++++++++++++++--- docs/design/obstruction-receipt-boundary.md | 3 +- .../optic-capability-grant-intent-boundary.md | 64 ++++++++++++-- 5 files changed, 178 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7566268..34909097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ - `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. + counterfactual candidate. It also carries the authority policy id/posture used + to classify the refusal when that policy context is present. - `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 diff --git a/crates/warp-core/src/optic_artifact.rs b/crates/warp-core/src/optic_artifact.rs index 84b8e710..d394593a 100644 --- a/crates/warp-core/src/optic_artifact.rs +++ b/crates/warp-core/src/optic_artifact.rs @@ -182,6 +182,10 @@ pub struct ObstructionReceipt { 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. @@ -197,11 +201,24 @@ impl ObstructionReceipt { #[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 receipt_input_bytes = - Self::capability_grant_intent_receipt_input(intent, &obstruction_kind); + 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 receipt_input_bytes = Self::capability_grant_intent_receipt_input( + intent, + policy_id.as_deref(), + &policy_posture, + &obstruction_kind, + ); let receipt_digest = *blake3::hash(&receipt_input_bytes).as_bytes(); Self { @@ -212,6 +229,8 @@ impl ObstructionReceipt { 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_input_bytes, @@ -221,6 +240,8 @@ impl ObstructionReceipt { fn capability_grant_intent_receipt_input( intent: &CapabilityGrantIntent, + policy_id: Option<&str>, + policy_posture: &str, obstruction_kind: &str, ) -> Vec { let mut bytes = Vec::new(); @@ -232,6 +253,8 @@ impl ObstructionReceipt { push_receipt_field(&mut bytes, intent.artifact_hash.as_bytes()); push_receipt_field(&mut bytes, intent.operation_id.as_bytes()); push_receipt_field(&mut bytes, intent.requirements_digest.as_bytes()); + push_receipt_field(&mut bytes, policy_id.unwrap_or_default().as_bytes()); + push_receipt_field(&mut bytes, policy_posture.as_bytes()); push_receipt_field(&mut bytes, obstruction_kind.as_bytes()); push_receipt_field_list(&mut bytes, &intent.rights); push_receipt_field(&mut bytes, &intent.scope_bytes); @@ -266,6 +289,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 { @@ -495,7 +528,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. @@ -575,6 +608,7 @@ impl CapabilityGrantIntentGate { fn obstructed_grant_intent( intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, obstruction: CapabilityGrantIntentObstruction, ) -> CapabilityGrantIntentOutcome { CapabilityGrantIntentOutcome::Obstructed(CapabilityGrantIntentPosture { @@ -583,7 +617,11 @@ impl CapabilityGrantIntentGate { proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, - receipt: ObstructionReceipt::for_capability_grant_intent(intent, obstruction), + receipt: ObstructionReceipt::for_capability_grant_intent( + intent, + authority_context, + obstruction, + ), }) } } diff --git a/crates/warp-core/tests/capability_grant_intent_tests.rs b/crates/warp-core/tests/capability_grant_intent_tests.rs index 1da83cea..4c1f507c 100644 --- a/crates/warp-core/tests/capability_grant_intent_tests.rs +++ b/crates/warp-core/tests/capability_grant_intent_tests.rs @@ -40,6 +40,7 @@ fn fixture_authority_context() -> AuthorityContext { fn expected_obstructed_posture( intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, obstruction: CapabilityGrantIntentObstruction, ) -> CapabilityGrantIntentOutcome { CapabilityGrantIntentOutcome::Obstructed(CapabilityGrantIntentPosture { @@ -48,7 +49,11 @@ fn expected_obstructed_posture( proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, - receipt: ObstructionReceipt::for_capability_grant_intent(intent, obstruction), + receipt: ObstructionReceipt::for_capability_grant_intent( + intent, + authority_context, + obstruction, + ), }) } @@ -70,12 +75,14 @@ fn capability_grant_intent_obstructs_malformed_grant_intent() { 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 ) ); @@ -106,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 ) ); @@ -136,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 ) ); @@ -153,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 ) ); @@ -176,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 + ) ); } @@ -196,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 + ) ); } @@ -216,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 ) ); @@ -232,17 +252,59 @@ 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_never_makes_grant_authority() { let mut malformed = fixture_intent("intent:malformed-empty-rights"); @@ -346,6 +408,11 @@ fn obstructed_intent_does_not_create_counterfactual_candidate() { 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" diff --git a/docs/design/obstruction-receipt-boundary.md b/docs/design/obstruction-receipt-boundary.md index 6258bc68..ac1cad4a 100644 --- a/docs/design/obstruction-receipt-boundary.md +++ b/docs/design/obstruction-receipt-boundary.md @@ -256,7 +256,8 @@ refused grant intent. The current `warp-core` grant-intent skeleton attaches an `ObstructionReceipt` to `CapabilityGrantIntentPosture`. That receipt carries the refused intent -context and remains `RewriteDisposition::Obstructed`; it is not an admission +context, authority policy id/posture when present, deterministic receipt input +bytes, and remains `RewriteDisposition::Obstructed`; it is not an admission ticket, not a law witness, and not a counterfactual candidate. ## Operating rules diff --git a/docs/design/optic-capability-grant-intent-boundary.md b/docs/design/optic-capability-grant-intent-boundary.md index 64c19a52..b527a86c 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,24 @@ 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 deterministic +length-prefixed receipt input bytes plus a BLAKE3 receipt digest. 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 +384,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; From 9b21329c847b075e1f3204ef6f8c90704b40a423 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 11:03:44 -0700 Subject: [PATCH 3/6] Fix: compact obstruction receipt digest input --- CHANGELOG.md | 4 +- crates/warp-core/src/optic_artifact.rs | 108 +++++++++++------- .../tests/capability_grant_intent_tests.rs | 53 ++++++++- docs/design/obstruction-receipt-boundary.md | 7 +- .../optic-capability-grant-intent-boundary.md | 7 +- 5 files changed, 127 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34909097..e945772b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ 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. + 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 diff --git a/crates/warp-core/src/optic_artifact.rs b/crates/warp-core/src/optic_artifact.rs index d394593a..b1d6fe05 100644 --- a/crates/warp-core/src/optic_artifact.rs +++ b/crates/warp-core/src/optic_artifact.rs @@ -161,6 +161,18 @@ pub enum RewriteDisposition { 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 @@ -190,9 +202,7 @@ pub struct ObstructionReceipt { pub obstruction_kind: String, /// Rewrite disposition. Obstruction receipts must remain obstructed. pub disposition: RewriteDisposition, - /// Deterministic receipt input bytes. - pub receipt_input_bytes: Vec, - /// BLAKE3 digest of `receipt_input_bytes`. + /// BLAKE3 digest of [`ObstructionReceipt::build_receipt_input_bytes`]. pub receipt_digest: [u8; 32], } @@ -213,15 +223,7 @@ impl ObstructionReceipt { .policy_evaluation .receipt_label() .to_owned(); - let receipt_input_bytes = Self::capability_grant_intent_receipt_input( - intent, - policy_id.as_deref(), - &policy_posture, - &obstruction_kind, - ); - let receipt_digest = *blake3::hash(&receipt_input_bytes).as_bytes(); - - Self { + let mut receipt = Self { kind: OBSTRUCTION_RECEIPT_KIND.to_owned(), intent_id: intent.intent_id.clone(), proposed_by: intent.proposed_by.clone(), @@ -233,35 +235,60 @@ impl ObstructionReceipt { policy_posture, obstruction_kind, disposition: RewriteDisposition::Obstructed, - receipt_input_bytes, - receipt_digest, - } + receipt_digest: [0_u8; 32], + }; + receipt.receipt_digest = *blake3::hash(&receipt.build_receipt_input_bytes()).as_bytes(); + receipt } - fn capability_grant_intent_receipt_input( - intent: &CapabilityGrantIntent, - policy_id: Option<&str>, - policy_posture: &str, - obstruction_kind: &str, - ) -> Vec { - let mut bytes = Vec::new(); - push_receipt_field(&mut bytes, OBSTRUCTION_RECEIPT_KIND.as_bytes()); - push_receipt_field(&mut bytes, b"rewrite-disposition.obstructed"); - push_receipt_field(&mut bytes, intent.intent_id.as_bytes()); - push_receipt_field(&mut bytes, intent.proposed_by.id.as_bytes()); - push_receipt_field(&mut bytes, intent.subject.id.as_bytes()); - push_receipt_field(&mut bytes, intent.artifact_hash.as_bytes()); - push_receipt_field(&mut bytes, intent.operation_id.as_bytes()); - push_receipt_field(&mut bytes, intent.requirements_digest.as_bytes()); - push_receipt_field(&mut bytes, policy_id.unwrap_or_default().as_bytes()); - push_receipt_field(&mut bytes, policy_posture.as_bytes()); - push_receipt_field(&mut bytes, obstruction_kind.as_bytes()); - push_receipt_field_list(&mut bytes, &intent.rights); - push_receipt_field(&mut bytes, &intent.scope_bytes); - push_optional_receipt_field(&mut bytes, intent.expiry_bytes.as_deref()); - push_optional_receipt_field(&mut bytes, intent.delegation_basis_bytes.as_deref()); + /// 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. @@ -631,13 +658,6 @@ fn push_receipt_field(bytes: &mut Vec, field: &[u8]) { bytes.extend_from_slice(field); } -fn push_receipt_field_list(bytes: &mut Vec, fields: &[String]) { - bytes.extend_from_slice(&(fields.len() as u64).to_be_bytes()); - for field in fields { - push_receipt_field(bytes, field.as_bytes()); - } -} - fn push_optional_receipt_field(bytes: &mut Vec, field: Option<&[u8]>) { match field { Some(field) => { diff --git a/crates/warp-core/tests/capability_grant_intent_tests.rs b/crates/warp-core/tests/capability_grant_intent_tests.rs index 4c1f507c..22dd2818 100644 --- a/crates/warp-core/tests/capability_grant_intent_tests.rs +++ b/crates/warp-core/tests/capability_grant_intent_tests.rs @@ -305,6 +305,57 @@ fn capability_grant_intent_obstruction_receipt_is_deterministic() { 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"); @@ -422,6 +473,6 @@ fn obstructed_intent_does_not_create_counterfactual_candidate() { receipt.disposition, RewriteDisposition::LegalUnselectedCounterfactual ); - assert!(!receipt.receipt_input_bytes.is_empty()); + 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 ac1cad4a..97bfd9eb 100644 --- a/docs/design/obstruction-receipt-boundary.md +++ b/docs/design/obstruction-receipt-boundary.md @@ -256,9 +256,10 @@ 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, deterministic receipt input -bytes, and remains `RewriteDisposition::Obstructed`; it is not an admission -ticket, not a law witness, and not a counterfactual candidate. +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 diff --git a/docs/design/optic-capability-grant-intent-boundary.md b/docs/design/optic-capability-grant-intent-boundary.md index b527a86c..9affe968 100644 --- a/docs/design/optic-capability-grant-intent-boundary.md +++ b/docs/design/optic-capability-grant-intent-boundary.md @@ -357,9 +357,10 @@ 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 deterministic -length-prefixed receipt input bytes plus a BLAKE3 receipt digest. It is not an -admission receipt, not a `LawWitness`, and not accepted authority. +`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 From 46c0c3db862844f7e278634e684c604abb40ef67 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 11:05:15 -0700 Subject: [PATCH 4/6] Fix: avoid apt ripgrep install in determinism guard --- .github/workflows/ci.yml | 10 ++++++++-- CHANGELOG.md | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f06b0eb..fe2c9164 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,8 +301,14 @@ 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: Verify ripgrep + shell: bash + run: | + set -euo pipefail + if ! command -v rg >/dev/null; then + echo "Error: ripgrep (rg) is required by determinism guards but is not present on this runner." >&2 + exit 1 + fi - name: Ban globals shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e945772b..847ae51a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,9 @@ ### Fixed +- `Determinism Guards` now verifies a local `rg` binary instead of running + `apt-get install ripgrep`, so mirror stalls fail fast instead of hanging the + static 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")`. From 6bc0c63b312f7261a12e0bfdfb66378b1cff76e0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 11:09:30 -0700 Subject: [PATCH 5/6] Fix: run determinism guards without ripgrep install --- .github/workflows/ci.yml | 8 -------- CHANGELOG.md | 6 +++--- scripts/ban-globals.sh | 27 ++++++++++++++++++++------- scripts/ban-nondeterminism.sh | 33 +++++++++++++++++++++++++++------ scripts/ban-unordered-abi.sh | 35 ++++++++++++++++++++++++++++------- 5 files changed, 78 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe2c9164..15905f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,14 +301,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Verify ripgrep - shell: bash - run: | - set -euo pipefail - if ! command -v rg >/dev/null; then - echo "Error: ripgrep (rg) is required by determinism guards but is not present on this runner." >&2 - exit 1 - fi - name: Ban globals shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 847ae51a..6bde50f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,9 +105,9 @@ ### Fixed -- `Determinism Guards` now verifies a local `rg` binary instead of running - `apt-get install ripgrep`, so mirror stalls fail fast instead of hanging the - static determinism gate. +- `Determinism Guards` no longer runs `apt-get install ripgrep`; static guard + scripts now fall back to `grep -P` 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/scripts/ban-globals.sh b/scripts/ban-globals.sh index e76bed81..33ae107a 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=( @@ -53,6 +47,7 @@ echo # Build rg args RG_ARGS=(--hidden --no-ignore --glob '!.git/*' --glob '!target/*' --glob '!**/node_modules/*') +GREP_ARGS=(-RInP --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules) # Apply allowlist as inverted matches (each line is a regex or fixed substring) # Allowlist format: @@ -60,6 +55,7 @@ RG_ARGS=(--hidden --no-ignore --glob '!.git/*' --glob '!target/*' --glob '!**/no # or: # ALLOW_RG_EXCLUDES=() +ALLOW_GREP_EXCLUDES=() if [[ -f "$ALLOWLIST" ]]; then # Read first column (pattern) per line, ignore comments while IFS= read -r line; do @@ -70,16 +66,33 @@ if [[ -f "$ALLOWLIST" ]]; then [[ -z "$pat" ]] && continue # Exclude lines matching allowlisted pattern ALLOW_RG_EXCLUDES+=(--glob "!$pat") + ALLOW_GREP_EXCLUDES+=(--exclude="$pat") done < "$ALLOWLIST" fi violations=0 +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 + + if ! printf 'x\n' | grep -P 'x' >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or grep -P is required." >&2 + return 2 + fi + + grep "${GREP_ARGS[@]}" "${ALLOW_GREP_EXCLUDES[@]}" "$pat" $PATHS +} + 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..da6947fe 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}" @@ -37,8 +32,17 @@ RG_ARGS=( --glob '!**/.clippy.toml' ) +GREP_ARGS=( + -RInP + --exclude-dir=.git + --exclude-dir=target + --exclude-dir=node_modules + --exclude=.clippy.toml +) + # You can allow file-level exceptions via allowlist (keep it tiny). ALLOW_GLOBS=() +ALLOW_GREP_EXCLUDES=() if [[ -f "$ALLOWLIST" ]]; then while IFS= read -r line; do [[ -z "$line" ]] && continue @@ -47,9 +51,26 @@ if [[ -f "$ALLOWLIST" ]]; then pat="${pat%% *}" [[ -z "$pat" ]] && continue ALLOW_GLOBS+=(--glob "!$pat") + ALLOW_GREP_EXCLUDES+=(--exclude="$pat") done < "$ALLOWLIST" fi +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 + + if ! printf 'x\n' | grep -P 'x' >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or grep -P is required." >&2 + return 2 + fi + + grep "${GREP_ARGS[@]}" "${ALLOW_GREP_EXCLUDES[@]}" "$pat" $PATHS +} + # Patterns: conservative and intentionally annoying. # If you hit a false positive, refactor; don't immediately allowlist. PATTERNS=( @@ -112,7 +133,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..28fe4b8f 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" @@ -32,6 +27,7 @@ RG_ARGS=( --glob '!**/target/**' --glob '!**/node_modules/**' ) +GREP_ARGS=(-RInP --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules) # You can allow file-level exceptions via allowlist (keep it tiny). ALLOW_PATTERNS=() @@ -50,7 +46,22 @@ 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) +if command -v rg >/dev/null 2>&1; then + mapfile -t files < <(rg "${RG_ARGS[@]}" -l -g'*.rs' "$pattern" crates/ || true) +else + if ! printf 'x\n' | grep -P 'x' >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or grep -P is required." >&2 + exit 2 + fi + mapfile -t files < <( + find crates -type f -name '*.rs' \ + -not -path '*/.git/*' \ + -not -path '*/target/*' \ + -not -path '*/node_modules/*' \ + -print | + grep -P "$pattern" || true + ) +fi shopt -s globstar filtered=() for f in "${files[@]}"; do @@ -76,8 +87,18 @@ 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 + search_result=0 + grep "${GREP_ARGS[@]}" '\b(HashMap|HashSet)\b' "${files[@]}" || search_result=$? +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 From 50e9379247b7221527ee928041491cce363ff7d6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 11:15:17 -0700 Subject: [PATCH 6/6] Fix: make determinism guards portable without ripgrep --- CHANGELOG.md | 2 +- scripts/ban-globals.sh | 72 ++++++++++++++++++++++++++----- scripts/ban-nondeterminism.sh | 79 ++++++++++++++++++++++++++++------- scripts/ban-unordered-abi.sh | 47 +++++++++++++++++---- 4 files changed, 165 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bde50f0..d3d7f6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,7 +106,7 @@ ### Fixed - `Determinism Guards` no longer runs `apt-get install ripgrep`; static guard - scripts now fall back to `grep -P` when `rg` is unavailable, so mirror stalls + 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 diff --git a/scripts/ban-globals.sh b/scripts/ban-globals.sh index 33ae107a..27c4aa73 100755 --- a/scripts/ban-globals.sh +++ b/scripts/ban-globals.sh @@ -45,9 +45,7 @@ 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/*') -GREP_ARGS=(-RInP --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules) # Apply allowlist as inverted matches (each line is a regex or fixed substring) # Allowlist format: @@ -55,7 +53,7 @@ GREP_ARGS=(-RInP --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modu # or: # ALLOW_RG_EXCLUDES=() -ALLOW_GREP_EXCLUDES=() +ALLOW_PATH_PATTERNS=() if [[ -f "$ALLOWLIST" ]]; then # Read first column (pattern) per line, ignore comments while IFS= read -r line; do @@ -66,12 +64,71 @@ if [[ -f "$ALLOWLIST" ]]; then [[ -z "$pat" ]] && continue # Exclude lines matching allowlisted pattern ALLOW_RG_EXCLUDES+=(--glob "!$pat") - ALLOW_GREP_EXCLUDES+=(--exclude="$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" @@ -80,12 +137,7 @@ search_pattern() { return $? fi - if ! printf 'x\n' | grep -P 'x' >/dev/null 2>&1; then - echo "ERROR: ripgrep (rg) or grep -P is required." >&2 - return 2 - fi - - grep "${GREP_ARGS[@]}" "${ALLOW_GREP_EXCLUDES[@]}" "$pat" $PATHS + search_pattern_with_perl "$pat" } for pat in "${PATTERNS[@]}"; do diff --git a/scripts/ban-nondeterminism.sh b/scripts/ban-nondeterminism.sh index da6947fe..a8b37dc6 100755 --- a/scripts/ban-nondeterminism.sh +++ b/scripts/ban-nondeterminism.sh @@ -32,17 +32,9 @@ RG_ARGS=( --glob '!**/.clippy.toml' ) -GREP_ARGS=( - -RInP - --exclude-dir=.git - --exclude-dir=target - --exclude-dir=node_modules - --exclude=.clippy.toml -) - # You can allow file-level exceptions via allowlist (keep it tiny). ALLOW_GLOBS=() -ALLOW_GREP_EXCLUDES=() +ALLOW_PATH_PATTERNS=() if [[ -f "$ALLOWLIST" ]]; then while IFS= read -r line; do [[ -z "$line" ]] && continue @@ -51,10 +43,70 @@ if [[ -f "$ALLOWLIST" ]]; then pat="${pat%% *}" [[ -z "$pat" ]] && continue ALLOW_GLOBS+=(--glob "!$pat") - ALLOW_GREP_EXCLUDES+=(--exclude="$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" @@ -63,12 +115,7 @@ search_pattern() { return $? fi - if ! printf 'x\n' | grep -P 'x' >/dev/null 2>&1; then - echo "ERROR: ripgrep (rg) or grep -P is required." >&2 - return 2 - fi - - grep "${GREP_ARGS[@]}" "${ALLOW_GREP_EXCLUDES[@]}" "$pat" $PATHS + search_pattern_with_perl "$pat" } # Patterns: conservative and intentionally annoying. diff --git a/scripts/ban-unordered-abi.sh b/scripts/ban-unordered-abi.sh index 28fe4b8f..9da0ab64 100755 --- a/scripts/ban-unordered-abi.sh +++ b/scripts/ban-unordered-abi.sh @@ -27,7 +27,6 @@ RG_ARGS=( --glob '!**/target/**' --glob '!**/node_modules/**' ) -GREP_ARGS=(-RInP --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules) # You can allow file-level exceptions via allowlist (keep it tiny). ALLOW_PATTERNS=() @@ -46,23 +45,36 @@ fi # Build pattern and trim trailing '|' to avoid matching everything pattern="$(printf '%s|' "${ABI_HINTS[@]}")" pattern="${pattern%|}" +files=() if command -v rg >/dev/null 2>&1; then - mapfile -t files < <(rg "${RG_ARGS[@]}" -l -g'*.rs' "$pattern" crates/ || true) + while IFS= read -r file; do + files+=("$file") + done < <(rg "${RG_ARGS[@]}" -l -g'*.rs' "$pattern" crates/ || true) else - if ! printf 'x\n' | grep -P 'x' >/dev/null 2>&1; then - echo "ERROR: ripgrep (rg) or grep -P is required." >&2 + if ! command -v perl >/dev/null 2>&1; then + echo "ERROR: ripgrep (rg) or perl is required." >&2 exit 2 fi - mapfile -t files < <( + 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 | - grep -P "$pattern" || true + 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 +shopt -s globstar 2>/dev/null || true filtered=() for f in "${files[@]}"; do allowed=false @@ -91,8 +103,27 @@ 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 - grep "${GREP_ARGS[@]}" '\b(HashMap|HashSet)\b' "${files[@]}" || search_result=$? + 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