From b26a360ecce9bb2c43e683341b6d52f47d488038 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 00:45:24 -0700 Subject: [PATCH 1/7] test: require rootless observation uncertainty --- .../bounded_workspace_observation_tests.rs | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/warp-core/tests/bounded_workspace_observation_tests.rs b/crates/warp-core/tests/bounded_workspace_observation_tests.rs index b63c825a..6783a31b 100644 --- a/crates/warp-core/tests/bounded_workspace_observation_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_observation_tests.rs @@ -26,7 +26,8 @@ use warp_core::external_action_adapter::{ admit_edict_external_action_request_v1, bounded_workspace_observation_basis_v1, encode_bounded_workspace_observation_input_v1, AdmittedEdictExternalActionRequestV1, BoundedWorkspaceObservationAdapterV1, BoundedWorkspaceObservationErrorV1, - BoundedWorkspaceObservationProfileV1, EdictExternalActionAdmissionErrorV1, + BoundedWorkspaceObservationProfileV1, BoundedWorkspaceObservationReconcilerV1, + EdictExternalActionAdmissionErrorV1, }; use warp_core::{Hash, WorldlineId}; @@ -1327,6 +1328,91 @@ fn requested_claimed_unknown_and_settled_postures_recover() { ); } +#[test] +fn outcome_unknown_settles_after_workspace_authority_disappears() { + let root = TempRoot::new("rootless-unknown"); + let root_path = root.path().to_owned(); + let bytes = b"possibly observed"; + root.write("uncertain.txt", bytes); + let admitted = admitted_request( + 61, + ["uncertain.txt".to_owned()], + digest("scope:rootless-unknown"), + bounded_workspace_observation_basis_v1([("uncertain.txt", bytes.as_slice())]), + 65_536, + ); + let runtime_profile = profile(&admitted, "bounded-observation:rootless-unknown"); + let adapter = must_ok(BoundedWorkspaceObservationAdapterV1::open( + root.path(), + ["uncertain.txt".to_owned()], + runtime_profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let _grant = claim( + &mut store, + &mut coordinator, + &admitted, + &adapter, + "rootless-unknown", + ); + + drop(adapter); + drop(root); + assert!(!root_path.exists()); + + let reconciler = must_ok(BoundedWorkspaceObservationReconcilerV1::new( + runtime_profile, + )); + let zero_evidence_grant = must_ok(coordinator.claim_grant(admitted.request().request_id())); + assert_eq!( + reconciler.admit_outcome_unknown( + &mut store, + &mut coordinator, + context("rootless-unknown:zero-evidence"), + &admitted, + zero_evidence_grant, + [0; 32], + ), + Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed) + ); + + let mut substituted_profile = runtime_profile; + substituted_profile.adapter_id = + ExternalActionAdapterIdV1::from_hash(digest("rootless-unknown:substituted-adapter")); + let substituted = must_ok(BoundedWorkspaceObservationReconcilerV1::new( + substituted_profile, + )); + let substituted_grant = must_ok(coordinator.claim_grant(admitted.request().request_id())); + assert_eq!( + substituted.admit_outcome_unknown( + &mut store, + &mut coordinator, + context("rootless-unknown:substituted-profile"), + &admitted, + substituted_grant, + digest("rootless-unknown:ambiguous"), + ), + Err(BoundedWorkspaceObservationErrorV1::GrantMismatch) + ); + assert_eq!(store.read_commits().len(), 2); + + let grant = must_ok(coordinator.claim_grant(admitted.request().request_id())); + let settled = must_ok(reconciler.admit_outcome_unknown( + &mut store, + &mut coordinator, + context("rootless-unknown:settlement"), + &admitted, + grant, + digest("rootless-unknown:ambiguous"), + )); + assert_eq!( + settled.settlement().kind, + ExternalActionSettlementKindV1::OutcomeUnknown + ); + assert_eq!(store.read_commits().len(), 3); +} + #[test] fn settled_replay_uses_wal_bytes_after_the_source_disappears() { let root = TempRoot::new("replay"); From 1dcf3e52c9bd2afe29d9c7b9a515ea33b3887ec3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 00:49:45 -0700 Subject: [PATCH 2/7] feat: reconcile observation uncertainty without a root --- .../warp-core/src/external_action_adapter.rs | 237 +++++++++++++----- .../bounded_workspace_observation_tests.rs | 21 ++ 2 files changed, 196 insertions(+), 62 deletions(-) diff --git a/crates/warp-core/src/external_action_adapter.rs b/crates/warp-core/src/external_action_adapter.rs index f5167460..b9e1a7a6 100644 --- a/crates/warp-core/src/external_action_adapter.rs +++ b/crates/warp-core/src/external_action_adapter.rs @@ -328,6 +328,16 @@ pub struct BoundedWorkspaceObservationAdapterV1 { profile: BoundedWorkspaceObservationProfileV1, } +/// Rootless settlement authority retained for post-claim reconciliation. +/// +/// This handle carries no directory capability and cannot perform an +/// observation. It can only admit the bounded profile's schema-valid +/// `OutcomeUnknown` settlement for an exact durable claim. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoundedWorkspaceObservationReconcilerV1 { + profile: BoundedWorkspaceObservationProfileV1, +} + impl BoundedWorkspaceObservationAdapterV1 { /// Opens the configured root once and retains only directory-relative authority. pub fn open( @@ -335,15 +345,7 @@ impl BoundedWorkspaceObservationAdapterV1 { permitted_paths: impl IntoIterator, profile: BoundedWorkspaceObservationProfileV1, ) -> Result { - if profile.operation_id.as_hash() == [0; 32] - || profile.input_schema_digest == [0; 32] - || profile.settlement_schema_digest == [0; 32] - || profile.reconciliation_law_digest == [0; 32] - || profile.authority_scope_digest == [0; 32] - || profile.adapter_id.as_hash() == [0; 32] - { - return Err(BoundedWorkspaceObservationErrorV1::ProfileMismatch); - } + validate_observation_profile(profile)?; let permitted_paths = permitted_paths.into_iter().collect::>(); for path in &permitted_paths { validate_relative_path(path)?; @@ -507,22 +509,7 @@ impl BoundedWorkspaceObservationAdapterV1 { grant: &ExternalActionClaimGrantV1, admitted: &AdmittedEdictExternalActionRequestV1, ) -> Result<(), BoundedWorkspaceObservationErrorV1> { - let request = grant.request(); - if request != admitted.request() - || request.input_digest != input_identity(admitted.canonical_operation_input()) - || grant.claim().adapter_id != self.profile.adapter_id - { - return Err(BoundedWorkspaceObservationErrorV1::GrantMismatch); - } - if request.operation_id != self.profile.operation_id - || request.input_schema_digest != self.profile.input_schema_digest - || request.settlement_schema_digest != self.profile.settlement_schema_digest - || request.reconciliation_law_digest != self.profile.reconciliation_law_digest - || request.authority_scope_digest != self.profile.authority_scope_digest - { - return Err(BoundedWorkspaceObservationErrorV1::ProfileMismatch); - } - Ok(()) + validate_observation_grant(self.profile, grant, admitted) } fn read_regular_file( @@ -673,50 +660,176 @@ impl BoundedWorkspaceObservationAdapterV1 { admitted: &AdmittedEdictExternalActionRequestV1, candidate: &ExternalActionSettlementCandidateV1, ) -> Result<(), BoundedWorkspaceObservationErrorV1> { - if candidate.request_id != grant.request().request_id() - || candidate.attempt_id != grant.claim().attempt_id - || candidate.adapter_id != self.profile.adapter_id - || candidate.settlement_schema_digest != self.profile.settlement_schema_digest - || candidate.basis_digest != grant.request().basis_digest - || candidate.declared_result_digest - != Hash::from(blake3::hash(&candidate.canonical_result_bytes)) - { + validate_observation_candidate( + self.profile, + Some(&self.permitted_paths), + grant, + admitted, + candidate, + ) + } +} + +impl BoundedWorkspaceObservationReconcilerV1 { + /// Constructs a reconciliation handle without acquiring filesystem authority. + pub fn new( + profile: BoundedWorkspaceObservationProfileV1, + ) -> Result { + validate_observation_profile(profile)?; + Ok(Self { profile }) + } + + /// Returns the registry binding for the exact retained adapter identity. + #[must_use] + pub const fn adapter_binding(&self) -> ExternalActionAdapterBindingV1 { + ExternalActionAdapterBindingV1 { + adapter_id: self.profile.adapter_id, + operation_id: self.profile.operation_id, + authority_scope_digest: self.profile.authority_scope_digest, + } + } + + /// Durably admits explicit uncertainty without reopening the external world. + #[allow(clippy::too_many_arguments)] + pub fn admit_outcome_unknown( + &self, + store: &mut impl WalStorePort, + coordinator: &mut ExternalActionCoordinatorV1, + context: ExternalActionTransactionContextV1, + admitted: &AdmittedEdictExternalActionRequestV1, + grant: ExternalActionClaimGrantV1, + external_evidence_digest: Hash, + ) -> Result { + validate_observation_grant(self.profile, &grant, admitted)?; + if external_evidence_digest == [0; 32] { return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); } - let value = decode_canonical_cbor_v1(&candidate.canonical_result_bytes) - .map_err(|_| BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed)?; - let expected_paths = if candidate.kind == ExternalActionSettlementKindV1::Succeeded { - let paths = decode_observation_input(admitted.canonical_operation_input()) - .map_err(|_| BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed)?; - let expected = paths.iter().cloned().collect::>(); - if expected.is_empty() - || expected.len() != paths.len() - || expected.iter().any(|path| { - validate_relative_path(path).is_err() || !self.permitted_paths.contains(path) - }) - { - return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); - } - Some(expected) - } else { - None - }; - validate_observation_settlement( - &value, - candidate.kind, - candidate.basis_digest, - candidate.external_evidence_digest, - expected_paths.as_ref(), + let result = encode_observation_settlement( + "outcomeUnknown", + grant.request().basis_digest, + external_evidence_digest, + &[], + Some("outcome-unknown"), )?; - let expected_evidence = schema_admission_evidence( - candidate.settlement_schema_digest, - &candidate.canonical_result_bytes, + if u64::try_from(result.len()).unwrap_or(u64::MAX) + > grant.request().budget.max_settlement_bytes + { + return Err(BoundedWorkspaceObservationErrorV1::SettlementBudgetExceeded); + } + let schema_admission_evidence_digest = + schema_admission_evidence(self.profile.settlement_schema_digest, &result); + let candidate = ExternalActionSettlementCandidateV1::new( + grant.request().request_id(), + grant.claim().attempt_id, + self.profile.adapter_id, + ExternalActionSettlementKindV1::OutcomeUnknown, + self.profile.settlement_schema_digest, + grant.request().basis_digest, + result, + schema_admission_evidence_digest, + external_evidence_digest, ); - if candidate.schema_admission_evidence_digest != expected_evidence { + validate_observation_candidate(self.profile, None, &grant, admitted, &candidate)?; + Ok(admit_external_action_settlement( + store, + coordinator, + context, + grant, + candidate, + )?) + } +} + +fn validate_observation_profile( + profile: BoundedWorkspaceObservationProfileV1, +) -> Result<(), BoundedWorkspaceObservationErrorV1> { + if profile.operation_id.as_hash() == [0; 32] + || profile.input_schema_digest == [0; 32] + || profile.settlement_schema_digest == [0; 32] + || profile.reconciliation_law_digest == [0; 32] + || profile.authority_scope_digest == [0; 32] + || profile.adapter_id.as_hash() == [0; 32] + { + return Err(BoundedWorkspaceObservationErrorV1::ProfileMismatch); + } + Ok(()) +} + +fn validate_observation_grant( + profile: BoundedWorkspaceObservationProfileV1, + grant: &ExternalActionClaimGrantV1, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), BoundedWorkspaceObservationErrorV1> { + let request = grant.request(); + if request != admitted.request() + || request.input_digest != input_identity(admitted.canonical_operation_input()) + || grant.claim().adapter_id != profile.adapter_id + { + return Err(BoundedWorkspaceObservationErrorV1::GrantMismatch); + } + if request.operation_id != profile.operation_id + || request.input_schema_digest != profile.input_schema_digest + || request.settlement_schema_digest != profile.settlement_schema_digest + || request.reconciliation_law_digest != profile.reconciliation_law_digest + || request.authority_scope_digest != profile.authority_scope_digest + { + return Err(BoundedWorkspaceObservationErrorV1::ProfileMismatch); + } + Ok(()) +} + +fn validate_observation_candidate( + profile: BoundedWorkspaceObservationProfileV1, + permitted_paths: Option<&BTreeSet>, + grant: &ExternalActionClaimGrantV1, + admitted: &AdmittedEdictExternalActionRequestV1, + candidate: &ExternalActionSettlementCandidateV1, +) -> Result<(), BoundedWorkspaceObservationErrorV1> { + if candidate.request_id != grant.request().request_id() + || candidate.attempt_id != grant.claim().attempt_id + || candidate.adapter_id != profile.adapter_id + || candidate.settlement_schema_digest != profile.settlement_schema_digest + || candidate.basis_digest != grant.request().basis_digest + || candidate.declared_result_digest + != Hash::from(blake3::hash(&candidate.canonical_result_bytes)) + { + return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); + } + let value = decode_canonical_cbor_v1(&candidate.canonical_result_bytes) + .map_err(|_| BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed)?; + let expected_paths = if candidate.kind == ExternalActionSettlementKindV1::Succeeded { + let permitted_paths = + permitted_paths.ok_or(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed)?; + let paths = decode_observation_input(admitted.canonical_operation_input()) + .map_err(|_| BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed)?; + let expected = paths.iter().cloned().collect::>(); + if expected.is_empty() + || expected.len() != paths.len() + || expected.iter().any(|path| { + validate_relative_path(path).is_err() || !permitted_paths.contains(path) + }) + { return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); } - Ok(()) + Some(expected) + } else { + None + }; + validate_observation_settlement( + &value, + candidate.kind, + candidate.basis_digest, + candidate.external_evidence_digest, + expected_paths.as_ref(), + )?; + let expected_evidence = schema_admission_evidence( + candidate.settlement_schema_digest, + &candidate.canonical_result_bytes, + ); + if candidate.schema_admission_evidence_digest != expected_evidence { + return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); } + Ok(()) } /// Encodes the operation-specific path request as canonical CBOR. diff --git a/crates/warp-core/tests/bounded_workspace_observation_tests.rs b/crates/warp-core/tests/bounded_workspace_observation_tests.rs index 6783a31b..ca0348f2 100644 --- a/crates/warp-core/tests/bounded_workspace_observation_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_observation_tests.rs @@ -1395,6 +1395,27 @@ fn outcome_unknown_settles_after_workspace_authority_disappears() { ), Err(BoundedWorkspaceObservationErrorV1::GrantMismatch) ); + + let substituted_request = admitted_request( + 62, + ["uncertain.txt".to_owned()], + digest("scope:rootless-unknown:substituted-request"), + bounded_workspace_observation_basis_v1([("uncertain.txt", bytes.as_slice())]), + 65_536, + ); + let substituted_request_grant = + must_ok(coordinator.claim_grant(admitted.request().request_id())); + assert_eq!( + reconciler.admit_outcome_unknown( + &mut store, + &mut coordinator, + context("rootless-unknown:substituted-request"), + &substituted_request, + substituted_request_grant, + digest("rootless-unknown:ambiguous"), + ), + Err(BoundedWorkspaceObservationErrorV1::GrantMismatch) + ); assert_eq!(store.read_commits().len(), 2); let grant = must_ok(coordinator.claim_grant(admitted.request().request_id())); From 9d1f7f5931949a6a114026d074fc424f79c08f03 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 00:50:53 -0700 Subject: [PATCH 3/7] docs: define rootless observation reconciliation --- docs/adr/0026-durable-external-action-settlement.md | 11 +++++++++++ docs/topics/ExternalActions.md | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docs/adr/0026-durable-external-action-settlement.md b/docs/adr/0026-durable-external-action-settlement.md index 43595cc3..4cf82481 100644 --- a/docs/adr/0026-durable-external-action-settlement.md +++ b/docs/adr/0026-durable-external-action-settlement.md @@ -94,6 +94,14 @@ The four terminal settlement kinds are: cannot establish whether the effect occurred. The named reconciliation law and request-stable idempotency key remain available for a later explicit decision. +An operation profile may retain a reconciliation handle that carries its exact +schema and adapter identity but no external-world capability. The bounded +workspace profile uses such a handle to admit `OutcomeUnknown` after directory +authority disappears. It revalidates the recovered grant and compiler-admitted +request, constructs the canonical operation-specific settlement inside Echo, +and admits it through the ordinary settlement transaction. It cannot observe a +path or construct a successful result. + A settlement binds the exact request, attempt, adapter, basis, settlement schema, canonical result bytes, result digest, schema-admission evidence, and external evidence. Echo rejects mismatched claims, stale bases, wrong schemas, @@ -203,6 +211,9 @@ invoking adapter execution. - Arbitrary recovered reports remain observation-only; trusted local recovery owns transition and replay authority. - Crash ambiguity has an explicit causal representation. +- Workspace loss after a bounded-observation claim cannot strand the action: + rootless reconciliation may durably record explicit uncertainty without + reacquiring read authority. - Operation-specific idempotency and reconciliation laws remain mandatory; Echo does not claim general exactly-once external execution. - The first capability-rooted read-only workspace adapter is implemented. diff --git a/docs/topics/ExternalActions.md b/docs/topics/ExternalActions.md index e0c5707b..5529c3d4 100644 --- a/docs/topics/ExternalActions.md +++ b/docs/topics/ExternalActions.md @@ -91,6 +91,14 @@ Path policy, stale basis, and settlement-budget failures are typed rejections. Definite host I/O failure is `Failed`. Reconciliation may admit `OutcomeUnknown` with explicit nonzero evidence. +`BoundedWorkspaceObservationReconcilerV1` retains only the exact runtime-owned +profile. It has no directory capability and cannot observe a path. After claim +recovery, it may construct, independently validate, and durably admit only the +profile's `OutcomeUnknown` settlement. The recovered grant, admitted compiler +request, adapter identity, schema, scope, basis, budget, and nonzero external +evidence must still match. Workspace loss therefore cannot strand a claim or +grant renewed read authority. + Before generic WAL admission, the operation profile independently validates: - candidate request, attempt, adapter, schema, basis, and result digest; @@ -122,6 +130,11 @@ Recovery reconstructs `Requested`, `Claimed`, or the exact settled outcome through the generic external-action coordinator. A recovered claim is a reconciliation obligation, not permission to reread the workspace. +If workspace authority is unavailable after claim recovery, the rootless +bounded-observation reconciler may admit explicit uncertainty. It cannot +produce a successful observation, and substituted profiles, grants, requests, +or evidence fail before another WAL commit. + Settled replay returns the canonical bytes retained in the WAL. It does not open the capability directory again. Removing or mutating source files after settlement therefore cannot change replay. From 09c367004351c33fd1f141c7dfaab1b37a761666 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 00:51:03 -0700 Subject: [PATCH 4/7] docs: record rootless observation uncertainty --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc13be1..b937f819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ ### Added +- Bounded workspace claims can now settle as `OutcomeUnknown` after directory + authority disappears. A rootless reconciliation handle retains only the + exact runtime-owned profile, revalidates the durable grant and + compiler-admitted request, and constructs the schema-bound settlement inside + Echo. It cannot observe files or construct success, while zero evidence and + substituted profiles, grants, or requests fail before another WAL commit. - Settled external-action candidates can now be reconciled idempotently after acknowledgement loss without a WAL store, transition context, or claim grant. An exact retained candidate returns the original admitted settlement From 8e5f74dcb4caa99ddfdcc5269034755f5a3c0184 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 01:07:31 -0700 Subject: [PATCH 5/7] test: bind settlements to claim commits --- .../bounded_workspace_observation_tests.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/warp-core/tests/bounded_workspace_observation_tests.rs b/crates/warp-core/tests/bounded_workspace_observation_tests.rs index ca0348f2..b63eb21e 100644 --- a/crates/warp-core/tests/bounded_workspace_observation_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_observation_tests.rs @@ -1434,6 +1434,82 @@ fn outcome_unknown_settles_after_workspace_authority_disappears() { assert_eq!(store.read_commits().len(), 3); } +#[test] +fn settlement_refuses_a_grant_from_a_distinct_claim_commit() { + let admitted = admitted_request( + 62, + ["uncertain.txt".to_owned()], + digest("scope:claim-commit-binding"), + digest("basis:claim-commit-binding"), + 65_536, + ); + let runtime_profile = profile(&admitted, "bounded-observation:claim-commit-binding"); + let reconciler = must_ok(BoundedWorkspaceObservationReconcilerV1::new( + runtime_profile, + )); + let request = admitted.request(); + + let mut initial_store = store(); + let mut initial_coordinator = must_ok(ExternalActionCoordinatorV1::recover(&initial_store)); + must_ok(record_external_action_request( + &mut initial_store, + &mut initial_coordinator, + context("claim-commit-binding:request"), + request, + )); + + let mut left_store = initial_store.clone(); + let mut right_store = initial_store; + let mut left_coordinator = must_ok(ExternalActionCoordinatorV1::recover(&left_store)); + let mut right_coordinator = must_ok(ExternalActionCoordinatorV1::recover(&right_store)); + let registry = ExternalActionAdapterRegistryV1::new([reconciler.adapter_binding()]); + let authorization = + must_ok(registry.authorize(&request, reconciler.adapter_binding().adapter_id)); + let left_recorded = must_ok(left_coordinator.recorded_request(request.request_id())); + let left_grant = must_ok(claim_external_action( + &mut left_store, + &mut left_coordinator, + context("claim-commit-binding:left"), + left_recorded, + authorization, + request.basis_digest, + 0, + digest("claim-commit-binding:lease"), + )); + let right_recorded = must_ok(right_coordinator.recorded_request(request.request_id())); + let right_grant = must_ok(claim_external_action( + &mut right_store, + &mut right_coordinator, + context("claim-commit-binding:right"), + right_recorded, + authorization, + request.basis_digest, + 0, + digest("claim-commit-binding:lease"), + )); + assert_eq!(left_grant.request(), right_grant.request()); + assert_eq!(left_grant.claim(), right_grant.claim()); + assert_ne!( + left_grant.claim_commit_digest(), + right_grant.claim_commit_digest() + ); + + assert_eq!( + reconciler.admit_outcome_unknown( + &mut right_store, + &mut right_coordinator, + context("claim-commit-binding:settlement"), + &admitted, + left_grant, + digest("claim-commit-binding:ambiguous"), + ), + Err(BoundedWorkspaceObservationErrorV1::Protocol( + ExternalActionProtocolErrorV1::SettlementClaimMismatch + )) + ); + assert_eq!(right_store.read_commits().len(), 2); +} + #[test] fn settled_replay_uses_wal_bytes_after_the_source_disappears() { let root = TempRoot::new("replay"); From a089108646bff50cb93dd1bb3ba9d531e7bb45f5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 01:10:01 -0700 Subject: [PATCH 6/7] fix: bind settlements to durable claims --- crates/warp-core/src/external_action.rs | 5 +- .../warp-core/src/external_action_adapter.rs | 138 +++++++++--------- 2 files changed, 70 insertions(+), 73 deletions(-) diff --git a/crates/warp-core/src/external_action.rs b/crates/warp-core/src/external_action.rs index 1095a8a8..41cc0e3e 100644 --- a/crates/warp-core/src/external_action.rs +++ b/crates/warp-core/src/external_action.rs @@ -1318,7 +1318,10 @@ pub fn admit_external_action_settlement( let recovered_claim = recovered .claim .ok_or(ExternalActionProtocolErrorV1::MissingClaim)?; - if recovered.request != claim_grant.request || recovered_claim != claim_grant.claim { + if recovered.request != claim_grant.request + || recovered_claim != claim_grant.claim + || recovered.claim_commit_digest != Some(claim_grant.claim_commit_digest) + { return Err(ExternalActionProtocolErrorV1::SettlementClaimMismatch); } if recovered.settlement.is_some() { diff --git a/crates/warp-core/src/external_action_adapter.rs b/crates/warp-core/src/external_action_adapter.rs index b9e1a7a6..f67f23b3 100644 --- a/crates/warp-core/src/external_action_adapter.rs +++ b/crates/warp-core/src/external_action_adapter.rs @@ -362,11 +362,7 @@ impl BoundedWorkspaceObservationAdapterV1 { /// Returns the runtime registry binding for this attenuated adapter. #[must_use] pub const fn adapter_binding(&self) -> ExternalActionAdapterBindingV1 { - ExternalActionAdapterBindingV1 { - adapter_id: self.profile.adapter_id, - operation_id: self.profile.operation_id, - authority_scope_digest: self.profile.authority_scope_digest, - } + adapter_binding_for(self.profile) } /// Performs one bounded observation after request and claim durability. @@ -465,22 +461,7 @@ impl BoundedWorkspaceObservationAdapterV1 { external_evidence_digest: Hash, ) -> Result { self.validate_grant(grant, admitted)?; - if external_evidence_digest == [0; 32] { - return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); - } - let result = encode_observation_settlement( - "outcomeUnknown", - grant.request().basis_digest, - external_evidence_digest, - &[], - Some("outcome-unknown"), - )?; - self.candidate( - grant, - ExternalActionSettlementKindV1::OutcomeUnknown, - result, - external_evidence_digest, - ) + build_outcome_unknown_candidate(self.profile, grant, external_evidence_digest) } /// Validates the operation-specific schema and durably admits the settlement. @@ -634,24 +615,7 @@ impl BoundedWorkspaceObservationAdapterV1 { result: Vec, external_evidence_digest: Hash, ) -> Result { - if u64::try_from(result.len()).unwrap_or(u64::MAX) - > grant.request().budget.max_settlement_bytes - { - return Err(BoundedWorkspaceObservationErrorV1::SettlementBudgetExceeded); - } - let schema_admission_evidence_digest = - schema_admission_evidence(self.profile.settlement_schema_digest, &result); - Ok(ExternalActionSettlementCandidateV1::new( - grant.request().request_id(), - grant.claim().attempt_id, - self.profile.adapter_id, - kind, - self.profile.settlement_schema_digest, - grant.request().basis_digest, - result, - schema_admission_evidence_digest, - external_evidence_digest, - )) + build_observation_candidate(self.profile, grant, kind, result, external_evidence_digest) } fn validate_candidate( @@ -682,11 +646,7 @@ impl BoundedWorkspaceObservationReconcilerV1 { /// Returns the registry binding for the exact retained adapter identity. #[must_use] pub const fn adapter_binding(&self) -> ExternalActionAdapterBindingV1 { - ExternalActionAdapterBindingV1 { - adapter_id: self.profile.adapter_id, - operation_id: self.profile.operation_id, - authority_scope_digest: self.profile.authority_scope_digest, - } + adapter_binding_for(self.profile) } /// Durably admits explicit uncertainty without reopening the external world. @@ -701,34 +661,8 @@ impl BoundedWorkspaceObservationReconcilerV1 { external_evidence_digest: Hash, ) -> Result { validate_observation_grant(self.profile, &grant, admitted)?; - if external_evidence_digest == [0; 32] { - return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); - } - let result = encode_observation_settlement( - "outcomeUnknown", - grant.request().basis_digest, - external_evidence_digest, - &[], - Some("outcome-unknown"), - )?; - if u64::try_from(result.len()).unwrap_or(u64::MAX) - > grant.request().budget.max_settlement_bytes - { - return Err(BoundedWorkspaceObservationErrorV1::SettlementBudgetExceeded); - } - let schema_admission_evidence_digest = - schema_admission_evidence(self.profile.settlement_schema_digest, &result); - let candidate = ExternalActionSettlementCandidateV1::new( - grant.request().request_id(), - grant.claim().attempt_id, - self.profile.adapter_id, - ExternalActionSettlementKindV1::OutcomeUnknown, - self.profile.settlement_schema_digest, - grant.request().basis_digest, - result, - schema_admission_evidence_digest, - external_evidence_digest, - ); + let candidate = + build_outcome_unknown_candidate(self.profile, &grant, external_evidence_digest)?; validate_observation_candidate(self.profile, None, &grant, admitted, &candidate)?; Ok(admit_external_action_settlement( store, @@ -740,6 +674,66 @@ impl BoundedWorkspaceObservationReconcilerV1 { } } +const fn adapter_binding_for( + profile: BoundedWorkspaceObservationProfileV1, +) -> ExternalActionAdapterBindingV1 { + ExternalActionAdapterBindingV1 { + adapter_id: profile.adapter_id, + operation_id: profile.operation_id, + authority_scope_digest: profile.authority_scope_digest, + } +} + +fn build_outcome_unknown_candidate( + profile: BoundedWorkspaceObservationProfileV1, + grant: &ExternalActionClaimGrantV1, + external_evidence_digest: Hash, +) -> Result { + if external_evidence_digest == [0; 32] { + return Err(BoundedWorkspaceObservationErrorV1::SchemaAdmissionFailed); + } + let result = encode_observation_settlement( + "outcomeUnknown", + grant.request().basis_digest, + external_evidence_digest, + &[], + Some("outcome-unknown"), + )?; + build_observation_candidate( + profile, + grant, + ExternalActionSettlementKindV1::OutcomeUnknown, + result, + external_evidence_digest, + ) +} + +fn build_observation_candidate( + profile: BoundedWorkspaceObservationProfileV1, + grant: &ExternalActionClaimGrantV1, + kind: ExternalActionSettlementKindV1, + result: Vec, + external_evidence_digest: Hash, +) -> Result { + if u64::try_from(result.len()).unwrap_or(u64::MAX) > grant.request().budget.max_settlement_bytes + { + return Err(BoundedWorkspaceObservationErrorV1::SettlementBudgetExceeded); + } + let schema_admission_evidence_digest = + schema_admission_evidence(profile.settlement_schema_digest, &result); + Ok(ExternalActionSettlementCandidateV1::new( + grant.request().request_id(), + grant.claim().attempt_id, + profile.adapter_id, + kind, + profile.settlement_schema_digest, + grant.request().basis_digest, + result, + schema_admission_evidence_digest, + external_evidence_digest, + )) +} + fn validate_observation_profile( profile: BoundedWorkspaceObservationProfileV1, ) -> Result<(), BoundedWorkspaceObservationErrorV1> { From 491dbc1decb9f7c7d6c90e81a6550a39b179ea3c Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 01:10:34 -0700 Subject: [PATCH 7/7] docs: clarify observation uncertainty evidence --- CHANGELOG.md | 9 +++++---- docs/topics/ExternalActions.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b937f819..95831b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ - Bounded workspace claims can now settle as `OutcomeUnknown` after directory authority disappears. A rootless reconciliation handle retains only the - exact runtime-owned profile, revalidates the durable grant and - compiler-admitted request, and constructs the schema-bound settlement inside - Echo. It cannot observe files or construct success, while zero evidence and - substituted profiles, grants, or requests fail before another WAL commit. + exact runtime-owned profile, revalidates the durable grant's exact claim + commit and compiler-admitted request, and constructs the schema-bound + settlement inside Echo. It cannot observe files or construct success, while + zero evidence and substituted profiles, grants, or requests fail before + another WAL commit. - Settled external-action candidates can now be reconciled idempotently after acknowledgement loss without a WAL store, transition context, or claim grant. An exact retained candidate returns the original admitted settlement diff --git a/docs/topics/ExternalActions.md b/docs/topics/ExternalActions.md index 5529c3d4..b482fc14 100644 --- a/docs/topics/ExternalActions.md +++ b/docs/topics/ExternalActions.md @@ -132,8 +132,8 @@ reconciliation obligation, not permission to reread the workspace. If workspace authority is unavailable after claim recovery, the rootless bounded-observation reconciler may admit explicit uncertainty. It cannot -produce a successful observation, and substituted profiles, grants, requests, -or evidence fail before another WAL commit. +produce a successful observation. Substituted profiles, grants, or requests +fail before another WAL commit, as does omitted zero-valued evidence. Settled replay returns the canonical bytes retained in the WAL. It does not open the capability directory again. Removing or mutating source files after