From a2135b259fa136f9a363d17e656a3143b31ecd2e Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 09:00:21 -0400 Subject: [PATCH 01/10] [FLO-13.2c.1] Reject incomplete artifact outcomes --- docs/integrations/README.md | 8 +- docs/integrations/hermetic-provider-kit.md | 51 ++++-- tests/fixtures/hermetic-provider/README.md | 10 +- tests/fixtures/hermetic-provider/main.rs | 59 ++++-- tests/hermetic_provider_kit.rs | 197 +++++++++++++++++++-- 5 files changed, 282 insertions(+), 43 deletions(-) diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 2d67eac..305f94b 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -107,9 +107,11 @@ and `AcceptedArtifactSet`. Two fresh roots prove identical semantic and content evidence while package, input, and binding bytes remain unchanged. Flow #45 adds prelaunch rejection, warning/partial evidence, bounded process lifecycle, stream overflow, invalid protocol evidence, and authoritative host-rejection -cases without weakening the immutable-package or success baseline. Physical -artifact adversaries, graph fixtures, and the final parent matrix remain with -#46. +cases without weakening the immutable-package or success baseline. Flow #56 +adds protocol-valid missing, extra, and partial physical artifact outcomes and +proves that each stops at its exact host-observation or artifact-acceptance +boundary. Corrupt/stale/contradictory evidence, graph fixtures, and the final +parent matrix remain with #57, #58, and #59 respectively. The following remain deferred: provider discovery from the filesystem, dynamic loading, cryptographic authenticity and transparency verification, diff --git a/docs/integrations/hermetic-provider-kit.md b/docs/integrations/hermetic-provider-kit.md index e48d4df..6e08dbc 100644 --- a/docs/integrations/hermetic-provider-kit.md +++ b/docs/integrations/hermetic-provider-kit.md @@ -6,9 +6,10 @@ The hermetic provider kit is Flow-owned conformance infrastructure for issue #29. Checkpoint #44 establishes its immutable package identity and deterministic success path. Checkpoint #45 keeps that baseline intact while adding a closed matrix for provider selection, lifecycle supervision, and protocol outcomes. -Both checkpoints exercise released public boundaries without importing a -sibling implementation or pretending to be an Aniflow, Optiflow, or Renderflow -algorithm. +Checkpoint #56 adds the first physical artifact-outcome matrix for missing, +extra, and partial outputs. All three checkpoints exercise released public +boundaries without importing a sibling implementation or pretending to be an +Aniflow, Optiflow, or Renderflow algorithm. The checkpoint composes only public contracts and APIs: @@ -54,16 +55,18 @@ means that its bound source is not mutated; it is not a claim that the shared multi-capability provider receives no output-write authority. Checkpoint #44 executes the inspection capability end to end. Checkpoint #45 -uses that same capability for the lifecycle and protocol matrix. The other -three IDs and their declared media/configuration profiles remain frozen so the -later graph fixtures do not invent parallel identities. Their end-to-end -coverage remains explicitly deferred. +uses that same capability for the lifecycle and protocol matrix, and checkpoint +#56 uses it for physical artifact outcomes after protocol-valid execution. The +other three IDs and their declared media/configuration profiles remain frozen +so the later graph fixtures do not invent parallel identities. Their +end-to-end coverage remains explicitly deferred. ## Configuration and deterministic success profile Configuration uses `flow.hermetic-provider-configuration/v1` with exactly two string fields. Checkpoint #44 defines `success`; checkpoint #45 expands the -closed `mode` vocabulary without changing the schema or the success evidence: +closed `mode` vocabulary, and checkpoint #56 adds three artifact-outcome modes +without changing the schema or the success evidence: | Field | Checkpoint-1 value | Identity rule | | --- | --- | --- | @@ -105,6 +108,18 @@ configuration after resolution rejects it. | `invalid-result` | Runtime configuration emits a terminal result whose authorization identity does not match the invocation. | Semantic result validation | `ProcessRunnerError::Validation` containing `ExecutionError::InvalidResult`; the raw result is retained for inspection. | No `ValidatedExecution` or `AcceptedArtifactSet`. | | `success-with-host-rejection` | The provider emits valid success-shaped evidence and the caller's authoritative `EventSink` rejects an event. | Host semantic-observation boundary | `ProcessRunnerError::Validation` containing `ExecutionError::EventSink`; decoded events and the result remain inspectable. | No `ValidatedExecution`, fallback, or `AcceptedArtifactSet`. | +## Checkpoint-3a physical artifact outcome matrix + +These modes deliberately pass process transport and semantic result validation. +They fail only when Flow observes or accepts physical artifact evidence. The +extra file is a closed fixture behavior, not an automatic discovery feature. + +| Case | Explicit trigger | Owning boundary | Exact outcome | Promotion | +| --- | --- | --- | --- | --- | +| `missing-output` | Runtime configuration suppresses creation of the one bound candidate while retaining a complete success-shaped transcript. | Host artifact observation | `observe_artifacts` returns `ArtifactObservationError::Missing` for the exact bound ID and locator. | Produces `ValidatedExecution`; no `ObservedArtifactSet` or `AcceptedArtifactSet`. | +| `extra-output` | The provider creates the bound candidate plus `outputs/undeclared-extra-output.json` and names both IDs in its event and result. | Artifact acceptance | Observation covers only the explicit binding set; `accept_artifacts` returns `ArtifactAcceptanceError::Mismatch` because provider-produced IDs do not exactly match declared outputs. | Produces `ValidatedExecution` and `ObservedArtifactSet`; no `AcceptedArtifactSet`. The undeclared sibling is not discovered or promoted. | +| `partial-output` | The provider writes a deterministic truncated synthetic candidate, reports its exact digest, and sets `partial_result: true`. | Artifact acceptance | Observation succeeds for the bytes that exist; `accept_artifacts` returns `ArtifactAcceptanceError::Mismatch` because acceptance requires a complete produced or reused result. | Produces `ValidatedExecution` and `ObservedArtifactSet`; no `AcceptedArtifactSet`. Flow does not claim generic JSON or provider-native semantic validation. | + The stream-capture `observed` value is a bounded overflow sentinel, not the provider's total emitted byte count. Each worker drains its stream to EOF but retains at most the configured limit plus one byte. @@ -158,6 +173,13 @@ baseline requires: - successful promotion through both `ValidatedExecution` and `AcceptedArtifactSet`. +The checkpoint #56 cases reuse the same real process path and additionally +prove that missing output stops at typed host observation, that an explicitly +undeclared sibling never expands the binding set, and that extra or partial +provider evidence stops at typed artifact acceptance. Every case re-observes +the immutable package and verifies that input and binding bytes remain +unchanged. + The fixture source, manifest, lock, materialization rules, offline commands, and redistribution terms live in [`tests/fixtures/hermetic-provider`](../../tests/fixtures/hermetic-provider/README.md). @@ -175,11 +197,8 @@ and reaping; it does not claim an operating-system sandbox, filesystem containme descriptor-bound execution, publisher authentication, descendant cleanup, or provider-native semantic validation. -Checkpoint #46 still owns missing, extra, corrupt, stale, changed, partial, and -contradictory physical artifact evidence; protocol-valid success followed by -host observation or artifact-acceptance failure; single- and multi-provider -graph fixtures; completed redistribution documentation; and the final parent -#29 requirement-to-test matrix. Checkpoint #45's `partial-result` case changes -only the result flag while leaving physical artifact-adversary coverage to that -final checkpoint. Durable run state, retry, checkpoint, and resume remain -outside all three. +Checkpoint #57 owns corrupt, stale, changed, and contradictory artifact +evidence. Checkpoint #58 owns the single- and multi-provider composition +fixtures. Checkpoint #59 owns completed redistribution documentation and the +final parent #29 requirement-to-test matrix. Durable run state, retry, +checkpoint, and resume remain outside the hermetic provider kit. diff --git a/tests/fixtures/hermetic-provider/README.md b/tests/fixtures/hermetic-provider/README.md index 30e34c5..df57922 100644 --- a/tests/fixtures/hermetic-provider/README.md +++ b/tests/fixtures/hermetic-provider/README.md @@ -77,6 +77,9 @@ runtime `mode` values are: | `success` | Writes one deterministic candidate artifact and a complete valid transcript. | | `warning` | Writes the same complete evidence with a redacted warning diagnostic; warning is not a distinct terminal outcome. | | `partial-result` | Writes a complete candidate and a protocol-valid produced result whose `partial_result` flag is true. | +| `missing-output` | Emits a complete valid transcript for the bound candidate without creating its file, so host observation returns the exact typed missing-artifact error. | +| `extra-output` | Writes the bound candidate plus one closed, bounded undeclared sibling and names both in the artifact event and result; observation remains binding-driven and acceptance rejects the extra provider declaration. | +| `partial-output` | Writes a deterministic truncated synthetic candidate, reports its exact byte digest, and sets `partial_result` so acceptance rejects incomplete evidence without claiming generic format validation. | | `nonzero-after-success` | Flushes success-shaped stdout, then exits with code `7`. | | `await-interruption` | Creates the explicit lifecycle control record, then waits for host timeout or cancellation. | | `stdout-overflow` | Emits deterministic stdout beyond the invocation limit. | @@ -119,10 +122,9 @@ no subprocess, mutates no source, and performs no destructive, signing, or publication action. `trusted-unconfined` remains an explicit test profile, not a sandbox or containment claim. -Artifact missing/extra/corrupt/stale/contradictory cases, host physical- -artifact observation and acceptance failures after complete protocol success, -graph fixtures, and the final parent requirement matrix remain outside this -checkpoint and are owned by issue #46. +Corrupt, changed, stale, and contradictory artifact cases remain with #57. +Graph fixtures remain with #58, and final redistribution documentation plus +the parent requirement matrix remain with #59. The source and generated package are distributed under the repository's MIT license. Do not redistribute a materialized package without its license or diff --git a/tests/fixtures/hermetic-provider/main.rs b/tests/fixtures/hermetic-provider/main.rs index 2c69ff9..4e65980 100644 --- a/tests/fixtures/hermetic-provider/main.rs +++ b/tests/fixtures/hermetic-provider/main.rs @@ -21,6 +21,8 @@ use sha2::{Digest, Sha256}; const CONFIGURATION_SCHEMA: &str = "flow.hermetic-provider-configuration/v1"; const ARTIFACT_SCHEMA: &str = "flow.hermetic-artifact/v1"; +const EXTRA_OUTPUT_ID: &str = "artifact:undeclared-extra-output"; +const EXTRA_OUTPUT_LOCATOR: &str = "outputs/undeclared-extra-output.json"; const NONZERO_AFTER_SUCCESS_EXIT_CODE: i32 = 7; const MAX_OVERFLOW_BYTES: u64 = 1_048_576; @@ -38,6 +40,9 @@ enum Behavior { Success, Warning, PartialResult, + MissingOutput, + ExtraOutput, + PartialOutput, NonzeroAfterSuccess, AwaitInterruption, StdoutOverflow, @@ -53,6 +58,9 @@ impl Behavior { "success" => Ok(Self::Success), "warning" => Ok(Self::Warning), "partial-result" => Ok(Self::PartialResult), + "missing-output" => Ok(Self::MissingOutput), + "extra-output" => Ok(Self::ExtraOutput), + "partial-output" => Ok(Self::PartialOutput), "nonzero-after-success" => Ok(Self::NonzeroAfterSuccess), "await-interruption" => Ok(Self::AwaitInterruption), "stdout-overflow" => Ok(Self::StdoutOverflow), @@ -208,7 +216,6 @@ fn run() -> ProviderResult<()> { output_binding.media_type == expected_output_type, "output binding type does not match the selected capability", )?; - let output_path = confined_new_output_path(&root, Path::new(&output_binding.locator))?; let artifact = HermeticArtifact { schema_version: ARTIFACT_SCHEMA, capability_id: &invocation.capability_id, @@ -223,16 +230,30 @@ fn run() -> ProviderResult<()> { }; let mut artifact_bytes = serde_json::to_vec(&artifact)?; artifact_bytes.push(b'\n'); - let mut output = OpenOptions::new() - .write(true) - .create_new(true) - .open(output_path)?; - output.write_all(&artifact_bytes)?; - output.sync_all()?; + if behavior == Behavior::PartialOutput { + artifact_bytes.truncate(artifact_bytes.len().div_ceil(2)); + } + if behavior != Behavior::MissingOutput { + write_new_output( + &root, + Path::new(&output_binding.locator), + &artifact_bytes, + )?; + } + if behavior == Behavior::ExtraOutput { + write_new_output( + &root, + Path::new(EXTRA_OUTPUT_LOCATOR), + b"{\"schema_version\":\"flow.hermetic-extra-artifact/v1\"}\n", + )?; + } let output_digest = digest_bytes(&artifact_bytes); let consumed_artifacts = vec![input_binding.artifact_id.clone()]; - let produced_artifacts = vec![output_binding.artifact_id.clone()]; + let mut produced_artifacts = vec![output_binding.artifact_id.clone()]; + if behavior == Behavior::ExtraOutput { + produced_artifacts.push(EXTRA_OUTPUT_ID.to_owned()); + } let mut events = [ event( &invocation, @@ -314,13 +335,17 @@ fn run() -> ProviderResult<()> { message: "The hermetic provider completed with synthetic warning evidence.".to_owned(), redacted: true, }), - Behavior::PartialResult => result.partial_result = true, + Behavior::PartialResult | Behavior::PartialOutput => result.partial_result = true, Behavior::InvalidEvent => events[1].sequence = events[0].sequence, Behavior::InvalidResult => { "authorization:hermetic-invalid-result-mismatch" .clone_into(&mut result.authorization_id); } - Behavior::Success | Behavior::NonzeroAfterSuccess | Behavior::SuccessWithHostRejection => {} + Behavior::Success + | Behavior::MissingOutput + | Behavior::ExtraOutput + | Behavior::NonzeroAfterSuccess + | Behavior::SuccessWithHostRejection => {} Behavior::AwaitInterruption | Behavior::StdoutOverflow | Behavior::StderrOverflow => { unreachable!("non-artifact behaviors return before evidence construction") } @@ -507,6 +532,17 @@ fn confined_new_output_path(root: &Path, locator: &Path) -> ProviderResult ProviderResult<()> { + let output_path = confined_new_output_path(root, locator)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(output_path)?; + output.write_all(bytes)?; + output.sync_all()?; + Ok(()) +} + fn ensure_portable_locator(locator: &Path) -> ProviderResult<()> { ensure(!locator.as_os_str().is_empty(), "locator must not be empty")?; ensure(!locator.is_absolute(), "locator must be relative")?; @@ -636,6 +672,9 @@ mod tests { ("success", Behavior::Success), ("warning", Behavior::Warning), ("partial-result", Behavior::PartialResult), + ("missing-output", Behavior::MissingOutput), + ("extra-output", Behavior::ExtraOutput), + ("partial-output", Behavior::PartialOutput), ("nonzero-after-success", Behavior::NonzeroAfterSuccess), ("await-interruption", Behavior::AwaitInterruption), ("stdout-overflow", Behavior::StdoutOverflow), diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index 316da86..ccf9ba7 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -7,16 +7,16 @@ use std::sync::atomic::{AtomicU64, Ordering}; use flow::{ ARTIFACT_BINDINGS_V1, AcceptedArtifactSet, ArtifactAcceptanceError, ArtifactBindingSet, - ArtifactKind, Authorization, AuthorizedProcess, CheckpointMode, Configuration, Domain, - EXECUTION_SUBJECT_LOCK_V1, EventKind, EventSinkError, EventState, ExecutionError, - ExecutionModeKind, ExecutionSubjectLock, ExtensionCatalog, ExtensionInvocation, ExtensionLock, - ExtensionManifest, ExtensionObservation, ExtensionResolution, FallbackPolicy, - HostArtifactObservationSet, HostExecutionSubjectObservationSet, InputArtifact, - InputArtifactBinding, InvocationExtension, InvocationInterface, InvocationPhase, - LocalProcessRunner, MatchedExecutionSubjects, NoSecrets, OutputArtifactBinding, - PROCESS_AUTHORITY_PROFILE_V1, ProcessIsolation, ProcessRunnerError, ProcessStream, - ProvenanceKind, ResolutionOutcome, ResolutionRequest, ResolutionResult, SHA256, Severity, - Trust, ValidatedExecution, ValidationStatus, accept_artifacts, authorize_process, + ArtifactKind, ArtifactObservationError, Authorization, AuthorizedProcess, CheckpointMode, + Configuration, Domain, EXECUTION_SUBJECT_LOCK_V1, EventKind, EventSinkError, EventState, + ExecutionError, ExecutionModeKind, ExecutionSubjectLock, ExtensionCatalog, + ExtensionInvocation, ExtensionLock, ExtensionManifest, ExtensionObservation, + ExtensionResolution, FallbackPolicy, HostArtifactObservationSet, + HostExecutionSubjectObservationSet, InputArtifact, InputArtifactBinding, InvocationExtension, + InvocationInterface, InvocationPhase, LocalProcessRunner, MatchedExecutionSubjects, NoSecrets, + OutputArtifactBinding, PROCESS_AUTHORITY_PROFILE_V1, ProcessIsolation, ProcessRunnerError, + ProcessStream, ProvenanceKind, ResolutionOutcome, ResolutionRequest, ResolutionResult, SHA256, + Severity, Trust, ValidatedExecution, ValidationStatus, accept_artifacts, authorize_process, observe_artifacts, observe_execution_subjects, }; use serde::Serialize; @@ -38,6 +38,8 @@ const INPUT_LOCATOR: &str = "inputs/source-text.txt"; const INPUT_ID: &str = "artifact:source-text"; const CONFIGURATION_SCHEMA: &str = "flow.hermetic-provider-configuration/v1"; const LIFECYCLE_CONTROL_LOCATOR: &str = "outputs/lifecycle-control.json"; +const EXTRA_OUTPUT_ID: &str = "artifact:undeclared-extra-output"; +const EXTRA_OUTPUT_LOCATOR: &str = "outputs/undeclared-extra-output.json"; static NEXT_ROOT_ID: AtomicU64 = AtomicU64::new(0); @@ -425,6 +427,181 @@ fn warning_and_partial_results_remain_semantically_distinct() { partial_fixture.assert_immutable_workspace_bytes(&partial_before); } +#[test] +fn missing_bound_output_fails_observation_after_valid_provider_success() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = fixture.prepare_lifecycle(capability, "missing-output", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + + let execution = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + + assert_eq!(events, execution.events()); + assert_eq!(execution.result().outcome, flow::Outcome::Produced); + assert!(!execution.result().partial_result); + assert_eq!( + execution.result().produced_artifacts, + [fixture.bindings.outputs[0].artifact_id.as_str()] + ); + assert!(!fixture.output_path().exists()); + + let error = observe_artifacts( + &fixture.root.path().join(WORKSPACE_LOCATOR), + &fixture.bindings, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactObservationError::Missing { + ref artifact_id, + ref locator, + } if artifact_id == &fixture.bindings.outputs[0].artifact_id + && locator == &fixture.bindings.outputs[0].locator + )); + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + +#[test] +fn extra_provider_output_fails_exact_acceptance_without_auto_discovery() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = fixture.prepare_lifecycle(capability, "extra-output", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + + let execution = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + + assert_eq!(events, execution.events()); + assert_eq!( + execution.result().produced_artifacts, + [ + fixture.bindings.outputs[0].artifact_id.as_str(), + EXTRA_OUTPUT_ID, + ] + ); + assert_eq!( + execution.events()[1].artifact_refs, + [ + fixture.bindings.outputs[0].artifact_id.as_str(), + EXTRA_OUTPUT_ID, + ] + ); + assert!( + fixture + .root + .path() + .join(WORKSPACE_LOCATOR) + .join(EXTRA_OUTPUT_LOCATOR) + .is_file() + ); + + let observed = observe_artifacts( + &fixture.root.path().join(WORKSPACE_LOCATOR), + &fixture.bindings, + ) + .unwrap(); + assert_eq!( + observed + .evidence() + .artifacts + .iter() + .map(|artifact| artifact.artifact_id.as_str()) + .collect::>(), + [ + fixture.bindings.inputs[0].artifact_id.as_str(), + fixture.bindings.outputs[0].artifact_id.as_str(), + ] + ); + let error = accept_artifacts( + prepared.resolved(), + &prepared.invocation, + &execution, + &fixture.bindings, + &observed, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "provider-produced artifacts do not exactly match declared outputs" + )); + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + +#[test] +fn partial_output_is_observable_but_cannot_be_accepted() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = fixture.prepare_lifecycle(capability, "partial-output", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + + let execution = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + + assert_eq!(events, execution.events()); + assert!(execution.result().partial_result); + let output_bytes = fs::read(fixture.output_path()).unwrap(); + assert!(!output_bytes.ends_with(b"\n")); + assert!(serde_json::from_slice::(&output_bytes).is_err()); + let observed = observe_artifacts( + &fixture.root.path().join(WORKSPACE_LOCATOR), + &fixture.bindings, + ) + .unwrap(); + assert_eq!( + execution.result().provenance[2].value, + format!("sha256:{}", observed.evidence().artifacts[1].digest) + ); + let error = accept_artifacts( + prepared.resolved(), + &prepared.invocation, + &execution, + &fixture.bindings, + &observed, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "artifact acceptance requires a complete produced or reused result" + )); + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + #[test] fn nonzero_after_success_never_promotes_provider_evidence() { let capability = CAPABILITIES[0]; From be0030d56d359ce86841a587241d8f887f8a5711 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 09:03:26 -0400 Subject: [PATCH 02/10] Clarify hermetic adversary evidence --- tests/fixtures/hermetic-provider/main.rs | 26 ++++++++++++++++-------- tests/hermetic_provider_kit.rs | 16 +++++++-------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/fixtures/hermetic-provider/main.rs b/tests/fixtures/hermetic-provider/main.rs index 4e65980..518c3e6 100644 --- a/tests/fixtures/hermetic-provider/main.rs +++ b/tests/fixtures/hermetic-provider/main.rs @@ -1,8 +1,9 @@ //! Redistribution-safe synthetic process provider used by Flow conformance tests. //! //! This executable deliberately uses only Flow's public contracts. It reads one -//! invocation frame, verifies explicitly bound inputs, writes one deterministic -//! candidate artifact, and emits a JSON Lines event/result transcript. +//! invocation frame, verifies explicitly bound inputs, exercises one closed +//! deterministic success or adversarial behavior, and emits a JSON Lines +//! event/result transcript. use std::error::Error; use std::ffi::OsString; @@ -234,11 +235,7 @@ fn run() -> ProviderResult<()> { artifact_bytes.truncate(artifact_bytes.len().div_ceil(2)); } if behavior != Behavior::MissingOutput { - write_new_output( - &root, - Path::new(&output_binding.locator), - &artifact_bytes, - )?; + write_new_output(&root, Path::new(&output_binding.locator), &artifact_bytes)?; } if behavior == Behavior::ExtraOutput { write_new_output( @@ -280,6 +277,18 @@ fn run() -> ProviderResult<()> { Vec::new(), ), ]; + let explanation = match behavior { + Behavior::MissingOutput => { + "The hermetic provider emitted success-shaped evidence without materializing the bound synthetic artifact." + } + Behavior::ExtraOutput => { + "The hermetic provider produced one bound and one undeclared synthetic artifact." + } + Behavior::PartialOutput => { + "The hermetic provider produced deterministic truncated synthetic artifact bytes and marked the result partial." + } + _ => "The hermetic provider produced one deterministic synthetic artifact.", + }; let mut result = ExtensionResult { schema_version: EXTENSION_RESULT_V1.to_owned(), run_id: invocation.run_id.clone(), @@ -324,8 +333,7 @@ fn run() -> ProviderResult<()> { retryable: false, }, checkpoint_refs: Vec::new(), - explanation: "The hermetic provider produced one deterministic synthetic artifact." - .to_owned(), + explanation: explanation.to_owned(), }; match behavior { diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index ccf9ba7..58a1d63 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -9,14 +9,14 @@ use flow::{ ARTIFACT_BINDINGS_V1, AcceptedArtifactSet, ArtifactAcceptanceError, ArtifactBindingSet, ArtifactKind, ArtifactObservationError, Authorization, AuthorizedProcess, CheckpointMode, Configuration, Domain, EXECUTION_SUBJECT_LOCK_V1, EventKind, EventSinkError, EventState, - ExecutionError, ExecutionModeKind, ExecutionSubjectLock, ExtensionCatalog, - ExtensionInvocation, ExtensionLock, ExtensionManifest, ExtensionObservation, - ExtensionResolution, FallbackPolicy, HostArtifactObservationSet, - HostExecutionSubjectObservationSet, InputArtifact, InputArtifactBinding, InvocationExtension, - InvocationInterface, InvocationPhase, LocalProcessRunner, MatchedExecutionSubjects, NoSecrets, - OutputArtifactBinding, PROCESS_AUTHORITY_PROFILE_V1, ProcessIsolation, ProcessRunnerError, - ProcessStream, ProvenanceKind, ResolutionOutcome, ResolutionRequest, ResolutionResult, SHA256, - Severity, Trust, ValidatedExecution, ValidationStatus, accept_artifacts, authorize_process, + ExecutionError, ExecutionModeKind, ExecutionSubjectLock, ExtensionCatalog, ExtensionInvocation, + ExtensionLock, ExtensionManifest, ExtensionObservation, ExtensionResolution, FallbackPolicy, + HostArtifactObservationSet, HostExecutionSubjectObservationSet, InputArtifact, + InputArtifactBinding, InvocationExtension, InvocationInterface, InvocationPhase, + LocalProcessRunner, MatchedExecutionSubjects, NoSecrets, OutputArtifactBinding, + PROCESS_AUTHORITY_PROFILE_V1, ProcessIsolation, ProcessRunnerError, ProcessStream, + ProvenanceKind, ResolutionOutcome, ResolutionRequest, ResolutionResult, SHA256, Severity, + Trust, ValidatedExecution, ValidationStatus, accept_artifacts, authorize_process, observe_artifacts, observe_execution_subjects, }; use serde::Serialize; From b0cc083de03e702a7515bbecc0efd0dc2ebda58f Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 09:58:11 -0400 Subject: [PATCH 03/10] [FLO-13.2c.2] Reject stale artifact evidence --- ROADMAP.md | 14 +- docs/architecture/foundation/ARCHITECTURE.md | 32 +-- ...-0007-root-confined-artifact-acceptance.md | 85 ++++++-- docs/integrations/README.md | 10 +- docs/integrations/artifact-bindings.md | 52 +++-- docs/integrations/hermetic-provider-kit.md | 85 ++++++-- src/artifacts.rs | 85 ++++++-- tests/artifacts.rs | 184 ++++++++++++++-- tests/fixtures/hermetic-provider/README.md | 26 ++- tests/fixtures/hermetic-provider/main.rs | 20 ++ tests/hermetic_provider_kit.rs | 196 ++++++++++++++++++ 11 files changed, 685 insertions(+), 104 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 78c0606..a197058 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,12 +3,12 @@ schema: aether.architecture-document/v1 id: flow-roadmap title: Flow Roadmap kind: architecture-document -version: 1.0.0 +version: 1.1.0 status: draft owners: - egohygiene created: 2026-08-13 -updated: 2026-09-22 +updated: 2026-09-24 governed_by: - architecture-roadmap depends_on: @@ -339,7 +339,10 @@ kinds, and portable locators beneath one caller-selected root. Recompute file and deterministic recursive-directory identity with Flow-owned code. Keep provider execution validation separate from artifact acceptance, and require a complete result plus exact invocation, result, event, binding, and host -observation correlation. +observation correlation. Bind each opaque observation token to its exact +binding snapshot and host-local canonical root, then require equal final +root-confined evidence immediately before promotion. This freshness check does +not make either observation atomic. **Delivered evidence:** Flow #36 / merged PR #37 adds `flow.artifact-bindings/v1`, `flow.artifact-observations/v1`, opaque observed and @@ -348,6 +351,11 @@ adversarial filesystem/correlation tests. It does not verify executables, enforce provider authority, launch a process, or validate provider-native artifact semantics. +Flow #57 hardens that acceptance boundary against changed evidence and stale +binding context without changing the portable artifact schemas or assigning +authoritative digest semantics to free-form provider provenance. The remaining +hermetic-kit graph and closeout checkpoints stay with #58 and #59. + ### Verify locked package and executable subjects Pin exactly one package directory and one regular executable file to the diff --git a/docs/architecture/foundation/ARCHITECTURE.md b/docs/architecture/foundation/ARCHITECTURE.md index 8072ddd..03b7c69 100644 --- a/docs/architecture/foundation/ARCHITECTURE.md +++ b/docs/architecture/foundation/ARCHITECTURE.md @@ -3,12 +3,12 @@ schema: aether.architecture-document/v1 id: flow-architecture title: Flow Architecture kind: architecture-document -version: 0.9.0 +version: 0.10.0 status: draft owners: - egohygiene created: 2026-08-13 -updated: 2026-09-21 +updated: 2026-09-24 governed_by: - architecture-architecture depends_on: @@ -73,9 +73,13 @@ Artifact bindings remain separate from path-independent extension envelopes. A Flow-owned binding set maps immutable input and candidate-output identities to logical ports, media types, kinds, and portable root-relative locators for one run. A Flow observer computes file or recursive directory identity beneath one -caller-selected root. Only the separate artifact-acceptance gate can correlate -those observations with resolved capability, invocation, event, and terminal -result evidence. +caller-selected root. Its opaque token privately retains the exact binding +snapshot and absolute canonical root while omitting that sensitive host context +from portable evidence and manual `Debug` output. Only the separate artifact- +acceptance gate can correlate those observations with resolved capability, +invocation, event, and terminal result evidence. Immediately before promotion, +the gate re-observes the retained root and requires the portable evidence to +remain equal. Process execution subjects are a third, distinct boundary. A Flow-owned lock pins one package directory and one regular executable file to an exact @@ -231,13 +235,17 @@ required by both process seams. Issue #40 adds exact process authority and isolation profiles, caller-attested enforcement evidence, and a second opaque token required by both process seams. Issue #42 adds the bounded local trusted-unconfined runner, direct transport workers, timeout/cancellation grace -and escalation, and direct-child reaping. Flow does not yet supply the public -CLI, real holon adapters, signature or transparency verification, -provider-native artifact validation, an operating-system sandbox or -authenticated enforcement evidence, descriptor-bound execution, process-tree -containment, durable run state, checkpoints, retry, or resume. Structural units -beyond these library seams remain constraints for later adapter and -orchestration work, not claims about current source layout. +and escalation, and direct-child reaping. Issue #57 binds an opaque artifact +observation to its exact binding snapshot and canonical root and requires an +equal final observation before acceptance, without changing portable artifact +schemas or free-form extension-result v1 provenance. Flow does not yet supply +the public CLI, real holon adapters, signature or transparency verification, +provider-native artifact validation, an atomic filesystem snapshot, an +operating-system sandbox or authenticated enforcement evidence, +descriptor-bound execution, process-tree containment, durable run state, +checkpoints, retry, or resume. Structural units beyond these library seams +remain constraints for later adapter and orchestration work, not claims about +current source layout. ## Open questions diff --git a/docs/architecture/governance/decisions/ADR-0007-root-confined-artifact-acceptance.md b/docs/architecture/governance/decisions/ADR-0007-root-confined-artifact-acceptance.md index 3a722a2..57bfa22 100644 --- a/docs/architecture/governance/decisions/ADR-0007-root-confined-artifact-acceptance.md +++ b/docs/architecture/governance/decisions/ADR-0007-root-confined-artifact-acceptance.md @@ -77,8 +77,12 @@ represent them losslessly. The portable observation document is serializable evidence, not an authority token. Flow wraps freshly observed evidence in an `ObservedArtifactSet` whose -fields are private. Deserializing or constructing -`HostArtifactObservationSet` cannot recreate that token or enter artifact +fields are private. The token also retains the canonical root and an exact copy +of the validated binding set used for that observation. The absolute canonical +root is sensitive host context and is omitted from portable evidence and the +token's manual `Debug` output; neither retained value is serialized. +Deserializing or constructing +`HostArtifactObservationSet` cannot recreate the token or enter artifact acceptance without a fresh host observation. Files use the lowercase hexadecimal SHA-256 digest of their bytes and their @@ -112,13 +116,20 @@ all of these checks pass together: introduces an undeclared type; 5. result-consumed IDs, result-produced IDs, and `artifact-produced` event IDs each match their declared sets exactly, with no duplicates; -6. host observations cover every input and output exactly once and match every - bound ID, port, media type, kind, and locator; and -7. every observed input digest equals its immutable expected digest. +6. the supplied bindings exactly equal the binding snapshot retained by the + opaque observation token; +7. host observations cover every input and output exactly once and match every + bound ID, port, media type, kind, and locator; +8. every observed input digest equals its immutable expected digest; and +9. immediately before promotion, Flow re-observes the same bindings beneath + the retained canonical root and requires the fresh portable evidence to + equal the original observation exactly. An output digest is learned from host observation rather than accepted from the provider. A provider result, event, exit code, or file existence alone cannot -construct `AcceptedArtifactSet`. +construct `AcceptedArtifactSet`. Provider-contributed artifact provenance +remains untrusted extension-result evidence; the final re-observation does not +reinterpret its free-form v1 value as an expected output digest. ## Rationale @@ -138,6 +149,13 @@ Keeping `ValidatedExecution` separate also prevents existing event/result validation from silently acquiring stronger filesystem claims than it can support. +Retaining the exact binding snapshot prevents a caller from replaying an +observation token against a modified declaration that happens to reuse an +identifier. Re-observing at the final promotion boundary detects a net change +to bound host evidence after the first observation without trusting a provider +to attest to its own output bytes or changing the provisional extension-result +v1 provenance model. + ## Evidence and assumptions - Closed extension invocation, event, result, and resolution models already @@ -145,8 +163,10 @@ support. - Both the injected in-process seam and host-neutral process seam produce the same `ValidatedExecution` type, so artifact acceptance can follow either. - The caller controls the observation root and is responsible for keeping it - quiescent while Flow observes it. Portable Rust filesystem APIs do not provide - an atomic, race-free directory capability across all supported hosts. + quiescent while Flow observes and accepts it. The final re-observation narrows + the observation-to-acceptance drift window, but portable Rust filesystem APIs + do not provide an atomic, race-free directory capability across all supported + hosts. - SHA-256 is already the suite's provisional content-digest algorithm. - Real adapters are expected to stage outputs beneath an isolated run root and to expose provider-native validation separately from byte identity. @@ -158,6 +178,10 @@ support. existing contract family. - **Trust provider-reported digests:** rejected because the provider would be approving the same evidence that Flow must independently assess. +- **Interpret free-form artifact provenance as an expected output digest:** + rejected because the v1 value does not bind a claim unambiguously to one + output, and silently adding that meaning would redefine a closed provisional + contract. - **Accept file existence plus exit `0`:** rejected because neither establishes immutable input identity, output bytes, exact declared coverage, or event and result agreement. @@ -183,16 +207,23 @@ Unix names. No normalization means visually similar Unicode names remain different artifacts. Case behavior follows exact string identity even on a case-insensitive filesystem. -Directory observation reads every descendant and can be expensive. The -checkpoint has no streaming manifest sink, incremental cache, hard-link -identity, or resource quota. A future runner must apply authority and resource -controls before observing untrusted large trees. +Directory observation reads every descendant and can be expensive. Successful +acceptance reads every bound artifact a second time. The checkpoint has no +streaming manifest sink, incremental cache, hard-link identity, or resource +quota. A future runner must apply authority and resource controls before +observing untrusted large trees. The check is not a race-free sandbox boundary. A concurrently mutating or -hostile workspace can change between metadata inspection and byte reads. The -production runner must create an isolated, quiescent workspace or replace this -portable observer with a stronger platform capability before making adversarial -concurrency claims. +hostile workspace can change during either traversal, change and return to the +same portable evidence between observations, or change after the final read. +The production runner must create an isolated, quiescent workspace or replace +this portable observer with a stronger platform capability before making +adversarial concurrency claims. + +The v1 observation token carries no run or invocation identity. Reuse across +runs is therefore indistinguishable when the exact binding snapshot and current +portable evidence are identical; the stale-context cases reject observable +correlation differences, not same-evidence replay. ## Expected consequences @@ -202,6 +233,8 @@ concurrency claims. caller mutation before acceptance. - Candidate outputs acquire host-observed content identities that downstream planning and provenance can reference. +- A changed binding declaration or a net change to bound evidence between the + first and final observations cannot construct `AcceptedArtifactSet`. - File and directory artifacts have one deterministic cross-platform profile. - `ValidatedExecution` and `AcceptedArtifactSet` communicate different evidence strengths in the type system. @@ -211,9 +244,12 @@ concurrency claims. ## Observed outcomes Flow issue #36 adds the two contracts, Rust binding/observation/acceptance APIs, -portable path and manifest validation, and adversarial conformance tests. It -does not add a real provider adapter or process runner. Default-branch CI after -merge remains the acceptance evidence for this implementation. +portable path and manifest validation, and adversarial conformance tests. Flow +issue #57 hardens the opaque observation token with its exact binding snapshot +and canonical root, then requires an equal final re-observation before +acceptance. It does not change either portable schema or the extension-result +v1 provenance model. Default-branch CI after merge remains the acceptance +evidence for this implementation. ## Security, privacy, and authority @@ -231,7 +267,10 @@ This decision grants no filesystem access to a provider and does not enforce a manifest's requested permissions. It does not verify executable/package bytes, publisher identity, signatures, or transparency logs. It is not an operating-system sandbox, a process launcher, a domain validator, or an atomic -filesystem snapshot. +filesystem snapshot. The privately retained canonical path may expose sensitive +host topology, so it is omitted from portable evidence and the token's manual +`Debug` output. It is host context for the final read, not a filesystem +capability, lock, or containment boundary. ## Review triggers @@ -263,5 +302,7 @@ missing artifacts, kind mismatch, duplicate IDs/ports/locators, absolute and traversing locators, backslash and drive ambiguity, symlinks, special nodes, non-UTF-8 names where supported, invalid/tampered manifests, incomplete host coverage, invocation/resolution mismatch, result/event mismatch, duplicates, -and partial or unsuccessful terminal evidence. Every rejected case must return -a typed error and never an `AcceptedArtifactSet`. +partial or unsuccessful terminal evidence, stale binding snapshots, changed +bound bytes after initial observation, and a missing or unsafe node during the +final re-observation. Every rejected case must return a typed error and never an +`AcceptedArtifactSet`. diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 305f94b..78d1fc8 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -110,8 +110,14 @@ stream overflow, invalid protocol evidence, and authoritative host-rejection cases without weakening the immutable-package or success baseline. Flow #56 adds protocol-valid missing, extra, and partial physical artifact outcomes and proves that each stops at its exact host-observation or artifact-acceptance -boundary. Corrupt/stale/contradictory evidence, graph fixtures, and the final -parent matrix remain with #57, #58, and #59 respectively. +boundary. Flow #57 distinguishes corrupt result evidence from a protocol-valid +event/result contradiction, binds each opaque observation token to its exact +binding snapshot, and requires equal final root-confined evidence before +artifact promotion. Changed or removed evidence and stale invocation or +binding context cannot construct `AcceptedArtifactSet`. This hardening does not +make observation atomic or reinterpret free-form extension-result v1 +provenance. Graph fixtures and the final parent matrix remain with #58 and #59 +respectively. The following remain deferred: provider discovery from the filesystem, dynamic loading, cryptographic authenticity and transparency verification, diff --git a/docs/integrations/artifact-bindings.md b/docs/integrations/artifact-bindings.md index cd6350f..3ca0548 100644 --- a/docs/integrations/artifact-bindings.md +++ b/docs/integrations/artifact-bindings.md @@ -21,14 +21,18 @@ executable content is a separate pre-execution boundary documented in | --- | --- | --- | | Declaration | `flow.artifact-bindings/v1` | Flow intends these immutable inputs and candidate outputs to occupy these logical ports and root-relative locators | | Portable observation document | `flow.artifact-observations/v1` | A serializable description of content identity and directory structure; deserialization alone does not prove who observed it | -| Flow observation token | `ObservedArtifactSet` | Flow's observer produced the contained document from one binding set and root during this process | +| Flow observation token | `ObservedArtifactSet` | Flow's observer produced the contained document from one binding set and root during this process and privately retained that exact binding snapshot and canonical root for final re-observation | | Provider evidence | `ValidatedExecution` | Provider events and result passed the extension contract and correlation gate; this is not filesystem acceptance | -| Accepted artifacts | `AcceptedArtifactSet` | Flow correlated a complete provider outcome with the resolved context, immutable bindings, and Flow-observed host evidence | +| Accepted artifacts | `AcceptedArtifactSet` | Flow correlated a complete provider outcome with the resolved context, the retained binding snapshot, and equal initial and final Flow-observed host evidence | Both `ObservedArtifactSet` and `AcceptedArtifactSet` have private fields. A caller can serialize or deserialize the portable observation contract for inspection, but cannot turn deserialized or provider-authored JSON directly into either token. Acceptance requires a fresh result from `observe_artifacts`. +The retained absolute canonical root is sensitive host context. It remains +host-local and is omitted from both the portable observation document and the +token's manual `Debug` output; the exact binding snapshot is likewise not +serialized as part of the observation document. ## Binding contract @@ -83,11 +87,13 @@ byte-for-byte in UTF-8. No Unicode normalization or case folding occurs. sockets, devices, FIFOs, other special nodes, and non-UTF-8 descendant names. 7. Validate the generated observation document before returning the opaque - `ObservedArtifactSet`. + `ObservedArtifactSet`, which privately retains the canonical root and an + exact copy of the validated bindings for the final acceptance read. Observation is read-only, but it reads every bound file. Callers must provide a -quiescent, isolated root. This portable implementation is not an atomic -filesystem snapshot and cannot prevent a hostile concurrent path swap. +quiescent, isolated root through acceptance. This portable implementation is +not an atomic filesystem snapshot and cannot prevent a hostile concurrent path +swap. ## Content identity @@ -141,12 +147,19 @@ an `AcceptedArtifactSet` only after all inputs agree: 7. Provider-consumed IDs exactly match inputs; provider-produced IDs exactly match outputs; and `artifact-produced` event references name every output exactly once. -8. The Flow observation covers every binding exactly once and matches its ID, - port, media type, kind, and locator. -9. Every host-observed input digest equals its expected immutable digest. +8. The supplied binding set exactly equals the binding snapshot retained by the + opaque observation token. +9. The initial Flow observation covers every binding exactly once and matches + its ID, port, media type, kind, and locator. +10. Every host-observed input digest equals its expected immutable digest. +11. Immediately before promotion, Flow re-observes the same bindings beneath + the retained canonical root and requires the fresh portable evidence to + equal the initial observation exactly. Set comparison rejects omissions, additions, and duplicates. No individual success report, event, exit code, or existing path bypasses the combined gate. +Provider-contributed artifact provenance remains untrusted, free-form v1 +evidence and is not interpreted as a host-observed output digest. ## Typical library sequence @@ -167,6 +180,9 @@ let accepted = accept_artifacts( concurrent bounded output capture, timeout/cancellation enforcement, and direct-child reaping. A later orchestration layer still owns safe workspace creation, provider-native output checks, quiescence, and artifact observation. +`accept_artifacts` performs filesystem I/O for its final re-observation. Its +success is point-in-time promotion evidence, not a filesystem lock or an +immutable snapshot. ## Failure surfaces @@ -176,8 +192,11 @@ nodes, non-UTF-8 names, canonical escape, I/O failure, manifest encoding, and size overflow. `ArtifactAcceptanceError` separates invalid invocation, bindings, or -observation contracts from cross-evidence mismatch. Error display text names -the failed invariant but does not include artifact contents. +observation contracts from cross-evidence mismatch. `Reobservation` retains a +typed `ArtifactObservationError` when the final read cannot complete, while +`ObservationChanged` reports valid final evidence that differs from the first +observation as `bound artifact evidence changed after host observation`. Error +display text names the failed invariant but does not include artifact contents. Errors return no accepted token. Raw filesystem contents and rejected portable observation documents remain caller-controlled evidence. @@ -188,8 +207,10 @@ The Rust suite covers repeated deterministic observation, files, nested directories, spaces, Unicode, immutable input changes, traversal and absolute locators, backslash and drive ambiguity, symlinks, tampered manifests, incomplete and mismatched observations, invocation mismatch, duplicate provider -artifact events, and partial results. JSON Schema plus the independent Python -validator cover the checked-in examples and semantic-invalid fixtures. +artifact events, partial results, stale binding snapshots, changed input and +output bytes after initial observation, and typed final re-observation failure. +JSON Schema plus the independent Python validator cover the checked-in examples +and semantic-invalid fixtures. Run the full local gates with: @@ -211,4 +232,9 @@ binding, operating-system permission enforcement, sandboxing, process launch/supervision, resource quotas during directory traversal, domain-specific artifact validation, durable provenance/state commit, checkpoint compatibility, retry, or resume. Those claims require their own Flow #25 and orchestration -checkpoints. +checkpoints. The final re-observation does not make either traversal atomic, +pin file descriptors or inodes, detect an intervening change restored to the +same portable evidence, or prevent mutation after the final read. Because the +v1 observation token has no run or invocation identity, it also cannot +distinguish cross-run reuse when the binding snapshot and current portable +evidence are identical. diff --git a/docs/integrations/hermetic-provider-kit.md b/docs/integrations/hermetic-provider-kit.md index 6e08dbc..05bc063 100644 --- a/docs/integrations/hermetic-provider-kit.md +++ b/docs/integrations/hermetic-provider-kit.md @@ -7,9 +7,11 @@ The hermetic provider kit is Flow-owned conformance infrastructure for issue success path. Checkpoint #45 keeps that baseline intact while adding a closed matrix for provider selection, lifecycle supervision, and protocol outcomes. Checkpoint #56 adds the first physical artifact-outcome matrix for missing, -extra, and partial outputs. All three checkpoints exercise released public -boundaries without importing a sibling implementation or pretending to be an -Aniflow, Optiflow, or Renderflow algorithm. +extra, and partial outputs. Checkpoint #57 closes corrupt, contradictory, +changed, and stale artifact-evidence cases and hardens final artifact freshness. +All four checkpoints exercise released public boundaries without importing a +sibling implementation or pretending to be an Aniflow, Optiflow, or Renderflow +algorithm. The checkpoint composes only public contracts and APIs: @@ -25,8 +27,9 @@ The checkpoint composes only public contracts and APIs: `ValidatedExecution`; 8. observe every explicit input and candidate output beneath the selected artifact root; and -9. correlate bindings, host observations, events, and the result before - constructing `AcceptedArtifactSet`. +9. correlate bindings, host observations, events, and the result; and +10. re-observe the retained root and require unchanged evidence immediately + before constructing `AcceptedArtifactSet`. The manifest and operator lock are finalized external catalog metadata. They remain outside the package directory whose digest they name, avoiding a @@ -56,17 +59,20 @@ multi-capability provider receives no output-write authority. Checkpoint #44 executes the inspection capability end to end. Checkpoint #45 uses that same capability for the lifecycle and protocol matrix, and checkpoint -#56 uses it for physical artifact outcomes after protocol-valid execution. The -other three IDs and their declared media/configuration profiles remain frozen -so the later graph fixtures do not invent parallel identities. Their -end-to-end coverage remains explicitly deferred. +#56 uses it for physical artifact outcomes after protocol-valid execution. +Checkpoint #57 adds two provider evidence modes and host-harness freshness +cases without changing the capability surface. The other three IDs and their +declared media/configuration profiles remain frozen so the later graph fixtures +do not invent parallel identities. Their end-to-end coverage remains explicitly +deferred. ## Configuration and deterministic success profile Configuration uses `flow.hermetic-provider-configuration/v1` with exactly two string fields. Checkpoint #44 defines `success`; checkpoint #45 expands the -closed `mode` vocabulary, and checkpoint #56 adds three artifact-outcome modes -without changing the schema or the success evidence: +closed `mode` vocabulary, checkpoint #56 adds three artifact-outcome modes, and +checkpoint #57 adds two artifact-evidence modes without changing the schema or +the success evidence: | Field | Checkpoint-1 value | Identity rule | | --- | --- | --- | @@ -81,6 +87,13 @@ The terminal result names the exact consumed and produced artifact IDs and records passed binding/input validation plus extension, configuration, and output-digest provenance. +The artifact provenance entry remains provider-contributed free-form +`flow.extension-result/v1` evidence. Flow validates that its value is nonempty, +but does not parse it as an authoritative expected output digest or use it to +promote host identity. Checkpoint #57 instead protects freshness by retaining +the Flow-owned observation context and requiring an equal final host +re-observation immediately before artifact acceptance. + The output is compact JSON followed by one line feed. It contains the artifact schema, capability, synthetic operation, configuration digest, seed, and sorted input identity evidence. It never includes ambient or host-specific @@ -120,6 +133,29 @@ extra file is a closed fixture behavior, not an automatic discovery feature. | `extra-output` | The provider creates the bound candidate plus `outputs/undeclared-extra-output.json` and names both IDs in its event and result. | Artifact acceptance | Observation covers only the explicit binding set; `accept_artifacts` returns `ArtifactAcceptanceError::Mismatch` because provider-produced IDs do not exactly match declared outputs. | Produces `ValidatedExecution` and `ObservedArtifactSet`; no `AcceptedArtifactSet`. The undeclared sibling is not discovered or promoted. | | `partial-output` | The provider writes a deterministic truncated synthetic candidate, reports its exact digest, and sets `partial_result: true`. | Artifact acceptance | Observation succeeds for the bytes that exist; `accept_artifacts` returns `ArtifactAcceptanceError::Mismatch` because acceptance requires a complete produced or reused result. | Produces `ValidatedExecution` and `ObservedArtifactSet`; no `AcceptedArtifactSet`. Flow does not claim generic JSON or provider-native semantic validation. | +## Checkpoint-3b artifact evidence and freshness matrix + +The first two cases are real provider modes. The remaining cases are +host-harness operations because only Flow can create an opaque observation +token and only its caller can attempt to reuse that token with changed host or +correlation context. + +| Case | Explicit trigger | Owning boundary | Exact outcome | Promotion | +| --- | --- | --- | --- | --- | +| `corrupt-artifact-evidence` | The provider clears the otherwise valid artifact-provenance value while still creating the candidate output. | Semantic result validation | `ProcessRunnerError::Validation` contains `ExecutionError::InvalidResult` with `result.provenance[2].value: must not be empty`. | Valid events and the raw invalid result remain inspectable, and the output may exist; no `ValidatedExecution` or `AcceptedArtifactSet`. “Corrupt” names explicit evidence corruption, not provider-native format validation. | +| `contradictory-artifact-evidence` | The result names the bound output while the `artifact-produced` event omits it. | Artifact acceptance | The transcript remains protocol-valid; `accept_artifacts` returns `ArtifactAcceptanceError::Mismatch` with `artifact-produced events do not exactly match declared outputs`. | Produces `ValidatedExecution` and `ObservedArtifactSet`; no `AcceptedArtifactSet`. | +| `changed-output-after-observation` | After normal provider success and initial host observation, the harness overwrites the bound output with deterministic replacement bytes. | Final artifact-freshness gate | `accept_artifacts` returns `ArtifactAcceptanceError::ObservationChanged` (`bound artifact evidence changed after host observation`). | Produces `ValidatedExecution` and the initial `ObservedArtifactSet`; no `AcceptedArtifactSet`. | +| `removed-output-after-observation` | After normal provider success and initial host observation, the harness removes the bound output. | Final artifact re-observation | `accept_artifacts` returns `ArtifactAcceptanceError::Reobservation` containing `ArtifactObservationError::Missing` for the exact output ID and locator. | Produces `ValidatedExecution` and the initial `ObservedArtifactSet`; no `AcceptedArtifactSet`. | +| `stale-invocation-and-binding-contexts` | The harness first supplies a changed run identity with valid execution evidence, then separately supplies a changed binding set with the valid observation token. | Artifact acceptance correlation | The invocation case returns `ArtifactAcceptanceError::Mismatch` with `validated execution context does not match the supplied invocation`; the binding case returns the same variant with `artifact bindings changed after host observation`. | Neither stale context can construct `AcceptedArtifactSet`. | + +The artifact-binding library cases additionally prove that changed input bytes +after initial observation return `ObservationChanged`, malformed portable +digests fail `HostArtifactObservationSet::validate`, contradictory directory +manifests fail before correlation, and deserialized portable evidence cannot +recreate the opaque observation token. The final re-observation detects a net +evidence change; it is not an atomic snapshot and does not interpret provider +artifact provenance as authoritative host identity. + The stream-capture `observed` value is a bounded overflow sentinel, not the provider's total emitted byte count. Each worker drains its stream to EOF but retains at most the configured limit plus one byte. @@ -180,6 +216,13 @@ provider evidence stops at typed artifact acceptance. Every case re-observes the immutable package and verifies that input and binding bytes remain unchanged. +The checkpoint #57 provider modes distinguish invalid result evidence from a +protocol-valid event/result contradiction. Its host-harness cases prove that a +changed output, a removed output, a stale invocation, or a changed binding +snapshot cannot reuse otherwise valid evidence to construct +`AcceptedArtifactSet`. The focused artifact tests also cover changed input +evidence and malformed or contradictory portable observations. + The fixture source, manifest, lock, materialization rules, offline commands, and redistribution terms live in [`tests/fixtures/hermetic-provider`](../../tests/fixtures/hermetic-provider/README.md). @@ -193,12 +236,14 @@ runner clears the inherited environment and passes no secret handles. The local runner profile is `trusted-unconfined`. This checkpoint proves exact contract correlation, byte identity, bounded transport, and direct-child use -and reaping; it does not claim an operating-system sandbox, filesystem containment, -descriptor-bound execution, publisher authentication, descendant cleanup, or -provider-native semantic validation. - -Checkpoint #57 owns corrupt, stale, changed, and contradictory artifact -evidence. Checkpoint #58 owns the single- and multi-provider composition -fixtures. Checkpoint #59 owns completed redistribution documentation and the -final parent #29 requirement-to-test matrix. Durable run state, retry, -checkpoint, and resume remain outside the hermetic provider kit. +and reaping; it does not claim an operating-system sandbox, filesystem +containment, atomic artifact observation, descriptor-bound execution, +publisher authentication, descendant cleanup, or provider-native semantic +validation. + +Checkpoint #57 delivers corrupt, stale, changed, and contradictory artifact +evidence coverage without changing extension-result v1 provenance. Checkpoint +#58 owns the single- and multi-provider composition fixtures. Checkpoint #59 +owns completed redistribution documentation and the final parent #29 +requirement-to-test matrix. Durable run state, retry, checkpoint, and resume +remain outside the hermetic provider kit. diff --git a/src/artifacts.rs b/src/artifacts.rs index e46d85e..957111f 100644 --- a/src/artifacts.rs +++ b/src/artifacts.rs @@ -102,13 +102,36 @@ pub struct DirectoryManifestEntry { /// Filesystem evidence produced by Flow's root-confined observer. /// -/// The field is private so a deserialized or provider-authored observation +/// Its fields are private so a deserialized or provider-authored observation /// document cannot be passed to [`accept_artifacts`] as if Flow had read it. -#[derive(Clone, Debug, Eq, PartialEq)] +/// The token also retains its canonical root and exact bindings so acceptance +/// can reject rebinding and repeat the observation immediately before +/// promotion. The retained path is host context, not an operating-system +/// capability or atomic snapshot. +#[derive(Clone)] pub struct ObservedArtifactSet { + canonical_root: PathBuf, + bindings: ArtifactBindingSet, evidence: HostArtifactObservationSet, } +impl std::fmt::Debug for ObservedArtifactSet { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ObservedArtifactSet") + .field("evidence", &self.evidence) + .finish_non_exhaustive() + } +} + +impl PartialEq for ObservedArtifactSet { + fn eq(&self, other: &Self) -> bool { + self.evidence == other.evidence + } +} + +impl Eq for ObservedArtifactSet {} + impl ObservedArtifactSet { #[must_use] pub const fn evidence(&self) -> &HostArtifactObservationSet { @@ -373,6 +396,8 @@ pub fn observe_artifacts( .validate() .map_err(|source| ArtifactObservationError::InvalidObservations { source })?; Ok(ObservedArtifactSet { + canonical_root, + bindings: bindings.clone(), evidence: observations, }) } @@ -381,9 +406,14 @@ pub fn observe_artifacts( /// /// # Errors /// -/// Returns an error unless every declared input and candidate output matches -/// the resolved capability, invocation, host observation, provider result, and -/// artifact-produced events exactly. +/// Returns an invalid-contract or mismatch error unless every declared input +/// and candidate output matches the resolved capability, invocation, retained +/// binding context, host observation, provider result, and artifact-produced +/// events exactly. [`ArtifactAcceptanceError::Reobservation`] retains a typed +/// host failure from the final read, while +/// [`ArtifactAcceptanceError::ObservationChanged`] reports valid evidence that +/// differs from the initial observation. The final read rejects net evidence +/// drift; it does not make either traversal atomic. #[allow(clippy::too_many_lines)] pub fn accept_artifacts( resolved: &ResolvedExtension, @@ -403,6 +433,10 @@ pub fn accept_artifacts( .validate() .map_err(|source| ArtifactAcceptanceError::InvalidObservations { source })?; + mismatch( + bindings == &observed.bindings, + "artifact bindings changed after host observation", + )?; mismatch( bindings.binding_set_id == observations.binding_set_id, "host observations reference a different binding set", @@ -426,7 +460,7 @@ pub fn accept_artifacts( .iter() .map(|binding| (binding.artifact_id.as_str(), binding)) .collect::>(); - let observed = observations + let observations_by_id = observations .artifacts .iter() .map(|observation| (observation.artifact_id.as_str(), observation)) @@ -493,17 +527,19 @@ pub fn accept_artifacts( )?; mismatch( - observed.len() == input_bindings.len() + output_bindings.len(), + observations_by_id.len() == input_bindings.len() + output_bindings.len(), "host observations do not cover every binding exactly", )?; let mut accepted_inputs = Vec::with_capacity(bindings.inputs.len()); for binding in &bindings.inputs { - let observation = observed.get(binding.artifact_id.as_str()).ok_or_else(|| { - ArtifactAcceptanceError::Mismatch { - message: "a declared input has no host observation".to_owned(), - } - })?; + let observation = observations_by_id + .get(binding.artifact_id.as_str()) + .ok_or_else(|| { + ArtifactAcceptanceError::Mismatch { + message: "a declared input has no host observation".to_owned(), + } + })?; correlate_observation( &binding.artifact_id, &binding.port, @@ -521,11 +557,13 @@ pub fn accept_artifacts( let mut accepted_outputs = Vec::with_capacity(bindings.outputs.len()); for binding in &bindings.outputs { - let observation = observed.get(binding.artifact_id.as_str()).ok_or_else(|| { - ArtifactAcceptanceError::Mismatch { - message: "a declared output has no host observation".to_owned(), - } - })?; + let observation = observations_by_id + .get(binding.artifact_id.as_str()) + .ok_or_else(|| { + ArtifactAcceptanceError::Mismatch { + message: "a declared output has no host observation".to_owned(), + } + })?; correlate_observation( &binding.artifact_id, &binding.port, @@ -537,6 +575,12 @@ pub fn accept_artifacts( accepted_outputs.push((*observation).clone()); } + let refreshed = observe_artifacts(&observed.canonical_root, bindings) + .map_err(|source| ArtifactAcceptanceError::Reobservation { source })?; + if refreshed.evidence() != observations { + return Err(ArtifactAcceptanceError::ObservationChanged); + } + Ok(AcceptedArtifactSet { binding_set_id: bindings.binding_set_id.clone(), inputs: accepted_inputs, @@ -612,6 +656,13 @@ pub enum ArtifactAcceptanceError { #[source] source: ValidationError, }, + #[error("failed to re-observe bound artifacts before acceptance: {source}")] + Reobservation { + #[source] + source: ArtifactObservationError, + }, + #[error("bound artifact evidence changed after host observation")] + ObservationChanged, #[error("artifact acceptance mismatch: {message}")] Mismatch { message: String }, } diff --git a/tests/artifacts.rs b/tests/artifacts.rs index 84a4c0d..29a3ff9 100644 --- a/tests/artifacts.rs +++ b/tests/artifacts.rs @@ -292,6 +292,8 @@ fn file_and_directory_artifacts_are_observed_deterministically_and_accepted() { let first = observe_artifacts(root.path(), &bindings).unwrap(); let second = observe_artifacts(root.path(), &bindings).unwrap(); assert_eq!(first, second); + let debug = format!("{first:?}"); + assert!(!debug.contains(&root.path().display().to_string())); assert_eq!( first.evidence().directory_manifest_profile, DIRECTORY_MANIFEST_V1 @@ -511,15 +513,34 @@ fn mismatched_host_provider_and_invocation_evidence_never_becomes_accepted() { let mut wrong_invocation = invocation.clone(); wrong_invocation.run_id = "run:different".to_owned(); + let error = accept_artifacts( + resolved, + &wrong_invocation, + &execution, + &bindings, + &observations, + ) + .unwrap_err(); assert!(matches!( - accept_artifacts( - resolved, - &wrong_invocation, - &execution, - &bindings, - &observations - ), - Err(ArtifactAcceptanceError::Mismatch { .. }) + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "validated execution context does not match the supplied invocation" + )); + + let mut wrong_invocation = invocation.clone(); + wrong_invocation.invocation_id = "invocation:different".to_owned(); + let error = accept_artifacts( + resolved, + &wrong_invocation, + &execution, + &bindings, + &observations, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "validated execution context does not match the supplied invocation" )); let mut incomplete_bindings = bindings.clone(); @@ -537,6 +558,33 @@ fn mismatched_host_provider_and_invocation_evidence_never_becomes_accepted() { )); } +#[test] +fn stale_binding_context_cannot_reuse_an_observation_token() { + let (root, bindings) = fixture(); + let observations = observe_artifacts(root.path(), &bindings).unwrap(); + let (catalog, request) = resolved_fixture(); + let resolution = catalog.resolve(&request); + let resolved = resolution.resolved().unwrap(); + let invocation = configured_invocation(resolved, &bindings); + let execution = execute(resolved, &invocation, ProviderBehavior::default()); + let mut stale_bindings = bindings.clone(); + stale_bindings.binding_set_id = "bindings:stale-artifact-test".to_owned(); + + let error = accept_artifacts( + resolved, + &invocation, + &execution, + &stale_bindings, + &observations, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "artifact bindings changed after host observation" + )); +} + #[test] fn duplicate_artifact_events_and_partial_results_are_not_completion() { let (root, bindings) = fixture(); @@ -606,16 +654,123 @@ fn changed_input_bytes_fail_the_immutable_digest_gate() { let invocation = configured_invocation(resolved, &bindings); let execution = execute(resolved, &invocation, ProviderBehavior::default()); + let error = accept_artifacts( + resolved, + &invocation, + &execution, + &bindings, + &observations, + ) + .unwrap_err(); assert!(matches!( - accept_artifacts(resolved, &invocation, &execution, &bindings, &observations), - Err(ArtifactAcceptanceError::Mismatch { .. }) + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "host-observed input digest conflicts with the immutable binding" + )); +} + +#[test] +fn changed_input_after_observation_cannot_be_accepted() { + let (root, bindings) = fixture(); + let observations = observe_artifacts(root.path(), &bindings).unwrap(); + let (catalog, request) = resolved_fixture(); + let resolution = catalog.resolve(&request); + let resolved = resolution.resolved().unwrap(); + let invocation = configured_invocation(resolved, &bindings); + let execution = execute(resolved, &invocation, ProviderBehavior::default()); + + fs::write( + root.path().join("inputs/source collection.json"), + b"changed after observation", + ) + .unwrap(); + + let error = accept_artifacts( + resolved, + &invocation, + &execution, + &bindings, + &observations, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::ObservationChanged + )); +} + +#[test] +fn changed_output_after_observation_cannot_be_accepted() { + let (root, bindings) = fixture(); + let observations = observe_artifacts(root.path(), &bindings).unwrap(); + let (catalog, request) = resolved_fixture(); + let resolution = catalog.resolve(&request); + let resolved = resolution.resolved().unwrap(); + let invocation = configured_invocation(resolved, &bindings); + let execution = execute(resolved, &invocation, ProviderBehavior::default()); + + fs::write( + root.path().join("outputs/report bundle/alpha.txt"), + b"changed after observation\n", + ) + .unwrap(); + + let error = accept_artifacts( + resolved, + &invocation, + &execution, + &bindings, + &observations, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::ObservationChanged + )); +} + +#[test] +fn removed_output_after_observation_returns_typed_reobservation_error() { + let (root, bindings) = fixture(); + let observations = observe_artifacts(root.path(), &bindings).unwrap(); + let (catalog, request) = resolved_fixture(); + let resolution = catalog.resolve(&request); + let resolved = resolution.resolved().unwrap(); + let invocation = configured_invocation(resolved, &bindings); + let execution = execute(resolved, &invocation, ProviderBehavior::default()); + + fs::remove_dir_all(root.path().join("outputs/report bundle")).unwrap(); + + let error = accept_artifacts( + resolved, + &invocation, + &execution, + &bindings, + &observations, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Reobservation { + source: ArtifactObservationError::Missing { + ref artifact_id, + ref locator, + } + } if artifact_id == OUTPUT_ID && locator == "outputs/report bundle" )); } #[test] -fn a_tampered_directory_manifest_is_invalid_before_correlation() { +fn corrupt_or_contradictory_portable_observations_are_invalid_before_correlation() { let (root, bindings) = fixture(); let observed = observe_artifacts(root.path(), &bindings).unwrap(); + + let mut corrupt_digest = observed.evidence().clone(); + corrupt_digest.artifacts[1].digest = "corrupt".to_owned(); + let error = corrupt_digest.validate().unwrap_err(); + assert_eq!(error.path, "observations.artifacts[1].digest"); + assert_eq!(error.message, "must be 64 lowercase hexadecimal characters"); + let mut observations = observed.evidence().clone(); let output = observations .artifacts @@ -624,5 +779,10 @@ fn a_tampered_directory_manifest_is_invalid_before_correlation() { .unwrap(); output.manifest.swap(0, 1); - assert!(observations.validate().is_err()); + let error = observations.validate().unwrap_err(); + assert_eq!(error.path, "observations.artifacts[1].manifest"); + assert_eq!( + error.message, + "entries must be unique and sorted by locator" + ); } diff --git a/tests/fixtures/hermetic-provider/README.md b/tests/fixtures/hermetic-provider/README.md index df57922..354df77 100644 --- a/tests/fixtures/hermetic-provider/README.md +++ b/tests/fixtures/hermetic-provider/README.md @@ -80,6 +80,8 @@ runtime `mode` values are: | `missing-output` | Emits a complete valid transcript for the bound candidate without creating its file, so host observation returns the exact typed missing-artifact error. | | `extra-output` | Writes the bound candidate plus one closed, bounded undeclared sibling and names both in the artifact event and result; observation remains binding-driven and acceptance rejects the extra provider declaration. | | `partial-output` | Writes a deterministic truncated synthetic candidate, reports its exact byte digest, and sets `partial_result` so acceptance rejects incomplete evidence without claiming generic format validation. | +| `corrupt-artifact-evidence` | Writes the normal candidate but clears the artifact-provenance value so semantic result validation returns an exact invalid-result error before `ValidatedExecution`. | +| `contradictory-artifact-evidence` | Writes the normal candidate and names it in the result while omitting it from the artifact-produced event; protocol validation succeeds, but artifact acceptance rejects the contradiction. | | `nonzero-after-success` | Flushes success-shaped stdout, then exits with code `7`. | | `await-interruption` | Creates the explicit lifecycle control record, then waits for host timeout or cancellation. | | `stdout-overflow` | Emits deterministic stdout beyond the invocation limit. | @@ -94,6 +96,23 @@ The unavailable case marks the exact observation unavailable. The incompatible case applies a case-local external manifest requirement of Flow `>=9.0.0`. Neither change mutates the hashed provider package. +## Host-harness artifact cases + +Two checkpoint #57 cases deliberately keep `mode=success` because only the host +can create or attempt to reuse Flow's opaque observation token: + +| Case | Harness operation | Exact rejection boundary | +| --- | --- | --- | +| `changed-output-after-observation` | Run the real provider successfully, observe the complete binding set, then overwrite the bound output with deterministic replacement bytes. | The final acceptance read returns `ArtifactAcceptanceError::ObservationChanged`; no accepted token is created. | +| `stale-invocation-and-binding-contexts` | Reuse valid execution or observation evidence first with a changed run identity and then with a changed binding set. | Artifact acceptance returns the exact invocation-context or retained-binding mismatch; no accepted token is created. | + +The supporting artifact-library cases also change an input after observation, +remove an output before the final read, and validate deliberately malformed or +contradictory portable observation documents. They do not add provider modes: +portable JSON cannot recreate `ObservedArtifactSet`, and the retained absolute +canonical root is sensitive host context omitted from portable evidence and +from the token's manual `Debug` output. + For `await-interruption`, the provider writes and syncs this compact record to a sibling temporary file, then atomically publishes it with one line feed at the explicit control locator: @@ -122,9 +141,10 @@ no subprocess, mutates no source, and performs no destructive, signing, or publication action. `trusted-unconfined` remains an explicit test profile, not a sandbox or containment claim. -Corrupt, changed, stale, and contradictory artifact cases remain with #57. -Graph fixtures remain with #58, and final redistribution documentation plus -the parent requirement matrix remain with #59. +Checkpoint #57 delivers corrupt, changed, stale, and contradictory artifact +evidence cases while leaving provider provenance v1 free-form and +non-authoritative. Graph fixtures remain with #58, and final redistribution +documentation plus the parent requirement matrix remain with #59. The source and generated package are distributed under the repository's MIT license. Do not redistribute a materialized package without its license or diff --git a/tests/fixtures/hermetic-provider/main.rs b/tests/fixtures/hermetic-provider/main.rs index 518c3e6..06e93f6 100644 --- a/tests/fixtures/hermetic-provider/main.rs +++ b/tests/fixtures/hermetic-provider/main.rs @@ -44,6 +44,8 @@ enum Behavior { MissingOutput, ExtraOutput, PartialOutput, + CorruptArtifactEvidence, + ContradictoryArtifactEvidence, NonzeroAfterSuccess, AwaitInterruption, StdoutOverflow, @@ -62,6 +64,8 @@ impl Behavior { "missing-output" => Ok(Self::MissingOutput), "extra-output" => Ok(Self::ExtraOutput), "partial-output" => Ok(Self::PartialOutput), + "corrupt-artifact-evidence" => Ok(Self::CorruptArtifactEvidence), + "contradictory-artifact-evidence" => Ok(Self::ContradictoryArtifactEvidence), "nonzero-after-success" => Ok(Self::NonzeroAfterSuccess), "await-interruption" => Ok(Self::AwaitInterruption), "stdout-overflow" => Ok(Self::StdoutOverflow), @@ -287,6 +291,12 @@ fn run() -> ProviderResult<()> { Behavior::PartialOutput => { "The hermetic provider produced deterministic truncated synthetic artifact bytes and marked the result partial." } + Behavior::CorruptArtifactEvidence => { + "The hermetic provider emitted an explicitly corrupt empty artifact-provenance value." + } + Behavior::ContradictoryArtifactEvidence => { + "The hermetic provider emitted contradictory result and artifact-produced event evidence." + } _ => "The hermetic provider produced one deterministic synthetic artifact.", }; let mut result = ExtensionResult { @@ -344,6 +354,8 @@ fn run() -> ProviderResult<()> { redacted: true, }), Behavior::PartialResult | Behavior::PartialOutput => result.partial_result = true, + Behavior::CorruptArtifactEvidence => result.provenance[2].value.clear(), + Behavior::ContradictoryArtifactEvidence => events[1].artifact_refs.clear(), Behavior::InvalidEvent => events[1].sequence = events[0].sequence, Behavior::InvalidResult => { "authorization:hermetic-invalid-result-mismatch" @@ -683,6 +695,14 @@ mod tests { ("missing-output", Behavior::MissingOutput), ("extra-output", Behavior::ExtraOutput), ("partial-output", Behavior::PartialOutput), + ( + "corrupt-artifact-evidence", + Behavior::CorruptArtifactEvidence, + ), + ( + "contradictory-artifact-evidence", + Behavior::ContradictoryArtifactEvidence, + ), ("nonzero-after-success", Behavior::NonzeroAfterSuccess), ("await-interruption", Behavior::AwaitInterruption), ("stdout-overflow", Behavior::StdoutOverflow), diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index 58a1d63..720e910 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -602,6 +602,202 @@ fn partial_output_is_observable_but_cannot_be_accepted() { fixture.assert_immutable_workspace_bytes(&immutable_before); } +#[test] +fn corrupt_artifact_evidence_never_promotes_execution() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = fixture.prepare_lifecycle(capability, "corrupt-artifact-evidence", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + + let error = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap_err(); + + match error { + ProcessRunnerError::Validation { + source: + ExecutionError::InvalidResult { + message, + events: retained_events, + result, + }, + } => { + assert_eq!( + message, + "result.provenance[2].value: must not be empty" + ); + assert_eq!(retained_events.len(), 3); + assert!(result.provenance[2].value.is_empty()); + } + other => panic!("unexpected corrupt-artifact-evidence error: {other:?}"), + } + assert_eq!(events.len(), 3, "valid events remain authoritative"); + assert!(fixture.output_path().is_file()); + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + +#[test] +fn contradictory_artifact_evidence_fails_exact_acceptance() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = + fixture.prepare_lifecycle(capability, "contradictory-artifact-evidence", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + + let execution = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + + assert_eq!(events, execution.events()); + assert!(execution.events()[1].artifact_refs.is_empty()); + assert_eq!( + execution.result().produced_artifacts, + [fixture.bindings.outputs[0].artifact_id.as_str()] + ); + let observed = observe_artifacts( + &fixture.root.path().join(WORKSPACE_LOCATOR), + &fixture.bindings, + ) + .unwrap(); + let error = accept_artifacts( + prepared.resolved(), + &prepared.invocation, + &execution, + &fixture.bindings, + &observed, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "artifact-produced events do not exactly match declared outputs" + )); + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + +#[test] +fn changed_output_after_host_observation_fails_freshness_gate() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = fixture.prepare_lifecycle(capability, "success", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + let execution = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + assert_eq!(events, execution.events()); + let observed = observe_artifacts( + &fixture.root.path().join(WORKSPACE_LOCATOR), + &fixture.bindings, + ) + .unwrap(); + + fs::write(fixture.output_path(), b"{\"changed\":true}\n").unwrap(); + + let error = accept_artifacts( + prepared.resolved(), + &prepared.invocation, + &execution, + &fixture.bindings, + &observed, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::ObservationChanged + )); + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + +#[test] +fn stale_invocation_and_binding_contexts_cannot_reuse_valid_evidence() { + let capability = CAPABILITIES[0]; + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); + let prepared = fixture.prepare_lifecycle(capability, "success", false); + let immutable_before = fixture.immutable_workspace_bytes(); + let mut events = Vec::new(); + let execution = LocalProcessRunner::run( + fixture.root.path(), + prepared.resolved(), + &prepared.invocation, + &prepared.subject_lock, + &prepared.authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + let observed = observe_artifacts( + &fixture.root.path().join(WORKSPACE_LOCATOR), + &fixture.bindings, + ) + .unwrap(); + + let mut stale_invocation = prepared.invocation.clone(); + stale_invocation.run_id = "run:stale-hermetic-evidence".to_owned(); + let error = accept_artifacts( + prepared.resolved(), + &stale_invocation, + &execution, + &fixture.bindings, + &observed, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "validated execution context does not match the supplied invocation" + )); + + let mut stale_bindings = fixture.bindings.clone(); + stale_bindings.binding_set_id = "bindings:stale-hermetic-evidence".to_owned(); + let error = accept_artifacts( + prepared.resolved(), + &prepared.invocation, + &execution, + &stale_bindings, + &observed, + ) + .unwrap_err(); + assert!(matches!( + error, + ArtifactAcceptanceError::Mismatch { ref message } + if message == "artifact bindings changed after host observation" + )); + + fixture.assert_subjects_unchanged(&prepared); + fixture.assert_immutable_workspace_bytes(&immutable_before); +} + #[test] fn nonzero_after_success_never_promotes_provider_evidence() { let capability = CAPABILITIES[0]; From f521d2bb39d7416ddb0095b365af69c18e1449b0 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 10:00:13 -0400 Subject: [PATCH 04/10] Format artifact freshness coverage --- src/artifacts.rs | 12 +++----- tests/artifacts.rs | 50 +++++++--------------------------- tests/hermetic_provider_kit.rs | 13 ++------- 3 files changed, 17 insertions(+), 58 deletions(-) diff --git a/src/artifacts.rs b/src/artifacts.rs index 957111f..9be2def 100644 --- a/src/artifacts.rs +++ b/src/artifacts.rs @@ -535,10 +535,8 @@ pub fn accept_artifacts( for binding in &bindings.inputs { let observation = observations_by_id .get(binding.artifact_id.as_str()) - .ok_or_else(|| { - ArtifactAcceptanceError::Mismatch { - message: "a declared input has no host observation".to_owned(), - } + .ok_or_else(|| ArtifactAcceptanceError::Mismatch { + message: "a declared input has no host observation".to_owned(), })?; correlate_observation( &binding.artifact_id, @@ -559,10 +557,8 @@ pub fn accept_artifacts( for binding in &bindings.outputs { let observation = observations_by_id .get(binding.artifact_id.as_str()) - .ok_or_else(|| { - ArtifactAcceptanceError::Mismatch { - message: "a declared output has no host observation".to_owned(), - } + .ok_or_else(|| ArtifactAcceptanceError::Mismatch { + message: "a declared output has no host observation".to_owned(), })?; correlate_observation( &binding.artifact_id, diff --git a/tests/artifacts.rs b/tests/artifacts.rs index 29a3ff9..6f0b4ca 100644 --- a/tests/artifacts.rs +++ b/tests/artifacts.rs @@ -654,14 +654,8 @@ fn changed_input_bytes_fail_the_immutable_digest_gate() { let invocation = configured_invocation(resolved, &bindings); let execution = execute(resolved, &invocation, ProviderBehavior::default()); - let error = accept_artifacts( - resolved, - &invocation, - &execution, - &bindings, - &observations, - ) - .unwrap_err(); + let error = + accept_artifacts(resolved, &invocation, &execution, &bindings, &observations).unwrap_err(); assert!(matches!( error, ArtifactAcceptanceError::Mismatch { ref message } @@ -685,18 +679,9 @@ fn changed_input_after_observation_cannot_be_accepted() { ) .unwrap(); - let error = accept_artifacts( - resolved, - &invocation, - &execution, - &bindings, - &observations, - ) - .unwrap_err(); - assert!(matches!( - error, - ArtifactAcceptanceError::ObservationChanged - )); + let error = + accept_artifacts(resolved, &invocation, &execution, &bindings, &observations).unwrap_err(); + assert!(matches!(error, ArtifactAcceptanceError::ObservationChanged)); } #[test] @@ -715,18 +700,9 @@ fn changed_output_after_observation_cannot_be_accepted() { ) .unwrap(); - let error = accept_artifacts( - resolved, - &invocation, - &execution, - &bindings, - &observations, - ) - .unwrap_err(); - assert!(matches!( - error, - ArtifactAcceptanceError::ObservationChanged - )); + let error = + accept_artifacts(resolved, &invocation, &execution, &bindings, &observations).unwrap_err(); + assert!(matches!(error, ArtifactAcceptanceError::ObservationChanged)); } #[test] @@ -741,14 +717,8 @@ fn removed_output_after_observation_returns_typed_reobservation_error() { fs::remove_dir_all(root.path().join("outputs/report bundle")).unwrap(); - let error = accept_artifacts( - resolved, - &invocation, - &execution, - &bindings, - &observations, - ) - .unwrap_err(); + let error = + accept_artifacts(resolved, &invocation, &execution, &bindings, &observations).unwrap_err(); assert!(matches!( error, ArtifactAcceptanceError::Reobservation { diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index 720e910..7e34637 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -631,10 +631,7 @@ fn corrupt_artifact_evidence_never_promotes_execution() { result, }, } => { - assert_eq!( - message, - "result.provenance[2].value: must not be empty" - ); + assert_eq!(message, "result.provenance[2].value: must not be empty"); assert_eq!(retained_events.len(), 3); assert!(result.provenance[2].value.is_empty()); } @@ -651,8 +648,7 @@ fn contradictory_artifact_evidence_fails_exact_acceptance() { let capability = CAPABILITIES[0]; let (provider_bytes, executable_name) = provider_binary_snapshot(); let fixture = KitFixture::new(capability, &provider_bytes, &executable_name); - let prepared = - fixture.prepare_lifecycle(capability, "contradictory-artifact-evidence", false); + let prepared = fixture.prepare_lifecycle(capability, "contradictory-artifact-evidence", false); let immutable_before = fixture.immutable_workspace_bytes(); let mut events = Vec::new(); @@ -730,10 +726,7 @@ fn changed_output_after_host_observation_fails_freshness_gate() { &observed, ) .unwrap_err(); - assert!(matches!( - error, - ArtifactAcceptanceError::ObservationChanged - )); + assert!(matches!(error, ArtifactAcceptanceError::ObservationChanged)); fixture.assert_subjects_unchanged(&prepared); fixture.assert_immutable_workspace_bytes(&immutable_before); } From 3164efcf578d0f7cd10e75d71e074a255005f8b6 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 10:47:03 -0400 Subject: [PATCH 05/10] test: execute deterministic provider compositions --- ROADMAP.md | 12 +- .../scenarios/canonical-digests.v1.json | 2 +- .../scenarios/multi-provider.v1.fixture.json | 29 +- docs/architecture/foundation/ARCHITECTURE.md | 13 +- docs/integrations/README.md | 8 +- docs/integrations/hermetic-provider-kit.md | 73 +- docs/integrations/scenario-fixtures.md | 25 + tests/fixtures/hermetic-provider/README.md | 53 +- .../hermetic-provider/extension-lock.v1.json | 6 +- .../extension-manifest.v1.json | 11 +- tests/fixtures/hermetic-provider/main.rs | 32 +- tests/hermetic_provider_kit.rs | 733 +++++++++++++++++- tests/scenario.rs | 2 +- 13 files changed, 938 insertions(+), 61 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a197058..9ed4939 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -178,6 +178,10 @@ behavior through stable error and evidence contracts. - Flow #40 / merged PR #41 supplies exact authority/isolation preflight with green default-branch CI at `5f411da6e7040e3494cbd275729de9a2ed8c67a3`. +- Flow #58 supplies fixed single-provider and two-provider compositions over + the hermetic kit. Each accepted inspection artifact is the exact immutable + input to transformation, and fresh roots must retain equal normalized + evidence and bytes without adding a production scheduler. - Flow #25 remains the active owner of process enforcement. Flow #42 / PR #43 is its final bounded child and adds direct launch/supervision without pre-empting later sandbox, durable-state, or real-adapter work. @@ -353,8 +357,12 @@ artifact semantics. Flow #57 hardens that acceptance boundary against changed evidence and stale binding context without changing the portable artifact schemas or assigning -authoritative digest semantics to free-form provider provenance. The remaining -hermetic-kit graph and closeout checkpoints stay with #58 and #59. +authoritative digest semantics to free-form provider provenance. Flow #58 adds +fixed two-stage single-provider and two-provider compositions that hand the +exact accepted inspection artifact into a transformation stage through public +Flow boundaries and produce equal normalized evidence and bytes across fresh +roots. It remains test-owned sequencing rather than a graph scheduler. The +hermetic-kit documentation and parent-evidence closeout remains with #59. ### Verify locked package and executable subjects diff --git a/contracts/fixtures/scenarios/canonical-digests.v1.json b/contracts/fixtures/scenarios/canonical-digests.v1.json index c7f658e..be87cbc 100644 --- a/contracts/fixtures/scenarios/canonical-digests.v1.json +++ b/contracts/fixtures/scenarios/canonical-digests.v1.json @@ -13,7 +13,7 @@ }, { "path": "fixtures/scenarios/multi-provider.v1.fixture.json", - "digest": "cbf8a6e6b436a20438661abb9f0b17206e720b08027f7efc1cd3c19b21493e2c" + "digest": "0f813edfe5492236ebd66f72e5861960afa9047b74e1a5cc8acf989f0fa64186" }, { "path": "fixtures/scenarios/observed-empty.v1.fixture.json", diff --git a/contracts/fixtures/scenarios/multi-provider.v1.fixture.json b/contracts/fixtures/scenarios/multi-provider.v1.fixture.json index f126111..a8458aa 100644 --- a/contracts/fixtures/scenarios/multi-provider.v1.fixture.json +++ b/contracts/fixtures/scenarios/multi-provider.v1.fixture.json @@ -2,9 +2,9 @@ "schema_version": "flow.scenario-manifest/v1", "scenario_id": "scenario:multi-provider-handoff", "fixture_id": "fixture:multi-provider-handoff-v1", - "fixture_version": "1.0.0", + "fixture_version": "1.1.0", "title": "Hermetic multi-provider handoff", - "description": "Describes an ordered inspect-then-render handoff through two independently identified synthetic providers.", + "description": "Describes an ordered inspect-then-transform handoff through two independently identified synthetic providers using only Flow-owned fake capabilities.", "fixture_class": "valid", "tags": ["clean-room", "multi-provider", "success"], "inputs": [ @@ -52,7 +52,7 @@ "parameters_digest": "6161616161616161616161616161616161616161616161616161616161616161" } }, - "required_capabilities": ["optiflow/inspect-artifact"] + "required_capabilities": ["flow/inspect-fixture"] }, { "provider_id": "org.egohygiene.synthetic-renderer", @@ -75,33 +75,33 @@ "parameters_digest": "9191919191919191919191919191919191919191919191919191919191919191" } }, - "required_capabilities": ["renderflow/render-synthetic-document"] + "required_capabilities": ["flow/transform-fixture"] } ], "stages": [ { "stage_id": "inspect", "provider_id": "org.egohygiene.synthetic-inspector", - "capability_id": "optiflow/inspect-artifact", + "capability_id": "flow/inspect-fixture", "depends_on": [], "consumes": ["source-document"], - "produces": ["inspection-evidence"], + "produces": ["inspection-report"], "required": true }, { - "stage_id": "render", + "stage_id": "transform", "provider_id": "org.egohygiene.synthetic-renderer", - "capability_id": "renderflow/render-synthetic-document", + "capability_id": "flow/transform-fixture", "depends_on": ["inspect"], - "consumes": ["inspection-evidence", "source-document"], - "produces": ["rendered-document"], + "consumes": ["inspection-report"], + "produces": ["transformation-report"], "required": true } ], "expectation": { "terminal_state": "complete", "evidence_state": "complete", - "expected_artifacts": ["inspection-evidence", "rendered-document"], + "expected_artifacts": ["inspection-report", "transformation-report"], "expected_diagnostics": [], "evidence_refs": [ { @@ -130,8 +130,11 @@ "covered_behaviors": [ "cross-provider immutable artifact handoff", "deterministic topological stage order", - "two-provider capability references" + "two-provider Flow-owned fake capability references" ], - "known_gaps": ["Uses synthetic package references rather than released holon binaries."] + "known_gaps": [ + "This manifest remains declarative; the hermetic provider kit separately executes its aligned two-stage composition without making the manifest a runtime plan.", + "Uses synthetic package references rather than released holon binaries." + ] } } diff --git a/docs/architecture/foundation/ARCHITECTURE.md b/docs/architecture/foundation/ARCHITECTURE.md index 03b7c69..aaa3989 100644 --- a/docs/architecture/foundation/ARCHITECTURE.md +++ b/docs/architecture/foundation/ARCHITECTURE.md @@ -238,7 +238,14 @@ trusted-unconfined runner, direct transport workers, timeout/cancellation grace and escalation, and direct-child reaping. Issue #57 binds an opaque artifact observation to its exact binding snapshot and canonical root and requires an equal final observation before acceptance, without changing portable artifact -schemas or free-form extension-result v1 provenance. Flow does not yet supply +schemas or free-form extension-result v1 provenance. Issue #58 adds a test-owned +fixed `inspect`-then-`transform` sequencer that runs both single-provider and +two-provider compositions through public resolution, subject observation, +authority, local-process execution, artifact observation, and acceptance. The +second stage consumes the exact accepted first-stage artifact in the same +workspace, and two fresh roots must produce equal portable evidence and output +bytes. This is conformance infrastructure, not a scenario-manifest executor or +production graph scheduler. Flow does not yet supply the public CLI, real holon adapters, signature or transparency verification, provider-native artifact validation, an atomic filesystem snapshot, an operating-system sandbox or authenticated enforcement evidence, @@ -271,4 +278,6 @@ authenticated sandbox conformance, descriptor-bound launch, process-tree containment, provider-native artifact validation, and scenario execution remain later gates for their corresponding runtime surfaces. Issue #42 adds real Unix direct-child conformance for literal launch state, supervised transport, -overflow, interruption, grace, escalation, and reaping. +overflow, interruption, grace, escalation, and reaping. Issue #58 additionally +validates deterministic fixed-order composition without adding plan/run state, +retry, checkpoint, resume, or real holon algorithms. diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 78d1fc8..110a9b5 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -116,8 +116,12 @@ binding snapshot, and requires equal final root-confined evidence before artifact promotion. Changed or removed evidence and stale invocation or binding context cannot construct `AcceptedArtifactSet`. This hardening does not make observation atomic or reinterpret free-form extension-result v1 -provenance. Graph fixtures and the final parent matrix remain with #58 and #59 -respectively. +provenance. Flow #58 executes fixed `inspect`-then-`transform` +single-provider and two-provider fixtures twice from fresh roots. Every stage +uses the public resolution, subject, authority, runner, observation, and +acceptance boundaries, and stage two receives the exact accepted stage-one +artifact without introducing a scenario runner or production scheduler. The +final #29 requirement matrix and redistribution closeout remain with #59. The following remain deferred: provider discovery from the filesystem, dynamic loading, cryptographic authenticity and transparency verification, diff --git a/docs/integrations/hermetic-provider-kit.md b/docs/integrations/hermetic-provider-kit.md index 05bc063..24506aa 100644 --- a/docs/integrations/hermetic-provider-kit.md +++ b/docs/integrations/hermetic-provider-kit.md @@ -9,7 +9,8 @@ matrix for provider selection, lifecycle supervision, and protocol outcomes. Checkpoint #56 adds the first physical artifact-outcome matrix for missing, extra, and partial outputs. Checkpoint #57 closes corrupt, contradictory, changed, and stale artifact-evidence cases and hardens final artifact freshness. -All four checkpoints exercise released public boundaries without importing a +Checkpoint #58 adds deterministic single-provider and two-provider composition +fixtures. All five checkpoints exercise released public boundaries without importing a sibling implementation or pretending to be an Aniflow, Optiflow, or Renderflow algorithm. @@ -46,12 +47,12 @@ The provider identity is introduced by issue #28. Its one process entrypoint exposes four stable Flow-domain capability IDs: -| Capability | Role | Content-changing | Output media type | +| Capability | Accepted input | Content-changing | Output media type | | --- | --- | --- | --- | -| `flow/inspect-fixture` | Inspect one immutable text fixture | No | `application/vnd.flow.fixture-inspection+json` | -| `flow/transform-fixture` | Produce a synthetic derivative description | Yes | `application/vnd.flow.fixture-transformation+json` | -| `flow/validate-fixture` | Produce synthetic validation evidence | No | `application/vnd.flow.fixture-validation+json` | -| `flow/observe-fixture` | Observe a source without mutating it | No | `application/vnd.flow.fixture-observation+json` | +| `flow/inspect-fixture` | `text/plain` | No | `application/vnd.flow.fixture-inspection+json` | +| `flow/transform-fixture` | `text/plain` or inspection JSON | Yes | `application/vnd.flow.fixture-transformation+json` | +| `flow/validate-fixture` | `text/plain` | No | `application/vnd.flow.fixture-validation+json` | +| `flow/observe-fixture` | `text/plain` | No | `application/vnd.flow.fixture-observation+json` | The observation capability still writes a new evidence artifact. “Read-only” means that its bound source is not mutated; it is not a claim that the shared @@ -61,10 +62,12 @@ Checkpoint #44 executes the inspection capability end to end. Checkpoint #45 uses that same capability for the lifecycle and protocol matrix, and checkpoint #56 uses it for physical artifact outcomes after protocol-valid execution. Checkpoint #57 adds two provider evidence modes and host-harness freshness -cases without changing the capability surface. The other three IDs and their -declared media/configuration profiles remain frozen so the later graph fixtures -do not invent parallel identities. Their end-to-end coverage remains explicitly -deferred. +cases without changing the capability surface. Checkpoint #58 executes +`flow/transform-fixture` for the first time and narrowly adds inspection JSON +to that capability's accepted inputs so a typed handoff is possible. It does +not widen any other capability profile or invent a real holon capability. +Validation and observation remain stable declared synthetic IDs but are not +claimed as executed graph stages by this minimal checkpoint. ## Configuration and deterministic success profile @@ -79,8 +82,11 @@ the success evidence: | `mode` | `success` | Included in compact JSON over the sorted configuration map | | `seed` | `hermetic-success-v1` | Included in the same SHA-256 configuration identity | -The provider requires exactly one bound `text/plain` input and one bound file -output of the capability's declared media type. It recomputes the input digest, +The provider requires exactly one bound input accepted by the selected +capability and one bound file output of that capability's declared media type. +Inspection, validation, and observation accept `text/plain`; transformation +additionally accepts the exact inspection JSON type used by checkpoint #58. It +recomputes the input digest, creates the output with create-new semantics, and emits deterministic started, artifact-produced, and completed events with sequences `0`, `1`, and `2`. The terminal result names the exact consumed and produced artifact IDs and @@ -193,6 +199,35 @@ direct child. The kit does not claim descendant discovery, signalling, or containment. Unix expects graceful default `SIGTERM` handling and therefore asserts `forced: false`; other hosts may require immediate forced termination. +## Checkpoint-3c deterministic compositions + +Checkpoint #58 adds two fixed, test-owned compositions. Both declare the exact +stage array `inspect` then `transform`; the latter depends on the former and +consumes its one accepted output. + +| Fixture | Inspect provider | Transform provider | Handoff | +| --- | --- | --- | --- | +| `single-provider-composition` | `org.egohygiene.synthetic-scenario-provider@0.1.0` | same provider | accepted `artifact:inspection-report` at `outputs/inspection-report.json` | +| `multi-provider-composition` | `org.egohygiene.synthetic-inspector@0.1.0` | `org.egohygiene.synthetic-renderer@0.1.0` | same accepted artifact identity | + +The multi-provider packages contain different fixed identity markers and +therefore have different package digests. They intentionally reuse the same +generic executable bytes, so the executable digest is equal; this is a +provider-boundary test, not an algorithm-diversity claim. + +For each stage, the harness calls public catalog resolution, execution-subject +observation, process authorization, `LocalProcessRunner`, artifact observation, +and artifact acceptance. Only the first stage's `AcceptedArtifactSet` may +supply the second stage's input binding. Artifact ID, port, media type, kind, +locator, and digest remain equal in the shared workspace. Provider, package, +executable, invocation, configuration, authority, execution, observation, and +accepted-artifact identities are retained as normalized stage evidence. + +Each fixture runs twice in fresh roots. Equal normalized evidence and +byte-identical accepted outputs are required. The code explicitly calls stage +zero and stage one; it has no ready queue, topological scheduler, retry, +checkpoint, resume, durable plan/run state, or scenario-manifest execution. + ## Conformance evidence `tests/hermetic_provider_kit.rs` builds fresh roots with identical package, @@ -223,14 +258,22 @@ snapshot cannot reuse otherwise valid evidence to construct `AcceptedArtifactSet`. The focused artifact tests also cover changed input evidence and malformed or contradictory portable observations. +The checkpoint #58 tests additionally require exact accepted-output-to-input +equality across both fixed compositions, stable ordered dependency evidence, +the expected provider selection at each stage, unchanged locked subjects, and +fresh-root repeat equivalence. The checked-in scenario manifests remain +declarative conformance intent rather than runtime instructions. + The fixture source, manifest, lock, materialization rules, offline commands, and redistribution terms live in [`tests/fixtures/hermetic-provider`](../../tests/fixtures/hermetic-provider/README.md). ## Authority and non-claims -The manifest requests only named workspace input/binding reads and named -workspace output writes. It requests no environment, subprocess, network, AI, +The manifest requests only named workspace input, binding, and prior-output +reads plus named workspace output writes. Prior-output read authority permits +the second fixed stage to consume an accepted artifact; it does not permit +input mutation. The provider requests no environment, subprocess, network, AI, GPU, source-mutation, destructive, signing, or publication authority. The runner clears the inherited environment and passes no secret handles. @@ -243,7 +286,7 @@ validation. Checkpoint #57 delivers corrupt, stale, changed, and contradictory artifact evidence coverage without changing extension-result v1 provenance. Checkpoint -#58 owns the single- and multi-provider composition fixtures. Checkpoint #59 +#58 delivers the single- and multi-provider composition fixtures. Checkpoint #59 owns completed redistribution documentation and the final parent #29 requirement-to-test matrix. Durable run state, retry, checkpoint, and resume remain outside the hermetic provider kit. diff --git a/docs/integrations/scenario-fixtures.md b/docs/integrations/scenario-fixtures.md index c31ed49..afd7bba 100644 --- a/docs/integrations/scenario-fixtures.md +++ b/docs/integrations/scenario-fixtures.md @@ -126,6 +126,31 @@ catalog without rewriting it. Any intentional fixture change therefore requires a reviewed manifest, version decision, and explicit digest update; drift cannot silently bless itself. +## Executed composition conformance + +Flow issue #58 aligns the declarative multi-provider fixture with the frozen +`flow/inspect-fixture` and `flow/transform-fixture` capability names and keeps +its explicit `inspect`-then-`transform` order and artifact dependency. The +manifest still is not a runtime plan. In particular, scenario artifact names +such as `inspection-report` and runtime binding IDs such as +`artifact:inspection-report` belong to different closed namespaces. + +Execution evidence lives in the hermetic provider-kit tests. A fixed test-owned +sequencer materializes these two cases: + +| Runtime fixture | Ordered providers | Accepted handoff | +| --- | --- | --- | +| `single-provider-composition` | scenario provider → same provider | `artifact:inspection-report` | +| `multi-provider-composition` | synthetic inspector → synthetic renderer | `artifact:inspection-report` | + +Every stage independently crosses public resolution, subject observation, +authority, local-process execution, artifact observation, and acceptance. The +downstream binding is derived only from the upstream `AcceptedArtifactSet`, and +two fresh runs must retain equal normalized evidence and byte-identical +outputs. This focused evidence does not add a general scenario executor, +production scheduler, durable plan or run, retry, checkpoint, resume, or real +provider-algorithm claim. + ## Checked-in corpus The corpus includes a single-provider success example plus multi-provider, diff --git a/tests/fixtures/hermetic-provider/README.md b/tests/fixtures/hermetic-provider/README.md index 354df77..d071799 100644 --- a/tests/fixtures/hermetic-provider/README.md +++ b/tests/fixtures/hermetic-provider/README.md @@ -11,14 +11,22 @@ adapter or a public Flow CLI. - Process mode: `hermetic-process` - Entrypoint: `flow-hermetic-provider` +The executable composition fixtures also finalize the same generic manifest +under the already declared synthetic identities +`org.egohygiene.synthetic-inspector@0.1.0` and +`org.egohygiene.synthetic-renderer@0.1.0`. Their package directories include a +provider-identity marker, so the two package digests are distinct while the +executable digest remains exactly equal. This proves separate provider +boundaries, not separate algorithms. + The manifest freezes four Flow-owned synthetic capabilities: -| Capability | Synthetic role | Candidate artifact type | +| Capability | Accepted input type | Candidate artifact type | | --- | --- | --- | -| `flow/inspect-fixture` | Inspection | `application/vnd.flow.fixture-inspection+json` | -| `flow/transform-fixture` | Transformation | `application/vnd.flow.fixture-transformation+json` | -| `flow/validate-fixture` | Validation | `application/vnd.flow.fixture-validation+json` | -| `flow/observe-fixture` | Read-only source observation | `application/vnd.flow.fixture-observation+json` | +| `flow/inspect-fixture` | `text/plain` | `application/vnd.flow.fixture-inspection+json` | +| `flow/transform-fixture` | `text/plain` or `application/vnd.flow.fixture-inspection+json` | `application/vnd.flow.fixture-transformation+json` | +| `flow/validate-fixture` | `text/plain` | `application/vnd.flow.fixture-validation+json` | +| `flow/observe-fixture` | `text/plain` | `application/vnd.flow.fixture-observation+json` | “Read-only” describes the source boundary: the provider never mutates an input. Every capability writes a new, explicitly bound evidence artifact, so the @@ -37,6 +45,10 @@ they are not copied into the hashed package root, which would create a self-referential package digest. The conformance test performs these steps in memory and then creates the exact execution-subject lock. +Composition packages for the inspector and renderer add only the documented +`PROVIDER-IDENTITY` marker beside the same executable and license. The marker +bytes are fixed and included in Flow's package observation. + Build from an already populated Cargo cache without network access: ```console @@ -96,6 +108,29 @@ The unavailable case marks the exact observation unavailable. The incompatible case applies a case-local external manifest requirement of Flow `>=9.0.0`. Neither change mutates the hashed provider package. +## Deterministic composition fixtures + +Checkpoint #58 owns two explicit, test-only two-stage fixtures: + +| Fixture | Ordered stages | Providers | Exact handoff | +| --- | --- | --- | --- | +| `single-provider-composition` | `inspect` → `transform` | `org.egohygiene.synthetic-scenario-provider` for both stages | accepted `artifact:inspection-report` | +| `multi-provider-composition` | `inspect` → `transform` | `org.egohygiene.synthetic-inspector` → `org.egohygiene.synthetic-renderer` | accepted `artifact:inspection-report` | + +The harness calls each stage explicitly in array order. It creates the second +binding only from the first stage's `AcceptedArtifactSet`, retaining the same +artifact ID, port, media type, kind, locator, and digest in one workspace. +Every stage independently resolves its provider, matches package and executable +subjects, authorizes the invocation, runs the direct child, observes the bound +artifacts, and accepts the output. Two fresh roots must produce equal portable +resolution, invocation, subject, authority, execution, observation, and +accepted-artifact evidence plus byte-identical outputs. + +The output read grant is required only so a later composition stage may consume +an earlier accepted output; it grants no mutation of that input. These fixtures +do not implement a ready queue, DAG scheduler, retry, resume, checkpoint, +durable state, scenario-manifest executor, or real holon algorithm. + ## Host-harness artifact cases Two checkpoint #57 cases deliberately keep `mode=success` because only the host @@ -136,15 +171,17 @@ may retain decoded provider evidence for inspection. Event delivery is not transactional, so an earlier valid event can reach the authoritative sink before later evidence rejects execution. -The provider reads no ambient environment, opens no network connection, starts +The provider reads only the named binding, input, and prior-output workspace +trees. It reads no ambient environment, opens no network connection, starts no subprocess, mutates no source, and performs no destructive, signing, or publication action. `trusted-unconfined` remains an explicit test profile, not a sandbox or containment claim. Checkpoint #57 delivers corrupt, changed, stale, and contradictory artifact evidence cases while leaving provider provenance v1 free-form and -non-authoritative. Graph fixtures remain with #58, and final redistribution -documentation plus the parent requirement matrix remain with #59. +non-authoritative. Checkpoint #58 delivers the deterministic composition +fixtures. Final redistribution documentation plus the parent requirement matrix +remain with #59. The source and generated package are distributed under the repository's MIT license. Do not redistribute a materialized package without its license or diff --git a/tests/fixtures/hermetic-provider/extension-lock.v1.json b/tests/fixtures/hermetic-provider/extension-lock.v1.json index e7850de..0e6d89a 100644 --- a/tests/fixtures/hermetic-provider/extension-lock.v1.json +++ b/tests/fixtures/hermetic-provider/extension-lock.v1.json @@ -18,7 +18,11 @@ "enabled": true, "trust": "trusted", "granted_permissions": { - "filesystem_read": ["workspace/artifact-bindings", "workspace/inputs"], + "filesystem_read": [ + "workspace/artifact-bindings", + "workspace/inputs", + "workspace/outputs" + ], "filesystem_write": ["workspace/outputs"], "environment_read": [], "subprocesses": [], diff --git a/tests/fixtures/hermetic-provider/extension-manifest.v1.json b/tests/fixtures/hermetic-provider/extension-manifest.v1.json index 6f87087..b936f48 100644 --- a/tests/fixtures/hermetic-provider/extension-manifest.v1.json +++ b/tests/fixtures/hermetic-provider/extension-manifest.v1.json @@ -45,7 +45,10 @@ "capability_id": "flow/transform-fixture", "domain": "flow", "configuration_schema": "flow.hermetic-provider-configuration/v1", - "accepts": ["text/plain"], + "accepts": [ + "application/vnd.flow.fixture-inspection+json", + "text/plain" + ], "produces": ["application/vnd.flow.fixture-transformation+json"], "preconditions": ["flow.hermetic-input-matches-binding/v1"], "postconditions": ["flow.hermetic-output-is-deterministic/v1"], @@ -99,7 +102,11 @@ } ], "requested_permissions": { - "filesystem_read": ["workspace/artifact-bindings", "workspace/inputs"], + "filesystem_read": [ + "workspace/artifact-bindings", + "workspace/inputs", + "workspace/outputs" + ], "filesystem_write": ["workspace/outputs"], "environment_read": [], "subprocesses": [], diff --git a/tests/fixtures/hermetic-provider/main.rs b/tests/fixtures/hermetic-provider/main.rs index 06e93f6..5159d0d 100644 --- a/tests/fixtures/hermetic-provider/main.rs +++ b/tests/fixtures/hermetic-provider/main.rs @@ -153,7 +153,8 @@ fn run() -> ProviderResult<()> { _ => {} } - let (operation, expected_output_type) = capability_profile(&invocation.capability_id)?; + let (operation, accepted_input_types, expected_output_type) = + capability_profile(&invocation.capability_id)?; let root_metadata = fs::symlink_metadata(&arguments.artifact_root)?; ensure( !root_metadata.file_type().is_symlink(), @@ -196,8 +197,8 @@ fn run() -> ProviderResult<()> { "the hermetic success input binding must name a file", )?; ensure( - input_binding.media_type == "text/plain", - "the hermetic success input must use text/plain", + accepted_input_types.contains(&input_binding.media_type.as_str()), + "input binding type is not accepted by the selected capability", )?; ensure( invocation_input.artifact_id == input_binding.artifact_id @@ -459,20 +460,31 @@ fn read_invocation() -> ProviderResult { Ok(serde_json::from_slice(&frame)?) } -fn capability_profile(capability_id: &str) -> ProviderResult<(&'static str, &'static str)> { +fn capability_profile( + capability_id: &str, +) -> ProviderResult<(&'static str, &'static [&'static str], &'static str)> { match capability_id { - "flow/inspect-fixture" => { - Ok(("inspection", "application/vnd.flow.fixture-inspection+json")) - } + "flow/inspect-fixture" => Ok(( + "inspection", + &["text/plain"], + "application/vnd.flow.fixture-inspection+json", + )), "flow/transform-fixture" => Ok(( "transformation", + &[ + "application/vnd.flow.fixture-inspection+json", + "text/plain", + ], "application/vnd.flow.fixture-transformation+json", )), - "flow/validate-fixture" => { - Ok(("validation", "application/vnd.flow.fixture-validation+json")) - } + "flow/validate-fixture" => Ok(( + "validation", + &["text/plain"], + "application/vnd.flow.fixture-validation+json", + )), "flow/observe-fixture" => Ok(( "read-only-observation", + &["text/plain"], "application/vnd.flow.fixture-observation+json", )), _ => Err(invalid_input("unsupported hermetic capability").into()), diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index 7e34637..506318d 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -11,8 +11,8 @@ use flow::{ Configuration, Domain, EXECUTION_SUBJECT_LOCK_V1, EventKind, EventSinkError, EventState, ExecutionError, ExecutionModeKind, ExecutionSubjectLock, ExtensionCatalog, ExtensionInvocation, ExtensionLock, ExtensionManifest, ExtensionObservation, ExtensionResolution, FallbackPolicy, - HostArtifactObservationSet, HostExecutionSubjectObservationSet, InputArtifact, - InputArtifactBinding, InvocationExtension, InvocationInterface, InvocationPhase, + HostArtifactObservation, HostArtifactObservationSet, HostExecutionSubjectObservationSet, + InputArtifact, InputArtifactBinding, InvocationExtension, InvocationInterface, InvocationPhase, LocalProcessRunner, MatchedExecutionSubjects, NoSecrets, OutputArtifactBinding, PROCESS_AUTHORITY_PROFILE_V1, ProcessIsolation, ProcessRunnerError, ProcessStream, ProvenanceKind, ResolutionOutcome, ResolutionRequest, ResolutionResult, SHA256, Severity, @@ -40,13 +40,15 @@ const CONFIGURATION_SCHEMA: &str = "flow.hermetic-provider-configuration/v1"; const LIFECYCLE_CONTROL_LOCATOR: &str = "outputs/lifecycle-control.json"; const EXTRA_OUTPUT_ID: &str = "artifact:undeclared-extra-output"; const EXTRA_OUTPUT_LOCATOR: &str = "outputs/undeclared-extra-output.json"; +const PROVIDER_IDENTITY_LOCATOR: &str = "PROVIDER-IDENTITY"; static NEXT_ROOT_ID: AtomicU64 = AtomicU64::new(0); -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] struct CapabilitySpec { capability_id: &'static str, name: &'static str, + accepted_input_media_types: &'static [&'static str], output_media_type: &'static str, } @@ -54,25 +56,126 @@ const CAPABILITIES: [CapabilitySpec; 4] = [ CapabilitySpec { capability_id: "flow/inspect-fixture", name: "inspection", + accepted_input_media_types: &["text/plain"], output_media_type: "application/vnd.flow.fixture-inspection+json", }, CapabilitySpec { capability_id: "flow/transform-fixture", name: "transformation", + accepted_input_media_types: &[ + "application/vnd.flow.fixture-inspection+json", + "text/plain", + ], output_media_type: "application/vnd.flow.fixture-transformation+json", }, CapabilitySpec { capability_id: "flow/validate-fixture", name: "validation", + accepted_input_media_types: &["text/plain"], output_media_type: "application/vnd.flow.fixture-validation+json", }, CapabilitySpec { capability_id: "flow/observe-fixture", name: "observation", + accepted_input_media_types: &["text/plain"], output_media_type: "application/vnd.flow.fixture-observation+json", }, ]; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProviderSpec { + extension_id: &'static str, + package_locator: &'static str, + package_subject_id: &'static str, + executable_subject_id: &'static str, + identity_marker: Option<&'static [u8]>, +} + +const PRIMARY_PROVIDER: ProviderSpec = ProviderSpec { + extension_id: "org.egohygiene.synthetic-scenario-provider", + package_locator: PACKAGE_LOCATOR, + package_subject_id: "package:hermetic-provider", + executable_subject_id: "executable:hermetic-provider", + identity_marker: None, +}; + +const INSPECTOR_PROVIDER: ProviderSpec = ProviderSpec { + extension_id: "org.egohygiene.synthetic-inspector", + package_locator: "packages/hermetic-provider-inspector", + package_subject_id: "package:hermetic-provider-inspector", + executable_subject_id: "executable:hermetic-provider-inspector", + identity_marker: Some(b"org.egohygiene.synthetic-inspector\n"), +}; + +const RENDERER_PROVIDER: ProviderSpec = ProviderSpec { + extension_id: "org.egohygiene.synthetic-renderer", + package_locator: "packages/hermetic-provider-renderer", + package_subject_id: "package:hermetic-provider-renderer", + executable_subject_id: "executable:hermetic-provider-renderer", + identity_marker: Some(b"org.egohygiene.synthetic-renderer\n"), +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CompositionStageSpec { + stage_id: &'static str, + provider: ProviderSpec, + capability: CapabilitySpec, + phase: InvocationPhase, + depends_on: &'static [&'static str], + consumes: &'static [&'static str], + produces: &'static [&'static str], + output_port: &'static str, + output_locator: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CompositionFixtureSpec { + fixture_id: &'static str, + stages: [CompositionStageSpec; 2], +} + +const SINGLE_PROVIDER_COMPOSITION: CompositionFixtureSpec = CompositionFixtureSpec { + fixture_id: "single-provider-composition", + stages: [ + CompositionStageSpec { + stage_id: "inspect", + provider: PRIMARY_PROVIDER, + capability: CAPABILITIES[0], + phase: InvocationPhase::Inspect, + depends_on: &[], + consumes: &[INPUT_ID], + produces: &["artifact:inspection-report"], + output_port: "port:inspection-report", + output_locator: "outputs/inspection-report.json", + }, + CompositionStageSpec { + stage_id: "transform", + provider: PRIMARY_PROVIDER, + capability: CAPABILITIES[1], + phase: InvocationPhase::Execute, + depends_on: &["inspect"], + consumes: &["artifact:inspection-report"], + produces: &["artifact:transformation-report"], + output_port: "port:transformation-report", + output_locator: "outputs/transformation-report.json", + }, + ], +}; + +const MULTI_PROVIDER_COMPOSITION: CompositionFixtureSpec = CompositionFixtureSpec { + fixture_id: "multi-provider-composition", + stages: [ + CompositionStageSpec { + provider: INSPECTOR_PROVIDER, + ..SINGLE_PROVIDER_COMPOSITION.stages[0] + }, + CompositionStageSpec { + provider: RENDERER_PROVIDER, + ..SINGLE_PROVIDER_COMPOSITION.stages[1] + }, + ], +}; + struct TestRoot { path: PathBuf, } @@ -110,6 +213,45 @@ struct KitFixture { package_observations: HostArtifactObservationSet, } +struct CompositionFixture { + root: TestRoot, + spec: CompositionFixtureSpec, + catalog: ExtensionCatalog, + providers: BTreeMap, + source_digest: String, +} + +struct MaterializedProvider { + spec: ProviderSpec, + package_digest: String, + executable_digest: String, + executable_locator: String, + grants_digest: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CompositionInput { + artifact_id: String, + port: String, + media_type: String, + kind: ArtifactKind, + locator: String, + digest: String, +} + +impl From<&HostArtifactObservation> for CompositionInput { + fn from(artifact: &HostArtifactObservation) -> Self { + Self { + artifact_id: artifact.artifact_id.clone(), + port: artifact.port.clone(), + media_type: artifact.media_type.clone(), + kind: artifact.kind, + locator: artifact.locator.clone(), + digest: artifact.digest.clone(), + } + } +} + #[derive(Clone, Copy, Debug)] struct CatalogProfile { available: bool, @@ -153,6 +295,29 @@ struct RunEvidence { output_bytes: Vec, } +#[derive(Debug, Eq, PartialEq)] +struct CompositionRunEvidence { + fixture_id: String, + stages: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +struct CompositionStageEvidence { + stage_id: String, + depends_on: Vec, + bindings: ArtifactBindingSet, + resolution: ExtensionResolution, + invocation: ExtensionInvocation, + subject_lock: ExecutionSubjectLock, + subjects: HostExecutionSubjectObservationSet, + authority_profile_digest: String, + enforcement_evidence_digest: String, + execution: ValidatedExecution, + observations: HostArtifactObservationSet, + accepted: AcceptedArtifactSet, + output_bytes: Vec, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct TreeEntry { path: PathBuf, @@ -199,7 +364,14 @@ fn templates_freeze_the_provider_identity_and_four_capabilities() { ); for (declared, expected) in manifest.capabilities.iter().zip(CAPABILITIES) { assert_eq!(declared.configuration_schema, CONFIGURATION_SCHEMA); - assert_eq!(declared.accepts, ["text/plain"]); + assert_eq!( + declared + .accepts + .iter() + .map(String::as_str) + .collect::>(), + expected.accepted_input_media_types + ); assert_eq!(declared.produces, [expected.output_media_type]); assert_eq!(declared.content_changes, expected.name == "transformation"); assert!(declared.cacheable); @@ -219,6 +391,14 @@ fn templates_freeze_the_provider_identity_and_four_capabilities() { assert!(manifest.requested_permissions.ai_providers.is_empty()); assert!(!manifest.requested_permissions.source_mutation); assert!(!manifest.requested_permissions.destructive); + assert_eq!( + manifest.requested_permissions.filesystem_read, + [ + "workspace/artifact-bindings", + "workspace/inputs", + "workspace/outputs", + ] + ); assert_eq!(lock.extensions.len(), 1); assert_eq!(lock.extensions[0].trust, Trust::Trusted); assert_eq!( @@ -263,6 +443,24 @@ fn inspection_is_deterministic_across_fresh_process_and_artifact_boundaries() { assert_eq!(first, second, "inspection evidence drifted"); } +#[test] +fn single_provider_composition_preserves_exact_handoff_and_determinism() { + assert_composition_fixture( + SINGLE_PROVIDER_COMPOSITION, + [PRIMARY_PROVIDER.extension_id, PRIMARY_PROVIDER.extension_id], + false, + ); +} + +#[test] +fn multi_provider_composition_preserves_exact_handoff_and_determinism() { + assert_composition_fixture( + MULTI_PROVIDER_COMPOSITION, + [INSPECTOR_PROVIDER.extension_id, RENDERER_PROVIDER.extension_id], + true, + ); +} + #[test] fn unavailable_provider_is_blocked_before_invocation_with_retained_evidence() { let capability = CAPABILITIES[0]; @@ -1073,6 +1271,533 @@ fn timeout_and_readiness_gated_cancellation_reap_the_direct_child() { timeout_fixture.assert_immutable_workspace_bytes(&timeout_before); } +impl CompositionFixture { + #[allow(clippy::too_many_lines)] + fn new( + spec: CompositionFixtureSpec, + provider_bytes: &[u8], + executable_name: &str, + ) -> Self { + let root = TestRoot::new(); + let workspace_path = root.path().join(WORKSPACE_LOCATOR); + fs::create_dir_all(workspace_path.join("inputs")).unwrap(); + fs::create_dir_all(workspace_path.join("outputs")).unwrap(); + fs::create_dir_all(workspace_path.join("artifact-bindings")).unwrap(); + fs::write(workspace_path.join(INPUT_LOCATOR), INPUT_BYTES).unwrap(); + let source_digest = digest_bytes(INPUT_BYTES); + + let manifest_template: ExtensionManifest = serde_json::from_str(MANIFEST_TEMPLATE).unwrap(); + let mut lock: ExtensionLock = serde_json::from_str(LOCK_TEMPLATE).unwrap(); + let locked_extension_template = lock.extensions[0].clone(); + lock.lock_id = format!("lock:{}", spec.fixture_id); + lock.extensions.clear(); + + let mut provider_specs = BTreeMap::new(); + for stage in spec.stages { + provider_specs.insert(stage.provider.extension_id, stage.provider); + } + + let mut manifests = Vec::new(); + let mut observations = Vec::new(); + let mut providers = BTreeMap::new(); + for provider_spec in provider_specs.values().copied() { + let package_path = root.path().join(provider_spec.package_locator); + fs::create_dir_all(&package_path).unwrap(); + let executable_path = package_path.join(executable_name); + fs::write(&executable_path, provider_bytes).unwrap(); + make_executable(&executable_path); + fs::write(package_path.join("LICENSE"), LICENSE_BYTES).unwrap(); + if let Some(identity_marker) = provider_spec.identity_marker { + fs::write( + package_path.join(PROVIDER_IDENTITY_LOCATOR), + identity_marker, + ) + .unwrap(); + } + + let package_bindings = ArtifactBindingSet { + schema_version: ARTIFACT_BINDINGS_V1.to_owned(), + binding_set_id: format!( + "bindings:composition-package-{}", + provider_spec.extension_id + ), + digest_algorithm: SHA256.to_owned(), + inputs: Vec::new(), + outputs: vec![OutputArtifactBinding { + artifact_id: format!( + "artifact:composition-package-{}", + provider_spec.extension_id + ), + port: format!("port:composition-package-{}", provider_spec.extension_id), + media_type: "application/vnd.flow.execution-package-directory".to_owned(), + kind: ArtifactKind::Directory, + locator: provider_spec.package_locator.to_owned(), + }], + }; + package_bindings.validate().unwrap(); + let package_observations = observe_artifacts(root.path(), &package_bindings) + .unwrap() + .into_evidence(); + let package_digest = package_observations.artifacts[0].digest.clone(); + let executable_digest = digest_bytes(&fs::read(&executable_path).unwrap()); + + let mut manifest = manifest_template.clone(); + provider_spec + .extension_id + .clone_into(&mut manifest.extension_id); + package_digest.clone_into(&mut manifest.integrity.value); + manifest.validate().unwrap(); + observations.push(ExtensionObservation::new( + manifest.extension_id.clone(), + manifest.version.clone(), + manifest.publisher.id.clone(), + manifest.integrity.clone(), + true, + )); + + let mut locked_extension = locked_extension_template.clone(); + provider_spec + .extension_id + .clone_into(&mut locked_extension.extension_id); + provider_spec + .package_locator + .clone_into(&mut locked_extension.discovery.location); + package_digest.clone_into(&mut locked_extension.integrity.value); + let grants_digest = digest_json(&locked_extension.granted_permissions); + lock.extensions.push(locked_extension); + manifests.push(manifest); + providers.insert( + provider_spec.extension_id.to_owned(), + MaterializedProvider { + spec: provider_spec, + package_digest, + executable_digest, + executable_locator: executable_name.to_owned(), + grants_digest, + }, + ); + } + + let default_provider = spec.stages[0].provider.extension_id; + for resolution in &mut lock.capability_resolution { + let selected_provider = spec + .stages + .iter() + .find(|stage| stage.capability.capability_id == resolution.capability_id) + .map_or(default_provider, |stage| stage.provider.extension_id); + resolution.ordered_extensions = vec![selected_provider.to_owned()]; + } + lock.validate().unwrap(); + let catalog = ExtensionCatalog::inspect(manifests, lock, observations).unwrap(); + + Self { + root, + spec, + catalog, + providers, + source_digest, + } + } + + fn run(&self) -> CompositionRunEvidence { + let inspect_spec = self.spec.stages[0]; + assert_eq!(inspect_spec.stage_id, "inspect"); + assert!(inspect_spec.depends_on.is_empty()); + assert_eq!(inspect_spec.consumes, [INPUT_ID]); + let inspect_input = CompositionInput { + artifact_id: INPUT_ID.to_owned(), + port: "port:source-text".to_owned(), + media_type: "text/plain".to_owned(), + kind: ArtifactKind::File, + locator: INPUT_LOCATOR.to_owned(), + digest: self.source_digest.clone(), + }; + let inspect = self.run_stage(inspect_spec, &inspect_input); + + let transform_spec = self.spec.stages[1]; + assert_eq!(transform_spec.stage_id, "transform"); + assert_eq!(transform_spec.depends_on, [inspect_spec.stage_id]); + let accepted_handoff = inspect.accepted.outputs()[0].clone(); + assert_eq!( + transform_spec.consumes, + [accepted_handoff.artifact_id.as_str()] + ); + let transform_input = CompositionInput::from(&accepted_handoff); + let transform = self.run_stage(transform_spec, &transform_input); + assert_eq!( + accepted_handoff, + transform.accepted.inputs()[0], + "the downstream input must be the exact accepted upstream artifact" + ); + assert_eq!( + fs::read( + self.root + .path() + .join(WORKSPACE_LOCATOR) + .join(INPUT_LOCATOR) + ) + .unwrap(), + INPUT_BYTES + ); + + CompositionRunEvidence { + fixture_id: self.spec.fixture_id.to_owned(), + stages: vec![inspect, transform], + } + } + + #[allow(clippy::too_many_lines)] + fn run_stage( + &self, + stage: CompositionStageSpec, + input: &CompositionInput, + ) -> CompositionStageEvidence { + assert_eq!(stage.consumes, [input.artifact_id.as_str()]); + assert_eq!(stage.produces.len(), 1); + assert!( + stage + .capability + .accepted_input_media_types + .contains(&input.media_type.as_str()) + ); + let bindings = ArtifactBindingSet { + schema_version: ARTIFACT_BINDINGS_V1.to_owned(), + binding_set_id: format!( + "bindings:{}-{}", + self.spec.fixture_id, stage.stage_id + ), + digest_algorithm: SHA256.to_owned(), + inputs: vec![InputArtifactBinding { + artifact_id: input.artifact_id.clone(), + port: input.port.clone(), + media_type: input.media_type.clone(), + kind: input.kind, + locator: input.locator.clone(), + expected_digest: input.digest.clone(), + }], + outputs: vec![OutputArtifactBinding { + artifact_id: stage.produces[0].to_owned(), + port: stage.output_port.to_owned(), + media_type: stage.capability.output_media_type.to_owned(), + kind: ArtifactKind::File, + locator: stage.output_locator.to_owned(), + }], + }; + bindings.validate().unwrap(); + let bindings_locator = format!( + "artifact-bindings/{}-{}.v1.json", + self.spec.fixture_id, stage.stage_id + ); + let bindings_path = self + .root + .path() + .join(WORKSPACE_LOCATOR) + .join(&bindings_locator); + assert!(!bindings_path.exists()); + fs::write(&bindings_path, serde_json::to_vec_pretty(&bindings).unwrap()).unwrap(); + + let request = ResolutionRequest::new( + format!("{}-{}", self.spec.fixture_id, stage.stage_id), + stage.capability.capability_id, + Domain::Flow, + "hermetic-process", + ExecutionModeKind::Process, + ); + let resolution = self.catalog.resolve(&request); + let resolved = resolution.resolved().unwrap_or_else(|| { + panic!( + "composition stage must resolve: {:#?}", + resolution.evidence() + ) + }); + assert_eq!(resolved.extension_id(), stage.provider.extension_id); + assert_eq!(resolved.fallback_policy(), FallbackPolicy::Forbidden); + let provider = self.providers.get(stage.provider.extension_id).unwrap(); + let seed = format!("{}-{}-v1", self.spec.fixture_id, stage.stage_id); + let configuration_values = BTreeMap::from([ + ("mode".to_owned(), serde_json::json!("success")), + ("seed".to_owned(), serde_json::json!(seed)), + ]); + let invocation = ExtensionInvocation { + schema_version: flow::EXTENSION_INVOCATION_V1.to_owned(), + invocation_id: format!( + "invocation:{}-{}", + self.spec.fixture_id, stage.stage_id + ), + run_id: format!("run:{}", self.spec.fixture_id), + phase: stage.phase, + extension: InvocationExtension { + extension_id: resolved.extension_id().to_owned(), + version: resolved.version().to_owned(), + publisher_id: resolved.publisher_id().to_owned(), + integrity: resolved.integrity().value.clone(), + }, + capability_id: stage.capability.capability_id.to_owned(), + interface: InvocationInterface { + kind: resolved.execution_mode().kind, + name: resolved.execution_mode().name.clone(), + protocol: resolved.execution_mode().protocol.clone(), + }, + input_artifacts: vec![InputArtifact { + artifact_id: input.artifact_id.clone(), + digest: input.digest.clone(), + }], + expected_output_types: vec![stage.capability.output_media_type.to_owned()], + configuration: Configuration { + schema_id: CONFIGURATION_SCHEMA.to_owned(), + digest: digest_json(&configuration_values), + values: configuration_values, + }, + authorization: Authorization { + authorization_id: format!( + "authorization:{}-{}", + self.spec.fixture_id, stage.stage_id + ), + lock_id: resolved.lock_id().to_owned(), + grants_digest: provider.grants_digest.clone(), + }, + limits: resolved.execution_mode().limits.clone(), + checkpoint_refs: Vec::new(), + secret_handles: Vec::new(), + cancellation_id: format!("cancel:{}-{}", self.spec.fixture_id, stage.stage_id), + }; + invocation.validate().unwrap(); + + let subject_lock = self.subject_lock(resolved, &invocation, stage, provider); + let subjects_before = observe_execution_subjects( + self.root.path(), + resolved, + &invocation, + &subject_lock, + ) + .unwrap(); + let mut authority_profile = process_authority_profile( + resolved, + &invocation, + &subject_lock, + &subjects_before, + ProcessIsolation::TrustedUnconfined, + ); + authority_profile.authority_profile_id = format!( + "authority-profile:{}-{}", + self.spec.fixture_id, stage.stage_id + ); + authority_profile.requested.argv = composition_provider_argv(&bindings_locator); + authority_profile + .granted + .argv + .clone_from(&authority_profile.requested.argv); + let mut enforcement = process_enforcement_evidence(&authority_profile, &subjects_before); + enforcement.enforcement_evidence_id = format!( + "enforcement:{}-{}", + self.spec.fixture_id, stage.stage_id + ); + let authority = authorize_process( + resolved, + &invocation, + &subject_lock, + &subjects_before, + &authority_profile, + &enforcement, + ) + .unwrap(); + + let mut events = Vec::new(); + let execution = LocalProcessRunner::run( + self.root.path(), + resolved, + &invocation, + &subject_lock, + &authority, + &NoSecrets, + &mut events, + ) + .unwrap(); + assert_eq!(events, execution.events()); + assert_eq!( + execution.result().consumed_artifacts, + [input.artifact_id.as_str()] + ); + assert_eq!( + execution.result().produced_artifacts, + [stage.produces[0]] + ); + assert_eq!( + execution.result().configuration_digest, + invocation.configuration.digest + ); + assert_eq!( + execution.result().extension_id, + stage.provider.extension_id + ); + + let observed = observe_artifacts( + &self.root.path().join(WORKSPACE_LOCATOR), + &bindings, + ) + .unwrap(); + let accepted = + accept_artifacts(resolved, &invocation, &execution, &bindings, &observed).unwrap(); + let output_bytes = fs::read( + self.root + .path() + .join(WORKSPACE_LOCATOR) + .join(stage.output_locator), + ) + .unwrap(); + assert_eq!(accepted.outputs()[0].digest, digest_bytes(&output_bytes)); + let subjects_after = observe_execution_subjects( + self.root.path(), + resolved, + &invocation, + &subject_lock, + ) + .unwrap(); + assert_eq!(subjects_before.evidence(), subjects_after.evidence()); + + CompositionStageEvidence { + stage_id: stage.stage_id.to_owned(), + depends_on: stage + .depends_on + .iter() + .map(|dependency| (*dependency).to_owned()) + .collect(), + bindings, + resolution: resolution.evidence().clone(), + invocation, + subject_lock, + subjects: subjects_after.evidence().clone(), + authority_profile_digest: authority.profile_digest().to_owned(), + enforcement_evidence_digest: authority.evidence_digest().to_owned(), + execution, + observations: observed.into_evidence(), + accepted, + output_bytes, + } + } + + fn subject_lock( + &self, + resolved: &flow::ResolvedExtension, + invocation: &ExtensionInvocation, + stage: CompositionStageSpec, + provider: &MaterializedProvider, + ) -> ExecutionSubjectLock { + let mut lock: ExecutionSubjectLock = serde_json::from_str(include_str!( + "../contracts/examples/execution-subject-lock.v1.example.json" + )) + .unwrap(); + EXECUTION_SUBJECT_LOCK_V1.clone_into(&mut lock.schema_version); + lock.subject_lock_id = format!( + "subject-lock:{}-{}", + self.spec.fixture_id, stage.stage_id + ); + resolved.lock_id().clone_into(&mut lock.extension_lock_id); + lock.extension.clone_from(&invocation.extension); + stage + .capability + .capability_id + .clone_into(&mut lock.capability_id); + lock.interface.clone_from(&invocation.interface); + lock.declared_entrypoint + .clone_from(&resolved.execution_mode().entrypoint); + provider + .spec + .package_subject_id + .clone_into(&mut lock.package.subject_id); + provider + .spec + .package_locator + .clone_into(&mut lock.package.locator); + provider + .package_digest + .clone_into(&mut lock.package.digest.value); + provider + .spec + .executable_subject_id + .clone_into(&mut lock.executable.subject_id); + lock.executable + .locator + .clone_from(&provider.executable_locator); + provider + .executable_digest + .clone_into(&mut lock.executable.digest.value); + lock.validate().unwrap(); + lock + } +} + +fn assert_composition_fixture( + spec: CompositionFixtureSpec, + expected_providers: [&str; 2], + distinct_packages: bool, +) { + let (provider_bytes, executable_name) = provider_binary_snapshot(); + let first_fixture = CompositionFixture::new(spec, &provider_bytes, &executable_name); + let first = first_fixture.run(); + let second_fixture = CompositionFixture::new(spec, &provider_bytes, &executable_name); + let second = second_fixture.run(); + + assert_eq!(first, second, "normalized composition evidence drifted"); + assert_eq!(first.fixture_id, spec.fixture_id); + assert_eq!( + first + .stages + .iter() + .map(|stage| stage.stage_id.as_str()) + .collect::>(), + ["inspect", "transform"] + ); + assert!(first.stages[0].depends_on.is_empty()); + assert_eq!(first.stages[1].depends_on, ["inspect"]); + assert_eq!( + first.stages[0].accepted.outputs()[0], + first.stages[1].accepted.inputs()[0] + ); + + for (stage, expected_provider) in first.stages.iter().zip(expected_providers) { + assert_eq!(stage.invocation.extension.extension_id, expected_provider); + assert_eq!(stage.subject_lock.extension, stage.invocation.extension); + assert_eq!(stage.subjects.extension, stage.invocation.extension); + assert_eq!(stage.execution.result().extension_id, expected_provider); + assert_eq!( + stage.execution.result().configuration_digest, + stage.invocation.configuration.digest + ); + assert_eq!(stage.accepted.outputs().len(), 1); + assert_eq!( + stage.accepted.outputs()[0].digest, + digest_bytes(&stage.output_bytes) + ); + } + + if distinct_packages { + assert_ne!( + first.stages[0].subject_lock.package.digest.value, + first.stages[1].subject_lock.package.digest.value + ); + } else { + assert_eq!( + first.stages[0].subject_lock.package.digest.value, + first.stages[1].subject_lock.package.digest.value + ); + } + assert_eq!( + first.stages[0].subject_lock.executable.digest.value, + first.stages[1].subject_lock.executable.digest.value, + "both synthetic providers intentionally reuse the immutable generic executable" + ); +} + +fn composition_provider_argv(bindings_locator: &str) -> Vec { + vec![ + "--artifact-root".to_owned(), + "../../workspace".to_owned(), + "--artifact-bindings".to_owned(), + bindings_locator.to_owned(), + ] +} + impl KitFixture { fn new(capability: CapabilitySpec, provider_bytes: &[u8], executable_name: &str) -> Self { Self::new_with_catalog_profile( diff --git a/tests/scenario.rs b/tests/scenario.rs index 1eaad10..37de4dd 100644 --- a/tests/scenario.rs +++ b/tests/scenario.rs @@ -109,7 +109,7 @@ fn canonical_digests_match_the_checked_in_cross_language_catalog() { ), ( MULTI_PROVIDER, - "cbf8a6e6b436a20438661abb9f0b17206e720b08027f7efc1cd3c19b21493e2c", + "0f813edfe5492236ebd66f72e5861960afa9047b74e1a5cc8acf989f0fa64186", ), ( OBSERVED_EMPTY, From 564f675032133f281745d41d6c1f3d3d97b42ac9 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 10:49:16 -0400 Subject: [PATCH 06/10] Format composition fixtures --- ROADMAP.md | 2 +- docs/architecture/foundation/ARCHITECTURE.md | 2 +- tests/fixtures/hermetic-provider/main.rs | 5 +- tests/hermetic_provider_kit.rs | 88 ++++++-------------- 4 files changed, 30 insertions(+), 67 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9ed4939..3a0e23d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ schema: aether.architecture-document/v1 id: flow-roadmap title: Flow Roadmap kind: architecture-document -version: 1.1.0 +version: 1.2.0 status: draft owners: - egohygiene diff --git a/docs/architecture/foundation/ARCHITECTURE.md b/docs/architecture/foundation/ARCHITECTURE.md index aaa3989..acc1e01 100644 --- a/docs/architecture/foundation/ARCHITECTURE.md +++ b/docs/architecture/foundation/ARCHITECTURE.md @@ -3,7 +3,7 @@ schema: aether.architecture-document/v1 id: flow-architecture title: Flow Architecture kind: architecture-document -version: 0.10.0 +version: 0.11.0 status: draft owners: - egohygiene diff --git a/tests/fixtures/hermetic-provider/main.rs b/tests/fixtures/hermetic-provider/main.rs index 5159d0d..de330e4 100644 --- a/tests/fixtures/hermetic-provider/main.rs +++ b/tests/fixtures/hermetic-provider/main.rs @@ -471,10 +471,7 @@ fn capability_profile( )), "flow/transform-fixture" => Ok(( "transformation", - &[ - "application/vnd.flow.fixture-inspection+json", - "text/plain", - ], + &["application/vnd.flow.fixture-inspection+json", "text/plain"], "application/vnd.flow.fixture-transformation+json", )), "flow/validate-fixture" => Ok(( diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index 506318d..a3f0c1c 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -62,10 +62,7 @@ const CAPABILITIES: [CapabilitySpec; 4] = [ CapabilitySpec { capability_id: "flow/transform-fixture", name: "transformation", - accepted_input_media_types: &[ - "application/vnd.flow.fixture-inspection+json", - "text/plain", - ], + accepted_input_media_types: &["application/vnd.flow.fixture-inspection+json", "text/plain"], output_media_type: "application/vnd.flow.fixture-transformation+json", }, CapabilitySpec { @@ -456,7 +453,10 @@ fn single_provider_composition_preserves_exact_handoff_and_determinism() { fn multi_provider_composition_preserves_exact_handoff_and_determinism() { assert_composition_fixture( MULTI_PROVIDER_COMPOSITION, - [INSPECTOR_PROVIDER.extension_id, RENDERER_PROVIDER.extension_id], + [ + INSPECTOR_PROVIDER.extension_id, + RENDERER_PROVIDER.extension_id, + ], true, ); } @@ -1273,11 +1273,7 @@ fn timeout_and_readiness_gated_cancellation_reap_the_direct_child() { impl CompositionFixture { #[allow(clippy::too_many_lines)] - fn new( - spec: CompositionFixtureSpec, - provider_bytes: &[u8], - executable_name: &str, - ) -> Self { + fn new(spec: CompositionFixtureSpec, provider_bytes: &[u8], executable_name: &str) -> Self { let root = TestRoot::new(); let workspace_path = root.path().join(WORKSPACE_LOCATOR); fs::create_dir_all(workspace_path.join("inputs")).unwrap(); @@ -1430,13 +1426,7 @@ impl CompositionFixture { "the downstream input must be the exact accepted upstream artifact" ); assert_eq!( - fs::read( - self.root - .path() - .join(WORKSPACE_LOCATOR) - .join(INPUT_LOCATOR) - ) - .unwrap(), + fs::read(self.root.path().join(WORKSPACE_LOCATOR).join(INPUT_LOCATOR)).unwrap(), INPUT_BYTES ); @@ -1462,10 +1452,7 @@ impl CompositionFixture { ); let bindings = ArtifactBindingSet { schema_version: ARTIFACT_BINDINGS_V1.to_owned(), - binding_set_id: format!( - "bindings:{}-{}", - self.spec.fixture_id, stage.stage_id - ), + binding_set_id: format!("bindings:{}-{}", self.spec.fixture_id, stage.stage_id), digest_algorithm: SHA256.to_owned(), inputs: vec![InputArtifactBinding { artifact_id: input.artifact_id.clone(), @@ -1494,7 +1481,11 @@ impl CompositionFixture { .join(WORKSPACE_LOCATOR) .join(&bindings_locator); assert!(!bindings_path.exists()); - fs::write(&bindings_path, serde_json::to_vec_pretty(&bindings).unwrap()).unwrap(); + fs::write( + &bindings_path, + serde_json::to_vec_pretty(&bindings).unwrap(), + ) + .unwrap(); let request = ResolutionRequest::new( format!("{}-{}", self.spec.fixture_id, stage.stage_id), @@ -1520,10 +1511,7 @@ impl CompositionFixture { ]); let invocation = ExtensionInvocation { schema_version: flow::EXTENSION_INVOCATION_V1.to_owned(), - invocation_id: format!( - "invocation:{}-{}", - self.spec.fixture_id, stage.stage_id - ), + invocation_id: format!("invocation:{}-{}", self.spec.fixture_id, stage.stage_id), run_id: format!("run:{}", self.spec.fixture_id), phase: stage.phase, extension: InvocationExtension { @@ -1564,13 +1552,9 @@ impl CompositionFixture { invocation.validate().unwrap(); let subject_lock = self.subject_lock(resolved, &invocation, stage, provider); - let subjects_before = observe_execution_subjects( - self.root.path(), - resolved, - &invocation, - &subject_lock, - ) - .unwrap(); + let subjects_before = + observe_execution_subjects(self.root.path(), resolved, &invocation, &subject_lock) + .unwrap(); let mut authority_profile = process_authority_profile( resolved, &invocation, @@ -1588,10 +1572,8 @@ impl CompositionFixture { .argv .clone_from(&authority_profile.requested.argv); let mut enforcement = process_enforcement_evidence(&authority_profile, &subjects_before); - enforcement.enforcement_evidence_id = format!( - "enforcement:{}-{}", - self.spec.fixture_id, stage.stage_id - ); + enforcement.enforcement_evidence_id = + format!("enforcement:{}-{}", self.spec.fixture_id, stage.stage_id); let authority = authorize_process( resolved, &invocation, @@ -1618,24 +1600,15 @@ impl CompositionFixture { execution.result().consumed_artifacts, [input.artifact_id.as_str()] ); - assert_eq!( - execution.result().produced_artifacts, - [stage.produces[0]] - ); + assert_eq!(execution.result().produced_artifacts, [stage.produces[0]]); assert_eq!( execution.result().configuration_digest, invocation.configuration.digest ); - assert_eq!( - execution.result().extension_id, - stage.provider.extension_id - ); + assert_eq!(execution.result().extension_id, stage.provider.extension_id); - let observed = observe_artifacts( - &self.root.path().join(WORKSPACE_LOCATOR), - &bindings, - ) - .unwrap(); + let observed = + observe_artifacts(&self.root.path().join(WORKSPACE_LOCATOR), &bindings).unwrap(); let accepted = accept_artifacts(resolved, &invocation, &execution, &bindings, &observed).unwrap(); let output_bytes = fs::read( @@ -1646,13 +1619,9 @@ impl CompositionFixture { ) .unwrap(); assert_eq!(accepted.outputs()[0].digest, digest_bytes(&output_bytes)); - let subjects_after = observe_execution_subjects( - self.root.path(), - resolved, - &invocation, - &subject_lock, - ) - .unwrap(); + let subjects_after = + observe_execution_subjects(self.root.path(), resolved, &invocation, &subject_lock) + .unwrap(); assert_eq!(subjects_before.evidence(), subjects_after.evidence()); CompositionStageEvidence { @@ -1688,10 +1657,7 @@ impl CompositionFixture { )) .unwrap(); EXECUTION_SUBJECT_LOCK_V1.clone_into(&mut lock.schema_version); - lock.subject_lock_id = format!( - "subject-lock:{}-{}", - self.spec.fixture_id, stage.stage_id - ); + lock.subject_lock_id = format!("subject-lock:{}-{}", self.spec.fixture_id, stage.stage_id); resolved.lock_id().clone_into(&mut lock.extension_lock_id); lock.extension.clone_from(&invocation.extension); stage From 0aedaf7f2260a0027a93e42650200b005abe1b98 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 10:50:32 -0400 Subject: [PATCH 07/10] Avoid copying composition specifications --- tests/hermetic_provider_kit.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index a3f0c1c..358a719 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -443,7 +443,7 @@ fn inspection_is_deterministic_across_fresh_process_and_artifact_boundaries() { #[test] fn single_provider_composition_preserves_exact_handoff_and_determinism() { assert_composition_fixture( - SINGLE_PROVIDER_COMPOSITION, + &SINGLE_PROVIDER_COMPOSITION, [PRIMARY_PROVIDER.extension_id, PRIMARY_PROVIDER.extension_id], false, ); @@ -452,7 +452,7 @@ fn single_provider_composition_preserves_exact_handoff_and_determinism() { #[test] fn multi_provider_composition_preserves_exact_handoff_and_determinism() { assert_composition_fixture( - MULTI_PROVIDER_COMPOSITION, + &MULTI_PROVIDER_COMPOSITION, [ INSPECTOR_PROVIDER.extension_id, RENDERER_PROVIDER.extension_id, @@ -1273,7 +1273,7 @@ fn timeout_and_readiness_gated_cancellation_reap_the_direct_child() { impl CompositionFixture { #[allow(clippy::too_many_lines)] - fn new(spec: CompositionFixtureSpec, provider_bytes: &[u8], executable_name: &str) -> Self { + fn new(spec: &CompositionFixtureSpec, provider_bytes: &[u8], executable_name: &str) -> Self { let root = TestRoot::new(); let workspace_path = root.path().join(WORKSPACE_LOCATOR); fs::create_dir_all(workspace_path.join("inputs")).unwrap(); @@ -1388,7 +1388,7 @@ impl CompositionFixture { Self { root, - spec, + spec: *spec, catalog, providers, source_digest, @@ -1694,7 +1694,7 @@ impl CompositionFixture { } fn assert_composition_fixture( - spec: CompositionFixtureSpec, + spec: &CompositionFixtureSpec, expected_providers: [&str; 2], distinct_packages: bool, ) { From 8c0ee2b15e294bde300f022fdba58281af918732 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 10:53:01 -0400 Subject: [PATCH 08/10] Use executable lifecycle phase for composition --- tests/hermetic_provider_kit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hermetic_provider_kit.rs b/tests/hermetic_provider_kit.rs index 358a719..0153f1c 100644 --- a/tests/hermetic_provider_kit.rs +++ b/tests/hermetic_provider_kit.rs @@ -138,7 +138,7 @@ const SINGLE_PROVIDER_COMPOSITION: CompositionFixtureSpec = CompositionFixtureSp stage_id: "inspect", provider: PRIMARY_PROVIDER, capability: CAPABILITIES[0], - phase: InvocationPhase::Inspect, + phase: InvocationPhase::Execute, depends_on: &[], consumes: &[INPUT_ID], produces: &["artifact:inspection-report"], From 2c752dc414c4e087324ab42324f873e5dbfb4796 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Thu, 24 Sep 2026 11:30:42 -0400 Subject: [PATCH 09/10] test: finalize hermetic provider kit evidence --- README.md | 24 ++- ROADMAP.md | 67 +++--- docs/integrations/README.md | 14 +- docs/integrations/hermetic-provider-kit.md | 228 +++++++++++++++++++-- docs/integrations/scenario-fixtures.md | 6 + tests/fixtures/hermetic-provider/README.md | 101 ++++++--- tests/hermetic_provider_kit.rs | 170 ++++++++++++++- 7 files changed, 515 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 47d84e3..abe5fc2 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ or a sandbox. - [Process authority and isolation](docs/integrations/authority-isolation.md) - [Artifact binding contract](docs/integrations/artifact-bindings.md) - [Scenario fixture contract](docs/integrations/scenario-fixtures.md) +- [Hermetic provider kit](docs/integrations/hermetic-provider-kit.md) - [Versioned contracts](contracts/README.md) - [Roadmap](ROADMAP.md) @@ -152,17 +153,18 @@ skills, agents, templates, and validators used to maintain these documents. ## Status Flow is in the **executable contract seam** phase. Issues #23, #26, #28, #36, -#38, and #40 are merged through PRs #24, #27, #35, #37, #39, and #41. Issue -#42 / PR #43 adds the final bounded parent-#25 slice: real -`trusted-unconfined` direct launch and supervision through the existing -subject, authority, and transcript gates. Issue #44 begins the parent-#29 -hermetic provider kit with an immutable package and deterministic accepted -inspection artifact. Issue #45 adds its bounded selection, lifecycle, and -protocol outcome matrix; #46 retains physical artifact adversaries, graph -fixtures, and the final parent evidence matrix. The process seam still does not -implement authenticity verification, operating-system sandbox enforcement, -authenticated host evidence, descriptor-bound launch, or process-tree -containment, and the scenario contract is not an executor. +#38, #40, #42, #44, and #45 are merged through PRs #24, #27, #35, #37, #39, +#41, #43, #47, and #48. PR #60 consolidates #46's four sequential checkpoints: +physical and evidence adversaries, deterministic single- and two-provider +compositions, exact package/executable content-identity correlation, a closed +behavior catalog, and the parent #29 evidence matrix. After that PR merges and +default-branch CI is green, #46 and #29 can close and #30 becomes the next +conformance-matrix checkpoint. +The synthetic providers are not the two real adapters required to finish +FLO-Q03. The process seam still does not implement authenticity verification, +operating-system sandbox enforcement, authenticated host evidence, +descriptor-bound launch, or process-tree containment, and the scenario contract +is not an executor. Current descriptions of Aniflow, Optiflow, and Renderflow are grounded in their default branches as inspected on 2026-08-13. The holons remain independently released repositories; real provider adapters and the restore-and-assess workflow remain diff --git a/ROADMAP.md b/ROADMAP.md index 3a0e23d..4093c16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ schema: aether.architecture-document/v1 id: flow-roadmap title: Flow Roadmap kind: architecture-document -version: 1.2.0 +version: 1.3.0 status: draft owners: - egohygiene @@ -30,29 +30,31 @@ repository: egohygiene/flow visibility: public publication: central route: /roadmap/flow/ -updated: 2026-09-22 +updated: 2026-09-24 --> -## 2026-09-21 execution snapshot +## 2026-09-24 execution snapshot > [!IMPORTANT] -> **2026-09-22 live-sweep checkpoint** +> **2026-09-24 hermetic-kit closeout checkpoint** > -> The current suite state and strict next-up queue are preserved in -> [the 2026-09-22 live-sweep checkpoint](docs/roadmaps/flow-suite-live-sweep-2026-09-22.md). -> It supersedes stale active-checkpoint and open-count claims in this snapshot while preserving -> the evidence below as history. Flow draft PR #47 is ready for maintainer review; stacked draft -> PR #48 follows it. The immediate Optiflow lane is #88 → #89 → #90 → #91 → #92 → #96 → #93, -> followed by #94 → #95 for lossless PNG replacement. +> Flow #42, #44, and #45 are merged. Draft PR #60 consolidates #46's four +> sequential checkpoints (#56–#59), including the final hermetic-kit package +> layout/finalization, behavior, and requirement evidence. Merge #60, require +> green default-branch CI, then close #46 and #29 and hand the executable +> conformance matrix to #30. +> [The 2026-09-22 live-sweep checkpoint](docs/roadmaps/flow-suite-live-sweep-2026-09-22.md) +> remains the historical suite-wide queue; this checkpoint supersedes only its +> older Flow PR state. > This evidence-reconciled snapshot is the issue-generation and visual-roadmap handoff. The longer-horizon strategy below remains canonical context; generated HTML, JSON, progress, issue plans, and commit lists are projections. **Lifecycle:** executable contract prototype -**Current gate:** Complete Flow #42, the final bounded child of Flow #25: launch -one exact authorized `trusted-unconfined` provider, enforce bounded transport -and direct-child lifecycle control, and preserve the existing subject, -authority, transcript, and artifact boundaries. +**Current gate:** Review and merge Flow PR #60, then require green +default-branch CI before closing #46 and #29. Flow #30 is the exact next +checkpoint for the broader executable compatibility, artifact, provider, +diagnostic, and privacy scenario matrix. **North-star outcome:** Federated orchestration across holons with stable provider seams, resumable work, and explicit evidence. @@ -62,8 +64,8 @@ authority, transcript, and artifact boundaries. **Route:** `/roadmap/flow/` **Current publication evidence:** Architecture, contract source, merged Flow #23 / PR #24, #26 / PR #27, #28 / PR #35, #36 / PR #37, #38 / PR #39, #40 / -PR #41, and the candidate #42 / PR #43; no executable release or Pages -publication observed. +PR #41, #42 / PR #43, #44 / PR #47, and #45 / PR #48, plus candidate #46 / +PR #60; no executable release or Pages publication observed. Publish the public-safe projection through egohygiene.io at /roadmap/flow/. This repository owns intent and acceptance evidence; it does not add a second site deployment. @@ -138,7 +140,7 @@ same acceptance gate validates a deterministic external-process transcript. id: FLO-Q03 status: active depends_on: [FLO-Q02] -issues: [13, 25, 28, 36, 38, 40, 42] +issues: [13, 25, 28, 29, 30, 36, 38, 40, 42] --> #### FLO-Q03 — Freeze conformance fixtures and implement provider adapters @@ -178,13 +180,20 @@ behavior through stable error and evidence contracts. - Flow #40 / merged PR #41 supplies exact authority/isolation preflight with green default-branch CI at `5f411da6e7040e3494cbd275729de9a2ed8c67a3`. -- Flow #58 supplies fixed single-provider and two-provider compositions over - the hermetic kit. Each accepted inspection artifact is the exact immutable - input to transformation, and fresh roots must retain equal normalized - evidence and bytes without adding a production scheduler. -- Flow #25 remains the active owner of process enforcement. Flow #42 / PR #43 - is its final bounded child and adds direct launch/supervision without - pre-empting later sandbox, durable-state, or real-adapter work. +- Flow #25 is complete through #42 / merged PR #43, which adds bounded direct + launch and supervision without claiming sandbox, durable-state, or + real-adapter behavior. +- Flow #44 / merged PR #47 establishes the immutable hermetic package and + accepted success path. Flow #45 / merged PR #48 adds its closed lifecycle and + protocol matrix. +- Flow #46 / candidate PR #60 carries the evidence intended to complete the + remaining artifact adversaries, fixed single-provider and two-provider + compositions, exact package and executable-digest correlation and tamper + rejection, the closed behavior catalog, and every parent #29 requirement + mapping. Its evidence is indexed in + [the hermetic provider kit](docs/integrations/hermetic-provider-kit.md). +- The synthetic providers are conformance infrastructure, not real adapters. + FLO-Q03 remains active for #30 and the later released-provider adapter work.