From e15f61e7e2915207adb09c821c7ca5ffe1c7f9d4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:30:14 -0700 Subject: [PATCH 01/14] test: admit validated workspace patch requests --- .../tests/bounded_workspace_patch_tests.rs | 86 ++++++++++++++++++ .../fixtures/external_action_patch/SOURCE.md | 20 ++++ .../apply-validated-patch.core.cbor | Bin 0 -> 2820 bytes .../apply-validated-patch.core.sha256 | 1 + .../apply-validated-patch.target-ir.cbor | Bin 0 -> 2252 bytes .../apply-validated-patch.target-ir.sha256 | 1 + 6 files changed, 108 insertions(+) create mode 100644 crates/warp-core/tests/bounded_workspace_patch_tests.rs create mode 100644 crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md create mode 100644 crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.core.cbor create mode 100644 crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.core.sha256 create mode 100644 crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.target-ir.cbor create mode 100644 crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.target-ir.sha256 diff --git a/crates/warp-core/tests/bounded_workspace_patch_tests.rs b/crates/warp-core/tests/bounded_workspace_patch_tests.rs new file mode 100644 index 00000000..e4a976c4 --- /dev/null +++ b/crates/warp-core/tests/bounded_workspace_patch_tests.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! RED contract for compiler-authored basis-bound workspace patches. + +#![allow(clippy::panic)] + +use echo_edict_canonical::{encode_canonical_cbor_v1, CanonicalValueV1}; +use warp_core::external_action_adapter::admit_edict_external_action_request_v1; +use warp_core::{Hash, WorldlineId}; + +const CORE_BYTES: &[u8] = + include_bytes!("fixtures/external_action_patch/apply-validated-patch.core.cbor"); +const TARGET_IR_BYTES: &[u8] = + include_bytes!("fixtures/external_action_patch/apply-validated-patch.target-ir.cbor"); +const CORE_DIGEST: &str = + include_str!("fixtures/external_action_patch/apply-validated-patch.core.sha256"); +const TARGET_IR_DIGEST: &str = + include_str!("fixtures/external_action_patch/apply-validated-patch.target-ir.sha256"); + +fn digest(label: &str) -> Hash { + blake3::hash(label.as_bytes()).into() +} + +fn must_ok(result: Result) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("expected Ok(..), got {error:?}"), + } +} + +fn text(value: &str) -> CanonicalValueV1 { + CanonicalValueV1::Text(value.to_owned()) +} + +fn map(entries: impl IntoIterator) -> CanonicalValueV1 { + CanonicalValueV1::Map( + entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect(), + ) +} + +fn application_input( + patch: Vec, + authority: Hash, + basis: Hash, + max_settlement_bytes: u64, +) -> Vec { + must_ok(encode_canonical_cbor_v1(&map([ + ("patch", CanonicalValueV1::Bytes(patch)), + ("authority", CanonicalValueV1::Bytes(authority.to_vec())), + ("basis", CanonicalValueV1::Bytes(basis.to_vec())), + ( + "maxSettlementBytes", + CanonicalValueV1::Integer(i128::from(max_settlement_bytes)), + ), + ("maxAttempts", CanonicalValueV1::Integer(1)), + ]))) +} + +#[test] +fn exact_compiler_artifacts_admit_one_noncallable_patch_request() { + let authority = digest("authority:exact"); + let basis = digest("basis:exact"); + let patch = must_ok(encode_canonical_cbor_v1(&map([]))); + let admitted = must_ok(admit_edict_external_action_request_v1( + WorldlineId::from_bytes([23; 32]), + CORE_BYTES, + TARGET_IR_BYTES, + "applyValidated", + &application_input(patch.clone(), authority, basis, 65_536), + )); + + assert_eq!(admitted.source_core_digest(), CORE_DIGEST.trim()); + assert_eq!(admitted.target_ir_digest(), TARGET_IR_DIGEST.trim()); + assert_eq!( + admitted.operation_coordinate(), + "workspace.patch.applyValidated@1" + ); + assert_eq!(admitted.canonical_operation_input(), patch); + assert_eq!(admitted.request().authority_scope_digest, authority); + assert_eq!(admitted.request().basis_digest, basis); + assert_eq!(admitted.request().budget.max_settlement_bytes, 65_536); + assert_eq!(admitted.request().budget.max_attempts, 1); +} diff --git a/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md b/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md new file mode 100644 index 00000000..f57d1380 --- /dev/null +++ b/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md @@ -0,0 +1,20 @@ + + + +# Compiler-Owned Workspace-Patch Fixture + +These bytes were copied without modification from Edict merge commit +`cf8c17f917b7262be2c89fa136898e01dab7f40a`: + +- `fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor` +- `fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor` + +Edict owns regeneration through: + +```sh +cargo xtask lawpack-goldens --write +``` + +Echo treats the files as received compiler artifacts. It independently checks +canonical encoding, the reviewed source-Core digest, the exact capability +closure, request-only shape, runtime request fields, and target identity. diff --git a/crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.core.cbor b/crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.core.cbor new file mode 100644 index 0000000000000000000000000000000000000000..b602428413e1cd4cd5f4bbad5d3bd7ebe7dc0dff GIT binary patch literal 2820 zcmd5;&rcIU6khRS{0sPX0E6)&D=p9y5(@EPLV$o0Pnzk@+wIWZ+3CzI-6m>~#OT$d z7ZciuH)A{+{~0g-9ln{;Ze2E#8VT;9>F&IF@6CJP_q{m<-4>uuhFRVOX~X!LaIJOjVPwlGNdFDAt<*M%Z3ufgJ;6*1i4mnbBb zv^nURYZG59`IH5q^s%q%q`X{tGV|hgqqOz2^z=?$PJeS}-_Z}x-@JVNeeCAf9~1kV zUqYKp5Rw*lzA z1@-E)%`bsegXAQvdYZ+27n%tkvWMU8^r%#iC!;@fUx39uKEvt!Jf3r}MSQtO^S9_JXG=akz31mCg2ZleBD0r}9DiW>65Lb-|;u468I^ zN+EE#vduM$$RtqTVon|ZQzg74V)znN66%3+Ujp}rH2nV<7NdyWU_ z-iW!7#olbTuZ aV!WU-jG0Bil%Itgp+Z}h_%s4a8-GxdGM7tFT z(IrSBB1l9!MTagOLI^54c7#z_Rw zfiZd_QFJq2?y z?#J>5hKmODKtu|+`uqBB_74tLkz_httfN?%-ANThmXbwDE%Xt=wNXf&T@ex#u{kD` z2q}ZYRbOyJc^pTcEG?C@cL~ml2*LP5!R>j%Vz+TP4p5hkEJB*J6}Fs9aJ;`88_ea< zwkDbqi8)+LF$)mII9$G%NX(Z@O}R?%l?v5MnJEMZoN#6+O>cGgP(O8f_>jY`pCNpD~OgZ4+Ax#aj>a{78!wSDxi zJ`Y|!TzS0wsp-=8@0O{tErynf$8QqNDUzBYq$~my2g^gNoj=#+XWpmo zjHUb+U)vAP3ypn)bHE>ALdOa=vW(<$BIfjx4Lw&=T%8Q3HR!AsvGQRAXNlvh?WF1Z zU;C3ywv~LA+P${)ng8ASu~Pj0?!(UZ)8>U=3tuiwf9qyGktB_p7z!oGXK=<$wS*BN N6gml-a^b{6{sFCMj2Hj_ literal 0 HcmV?d00001 diff --git a/crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.target-ir.sha256 b/crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.target-ir.sha256 new file mode 100644 index 00000000..362e37cc --- /dev/null +++ b/crates/warp-core/tests/fixtures/external_action_patch/apply-validated-patch.target-ir.sha256 @@ -0,0 +1 @@ +sha256:7aa0f5e9f1091a9b73d36a8a90bd071fc605bfd457b4722f69331a0207911c17 From 5c59deb9938584ee86fa9c2a25ebd59d5fa1211f Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:30:59 -0700 Subject: [PATCH 02/14] feat: admit request-only external actions --- crates/warp-core/src/external_action_adapter.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/warp-core/src/external_action_adapter.rs b/crates/warp-core/src/external_action_adapter.rs index f67f23b3..4cfa373e 100644 --- a/crates/warp-core/src/external_action_adapter.rs +++ b/crates/warp-core/src/external_action_adapter.rs @@ -36,7 +36,8 @@ const CORE_DIGEST_DOMAIN: &str = "edict.core.module/v1"; const TARGET_IR_DIGEST_DOMAIN: &str = "edict.target-ir.artifact/v1"; const ECHO_TARGET_IR_DOMAIN: &str = "echo.span-ir/v1"; const ECHO_TARGET_PROFILE_COORDINATE: &str = "echo.dpo@1"; -const EXTERNAL_REQUEST_OPERATION_PROFILE: &str = "continuum.profile.read-only/v1"; +const COMPATIBILITY_READ_ONLY_REQUEST_PROFILE: &str = "continuum.profile.read-only/v1"; +const EXTERNAL_REQUEST_OPERATION_PROFILE: &str = "continuum.profile.request-only/v1"; const RESOURCE_ID_DOMAIN: &[u8] = b"echo.external-action.resource-id/v1"; const TARGET_OPERATION_ID_DOMAIN: &[u8] = b"echo.external-action.target-operation-id/v1"; const INPUT_DIGEST_DOMAIN: &[u8] = b"echo.external-action.input/v1"; @@ -202,11 +203,12 @@ pub fn admit_edict_external_action_request_v1( let intent = map_field(intents, intent_name) .ok_or(EdictExternalActionAdmissionErrorV1::MissingIntent)?; let intent_map = expect_map(intent)?; - require_text_field( - intent_map, - "operationProfile", - EXTERNAL_REQUEST_OPERATION_PROFILE, - )?; + let operation_profile = require_nonempty_text(intent_map, "operationProfile")?; + if operation_profile != EXTERNAL_REQUEST_OPERATION_PROFILE + && operation_profile != COMPATIBILITY_READ_ONLY_REQUEST_PROFILE + { + return Err(EdictExternalActionAdmissionErrorV1::ArtifactShape); + } if !expect_array(require_field(intent_map, "inputConstraints")?)?.is_empty() || !expect_array(require_field(intent_map, "requirements")?)?.is_empty() { From 41ef9e945258e620abc81f7cb70c75c4fbf9a3cb Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:49:25 -0700 Subject: [PATCH 03/14] feat: execute basis-bound workspace patches --- crates/warp-core/src/lib.rs | 2 + .../src/validated_workspace_patch.rs | 1204 +++++++++++++++++ .../tests/bounded_workspace_patch_tests.rs | 734 +++++++++- 3 files changed, 1938 insertions(+), 2 deletions(-) create mode 100644 crates/warp-core/src/validated_workspace_patch.rs diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index 143eec82..ade5e6b7 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -179,6 +179,8 @@ mod tick_patch; #[cfg(all(feature = "native_rule_bootstrap", feature = "trusted_runtime"))] mod trusted_runtime_host; mod tx; +#[cfg(not(target_arch = "wasm32"))] +pub mod validated_workspace_patch; mod warp_state; mod witness; mod witnessed_suffix; diff --git a/crates/warp-core/src/validated_workspace_patch.rs b/crates/warp-core/src/validated_workspace_patch.rs new file mode 100644 index 00000000..75efbb53 --- /dev/null +++ b/crates/warp-core/src/validated_workspace_patch.rs @@ -0,0 +1,1204 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Basis-bound, capability-rooted application of compiler-authored patches. +//! +//! Edict contributes canonical request data but receives no filesystem +//! authority. Echo durably records and claims the request before this adapter +//! can validate or mutate one exact regular file. Ambiguous attempts are +//! reconciled by observation and are never blindly reapplied. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::{Component, Path}; + +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt}; +use cap_std::{ + ambient_authority, + fs::{Dir, Metadata, OpenOptions}, +}; +use echo_edict_canonical::{decode_canonical_cbor_v1, encode_canonical_cbor_v1, CanonicalValueV1}; +use thiserror::Error; + +use crate::causal_wal::WalStorePort; +use crate::external_action::{ + admit_external_action_settlement, AdmittedExternalActionSettlementV1, + ExternalActionAdapterBindingV1, ExternalActionAdapterIdV1, ExternalActionClaimGrantV1, + ExternalActionCoordinatorV1, ExternalActionOperationIdV1, ExternalActionProtocolErrorV1, + ExternalActionSettlementCandidateV1, ExternalActionSettlementKindV1, + ExternalActionTransactionContextV1, +}; +use crate::external_action_adapter::{ + bounded_workspace_observation_basis_v1, AdmittedEdictExternalActionRequestV1, +}; +use crate::Hash; + +const PATCH_INPUT_KIND: &str = "validatedWorkspacePatchInput"; +const PATCH_SETTLEMENT_KIND: &str = "validatedWorkspacePatchSettlement"; +const PATCH_AUTHORITY_DOMAIN: &[u8] = b"echo.validated-workspace-patch.authority/v1"; +const PATCH_SCHEMA_EVIDENCE_DOMAIN: &[u8] = b"echo.validated-workspace-patch.schema-evidence/v1"; +const PATCH_EXTERNAL_EVIDENCE_DOMAIN: &[u8] = + b"echo.validated-workspace-patch.external-evidence/v1"; +const EXTERNAL_ACTION_INPUT_DOMAIN: &[u8] = b"echo.external-action.input/v1"; +const MAX_CANONICAL_PATCH_INPUT_BYTES: u64 = 65_536; + +/// Runtime-owner profile binding one patch adapter to compiler identities. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ValidatedWorkspacePatchProfileV1 { + /// Exact compiler-declared operation family. + pub operation_id: ExternalActionOperationIdV1, + /// Exact compiler-declared input schema. + pub input_schema_digest: Hash, + /// Exact compiler-declared settlement schema. + pub settlement_schema_digest: Hash, + /// Exact compiler-declared reconciliation law. + pub reconciliation_law_digest: Hash, + /// Exact writable-path policy delegated by the runtime owner. + pub authority_scope_digest: Hash, + /// Runtime-owned adapter identity. + pub adapter_id: ExternalActionAdapterIdV1, + /// Maximum existing or replacement file size admitted by this adapter. + pub max_file_bytes: u64, +} + +/// Capability-rooted adapter that can replace one exact regular file. +pub struct ValidatedWorkspacePatchAdapterV1 { + root: Dir, + permitted_paths: BTreeSet, + profile: ValidatedWorkspacePatchProfileV1, +} + +/// Read-only reconciliation handle for a claimed patch attempt. +pub struct ValidatedWorkspacePatchReconcilerV1 { + root: Dir, + permitted_paths: BTreeSet, + profile: ValidatedWorkspacePatchProfileV1, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ValidatedPatchInputV1 { + path: String, + expected_content_digest: Hash, + replacement: Vec, + replacement_digest: Hash, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SettlementEvidenceV1 { + posture: &'static str, + path: Option, + request_basis: Hash, + evidence: Hash, + before_content_digest: Option, + after_content_digest: Option, + resulting_basis: Option, + obstruction: Option, +} + +impl ValidatedWorkspacePatchAdapterV1 { + /// Opens one workspace root and retains only exact path-relative authority. + pub fn open( + root: &Path, + permitted_paths: impl IntoIterator, + profile: ValidatedWorkspacePatchProfileV1, + ) -> Result { + validate_profile(profile)?; + let permitted_paths = validate_permitted_paths(permitted_paths)?; + if validated_workspace_patch_authority_v1(permitted_paths.iter().map(String::as_str)) + != profile.authority_scope_digest + { + return Err(ValidatedWorkspacePatchErrorV1::ProfileMismatch); + } + let root = Dir::open_ambient_dir(root, ambient_authority()) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + Ok(Self { + root, + permitted_paths, + profile, + }) + } + + /// Returns the registry binding for this exact attenuated adapter. + #[must_use] + pub const fn adapter_binding(&self) -> ExternalActionAdapterBindingV1 { + adapter_binding_for(self.profile) + } + + /// Validates and applies one basis-bound patch after request and claim durability. + pub fn apply( + &self, + grant: &ExternalActionClaimGrantV1, + admitted: &AdmittedEdictExternalActionRequestV1, + ) -> Result { + validate_grant(self.profile, grant, admitted)?; + let input = match decode_patch_input(admitted.canonical_operation_input()) { + Ok(input) => input, + Err(ValidatedWorkspacePatchErrorV1::Canonical) => { + return self.obstruction(grant, None, "malformed-input", None); + } + Err(error) => return Err(error), + }; + if let Some(code) = validate_requested_path(&input.path, &self.permitted_paths) { + return self.obstruction(grant, Some(&input.path), code, None); + } + if u64::try_from(input.replacement.len()).unwrap_or(u64::MAX) > self.profile.max_file_bytes + { + return self.obstruction( + grant, + Some(&input.path), + "replacement-budget-exceeded", + None, + ); + } + + let (parent, file_name) = match open_parent_nofollow(&self.root, &input.path) { + Ok(value) => value, + Err(error) => return self.filesystem_obstruction(grant, &input.path, error), + }; + let (before, metadata) = + match read_regular_file(&parent, &file_name, self.profile.max_file_bytes) { + Ok(value) => value, + Err(error) => return self.filesystem_obstruction(grant, &input.path, error), + }; + let before_digest: Hash = blake3::hash(&before).into(); + let observed_basis = validated_workspace_patch_basis_v1(&input.path, &before); + if before_digest != input.expected_content_digest + || observed_basis != grant.request().basis_digest + { + return self.obstruction( + grant, + Some(&input.path), + "stale-basis", + Some((observed_basis, before_digest)), + ); + } + + let temp_name = temporary_name(&file_name, grant); + if let Err(error) = stage_replacement(&parent, &temp_name, &input.replacement, &metadata) { + return self.filesystem_obstruction(grant, &input.path, error); + } + + let pre_rename = read_regular_file(&parent, &file_name, self.profile.max_file_bytes); + let pre_rename_matches = pre_rename + .as_ref() + .is_ok_and(|(bytes, _)| bytes.as_slice() == before.as_slice()); + if !pre_rename_matches { + let _ = parent.remove_file(&temp_name); + return self.obstruction( + grant, + Some(&input.path), + "stale-basis", + Some((observed_basis, before_digest)), + ); + } + + if parent.rename(&temp_name, &parent, &file_name).is_err() { + let _ = parent.remove_file(&temp_name); + return self.outcome_unknown(grant, Some(&input.path), "rename-outcome-unknown", None); + } + if sync_directory(&parent).is_err() { + return self.outcome_unknown( + grant, + Some(&input.path), + "directory-sync-outcome-unknown", + None, + ); + } + + let (after, _) = match read_regular_file(&parent, &file_name, self.profile.max_file_bytes) { + Ok(value) => value, + Err(_) => { + return self.outcome_unknown( + grant, + Some(&input.path), + "postcondition-unreadable", + None, + ); + } + }; + let after_digest: Hash = blake3::hash(&after).into(); + let resulting_basis = validated_workspace_patch_basis_v1(&input.path, &after); + if after_digest != input.replacement_digest || after != input.replacement { + return self.outcome_unknown( + grant, + Some(&input.path), + "postcondition-mismatch", + Some((resulting_basis, after_digest)), + ); + } + self.candidate( + grant, + ExternalActionSettlementKindV1::Succeeded, + SettlementEvidenceV1 { + posture: "succeeded", + path: Some(input.path), + request_basis: grant.request().basis_digest, + evidence: resulting_basis, + before_content_digest: Some(before_digest), + after_content_digest: Some(after_digest), + resulting_basis: Some(resulting_basis), + obstruction: None, + }, + ) + } + + /// Validates the operation schema and durably admits the settlement. + pub fn admit_settlement( + &self, + store: &mut impl WalStorePort, + coordinator: &mut ExternalActionCoordinatorV1, + context: ExternalActionTransactionContextV1, + admitted: &AdmittedEdictExternalActionRequestV1, + grant: ExternalActionClaimGrantV1, + candidate: ExternalActionSettlementCandidateV1, + ) -> Result { + validate_grant(self.profile, &grant, admitted)?; + validate_candidate( + self.profile, + Some(&self.permitted_paths), + &grant, + admitted, + &candidate, + )?; + Ok(admit_external_action_settlement( + store, + coordinator, + context, + grant, + candidate, + )?) + } + + fn filesystem_obstruction( + &self, + grant: &ExternalActionClaimGrantV1, + path: &str, + error: ValidatedWorkspacePatchErrorV1, + ) -> Result { + let code = match error { + ValidatedWorkspacePatchErrorV1::SymlinkRefused => "symlink-refused", + ValidatedWorkspacePatchErrorV1::NotRegularFile => "not-regular-file", + ValidatedWorkspacePatchErrorV1::FileBudgetExceeded => "file-budget-exceeded", + ValidatedWorkspacePatchErrorV1::InvalidPath => "invalid-path", + _ => "io-failure", + }; + let kind = if code == "io-failure" { + ExternalActionSettlementKindV1::Failed + } else { + ExternalActionSettlementKindV1::Rejected + }; + self.candidate( + grant, + kind, + SettlementEvidenceV1 { + posture: posture_for(kind), + path: Some(path.to_owned()), + request_basis: grant.request().basis_digest, + evidence: external_evidence(code, path), + before_content_digest: None, + after_content_digest: None, + resulting_basis: None, + obstruction: Some(code.to_owned()), + }, + ) + } + + fn obstruction( + &self, + grant: &ExternalActionClaimGrantV1, + path: Option<&str>, + code: &str, + observed: Option<(Hash, Hash)>, + ) -> Result { + let (evidence, before_content_digest) = observed.map_or_else( + || (external_evidence(code, path.unwrap_or("")), None), + |(basis, digest)| (basis, Some(digest)), + ); + self.candidate( + grant, + ExternalActionSettlementKindV1::Rejected, + SettlementEvidenceV1 { + posture: "rejected", + path: path.map(str::to_owned), + request_basis: grant.request().basis_digest, + evidence, + before_content_digest, + after_content_digest: None, + resulting_basis: None, + obstruction: Some(code.to_owned()), + }, + ) + } + + fn outcome_unknown( + &self, + grant: &ExternalActionClaimGrantV1, + path: Option<&str>, + code: &str, + observed: Option<(Hash, Hash)>, + ) -> Result { + let (evidence, before_content_digest) = observed.map_or_else( + || (external_evidence(code, path.unwrap_or("")), None), + |(basis, digest)| (basis, Some(digest)), + ); + self.candidate( + grant, + ExternalActionSettlementKindV1::OutcomeUnknown, + SettlementEvidenceV1 { + posture: "outcomeUnknown", + path: path.map(str::to_owned), + request_basis: grant.request().basis_digest, + evidence, + before_content_digest, + after_content_digest: None, + resulting_basis: None, + obstruction: Some(code.to_owned()), + }, + ) + } + + fn candidate( + &self, + grant: &ExternalActionClaimGrantV1, + kind: ExternalActionSettlementKindV1, + evidence: SettlementEvidenceV1, + ) -> Result { + build_candidate(self.profile, grant, kind, evidence) + } +} + +impl ValidatedWorkspacePatchReconcilerV1 { + /// Opens a read-only reconciliation handle for one exact path policy. + pub fn open( + root: &Path, + permitted_paths: impl IntoIterator, + profile: ValidatedWorkspacePatchProfileV1, + ) -> Result { + validate_profile(profile)?; + let permitted_paths = validate_permitted_paths(permitted_paths)?; + if validated_workspace_patch_authority_v1(permitted_paths.iter().map(String::as_str)) + != profile.authority_scope_digest + { + return Err(ValidatedWorkspacePatchErrorV1::ProfileMismatch); + } + let root = Dir::open_ambient_dir(root, ambient_authority()) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + Ok(Self { + root, + permitted_paths, + profile, + }) + } + + /// Returns the registry binding for the exact retained adapter identity. + #[must_use] + pub const fn adapter_binding(&self) -> ExternalActionAdapterBindingV1 { + adapter_binding_for(self.profile) + } + + /// Observes the postcondition of an ambiguous attempt without reapplying it. + pub fn reconcile( + &self, + grant: &ExternalActionClaimGrantV1, + admitted: &AdmittedEdictExternalActionRequestV1, + ) -> Result { + validate_grant(self.profile, grant, admitted)?; + let input = match decode_patch_input(admitted.canonical_operation_input()) { + Ok(input) => input, + Err(_) => { + return build_obstruction_candidate(self.profile, grant, None, "malformed-input"); + } + }; + if let Some(code) = validate_requested_path(&input.path, &self.permitted_paths) { + return build_obstruction_candidate(self.profile, grant, Some(&input.path), code); + } + let (parent, file_name) = match open_parent_nofollow(&self.root, &input.path) { + Ok(value) => value, + Err(_) => { + return build_outcome_unknown_candidate( + self.profile, + grant, + Some(&input.path), + "postcondition-unreadable", + None, + ); + } + }; + let (bytes, _) = match read_regular_file(&parent, &file_name, self.profile.max_file_bytes) { + Ok(value) => value, + Err(_) => { + return build_outcome_unknown_candidate( + self.profile, + grant, + Some(&input.path), + "postcondition-unreadable", + None, + ); + } + }; + let observed_digest: Hash = blake3::hash(&bytes).into(); + let observed_basis = validated_workspace_patch_basis_v1(&input.path, &bytes); + if observed_digest == input.replacement_digest && bytes == input.replacement { + return build_candidate( + self.profile, + grant, + ExternalActionSettlementKindV1::Succeeded, + SettlementEvidenceV1 { + posture: "succeeded", + path: Some(input.path), + request_basis: grant.request().basis_digest, + evidence: observed_basis, + before_content_digest: Some(input.expected_content_digest), + after_content_digest: Some(observed_digest), + resulting_basis: Some(observed_basis), + obstruction: None, + }, + ); + } + build_candidate( + self.profile, + grant, + ExternalActionSettlementKindV1::OutcomeUnknown, + SettlementEvidenceV1 { + posture: "outcomeUnknown", + path: Some(input.path), + request_basis: grant.request().basis_digest, + evidence: observed_basis, + before_content_digest: Some(observed_digest), + after_content_digest: None, + resulting_basis: None, + obstruction: Some("postcondition-not-observed".to_owned()), + }, + ) + } + + /// Validates and durably admits a reconciled terminal settlement. + pub fn admit_settlement( + &self, + store: &mut impl WalStorePort, + coordinator: &mut ExternalActionCoordinatorV1, + context: ExternalActionTransactionContextV1, + admitted: &AdmittedEdictExternalActionRequestV1, + grant: ExternalActionClaimGrantV1, + candidate: ExternalActionSettlementCandidateV1, + ) -> Result { + validate_grant(self.profile, &grant, admitted)?; + validate_candidate( + self.profile, + Some(&self.permitted_paths), + &grant, + admitted, + &candidate, + )?; + Ok(admit_external_action_settlement( + store, + coordinator, + context, + grant, + candidate, + )?) + } +} + +/// Encodes one canonical, content-addressed file replacement. +pub fn encode_validated_workspace_patch_input_v1( + path: String, + expected_content_digest: Hash, + replacement: Vec, +) -> Result, ValidatedWorkspacePatchErrorV1> { + validate_relative_path(&path)?; + let replacement_digest: Hash = blake3::hash(&replacement).into(); + let bytes = encode_canonical_cbor_v1(&canonical_map([ + ("kind", CanonicalValueV1::Text(PATCH_INPUT_KIND.to_owned())), + ("path", CanonicalValueV1::Text(path)), + ( + "expectedContentDigest", + CanonicalValueV1::Bytes(expected_content_digest.to_vec()), + ), + ("replacement", CanonicalValueV1::Bytes(replacement)), + ( + "replacementDigest", + CanonicalValueV1::Bytes(replacement_digest.to_vec()), + ), + ])) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Canonical)?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_CANONICAL_PATCH_INPUT_BYTES { + return Err(ValidatedWorkspacePatchErrorV1::FileBudgetExceeded); + } + Ok(bytes) +} + +/// Commits one exact relative path and its complete file bytes. +#[must_use] +pub fn validated_workspace_patch_basis_v1(path: &str, bytes: &[u8]) -> Hash { + bounded_workspace_observation_basis_v1([(path, bytes)]) +} + +/// Commits the exact writable aperture and immutable patch safety policy. +#[must_use] +pub fn validated_workspace_patch_authority_v1<'a>( + paths: impl IntoIterator, +) -> Hash { + let mut paths = paths.into_iter().collect::>(); + paths.sort_unstable(); + paths.dedup(); + let mut hasher = blake3::Hasher::new(); + hasher.update(PATCH_AUTHORITY_DOMAIN); + hash_len_prefixed(&mut hasher, b"single-file-replace"); + hash_len_prefixed(&mut hasher, b"no-follow"); + hash_len_prefixed(&mut hasher, b"regular-file-only"); + hash_len_prefixed(&mut hasher, b"ci-workflow-forbidden"); + for path in paths { + hash_len_prefixed(&mut hasher, path.as_bytes()); + } + hasher.finalize().into() +} + +/// Stable adapter and schema failures. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ValidatedWorkspacePatchErrorV1 { + /// The canonical input or settlement encoding is invalid. + #[error("validated workspace patch canonical value is invalid")] + Canonical, + /// The durable grant does not identify the admitted compiler request. + #[error("validated workspace patch grant does not match the admitted request")] + GrantMismatch, + /// The compiler request is outside the runtime-owned adapter profile. + #[error("validated workspace patch request does not match adapter policy")] + ProfileMismatch, + /// A path is empty, absolute, non-normalized, or traverses a parent. + #[error("validated workspace patch path is invalid")] + InvalidPath, + /// A path is outside the exact attenuated writable aperture. + #[error("validated workspace patch path is unauthorized")] + UnauthorizedPath, + /// CI workflow paths are outside this operation family. + #[error("validated workspace patch refuses CI workflow paths")] + CiWorkflowPathRefused, + /// A path or traversed component is a symlink. + #[error("validated workspace patch refuses symlinks")] + SymlinkRefused, + /// The target must be one regular file. + #[error("validated workspace patch requires one regular file")] + NotRegularFile, + /// Existing or replacement bytes exceed the runtime-owned bound. + #[error("validated workspace patch file exceeds its byte budget")] + FileBudgetExceeded, + /// The candidate did not pass operation-specific settlement admission. + #[error("validated workspace patch settlement schema admission failed")] + SchemaAdmissionFailed, + /// Capability-rooted filesystem access failed. + #[error("validated workspace patch filesystem access failed")] + Io, + /// The generic durable protocol refused the transition. + #[error(transparent)] + Protocol(#[from] ExternalActionProtocolErrorV1), +} + +const fn adapter_binding_for( + profile: ValidatedWorkspacePatchProfileV1, +) -> ExternalActionAdapterBindingV1 { + ExternalActionAdapterBindingV1 { + adapter_id: profile.adapter_id, + operation_id: profile.operation_id, + authority_scope_digest: profile.authority_scope_digest, + } +} + +fn validate_profile( + profile: ValidatedWorkspacePatchProfileV1, +) -> Result<(), ValidatedWorkspacePatchErrorV1> { + 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] + || profile.max_file_bytes == 0 + || profile.max_file_bytes > MAX_CANONICAL_PATCH_INPUT_BYTES + { + return Err(ValidatedWorkspacePatchErrorV1::ProfileMismatch); + } + Ok(()) +} + +fn validate_permitted_paths( + paths: impl IntoIterator, +) -> Result, ValidatedWorkspacePatchErrorV1> { + let paths = paths.into_iter().collect::>(); + if paths.is_empty() { + return Err(ValidatedWorkspacePatchErrorV1::ProfileMismatch); + } + for path in &paths { + validate_relative_path(path)?; + if is_ci_workflow_path(path) { + return Err(ValidatedWorkspacePatchErrorV1::CiWorkflowPathRefused); + } + } + Ok(paths) +} + +fn validate_grant( + profile: ValidatedWorkspacePatchProfileV1, + grant: &ExternalActionClaimGrantV1, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), ValidatedWorkspacePatchErrorV1> { + let request = grant.request(); + if request != admitted.request() + || request.input_digest + != external_action_input_identity(admitted.canonical_operation_input()) + || grant.claim().adapter_id != profile.adapter_id + { + return Err(ValidatedWorkspacePatchErrorV1::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(ValidatedWorkspacePatchErrorV1::ProfileMismatch); + } + Ok(()) +} + +fn validate_candidate( + profile: ValidatedWorkspacePatchProfileV1, + permitted_paths: Option<&BTreeSet>, + grant: &ExternalActionClaimGrantV1, + admitted: &AdmittedEdictExternalActionRequestV1, + candidate: &ExternalActionSettlementCandidateV1, +) -> Result<(), ValidatedWorkspacePatchErrorV1> { + 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(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + let evidence = decode_settlement(&candidate.canonical_result_bytes)?; + if evidence.posture != posture_for(candidate.kind) + || evidence.request_basis != candidate.basis_digest + || evidence.evidence != candidate.external_evidence_digest + { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + if evidence.evidence == [0; 32] { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + let input = decode_patch_input(admitted.canonical_operation_input()).ok(); + if candidate.kind == ExternalActionSettlementKindV1::Succeeded { + let Some(input) = input.as_ref() else { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + }; + let Some(permitted_paths) = permitted_paths else { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + }; + let expected_resulting_basis = + validated_workspace_patch_basis_v1(&input.path, &input.replacement); + if evidence.path.as_deref() != Some(input.path.as_str()) + || !permitted_paths.contains(&input.path) + || evidence.before_content_digest != Some(input.expected_content_digest) + || evidence.after_content_digest != Some(input.replacement_digest) + || evidence.resulting_basis != Some(expected_resulting_basis) + || evidence.evidence != expected_resulting_basis + || evidence.obstruction.is_some() + { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + } else if evidence.obstruction.as_deref().is_none_or(str::is_empty) + || evidence.resulting_basis.is_some() + || evidence.after_content_digest.is_some() + { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } else if let Some(input) = input { + if evidence.path.as_deref() != Some(input.path.as_str()) { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + } else if evidence.path.is_some() || evidence.obstruction.as_deref() != Some("malformed-input") + { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + let expected_schema_evidence = schema_admission_evidence( + profile.settlement_schema_digest, + &candidate.canonical_result_bytes, + ); + if candidate.schema_admission_evidence_digest != expected_schema_evidence { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + Ok(()) +} + +fn build_obstruction_candidate( + profile: ValidatedWorkspacePatchProfileV1, + grant: &ExternalActionClaimGrantV1, + path: Option<&str>, + code: &str, +) -> Result { + build_candidate( + profile, + grant, + ExternalActionSettlementKindV1::Rejected, + SettlementEvidenceV1 { + posture: "rejected", + path: path.map(str::to_owned), + request_basis: grant.request().basis_digest, + evidence: external_evidence(code, path.unwrap_or("")), + before_content_digest: None, + after_content_digest: None, + resulting_basis: None, + obstruction: Some(code.to_owned()), + }, + ) +} + +fn build_outcome_unknown_candidate( + profile: ValidatedWorkspacePatchProfileV1, + grant: &ExternalActionClaimGrantV1, + path: Option<&str>, + code: &str, + observed: Option<(Hash, Hash)>, +) -> Result { + let (evidence, before_content_digest) = observed.map_or_else( + || (external_evidence(code, path.unwrap_or("")), None), + |(basis, digest)| (basis, Some(digest)), + ); + build_candidate( + profile, + grant, + ExternalActionSettlementKindV1::OutcomeUnknown, + SettlementEvidenceV1 { + posture: "outcomeUnknown", + path: path.map(str::to_owned), + request_basis: grant.request().basis_digest, + evidence, + before_content_digest, + after_content_digest: None, + resulting_basis: None, + obstruction: Some(code.to_owned()), + }, + ) +} + +fn build_candidate( + profile: ValidatedWorkspacePatchProfileV1, + grant: &ExternalActionClaimGrantV1, + kind: ExternalActionSettlementKindV1, + evidence: SettlementEvidenceV1, +) -> Result { + let result = encode_settlement(&evidence)?; + if u64::try_from(result.len()).unwrap_or(u64::MAX) > grant.request().budget.max_settlement_bytes + { + return Err(ValidatedWorkspacePatchErrorV1::FileBudgetExceeded); + } + let schema_evidence = 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_evidence, + evidence.evidence, + )) +} + +fn decode_patch_input( + bytes: &[u8], +) -> Result { + let value = + decode_canonical_cbor_v1(bytes).map_err(|_| ValidatedWorkspacePatchErrorV1::Canonical)?; + let CanonicalValueV1::Map(entries) = value else { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + }; + if entries.len() != 5 + || !matches!( + map_field(&entries, "kind"), + Some(CanonicalValueV1::Text(value)) if value == PATCH_INPUT_KIND + ) + { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + } + let Some(CanonicalValueV1::Text(path)) = map_field(&entries, "path") else { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + }; + let Some(CanonicalValueV1::Bytes(expected_content_digest)) = + map_field(&entries, "expectedContentDigest") + else { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + }; + let Some(CanonicalValueV1::Bytes(replacement)) = map_field(&entries, "replacement") else { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + }; + let Some(CanonicalValueV1::Bytes(replacement_digest)) = + map_field(&entries, "replacementDigest") + else { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + }; + let expected_content_digest = expected_content_digest + .as_slice() + .try_into() + .map_err(|_| ValidatedWorkspacePatchErrorV1::Canonical)?; + let replacement_digest: Hash = replacement_digest + .as_slice() + .try_into() + .map_err(|_| ValidatedWorkspacePatchErrorV1::Canonical)?; + if replacement_digest != Hash::from(blake3::hash(replacement)) { + return Err(ValidatedWorkspacePatchErrorV1::Canonical); + } + Ok(ValidatedPatchInputV1 { + path: path.clone(), + expected_content_digest, + replacement: replacement.clone(), + replacement_digest, + }) +} + +fn encode_settlement( + evidence: &SettlementEvidenceV1, +) -> Result, ValidatedWorkspacePatchErrorV1> { + encode_canonical_cbor_v1(&canonical_map([ + ( + "kind", + CanonicalValueV1::Text(PATCH_SETTLEMENT_KIND.to_owned()), + ), + ( + "posture", + CanonicalValueV1::Text(evidence.posture.to_owned()), + ), + ( + "path", + evidence + .path + .as_ref() + .map_or(CanonicalValueV1::Null, |path| { + CanonicalValueV1::Text(path.clone()) + }), + ), + ( + "requestBasis", + CanonicalValueV1::Bytes(evidence.request_basis.to_vec()), + ), + ( + "evidence", + CanonicalValueV1::Bytes(evidence.evidence.to_vec()), + ), + ( + "beforeContentDigest", + optional_hash(evidence.before_content_digest), + ), + ( + "afterContentDigest", + optional_hash(evidence.after_content_digest), + ), + ("resultingBasis", optional_hash(evidence.resulting_basis)), + ( + "obstruction", + evidence + .obstruction + .as_ref() + .map_or(CanonicalValueV1::Null, |value| { + CanonicalValueV1::Text(value.clone()) + }), + ), + ])) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Canonical) +} + +fn decode_settlement(bytes: &[u8]) -> Result { + let value = + decode_canonical_cbor_v1(bytes).map_err(|_| ValidatedWorkspacePatchErrorV1::Canonical)?; + let CanonicalValueV1::Map(entries) = value else { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + }; + if entries.len() != 9 + || !matches!( + map_field(&entries, "kind"), + Some(CanonicalValueV1::Text(value)) if value == PATCH_SETTLEMENT_KIND + ) + { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + } + let Some(CanonicalValueV1::Text(posture)) = map_field(&entries, "posture") else { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + }; + let posture = match posture.as_str() { + "succeeded" => "succeeded", + "rejected" => "rejected", + "failed" => "failed", + "outcomeUnknown" => "outcomeUnknown", + _ => return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed), + }; + Ok(SettlementEvidenceV1 { + posture, + path: optional_text(map_field(&entries, "path"))?, + request_basis: required_hash(map_field(&entries, "requestBasis"))?, + evidence: required_hash(map_field(&entries, "evidence"))?, + before_content_digest: optional_hash_value(map_field(&entries, "beforeContentDigest"))?, + after_content_digest: optional_hash_value(map_field(&entries, "afterContentDigest"))?, + resulting_basis: optional_hash_value(map_field(&entries, "resultingBasis"))?, + obstruction: optional_text(map_field(&entries, "obstruction"))?, + }) +} + +fn validate_requested_path(path: &str, permitted_paths: &BTreeSet) -> Option<&'static str> { + if validate_relative_path(path).is_err() { + Some("invalid-path") + } else if is_ci_workflow_path(path) { + Some("ci-workflow-refused") + } else if !permitted_paths.contains(path) { + Some("unauthorized-path") + } else { + None + } +} + +fn validate_relative_path(path: &str) -> Result<(), ValidatedWorkspacePatchErrorV1> { + if path.is_empty() + || path.starts_with('/') + || path.ends_with('/') + || path.contains("//") + || path.contains('\\') + || path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(ValidatedWorkspacePatchErrorV1::InvalidPath); + } + let mut count = 0_usize; + for component in Path::new(path).components() { + match component { + Component::Normal(value) if value.to_str().is_some() => count = count.saturating_add(1), + _ => return Err(ValidatedWorkspacePatchErrorV1::InvalidPath), + } + } + if count == 0 { + Err(ValidatedWorkspacePatchErrorV1::InvalidPath) + } else { + Ok(()) + } +} + +fn is_ci_workflow_path(path: &str) -> bool { + path == ".github/workflows" || path.starts_with(".github/workflows/") +} + +fn open_parent_nofollow( + root: &Dir, + path: &str, +) -> Result<(Dir, OsString), ValidatedWorkspacePatchErrorV1> { + validate_relative_path(path)?; + let components = Path::new(path).components().collect::>(); + let Some((file_name, parents)) = components.split_last() else { + return Err(ValidatedWorkspacePatchErrorV1::InvalidPath); + }; + let Component::Normal(file_name) = file_name else { + return Err(ValidatedWorkspacePatchErrorV1::InvalidPath); + }; + let mut parent = root + .try_clone() + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + for component in parents { + let Component::Normal(component) = component else { + return Err(ValidatedWorkspacePatchErrorV1::InvalidPath); + }; + let metadata = parent + .symlink_metadata(component) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + if metadata.file_type().is_symlink() { + return Err(ValidatedWorkspacePatchErrorV1::SymlinkRefused); + } + parent = parent + .open_dir_nofollow(component) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + } + Ok((parent, file_name.to_os_string())) +} + +fn read_regular_file( + parent: &Dir, + file_name: &OsString, + max_bytes: u64, +) -> Result<(Vec, Metadata), ValidatedWorkspacePatchErrorV1> { + let metadata = parent + .symlink_metadata(file_name) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + if metadata.file_type().is_symlink() { + return Err(ValidatedWorkspacePatchErrorV1::SymlinkRefused); + } + if !metadata.is_file() { + return Err(ValidatedWorkspacePatchErrorV1::NotRegularFile); + } + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No).nonblock(true); + let mut file = parent + .open_with(file_name, &options) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + let opened_metadata = file + .metadata() + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + if !opened_metadata.is_file() { + return Err(ValidatedWorkspacePatchErrorV1::NotRegularFile); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(ValidatedWorkspacePatchErrorV1::FileBudgetExceeded); + } + Ok((bytes, opened_metadata)) +} + +fn stage_replacement( + parent: &Dir, + temp_name: &OsString, + replacement: &[u8], + metadata: &Metadata, +) -> Result<(), ValidatedWorkspacePatchErrorV1> { + let mut options = OpenOptions::new(); + options + .write(true) + .create_new(true) + .follow(FollowSymlinks::No); + let mut file = parent + .open_with(temp_name, &options) + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)?; + if let Err(error) = file.write_all(replacement) { + let _ = parent.remove_file(temp_name); + return Err(if error.kind() == std::io::ErrorKind::InvalidInput { + ValidatedWorkspacePatchErrorV1::FileBudgetExceeded + } else { + ValidatedWorkspacePatchErrorV1::Io + }); + } + if parent + .set_permissions(temp_name, metadata.permissions()) + .is_err() + { + let _ = parent.remove_file(temp_name); + return Err(ValidatedWorkspacePatchErrorV1::Io); + } + if file.sync_all().is_err() { + let _ = parent.remove_file(temp_name); + return Err(ValidatedWorkspacePatchErrorV1::Io); + } + Ok(()) +} + +fn temporary_name(file_name: &OsString, grant: &ExternalActionClaimGrantV1) -> OsString { + let mut name = OsString::from("."); + name.push(file_name); + name.push(".echo-patch-"); + name.push(hex::encode(grant.claim().attempt_id.as_hash())); + name +} + +fn sync_directory(parent: &Dir) -> Result<(), ValidatedWorkspacePatchErrorV1> { + parent + .try_clone() + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)? + .into_std_file() + .sync_all() + .map_err(|_| ValidatedWorkspacePatchErrorV1::Io) +} + +fn external_action_input_identity(bytes: &[u8]) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(EXTERNAL_ACTION_INPUT_DOMAIN); + hash_len_prefixed(&mut hasher, bytes); + hasher.finalize().into() +} + +fn schema_admission_evidence(schema: Hash, bytes: &[u8]) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(PATCH_SCHEMA_EVIDENCE_DOMAIN); + hasher.update(&schema); + hash_len_prefixed(&mut hasher, bytes); + hasher.finalize().into() +} + +fn external_evidence(code: &str, detail: &str) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(PATCH_EXTERNAL_EVIDENCE_DOMAIN); + hash_len_prefixed(&mut hasher, code.as_bytes()); + hash_len_prefixed(&mut hasher, detail.as_bytes()); + hasher.finalize().into() +} + +fn posture_for(kind: ExternalActionSettlementKindV1) -> &'static str { + match kind { + ExternalActionSettlementKindV1::Succeeded => "succeeded", + ExternalActionSettlementKindV1::Rejected => "rejected", + ExternalActionSettlementKindV1::Failed => "failed", + ExternalActionSettlementKindV1::OutcomeUnknown => "outcomeUnknown", + } +} + +fn canonical_map<'a>( + entries: impl IntoIterator, +) -> CanonicalValueV1 { + CanonicalValueV1::Map( + entries + .into_iter() + .map(|(key, value)| (CanonicalValueV1::Text(key.to_owned()), value)) + .collect(), + ) +} + +fn map_field<'a>( + entries: &'a [(CanonicalValueV1, CanonicalValueV1)], + field: &str, +) -> Option<&'a CanonicalValueV1> { + entries.iter().find_map(|(key, value)| match key { + CanonicalValueV1::Text(key) if key == field => Some(value), + _ => None, + }) +} + +fn optional_hash(value: Option) -> CanonicalValueV1 { + value.map_or(CanonicalValueV1::Null, |value| { + CanonicalValueV1::Bytes(value.to_vec()) + }) +} + +fn required_hash(value: Option<&CanonicalValueV1>) -> Result { + let Some(CanonicalValueV1::Bytes(value)) = value else { + return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); + }; + value + .as_slice() + .try_into() + .map_err(|_| ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed) +} + +fn optional_hash_value( + value: Option<&CanonicalValueV1>, +) -> Result, ValidatedWorkspacePatchErrorV1> { + match value { + Some(CanonicalValueV1::Null) => Ok(None), + Some(value) => required_hash(Some(value)).map(Some), + None => Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed), + } +} + +fn optional_text( + value: Option<&CanonicalValueV1>, +) -> Result, ValidatedWorkspacePatchErrorV1> { + match value { + Some(CanonicalValueV1::Null) => Ok(None), + Some(CanonicalValueV1::Text(value)) => Ok(Some(value.clone())), + _ => Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed), + } +} + +fn hash_len_prefixed(hasher: &mut blake3::Hasher, bytes: &[u8]) { + hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes()); + hasher.update(bytes); +} diff --git a/crates/warp-core/tests/bounded_workspace_patch_tests.rs b/crates/warp-core/tests/bounded_workspace_patch_tests.rs index e4a976c4..738fdf22 100644 --- a/crates/warp-core/tests/bounded_workspace_patch_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_patch_tests.rs @@ -4,8 +4,33 @@ #![allow(clippy::panic)] -use echo_edict_canonical::{encode_canonical_cbor_v1, CanonicalValueV1}; -use warp_core::external_action_adapter::admit_edict_external_action_request_v1; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use echo_edict_canonical::{decode_canonical_cbor_v1, encode_canonical_cbor_v1, CanonicalValueV1}; +use warp_core::causal_wal::{ + InMemoryWalStore, Lsn, PayloadCodecId, PayloadSchemaId, WalDurabilityMode, WalSegmentId, + WalStorePort, WalTransactionId, WriterEpochId, WriterEpochRequest, +}; +use warp_core::external_action::{ + claim_external_action, reconcile_external_action_settlement_retry, + record_external_action_request, ExternalActionAdapterBindingV1, ExternalActionAdapterIdV1, + ExternalActionAdapterRegistryV1, ExternalActionClaimGrantV1, ExternalActionCoordinatorV1, + ExternalActionProtocolErrorV1, ExternalActionSettlementCandidateV1, + ExternalActionSettlementKindV1, ExternalActionTransactionContextV1, + RecoveredExternalActionPostureV1, +}; +use warp_core::external_action_adapter::{ + admit_edict_external_action_request_v1, bounded_workspace_observation_basis_v1, + AdmittedEdictExternalActionRequestV1, +}; +use warp_core::validated_workspace_patch::{ + encode_validated_workspace_patch_input_v1, validated_workspace_patch_authority_v1, + validated_workspace_patch_basis_v1, ValidatedWorkspacePatchAdapterV1, + ValidatedWorkspacePatchErrorV1, ValidatedWorkspacePatchProfileV1, + ValidatedWorkspacePatchReconcilerV1, +}; use warp_core::{Hash, WorldlineId}; const CORE_BYTES: &[u8] = @@ -28,6 +53,13 @@ fn must_ok(result: Result) -> T { } } +fn must_some(value: Option) -> T { + match value { + Some(value) => value, + None => panic!("expected Some(..), got None"), + } +} + fn text(value: &str) -> CanonicalValueV1 { CanonicalValueV1::Text(value.to_owned()) } @@ -59,6 +91,193 @@ fn application_input( ]))) } +fn admitted_request( + worldline_byte: u8, + path: &str, + before: &[u8], + replacement: &[u8], + authority: Hash, + max_settlement_bytes: u64, +) -> AdmittedEdictExternalActionRequestV1 { + let patch = must_ok(encode_validated_workspace_patch_input_v1( + path.to_owned(), + blake3::hash(before).into(), + replacement.to_vec(), + )); + must_ok(admit_edict_external_action_request_v1( + WorldlineId::from_bytes([worldline_byte; 32]), + CORE_BYTES, + TARGET_IR_BYTES, + "applyValidated", + &application_input( + patch, + authority, + validated_workspace_patch_basis_v1(path, before), + max_settlement_bytes, + ), + )) +} + +fn raw_patch_input(path: &str, before: &[u8], replacement: &[u8]) -> Vec { + must_ok(encode_canonical_cbor_v1(&map([ + ( + "kind", + CanonicalValueV1::Text("validatedWorkspacePatchInput".to_owned()), + ), + ("path", CanonicalValueV1::Text(path.to_owned())), + ( + "expectedContentDigest", + CanonicalValueV1::Bytes(blake3::hash(before).as_bytes().to_vec()), + ), + ("replacement", CanonicalValueV1::Bytes(replacement.to_vec())), + ( + "replacementDigest", + CanonicalValueV1::Bytes(blake3::hash(replacement).as_bytes().to_vec()), + ), + ]))) +} + +fn profile( + admitted: &AdmittedEdictExternalActionRequestV1, + adapter_label: &str, + max_file_bytes: u64, +) -> ValidatedWorkspacePatchProfileV1 { + let request = admitted.request(); + ValidatedWorkspacePatchProfileV1 { + operation_id: request.operation_id, + input_schema_digest: request.input_schema_digest, + settlement_schema_digest: request.settlement_schema_digest, + reconciliation_law_digest: request.reconciliation_law_digest, + authority_scope_digest: request.authority_scope_digest, + adapter_id: ExternalActionAdapterIdV1::from_hash(digest(adapter_label)), + max_file_bytes, + } +} + +fn epoch_id() -> WriterEpochId { + WriterEpochId::from_hash(digest("bounded-patch:epoch")) +} + +fn store() -> InMemoryWalStore { + let mut store = InMemoryWalStore::new(); + must_ok(store.acquire_writer_epoch(WriterEpochRequest { + epoch_id: epoch_id(), + storage_fencing_token: digest("bounded-patch:fencing"), + process_identity: digest("bounded-patch:process"), + host_identity: digest("bounded-patch:host"), + started_at_lsn: Lsn::from_raw(0), + previous_epoch_id: None, + previous_epoch_final_commit_digest: None, + lease_or_lock_evidence: digest("bounded-patch:lease"), + })); + store +} + +fn context(label: &str) -> ExternalActionTransactionContextV1 { + ExternalActionTransactionContextV1 { + writer_epoch: epoch_id(), + segment_id: WalSegmentId::from_raw(1), + transaction_id: WalTransactionId::from_hash(digest(label)), + durability_mode: WalDurabilityMode::Buffered, + payload_codec_id: PayloadCodecId::from_hash(digest("bounded-patch:codec")), + payload_schema_id: PayloadSchemaId::from_hash(digest("bounded-patch:schema")), + payload_schema_version: 1, + canonical_encoding_version: 1, + digest_domain: digest("bounded-patch:wal-domain"), + } +} + +fn claim( + store: &mut InMemoryWalStore, + coordinator: &mut ExternalActionCoordinatorV1, + admitted: &AdmittedEdictExternalActionRequestV1, + binding: ExternalActionAdapterBindingV1, + label: &str, +) -> ExternalActionClaimGrantV1 { + let request = admitted.request(); + let recorded = must_ok(record_external_action_request( + store, + coordinator, + context(&format!("{label}:request")), + request, + )); + let registry = ExternalActionAdapterRegistryV1::new([binding]); + let authorization = must_ok(registry.authorize(&request, binding.adapter_id)); + must_ok(claim_external_action( + store, + coordinator, + context(&format!("{label}:claim")), + recorded, + authorization, + request.basis_digest, + 0, + digest(&format!("{label}:lease")), + )) +} + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct TempRoot(PathBuf); + +impl TempRoot { + fn new(label: &str) -> Self { + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "echo-bounded-patch-{}-{counter}-{label}", + std::process::id() + )); + if root.exists() { + must_ok(fs::remove_dir_all(&root)); + } + must_ok(fs::create_dir_all(&root)); + Self(root) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn write(&self, relative: &str, bytes: &[u8]) { + let path = self.0.join(relative); + if let Some(parent) = path.parent() { + must_ok(fs::create_dir_all(parent)); + } + must_ok(fs::write(path, bytes)); + } + + fn read(&self, relative: &str) -> Vec { + must_ok(fs::read(self.0.join(relative))) + } +} + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn field(candidate: &ExternalActionSettlementCandidateV1, name: &str) -> CanonicalValueV1 { + let value = must_ok(decode_canonical_cbor_v1(&candidate.canonical_result_bytes)); + let CanonicalValueV1::Map(entries) = value else { + panic!("expected settlement map"); + }; + let value = entries.into_iter().find_map(|(key, value)| match key { + CanonicalValueV1::Text(key) if key == name => Some(value), + _ => None, + }); + match value { + Some(value) => value, + None => panic!("missing settlement field {name}"), + } +} + +fn obstruction(candidate: &ExternalActionSettlementCandidateV1) -> String { + match field(candidate, "obstruction") { + CanonicalValueV1::Text(value) => value, + value => panic!("expected obstruction text, got {value:?}"), + } +} + #[test] fn exact_compiler_artifacts_admit_one_noncallable_patch_request() { let authority = digest("authority:exact"); @@ -83,4 +302,515 @@ fn exact_compiler_artifacts_admit_one_noncallable_patch_request() { assert_eq!(admitted.request().basis_digest, basis); assert_eq!(admitted.request().budget.max_settlement_bytes, 65_536); assert_eq!(admitted.request().budget.max_attempts, 1); + assert_eq!( + validated_workspace_patch_basis_v1("src/lib.rs", b"before"), + bounded_workspace_observation_basis_v1([("src/lib.rs", b"before".as_slice())]) + ); +} + +#[test] +fn durable_claim_precedes_mutation_and_settlement_precedes_replay() { + let root = TempRoot::new("golden"); + let path = "src/message.txt"; + let before = b"hello"; + let replacement = b"hello from Echo"; + root.write(path, before); + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(30, path, before, replacement, authority, 65_536); + let profile = profile(&admitted, "bounded-patch:golden-adapter", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + + assert_eq!(root.read(path), before); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "golden", + ); + let request_id = grant.request().request_id(); + assert_eq!( + must_some(coordinator.observed_index().get(request_id)).posture, + RecoveredExternalActionPostureV1::Claimed + ); + assert_eq!(root.read(path), before); + + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); + assert_eq!(root.read(path), replacement); + assert!(matches!( + must_ok(coordinator.claim_grant(request_id)).claim(), + claim if claim == grant.claim() + )); + + let settled = must_ok(adapter.admit_settlement( + &mut store, + &mut coordinator, + context("golden:settlement"), + &admitted, + grant, + candidate.clone(), + )); + assert_eq!( + settled.settlement().canonical_result_bytes, + candidate.canonical_result_bytes + ); + root.write(path, b"changed after settlement"); + let retried = must_ok(reconcile_external_action_settlement_retry( + &coordinator, + candidate.clone(), + )); + assert_eq!( + retried.settlement().canonical_result_bytes, + candidate.canonical_result_bytes + ); + assert_eq!(root.read(path), b"changed after settlement"); + let mut conflicting = candidate.clone(); + conflicting.external_evidence_digest = digest("conflicting-settlement"); + assert_eq!( + reconcile_external_action_settlement_retry(&coordinator, conflicting), + Err(ExternalActionProtocolErrorV1::ConflictingSettlement) + ); + + let recovered = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let recovered_entry = must_some(recovered.observed_index().get(request_id)); + assert_eq!( + recovered_entry.posture, + RecoveredExternalActionPostureV1::Settled(ExternalActionSettlementKindV1::Succeeded) + ); + assert_eq!( + must_some(recovered_entry.settlement.as_ref()).canonical_result_bytes, + candidate.canonical_result_bytes + ); + assert_eq!(root.read(path), b"changed after settlement"); +} + +#[test] +fn malformed_and_invalid_inputs_settle_as_refusals() { + let root = TempRoot::new("invalid-input"); + let path = "src/value.txt"; + let before = b"before"; + root.write(path, before); + let authority = validated_workspace_patch_authority_v1([path]); + + for (ordinal, input, code) in [ + (35_u8, vec![0xff], "malformed-input"), + ( + 36_u8, + raw_patch_input("../outside.txt", before, b"after"), + "invalid-path", + ), + ] { + let admitted = must_ok(admit_edict_external_action_request_v1( + WorldlineId::from_bytes([ordinal; 32]), + CORE_BYTES, + TARGET_IR_BYTES, + "applyValidated", + &application_input( + input, + authority, + validated_workspace_patch_basis_v1(path, before), + 65_536, + ), + )); + let profile = profile( + &admitted, + &format!("bounded-patch:invalid-{ordinal}"), + 65_536, + ); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + &format!("invalid-{ordinal}"), + ); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); + assert_eq!(obstruction(&candidate), code); + must_ok(adapter.admit_settlement( + &mut store, + &mut coordinator, + context(&format!("invalid-{ordinal}:settlement")), + &admitted, + grant, + candidate, + )); + assert_eq!(root.read(path), before); + } +} + +#[test] +fn stale_basis_and_path_policy_refuse_before_mutation() { + let root = TempRoot::new("refusals"); + let permitted = "src/permitted.txt"; + root.write(permitted, b"current"); + root.write("src/other.txt", b"other"); + root.write(".github/workflows/ci.yml", b"name: ci"); + let authority = validated_workspace_patch_authority_v1([permitted]); + + for (ordinal, requested_path, expected_before, code) in [ + (40_u8, permitted, b"stale".as_slice(), "stale-basis"), + ( + 41, + "src/other.txt", + b"other".as_slice(), + "unauthorized-path", + ), + ( + 42, + ".github/workflows/ci.yml", + b"name: ci".as_slice(), + "ci-workflow-refused", + ), + ] { + let admitted = admitted_request( + ordinal, + requested_path, + expected_before, + b"replacement", + authority, + 65_536, + ); + let profile = profile( + &admitted, + &format!("bounded-patch:refusal-{ordinal}"), + 65_536, + ); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [permitted.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + &format!("refusal-{ordinal}"), + ); + let before_bytes = root.read(requested_path); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); + assert_eq!(obstruction(&candidate), code); + assert_eq!(root.read(requested_path), before_bytes); + } +} + +#[cfg(unix)] +#[test] +fn symlinks_and_special_files_refuse_before_mutation() { + use std::os::unix::fs::symlink; + + let root = TempRoot::new("file-kinds"); + root.write("src/real.txt", b"real"); + must_ok(symlink( + root.path().join("src/real.txt"), + root.path().join("src/link.txt"), + )); + must_ok(fs::create_dir_all(root.path().join("src/directory"))); + + for (ordinal, path, code) in [ + (50_u8, "src/link.txt", "symlink-refused"), + (51_u8, "src/directory", "not-regular-file"), + ] { + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(ordinal, path, b"real", b"replacement", authority, 65_536); + let profile = profile( + &admitted, + &format!("bounded-patch:file-kind-{ordinal}"), + 65_536, + ); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + &format!("file-kind-{ordinal}"), + ); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); + assert_eq!(obstruction(&candidate), code); + } + assert_eq!(root.read("src/real.txt"), b"real"); + assert!(root.path().join("src/directory").is_dir()); +} + +#[test] +fn file_budget_and_grant_substitution_fail_closed() { + let root = TempRoot::new("budgets"); + let path = "bounded.txt"; + root.write(path, b"1234"); + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(60, path, b"1234", b"56789", authority, 65_536); + let profile = profile(&admitted, "bounded-patch:budget", 4); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "budget", + ); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); + assert_eq!(obstruction(&candidate), "replacement-budget-exceeded"); + assert_eq!(root.read(path), b"1234"); + + let other = admitted_request(61, path, b"1234", b"other", authority, 65_536); + assert_eq!( + adapter.apply(&grant, &other), + Err(ValidatedWorkspacePatchErrorV1::GrantMismatch) + ); + assert_eq!(root.read(path), b"1234"); +} + +#[test] +fn reconciliation_observes_postcondition_without_reapplying() { + let root = TempRoot::new("reconcile-success"); + let path = "src/reconcile.txt"; + let before = b"before"; + let replacement = b"after"; + root.write(path, before); + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(70, path, before, replacement, authority, 65_536); + let profile = profile(&admitted, "bounded-patch:reconciler", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "reconcile-success", + ); + let request_id = grant.request().request_id(); + + let first_candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!( + first_candidate.kind, + ExternalActionSettlementKindV1::Succeeded + ); + assert_eq!(root.read(path), replacement); + let recovered_claimed = must_ok(ExternalActionCoordinatorV1::recover(&store)); + assert_eq!( + must_some(recovered_claimed.observed_index().get(request_id)).posture, + RecoveredExternalActionPostureV1::Claimed + ); + + let reconciler = must_ok(ValidatedWorkspacePatchReconcilerV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let candidate = must_ok(reconciler.reconcile(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); + assert_eq!(root.read(path), replacement); + must_ok(reconciler.admit_settlement( + &mut store, + &mut coordinator, + context("reconcile-success:settlement"), + &admitted, + grant, + candidate, + )); + assert_eq!(root.read(path), replacement); +} + +#[test] +fn reconciliation_reports_unknown_when_postcondition_is_absent() { + let root = TempRoot::new("reconcile-unknown"); + let path = "src/reconcile.txt"; + let before = b"before"; + root.write(path, before); + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(71, path, before, b"intended", authority, 65_536); + let profile = profile(&admitted, "bounded-patch:unknown-reconciler", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "reconcile-unknown", + ); + root.write(path, b"neither before nor intended"); + + let reconciler = must_ok(ValidatedWorkspacePatchReconcilerV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let candidate = must_ok(reconciler.reconcile(&grant, &admitted)); + assert_eq!( + candidate.kind, + ExternalActionSettlementKindV1::OutcomeUnknown + ); + assert_eq!(obstruction(&candidate), "postcondition-not-observed"); + assert_eq!(root.read(path), b"neither before nor intended"); +} + +#[test] +fn reconciliation_reports_unknown_when_postcondition_is_unreadable() { + let root = TempRoot::new("reconcile-unreadable"); + let path = "src/reconcile.txt"; + let before = b"before"; + root.write(path, before); + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(72, path, before, b"intended", authority, 65_536); + let profile = profile(&admitted, "bounded-patch:unreadable-reconciler", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "reconcile-unreadable", + ); + must_ok(fs::remove_file(root.path().join(path))); + + let reconciler = must_ok(ValidatedWorkspacePatchReconcilerV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let candidate = must_ok(reconciler.reconcile(&grant, &admitted)); + assert_eq!( + candidate.kind, + ExternalActionSettlementKindV1::OutcomeUnknown + ); + assert_eq!(obstruction(&candidate), "postcondition-unreadable"); + assert!(!root.path().join(path).exists()); +} + +#[test] +fn fixed_seed_patch_property_covers_binary_replacements() { + let root = TempRoot::new("property"); + let path = "property.bin"; + let authority = validated_workspace_patch_authority_v1([path]); + let mut state = 0x5eed_cafe_f00d_beef_u64; + + for ordinal in 0_u8..32 { + let before = vec![ordinal; usize::from(ordinal % 7) + 1]; + let mut replacement = vec![0_u8; usize::from(ordinal % 19) + 1]; + for byte in &mut replacement { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + *byte = (state >> 56) as u8; + } + root.write(path, &before); + let admitted = + admitted_request(ordinal + 80, path, &before, &replacement, authority, 65_536); + let profile = profile( + &admitted, + &format!("bounded-patch:property-{ordinal}"), + 65_536, + ); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + &format!("property-{ordinal}"), + ); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); + assert_eq!(root.read(path), replacement); + } +} + +#[test] +fn bounded_stress_applies_sixty_four_independent_patches() { + let root = TempRoot::new("stress"); + + for ordinal in 0_u8..64 { + let path = format!("stress/file-{ordinal:02}.txt"); + let before = format!("before-{ordinal}").into_bytes(); + let replacement = format!("after-{ordinal}").into_bytes(); + root.write(&path, &before); + let authority = validated_workspace_patch_authority_v1([path.as_str()]); + let admitted = admitted_request( + ordinal + 120, + &path, + &before, + &replacement, + authority, + 65_536, + ); + let profile = profile( + &admitted, + &format!("bounded-patch:stress-{ordinal}"), + 65_536, + ); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.clone()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + &format!("stress-{ordinal}"), + ); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); + assert_eq!(root.read(&path), replacement); + } } From b061b7aab2bbaa5930c069cec68874b898942e6c Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:51:44 -0700 Subject: [PATCH 04/14] docs: define basis-bound patch execution --- README.md | 2 +- ...0026-durable-external-action-settlement.md | 13 +++++- docs/topics/ExternalActions.md | 40 ++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 941c7e8c..978a156f 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ Echo owns: - causal history, frontiers, and runtime coordinates; - admission, scheduling, ticks, and settlement; - receipts, witnesses, reading envelopes, and retained artifacts; -- bounded observation machinery; +- bounded observation and basis-bound external-action coordination; - generic contract hosting and suffix import/export surfaces. You own: diff --git a/docs/adr/0026-durable-external-action-settlement.md b/docs/adr/0026-durable-external-action-settlement.md index 4cf82481..e3d48083 100644 --- a/docs/adr/0026-durable-external-action-settlement.md +++ b/docs/adr/0026-durable-external-action-settlement.md @@ -223,8 +223,15 @@ invoking adapter execution. settlements must cover exactly the admitted path aperture. Settlement admission revalidates the live registry grant, while nonblocking no-follow opens refuse special-file substitution without stalling. - Process, network, Git, GitHub, timer, model, and mutation adapters remain - absent. +- The first capability-rooted mutation adapter applies one validated, + basis-bound regular-file replacement. Its authority commits the exact path + aperture and immutable no-follow, single-file, regular-file-only, and + CI-workflow-exclusion policy. It consumes the bounded-observation basis, + records the request and claim before mutation, synchronizes a same-directory + atomic replacement, and records the resulting basis and content identities + before resumption. Ambiguous attempts are reconciled by bounded + postcondition observation and never by reapplying the write. +- Process, network, Git, GitHub, timer, and model adapters remain absent. ## Rejected Alternatives @@ -252,9 +259,11 @@ deterministic validation and a separately authorized adapter govern mutation. - `crates/warp-core/src/external_action.rs` - `crates/warp-core/src/external_action_adapter.rs` +- `crates/warp-core/src/validated_workspace_patch.rs` - `crates/warp-core/src/causal_wal.rs` - `crates/warp-core/tests/external_action_protocol_tests.rs` - `crates/warp-core/tests/bounded_workspace_observation_tests.rs` +- `crates/warp-core/tests/bounded_workspace_patch_tests.rs` - [External Actions](../topics/ExternalActions.md) - [WAL](../topics/WAL.md) - [Runtime Authority](../topics/RuntimeAuthority.md) diff --git a/docs/topics/ExternalActions.md b/docs/topics/ExternalActions.md index b482fc14..e35cf190 100644 --- a/docs/topics/ExternalActions.md +++ b/docs/topics/ExternalActions.md @@ -39,7 +39,7 @@ Admission also requires enough retained-settlement capacity to encode every terminal posture; a request cannot select a budget that makes rejection, failure, or ambiguity unrecordable. -## First Adapter Profile +## Adapter Profiles `BoundedWorkspaceObservationAdapterV1` is the first operation-specific adapter. Runtime configuration supplies: @@ -63,6 +63,24 @@ boundary needs an unforgeable directory capability plus component-wise no-follow opens. Both packages were already pinned in the workspace lockfile; the adapter does not introduce an ambient path or shell interface. +`ValidatedWorkspacePatchAdapterV1` is the first mutation profile. It consumes +the exact Edict-emitted `workspace.patch.applyValidated@1` request and one +runtime-owned writable aperture. The canonical patch input names one relative +regular file, its expected content identity, and replacement bytes. The +request basis is the exact singleton basis produced by +`bounded_workspace_observation_basis_v1`, so the observation settlement can +feed the write precondition without translation. + +The v1 adapter deliberately supports one file replacement. It rejects escaped, +unauthorized, symlinked, special-file, oversized, stale, and +`.github/workflows/` targets before mutation. The authority digest commits the +exact path set plus the single-file, no-follow, regular-file-only, and +CI-workflow-exclusion policies. A same-directory temporary file is created +without following links, written and synchronized, assigned the original +permissions, atomically renamed, and followed by directory synchronization. +The runtime owner must serialize the granted workspace mutation aperture; this +profile is not a general multi-file filesystem transaction. + ## Ordering The adapter accepts only `ExternalActionClaimGrantV1`. That grant exists only @@ -71,7 +89,7 @@ after Echo commits: ```text REQUESTED -> CLAIMED - -> adapter observation + -> adapter execution -> validated settlement candidate -> SETTLED ``` @@ -115,6 +133,14 @@ Before generic WAL admission, the operation profile independently validates: Malformed or substituted candidates fail before the settlement transaction. +Patch success retains the request's observation basis, exact resulting basis, +path, before and after content identities, adapter evidence, and postcondition +evidence. Stale basis, path policy, special-file, and byte-budget violations +settle as typed rejections without mutation. The adapter can return +`OutcomeUnknown` after an ambiguous rename, directory synchronization, or +postcondition read. A terminal settlement is durable before its result becomes +resumable program input. + If an adapter loses the acknowledgement after settlement commit, it may submit the retained candidate to `reconcile_external_action_settlement_retry`. That function has no store, @@ -135,6 +161,14 @@ bounded-observation reconciler may admit explicit uncertainty. It cannot produce a successful observation. Substituted profiles, grants, or requests fail before another WAL commit, as does omitted zero-valued evidence. +Patch reconciliation is different because the operation law admits bounded +postcondition observation. `ValidatedWorkspacePatchReconcilerV1` exposes no +write method. It reopens only the exact capability root and path aperture, +observes whether the intended replacement and resulting basis already hold, +and settles success when they do. Any other readable state or unreadable +postcondition becomes `OutcomeUnknown`; reconciliation never stages or renames +a file. + 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. @@ -147,6 +181,8 @@ history. - `crates/warp-core/src/external_action_adapter.rs` - `crates/warp-core/tests/bounded_workspace_observation_tests.rs` +- `crates/warp-core/src/validated_workspace_patch.rs` +- `crates/warp-core/tests/bounded_workspace_patch_tests.rs` - `crates/warp-core/src/external_action.rs` - `crates/warp-core/tests/external_action_protocol_tests.rs` - [ADR 0026](../adr/0026-durable-external-action-settlement.md) From ffa24b35630f34559cd152206c1d728ac6549eeb Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:52:03 -0700 Subject: [PATCH 05/14] docs: record basis-bound patch adapter --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95831b55..e0fd82da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ ### Added +- Echo now consumes the exact independently verified Edict + `workspace.patch.applyValidated@1` request through a capability-rooted + single-file patch adapter. The request binds the prior bounded-observation + basis, replacement identity, exact writable aperture, immutable no-follow + and CI-workflow-exclusion policy, and byte budgets before mutation. The + adapter rejects stale bases, escaped or substituted paths, symlinks, special + files, and oversized writes without mutation; synchronizes a same-directory + atomic replacement; and settles the resulting basis and before/after content + identities before resumption. Claimed attempts reconcile by observing the + exact postcondition or settling `OutcomeUnknown`, never by reapplying the + patch. Identical settlement retry and replay remain effect-free. - 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's exact claim From 6f438bf3a146e270176aa118bd0c6c8a84f23a4f Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:55:29 -0700 Subject: [PATCH 06/14] fix: preflight patch settlement budgets --- .../warp-core/src/external_action_adapter.rs | 8 ++- .../src/validated_workspace_patch.rs | 43 ++++++++----- .../tests/bounded_workspace_patch_tests.rs | 61 ++++++++++++++++++- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/crates/warp-core/src/external_action_adapter.rs b/crates/warp-core/src/external_action_adapter.rs index 4cfa373e..0b21a789 100644 --- a/crates/warp-core/src/external_action_adapter.rs +++ b/crates/warp-core/src/external_action_adapter.rs @@ -49,6 +49,7 @@ const OBSERVATION_REFUSAL_EVIDENCE_DOMAIN: &[u8] = b"echo.bounded-observation.re const MAX_CORE_EVALUATION_STEPS_V1: u64 = 4_096; const MAX_CORE_EVALUATION_ALLOCATED_BYTES_V1: u64 = 4 * 1_024 * 1_024; const MAX_CORE_EVALUATION_OUTPUT_BYTES_V1: u64 = 1_024 * 1_024; +const MIN_REQUEST_ONLY_SETTLEMENT_BYTES_V1: u64 = 1_024; /// One canonical Edict request admitted from exact Core and Target IR bytes. #[derive(Clone, Debug, PartialEq, Eq)] @@ -276,7 +277,12 @@ pub fn admit_edict_external_action_request_v1( if max_settlement_bytes > u64::try_from(bytes_type_max(settlement_type)?).unwrap_or(u64::MAX) { return Err(EdictExternalActionAdmissionErrorV1::InvalidRuntimeValue); } - if max_settlement_bytes < minimum_terminal_settlement_bytes_v1(basis_digest)? { + let minimum_settlement_bytes = if operation_profile == EXTERNAL_REQUEST_OPERATION_PROFILE { + MIN_REQUEST_ONLY_SETTLEMENT_BYTES_V1 + } else { + minimum_terminal_settlement_bytes_v1(basis_digest)? + }; + if max_settlement_bytes < minimum_settlement_bytes { return Err(EdictExternalActionAdmissionErrorV1::InvalidRuntimeValue); } diff --git a/crates/warp-core/src/validated_workspace_patch.rs b/crates/warp-core/src/validated_workspace_patch.rs index 75efbb53..d86db625 100644 --- a/crates/warp-core/src/validated_workspace_patch.rs +++ b/crates/warp-core/src/validated_workspace_patch.rs @@ -172,6 +172,29 @@ impl ValidatedWorkspacePatchAdapterV1 { Some((observed_basis, before_digest)), ); } + let expected_after_digest = input.replacement_digest; + let expected_resulting_basis = + validated_workspace_patch_basis_v1(&input.path, &input.replacement); + let success_candidate = match self.candidate( + grant, + ExternalActionSettlementKindV1::Succeeded, + SettlementEvidenceV1 { + posture: "succeeded", + path: Some(input.path.clone()), + request_basis: grant.request().basis_digest, + evidence: expected_resulting_basis, + before_content_digest: Some(before_digest), + after_content_digest: Some(expected_after_digest), + resulting_basis: Some(expected_resulting_basis), + obstruction: None, + }, + ) { + Ok(candidate) => candidate, + Err(ValidatedWorkspacePatchErrorV1::FileBudgetExceeded) => { + return self.obstruction(grant, None, "settlement-budget-exceeded", None); + } + Err(error) => return Err(error), + }; let temp_name = temporary_name(&file_name, grant); if let Err(error) = stage_replacement(&parent, &temp_name, &input.replacement, &metadata) { @@ -226,20 +249,7 @@ impl ValidatedWorkspacePatchAdapterV1 { Some((resulting_basis, after_digest)), ); } - self.candidate( - grant, - ExternalActionSettlementKindV1::Succeeded, - SettlementEvidenceV1 { - posture: "succeeded", - path: Some(input.path), - request_basis: grant.request().basis_digest, - evidence: resulting_basis, - before_content_digest: Some(before_digest), - after_content_digest: Some(after_digest), - resulting_basis: Some(resulting_basis), - obstruction: None, - }, - ) + Ok(success_candidate) } /// Validates the operation schema and durably admits the settlement. @@ -715,7 +725,10 @@ fn validate_candidate( { return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); } else if let Some(input) = input { - if evidence.path.as_deref() != Some(input.path.as_str()) { + let path_may_be_omitted = evidence.obstruction.as_deref() + == Some("settlement-budget-exceeded") + && evidence.path.is_none(); + if !path_may_be_omitted && evidence.path.as_deref() != Some(input.path.as_str()) { return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); } } else if evidence.path.is_some() || evidence.obstruction.as_deref() != Some("malformed-input") diff --git a/crates/warp-core/tests/bounded_workspace_patch_tests.rs b/crates/warp-core/tests/bounded_workspace_patch_tests.rs index 738fdf22..0aee2ce5 100644 --- a/crates/warp-core/tests/bounded_workspace_patch_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_patch_tests.rs @@ -23,7 +23,7 @@ use warp_core::external_action::{ }; use warp_core::external_action_adapter::{ admit_edict_external_action_request_v1, bounded_workspace_observation_basis_v1, - AdmittedEdictExternalActionRequestV1, + AdmittedEdictExternalActionRequestV1, EdictExternalActionAdmissionErrorV1, }; use warp_core::validated_workspace_patch::{ encode_validated_workspace_patch_input_v1, validated_workspace_patch_authority_v1, @@ -308,6 +308,32 @@ fn exact_compiler_artifacts_admit_one_noncallable_patch_request() { ); } +#[test] +fn request_only_settlement_budget_has_an_exact_admission_floor() { + let authority = digest("authority:budget-floor"); + let basis = digest("basis:budget-floor"); + let patch = raw_patch_input("src/value.txt", b"before", b"after"); + + let exact = must_ok(admit_edict_external_action_request_v1( + WorldlineId::from_bytes([24; 32]), + CORE_BYTES, + TARGET_IR_BYTES, + "applyValidated", + &application_input(patch.clone(), authority, basis, 1_024), + )); + assert_eq!(exact.request().budget.max_settlement_bytes, 1_024); + assert_eq!( + admit_edict_external_action_request_v1( + WorldlineId::from_bytes([25; 32]), + CORE_BYTES, + TARGET_IR_BYTES, + "applyValidated", + &application_input(patch, authority, basis, 1_023), + ), + Err(EdictExternalActionAdmissionErrorV1::InvalidRuntimeValue) + ); +} + #[test] fn durable_claim_precedes_mutation_and_settlement_precedes_replay() { let root = TempRoot::new("golden"); @@ -593,6 +619,39 @@ fn file_budget_and_grant_substitution_fail_closed() { assert_eq!(root.read(path), b"1234"); } +#[test] +fn settlement_budget_is_preflighted_before_mutation() { + let root = TempRoot::new("settlement-budget"); + let segment = "a".repeat(80); + let path = format!( + "{segment}/{segment}/{segment}/{segment}/{segment}/{segment}/{segment}/{segment}/{segment}/{segment}/value.txt" + ); + let before = b"before"; + root.write(&path, before); + let authority = validated_workspace_patch_authority_v1([path.as_str()]); + let admitted = admitted_request(62, &path, before, b"after", authority, 1_024); + let profile = profile(&admitted, "bounded-patch:settlement-budget", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.clone()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "settlement-budget", + ); + + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); + assert_eq!(obstruction(&candidate), "settlement-budget-exceeded"); + assert_eq!(root.read(&path), before); +} + #[test] fn reconciliation_observes_postcondition_without_reapplying() { let root = TempRoot::new("reconcile-success"); From 065530e49239910eeaa6ebcd882f5c830c46e893 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:56:01 -0700 Subject: [PATCH 07/14] docs: define patch budget preflight --- docs/topics/ExternalActions.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/topics/ExternalActions.md b/docs/topics/ExternalActions.md index e35cf190..d4ef03d5 100644 --- a/docs/topics/ExternalActions.md +++ b/docs/topics/ExternalActions.md @@ -37,7 +37,10 @@ basis commitments, a retained-settlement bound, and exactly one attempt. A call expression or callable Target IR step is outside this admission profile. Admission also requires enough retained-settlement capacity to encode every terminal posture; a request cannot select a budget that makes rejection, -failure, or ambiguity unrecordable. +failure, or ambiguity unrecordable. Legacy bounded-observation requests retain +their exact operation-specific boundary. The generic request-only profile +requires at least 1,024 retained bytes so a new adapter can always record a +compact terminal obstruction. ## Adapter Profiles @@ -78,6 +81,8 @@ exact path set plus the single-file, no-follow, regular-file-only, and CI-workflow-exclusion policies. A same-directory temporary file is created without following links, written and synchronized, assigned the original permissions, atomically renamed, and followed by directory synchronization. +The exact success settlement is encoded and checked against the retained-byte +budget before the temporary file is staged. The runtime owner must serialize the granted workspace mutation aperture; this profile is not a general multi-file filesystem transaction. From e13046b53e47720f7eae563da5d2111b93042d98 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:57:50 -0700 Subject: [PATCH 08/14] fix: seal patch staging aperture --- crates/warp-core/src/validated_workspace_patch.rs | 9 ++++++++- .../warp-core/tests/bounded_workspace_patch_tests.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/warp-core/src/validated_workspace_patch.rs b/crates/warp-core/src/validated_workspace_patch.rs index d86db625..7838c9b5 100644 --- a/crates/warp-core/src/validated_workspace_patch.rs +++ b/crates/warp-core/src/validated_workspace_patch.rs @@ -555,6 +555,7 @@ pub fn validated_workspace_patch_authority_v1<'a>( let mut hasher = blake3::Hasher::new(); hasher.update(PATCH_AUTHORITY_DOMAIN); hash_len_prefixed(&mut hasher, b"single-file-replace"); + hash_len_prefixed(&mut hasher, b"attempt-scoped-sibling-staging"); hash_len_prefixed(&mut hasher, b"no-follow"); hash_len_prefixed(&mut hasher, b"regular-file-only"); hash_len_prefixed(&mut hasher, b"ci-workflow-forbidden"); @@ -998,7 +999,13 @@ fn validate_relative_path(path: &str) -> Result<(), ValidatedWorkspacePatchError } fn is_ci_workflow_path(path: &str) -> bool { - path == ".github/workflows" || path.starts_with(".github/workflows/") + let mut components = path.split('/'); + matches!( + (components.next(), components.next()), + (Some(github), Some(workflows)) + if github.eq_ignore_ascii_case(".github") + && workflows.eq_ignore_ascii_case("workflows") + ) } fn open_parent_nofollow( diff --git a/crates/warp-core/tests/bounded_workspace_patch_tests.rs b/crates/warp-core/tests/bounded_workspace_patch_tests.rs index 0aee2ce5..38e7d2d3 100644 --- a/crates/warp-core/tests/bounded_workspace_patch_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_patch_tests.rs @@ -370,6 +370,11 @@ fn durable_claim_precedes_mutation_and_settlement_precedes_replay() { let candidate = must_ok(adapter.apply(&grant, &admitted)); assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); assert_eq!(root.read(path), replacement); + let staged_name = format!( + "src/.message.txt.echo-patch-{}", + hex::encode(grant.claim().attempt_id.as_hash()) + ); + assert!(!root.path().join(staged_name).exists()); assert!(matches!( must_ok(coordinator.claim_grant(request_id)).claim(), claim if claim == grant.claim() @@ -486,6 +491,7 @@ fn stale_basis_and_path_policy_refuse_before_mutation() { root.write(permitted, b"current"); root.write("src/other.txt", b"other"); root.write(".github/workflows/ci.yml", b"name: ci"); + root.write(".GITHUB/Workflows/upper.yml", b"name: upper"); let authority = validated_workspace_patch_authority_v1([permitted]); for (ordinal, requested_path, expected_before, code) in [ @@ -502,6 +508,12 @@ fn stale_basis_and_path_policy_refuse_before_mutation() { b"name: ci".as_slice(), "ci-workflow-refused", ), + ( + 43, + ".GITHUB/Workflows/upper.yml", + b"name: upper".as_slice(), + "ci-workflow-refused", + ), ] { let admitted = admitted_request( ordinal, From 83184444c8af3651245be9635b41b80cf99d21fa Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 02:58:12 -0700 Subject: [PATCH 09/14] docs: bind patch staging authority --- docs/adr/0026-durable-external-action-settlement.md | 5 +++-- docs/topics/ExternalActions.md | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/adr/0026-durable-external-action-settlement.md b/docs/adr/0026-durable-external-action-settlement.md index e3d48083..81a6b1dc 100644 --- a/docs/adr/0026-durable-external-action-settlement.md +++ b/docs/adr/0026-durable-external-action-settlement.md @@ -225,8 +225,9 @@ invoking adapter execution. opens refuse special-file substitution without stalling. - The first capability-rooted mutation adapter applies one validated, basis-bound regular-file replacement. Its authority commits the exact path - aperture and immutable no-follow, single-file, regular-file-only, and - CI-workflow-exclusion policy. It consumes the bounded-observation basis, + aperture, attempt-scoped sibling staging path, and immutable no-follow, + single-file, regular-file-only, and case-insensitive CI-workflow-exclusion + policy. It consumes the bounded-observation basis, records the request and claim before mutation, synchronizes a same-directory atomic replacement, and records the resulting basis and content identities before resumption. Ambiguous attempts are reconciled by bounded diff --git a/docs/topics/ExternalActions.md b/docs/topics/ExternalActions.md index d4ef03d5..ddd36cdc 100644 --- a/docs/topics/ExternalActions.md +++ b/docs/topics/ExternalActions.md @@ -78,9 +78,13 @@ The v1 adapter deliberately supports one file replacement. It rejects escaped, unauthorized, symlinked, special-file, oversized, stale, and `.github/workflows/` targets before mutation. The authority digest commits the exact path set plus the single-file, no-follow, regular-file-only, and -CI-workflow-exclusion policies. A same-directory temporary file is created -without following links, written and synchronized, assigned the original -permissions, atomically renamed, and followed by directory synchronization. +CI-workflow-exclusion policies. It also commits the derived, +attempt-identity-scoped sibling staging rule; the adapter cannot select an +arbitrary second path. A same-directory temporary file is created without +following links, written and synchronized, assigned the original permissions, +atomically renamed, and followed by directory synchronization. CI-workflow +matching is ASCII-case-insensitive so a case-insensitive host cannot alias a +forbidden path through spelling alone. The exact success settlement is encoded and checked against the retained-byte budget before the temporary file is staged. The runtime owner must serialize the granted workspace mutation aperture; this From 542f9b8226706817a86bd9a63c52b03574583aca Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 03:03:35 -0700 Subject: [PATCH 10/14] test: allow bounded patch fixture I/O --- .ban-nondeterminism-allowlist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.ban-nondeterminism-allowlist b/.ban-nondeterminism-allowlist index b29cb2a2..2be9b786 100644 --- a/.ban-nondeterminism-allowlist +++ b/.ban-nondeterminism-allowlist @@ -18,6 +18,9 @@ std-process crates/warp-core/tests/external_action_protocol_tests.rs external-ac std-env crates/warp-core/tests/bounded_workspace_observation_tests.rs bounded-observation filesystem fixture temp directory selection only. std-fs crates/warp-core/tests/bounded_workspace_observation_tests.rs bounded-observation filesystem fixture I/O only. std-process crates/warp-core/tests/bounded_workspace_observation_tests.rs bounded-observation filesystem fixture temp directory disambiguation only. +std-env crates/warp-core/tests/bounded_workspace_patch_tests.rs bounded-patch filesystem fixture temp directory selection only. +std-fs crates/warp-core/tests/bounded_workspace_patch_tests.rs bounded-patch filesystem fixture I/O only. +std-process crates/warp-core/tests/bounded_workspace_patch_tests.rs bounded-patch filesystem fixture temp directory disambiguation only. std-fs crates/warp-core/tests/external_consumer_contract_fixture_tests.rs installed-contract restart WAL fixture I/O only. std-fs crates/warp-core/tests/executable_operation_pipeline_tests.rs executable-operation restart WAL fixture I/O only. std-fs crates/warp-core/tests/provider_contract_admission_tests.rs provider invocation restart WAL fixture I/O only. From 6c60e4021a2bd6281077f529498b42238a451f01 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 03:19:38 -0700 Subject: [PATCH 11/14] fix: sync patch directories on Linux --- crates/warp-core/src/validated_workspace_patch.rs | 5 +++-- crates/warp-core/tests/bounded_workspace_patch_tests.rs | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/warp-core/src/validated_workspace_patch.rs b/crates/warp-core/src/validated_workspace_patch.rs index 7838c9b5..9145a763 100644 --- a/crates/warp-core/src/validated_workspace_patch.rs +++ b/crates/warp-core/src/validated_workspace_patch.rs @@ -1121,10 +1121,11 @@ fn temporary_name(file_name: &OsString, grant: &ExternalActionClaimGrantV1) -> O } fn sync_directory(parent: &Dir) -> Result<(), ValidatedWorkspacePatchErrorV1> { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); parent - .try_clone() + .open_with(".", &options) .map_err(|_| ValidatedWorkspacePatchErrorV1::Io)? - .into_std_file() .sync_all() .map_err(|_| ValidatedWorkspacePatchErrorV1::Io) } diff --git a/crates/warp-core/tests/bounded_workspace_patch_tests.rs b/crates/warp-core/tests/bounded_workspace_patch_tests.rs index 38e7d2d3..1feee211 100644 --- a/crates/warp-core/tests/bounded_workspace_patch_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_patch_tests.rs @@ -368,7 +368,12 @@ fn durable_claim_precedes_mutation_and_settlement_precedes_replay() { assert_eq!(root.read(path), before); let candidate = must_ok(adapter.apply(&grant, &admitted)); - assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); + assert_eq!( + candidate.kind, + ExternalActionSettlementKindV1::Succeeded, + "unexpected settlement obstruction: {}", + obstruction(&candidate) + ); assert_eq!(root.read(path), replacement); let staged_name = format!( "src/.message.txt.echo-patch-{}", From 619dcbf863e3e4f96b519f63dc1e23ab57c0384c Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 03:28:47 -0700 Subject: [PATCH 12/14] test: harden workspace patch admission --- .../tests/bounded_workspace_patch_tests.rs | 205 +++++++++++++++++- 1 file changed, 201 insertions(+), 4 deletions(-) diff --git a/crates/warp-core/tests/bounded_workspace_patch_tests.rs b/crates/warp-core/tests/bounded_workspace_patch_tests.rs index 1feee211..25f2a5dc 100644 --- a/crates/warp-core/tests/bounded_workspace_patch_tests.rs +++ b/crates/warp-core/tests/bounded_workspace_patch_tests.rs @@ -16,10 +16,10 @@ use warp_core::causal_wal::{ use warp_core::external_action::{ claim_external_action, reconcile_external_action_settlement_retry, record_external_action_request, ExternalActionAdapterBindingV1, ExternalActionAdapterIdV1, - ExternalActionAdapterRegistryV1, ExternalActionClaimGrantV1, ExternalActionCoordinatorV1, - ExternalActionProtocolErrorV1, ExternalActionSettlementCandidateV1, - ExternalActionSettlementKindV1, ExternalActionTransactionContextV1, - RecoveredExternalActionPostureV1, + ExternalActionAdapterRegistryV1, ExternalActionAttemptIdV1, ExternalActionClaimGrantV1, + ExternalActionCoordinatorV1, ExternalActionProtocolErrorV1, + ExternalActionSettlementCandidateV1, ExternalActionSettlementKindV1, + ExternalActionTransactionContextV1, RecoveredExternalActionPostureV1, }; use warp_core::external_action_adapter::{ admit_edict_external_action_request_v1, bounded_workspace_observation_basis_v1, @@ -73,6 +73,48 @@ fn map(entries: impl IntoIterator) -> C ) } +fn map_field_mut<'a>(value: &'a mut CanonicalValueV1, field: &str) -> &'a mut CanonicalValueV1 { + let CanonicalValueV1::Map(entries) = value else { + panic!("expected canonical map"); + }; + let value = entries.iter_mut().find_map(|(key, value)| match key { + CanonicalValueV1::Text(key) if key == field => Some(value), + _ => None, + }); + match value { + Some(value) => value, + None => panic!("missing canonical field {field}"), + } +} + +fn encoded(value: &CanonicalValueV1) -> Vec { + must_ok(encode_canonical_cbor_v1(value)) +} + +fn patch_schema_admission_evidence(schema: Hash, bytes: &[u8]) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo.validated-workspace-patch.schema-evidence/v1"); + hasher.update(&schema); + hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes()); + hasher.update(bytes); + hasher.finalize().into() +} + +fn replace_candidate_field( + candidate: &mut ExternalActionSettlementCandidateV1, + field: &str, + value: CanonicalValueV1, +) { + let mut settlement = must_ok(decode_canonical_cbor_v1(&candidate.canonical_result_bytes)); + *map_field_mut(&mut settlement, field) = value; + candidate.canonical_result_bytes = encoded(&settlement); + candidate.declared_result_digest = Hash::from(blake3::hash(&candidate.canonical_result_bytes)); + candidate.schema_admission_evidence_digest = patch_schema_admission_evidence( + candidate.settlement_schema_digest, + &candidate.canonical_result_bytes, + ); +} + fn application_input( patch: Vec, authority: Hash, @@ -308,6 +350,27 @@ fn exact_compiler_artifacts_admit_one_noncallable_patch_request() { ); } +#[test] +fn target_operation_profile_cannot_downgrade_the_core_requirement() { + let authority = digest("authority:profile-derivation"); + let basis = digest("basis:profile-derivation"); + let patch = raw_patch_input("src/value.txt", b"before", b"after"); + let mut target = must_ok(decode_canonical_cbor_v1(TARGET_IR_BYTES)); + let intent = map_field_mut(map_field_mut(&mut target, "intents"), "applyValidated"); + *map_field_mut(intent, "operationProfile") = text("continuum.profile.read-only/v1"); + + assert_eq!( + admit_edict_external_action_request_v1( + WorldlineId::from_bytes([26; 32]), + CORE_BYTES, + &encoded(&target), + "applyValidated", + &application_input(patch, authority, basis, 65_536), + ), + Err(EdictExternalActionAdmissionErrorV1::TargetDerivationMismatch) + ); +} + #[test] fn request_only_settlement_budget_has_an_exact_admission_floor() { let authority = digest("authority:budget-floor"); @@ -551,6 +614,12 @@ fn stale_basis_and_path_policy_refuse_before_mutation() { let candidate = must_ok(adapter.apply(&grant, &admitted)); assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); assert_eq!(obstruction(&candidate), code); + if code == "stale-basis" { + assert_ne!( + candidate.external_evidence_digest, + validated_workspace_patch_basis_v1(requested_path, &before_bytes) + ); + } assert_eq!(root.read(requested_path), before_bytes); } } @@ -636,6 +705,129 @@ fn file_budget_and_grant_substitution_fail_closed() { assert_eq!(root.read(path), b"1234"); } +#[test] +fn settlement_admission_rejects_tampered_candidates() { + let root = TempRoot::new("candidate-admission"); + let path = "src/candidate.txt"; + let before = b"before"; + let replacement = b"after"; + root.write(path, before); + let authority = validated_workspace_patch_authority_v1([path]); + let admitted = admitted_request(63, path, before, replacement, authority, 65_536); + let profile = profile(&admitted, "bounded-patch:candidate-admission", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [path.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "candidate-admission", + ); + let request_id = grant.request().request_id(); + let candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(root.read(path), replacement); + + let mut wrong_attempt = candidate.clone(); + wrong_attempt.attempt_id = + ExternalActionAttemptIdV1::from_hash(digest("candidate:wrong-attempt")); + + let mut outside_path = candidate.clone(); + replace_candidate_field( + &mut outside_path, + "path", + CanonicalValueV1::Text("src/outside.txt".to_owned()), + ); + + let mut zero_external_evidence = candidate.clone(); + zero_external_evidence.external_evidence_digest = [0; 32]; + + let mut substituted_schema_evidence = candidate; + substituted_schema_evidence.schema_admission_evidence_digest = + digest("candidate:substituted-schema-evidence"); + + for (ordinal, tampered) in [ + (0_u8, wrong_attempt), + (1, outside_path), + (2, zero_external_evidence), + (3, substituted_schema_evidence), + ] { + let grant = must_ok(coordinator.claim_grant(request_id)); + assert!(matches!( + adapter.admit_settlement( + &mut store, + &mut coordinator, + context(&format!("candidate-admission:{ordinal}")), + &admitted, + grant, + tampered, + ), + Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed) + )); + assert_eq!(root.read(path), replacement); + assert_eq!( + must_some(coordinator.observed_index().get(request_id)).posture, + RecoveredExternalActionPostureV1::Claimed + ); + } +} + +#[test] +fn settlement_admission_rejects_unknown_obstruction_codes() { + let root = TempRoot::new("obstruction-vocabulary"); + let permitted = "src/permitted.txt"; + let denied = "src/denied.txt"; + root.write(permitted, b"preserved"); + let authority = validated_workspace_patch_authority_v1([permitted]); + let admitted = admitted_request(64, denied, b"absent", b"after", authority, 65_536); + let profile = profile(&admitted, "bounded-patch:obstruction-vocabulary", 65_536); + let adapter = must_ok(ValidatedWorkspacePatchAdapterV1::open( + root.path(), + [permitted.to_owned()], + profile, + )); + let mut store = store(); + let mut coordinator = must_ok(ExternalActionCoordinatorV1::recover(&store)); + let grant = claim( + &mut store, + &mut coordinator, + &admitted, + adapter.adapter_binding(), + "obstruction-vocabulary", + ); + let request_id = grant.request().request_id(); + let mut candidate = must_ok(adapter.apply(&grant, &admitted)); + assert_eq!(obstruction(&candidate), "unauthorized-path"); + replace_candidate_field( + &mut candidate, + "obstruction", + CanonicalValueV1::Text("unknown-obstruction".to_owned()), + ); + + assert!(matches!( + adapter.admit_settlement( + &mut store, + &mut coordinator, + context("obstruction-vocabulary:settlement"), + &admitted, + grant, + candidate, + ), + Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed) + )); + assert_eq!(root.read(permitted), b"preserved"); + assert!(!root.path().join(denied).exists()); + assert_eq!( + must_some(coordinator.observed_index().get(request_id)).posture, + RecoveredExternalActionPostureV1::Claimed + ); +} + #[test] fn settlement_budget_is_preflighted_before_mutation() { let root = TempRoot::new("settlement-budget"); @@ -666,6 +858,7 @@ fn settlement_budget_is_preflighted_before_mutation() { let candidate = must_ok(adapter.apply(&grant, &admitted)); assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Rejected); assert_eq!(obstruction(&candidate), "settlement-budget-exceeded"); + assert!(candidate.canonical_result_bytes.len() <= 1_024); assert_eq!(root.read(&path), before); } @@ -714,6 +907,10 @@ fn reconciliation_observes_postcondition_without_reapplying() { )); let candidate = must_ok(reconciler.reconcile(&grant, &admitted)); assert_eq!(candidate.kind, ExternalActionSettlementKindV1::Succeeded); + assert_eq!( + field(&candidate, "beforeContentDigest"), + CanonicalValueV1::Null + ); assert_eq!(root.read(path), replacement); must_ok(reconciler.admit_settlement( &mut store, From 893782d3cff380d1d95466f0f40b1134a4c2ca60 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 03:37:04 -0700 Subject: [PATCH 13/14] fix: bind patch settlement evidence --- .../warp-core/src/external_action_adapter.rs | 4 ++ .../src/validated_workspace_patch.rs | 57 ++++++++++++++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/warp-core/src/external_action_adapter.rs b/crates/warp-core/src/external_action_adapter.rs index 0b21a789..5479c670 100644 --- a/crates/warp-core/src/external_action_adapter.rs +++ b/crates/warp-core/src/external_action_adapter.rs @@ -49,6 +49,8 @@ const OBSERVATION_REFUSAL_EVIDENCE_DOMAIN: &[u8] = b"echo.bounded-observation.re const MAX_CORE_EVALUATION_STEPS_V1: u64 = 4_096; const MAX_CORE_EVALUATION_ALLOCATED_BYTES_V1: u64 = 4 * 1_024 * 1_024; const MAX_CORE_EVALUATION_OUTPUT_BYTES_V1: u64 = 1_024 * 1_024; +// Must continue to fit the pathless terminal obstruction asserted by the +// workspace-patch settlement-budget boundary test. const MIN_REQUEST_ONLY_SETTLEMENT_BYTES_V1: u64 = 1_024; /// One canonical Edict request admitted from exact Core and Target IR bytes. @@ -1083,6 +1085,8 @@ fn verify_target_derivation( || require_field(core_intent, "coreEvaluationBudget")? != require_field(target_intent, "coreEvaluationBudget")? || require_field(core_intent, "basis")? != require_field(target_intent, "basis")? + || require_field(core_intent, "requiredOperationProfile")? + != require_field(target_intent, "operationProfile")? { return Err(EdictExternalActionAdmissionErrorV1::TargetDerivationMismatch); } diff --git a/crates/warp-core/src/validated_workspace_patch.rs b/crates/warp-core/src/validated_workspace_patch.rs index 9145a763..345569fb 100644 --- a/crates/warp-core/src/validated_workspace_patch.rs +++ b/crates/warp-core/src/validated_workspace_patch.rs @@ -6,6 +6,11 @@ //! authority. Echo durably records and claims the request before this adapter //! can validate or mutate one exact regular file. Ambiguous attempts are //! reconciled by observation and are never blindly reapplied. +//! +//! The adapter refuses a basis already observed stale. It does not provide +//! serializability against an external writer racing the final check and +//! rename. `beforeContentDigest` records the earlier observation; it does not +//! prove that no intermediate write occurred. use std::collections::BTreeSet; use std::ffi::OsString; @@ -269,6 +274,7 @@ impl ValidatedWorkspacePatchAdapterV1 { &grant, admitted, &candidate, + true, )?; Ok(admit_external_action_settlement( store, @@ -322,7 +328,7 @@ impl ValidatedWorkspacePatchAdapterV1 { ) -> Result { let (evidence, before_content_digest) = observed.map_or_else( || (external_evidence(code, path.unwrap_or("")), None), - |(basis, digest)| (basis, Some(digest)), + |(basis, digest)| (observed_external_evidence(code, basis), Some(digest)), ); self.candidate( grant, @@ -349,7 +355,7 @@ impl ValidatedWorkspacePatchAdapterV1 { ) -> Result { let (evidence, before_content_digest) = observed.map_or_else( || (external_evidence(code, path.unwrap_or("")), None), - |(basis, digest)| (basis, Some(digest)), + |(basis, digest)| (observed_external_evidence(code, basis), Some(digest)), ); self.candidate( grant, @@ -458,7 +464,7 @@ impl ValidatedWorkspacePatchReconcilerV1 { path: Some(input.path), request_basis: grant.request().basis_digest, evidence: observed_basis, - before_content_digest: Some(input.expected_content_digest), + before_content_digest: None, after_content_digest: Some(observed_digest), resulting_basis: Some(observed_basis), obstruction: None, @@ -473,7 +479,7 @@ impl ValidatedWorkspacePatchReconcilerV1 { posture: "outcomeUnknown", path: Some(input.path), request_basis: grant.request().basis_digest, - evidence: observed_basis, + evidence: observed_external_evidence("postcondition-not-observed", observed_basis), before_content_digest: Some(observed_digest), after_content_digest: None, resulting_basis: None, @@ -499,6 +505,7 @@ impl ValidatedWorkspacePatchReconcilerV1 { &grant, admitted, &candidate, + false, )?; Ok(admit_external_action_settlement( store, @@ -679,6 +686,7 @@ fn validate_candidate( grant: &ExternalActionClaimGrantV1, admitted: &AdmittedEdictExternalActionRequestV1, candidate: &ExternalActionSettlementCandidateV1, + requires_observed_before: bool, ) -> Result<(), ValidatedWorkspacePatchErrorV1> { if candidate.request_id != grant.request().request_id() || candidate.attempt_id != grant.claim().attempt_id @@ -710,9 +718,11 @@ fn validate_candidate( }; let expected_resulting_basis = validated_workspace_patch_basis_v1(&input.path, &input.replacement); + let expected_before_content_digest = + requires_observed_before.then_some(input.expected_content_digest); if evidence.path.as_deref() != Some(input.path.as_str()) || !permitted_paths.contains(&input.path) - || evidence.before_content_digest != Some(input.expected_content_digest) + || evidence.before_content_digest != expected_before_content_digest || evidence.after_content_digest != Some(input.replacement_digest) || evidence.resulting_basis != Some(expected_resulting_basis) || evidence.evidence != expected_resulting_basis @@ -720,7 +730,10 @@ fn validate_candidate( { return Err(ValidatedWorkspacePatchErrorV1::SchemaAdmissionFailed); } - } else if evidence.obstruction.as_deref().is_none_or(str::is_empty) + } else if evidence + .obstruction + .as_deref() + .is_none_or(|code| !is_known_obstruction(code)) || evidence.resulting_basis.is_some() || evidence.after_content_digest.is_some() { @@ -778,7 +791,7 @@ fn build_outcome_unknown_candidate( ) -> Result { let (evidence, before_content_digest) = observed.map_or_else( || (external_evidence(code, path.unwrap_or("")), None), - |(basis, digest)| (basis, Some(digest)), + |(basis, digest)| (observed_external_evidence(code, basis), Some(digest)), ); build_candidate( profile, @@ -1153,6 +1166,36 @@ fn external_evidence(code: &str, detail: &str) -> Hash { hasher.finalize().into() } +fn observed_external_evidence(code: &str, observed_basis: Hash) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(PATCH_EXTERNAL_EVIDENCE_DOMAIN); + hash_len_prefixed(&mut hasher, code.as_bytes()); + hash_len_prefixed(&mut hasher, &observed_basis); + hasher.finalize().into() +} + +fn is_known_obstruction(code: &str) -> bool { + matches!( + code, + "malformed-input" + | "replacement-budget-exceeded" + | "invalid-path" + | "ci-workflow-refused" + | "unauthorized-path" + | "symlink-refused" + | "not-regular-file" + | "file-budget-exceeded" + | "io-failure" + | "stale-basis" + | "settlement-budget-exceeded" + | "rename-outcome-unknown" + | "directory-sync-outcome-unknown" + | "postcondition-unreadable" + | "postcondition-mismatch" + | "postcondition-not-observed" + ) +} + fn posture_for(kind: ExternalActionSettlementKindV1) -> &'static str { match kind { ExternalActionSettlementKindV1::Succeeded => "succeeded", From ce2133311eb64604f8c98498e7244f56bf272370 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 03:37:28 -0700 Subject: [PATCH 14/14] docs: clarify patch evidence provenance --- CHANGELOG.md | 9 +++++---- .../fixtures/external_action_patch/SOURCE.md | 10 +++++++++- docs/topics/ExternalActions.md | 16 +++++++++++----- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0fd82da..9a0a187c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,11 @@ and CI-workflow-exclusion policy, and byte budgets before mutation. The adapter rejects stale bases, escaped or substituted paths, symlinks, special files, and oversized writes without mutation; synchronizes a same-directory - atomic replacement; and settles the resulting basis and before/after content - identities before resumption. Claimed attempts reconcile by observing the - exact postcondition or settling `OutcomeUnknown`, never by reapplying the - patch. Identical settlement retry and replay remain effect-free. + atomic replacement; and settles the resulting basis and observed before/after + content identities before resumption. Claimed attempts reconcile by observing + only the exact postcondition or settling `OutcomeUnknown`, never by claiming + an unobserved pre-state or reapplying the patch. Identical settlement retry + and replay remain effect-free. - 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's exact claim diff --git a/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md b/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md index f57d1380..89ec8fba 100644 --- a/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md +++ b/crates/warp-core/tests/fixtures/external_action_patch/SOURCE.md @@ -7,14 +7,22 @@ These bytes were copied without modification from Edict merge commit `cf8c17f917b7262be2c89fa136898e01dab7f40a`: - `fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor` +- `fixtures/lawpack/workspace-patch/apply-validated-patch.core.sha256` - `fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor` +- `fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.sha256` -Edict owns regeneration through: +Edict emits the `.cbor` files and their reviewed `sha256:` identities together. +Echo copies all four files unchanged; it does not recompute or regenerate the +digest fixtures. Edict owns their refresh through: ```sh cargo xtask lawpack-goldens --write ``` +Refresh the Edict goldens first, copy the four exact files from one committed +Edict revision, and update the source commit above in the same Echo change. +Echo's `lawpack-goldens` task does not own these external compiler artifacts. + Echo treats the files as received compiler artifacts. It independently checks canonical encoding, the reviewed source-Core digest, the exact capability closure, request-only shape, runtime request fields, and target identity. diff --git a/docs/topics/ExternalActions.md b/docs/topics/ExternalActions.md index ddd36cdc..b9981a78 100644 --- a/docs/topics/ExternalActions.md +++ b/docs/topics/ExternalActions.md @@ -88,7 +88,11 @@ forbidden path through spelling alone. The exact success settlement is encoded and checked against the retained-byte budget before the temporary file is staged. The runtime owner must serialize the granted workspace mutation aperture; this -profile is not a general multi-file filesystem transaction. +profile is not a general multi-file filesystem transaction. The adapter refuses +a basis already observed stale, but the final check and rename do not establish +serializability against an external writer. The direct success settlement's +before-content identity is the earlier observation, not proof that no +intermediate write occurred. ## Ordering @@ -142,10 +146,12 @@ Before generic WAL admission, the operation profile independently validates: Malformed or substituted candidates fail before the settlement transaction. -Patch success retains the request's observation basis, exact resulting basis, -path, before and after content identities, adapter evidence, and postcondition -evidence. Stale basis, path policy, special-file, and byte-budget violations -settle as typed rejections without mutation. The adapter can return +Direct patch success retains the request's observation basis, exact resulting +basis, path, observed before and after content identities, adapter evidence, and +postcondition evidence. A reconciled success retains only the postcondition +identity it actually observed; its `beforeContentDigest` is absent. Stale basis, +path policy, special-file, and byte-budget violations settle as typed rejections +without mutation. The adapter can return `OutcomeUnknown` after an ambiguous rename, directory synchronization, or postcondition read. A terminal settlement is durable before its result becomes resumable program input.