From f1d5272165a864f284e2e788340c0df524ae2749 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 13 May 2026 06:27:19 -0700 Subject: [PATCH] feat(core): add grant intent obstruction receipt shape --- CHANGELOG.md | 5 + crates/warp-core/src/lib.rs | 3 +- crates/warp-core/src/optic_artifact.rs | 98 ++++++++++++- .../tests/capability_grant_intent_tests.rs | 134 ++++++++++++++++-- .../optic-capability-grant-intent-boundary.md | 57 ++++++-- 5 files changed, 276 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8180d63..4a673f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ invalid-delegation, scope-escalation, replay/duplicate, and unsupported-policy grant intents, and keeps all submissions from becoming authority until future witnessed grant admission exists. +- Capability grant intent obstruction now carries a + `CapabilityGrantIntentObstructionReceipt`. The receipt echoes refusal context, + stores deterministic length-prefixed receipt input bytes, and records a + BLAKE3 receipt digest without creating admitted authority, an admission + receipt, or a law witness. - Echo-owned WASM package boundary tooling: `scripts/build-warp-wasm-package.sh` now builds `crates/warp-wasm/pkg` with the bundler target and the package export smoke test imports `crates/warp-wasm/pkg/rmg_wasm.js` to verify the diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index d4ae77c4..19b0d099 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -249,7 +249,8 @@ pub use optic::{ }; pub use optic_artifact::{ AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantIntent, - CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, CapabilityGrantIntentOutcome, + CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, + CapabilityGrantIntentObstructionReceipt, CapabilityGrantIntentOutcome, CapabilityGrantIntentPosture, OpticAdmissionRequirements, OpticAdmissionTicketPosture, OpticApertureRequest, OpticArtifact, OpticArtifactHandle, OpticArtifactOperation, OpticArtifactRegistrationError, OpticArtifactRegistry, OpticBasisRequest, diff --git a/crates/warp-core/src/optic_artifact.rs b/crates/warp-core/src/optic_artifact.rs index 02693c51..422ff2d1 100644 --- a/crates/warp-core/src/optic_artifact.rs +++ b/crates/warp-core/src/optic_artifact.rs @@ -230,6 +230,33 @@ pub enum CapabilityGrantIntentObstruction { UnsupportedAuthorityPolicy, } +/// Echo-owned refusal receipt for an obstructed capability grant intent. +/// +/// This is not an admission receipt, not a law witness, and not accepted +/// authority. It makes refusal durable by carrying deterministic input bytes +/// and a deterministic v0 receipt digest for the obstructed grant intent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapabilityGrantIntentObstructionReceipt { + /// Stable discriminator for callers and wire adapters. + pub kind: String, + /// Intent id named by the grant intent. + pub intent_id: String, + /// Principal proposing the authority change. + pub proposed_by: PrincipalRef, + /// Subject that would receive authority if the intent were admitted. + pub subject: PrincipalRef, + /// Structured reason Echo obstructed before admitting the grant. + pub obstruction: CapabilityGrantIntentObstruction, + /// Authority policy id supplied with the obstruction context, if any. + pub policy_id: Option, + /// Obstruction-only policy evaluation posture. + pub policy_posture: String, + /// Deterministic bytes used to derive the receipt digest. + pub receipt_input_bytes: Vec, + /// Deterministic v0 receipt digest bytes. + pub receipt_digest: Vec, +} + /// Obstructed posture for a submitted capability grant intent. /// /// This is not an admitted grant receipt and does not make the grant authority. @@ -248,6 +275,8 @@ pub struct CapabilityGrantIntentPosture { pub subject: PrincipalRef, /// Structured reason Echo obstructed before admitting the grant. pub obstruction: CapabilityGrantIntentObstruction, + /// Receipt-shaped durable refusal context. + pub receipt: CapabilityGrantIntentObstructionReceipt, } /// Submission outcome for a capability grant intent skeleton. @@ -383,7 +412,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,16 +492,83 @@ impl CapabilityGrantIntentGate { fn obstructed_grant_intent( intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, obstruction: CapabilityGrantIntentObstruction, ) -> CapabilityGrantIntentOutcome { + let receipt = Self::obstruction_receipt(intent, authority_context, obstruction); CapabilityGrantIntentOutcome::Obstructed(CapabilityGrantIntentPosture { kind: "capability-grant-intent-posture".to_owned(), intent_id: intent.intent_id.clone(), proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, + receipt, }) } + + fn obstruction_receipt( + intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, + obstruction: CapabilityGrantIntentObstruction, + ) -> CapabilityGrantIntentObstructionReceipt { + let policy_id = authority_context + .policy + .as_ref() + .map(|policy| policy.policy_id.clone()); + let policy_posture = format!("{:?}", authority_context.policy_evaluation); + let receipt_input_bytes = Self::obstruction_receipt_input_bytes( + intent, + obstruction, + policy_id.as_deref(), + &policy_posture, + ); + let receipt_digest = Self::obstruction_receipt_digest(&receipt_input_bytes); + + CapabilityGrantIntentObstructionReceipt { + kind: "capability-grant-intent-obstruction-receipt".to_owned(), + intent_id: intent.intent_id.clone(), + proposed_by: intent.proposed_by.clone(), + subject: intent.subject.clone(), + obstruction, + policy_id, + policy_posture, + receipt_input_bytes, + receipt_digest, + } + } + + fn obstruction_receipt_input_bytes( + intent: &CapabilityGrantIntent, + obstruction: CapabilityGrantIntentObstruction, + policy_id: Option<&str>, + policy_posture: &str, + ) -> Vec { + let obstruction = format!("{obstruction:?}"); + let mut input = Vec::new(); + Self::append_receipt_field( + &mut input, + "domain", + b"capability-grant-intent-obstruction-receipt/v0", + ); + Self::append_receipt_field(&mut input, "intent_id", intent.intent_id.as_bytes()); + Self::append_receipt_field(&mut input, "proposed_by", intent.proposed_by.id.as_bytes()); + Self::append_receipt_field(&mut input, "subject", intent.subject.id.as_bytes()); + Self::append_receipt_field(&mut input, "obstruction", obstruction.as_bytes()); + Self::append_receipt_field(&mut input, "policy_id", policy_id.unwrap_or("").as_bytes()); + Self::append_receipt_field(&mut input, "policy_posture", policy_posture.as_bytes()); + input + } + + fn obstruction_receipt_digest(receipt_input_bytes: &[u8]) -> Vec { + blake3::hash(receipt_input_bytes).as_bytes().to_vec() + } + + fn append_receipt_field(input: &mut Vec, field_name: &str, value: &[u8]) { + input.extend_from_slice(field_name.as_bytes()); + input.push(0); + input.extend_from_slice(&(value.len() as u64).to_be_bytes()); + input.extend_from_slice(value); + } } /// Echo-owned runtime-local registry for Wesley-compiled optic artifacts. diff --git a/crates/warp-core/tests/capability_grant_intent_tests.rs b/crates/warp-core/tests/capability_grant_intent_tests.rs index cbc77453..95ccbaf0 100644 --- a/crates/warp-core/tests/capability_grant_intent_tests.rs +++ b/crates/warp-core/tests/capability_grant_intent_tests.rs @@ -4,7 +4,8 @@ use warp_core::{ AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantIntent, - CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, CapabilityGrantIntentOutcome, + CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, + CapabilityGrantIntentObstructionReceipt, CapabilityGrantIntentOutcome, CapabilityGrantIntentPosture, 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,27 +49,124 @@ fn expected_obstructed_posture( proposed_by: intent.proposed_by.clone(), subject: intent.subject.clone(), obstruction, + receipt: expected_obstruction_receipt(intent, authority_context, obstruction), }) } +fn expected_obstruction_receipt( + intent: &CapabilityGrantIntent, + authority_context: &AuthorityContext, + obstruction: CapabilityGrantIntentObstruction, +) -> CapabilityGrantIntentObstructionReceipt { + let policy_id = authority_context + .policy + .as_ref() + .map(|policy| policy.policy_id.clone()); + let policy_posture = format!("{:?}", authority_context.policy_evaluation); + let receipt_input_bytes = + expected_receipt_input_bytes(intent, obstruction, policy_id.as_deref(), &policy_posture); + let receipt_digest = blake3::hash(&receipt_input_bytes).as_bytes().to_vec(); + + CapabilityGrantIntentObstructionReceipt { + kind: "capability-grant-intent-obstruction-receipt".to_owned(), + intent_id: intent.intent_id.clone(), + proposed_by: intent.proposed_by.clone(), + subject: intent.subject.clone(), + obstruction, + policy_id, + policy_posture, + receipt_input_bytes, + receipt_digest, + } +} + +fn expected_receipt_input_bytes( + intent: &CapabilityGrantIntent, + obstruction: CapabilityGrantIntentObstruction, + policy_id: Option<&str>, + policy_posture: &str, +) -> Vec { + let obstruction = format!("{obstruction:?}"); + let mut input = Vec::new(); + append_expected_receipt_field( + &mut input, + "domain", + b"capability-grant-intent-obstruction-receipt/v0", + ); + append_expected_receipt_field(&mut input, "intent_id", intent.intent_id.as_bytes()); + append_expected_receipt_field(&mut input, "proposed_by", intent.proposed_by.id.as_bytes()); + append_expected_receipt_field(&mut input, "subject", intent.subject.id.as_bytes()); + append_expected_receipt_field(&mut input, "obstruction", obstruction.as_bytes()); + append_expected_receipt_field(&mut input, "policy_id", policy_id.unwrap_or("").as_bytes()); + append_expected_receipt_field(&mut input, "policy_posture", policy_posture.as_bytes()); + input +} + +fn append_expected_receipt_field(input: &mut Vec, field_name: &str, value: &[u8]) { + input.extend_from_slice(field_name.as_bytes()); + input.push(0); + input.extend_from_slice(&(value.len() as u64).to_be_bytes()); + input.extend_from_slice(value); +} + fn obstruction_for(outcome: &CapabilityGrantIntentOutcome) -> CapabilityGrantIntentObstruction { match outcome { CapabilityGrantIntentOutcome::Obstructed(posture) => posture.obstruction, } } +#[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.clone()); + + assert_eq!( + outcome, + CapabilityGrantIntentOutcome::Obstructed(CapabilityGrantIntentPosture { + kind: "capability-grant-intent-posture".to_owned(), + intent_id: intent.intent_id.clone(), + proposed_by: intent.proposed_by.clone(), + subject: intent.subject.clone(), + obstruction: CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy, + receipt: expected_obstruction_receipt( + &intent, + &authority_context, + CapabilityGrantIntentObstruction::UnsupportedAuthorityPolicy, + ), + }) + ); +} + +#[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_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 +197,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 +229,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 +247,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 +271,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 +295,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 +319,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,12 +336,14 @@ 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 ) ); diff --git a/docs/design/optic-capability-grant-intent-boundary.md b/docs/design/optic-capability-grant-intent-boundary.md index 26369757..27e91f0b 100644 --- a/docs/design/optic-capability-grant-intent-boundary.md +++ b/docs/design/optic-capability-grant-intent-boundary.md @@ -17,6 +17,9 @@ prior authority, host root policy, quorum, or governance rule. This slice only adds the shape and obstruction boundary. It does not implement a real authority policy and therefore every grant intent remains obstructed. +Refusal is a first-class causal event. An obstruction receipt makes "no" +durable without making "yes" real. + The ladder is: - registered handle is not authority; @@ -33,8 +36,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 a + `CapabilityGrantIntentObstructionReceipt` 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. @@ -51,6 +54,7 @@ flowchart LR Context[AuthorityContext] Policy[AuthorityPolicy] Gate[CapabilityGrantIntentGate] + Receipt[CapabilityGrantIntentObstructionReceipt] Invocation[OpticInvocation] Admission[Invocation admission] FutureGrant[Future admitted grant] @@ -65,7 +69,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 @@ -91,25 +96,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 @@ -197,6 +202,19 @@ classDiagram +proposed_by +subject +obstruction + +receipt + } + + class CapabilityGrantIntentObstructionReceipt { + +kind + +intent_id + +proposed_by + +subject + +obstruction + +policy_id + +policy_posture + +receipt_input_bytes + +receipt_digest } class CapabilityGrantIntentObstruction { @@ -224,6 +242,7 @@ classDiagram CapabilityGrantIntentGate --> CapabilityGrantIntentOutcome : returns CapabilityGrantIntentOutcome --> CapabilityGrantIntentPosture : carries CapabilityGrantIntentPosture --> CapabilityGrantIntentObstruction : explains + CapabilityGrantIntentPosture --> CapabilityGrantIntentObstructionReceipt : receipts ``` ## Entity relationship @@ -236,6 +255,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 ||--|| GRANT_INTENT_OBSTRUCTION_RECEIPT : carries CAPABILITY_PRESENTATION }o--o| CAPABILITY_GRANT_INTENT : claims OPTIC_INVOCATION }o--o| CAPABILITY_PRESENTATION : carries @@ -289,6 +309,18 @@ erDiagram string kind string obstruction } + + GRANT_INTENT_OBSTRUCTION_RECEIPT { + string kind + string intent_id + string proposed_by + string subject + string obstruction + string policy_id + string policy_posture + bytes receipt_input_bytes + bytes receipt_digest + } ``` ## Current grant intent shape @@ -311,18 +343,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. +`CapabilityGrantIntentObstructionReceipt` 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`; +- defines `CapabilityGrantIntentObstructionReceipt`; - 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. @@ -332,6 +370,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;