diff --git a/CHANGELOG.md b/CHANGELOG.md index 802d6b68..7380c3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ ### Added +- `warp-core` now publishes `GraphFact::CapabilityGrantValidationObstructed` + when recorded capability grant material fails narrow identity coverage against + a registered optic artifact. The validation checks artifact hash, operation + id, requirements digest, and explicit expiry posture, and remains + refusal-first: it does not issue successful admission tickets, law witnesses, + scheduler work, execution, delegation policy, quorum governance, or Continuum + protocol. +- `docs/design/capability-grant-validation-obstruction-facts.md` defines grant + validation obstruction as graph evidence rather than authority. A recorded + grant can fail causally before any invocation can succeed. - `warp-core` now publishes `GraphFact::OpticInvocationObstructed` whenever the optic invocation admission skeleton refuses an invocation. The fact records the artifact handle id, operation id, canonical variables digest, basis and @@ -74,9 +84,9 @@ registered artifact handles internally and obstructs unknown handles, operation mismatches, and registered-handle invocations without capability presentation. Admission outcomes are must-use, and placeholder capability - presentations still obstruct until real grant validation exists. The - registration and invocation regression fixtures avoid `expect(...)` so - all-target Clippy remains clean. + presentations still obstruct until grant validation is wired into invocation + admission. The registration and invocation regression fixtures avoid + `expect(...)` so all-target Clippy remains clean. - Optic invocation obstruction now returns a ticket-shaped pre-admission posture carrying the invocation handle, operation id, canonical variables digest, basis request, aperture request, and structured obstruction reason. diff --git a/backlog/bad-code/RE-031-capability-grant-validation-admission-integration.md b/backlog/bad-code/RE-031-capability-grant-validation-admission-integration.md new file mode 100644 index 00000000..36dbd52d --- /dev/null +++ b/backlog/bad-code/RE-031-capability-grant-validation-admission-integration.md @@ -0,0 +1,36 @@ + + + +# RE-031 Capability Grant Validation Admission Integration + +Lane: bad-code. +Status: follow-up. + +## Problem + +`CapabilityGrantIntentGate` can now publish obstruction facts when recorded +grant material fails narrow identity coverage against a registered optic +artifact. That is correct refusal-first substrate work, but it is not a full +authority model. + +The next authority slice must not let identity coverage drift into accepted +authority by implication. + +## Required follow-up + +- Define accepted grant material separately from submitted grant intent + material. +- Keep `CapabilityGrantValidationObstructed` as refusal evidence, not an + admission receipt. +- Add a real admission boundary before any successful `AdmissionTicket` exists. +- Add deterministic expiry semantics instead of opaque caller-supplied expiry + posture. +- Decide where successful grant validation, admission tickets, and law witness + bundles live. + +## Non-goals + +- no Continuum schema freeze; +- no quorum model; +- no scheduler integration; +- no execution path. diff --git a/crates/warp-core/src/causal_facts.rs b/crates/warp-core/src/causal_facts.rs index 5f3ce2ad..0dbfcfa8 100644 --- a/crates/warp-core/src/causal_facts.rs +++ b/crates/warp-core/src/causal_facts.rs @@ -49,10 +49,33 @@ pub enum InvocationObstructionKind { MalformedCapabilityPresentation, /// Invocation supplied a presentation not bound to a grant id. UnboundCapabilityPresentation, - /// Invocation supplied a placeholder presentation before grant validation exists. + /// Invocation supplied a placeholder presentation before grant validation + /// is wired into invocation admission. CapabilityValidationUnavailable, } +/// Obstruction kind recorded when capability grant validation fails before any +/// successful admission ticket, law witness, scheduler selection, or execution +/// boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapabilityGrantValidationObstructionKind { + /// Presentation supplied unusable shape for grant validation. + MalformedCapabilityPresentation, + /// Presentation did not bind to a grant id. + UnboundCapabilityPresentation, + /// Presentation named grant material Echo has not recorded. + UnknownGrant, + /// Grant artifact hash did not cover the registered artifact. + ArtifactHashMismatch, + /// Grant operation id did not cover the registered artifact operation. + OperationIdMismatch, + /// Grant requirements digest did not cover the registered artifact + /// requirements. + RequirementsDigestMismatch, + /// Grant expiry posture obstructed validation. + ExpiredGrant, +} + impl InvocationObstructionKind { fn digest_label(self) -> &'static [u8] { match self { @@ -66,6 +89,20 @@ impl InvocationObstructionKind { } } +impl CapabilityGrantValidationObstructionKind { + fn digest_label(self) -> &'static [u8] { + match self { + Self::MalformedCapabilityPresentation => b"malformed-capability-presentation", + Self::UnboundCapabilityPresentation => b"unbound-capability-presentation", + Self::UnknownGrant => b"unknown-grant", + Self::ArtifactHashMismatch => b"artifact-hash-mismatch", + Self::OperationIdMismatch => b"operation-id-mismatch", + Self::RequirementsDigestMismatch => b"requirements-digest-mismatch", + Self::ExpiredGrant => b"expired-grant", + } + } +} + impl ArtifactRegistrationObstructionKind { fn digest_label(self) -> &'static [u8] { match self { @@ -117,6 +154,30 @@ pub enum GraphFact { /// Structured invocation obstruction kind. obstruction: InvocationObstructionKind, }, + /// Echo refused capability grant validation before treating grant material + /// as authority. + CapabilityGrantValidationObstructed { + /// Presentation identity supplied by the caller. + presentation_id: String, + /// Grant id named by the presentation, when structurally available. + grant_id: Option, + /// Echo-owned runtime-local artifact handle id being covered. + artifact_handle_id: String, + /// Registered artifact hash Echo expected the grant to cover. + expected_artifact_hash: String, + /// Artifact hash named by the grant material, when available. + grant_artifact_hash: Option, + /// Registered operation id Echo expected the grant to cover. + expected_operation_id: String, + /// Operation id named by the grant material, when available. + grant_operation_id: Option, + /// Registered requirements digest Echo expected the grant to cover. + expected_requirements_digest: String, + /// Requirements digest named by the grant material, when available. + grant_requirements_digest: Option, + /// Structured capability grant validation obstruction kind. + obstruction: CapabilityGrantValidationObstructionKind, + }, } impl GraphFact { @@ -185,6 +246,66 @@ impl GraphFact { ); push_digest_field(&mut bytes, b"obstruction", obstruction.digest_label()); } + Self::CapabilityGrantValidationObstructed { + presentation_id, + grant_id, + artifact_handle_id, + expected_artifact_hash, + grant_artifact_hash, + expected_operation_id, + grant_operation_id, + expected_requirements_digest, + grant_requirements_digest, + obstruction, + } => { + push_digest_field( + &mut bytes, + b"variant", + b"capability-grant-validation-obstructed", + ); + push_digest_field(&mut bytes, b"presentation-id", presentation_id.as_bytes()); + push_optional_digest_field( + &mut bytes, + b"grant-id", + grant_id.as_deref().map(str::as_bytes), + ); + push_digest_field( + &mut bytes, + b"artifact-handle-id", + artifact_handle_id.as_bytes(), + ); + push_digest_field( + &mut bytes, + b"expected-artifact-hash", + expected_artifact_hash.as_bytes(), + ); + push_optional_digest_field( + &mut bytes, + b"grant-artifact-hash", + grant_artifact_hash.as_deref().map(str::as_bytes), + ); + push_digest_field( + &mut bytes, + b"expected-operation-id", + expected_operation_id.as_bytes(), + ); + push_optional_digest_field( + &mut bytes, + b"grant-operation-id", + grant_operation_id.as_deref().map(str::as_bytes), + ); + push_digest_field( + &mut bytes, + b"expected-requirements-digest", + expected_requirements_digest.as_bytes(), + ); + push_optional_digest_field( + &mut bytes, + b"grant-requirements-digest", + grant_requirements_digest.as_deref().map(str::as_bytes), + ); + push_digest_field(&mut bytes, b"obstruction", obstruction.digest_label()); + } } FactDigest(*blake3::hash(&bytes).as_bytes()) diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index e28c1c16..dcbdd9ed 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -168,8 +168,8 @@ pub use attachment::{ }; pub use causal_facts::{ digest_invocation_request_bytes, ArtifactRegistrationObstructionKind, - ArtifactRegistrationReceipt, FactDigest, GraphFact, InvocationObstructionKind, - PublishedGraphFact, ARTIFACT_REGISTRATION_RECEIPT_KIND, + ArtifactRegistrationReceipt, CapabilityGrantValidationObstructionKind, FactDigest, GraphFact, + InvocationObstructionKind, PublishedGraphFact, ARTIFACT_REGISTRATION_RECEIPT_KIND, }; pub use clock::{GlobalTick, RunId, WorldlineTick}; pub use cmd::{ @@ -254,14 +254,17 @@ pub use optic::{ StagedIntent, StagedIntentReason, WitnessBasis, WorldlineHeadOptic, }; pub use optic_artifact::{ - AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantIntent, - CapabilityGrantIntentGate, CapabilityGrantIntentObstruction, CapabilityGrantIntentOutcome, - CapabilityGrantIntentPosture, ObstructionReceipt, OpticAdmissionRequirements, + AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantExpiryPosture, + CapabilityGrantIdentityCoverage, CapabilityGrantIntent, CapabilityGrantIntentGate, + CapabilityGrantIntentObstruction, CapabilityGrantIntentOutcome, CapabilityGrantIntentPosture, + CapabilityGrantValidationObstruction, CapabilityGrantValidationOutcome, + CapabilityGrantValidationPosture, ObstructionReceipt, OpticAdmissionRequirements, OpticAdmissionTicketPosture, OpticApertureRequest, OpticArtifact, OpticArtifactHandle, OpticArtifactOperation, OpticArtifactRegistrationError, OpticArtifactRegistry, OpticBasisRequest, OpticCapabilityPresentation, OpticInvocation, OpticInvocationAdmissionOutcome, OpticInvocationObstruction, OpticRegistrationDescriptor, - PrincipalRef, RegisteredOpticArtifact, RewriteDisposition, OBSTRUCTION_RECEIPT_KIND, + PrincipalRef, RegisteredOpticArtifact, RewriteDisposition, + CAPABILITY_GRANT_VALIDATION_POSTURE_KIND, 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 9a9793d7..f563b1d8 100644 --- a/crates/warp-core/src/optic_artifact.rs +++ b/crates/warp-core/src/optic_artifact.rs @@ -5,17 +5,18 @@ //! This module owns optic artifact registration and the first admission-only //! invocation gate. [`OpticArtifactRegistry::admit_optic_invocation`] resolves //! handles internally, checks operation identity, and reports obstruction in a -//! ticket-shaped pre-admission posture without validating grants, issuing -//! success tickets, emitting law witnesses, or executing runtime work. A -//! capability presentation slot is not authority; every presentation posture -//! obstructs until Echo can validate a real bounded grant. +//! ticket-shaped pre-admission posture without wiring grant validation into +//! invocation admission, issuing success tickets, emitting law witnesses, or +//! executing runtime work. A capability presentation slot is not authority; +//! every presentation posture obstructs until Echo can validate a real bounded +//! grant and admit authority. use std::collections::BTreeMap; use crate::{ digest_invocation_request_bytes, ArtifactRegistrationObstructionKind, - ArtifactRegistrationReceipt, GraphFact, InvocationObstructionKind, PublishedGraphFact, - ARTIFACT_REGISTRATION_RECEIPT_KIND, + ArtifactRegistrationReceipt, CapabilityGrantValidationObstructionKind, GraphFact, + InvocationObstructionKind, PublishedGraphFact, ARTIFACT_REGISTRATION_RECEIPT_KIND, }; use thiserror::Error; @@ -28,6 +29,9 @@ pub const OPTIC_ADMISSION_TICKET_POSTURE_KIND: &str = "optic-admission-ticket-po /// Echo-owned kind for a causal refusal receipt. pub const OBSTRUCTION_RECEIPT_KIND: &str = "obstruction-receipt"; +/// Echo-owned kind for capability grant validation obstruction posture. +pub const CAPABILITY_GRANT_VALIDATION_POSTURE_KIND: &str = "capability-grant-validation-posture"; + const OPTIC_ARTIFACT_HANDLE_ID_PREFIX: &str = "optic-artifact-handle:"; /// Opaque Echo-owned runtime handle for a registered optic artifact. @@ -138,16 +142,17 @@ pub struct OpticApertureRequest { /// /// This v0 shape is intentionally not sufficient to authorize invocation. It /// exists only so the admission skeleton can classify presentation posture -/// without inventing grant validation semantics. +/// without treating a presentation as authority. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpticCapabilityPresentation { /// Presentation identity supplied by the caller. pub presentation_id: String, /// Grant id the presentation claims to bind to. /// - /// Echo does not validate this grant in this slice. A non-empty value only - /// moves the presentation from unbound to validation-unavailable - /// obstruction; it never authorizes invocation. + /// [`OpticArtifactRegistry::admit_optic_invocation`] does not validate this + /// grant. A non-empty value only moves the presentation from unbound to + /// validation-unavailable obstruction inside invocation admission; it never + /// authorizes invocation. pub bound_grant_id: Option, } @@ -438,6 +443,112 @@ pub enum CapabilityGrantIntentOutcome { Obstructed(CapabilityGrantIntentPosture), } +/// Caller-supplied expiry posture for narrow grant validation. +/// +/// Echo does not parse [`CapabilityGrantIntent::expiry_bytes`] in this slice. +/// The posture is explicit validation input so tests and future adapters can +/// prove that an already-known expired grant obstructs without adding clock +/// policy, admission tickets, witnesses, or execution. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapabilityGrantExpiryPosture { + /// Expiry was not evaluated for this validation attempt. + NotEvaluated, + /// Expiry was evaluated and did not obstruct this validation attempt. + Current, + /// Expiry was evaluated and obstructed this validation attempt. + Expired, +} + +/// Obstruction reason for capability grant validation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapabilityGrantValidationObstruction { + /// Presentation supplied unusable shape for grant validation. + MalformedCapabilityPresentation, + /// Presentation did not bind to a grant id. + UnboundCapabilityPresentation, + /// Presentation named grant material Echo has not recorded. + UnknownGrant, + /// Grant artifact hash did not cover the registered artifact. + ArtifactHashMismatch, + /// Grant operation id did not cover the registered artifact operation. + OperationIdMismatch, + /// Grant requirements digest did not cover the registered artifact + /// requirements. + RequirementsDigestMismatch, + /// Grant expiry posture obstructed validation. + ExpiredGrant, +} + +/// Identity coverage returned when narrow grant validation finds no identity +/// mismatch. +/// +/// This is not an authority grant, not an admission ticket, not a witness, and +/// not permission to execute. It only says the recorded grant material names the +/// same artifact hash, operation id, and requirements digest as the registered +/// artifact for this validation attempt. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapabilityGrantIdentityCoverage { + /// Stable discriminator for callers and wire adapters. + pub kind: String, + /// Presentation identity supplied by the caller. + pub presentation_id: String, + /// Grant id named by the presentation. + pub grant_id: String, + /// Echo-owned runtime-local artifact handle id being covered. + pub artifact_handle_id: String, + /// Registered artifact hash covered by the grant material. + pub artifact_hash: String, + /// Registered operation id covered by the grant material. + pub operation_id: String, + /// Registered requirements digest covered by the grant material. + pub requirements_digest: String, +} + +/// Obstructed posture for capability grant validation. +/// +/// Grant validation obstruction is graph evidence, not authority. This posture +/// reports why recorded grant material did not cover a registered artifact +/// identity; it never admits invocation or issues a success ticket. +#[must_use = "capability grant validation postures explain obstructions that must be handled"] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapabilityGrantValidationPosture { + /// Stable discriminator for callers and wire adapters. + pub kind: String, + /// Presentation identity supplied by the caller. + pub presentation_id: String, + /// Grant id named by the presentation, when structurally available. + pub grant_id: Option, + /// Echo-owned runtime-local artifact handle id being covered. + pub artifact_handle_id: String, + /// Registered artifact hash Echo expected the grant to cover. + pub expected_artifact_hash: String, + /// Artifact hash named by the grant material, when available. + pub grant_artifact_hash: Option, + /// Registered operation id Echo expected the grant to cover. + pub expected_operation_id: String, + /// Operation id named by the grant material, when available. + pub grant_operation_id: Option, + /// Registered requirements digest Echo expected the grant to cover. + pub expected_requirements_digest: String, + /// Requirements digest named by the grant material, when available. + pub grant_requirements_digest: Option, + /// Structured reason Echo obstructed before treating grant material as + /// authority. + pub obstruction: CapabilityGrantValidationObstruction, +} + +/// Outcome for narrow capability grant validation. +#[must_use = "capability grant validation outcomes carry obstructions that must be handled"] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CapabilityGrantValidationOutcome { + /// Recorded grant material covered the registered artifact identity only. + /// + /// This is not successful invocation admission. + IdentityCovered(CapabilityGrantIdentityCoverage), + /// Echo obstructed grant validation before authority could be considered. + Obstructed(CapabilityGrantValidationPosture), +} + /// Runtime invocation request against a registered optic artifact. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpticInvocation { @@ -468,8 +579,8 @@ pub enum OpticInvocationObstruction { MalformedCapabilityPresentation, /// The invocation carries a presentation that is not bound to any grant. UnboundCapabilityPresentation, - /// A placeholder presentation was supplied, but real grant validation does - /// not exist in this slice. + /// A placeholder presentation was supplied, but grant validation is not + /// wired into invocation admission in this slice. CapabilityValidationUnavailable, } @@ -477,7 +588,8 @@ pub enum OpticInvocationObstruction { /// /// This is not a successful admission ticket and does not authorize runtime /// execution. It carries enough invocation context for callers and later -/// witness code to explain why Echo obstructed before grant validation exists. +/// witness code to explain why Echo obstructed before grant validation was +/// wired into invocation admission. #[must_use = "optic admission ticket postures explain obstructions that must be handled"] #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpticAdmissionTicketPosture { @@ -531,12 +643,14 @@ pub enum OpticArtifactRegistrationError { /// Echo-owned deterministic intake for capability grant intents. /// /// This registry records submitted grant intents so duplicate intent ids can be -/// obstructed deterministically. It does not validate grant applicability, -/// admit grants into witnessed history, issue admission tickets, emit law -/// witnesses, or execute runtime work. +/// obstructed deterministically. It can validate narrow identity coverage +/// against registered artifact material, but it does not admit grants into +/// witnessed history, issue admission tickets, emit law witnesses, or execute +/// runtime work. #[derive(Clone, Debug, Default)] pub struct CapabilityGrantIntentGate { intents_by_id: BTreeMap, + published_graph_facts: Vec, } impl CapabilityGrantIntentGate { @@ -578,6 +692,102 @@ impl CapabilityGrantIntentGate { self.intents_by_id.is_empty() } + /// Validates recorded grant material against a registered artifact identity. + /// + /// This is a refusal-first substrate check. It only compares the bound grant + /// material's artifact hash, operation id, requirements digest, and explicit + /// expiry posture against Echo's registered artifact material. It does not + /// admit authority, issue an admission ticket, emit a law witness, validate + /// delegation, check quorum, or execute runtime work. + #[must_use = "capability grant validation outcomes carry obstructions that must be handled"] + pub fn validate_capability_presentation_for_artifact( + &mut self, + presentation: &OpticCapabilityPresentation, + registered: &RegisteredOpticArtifact, + expiry_posture: CapabilityGrantExpiryPosture, + ) -> CapabilityGrantValidationOutcome { + if presentation.presentation_id.is_empty() + || presentation + .bound_grant_id + .as_ref() + .is_some_and(String::is_empty) + { + return self.obstructed_capability_grant_validation( + presentation, + registered, + None, + CapabilityGrantValidationObstruction::MalformedCapabilityPresentation, + ); + } + + let Some(grant_id) = presentation.bound_grant_id.as_deref() else { + return self.obstructed_capability_grant_validation( + presentation, + registered, + None, + CapabilityGrantValidationObstruction::UnboundCapabilityPresentation, + ); + }; + + let Some(grant) = self.intents_by_id.get(grant_id).cloned() else { + return self.obstructed_capability_grant_validation( + presentation, + registered, + None, + CapabilityGrantValidationObstruction::UnknownGrant, + ); + }; + + if grant.artifact_hash != registered.artifact_hash { + return self.obstructed_capability_grant_validation( + presentation, + registered, + Some(&grant), + CapabilityGrantValidationObstruction::ArtifactHashMismatch, + ); + } + if grant.operation_id != registered.operation_id { + return self.obstructed_capability_grant_validation( + presentation, + registered, + Some(&grant), + CapabilityGrantValidationObstruction::OperationIdMismatch, + ); + } + if grant.requirements_digest != registered.requirements_digest { + return self.obstructed_capability_grant_validation( + presentation, + registered, + Some(&grant), + CapabilityGrantValidationObstruction::RequirementsDigestMismatch, + ); + } + if grant.expiry_bytes.is_some() && expiry_posture == CapabilityGrantExpiryPosture::Expired { + return self.obstructed_capability_grant_validation( + presentation, + registered, + Some(&grant), + CapabilityGrantValidationObstruction::ExpiredGrant, + ); + } + + CapabilityGrantValidationOutcome::IdentityCovered(CapabilityGrantIdentityCoverage { + kind: "capability-grant-identity-coverage".to_owned(), + presentation_id: presentation.presentation_id.clone(), + grant_id: grant.intent_id, + artifact_handle_id: registered.handle.id.clone(), + artifact_hash: registered.artifact_hash.clone(), + operation_id: registered.operation_id.clone(), + requirements_digest: registered.requirements_digest.clone(), + }) + } + + /// Returns in-memory graph facts published by this gate instance. + #[must_use] + pub fn published_graph_facts(&self) -> &[PublishedGraphFact] { + &self.published_graph_facts + } + fn classify_capability_grant_intent( &self, intent: &CapabilityGrantIntent, @@ -659,6 +869,71 @@ impl CapabilityGrantIntentGate { ), }) } + + fn obstructed_capability_grant_validation( + &mut self, + presentation: &OpticCapabilityPresentation, + registered: &RegisteredOpticArtifact, + grant: Option<&CapabilityGrantIntent>, + obstruction: CapabilityGrantValidationObstruction, + ) -> CapabilityGrantValidationOutcome { + self.publish_capability_grant_validation_obstruction( + presentation, + registered, + grant, + obstruction, + ); + + CapabilityGrantValidationOutcome::Obstructed(CapabilityGrantValidationPosture { + kind: CAPABILITY_GRANT_VALIDATION_POSTURE_KIND.to_owned(), + presentation_id: presentation.presentation_id.clone(), + grant_id: Self::validation_grant_id(presentation, grant), + artifact_handle_id: registered.handle.id.clone(), + expected_artifact_hash: registered.artifact_hash.clone(), + grant_artifact_hash: grant.map(|grant| grant.artifact_hash.clone()), + expected_operation_id: registered.operation_id.clone(), + grant_operation_id: grant.map(|grant| grant.operation_id.clone()), + expected_requirements_digest: registered.requirements_digest.clone(), + grant_requirements_digest: grant.map(|grant| grant.requirements_digest.clone()), + obstruction, + }) + } + + fn publish_capability_grant_validation_obstruction( + &mut self, + presentation: &OpticCapabilityPresentation, + registered: &RegisteredOpticArtifact, + grant: Option<&CapabilityGrantIntent>, + obstruction: CapabilityGrantValidationObstruction, + ) { + self.published_graph_facts.push(PublishedGraphFact::new( + GraphFact::CapabilityGrantValidationObstructed { + presentation_id: presentation.presentation_id.clone(), + grant_id: Self::validation_grant_id(presentation, grant), + artifact_handle_id: registered.handle.id.clone(), + expected_artifact_hash: registered.artifact_hash.clone(), + grant_artifact_hash: grant.map(|grant| grant.artifact_hash.clone()), + expected_operation_id: registered.operation_id.clone(), + grant_operation_id: grant.map(|grant| grant.operation_id.clone()), + expected_requirements_digest: registered.requirements_digest.clone(), + grant_requirements_digest: grant.map(|grant| grant.requirements_digest.clone()), + obstruction: capability_grant_validation_obstruction_kind(obstruction), + }, + )); + } + + fn validation_grant_id( + presentation: &OpticCapabilityPresentation, + grant: Option<&CapabilityGrantIntent>, + ) -> Option { + grant.map(|grant| grant.intent_id.clone()).or_else(|| { + presentation + .bound_grant_id + .as_ref() + .filter(|grant_id| !grant_id.is_empty()) + .cloned() + }) + } } fn push_receipt_field(bytes: &mut Vec, field: &[u8]) { @@ -987,3 +1262,31 @@ fn invocation_obstruction_kind( } } } + +fn capability_grant_validation_obstruction_kind( + obstruction: CapabilityGrantValidationObstruction, +) -> CapabilityGrantValidationObstructionKind { + match obstruction { + CapabilityGrantValidationObstruction::MalformedCapabilityPresentation => { + CapabilityGrantValidationObstructionKind::MalformedCapabilityPresentation + } + CapabilityGrantValidationObstruction::UnboundCapabilityPresentation => { + CapabilityGrantValidationObstructionKind::UnboundCapabilityPresentation + } + CapabilityGrantValidationObstruction::UnknownGrant => { + CapabilityGrantValidationObstructionKind::UnknownGrant + } + CapabilityGrantValidationObstruction::ArtifactHashMismatch => { + CapabilityGrantValidationObstructionKind::ArtifactHashMismatch + } + CapabilityGrantValidationObstruction::OperationIdMismatch => { + CapabilityGrantValidationObstructionKind::OperationIdMismatch + } + CapabilityGrantValidationObstruction::RequirementsDigestMismatch => { + CapabilityGrantValidationObstructionKind::RequirementsDigestMismatch + } + CapabilityGrantValidationObstruction::ExpiredGrant => { + CapabilityGrantValidationObstructionKind::ExpiredGrant + } + } +} diff --git a/crates/warp-core/tests/capability_grant_validation_tests.rs b/crates/warp-core/tests/capability_grant_validation_tests.rs new file mode 100644 index 00000000..992b03f4 --- /dev/null +++ b/crates/warp-core/tests/capability_grant_validation_tests.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Regression tests for capability grant validation obstruction facts. + +use warp_core::{ + AuthorityContext, AuthorityPolicy, AuthorityPolicyEvaluation, CapabilityGrantExpiryPosture, + CapabilityGrantIntent, CapabilityGrantIntentGate, CapabilityGrantValidationObstruction, + CapabilityGrantValidationOutcome, CapabilityGrantValidationPosture, GraphFact, + OpticAdmissionRequirements, OpticArtifact, OpticArtifactOperation, OpticArtifactRegistry, + OpticCapabilityPresentation, OpticRegistrationDescriptor, PrincipalRef, +}; + +fn principal(id: &str) -> PrincipalRef { + PrincipalRef { id: id.to_owned() } +} + +fn fixture_artifact() -> OpticArtifact { + OpticArtifact { + artifact_id: "optic-artifact:stack-witness-0001".to_owned(), + artifact_hash: "artifact-hash:stack-witness-0001".to_owned(), + schema_id: "schema:jedit-text-buffer-optic:v0".to_owned(), + requirements_digest: "requirements-digest:stack-witness-0001".to_owned(), + operation: OpticArtifactOperation { + operation_id: "operation:textWindow:v0".to_owned(), + }, + requirements: OpticAdmissionRequirements { + codec: "wesley.requirements.canonical-json.v0".to_owned(), + digest: "requirements-digest:stack-witness-0001".to_owned(), + bytes: b"fixture admission requirements".to_vec(), + }, + } +} + +fn fixture_descriptor() -> OpticRegistrationDescriptor { + OpticRegistrationDescriptor { + artifact_id: "optic-artifact:stack-witness-0001".to_owned(), + artifact_hash: "artifact-hash:stack-witness-0001".to_owned(), + schema_id: "schema:jedit-text-buffer-optic:v0".to_owned(), + operation_id: "operation:textWindow:v0".to_owned(), + requirements_digest: "requirements-digest:stack-witness-0001".to_owned(), + } +} + +fn fixture_grant(grant_id: &str) -> CapabilityGrantIntent { + CapabilityGrantIntent { + intent_id: grant_id.to_owned(), + proposed_by: principal("principal:issuer"), + subject: principal("principal:jedit-session"), + artifact_hash: "artifact-hash:stack-witness-0001".to_owned(), + operation_id: "operation:textWindow:v0".to_owned(), + requirements_digest: "requirements-digest:stack-witness-0001".to_owned(), + rights: vec!["optic.invoke".to_owned()], + scope_bytes: b"scope:fixture".to_vec(), + expiry_bytes: Some(b"expiry:fixture".to_vec()), + delegation_basis_bytes: Some(b"delegation-basis:fixture".to_vec()), + } +} + +fn fixture_authority_context() -> AuthorityContext { + AuthorityContext { + issuer: Some(principal("principal:issuer")), + policy: Some(AuthorityPolicy { + policy_id: "authority-policy:fixture".to_owned(), + }), + policy_evaluation: AuthorityPolicyEvaluation::Unsupported, + } +} + +fn fixture_presentation(grant_id: &str) -> OpticCapabilityPresentation { + OpticCapabilityPresentation { + presentation_id: "presentation:fixture".to_owned(), + bound_grant_id: Some(grant_id.to_owned()), + } +} + +fn fixture_registered_artifact() -> Result { + let mut registry = OpticArtifactRegistry::new(); + let handle = registry + .register_optic_artifact(fixture_artifact(), fixture_descriptor()) + .map_err(|err| format!("fixture descriptor should register: {err:?}"))?; + registry + .resolve_optic_artifact_handle(&handle) + .cloned() + .map_err(|err| format!("registered handle should resolve: {err:?}")) +} + +fn fixture_gate_with_grant(grant: CapabilityGrantIntent) -> CapabilityGrantIntentGate { + let mut gate = CapabilityGrantIntentGate::new(); + let _ = gate.submit_grant_intent(grant, fixture_authority_context()); + gate +} + +fn obstructed_posture( + outcome: &CapabilityGrantValidationOutcome, +) -> Result<&CapabilityGrantValidationPosture, String> { + match outcome { + CapabilityGrantValidationOutcome::Obstructed(posture) => Ok(posture), + CapabilityGrantValidationOutcome::IdentityCovered(_) => { + Err("expected grant validation obstruction".to_owned()) + } + } +} + +fn latest_validation_obstruction_fact( + gate: &CapabilityGrantIntentGate, +) -> Result<&GraphFact, String> { + gate.published_graph_facts() + .last() + .map(|published| &published.fact) + .ok_or_else(|| "expected capability grant validation obstruction fact".to_owned()) +} + +#[test] +fn grant_validation_obstructs_artifact_hash_mismatch() -> Result<(), String> { + let registered = fixture_registered_artifact()?; + let mut grant = fixture_grant("grant:artifact-mismatch"); + grant.artifact_hash = "artifact-hash:other".to_owned(); + let mut gate = fixture_gate_with_grant(grant); + + let outcome = gate.validate_capability_presentation_for_artifact( + &fixture_presentation("grant:artifact-mismatch"), + ®istered, + CapabilityGrantExpiryPosture::NotEvaluated, + ); + + let posture = obstructed_posture(&outcome)?; + assert_eq!( + posture.obstruction, + CapabilityGrantValidationObstruction::ArtifactHashMismatch + ); + assert!(matches!( + latest_validation_obstruction_fact(&gate)?, + GraphFact::CapabilityGrantValidationObstructed { + grant_artifact_hash, + expected_artifact_hash, + obstruction, + .. + } if grant_artifact_hash.as_deref() == Some("artifact-hash:other") + && expected_artifact_hash == "artifact-hash:stack-witness-0001" + && *obstruction == warp_core::CapabilityGrantValidationObstructionKind::ArtifactHashMismatch + )); + Ok(()) +} + +#[test] +fn grant_validation_obstructs_operation_id_mismatch() -> Result<(), String> { + let registered = fixture_registered_artifact()?; + let mut grant = fixture_grant("grant:operation-mismatch"); + grant.operation_id = "operation:replaceRange:v0".to_owned(); + let mut gate = fixture_gate_with_grant(grant); + + let outcome = gate.validate_capability_presentation_for_artifact( + &fixture_presentation("grant:operation-mismatch"), + ®istered, + CapabilityGrantExpiryPosture::NotEvaluated, + ); + + let posture = obstructed_posture(&outcome)?; + assert_eq!( + posture.obstruction, + CapabilityGrantValidationObstruction::OperationIdMismatch + ); + Ok(()) +} + +#[test] +fn grant_validation_obstructs_requirements_digest_mismatch() -> Result<(), String> { + let registered = fixture_registered_artifact()?; + let mut grant = fixture_grant("grant:requirements-mismatch"); + grant.requirements_digest = "requirements-digest:other".to_owned(); + let mut gate = fixture_gate_with_grant(grant); + + let outcome = gate.validate_capability_presentation_for_artifact( + &fixture_presentation("grant:requirements-mismatch"), + ®istered, + CapabilityGrantExpiryPosture::NotEvaluated, + ); + + let posture = obstructed_posture(&outcome)?; + assert_eq!( + posture.obstruction, + CapabilityGrantValidationObstruction::RequirementsDigestMismatch + ); + Ok(()) +} + +#[test] +fn grant_validation_obstructs_expired_grant_if_expiry_exists() -> Result<(), String> { + let registered = fixture_registered_artifact()?; + let grant = fixture_grant("grant:expired"); + let mut gate = fixture_gate_with_grant(grant); + + let outcome = gate.validate_capability_presentation_for_artifact( + &fixture_presentation("grant:expired"), + ®istered, + CapabilityGrantExpiryPosture::Expired, + ); + + let posture = obstructed_posture(&outcome)?; + assert_eq!( + posture.obstruction, + CapabilityGrantValidationObstruction::ExpiredGrant + ); + Ok(()) +} + +#[test] +fn grant_validation_obstruction_publishes_graph_fact() -> Result<(), String> { + let registered = fixture_registered_artifact()?; + let mut grant = fixture_grant("grant:publish-fact"); + grant.requirements_digest = "requirements-digest:other".to_owned(); + let mut gate = fixture_gate_with_grant(grant); + + let outcome = gate.validate_capability_presentation_for_artifact( + &fixture_presentation("grant:publish-fact"), + ®istered, + CapabilityGrantExpiryPosture::NotEvaluated, + ); + + assert_eq!( + obstructed_posture(&outcome)?.obstruction, + CapabilityGrantValidationObstruction::RequirementsDigestMismatch + ); + assert!(matches!( + latest_validation_obstruction_fact(&gate)?, + GraphFact::CapabilityGrantValidationObstructed { + presentation_id, + grant_id, + artifact_handle_id, + expected_operation_id, + grant_operation_id, + expected_requirements_digest, + grant_requirements_digest, + obstruction, + .. + } if presentation_id == "presentation:fixture" + && grant_id.as_deref() == Some("grant:publish-fact") + && artifact_handle_id == "optic-artifact-handle:0000000000000001" + && expected_operation_id == "operation:textWindow:v0" + && grant_operation_id.as_deref() == Some("operation:textWindow:v0") + && expected_requirements_digest == "requirements-digest:stack-witness-0001" + && grant_requirements_digest.as_deref() == Some("requirements-digest:other") + && *obstruction == warp_core::CapabilityGrantValidationObstructionKind::RequirementsDigestMismatch + )); + Ok(()) +} + +#[test] +fn grant_validation_obstruction_fact_digest_is_deterministic() { + let first = GraphFact::CapabilityGrantValidationObstructed { + presentation_id: "presentation:fixture".to_owned(), + grant_id: Some("grant:fixture".to_owned()), + artifact_handle_id: "optic-artifact-handle:0000000000000001".to_owned(), + expected_artifact_hash: "artifact-hash:stack-witness-0001".to_owned(), + grant_artifact_hash: Some("artifact-hash:other".to_owned()), + expected_operation_id: "operation:textWindow:v0".to_owned(), + grant_operation_id: Some("operation:textWindow:v0".to_owned()), + expected_requirements_digest: "requirements-digest:stack-witness-0001".to_owned(), + grant_requirements_digest: Some("requirements-digest:stack-witness-0001".to_owned()), + obstruction: warp_core::CapabilityGrantValidationObstructionKind::ArtifactHashMismatch, + }; + let repeated = first.clone(); + + assert_eq!(first.digest(), repeated.digest()); +} diff --git a/crates/warp-core/tests/optic_invocation_admission_tests.rs b/crates/warp-core/tests/optic_invocation_admission_tests.rs index 9f350921..a2fb1b92 100644 --- a/crates/warp-core/tests/optic_invocation_admission_tests.rs +++ b/crates/warp-core/tests/optic_invocation_admission_tests.rs @@ -170,7 +170,7 @@ fn optic_invocation_obstructs_unbound_capability_presentation() -> Result<(), St } #[test] -fn optic_invocation_obstructs_placeholder_capability_presentation_until_grant_validation_exists( +fn optic_invocation_obstructs_placeholder_capability_presentation_until_grant_validation_is_wired_into_admission( ) -> Result<(), String> { let (mut registry, handle) = fixture_registry_and_handle()?; let mut invocation = fixture_invocation(handle); @@ -192,8 +192,8 @@ fn optic_invocation_obstructs_placeholder_capability_presentation_until_grant_va } #[test] -fn optic_invocation_presentation_never_admits_without_real_grant_validation() -> Result<(), String> -{ +fn optic_invocation_presentation_never_admits_without_grant_validation_wired_into_admission( +) -> Result<(), String> { let presentations = [ ( OpticCapabilityPresentation { diff --git a/docs/design/capability-grant-validation-obstruction-facts.md b/docs/design/capability-grant-validation-obstruction-facts.md new file mode 100644 index 00000000..fba98ae4 --- /dev/null +++ b/docs/design/capability-grant-validation-obstruction-facts.md @@ -0,0 +1,145 @@ + + + +# Capability Grant Validation Obstruction Facts + +Status: implementation slice. +Scope: in-memory causal graph fact publication for narrow capability grant +validation refusal. + +## Doctrine + +A grant can fail validation causally before any invocation can succeed. + +Grant validation obstruction is graph evidence, not authority. Recorded grant +material is not an accepted grant, not a capability, not an admission ticket, +not a law witness, and not permission to execute. + +```text +capability presentation + + recorded grant intent material + + registered optic artifact + -> identity coverage check + -> obstruction posture + -> GraphFact::CapabilityGrantValidationObstructed +``` + +This slice validates only identity coverage: + +- artifact hash; +- operation id; +- requirements digest; +- explicit expiry posture when supplied by the caller. + +Expiry remains caller-supplied posture in this slice. Echo does not parse +`CapabilityGrantIntent::expiry_bytes`, read clocks, or derive temporal authority. + +## Fact model + +`GraphFact::CapabilityGrantValidationObstructed` records: + +- `presentation_id`; +- `grant_id`; +- `artifact_handle_id`; +- `expected_artifact_hash`; +- `grant_artifact_hash`; +- `expected_operation_id`; +- `grant_operation_id`; +- `expected_requirements_digest`; +- `grant_requirements_digest`; +- `obstruction`. + +Expected fields come from Echo's registered artifact material. Grant fields +come from the recorded grant intent material when that material is available. +Both sides are included so fact digests distinguish different failed grants +against the same registered artifact. + +## Flow + +```mermaid +flowchart TD + Presentation[OpticCapabilityPresentation] + Gate[CapabilityGrantIntentGate] + Grant[Recorded grant material] + Artifact[RegisteredOpticArtifact] + Identity[Check identity coverage] + Expiry[Check explicit expiry posture] + Posture[CapabilityGrantValidationPosture] + Fact[GraphFact::CapabilityGrantValidationObstructed] + Digest[FactDigest] + + Presentation --> Gate + Gate --> Grant + Artifact --> Identity + Grant --> Identity + Identity -->|mismatch| Posture + Identity -->|match| Expiry + Expiry -->|expired| Posture + Posture --> Fact + Fact --> Digest +``` + +## Sequence + +```mermaid +sequenceDiagram + participant Caller as caller + participant Gate as CapabilityGrantIntentGate + participant Facts as in-memory fact log + + Caller->>Gate: validate_capability_presentation_for_artifact(...) + Gate->>Gate: resolve recorded grant material + Gate->>Gate: compare artifact hash, operation id, requirements digest + Gate->>Gate: apply explicit expiry posture + Gate->>Facts: append CapabilityGrantValidationObstructed + Gate-->>Caller: obstructed validation posture +``` + +## Class diagram + +```mermaid +classDiagram + class CapabilityGrantIntentGate { + +submit_grant_intent(intent, authority_context) + +validate_capability_presentation_for_artifact(presentation, registered, expiry_posture) + +published_graph_facts() + } + + class GraphFact { + CapabilityGrantValidationObstructed + +digest() + } + + class CapabilityGrantValidationObstructionKind { + MalformedCapabilityPresentation + UnboundCapabilityPresentation + UnknownGrant + ArtifactHashMismatch + OperationIdMismatch + RequirementsDigestMismatch + ExpiredGrant + } + + CapabilityGrantIntentGate --> GraphFact + GraphFact --> CapabilityGrantValidationObstructionKind +``` + +## Non-goals + +- no successful grant admission; +- no successful `AdmissionTicket`; +- no `LawWitness`; +- no execution; +- no scheduler; +- no real delegation policy; +- no quorum or governance; +- no Continuum schema; +- no clock parsing; +- no invocation success path. + +## Operating rule + +Capability grant validation obstruction facts are refusal records. They prove +Echo noticed that recorded grant material failed to cover a registered artifact +identity. They do not prove that any grant is accepted, sufficient, delegated, +current, or authorized. diff --git a/docs/design/invocation-obstruction-graph-facts.md b/docs/design/invocation-obstruction-graph-facts.md index e280b087..52ad0489 100644 --- a/docs/design/invocation-obstruction-graph-facts.md +++ b/docs/design/invocation-obstruction-graph-facts.md @@ -140,7 +140,7 @@ erDiagram - no success admission; - no `AdmissionTicket`; - no `LawWitness`; -- no grant validation; +- no grant validation inside `admit_optic_invocation`; - no execution; - no scheduler; - no persistence;