From d54457887cdf53e87cd0fcc28d5e9a908af9acef Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 15 May 2026 03:31:55 -0700 Subject: [PATCH] feat(core): publish invocation obstruction graph facts --- CHANGELOG.md | 10 ++ crates/warp-core/src/causal_facts.rs | 84 ++++++++++ crates/warp-core/src/lib.rs | 3 +- crates/warp-core/src/optic_artifact.rs | 69 ++++++-- .../tests/optic_invocation_admission_tests.rs | 157 +++++++++++++++++- .../invocation-obstruction-graph-facts.md | 153 +++++++++++++++++ 6 files changed, 455 insertions(+), 21 deletions(-) create mode 100644 docs/design/invocation-obstruction-graph-facts.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 532b505d..a85d3bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ ### Added +- `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 + aperture request digests, and structured obstruction kind without creating a + success admission ticket, law witness, execution, scheduler output, or + counterfactual candidate. +- `docs/design/invocation-obstruction-graph-facts.md` defines the invocation + refusal publication boundary: registered handles are not authority, and + invocation obstruction facts are causal refusal evidence rather than + counterfactual worlds. - `warp-core` now publishes in-memory causal graph facts from optic artifact registration. Successful registration emits `GraphFact::ArtifactRegistered`, computes a deterministic `FactDigest`, and links that digest from an diff --git a/crates/warp-core/src/causal_facts.rs b/crates/warp-core/src/causal_facts.rs index 02d5dcb4..5f3ce2ad 100644 --- a/crates/warp-core/src/causal_facts.rs +++ b/crates/warp-core/src/causal_facts.rs @@ -35,6 +35,37 @@ pub enum ArtifactRegistrationObstructionKind { RequirementsDigestMismatch, } +/// Obstruction kind recorded when optic invocation admission refuses before a +/// success ticket, witness, scheduler selection, or execution boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvocationObstructionKind { + /// Echo did not issue the supplied artifact handle. + UnknownHandle, + /// Invocation operation id did not match the registered artifact operation. + OperationMismatch, + /// Invocation supplied no capability presentation. + MissingCapability, + /// Invocation supplied a malformed capability presentation. + MalformedCapabilityPresentation, + /// Invocation supplied a presentation not bound to a grant id. + UnboundCapabilityPresentation, + /// Invocation supplied a placeholder presentation before grant validation exists. + CapabilityValidationUnavailable, +} + +impl InvocationObstructionKind { + fn digest_label(self) -> &'static [u8] { + match self { + Self::UnknownHandle => b"unknown-handle", + Self::OperationMismatch => b"operation-mismatch", + Self::MissingCapability => b"missing-capability", + Self::MalformedCapabilityPresentation => b"malformed-capability-presentation", + Self::UnboundCapabilityPresentation => b"unbound-capability-presentation", + Self::CapabilityValidationUnavailable => b"capability-validation-unavailable", + } + } +} + impl ArtifactRegistrationObstructionKind { fn digest_label(self) -> &'static [u8] { match self { @@ -71,6 +102,21 @@ pub enum GraphFact { /// Structured obstruction kind. obstruction: ArtifactRegistrationObstructionKind, }, + /// Echo refused optic invocation before admission success. + OpticInvocationObstructed { + /// Echo-owned runtime-local artifact handle id named by the invocation. + artifact_handle_id: String, + /// Operation id named by the invocation. + operation_id: String, + /// Digest of the invocation's canonical variable bytes. + canonical_variables_digest: Vec, + /// Digest of the opaque basis request bytes. + basis_request_digest: [u8; 32], + /// Digest of the opaque aperture request bytes. + aperture_request_digest: [u8; 32], + /// Structured invocation obstruction kind. + obstruction: InvocationObstructionKind, + }, } impl GraphFact { @@ -111,12 +157,50 @@ impl GraphFact { ); push_digest_field(&mut bytes, b"obstruction", obstruction.digest_label()); } + Self::OpticInvocationObstructed { + artifact_handle_id, + operation_id, + canonical_variables_digest, + basis_request_digest, + aperture_request_digest, + obstruction, + } => { + push_digest_field(&mut bytes, b"variant", b"optic-invocation-obstructed"); + push_digest_field( + &mut bytes, + b"artifact-handle-id", + artifact_handle_id.as_bytes(), + ); + push_digest_field(&mut bytes, b"operation-id", operation_id.as_bytes()); + push_digest_field( + &mut bytes, + b"canonical-variables-digest", + canonical_variables_digest, + ); + push_digest_field(&mut bytes, b"basis-request-digest", basis_request_digest); + push_digest_field( + &mut bytes, + b"aperture-request-digest", + aperture_request_digest, + ); + push_digest_field(&mut bytes, b"obstruction", obstruction.digest_label()); + } } FactDigest(*blake3::hash(&bytes).as_bytes()) } } +/// Computes a deterministic digest for opaque invocation request bytes named +/// inside graph facts. +#[must_use] +pub fn digest_invocation_request_bytes(domain: &[u8], bytes: &[u8]) -> [u8; 32] { + let mut input = Vec::new(); + push_digest_field(&mut input, b"domain", domain); + push_digest_field(&mut input, b"bytes", bytes); + *blake3::hash(&input).as_bytes() +} + /// Graph fact plus its deterministic digest. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublishedGraphFact { diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index 62d39413..e28c1c16 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -167,7 +167,8 @@ pub use attachment::{ CodecRegistry, DecodeError, ErasedCodec, RegistryError, }; pub use causal_facts::{ - ArtifactRegistrationObstructionKind, ArtifactRegistrationReceipt, FactDigest, GraphFact, + digest_invocation_request_bytes, ArtifactRegistrationObstructionKind, + ArtifactRegistrationReceipt, FactDigest, GraphFact, InvocationObstructionKind, PublishedGraphFact, ARTIFACT_REGISTRATION_RECEIPT_KIND, }; pub use clock::{GlobalTick, RunId, WorldlineTick}; diff --git a/crates/warp-core/src/optic_artifact.rs b/crates/warp-core/src/optic_artifact.rs index 01225df8..3799ce0b 100644 --- a/crates/warp-core/src/optic_artifact.rs +++ b/crates/warp-core/src/optic_artifact.rs @@ -13,8 +13,9 @@ use std::collections::BTreeMap; use crate::{ - ArtifactRegistrationObstructionKind, ArtifactRegistrationReceipt, GraphFact, - PublishedGraphFact, ARTIFACT_REGISTRATION_RECEIPT_KIND, + digest_invocation_request_bytes, ArtifactRegistrationObstructionKind, + ArtifactRegistrationReceipt, GraphFact, InvocationObstructionKind, PublishedGraphFact, + ARTIFACT_REGISTRATION_RECEIPT_KIND, }; use thiserror::Error; @@ -756,24 +757,20 @@ impl OpticArtifactRegistry { /// and that obstruction is reported as a structured pre-ticket posture. #[must_use = "optic invocation admission outcomes carry obstructions that must be handled"] pub fn admit_optic_invocation( - &self, + &mut self, invocation: &OpticInvocation, ) -> OpticInvocationAdmissionOutcome { let Ok(registered) = self.resolve_optic_artifact_handle(&invocation.artifact_handle) else { - return Self::obstructed_invocation( - invocation, - OpticInvocationObstruction::UnknownHandle, - ); + return self + .obstructed_invocation(invocation, OpticInvocationObstruction::UnknownHandle); }; if invocation.operation_id != registered.operation_id { - return Self::obstructed_invocation( - invocation, - OpticInvocationObstruction::OperationMismatch, - ); + return self + .obstructed_invocation(invocation, OpticInvocationObstruction::OperationMismatch); } - Self::obstructed_invocation( + self.obstructed_invocation( invocation, Self::classify_capability_presentation(invocation.capability_presentation.as_ref()), ) @@ -803,9 +800,11 @@ impl OpticArtifactRegistry { } fn obstructed_invocation( + &mut self, invocation: &OpticInvocation, obstruction: OpticInvocationObstruction, ) -> OpticInvocationAdmissionOutcome { + self.publish_invocation_obstruction_fact(invocation, obstruction); OpticInvocationAdmissionOutcome::Obstructed(OpticAdmissionTicketPosture { kind: OPTIC_ADMISSION_TICKET_POSTURE_KIND.to_owned(), artifact_handle: invocation.artifact_handle.clone(), @@ -915,6 +914,29 @@ impl OpticArtifactRegistry { )); } } + + fn publish_invocation_obstruction_fact( + &mut self, + invocation: &OpticInvocation, + obstruction: OpticInvocationObstruction, + ) { + self.published_graph_facts.push(PublishedGraphFact::new( + GraphFact::OpticInvocationObstructed { + artifact_handle_id: invocation.artifact_handle.id.clone(), + operation_id: invocation.operation_id.clone(), + canonical_variables_digest: invocation.canonical_variables_digest.clone(), + basis_request_digest: digest_invocation_request_bytes( + b"echo.optic-invocation.basis-request.v0", + &invocation.basis_request.bytes, + ), + aperture_request_digest: digest_invocation_request_bytes( + b"echo.optic-invocation.aperture-request.v0", + &invocation.aperture_request.bytes, + ), + obstruction: invocation_obstruction_kind(obstruction), + }, + )); + } } fn artifact_registration_obstruction_kind( @@ -939,3 +961,26 @@ fn artifact_registration_obstruction_kind( OpticArtifactRegistrationError::UnknownHandle => None, } } + +fn invocation_obstruction_kind( + obstruction: OpticInvocationObstruction, +) -> InvocationObstructionKind { + match obstruction { + OpticInvocationObstruction::UnknownHandle => InvocationObstructionKind::UnknownHandle, + OpticInvocationObstruction::OperationMismatch => { + InvocationObstructionKind::OperationMismatch + } + OpticInvocationObstruction::MissingCapability => { + InvocationObstructionKind::MissingCapability + } + OpticInvocationObstruction::MalformedCapabilityPresentation => { + InvocationObstructionKind::MalformedCapabilityPresentation + } + OpticInvocationObstruction::UnboundCapabilityPresentation => { + InvocationObstructionKind::UnboundCapabilityPresentation + } + OpticInvocationObstruction::CapabilityValidationUnavailable => { + InvocationObstructionKind::CapabilityValidationUnavailable + } + } +} diff --git a/crates/warp-core/tests/optic_invocation_admission_tests.rs b/crates/warp-core/tests/optic_invocation_admission_tests.rs index 07985859..6b1d48cd 100644 --- a/crates/warp-core/tests/optic_invocation_admission_tests.rs +++ b/crates/warp-core/tests/optic_invocation_admission_tests.rs @@ -3,10 +3,12 @@ //! Regression tests for optic invocation admission obstruction. use warp_core::{ + digest_invocation_request_bytes, GraphFact, InvocationObstructionKind, OpticAdmissionRequirements, OpticAdmissionTicketPosture, OpticApertureRequest, OpticArtifact, OpticArtifactHandle, OpticArtifactOperation, OpticArtifactRegistry, OpticBasisRequest, OpticCapabilityPresentation, OpticInvocation, OpticInvocationAdmissionOutcome, - OpticInvocationObstruction, OpticRegistrationDescriptor, OPTIC_ADMISSION_TICKET_POSTURE_KIND, + OpticInvocationObstruction, OpticRegistrationDescriptor, RewriteDisposition, + OPTIC_ADMISSION_TICKET_POSTURE_KIND, }; fn fixture_artifact() -> OpticArtifact { @@ -80,7 +82,7 @@ fn obstruction_for(outcome: &OpticInvocationAdmissionOutcome) -> OpticInvocation #[test] fn optic_invocation_obstructs_unknown_handle() { - let registry = OpticArtifactRegistry::new(); + let mut registry = OpticArtifactRegistry::new(); let invocation = fixture_invocation(OpticArtifactHandle { kind: "optic-artifact-handle".to_owned(), id: "unregistered-handle".to_owned(), @@ -96,7 +98,7 @@ fn optic_invocation_obstructs_unknown_handle() { #[test] fn optic_invocation_obstructs_operation_mismatch() -> Result<(), String> { - let (registry, handle) = fixture_registry_and_handle()?; + let (mut registry, handle) = fixture_registry_and_handle()?; let mut invocation = fixture_invocation(handle); invocation.operation_id = "operation:replaceRange:v0".to_owned(); @@ -111,7 +113,7 @@ fn optic_invocation_obstructs_operation_mismatch() -> Result<(), String> { #[test] fn optic_invocation_obstructs_missing_capability_for_registered_handle() -> Result<(), String> { - let (registry, handle) = fixture_registry_and_handle()?; + let (mut registry, handle) = fixture_registry_and_handle()?; let invocation = fixture_invocation(handle); let outcome = registry.admit_optic_invocation(&invocation); @@ -125,7 +127,7 @@ fn optic_invocation_obstructs_missing_capability_for_registered_handle() -> Resu #[test] fn optic_invocation_obstructs_malformed_capability_presentation() -> Result<(), String> { - let (registry, handle) = fixture_registry_and_handle()?; + let (mut registry, handle) = fixture_registry_and_handle()?; let mut invocation = fixture_invocation(handle); invocation.capability_presentation = Some(OpticCapabilityPresentation { presentation_id: String::new(), @@ -146,7 +148,7 @@ fn optic_invocation_obstructs_malformed_capability_presentation() -> Result<(), #[test] fn optic_invocation_obstructs_unbound_capability_presentation() -> Result<(), String> { - let (registry, handle) = fixture_registry_and_handle()?; + let (mut registry, handle) = fixture_registry_and_handle()?; let mut invocation = fixture_invocation(handle); invocation.capability_presentation = Some(OpticCapabilityPresentation { presentation_id: "presentation:unbound".to_owned(), @@ -168,7 +170,7 @@ fn optic_invocation_obstructs_unbound_capability_presentation() -> Result<(), St #[test] fn optic_invocation_obstructs_placeholder_capability_presentation_until_grant_validation_exists( ) -> Result<(), String> { - let (registry, handle) = fixture_registry_and_handle()?; + let (mut registry, handle) = fixture_registry_and_handle()?; let mut invocation = fixture_invocation(handle); invocation.capability_presentation = Some(OpticCapabilityPresentation { presentation_id: "presentation:placeholder".to_owned(), @@ -215,7 +217,7 @@ fn optic_invocation_presentation_never_admits_without_real_grant_validation() -> ]; for (presentation, expected_obstruction) in presentations { - let (registry, handle) = fixture_registry_and_handle()?; + let (mut registry, handle) = fixture_registry_and_handle()?; let mut invocation = fixture_invocation(handle); invocation.capability_presentation = Some(presentation); @@ -225,3 +227,142 @@ fn optic_invocation_presentation_never_admits_without_real_grant_validation() -> } Ok(()) } + +fn latest_invocation_obstruction_fact( + registry: &OpticArtifactRegistry, +) -> Result<&GraphFact, String> { + registry + .published_graph_facts() + .last() + .map(|published| &published.fact) + .ok_or_else(|| "expected invocation obstruction graph fact".to_owned()) +} + +#[test] +fn unknown_handle_publishes_invocation_obstruction_fact() -> Result<(), String> { + let mut registry = OpticArtifactRegistry::new(); + let invocation = fixture_invocation(OpticArtifactHandle { + kind: "optic-artifact-handle".to_owned(), + id: "unregistered-handle".to_owned(), + }); + + let outcome = registry.admit_optic_invocation(&invocation); + + assert_eq!( + obstruction_for(&outcome), + OpticInvocationObstruction::UnknownHandle + ); + assert!(matches!( + latest_invocation_obstruction_fact(®istry)?, + GraphFact::OpticInvocationObstructed { + artifact_handle_id, + operation_id, + canonical_variables_digest, + obstruction, + .. + } if artifact_handle_id == "unregistered-handle" + && operation_id == "operation:textWindow:v0" + && canonical_variables_digest == b"vars-digest:textWindow" + && *obstruction == InvocationObstructionKind::UnknownHandle + )); + Ok(()) +} + +#[test] +fn operation_mismatch_publishes_invocation_obstruction_fact() -> Result<(), String> { + let (mut registry, handle) = fixture_registry_and_handle()?; + let mut invocation = fixture_invocation(handle); + invocation.operation_id = "operation:replaceRange:v0".to_owned(); + + let outcome = registry.admit_optic_invocation(&invocation); + + assert_eq!( + obstruction_for(&outcome), + OpticInvocationObstruction::OperationMismatch + ); + assert!(matches!( + latest_invocation_obstruction_fact(®istry)?, + GraphFact::OpticInvocationObstructed { + operation_id, + obstruction, + .. + } if operation_id == "operation:replaceRange:v0" + && *obstruction == InvocationObstructionKind::OperationMismatch + )); + Ok(()) +} + +#[test] +fn missing_capability_publishes_invocation_obstruction_fact() -> Result<(), String> { + let (mut registry, handle) = fixture_registry_and_handle()?; + let invocation = fixture_invocation(handle); + + let outcome = registry.admit_optic_invocation(&invocation); + + assert_eq!( + obstruction_for(&outcome), + OpticInvocationObstruction::MissingCapability + ); + assert!(matches!( + latest_invocation_obstruction_fact(®istry)?, + GraphFact::OpticInvocationObstructed { + basis_request_digest, + aperture_request_digest, + obstruction, + .. + } if *basis_request_digest == digest_invocation_request_bytes( + b"echo.optic-invocation.basis-request.v0", + b"basis-request:fixture" + ) + && *aperture_request_digest == digest_invocation_request_bytes( + b"echo.optic-invocation.aperture-request.v0", + b"aperture-request:fixture" + ) + && *obstruction == InvocationObstructionKind::MissingCapability + )); + Ok(()) +} + +#[test] +fn invocation_obstruction_fact_digest_is_deterministic() { + let first = GraphFact::OpticInvocationObstructed { + artifact_handle_id: "handle:1".to_owned(), + operation_id: "operation:textWindow:v0".to_owned(), + canonical_variables_digest: b"vars".to_vec(), + basis_request_digest: digest_invocation_request_bytes( + b"echo.optic-invocation.basis-request.v0", + b"basis", + ), + aperture_request_digest: digest_invocation_request_bytes( + b"echo.optic-invocation.aperture-request.v0", + b"aperture", + ), + obstruction: InvocationObstructionKind::MissingCapability, + }; + let repeated = first.clone(); + + assert_eq!(first.digest(), repeated.digest()); +} + +#[test] +fn invocation_obstruction_fact_is_not_counterfactual_candidate() -> Result<(), String> { + let (mut registry, handle) = fixture_registry_and_handle()?; + let invocation = fixture_invocation(handle); + + let outcome = registry.admit_optic_invocation(&invocation); + + assert_eq!( + obstruction_for(&outcome), + OpticInvocationObstruction::MissingCapability + ); + let disposition = RewriteDisposition::Obstructed; + assert_ne!( + disposition, + RewriteDisposition::LegalUnselectedCounterfactual + ); + assert!(matches!( + latest_invocation_obstruction_fact(®istry)?, + GraphFact::OpticInvocationObstructed { .. } + )); + Ok(()) +} diff --git a/docs/design/invocation-obstruction-graph-facts.md b/docs/design/invocation-obstruction-graph-facts.md new file mode 100644 index 00000000..e280b087 --- /dev/null +++ b/docs/design/invocation-obstruction-graph-facts.md @@ -0,0 +1,153 @@ + + + +# Invocation Obstruction Graph Facts + +Status: implementation slice. +Scope: in-memory causal graph fact publication for optic invocation refusal. + +## Doctrine + +Registered handle does not imply authority. + +Invocation obstruction is causal refusal evidence. It is not a successful +admission ticket, not a law witness, not execution, not scheduler output, and +not a counterfactual candidate. + +```text +registered artifact handle + -> invocation attempted + -> authority/presentation unavailable or invalid + -> obstruction posture + -> GraphFact::OpticInvocationObstructed +``` + +Receipts explain graph outcomes. They do not replace graph facts. + +## Fact model + +`GraphFact::OpticInvocationObstructed` records: + +- `artifact_handle_id`; +- `operation_id`; +- `canonical_variables_digest`; +- `basis_request_digest`; +- `aperture_request_digest`; +- `obstruction`. + +The basis and aperture request fields are stored as deterministic digests of +opaque request bytes. Echo does not interpret those request bytes in this slice. + +## Flow + +```mermaid +flowchart TD + Caller[Caller] + Invocation[OpticInvocation] + Registry[OpticArtifactRegistry] + Resolve[Resolve handle] + Operation[Check operation id] + Capability[Classify capability presentation] + Posture[OpticAdmissionTicketPosture] + Fact[GraphFact::OpticInvocationObstructed] + Digest[FactDigest] + + Caller --> Invocation + Invocation --> Registry + Registry --> Resolve + Resolve -->|unknown| Posture + Resolve -->|known| Operation + Operation -->|mismatch| Posture + Operation -->|match| Capability + Capability --> Posture + Posture --> Fact + Fact --> Digest +``` + +## Sequence + +```mermaid +sequenceDiagram + participant Caller as caller + participant Registry as OpticArtifactRegistry + participant Facts as in-memory fact log + + Caller->>Registry: admit_optic_invocation(invocation) + Registry->>Registry: resolve handle + Registry->>Registry: classify obstruction + Registry->>Facts: append OpticInvocationObstructed + Registry-->>Caller: obstructed admission posture +``` + +## Class diagram + +```mermaid +classDiagram + class OpticArtifactRegistry { + +admit_optic_invocation(invocation) + +published_graph_facts() + } + + class GraphFact { + OpticInvocationObstructed + +digest() + } + + class OpticInvocation { + +artifact_handle + +operation_id + +canonical_variables_digest + +basis_request + +aperture_request + +capability_presentation + } + + class InvocationObstructionKind { + UnknownHandle + OperationMismatch + MissingCapability + MalformedCapabilityPresentation + UnboundCapabilityPresentation + CapabilityValidationUnavailable + } + + OpticArtifactRegistry --> OpticInvocation + OpticArtifactRegistry --> GraphFact + GraphFact --> InvocationObstructionKind +``` + +## Entity relationship + +```mermaid +erDiagram + OPTIC_INVOCATION ||--|| OPTIC_ARTIFACT_HANDLE : names + OPTIC_ARTIFACT_REGISTRY ||--o{ PUBLISHED_GRAPH_FACT : publishes + PUBLISHED_GRAPH_FACT ||--|| FACT_DIGEST : has + PUBLISHED_GRAPH_FACT ||--|| INVOCATION_OBSTRUCTION : records + + INVOCATION_OBSTRUCTION { + string artifact_handle_id + string operation_id + bytes canonical_variables_digest + bytes basis_request_digest + bytes aperture_request_digest + string obstruction + } +``` + +## Non-goals + +- no success admission; +- no `AdmissionTicket`; +- no `LawWitness`; +- no grant validation; +- no execution; +- no scheduler; +- no persistence; +- no Continuum schema. + +## Operating rule + +Only legally admitted but unselected rewrites can become counterfactual +candidates. Invocation obstruction facts are refusal records, not unrealized +legal worlds.