diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..01c696f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + witnesses: + name: Witness gate + runs-on: ubuntu-latest + steps: + - name: Check out Hello Echo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Read the pinned producer commits + id: producers + run: | + set -eu + test "$(jq -r '.version' producers.lock.json)" = 1 + { + printf 'edict_repo=%s\n' "$(jq -r '.edict.repository' producers.lock.json)" + printf 'edict_commit=%s\n' "$(jq -r '.edict.commit' producers.lock.json)" + printf 'echo_repo=%s\n' "$(jq -r '.echo.repository' producers.lock.json)" + printf 'echo_commit=%s\n' "$(jq -r '.echo.commit' producers.lock.json)" + } >>"$GITHUB_OUTPUT" + + - name: Check out Edict at the pinned commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: ${{ steps.producers.outputs.edict_repo }} + ref: ${{ steps.producers.outputs.edict_commit }} + path: .producers/edict + + - name: Check out Echo at the pinned commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: ${{ steps.producers.outputs.echo_repo }} + ref: ${{ steps.producers.outputs.echo_commit }} + path: .producers/echo + + - name: Install the Rust toolchain + run: | + set -eu + rustup toolchain install stable --profile minimal \ + --component rustfmt --component clippy + rustup default stable + + - name: Install witness tools + run: | + set -eu + sudo apt-get update + sudo apt-get install --yes --no-install-recommends b3sum xxd + + - name: Cache cargo + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + .build + key: cargo-${{ runner.os }}-${{ steps.producers.outputs.edict_commit }}-${{ steps.producers.outputs.echo_commit }} + restore-keys: cargo-${{ runner.os }}- + + - name: Check shell syntax + run: | + set -eu + for script in tests/*.sh tests/lib/*.sh; do + test -e "$script" || continue + sh -n "$script" + done + + - name: Verify the producer lock + env: + EDICT_REPO: .producers/edict + ECHO_REPO: .producers/echo + run: ./tests/producer-lock.sh + + # Every witness builds what it needs, so the build boundaries are covered + # by the suites that invoke them. + - name: Run the pure runtime witness + env: + EDICT_REPO: .producers/edict + ECHO_REPO: .producers/echo + run: ./tests/runtime.sh + + - name: Run the workspace observation witness + env: + EDICT_REPO: .producers/edict + ECHO_REPO: .producers/echo + run: ./tests/effect-runtime.sh + + - name: Run the validated patch witness + env: + EDICT_REPO: .producers/edict + ECHO_REPO: .producers/echo + run: ./tests/patch-runtime.sh + + - name: Run the stale-output witness + env: + EDICT_REPO: .producers/edict + ECHO_REPO: .producers/echo + run: ./tests/build-cleans-output.sh + + - name: Run the hermetic assertion tests + run: ./tests/writer-epoch-assertions.sh + + - name: Check host formatting + run: | + set -eu + cargo fmt --manifest-path .build/effect/host/Cargo.toml -- --check + cargo fmt --manifest-path .build/patch/host/Cargo.toml -- --check + + - name: Lint the generated hosts + run: | + set -eu + cargo clippy --manifest-path .build/effect/host/Cargo.toml \ + --target-dir .build/effect/host-target --all-targets -- -D warnings + cargo clippy --manifest-path .build/patch/host/Cargo.toml \ + --target-dir .build/patch/host-target --all-targets -- -D warnings diff --git a/.gitignore b/.gitignore index 0e03e15..efc9315 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /.build/ + +# Producer checkouts materialised by CI. +.producers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 389b133..0805cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/). runtime-request admission errors at every phase boundary, checkout-independent artifact paths, path and budget refusals, fixed-seed cases, and bounded stress. +- Compiler-authored basis-bound patch witness covering request-before-write, + exact writable apertures, postcondition settlement, effect-free retry and + replay, crash reconciliation, ambiguous outcomes, path and basis refusals, + request-budget boundaries, compiler-artifact substitution, fixed-seed binary + replacements, and bounded stress. +- Writer-epoch chain evidence in the observation and patch reports, with + witness cases requiring a fresh epoch per write phase, exact predecessor and + final-commit-digest linkage, a strictly advancing start LSN, no epoch on + read-only phases, and no epoch reused across the ordered golden path. The + predecessor linkage is compared against the commit digest the predecessor + reported, not merely checked for shape. +- A retained-ledger plateau case in both witnesses, driving sixteen fresh + writer epochs on one WAL and requiring the persisted ledger to stop changing + size, which a fixed-size ceiling could not establish. +- A two-route basis binding for the reconciled success settlement, since the + reconciler is a distinct implementation from the adapter and the existing + probe exercised only the apply path. +- Writer-epoch coverage on the reconciliation and uncertainty write + entrypoints, and retained-ledger snapshots across both retries, since a null + epoch in a retry report is supplied by the phase itself and cannot show that + no epoch was taken. +- Negative coverage for the retained postcondition evidence: the one settlement + family where the declared replacement and the observed post-state differ now + pins that the evidence varies with what was observed and not with what was + requested, and records that `beforeContentDigest` reports the observed bytes + in that case. +- `wal.lastCommitDigest` in both reports, so a successor epoch's declared + predecessor commit can be compared with the commit that actually closed it. +- Hermetic `tests/writer-epoch-assertions.sh` covering the shared writer-epoch + assertions against mutated reports. It requires no producer checkout and no + `cargo`. + +- `replacementExceedsRequestBudget` and `observationExceedsFileBudget` as + distinct request obstructions, with witness cases covering a replacement + above the encodable ceiling and a declared pre-state above the file budget. + +- `producers.lock.json` pinning the exact Edict and Echo commits, enforced at + every build boundary by `tests/producer-lock.sh`, so a stale, mismatched, or + locally modified producer checkout fails rather than silently changing what + is proven. +- A CI workflow that reads that lock, checks the producers out at those + commits, and runs the complete witness gate plus shell syntax, formatting, + and strict clippy on pull requests and pushes to `main`. + +- Large file bodies are handed to `jq` through `--rawfile` rather than `--arg`. + A body at the file budget is 131,072 hex characters, which is exactly Linux's + `MAX_ARG_STRLEN`, so the oversize cases failed with "Argument list too long" + anywhere but a developer machine. +- Absolute symlink targets in the relative-producer-path probe. The probe + linked the producer checkout as given, so a relative producer path produced a + dangling link rather than a relative path, and the observation witness failed + before it began. It worked only because every caller had passed absolute + paths until CI existed. + +### Removed + +- `tests/lib/check-resource-identities.sh` and + `tests/resource-identity-guard.sh`. The guard had grown into a second Edict + parser written in shell and living in the consumer: declaration syntax, + comments, coordinates, same-line clauses, sidecar terminators, digest + grammar, and placeholder recognition. Edict owns canonical resource + construction, identity derivation, closure validation, and rejection of + malformed, missing, substituted, and sentinel resources, and the build + already corroborates every artifact byte-for-byte and invokes that validator. + Hello Echo corroborates Edict artifacts; it does not partially reparse Edict + source. ### Changed +- Advanced the vendored `workspace.patch@1` closure to Edict + `df80f92ad6242c6da31a64224666fd37aa43b0d0`, which replaces the sentinel + `workspace.patch.input@1`, `workspace.patch.settlement@1`, and + `workspace.patch.reconcile@1` digests with the exact identities of vendored + `edict.external-action-resource/v1` artifacts, now supplied to the build + through `externalActionResources`. +- Acquire the patch host's writer epoch through Echo's + `FilesystemWalStore::acquire_fresh_writer_epoch` against Echo + `c354d531679861fb7bbd52ab7b7703807909ab86`, replacing the static epoch + identity, fixed fencing, process, host, and lease digests, and absent + predecessor linkage that could not fence overlapping or restarted hosts. +- Advanced the vendored `workspace.snapshot@1` closure to the same Edict + commit, which likewise replaces its sentinel `workspace.snapshot.input@1`, + `workspace.snapshot.settlement@1`, and `workspace.snapshot.reconcile@1` + digests with vendored external-action resource identities, and acquire the + observation host's writer epoch through the same producer-owned fresh-epoch + contract. - Require the runtime witness to retain the exact Edict-authored `GreetingCreated { key, message }` result identity and canonical bytes through generic Echo evaluation and to compare the applied, fresh-host, and diff --git a/README.md b/README.md index e5851db..0e68293 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,23 @@ basis-bound patch application, Graft hosting, Git and GitHub adapters, and the self-hosted delivery loop. The delivery loop is Roadmap Ω, not Hello Echo's bootstrap workload. +## Producer pin + +`producers.lock.json` records the exact Edict and Echo commits this repository +is proven against. Every build boundary calls `tests/producer-lock.sh` first +and refuses a checkout that is not those commits, or that has uncommitted +changes, so a stale or locally modified producer fails loudly instead of +producing a misleading result. A commit id alone would not catch the second +case: edits to the producer leave `rev-parse` reporting the pinned commit while +the build compiles different sources. CI reads the same file and +checks the producers out at those commits. + +Advancing a producer means changing that file in the same commit as the +re-vendored artifacts it implies. + ## Local build -Set `EDICT_REPO` and `ECHO_REPO` to compatible local checkouts, then run: +Set `EDICT_REPO` and `ECHO_REPO` to checkouts at the pinned commits, then run: ```sh EDICT_REPO=/path/to/edict \ @@ -139,10 +153,18 @@ bounded workspace adapter. It proves: post-claim aperture substitution cannot recover the claim; - unauthorized, parent-escaped, symlink, and stale-basis paths settle as typed refusals; -- the exact settlement-size boundary succeeds and one byte less refuses; and +- the exact settlement-size boundary succeeds and one byte less refuses; - substituted compiler artifacts are rejected with the same typed obstruction at request and recovery boundaries without appending to the WAL, while - invalid runtime requests remain a distinct pre-commit refusal. + invalid runtime requests remain a distinct pre-commit refusal; and +- every write phase runs under a fresh Echo-derived writer epoch chained to the + persisted predecessor, while read-only phases acquire no epoch. + +The request pins `workspace.snapshot.input@1`, +`workspace.snapshot.settlement@1`, and `workspace.snapshot.reconcile@1` to the +exact identities of vendored `edict.external-action-resource/v1` artifacts, +supplied to the build through `externalActionResources`. Edict validates that +closure; Hello Echo corroborates the artifacts and invokes the validator. The fixed suite contains one ordered golden path, one relative compiler-artifact path probe, one idempotent retry, one conflicting retry, one @@ -160,3 +182,112 @@ process, network, or model authority and introduces no application callback. No artifact in this repository may be replaced by a handwritten Echo package, and no native Hello Echo callback may implement application semantics. + +## Hello Effect validated patch application + +The second external-effect proof accepts bounded observation evidence as basis +input and applies one compiler-authored validated patch: + +```sh +EDICT_REPO=/path/to/edict \ +ECHO_REPO=/path/to/echo \ +./tests/patch-runtime.sh +``` + +This witness needs `b3sum` and `xxd` in addition to `jq`. It compares the content digests +the settlement reports against digests computed from the witnessed bytes, so it +must hash them the same way the adapter does. + +The build corroborates the exact Edict source, lawpack closure, digest +sidecars, Core artifact, and Target IR artifact for +`workspace.patch.applyValidated@1`. Edict emits request data only. The compiler +provider receives no filesystem authority and emits no executable-operation +package. + +The request JSON separates untrusted `proposal` data from the declared +`observation` basis. The host owns `permittedPaths` and the adapter's +65,536-byte file cap; the model controls only the closed `proposal` schema. + +That 65,536-byte cap bounds the file, not the replacement. The encoded patch +carries the target path and the expected content digest inside the same bounded +request carrier, so the largest accepted replacement is smaller than the cap and +shrinks as the path grows. + +The budget is producer-owned. Echo's `encode_validated_workspace_patch_input_v1` +refuses with `FileBudgetExceeded` once the canonical encoding passes +`MAX_CANONICAL_PATCH_INPUT_BYTES`, and Hello Echo surfaces that as +`replacementExceedsRequestBudget` rather than deriving a ceiling of its own. A +replacement that cannot be carried is therefore refused for a stated reason +instead of being reported as a malformed request. + +The reachable size follows from that bound minus the canonical framing, so it is +not a constant this repository can pin. Measured against Echo +`c354d531679861fb7bbd52ab7b7703807909ab86`, it was 65,366 bytes for `a.txt`, +65,360 for `notes/x.txt`, and 65,313 for a 57-character path. Those figures +illustrate the shape of the bound; they are not a contract, and they move with +the producer's encoding. Nothing in this repository depends on them, and +`tests/patch-runtime.sh` probes the refusal rather than any particular +threshold. +The host uses Echo's generic validated-patch encoder and authority functions; +it does not reconstruct patch policy or perform native application semantics. +The observation is also a closed schema. Echo durably records the request and +claim before only the bounded adapter receives a workspace root. + +This witness proves the basis-bound write boundary independently. It does not +claim that the observation and patch run share one chained transaction or +worldline. + +The runtime witness proves: + +- request and claim commit before mutation, across separate processes; +- recovery exposes pending requested and claimed states without workspace + authority; +- the adapter can mutate only an exact permitted path under the admitted + observation basis; +- the canonical settlement commits before the result is reported, and the + report cross-compares its attempt, request basis, external evidence, + postcondition digest, and resulting basis; +- exact retry is effect-free and conflicting retry obstructs without WAL + growth; +- replay accepts no workspace root and does not reapply a settled patch after + the file changes again; +- a crash after mutation but before settlement reconciles from the observed + postcondition without inventing pre-state evidence; +- an ambiguous postcondition settles as `outcomeUnknown` without another + mutation; +- stale basis, unauthorized path, parent escape, symlink, and CI-workflow + policy failures obstruct before mutation; +- the exact request-only settlement floor passes and one byte less refuses + before a WAL commit; +- a replacement too large for the compiler-declared request carrier refuses as + `replacementExceedsRequestBudget` before a WAL commit, and a declared + pre-state above the host file budget refuses as + `observationExceedsFileBudget`, both distinct from a malformed request and + from each other; +- compiler-artifact substitution fails at request and claim boundaries + without hidden WAL growth; +- every write phase runs under a fresh Echo-derived writer epoch chained to the + persisted predecessor, read-only phases acquire no epoch, and no epoch is + reused across the ordered path; and +- fixed-seed text, Unicode, and binary replacements plus eight bounded stress + worldlines pass. + +The request pins `workspace.patch.input@1`, `workspace.patch.settlement@1`, and +`workspace.patch.reconcile@1` to the exact identities of vendored +`edict.external-action-resource/v1` artifacts, supplied to the build through +`externalActionResources`. Edict recomputes and validates the complete closure +and refuses a malformed, missing, substituted, or unresolved resource. Hello +Echo corroborates those artifacts byte-for-byte and invokes that validator; it +does not reparse Edict source. + +Writer-epoch fencing is producer-owned. Each host phase is a separate process, +and it calls Echo's `FilesystemWalStore::acquire_fresh_writer_epoch`, which +takes the filesystem writer lease, rereads the persisted epoch ledger, closes +an epoch left by a terminated process, and derives the successor from that +predecessor's identity and final commit digest. Hello Echo constructs no epoch +identity and reuses no fencing token across restarts. + +The model-facing surface is data only. Edict owns the request declaration, +Echo owns admission and durable coordination, and the adapter alone owns the +bounded write. No generic filesystem write, process, network, Git, or model +authority is introduced. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6db74df..44cf62b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,11 +132,28 @@ Add `ApplyValidatedPatch` only after read-only observation is green: - settlement records the resulting basis or `outcome_unknown`; and - replay never reapplies the patch. +The `workspace.patch.applyValidated@1` consumer proof now satisfies this phase. +Exact Edict Core and Target IR artifacts construct the request without callable +write effects. Proposal data is deterministically encoded against declared +bounded-observation input and an exact host-owned writable aperture. +Model-facing fields are a closed schema, while both the aperture and file cap +remain host-owned and cannot be substituted after claim. This independently +proves the basis-bound write boundary; it does not claim one chained +observation-to-patch transaction or worldline. Echo records request and claim +before only its generic adapter receives the workspace root. Settlement +precedes publication; exact retry and replay are effect-free; ambiguous +postconditions reconcile to either the observed success or `outcomeUnknown`; +the consumer cross-compares Echo's attempt, request-basis, external-evidence, +postcondition, and resulting-basis bindings; and path, basis, symlink, +CI-workflow, budget, and compiler-artifact violations fail closed at their +owning boundaries. + No generic process, filesystem, or network capability is introduced. -Roadmap A.1 is four of five phases complete: Echo durable external actions, -Edict typed external requests, Echo bounded observation, and this external -consumer proof are complete. Basis-bound validated patch application remains. +Roadmap A.1 is five of five phases complete: Echo durable external actions, +Edict typed external requests, Echo bounded observation, the external +observation consumer proof, and basis-bound validated patch application are +complete. ## Roadmap B — Graft hosted by Echo diff --git a/edict.effect.application.json b/edict.effect.application.json index d3ddba5..57a2082 100644 --- a/edict.effect.application.json +++ b/edict.effect.application.json @@ -13,6 +13,17 @@ "targetConfiguration": "effect/vendor/workspace-snapshot/request-profile-configuration.cbor" } ], + "externalActionResources": [ + { + "artifact": "effect/vendor/workspace-snapshot/input-schema.cbor" + }, + { + "artifact": "effect/vendor/workspace-snapshot/settlement-schema.cbor" + }, + { + "artifact": "effect/vendor/workspace-snapshot/reconciliation-law.cbor" + } + ], "target": { "profile": "echo.dpo@1", "providerPackage": ".build/effect/echo-provider" diff --git a/edict.patch.application.json b/edict.patch.application.json new file mode 100644 index 0000000..5774992 --- /dev/null +++ b/edict.patch.application.json @@ -0,0 +1,32 @@ +{ + "schema": "edict.application/v1", + "buildKind": "externalAction", + "coordinate": "examples.workspace_patcher@1", + "sources": [ + "patch/src/apply-validated-patch.edict" + ], + "lawpacks": [ + { + "manifest": "patch/vendor/workspace-patch/manifest.cbor", + "exports": "patch/vendor/workspace-patch/exports.cbor", + "adapter": "patch/vendor/workspace-patch/adapter.cbor", + "targetConfiguration": "patch/vendor/workspace-patch/request-profile-configuration.cbor" + } + ], + "externalActionResources": [ + { + "artifact": "patch/vendor/workspace-patch/input-schema.cbor" + }, + { + "artifact": "patch/vendor/workspace-patch/settlement-schema.cbor" + }, + { + "artifact": "patch/vendor/workspace-patch/reconciliation-law.cbor" + } + ], + "target": { + "profile": "echo.dpo@1", + "providerPackage": ".build/patch/echo-provider" + }, + "outputDirectory": ".build/patch/application" +} diff --git a/effect-host/src/main.rs b/effect-host/src/main.rs index 312a358..75bdf9a 100644 --- a/effect-host/src/main.rs +++ b/effect-host/src/main.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use warp_core::causal_wal::{ FilesystemWalStore, Lsn, PayloadCodecId, PayloadSchemaId, WalDurabilityMode, WalSegmentId, - WalStorePort, WalTransactionId, WriterEpochId, WriterEpochRequest, + WalStorePort, WalTransactionId, WriterEpoch, WriterEpochId, }; use warp_core::external_action::{ claim_external_action, reconcile_external_action_settlement_retry, @@ -224,12 +224,12 @@ fn request_phase( request_case: &RequestCase, admitted: &AdmittedEdictExternalActionRequestV1, ) -> Result<(), String> { - let mut store = open_write_store(&invocation.wal_dir)?; + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; let mut coordinator = recover(&store)?; record_external_action_request( &mut store, &mut coordinator, - transaction_context("request", admitted), + transaction_context("request", admitted, writer_epoch.epoch_id), admitted.request(), ) .map_err(|error| format!("request admission failed: {error:?}"))?; @@ -239,6 +239,7 @@ fn request_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -247,7 +248,7 @@ fn claim_phase( request_case: &RequestCase, admitted: &AdmittedEdictExternalActionRequestV1, ) -> Result<(), String> { - let mut store = open_write_store(&invocation.wal_dir)?; + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; let mut coordinator = recover(&store)?; let request = admitted.request(); let recorded = coordinator @@ -263,7 +264,7 @@ fn claim_phase( claim_external_action( &mut store, &mut coordinator, - transaction_context("claim", admitted), + transaction_context("claim", admitted, writer_epoch.epoch_id), recorded, authorization, request.basis_digest, @@ -277,6 +278,7 @@ fn claim_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -299,6 +301,8 @@ fn inspect_phase( admitted, &store, &coordinator, + // Read-only phases take no writer lease and acquire no epoch. + None, ) } @@ -312,7 +316,7 @@ fn settlement_phase( .as_ref() .map(Path::new) .ok_or_else(|| format!("{} requires a workspace root", invocation.phase))?; - let mut store = open_write_store(&invocation.wal_dir)?; + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; let mut coordinator = recover(&store)?; let grant = coordinator .claim_grant(admitted.request().request_id()) @@ -330,7 +334,7 @@ fn settlement_phase( .admit_settlement( &mut store, &mut coordinator, - transaction_context("settlement", admitted), + transaction_context("settlement", admitted, writer_epoch.epoch_id), admitted, grant, candidate, @@ -342,6 +346,7 @@ fn settlement_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -353,7 +358,7 @@ fn uncertainty_phase( if invocation.argument.is_some() { return Err("unknown does not accept external-world authority".to_owned()); } - let mut store = open_write_store(&invocation.wal_dir)?; + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; let mut coordinator = recover(&store)?; let grant = coordinator .claim_grant(admitted.request().request_id()) @@ -364,7 +369,7 @@ fn uncertainty_phase( .admit_outcome_unknown( &mut store, &mut coordinator, - transaction_context("settlement", admitted), + transaction_context("settlement", admitted, writer_epoch.epoch_id), admitted, grant, digest("hello-effect:outcome-unknown-evidence"), @@ -376,6 +381,7 @@ fn uncertainty_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -419,6 +425,7 @@ fn retry_phase( admitted, &store, &coordinator, + None, )?; let mut report = report .as_object() @@ -445,6 +452,7 @@ fn retry_phase( admitted, &store, &coordinator, + None, )? .as_object() .cloned() @@ -472,8 +480,16 @@ fn print_report( admitted: &AdmittedEdictExternalActionRequestV1, store: &FilesystemWalStore, coordinator: &ExternalActionCoordinatorV1, + writer_epoch: Option<&WriterEpoch>, ) -> Result<(), String> { - print_json(&report(phase, request_case, admitted, store, coordinator)?) + print_json(&report( + phase, + request_case, + admitted, + store, + coordinator, + writer_epoch, + )?) } fn report( @@ -482,6 +498,7 @@ fn report( admitted: &AdmittedEdictExternalActionRequestV1, store: &FilesystemWalStore, coordinator: &ExternalActionCoordinatorV1, + writer_epoch: Option<&WriterEpoch>, ) -> Result { let request = admitted.request(); let recovered = coordinator @@ -518,8 +535,23 @@ fn report( RecoveredExternalActionPostureV1::Claimed => "claimed", RecoveredExternalActionPostureV1::Settled(_) => "settled", }; + let writer_epoch = writer_epoch.map(|epoch| { + json!({ + "epochId": hex(&epoch.epoch_id.as_hash()), + "previousEpochId": epoch + .previous_epoch_id + .map(|previous| json!(hex(&previous.as_hash()))) + .unwrap_or(Value::Null), + "previousEpochFinalCommitDigest": epoch + .previous_epoch_final_commit_digest + .map(|digest| json!(hex(&digest))) + .unwrap_or(Value::Null), + "startedAtLsn": epoch.started_at_lsn.as_u64() + }) + }); Ok(json!({ "phase": phase, + "writerEpoch": writer_epoch, "requestId": hex(&request.request_id().as_hash()), "compiler": { "coreDigest": admitted.source_core_digest(), @@ -528,7 +560,17 @@ fn report( "intent": request_case.intent }, "posture": posture, - "wal": {"commitCount": commits.len()}, + "wal": { + "commitCount": commits.len(), + // The digest closing this epoch's last commit. A successor's + // previousEpochFinalCommitDigest must equal the value the + // predecessor reported here, which is what makes the chain + // evidence exact rather than merely well-formed. + "lastCommitDigest": commits + .last() + .map(|commit| json!(hex(&commit.commit_digest))) + .unwrap_or(Value::Null) + }, "ordering": { "requestCommit": request_commit, "claimCommit": claim_commit, @@ -619,21 +661,25 @@ fn adapter_profile( } } -fn open_write_store(path: &Path) -> Result { +/// Opens the WAL for writing under a fresh, durably linked writer epoch. +/// +/// Echo owns epoch derivation, predecessor linkage, and lease enforcement in +/// `FilesystemWalStore::acquire_fresh_writer_epoch`: it takes the filesystem +/// writer lease, rereads the persisted epoch ledger, closes an epoch left by a +/// terminated process, and derives the successor from that predecessor's +/// identity and final commit digest. Every host phase here is a separate +/// process, so each phase acquires a new epoch chained to the persisted +/// predecessor. +/// +/// Hello Echo derives no epoch identity of its own and reuses no fencing +/// token across restarts. A concurrently live writer keeps the lease and this +/// call refuses rather than taking over. +fn open_write_store(path: &Path) -> Result<(FilesystemWalStore, WriterEpoch), String> { let mut store = open_read_store(path)?; - store - .acquire_writer_epoch(WriterEpochRequest { - epoch_id: epoch_id(), - storage_fencing_token: digest("hello-effect:fencing"), - process_identity: digest("hello-effect:process"), - host_identity: digest("hello-effect:host"), - started_at_lsn: Lsn::from_raw(0), - previous_epoch_id: None, - previous_epoch_final_commit_digest: None, - lease_or_lock_evidence: digest("hello-effect:lease"), - }) + let epoch = store + .acquire_fresh_writer_epoch(Lsn::from_raw(0)) .map_err(|error| format!("writer epoch acquisition failed: {error:?}"))?; - Ok(store) + Ok((store, epoch)) } fn open_read_store(path: &Path) -> Result { @@ -653,16 +699,13 @@ fn recover(store: &FilesystemWalStore) -> Result WriterEpochId { - WriterEpochId::from_hash(digest("hello-effect:epoch")) -} - fn transaction_context( phase: &str, admitted: &AdmittedEdictExternalActionRequestV1, + writer_epoch: WriterEpochId, ) -> ExternalActionTransactionContextV1 { ExternalActionTransactionContextV1 { - writer_epoch: epoch_id(), + writer_epoch, segment_id: SEGMENT_ID, transaction_id: WalTransactionId::from_hash(digest(&format!( "hello-effect:{phase}:{}", diff --git a/effect/src/observe-workspace.edict b/effect/src/observe-workspace.edict index 9a1dd1b..c2c10f1 100644 --- a/effect/src/observe-workspace.edict +++ b/effect/src/observe-workspace.edict @@ -20,15 +20,15 @@ intent observe(input: ObserveInput) request pending: ExternalActionRequest> = snapshot(input.payload) input schema workspace.snapshot.input@1 - digest "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + digest "sha256:c390651d8975c49ff148a332feba4054a53fa9867e0412c1c303e0771cda1096" settlement schema workspace.snapshot.settlement@1 - digest "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + digest "sha256:efff3be35fba0aeeadc59e73fe771cb42213d390a269bbc5b2e24a028bb84832" authority input.scope basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts reconcile workspace.snapshot.reconcile@1 - digest "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + digest "sha256:352dab01263aa1a1f01a8ac213be0debef1bf23d7b522dce24527993124315c9"; return pending; } diff --git a/effect/vendor/workspace-snapshot/input-schema.cbor b/effect/vendor/workspace-snapshot/input-schema.cbor new file mode 100644 index 0000000..49c8491 --- /dev/null +++ b/effect/vendor/workspace-snapshot/input-schema.cbor @@ -0,0 +1 @@ +¤dkindkinputSchemajapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.snapshot.input@1jdefinition¤drootx boundedWorkspaceObservationInputfclosedõffields‚¤dnamedkinddtypex(literal:boundedWorkspaceObservationInputhrequiredõiauthorityxexact operation discriminator¤dnameepathsdtypexarrayhrequiredõiauthorityxordered exact read aperturehencodingncanonical-cbor \ No newline at end of file diff --git a/effect/vendor/workspace-snapshot/input-schema.sha256 b/effect/vendor/workspace-snapshot/input-schema.sha256 new file mode 100644 index 0000000..9dd7bb6 --- /dev/null +++ b/effect/vendor/workspace-snapshot/input-schema.sha256 @@ -0,0 +1 @@ +sha256:c390651d8975c49ff148a332feba4054a53fa9867e0412c1c303e0771cda1096 diff --git a/effect/vendor/workspace-snapshot/reconciliation-law.cbor b/effect/vendor/workspace-snapshot/reconciliation-law.cbor new file mode 100644 index 0000000..df55572 --- /dev/null +++ b/effect/vendor/workspace-snapshot/reconciliation-law.cbor @@ -0,0 +1 @@ +¤dkindqreconciliationLawjapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.snapshot.reconcile@1jdefinition¥jreplayRulexFreplay consumes the admitted settlement and performs no workspace readkrequestKindx boundedWorkspaceObservationInputnsettlementKindx%boundedWorkspaceObservationSettlementprequiredBindings„ebasishevidenceefileskobstructionpterminalPosturesƒisucceededjobstructednoutcomeUnknown \ No newline at end of file diff --git a/effect/vendor/workspace-snapshot/reconciliation-law.sha256 b/effect/vendor/workspace-snapshot/reconciliation-law.sha256 new file mode 100644 index 0000000..957cd04 --- /dev/null +++ b/effect/vendor/workspace-snapshot/reconciliation-law.sha256 @@ -0,0 +1 @@ +sha256:352dab01263aa1a1f01a8ac213be0debef1bf23d7b522dce24527993124315c9 diff --git a/effect/vendor/workspace-snapshot/settlement-schema.cbor b/effect/vendor/workspace-snapshot/settlement-schema.cbor new file mode 100644 index 0000000..f01ad68 --- /dev/null +++ b/effect/vendor/workspace-snapshot/settlement-schema.cbor @@ -0,0 +1 @@ +¤dkindpsettlementSchemajapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.snapshot.settlement@1jdefinition¤drootx%boundedWorkspaceObservationSettlementfclosedõffields†¤dnamedkinddtypex-literal:boundedWorkspaceObservationSettlementhrequiredõiauthorityxexact settlement discriminator¤dnamegposturedtypex(enum:succeeded|obstructed|outcomeUnknownhrequiredõiauthorityx terminal external-action posture¤dnameebasisdtypeobyteshrequiredõiauthoritywobserved workspace root¤dnamehevidencedtypeobyteshrequiredõiauthorityx%domain-separated observation evidence¤dnameefilesdtypex:arrayhrequiredõiauthorityx,strictly ordered requested file observations¤dnamekobstructiondtypenoptionalhrequiredõiauthorityx)typed obstruction or outcome-unknown codehencodingncanonical-cbor \ No newline at end of file diff --git a/effect/vendor/workspace-snapshot/settlement-schema.sha256 b/effect/vendor/workspace-snapshot/settlement-schema.sha256 new file mode 100644 index 0000000..06fcc0e --- /dev/null +++ b/effect/vendor/workspace-snapshot/settlement-schema.sha256 @@ -0,0 +1 @@ +sha256:efff3be35fba0aeeadc59e73fe771cb42213d390a269bbc5b2e24a028bb84832 diff --git a/patch-host/Cargo.toml.template b/patch-host/Cargo.toml.template new file mode 100644 index 0000000..2d5f6fb --- /dev/null +++ b/patch-host/Cargo.toml.template @@ -0,0 +1,16 @@ +[package] +name = "hello-effect-patch-host" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +blake3 = "1" +echo-edict-canonical = { path = "@ECHO_REPO@/crates/echo-edict-canonical" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +warp-core = { path = "@ECHO_REPO@/crates/warp-core" } + +[[bin]] +name = "hello-effect-patch-host" +path = "@PROJECT_ROOT@/patch-host/src/main.rs" diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs new file mode 100644 index 0000000..33441d8 --- /dev/null +++ b/patch-host/src/main.rs @@ -0,0 +1,896 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use echo_edict_canonical::{decode_canonical_cbor_v1, encode_canonical_cbor_v1, CanonicalValueV1}; +use serde::Deserialize; +use serde_json::{json, Value}; +use warp_core::causal_wal::{ + FilesystemWalStore, Lsn, PayloadCodecId, PayloadSchemaId, WalDurabilityMode, WalSegmentId, + WalStorePort, WalTransactionId, WriterEpoch, WriterEpochId, +}; +use warp_core::external_action::{ + claim_external_action, reconcile_external_action_settlement_retry, + record_external_action_request, ExternalActionAdapterBindingV1, ExternalActionAdapterIdV1, + ExternalActionAdapterRegistryV1, ExternalActionCoordinatorV1, ExternalActionProtocolErrorV1, + ExternalActionSettlementCandidateV1, ExternalActionSettlementKindV1, + ExternalActionTransactionContextV1, RecoveredExternalActionPostureV1, +}; +use warp_core::external_action_adapter::{ + admit_edict_external_action_request_v1, AdmittedEdictExternalActionRequestV1, + EdictExternalActionAdmissionErrorV1, +}; +use warp_core::validated_workspace_patch::{ + encode_validated_workspace_patch_input_v1, validated_workspace_patch_authority_v1, + validated_workspace_patch_basis_v1, ValidatedWorkspacePatchAdapterV1, + ValidatedWorkspacePatchErrorV1, ValidatedWorkspacePatchProfileV1, + ValidatedWorkspacePatchReconcilerV1, +}; +use warp_core::{Hash, WorldlineId}; + +const SEGMENT_ID: WalSegmentId = WalSegmentId::from_raw(1); +const MAX_FILE_BYTES_V1: u64 = 65_536; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RequestCase { + worldline_byte: u8, + intent: String, + proposal: PatchProposal, + observation: WorkspaceObservation, + permitted_paths: Vec, + max_settlement_bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PatchProposal { + path: String, + replacement_bytes_hex: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct WorkspaceObservation { + path: String, + bytes_hex: String, +} + +struct Invocation { + phase: String, + request_file: PathBuf, + wal_dir: PathBuf, + core_file: PathBuf, + target_ir_file: PathBuf, + argument: Option, +} + +fn main() { + match run() { + Ok(()) => {} + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } +} + +fn run() -> Result<(), String> { + let invocation = parse_invocation()?; + let request_bytes = fs::read(&invocation.request_file).map_err(|error| error.to_string())?; + let request_case: RequestCase = match serde_json::from_slice(&request_bytes) { + Ok(request_case) => request_case, + Err(_) => { + let commit_count = wal_commit_count(&invocation.wal_dir)?; + print_json(&json!({ + "phase": invocation.phase, + "obstruction": "requestRejected", + "wal": {"commitCount": commit_count} + }))?; + std::process::exit(3); + } + }; + let core_bytes = fs::read(&invocation.core_file).map_err(|error| error.to_string())?; + let target_ir_bytes = + fs::read(&invocation.target_ir_file).map_err(|error| error.to_string())?; + + let application_input = match application_input(&request_case) { + Ok(input) => input, + Err(refusal) => { + let obstruction = match refusal { + ApplicationInputRefusal::Malformed => "requestRejected", + ApplicationInputRefusal::ObservationExceedsFileBudget => { + "observationExceedsFileBudget" + } + ApplicationInputRefusal::ReplacementExceedsRequestBudget => { + "replacementExceedsRequestBudget" + } + }; + let commit_count = wal_commit_count(&invocation.wal_dir)?; + print_json(&json!({ + "phase": invocation.phase, + "obstruction": obstruction, + "wal": {"commitCount": commit_count} + }))?; + std::process::exit(3); + } + }; + let admitted = match admit_edict_external_action_request_v1( + WorldlineId::from_bytes([request_case.worldline_byte; 32]), + &core_bytes, + &target_ir_bytes, + &request_case.intent, + &application_input, + ) { + Ok(admitted) => admitted, + Err(error) => { + let obstruction = if is_compiler_artifact_rejection(&error) { + "compilerArtifactRejected" + } else { + "requestRejected" + }; + let commit_count = wal_commit_count(&invocation.wal_dir)?; + print_json(&json!({ + "phase": invocation.phase, + "obstruction": obstruction, + "wal": {"commitCount": commit_count} + }))?; + std::process::exit(3); + } + }; + + match invocation.phase.as_str() { + "request" => request_phase(&invocation, &request_case, &admitted), + "claim" => claim_phase(&invocation, &request_case, &admitted), + "inspect" | "replay" => inspect_phase(&invocation, &request_case, &admitted), + "apply" => apply_phase(&invocation, &request_case, &admitted), + "reconcile" => reconcile_phase(&invocation, &request_case, &admitted), + "retry" => retry_phase(&invocation, &request_case, &admitted), + phase => Err(format!("unsupported phase: {phase}")), + } +} + +fn parse_invocation() -> Result { + let mut args = env::args().skip(1); + let invocation = Invocation { + phase: args.next().ok_or_else(|| "missing phase".to_owned())?, + request_file: args + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing request file".to_owned())?, + wal_dir: args + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing WAL directory".to_owned())?, + core_file: args + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing Core artifact".to_owned())?, + target_ir_file: args + .next() + .map(PathBuf::from) + .ok_or_else(|| "missing Target IR artifact".to_owned())?, + argument: args.next(), + }; + if args.next().is_some() { + return Err("too many arguments".to_owned()); + } + Ok(invocation) +} + +/// Why a request could not be turned into an application input. +/// +/// Budget refusals are kept distinct from malformed ones, and from each other: +/// the host accepts replacement sizes the encoded request cannot carry, and +/// the observation and the replacement are separate inputs. Reporting them +/// under one obstruction would make a budget refusal indistinguishable from a +/// bad request, or name the wrong input. +enum ApplicationInputRefusal { + Malformed, + /// The declared pre-state does not fit the host file budget. + ObservationExceedsFileBudget, + /// The proposal was well formed, but its encoded patch does not fit the + /// compiler-declared request carrier. + ReplacementExceedsRequestBudget, +} + +fn application_input(request_case: &RequestCase) -> Result, ApplicationInputRefusal> { + if request_case.proposal.path != request_case.observation.path { + return Err(ApplicationInputRefusal::Malformed); + } + let before = decode_hex(&request_case.observation.bytes_hex) + .map_err(|_| ApplicationInputRefusal::Malformed)?; + let replacement = decode_hex(&request_case.proposal.replacement_bytes_hex) + .map_err(|_| ApplicationInputRefusal::Malformed)?; + let max_file_bytes = + usize::try_from(MAX_FILE_BYTES_V1).map_err(|_| ApplicationInputRefusal::Malformed)?; + // The observation and the replacement are separate inputs with separate + // faults. Collapsing them would report an oversized pre-state as a + // replacement-budget refusal and name the wrong input. + // + // The observation is checked first, so an input oversized in both reports + // observationExceedsFileBudget. tests/patch-runtime.sh pins that + // obstruction, so reordering these two checks would retarget it. + if before.len() > max_file_bytes { + return Err(ApplicationInputRefusal::ObservationExceedsFileBudget); + } + if replacement.len() > max_file_bytes { + return Err(ApplicationInputRefusal::ReplacementExceedsRequestBudget); + } + let patch = encode_validated_workspace_patch_input_v1( + request_case.proposal.path.clone(), + blake3::hash(&before).into(), + replacement, + ) + .map_err(|error| match error { + // The encoder holds the carrier bound. The encoded patch carries the + // path and expected content digest alongside the replacement bytes, so + // the reachable replacement size is smaller than the host's raw file + // cap and shrinks as the path grows. Only the producer knows the exact + // framing cost, so its budget refusal is surfaced rather than + // re-derived here. + ValidatedWorkspacePatchErrorV1::FileBudgetExceeded => { + ApplicationInputRefusal::ReplacementExceedsRequestBudget + } + _ => ApplicationInputRefusal::Malformed, + })?; + let authority = validated_workspace_patch_authority_v1( + request_case.permitted_paths.iter().map(String::as_str), + ); + let basis = validated_workspace_patch_basis_v1(&request_case.proposal.path, &before); + encode_canonical_cbor_v1(&canonical_map([ + ("patch", CanonicalValueV1::Bytes(patch)), + ("authority", CanonicalValueV1::Bytes(authority.to_vec())), + ("basis", CanonicalValueV1::Bytes(basis.to_vec())), + ( + "maxSettlementBytes", + CanonicalValueV1::Integer(i128::from(request_case.max_settlement_bytes)), + ), + ("maxAttempts", CanonicalValueV1::Integer(1)), + ])) + .map_err(|_| ApplicationInputRefusal::Malformed) +} + +fn is_compiler_artifact_rejection(error: &EdictExternalActionAdmissionErrorV1) -> bool { + matches!( + error, + EdictExternalActionAdmissionErrorV1::Canonical(_) + | EdictExternalActionAdmissionErrorV1::ArtifactShape + | EdictExternalActionAdmissionErrorV1::RequestCardinality + | EdictExternalActionAdmissionErrorV1::CallableStepsPresent + | EdictExternalActionAdmissionErrorV1::MissingSemanticClosure + | EdictExternalActionAdmissionErrorV1::CoreDigestMismatch + | EdictExternalActionAdmissionErrorV1::TargetDerivationMismatch + | EdictExternalActionAdmissionErrorV1::CapabilityClosureMismatch + | EdictExternalActionAdmissionErrorV1::UnsupportedExpression + | EdictExternalActionAdmissionErrorV1::InvalidDigest + ) +} + +fn request_phase( + invocation: &Invocation, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), String> { + if invocation.argument.is_some() { + return Err("request does not accept workspace authority".to_owned()); + } + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; + let mut coordinator = recover(&store)?; + record_external_action_request( + &mut store, + &mut coordinator, + transaction_context("request", admitted, writer_epoch.epoch_id), + admitted.request(), + ) + .map_err(|error| format!("request admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + Some(&writer_epoch), + ) +} + +fn claim_phase( + invocation: &Invocation, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), String> { + if invocation.argument.is_some() { + return Err("claim does not accept workspace authority".to_owned()); + } + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; + let mut coordinator = recover(&store)?; + let request = admitted.request(); + let recorded = coordinator + .recorded_request(request.request_id()) + .map_err(|error| format!("request recovery failed: {error:?}"))?; + let profile = adapter_profile(admitted); + let binding = ExternalActionAdapterBindingV1 { + adapter_id: profile.adapter_id, + operation_id: profile.operation_id, + authority_scope_digest: profile.authority_scope_digest, + }; + let registry = ExternalActionAdapterRegistryV1::new([binding]); + let authorization = registry + .authorize(&request, binding.adapter_id) + .map_err(|error| format!("adapter authorization failed: {error:?}"))?; + claim_external_action( + &mut store, + &mut coordinator, + transaction_context("claim", admitted, writer_epoch.epoch_id), + recorded, + authorization, + request.basis_digest, + 0, + digest("hello-effect-patch:adapter-lease"), + ) + .map_err(|error| format!("claim admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + Some(&writer_epoch), + ) +} + +fn inspect_phase( + invocation: &Invocation, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), String> { + if invocation.argument.is_some() { + return Err(format!( + "{} does not accept workspace authority", + invocation.phase + )); + } + let store = open_read_store(&invocation.wal_dir)?; + let coordinator = recover(&store)?; + // Read-only phases take no writer lease and acquire no epoch. + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + None, + ) +} + +fn apply_phase( + invocation: &Invocation, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), String> { + let workspace_root = invocation + .argument + .as_ref() + .map(Path::new) + .ok_or_else(|| "apply requires a workspace root".to_owned())?; + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; + let mut coordinator = recover(&store)?; + let grant = coordinator + .claim_grant(admitted.request().request_id()) + .map_err(|error| format!("claim recovery failed: {error:?}"))?; + let adapter = ValidatedWorkspacePatchAdapterV1::open( + workspace_root, + request_case.permitted_paths.clone(), + adapter_profile(admitted), + ) + .map_err(|error| format!("adapter open failed: {error:?}"))?; + let candidate = adapter + .apply(&grant, admitted) + .map_err(|error| format!("validated patch application failed: {error:?}"))?; + adapter + .admit_settlement( + &mut store, + &mut coordinator, + transaction_context("settlement", admitted, writer_epoch.epoch_id), + admitted, + grant, + candidate, + ) + .map_err(|error| format!("settlement admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + Some(&writer_epoch), + ) +} + +fn reconcile_phase( + invocation: &Invocation, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), String> { + let workspace_root = invocation + .argument + .as_ref() + .map(Path::new) + .ok_or_else(|| "reconcile requires a workspace root".to_owned())?; + let (mut store, writer_epoch) = open_write_store(&invocation.wal_dir)?; + let mut coordinator = recover(&store)?; + let grant = coordinator + .claim_grant(admitted.request().request_id()) + .map_err(|error| format!("claim recovery failed: {error:?}"))?; + let reconciler = ValidatedWorkspacePatchReconcilerV1::open( + workspace_root, + request_case.permitted_paths.clone(), + adapter_profile(admitted), + ) + .map_err(|error| format!("reconciler open failed: {error:?}"))?; + let candidate = reconciler + .reconcile(&grant, admitted) + .map_err(|error| format!("patch reconciliation failed: {error:?}"))?; + reconciler + .admit_settlement( + &mut store, + &mut coordinator, + transaction_context("settlement", admitted, writer_epoch.epoch_id), + admitted, + grant, + candidate, + ) + .map_err(|error| format!("reconciled settlement admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + Some(&writer_epoch), + ) +} + +fn retry_phase( + invocation: &Invocation, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> Result<(), String> { + let retry_mode = invocation + .argument + .as_deref() + .ok_or_else(|| "retry requires exact or conflict-kind".to_owned())?; + let store = open_read_store(&invocation.wal_dir)?; + let coordinator = recover(&store)?; + let commit_count_before = store.read_commits().len(); + let admitted_settlement = coordinator + .admitted_settlement(admitted.request().request_id()) + .map_err(|error| format!("settlement recovery failed: {error:?}"))?; + let settlement = admitted_settlement.settlement(); + let kind = match retry_mode { + "exact" => settlement.kind, + "conflict-kind" => conflicting_kind(settlement.kind), + _ => return Err(format!("unsupported retry mode: {retry_mode}")), + }; + let candidate = ExternalActionSettlementCandidateV1::new( + settlement.request_id, + settlement.attempt_id, + settlement.adapter_id, + kind, + settlement.settlement_schema_digest, + settlement.basis_digest, + settlement.canonical_result_bytes.clone(), + settlement.schema_admission_evidence_digest, + settlement.external_evidence_digest, + ); + match reconcile_external_action_settlement_retry(&coordinator, candidate) { + Ok(reconciled) if retry_mode == "exact" => { + let report = report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + None, + )?; + let mut report = report + .as_object() + .cloned() + .ok_or_else(|| "report was not an object".to_owned())?; + report.insert("retry".to_owned(), json!("idempotent")); + report.insert( + "retryCommitDigest".to_owned(), + json!(hex(&reconciled.settlement_commit_digest())), + ); + report.insert( + "wal".to_owned(), + json!({ + "commitCountBefore": commit_count_before, + "commitCountAfter": store.read_commits().len() + }), + ); + print_json(&Value::Object(report)) + } + // Only a conflict may report a conflict. A wildcard would let an + // internal validation or recovery regression satisfy the witness case + // that is meant to prove the kind-only mutation was rejected for + // conflicting with the retained settlement. + Err(ExternalActionProtocolErrorV1::ConflictingSettlement) + if retry_mode == "conflict-kind" => + { + let mut report = report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + None, + )? + .as_object() + .cloned() + .ok_or_else(|| "report was not an object".to_owned())?; + report.insert("retry".to_owned(), json!("obstructed")); + report.insert("obstruction".to_owned(), json!("conflictingSettlement")); + report.insert( + "wal".to_owned(), + json!({ + "commitCountBefore": commit_count_before, + "commitCountAfter": store.read_commits().len() + }), + ); + print_json(&Value::Object(report))?; + std::process::exit(3); + } + Ok(_) => Err("conflicting settlement retry unexpectedly admitted".to_owned()), + Err(error) => Err(format!("exact settlement retry failed: {error:?}")), + } +} + +fn print_report( + phase: &str, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, + store: &FilesystemWalStore, + coordinator: &ExternalActionCoordinatorV1, + writer_epoch: Option<&WriterEpoch>, +) -> Result<(), String> { + print_json(&report( + phase, + request_case, + admitted, + store, + coordinator, + writer_epoch, + )?) +} + +fn report( + phase: &str, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, + store: &FilesystemWalStore, + coordinator: &ExternalActionCoordinatorV1, + writer_epoch: Option<&WriterEpoch>, +) -> Result { + let request = admitted.request(); + let recovered = coordinator + .observed_index() + .get(request.request_id()) + .ok_or_else(|| "request absent from recovered index".to_owned())?; + let commits = store.read_commits(); + let settlement = recovered + .settlement + .as_ref() + .map(|settlement| { + let patch = decode_patch_settlement(&settlement.canonical_result_bytes)?; + let commit_digest = recovered + .settlement_commit_digest + .ok_or_else(|| "settlement commit digest absent".to_owned())?; + Ok::<_, String>(json!({ + "kind": settlement_kind(settlement.kind), + "attemptId": hex(&settlement.attempt_id.as_hash()), + "basisDigest": hex(&settlement.basis_digest), + "externalEvidenceDigest": hex(&settlement.external_evidence_digest), + "schemaAdmissionEvidenceDigest": hex( + &settlement.schema_admission_evidence_digest, + ), + "commitDigest": hex(&commit_digest), + "resultDigest": hex(&settlement.result_digest), + "canonicalResultByteCount": settlement.canonical_result_bytes.len(), + "patch": patch + })) + }) + .transpose()?; + let request_commit = commit_position(&commits, recovered.request_commit_digest); + let claim_commit = recovered + .claim_commit_digest + .and_then(|digest| commit_position(&commits, digest)); + let settlement_commit = recovered + .settlement_commit_digest + .and_then(|digest| commit_position(&commits, digest)); + let posture = match recovered.posture { + RecoveredExternalActionPostureV1::Requested => "requested", + RecoveredExternalActionPostureV1::Claimed => "claimed", + RecoveredExternalActionPostureV1::Settled(_) => "settled", + }; + let writer_epoch = writer_epoch.map(|epoch| { + json!({ + "epochId": hex(&epoch.epoch_id.as_hash()), + "previousEpochId": epoch + .previous_epoch_id + .map(|previous| json!(hex(&previous.as_hash()))) + .unwrap_or(Value::Null), + "previousEpochFinalCommitDigest": epoch + .previous_epoch_final_commit_digest + .map(|digest| json!(hex(&digest))) + .unwrap_or(Value::Null), + "startedAtLsn": epoch.started_at_lsn.as_u64() + }) + }); + Ok(json!({ + "phase": phase, + "requestId": hex(&request.request_id().as_hash()), + "writerEpoch": writer_epoch, + "compiler": { + "coreDigest": admitted.source_core_digest(), + "targetIrDigest": admitted.target_ir_digest(), + "operation": admitted.operation_coordinate(), + "intent": request_case.intent + }, + "posture": posture, + "wal": { + "commitCount": commits.len(), + // The digest closing this epoch's last commit. A successor's + // previousEpochFinalCommitDigest must equal the value the + // predecessor reported here, which is what makes the chain + // evidence exact rather than merely well-formed. + "lastCommitDigest": commits + .last() + .map(|commit| json!(hex(&commit.commit_digest))) + .unwrap_or(Value::Null) + }, + "ordering": { + "requestCommit": request_commit, + "claimCommit": claim_commit, + "settlementCommit": settlement_commit + }, + "publication": { + "settlementCommittedBeforeResult": settlement_commit.is_some(), + "replayedFromRetainedSettlement": phase == "replay" && settlement.is_some() + }, + "settlement": settlement + })) +} + +fn decode_patch_settlement(bytes: &[u8]) -> Result { + let value = decode_canonical_cbor_v1(bytes) + .map_err(|error| format!("settlement result was not canonical: {error:?}"))?; + Ok(json!({ + "status": canonical_text_field(&value, "posture")?, + "path": canonical_optional_text_field(&value, "path")?, + "requestBasis": hex(canonical_bytes_field(&value, "requestBasis")?), + "evidence": hex(canonical_bytes_field(&value, "evidence")?), + "beforeContentDigest": canonical_optional_bytes_hex_field( + &value, + "beforeContentDigest", + )?, + "afterContentDigest": canonical_optional_bytes_hex_field( + &value, + "afterContentDigest", + )?, + "resultingBasis": canonical_optional_bytes_hex_field(&value, "resultingBasis")?, + "obstruction": canonical_optional_text_field(&value, "obstruction")? + })) +} + +fn canonical_field<'a>( + value: &'a CanonicalValueV1, + field: &str, +) -> Result<&'a CanonicalValueV1, String> { + let CanonicalValueV1::Map(entries) = value else { + return Err(format!("canonical value containing {field} was not a map")); + }; + entries + .iter() + .find_map(|(key, value)| match key { + CanonicalValueV1::Text(key) if key == field => Some(value), + _ => None, + }) + .ok_or_else(|| format!("canonical field {field} was absent")) +} + +fn canonical_text_field(value: &CanonicalValueV1, field: &str) -> Result { + match canonical_field(value, field)? { + CanonicalValueV1::Text(value) => Ok(value.clone()), + _ => Err(format!("canonical field {field} was not text")), + } +} + +fn canonical_bytes_field<'a>(value: &'a CanonicalValueV1, field: &str) -> Result<&'a [u8], String> { + match canonical_field(value, field)? { + CanonicalValueV1::Bytes(value) => Ok(value), + _ => Err(format!("canonical field {field} was not bytes")), + } +} + +fn canonical_optional_text_field(value: &CanonicalValueV1, field: &str) -> Result { + match canonical_field(value, field)? { + CanonicalValueV1::Null => Ok(Value::Null), + CanonicalValueV1::Text(value) => Ok(json!(value)), + _ => Err(format!("canonical field {field} was not optional text")), + } +} + +fn canonical_optional_bytes_hex_field( + value: &CanonicalValueV1, + field: &str, +) -> Result { + match canonical_field(value, field)? { + CanonicalValueV1::Null => Ok(Value::Null), + CanonicalValueV1::Bytes(value) => Ok(json!(hex(value))), + _ => Err(format!("canonical field {field} was not optional bytes")), + } +} + +fn adapter_id() -> ExternalActionAdapterIdV1 { + ExternalActionAdapterIdV1::from_hash(digest("hello-effect-patch:bounded-adapter")) +} + +fn adapter_profile( + admitted: &AdmittedEdictExternalActionRequestV1, +) -> ValidatedWorkspacePatchProfileV1 { + let request = admitted.request(); + ValidatedWorkspacePatchProfileV1 { + operation_id: request.operation_id, + input_schema_digest: request.input_schema_digest, + settlement_schema_digest: request.settlement_schema_digest, + reconciliation_law_digest: request.reconciliation_law_digest, + authority_scope_digest: request.authority_scope_digest, + adapter_id: adapter_id(), + max_file_bytes: MAX_FILE_BYTES_V1, + } +} + +/// Opens the WAL for writing under a fresh, durably linked writer epoch. +/// +/// Echo owns epoch derivation, predecessor linkage, and lease enforcement in +/// `FilesystemWalStore::acquire_fresh_writer_epoch`: it takes the filesystem +/// writer lease, rereads the persisted epoch ledger, closes an epoch left by a +/// terminated process, and derives the successor from that predecessor's +/// identity and final commit digest. Every host phase here is a separate +/// process, so each phase acquires a new epoch chained to the persisted +/// predecessor. +/// +/// Hello Echo derives no epoch identity of its own and reuses no fencing +/// token across restarts. A concurrently live writer keeps the lease and this +/// call refuses rather than taking over. +fn open_write_store(path: &Path) -> Result<(FilesystemWalStore, WriterEpoch), String> { + let mut store = open_read_store(path)?; + let epoch = store + .acquire_fresh_writer_epoch(Lsn::from_raw(0)) + .map_err(|error| format!("writer epoch acquisition failed: {error:?}"))?; + Ok((store, epoch)) +} + +fn open_read_store(path: &Path) -> Result { + FilesystemWalStore::open(path, SEGMENT_ID) + .map_err(|error| format!("filesystem WAL open failed: {error:?}")) +} + +fn wal_commit_count(path: &Path) -> Result { + if !path.exists() { + return Ok(0); + } + Ok(open_read_store(path)?.read_commits().len()) +} + +fn recover(store: &FilesystemWalStore) -> Result { + ExternalActionCoordinatorV1::recover(store) + .map_err(|error| format!("external-action recovery failed: {error:?}")) +} + +fn transaction_context( + phase: &str, + admitted: &AdmittedEdictExternalActionRequestV1, + writer_epoch: WriterEpochId, +) -> ExternalActionTransactionContextV1 { + ExternalActionTransactionContextV1 { + writer_epoch, + segment_id: SEGMENT_ID, + transaction_id: WalTransactionId::from_hash(digest(&format!( + "hello-effect-patch:{phase}:{}", + hex(&admitted.request().request_id().as_hash()) + ))), + durability_mode: WalDurabilityMode::StrictFilesystem, + payload_codec_id: PayloadCodecId::from_hash(digest("hello-effect-patch:codec")), + payload_schema_id: PayloadSchemaId::from_hash(digest("hello-effect-patch:schema")), + payload_schema_version: 1, + canonical_encoding_version: 1, + digest_domain: digest("hello-effect-patch:wal-domain"), + } +} + +fn canonical_map( + entries: [(&'static str, CanonicalValueV1); N], +) -> CanonicalValueV1 { + CanonicalValueV1::Map( + entries + .into_iter() + .map(|(key, value)| (CanonicalValueV1::Text(key.to_owned()), value)) + .collect(), + ) +} + +fn conflicting_kind(kind: ExternalActionSettlementKindV1) -> ExternalActionSettlementKindV1 { + match kind { + ExternalActionSettlementKindV1::Succeeded => ExternalActionSettlementKindV1::Rejected, + _ => ExternalActionSettlementKindV1::Succeeded, + } +} + +fn settlement_kind(kind: ExternalActionSettlementKindV1) -> &'static str { + match kind { + ExternalActionSettlementKindV1::Succeeded => "succeeded", + ExternalActionSettlementKindV1::Rejected => "rejected", + ExternalActionSettlementKindV1::Failed => "failed", + ExternalActionSettlementKindV1::OutcomeUnknown => "outcomeUnknown", + } +} + +fn commit_position( + commits: &[warp_core::causal_wal::WalTransactionCommit], + digest: Hash, +) -> Option { + commits + .iter() + .position(|commit| commit.commit_digest == digest) + .map(|index| index + 1) +} + +fn print_json(value: &Value) -> Result<(), String> { + println!( + "{}", + serde_json::to_string(value).map_err(|error| error.to_string())? + ); + Ok(()) +} + +fn digest(label: &str) -> Hash { + blake3::hash(label.as_bytes()).into() +} + +fn decode_hex(value: &str) -> Result, String> { + if !value.len().is_multiple_of(2) { + return Err("hex value has odd length".to_owned()); + } + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = hex_nibble(pair[0])?; + let low = hex_nibble(pair[1])?; + Ok((high << 4) | low) + }) + .collect() +} + +fn hex_nibble(value: u8) -> Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + _ => Err("hex value was not lowercase hexadecimal".to_owned()), + } +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(DIGITS[usize::from(byte >> 4)])); + out.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + out +} diff --git a/patch/src/apply-validated-patch.edict b/patch/src/apply-validated-patch.edict new file mode 100644 index 0000000..6e11a3d --- /dev/null +++ b/patch/src/apply-validated-patch.edict @@ -0,0 +1,34 @@ +package examples.workspace_patcher@1; + +use lawpack workspace.patch@1 digest "sha256:7d256b314ee6315e2768721cba2e2191649e7c877e85bc020ab2e119564cb06c" as workspace; +use capability workspace.patch.applyValidated@1 digest "sha256:7d256b314ee6315e2768721cba2e2191649e7c877e85bc020ab2e119564cb06c" as patch; + +type ApplyPatchInput = { + patch: Bytes, + authority: Bytes, + basis: Bytes, + maxSettlementBytes: U64, + maxAttempts: U32, +}; + +intent applyValidated(input: ApplyPatchInput) + returns ExternalActionRequest> + profile workspace.applyValidatedRequest + basis input.basis + budget <= workspace.tinyPatchBudget +{ + request pending: ExternalActionRequest> = + patch(input.patch) + input schema workspace.patch.input@1 + digest "sha256:a815f7baa77c260f9c84a73552b6cab244900fcf27db7d8384d473a59c7e8607" + settlement schema workspace.patch.settlement@1 + digest "sha256:b74398fa5a7a997ccf3af3ee225bb2ef6eb776182d32789d7f8252eadb983a4d" + authority input.authority + basis input.basis + budget + maxSettlementBytes input.maxSettlementBytes + maxAttempts input.maxAttempts + reconcile workspace.patch.reconcile@1 + digest "sha256:efa7abd9a5f485994aab71ca796c9762b0f7676262b847750d5310e435da3194"; + return pending; +} diff --git a/patch/vendor/workspace-patch/SOURCE.md b/patch/vendor/workspace-patch/SOURCE.md new file mode 100644 index 0000000..9525caa --- /dev/null +++ b/patch/vendor/workspace-patch/SOURCE.md @@ -0,0 +1,43 @@ +# Compiler-Owned Workspace-Patch Closure + +The checked artifacts in this directory were copied without modification from +Edict merge commit `df80f92ad6242c6da31a64224666fd37aa43b0d0`: + +- `manifest.cbor` and `manifest.sha256`; +- `exports.cbor` and `exports.sha256`; +- `adapter.cbor` and `adapter.sha256`; +- `request-profile-configuration.cbor` and + `request-profile-configuration.sha256`; +- `input-schema.cbor` and `input-schema.sha256`; +- `settlement-schema.cbor` and `settlement-schema.sha256`; and +- `reconciliation-law.cbor` and `reconciliation-law.sha256`. + +The last three are canonical `edict.external-action-resource/v1` artifacts. +`apply-validated-patch.edict` pins their exact identities for +`workspace.patch.input@1`, `workspace.patch.settlement@1`, and +`workspace.patch.reconcile@1`. `edict.patch.application.json` supplies the same +three artifact paths through `externalActionResources`, and Edict recomputes +and validates the complete closure before publishing Core or Target IR. A +digest that names no supplied artifact fails the build closed. + +Each `.sha256` sidecar carries the generator-owned canonical resource identity, +not a digest of the enclosing file's bytes. + +Edict owns these bytes. Regenerate them in Edict with: + +```sh +cargo xtask lawpack-goldens --write +cargo xtask lawpack-goldens +``` + +Then copy the fourteen exact files and +`fixtures/lawpack/workspace-patch/apply-validated-patch.edict` into this +repository. `tests/patch-build.sh` compares the complete local closure with the +selected Edict checkout before invoking the public application build. Edict +owns validation of that closure and refuses malformed, missing, substituted, +and unresolved resources; Hello Echo does not reparse the source to re-check +what the compiler already enforces. + +Hello Echo does not regenerate, reinterpret, or replace the closure. Echo owns +dynamic admission, bounded mutation, durable settlement, reconciliation, and +effect-free replay. diff --git a/patch/vendor/workspace-patch/adapter.cbor b/patch/vendor/workspace-patch/adapter.cbor new file mode 100644 index 0000000..2d6f09a Binary files /dev/null and b/patch/vendor/workspace-patch/adapter.cbor differ diff --git a/patch/vendor/workspace-patch/adapter.sha256 b/patch/vendor/workspace-patch/adapter.sha256 new file mode 100644 index 0000000..d44eb96 --- /dev/null +++ b/patch/vendor/workspace-patch/adapter.sha256 @@ -0,0 +1 @@ +sha256:aa9627841f6363cb28c045de575faa1895ca6532db7c44faa2efebbd67925289 diff --git a/patch/vendor/workspace-patch/exports.cbor b/patch/vendor/workspace-patch/exports.cbor new file mode 100644 index 0000000..3a6fd19 --- /dev/null +++ b/patch/vendor/workspace-patch/exports.cbor @@ -0,0 +1 @@ +¦etypes€geffects€iconstants€lobstructions€mpureFunctions€qoperationProfiles¡x'workspace.patch@1.applyValidatedRequest¢mopticTemplate¥iopticKindjrevelationlboundaryKindjprojectionmsupportPolicyxworkspace.patch@1.requestOnlyolossDispositionxworkspace.patch@1.losslesssapertureRequirement¢crefx$workspace.patch@1.writablePathPolicydkindxabstractFootprintObligationoeffectPredicatex)workspace.patch@1.externalMutationRequest \ No newline at end of file diff --git a/patch/vendor/workspace-patch/exports.sha256 b/patch/vendor/workspace-patch/exports.sha256 new file mode 100644 index 0000000..b301dd8 --- /dev/null +++ b/patch/vendor/workspace-patch/exports.sha256 @@ -0,0 +1 @@ +sha256:6ea8af330e491024d0c114af1f905277a54b1864094caeadcd4d88522daa9d71 diff --git a/patch/vendor/workspace-patch/input-schema.cbor b/patch/vendor/workspace-patch/input-schema.cbor new file mode 100644 index 0000000..3045d9b --- /dev/null +++ b/patch/vendor/workspace-patch/input-schema.cbor @@ -0,0 +1 @@ +¤dkindkinputSchemajapiVersionx!edict.external-action-resource/v1jcoordinatewworkspace.patch.input@1jdefinition¤drootxvalidatedWorkspacePatchInputfclosedõffields…¤dnamedkinddtypex$literal:validatedWorkspacePatchInputhrequiredõiauthorityxexact operation discriminator¤dnamedpathdtypewcanonical-relative-pathhrequiredõiauthorityxsingle writable aperture¤dnameuexpectedContentDigestdtypeobyteshrequiredõiauthorityxbasis-bound precondition¤dnamekreplacementdtypepbyteshrequiredõiauthorityxvalidated replacement bytes¤dnameqreplacementDigestdtypeobyteshrequiredõiauthoritytreplacement identityhencodingncanonical-cbor \ No newline at end of file diff --git a/patch/vendor/workspace-patch/input-schema.sha256 b/patch/vendor/workspace-patch/input-schema.sha256 new file mode 100644 index 0000000..9f1284b --- /dev/null +++ b/patch/vendor/workspace-patch/input-schema.sha256 @@ -0,0 +1 @@ +sha256:a815f7baa77c260f9c84a73552b6cab244900fcf27db7d8384d473a59c7e8607 diff --git a/patch/vendor/workspace-patch/manifest.cbor b/patch/vendor/workspace-patch/manifest.cbor new file mode 100644 index 0000000..77e56c7 Binary files /dev/null and b/patch/vendor/workspace-patch/manifest.cbor differ diff --git a/patch/vendor/workspace-patch/manifest.sha256 b/patch/vendor/workspace-patch/manifest.sha256 new file mode 100644 index 0000000..8316978 --- /dev/null +++ b/patch/vendor/workspace-patch/manifest.sha256 @@ -0,0 +1 @@ +sha256:7d256b314ee6315e2768721cba2e2191649e7c877e85bc020ab2e119564cb06c diff --git a/patch/vendor/workspace-patch/reconciliation-law.cbor b/patch/vendor/workspace-patch/reconciliation-law.cbor new file mode 100644 index 0000000..0736b75 --- /dev/null +++ b/patch/vendor/workspace-patch/reconciliation-law.cbor @@ -0,0 +1 @@ +¤dkindqreconciliationLawjapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.patch.reconcile@1jdefinition¥jreplayRulexEreplay consumes the admitted settlement and never reapplies the patchkrequestKindxvalidatedWorkspacePatchInputnsettlementKindx!validatedWorkspacePatchSettlementprequiredBindings‡dpathlrequestBasishevidencesbeforeContentDigestrafterContentDigestnresultingBasiskobstructionpterminalPosturesƒisucceededjobstructednoutcomeUnknown \ No newline at end of file diff --git a/patch/vendor/workspace-patch/reconciliation-law.sha256 b/patch/vendor/workspace-patch/reconciliation-law.sha256 new file mode 100644 index 0000000..de31fc9 --- /dev/null +++ b/patch/vendor/workspace-patch/reconciliation-law.sha256 @@ -0,0 +1 @@ +sha256:efa7abd9a5f485994aab71ca796c9762b0f7676262b847750d5310e435da3194 diff --git a/patch/vendor/workspace-patch/request-profile-configuration.cbor b/patch/vendor/workspace-patch/request-profile-configuration.cbor new file mode 100644 index 0000000..5e2870b --- /dev/null +++ b/patch/vendor/workspace-patch/request-profile-configuration.cbor @@ -0,0 +1 @@ +¨ioperationx workspace.patch.applyValidated@1japiVersionx"workspace.patch.request-profile/v1jbasisClassnworkspace-rootjpatchClassxcanonical-validated-patchnauthorityClasswexact-writable-path-setrforbiddenPathClasskci-workflowrpostconditionClassxexact-resulting-workspace-rootsreconciliationClassx(observe-postcondition-or-outcome-unknown \ No newline at end of file diff --git a/patch/vendor/workspace-patch/request-profile-configuration.sha256 b/patch/vendor/workspace-patch/request-profile-configuration.sha256 new file mode 100644 index 0000000..4345ae2 --- /dev/null +++ b/patch/vendor/workspace-patch/request-profile-configuration.sha256 @@ -0,0 +1 @@ +sha256:27fe306359f560c8df2d71efd2e9552d7ac7e14c11cd6e7d75b102fd84f1eecf diff --git a/patch/vendor/workspace-patch/settlement-schema.cbor b/patch/vendor/workspace-patch/settlement-schema.cbor new file mode 100644 index 0000000..0a93ac1 --- /dev/null +++ b/patch/vendor/workspace-patch/settlement-schema.cbor @@ -0,0 +1 @@ +¤dkindpsettlementSchemajapiVersionx!edict.external-action-resource/v1jcoordinatexworkspace.patch.settlement@1jdefinition¤drootx!validatedWorkspacePatchSettlementfclosedõffields‰¤dnamedkinddtypex)literal:validatedWorkspacePatchSettlementhrequiredõiauthorityxexact settlement discriminator¤dnamegposturedtypex(enum:succeeded|obstructed|outcomeUnknownhrequiredõiauthorityx terminal external-action posture¤dnamedpathdtypex!optionalhrequiredõiauthoritypsettled aperture¤dnamelrequestBasisdtypeobyteshrequiredõiauthorityxadmitted workspace basis¤dnamehevidencedtypeobyteshrequiredõiauthorityx$domain-separated settlement evidence¤dnamesbeforeContentDigestdtypexoptional>hrequiredõiauthorityxobserved pre-mutation content¤dnamerafterContentDigestdtypexoptional>hrequiredõiauthorityxobserved postcondition content¤dnamenresultingBasisdtypexoptional>hrequiredõiauthorityx%observed postcondition workspace root¤dnamekobstructiondtypenoptionalhrequiredõiauthorityx)typed obstruction or outcome-unknown codehencodingncanonical-cbor \ No newline at end of file diff --git a/patch/vendor/workspace-patch/settlement-schema.sha256 b/patch/vendor/workspace-patch/settlement-schema.sha256 new file mode 100644 index 0000000..f1bb3fb --- /dev/null +++ b/patch/vendor/workspace-patch/settlement-schema.sha256 @@ -0,0 +1 @@ +sha256:b74398fa5a7a997ccf3af3ee225bb2ef6eb776182d32789d7f8252eadb983a4d diff --git a/producers.lock.json b/producers.lock.json new file mode 100644 index 0000000..9d7d430 --- /dev/null +++ b/producers.lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "edict": { + "repository": "flyingrobots/edict", + "commit": "df80f92ad6242c6da31a64224666fd37aa43b0d0" + }, + "echo": { + "repository": "flyingrobots/echo", + "commit": "c354d531679861fb7bbd52ab7b7703807909ab86" + } +} diff --git a/tests/build.sh b/tests/build.sh index 174b79f..7ec616e 100755 --- a/tests/build.sh +++ b/tests/build.sh @@ -4,6 +4,9 @@ set -eu : "${EDICT_REPO:?set EDICT_REPO to the compatible Edict checkout}" : "${ECHO_REPO:?set ECHO_REPO to the compatible Echo checkout}" +# The producer pair is pinned in-repository, not chosen by the caller. +./tests/producer-lock.sh + test -f "$EDICT_REPO/crates/edict-cli/Cargo.toml" test -f "$ECHO_REPO/Cargo.toml" diff --git a/tests/effect-build.sh b/tests/effect-build.sh index eedcbac..de0a43d 100755 --- a/tests/effect-build.sh +++ b/tests/effect-build.sh @@ -4,6 +4,9 @@ set -eu : "${EDICT_REPO:?set EDICT_REPO to the compatible Edict checkout}" : "${ECHO_REPO:?set ECHO_REPO to the compatible Echo checkout}" +# The producer pair is pinned in-repository, not chosen by the caller. +./tests/producer-lock.sh + command -v jq >/dev/null EDICT_REPO=$(CDPATH='' cd -- "$EDICT_REPO" && pwd -P) @@ -18,7 +21,13 @@ for artifact in \ manifest.cbor \ exports.cbor \ adapter.cbor \ - request-profile-configuration.cbor + request-profile-configuration.cbor \ + input-schema.cbor \ + input-schema.sha256 \ + settlement-schema.cbor \ + settlement-schema.sha256 \ + reconciliation-law.cbor \ + reconciliation-law.sha256 do cmp "effect/vendor/workspace-snapshot/$artifact" "$fixture_source/$artifact" done diff --git a/tests/effect-runtime.sh b/tests/effect-runtime.sh index 0d38571..dda8942 100755 --- a/tests/effect-runtime.sh +++ b/tests/effect-runtime.sh @@ -24,10 +24,16 @@ mkdir -p "$effect_root" # Producer checkout paths may be relative at the public shell boundary. The # generated Cargo manifest must contain their canonical targets, not paths that # Cargo would reinterpret relative to the nested build directory. +# +# The link path is deliberately relative, because that is what this probe +# exercises. The link target must be absolute: a symlink target is resolved +# against the directory holding the link, so linking a relative producer path +# from a nested build directory produces a dangling link rather than a +# relative producer path. relative_repo_links="$effect_root/relative-repos" mkdir -p "$relative_repo_links" -ln -s "$EDICT_REPO" "$relative_repo_links/edict" -ln -s "$ECHO_REPO" "$relative_repo_links/echo" +ln -s "$(CDPATH='' cd -- "$EDICT_REPO" && pwd -P)" "$relative_repo_links/edict" +ln -s "$(CDPATH='' cd -- "$ECHO_REPO" && pwd -P)" "$relative_repo_links/echo" EDICT_REPO="$relative_repo_links/edict" \ ECHO_REPO="$relative_repo_links/echo" \ ./tests/effect-build.sh @@ -100,6 +106,10 @@ assert_identity() { ' "$report_file" >/dev/null } +# Writer-epoch assertions are shared by both runtime witnesses and are +# themselves covered by tests/writer-epoch-assertions.sh. +. tests/lib/writer-epoch-assertions.sh + assert_posture() { report_file=$1 phase=$2 @@ -182,6 +192,12 @@ run_phase request "$golden_case" "$golden_wal" "$golden_root/request-report.json assert_posture "$golden_root/request-report.json" request requested 1 test ! -e "$golden_workspace/$golden_path" +# The first write phase opens the epoch chain on a fresh WAL, and Echo persists +# the ledger and the writer lease that fence it. +assert_first_writer_epoch "$golden_root/request-report.json" +test -s "$golden_wal/writer-epochs.ecwal" +test -e "$golden_wal/writer-epoch.lock" + project_root=$(pwd -P) ( cd "$effect_root" @@ -197,13 +213,18 @@ assert_posture "$golden_root/relative-artifact-report.json" inspect requested 1 run_phase inspect "$golden_case" "$golden_wal" "$golden_root/request-recovery.json" assert_posture "$golden_root/request-recovery.json" inspect requested 1 +assert_no_writer_epoch "$golden_root/request-recovery.json" run_phase claim "$golden_case" "$golden_wal" "$golden_root/claim-report.json" assert_posture "$golden_root/claim-report.json" claim claimed 2 +assert_chained_writer_epoch \ + "$golden_root/claim-report.json" \ + "$golden_root/request-report.json" test ! -e "$golden_workspace/$golden_path" run_phase inspect "$golden_case" "$golden_wal" "$golden_root/claim-recovery.json" assert_posture "$golden_root/claim-recovery.json" inspect claimed 2 +assert_no_writer_epoch "$golden_root/claim-recovery.json" printf '%s' "$golden_value" >"$golden_workspace/$golden_path" run_phase \ @@ -214,6 +235,61 @@ run_phase \ "$golden_workspace" golden_settlement="$golden_root/settlement-report.json" assert_posture "$golden_settlement" settle settled 3 +assert_chained_writer_epoch "$golden_settlement" "$golden_root/claim-report.json" + +# No writer epoch is reused anywhere in the ordered golden path. +test "$( + jq -r '.writerEpoch.epochId' \ + "$golden_root/request-report.json" \ + "$golden_root/claim-report.json" \ + "$golden_settlement" | + sort -u | + wc -l | + tr -d ' ' +)" = 3 + +# Retained epoch state must stay bounded across restarts rather than growing +# with each one. A size ceiling checked after a handful of epochs cannot show +# that, because an append-only ledger also fits any ceiling early on. This +# drives many fresh write phases on one WAL, each a separate host process +# taking a new epoch, and requires the ledger to stop changing size. +ledger_root="$effect_root/ledger-plateau" +mkdir -p "$ledger_root/workspace/notes" +printf 'observed' >"$ledger_root/workspace/notes/ledger.txt" +ledger_wal="$ledger_root/wal" +ledger_sizes="$ledger_root/sizes" +: >"$ledger_sizes" +ledger_worldline=100 +while test "$ledger_worldline" -lt 116 +do + make_case \ + "$ledger_root/request-$ledger_worldline.json" \ + "$ledger_worldline" \ + notes/ledger.txt \ + "$(hex_bytes observed)" \ + 65536 \ + notes/ledger.txt + run_phase \ + request \ + "$ledger_root/request-$ledger_worldline.json" \ + "$ledger_wal" \ + "$ledger_root/report-$ledger_worldline.json" + wc -c <"$ledger_wal/writer-epochs.ecwal" | tr -d ' ' >>"$ledger_sizes" + ledger_worldline=$((ledger_worldline + 1)) +done + +test "$( + jq -sr '[.[].writerEpoch.epochId] | unique | length' \ + "$ledger_root"/report-*.json +)" = 16 + +ledger_plateau=$(tail -n 8 "$ledger_sizes" | sort -u | wc -l | tr -d ' ') +if test "$ledger_plateau" -ne 1; then + echo "retained writer-epoch ledger did not stop growing across restarts" >&2 + tail -n 8 "$ledger_sizes" >&2 + exit 1 +fi + jq -e \ --arg path "$golden_path" \ --arg bytes_hex "$(hex_bytes "$golden_value")" \ @@ -230,10 +306,15 @@ jq -e \ # A retained exact candidate reconciles to the original fact without WAL # growth; a valid kind-only mutation obstructs without another commit. +# A null writerEpoch in a retry report cannot show that retry took no epoch: +# the phase supplies that null itself, and acquiring an epoch would change the +# persisted ledger without changing the commit count. +cp "$golden_wal/writer-epochs.ecwal" "$golden_root/ledger-before-retry.ecwal" run_phase retry "$golden_case" "$golden_wal" "$golden_root/retry-report.json" exact jq -e ' .phase == "retry" and .retry == "idempotent" + and .writerEpoch == null and .posture == "settled" and .wal.commitCountBefore == 3 and .wal.commitCountAfter == 3 @@ -254,10 +335,15 @@ jq -e ' .phase == "retry" and .retry == "obstructed" and .obstruction == "conflictingSettlement" + and .writerEpoch == null and .wal.commitCountBefore == 3 and .wal.commitCountAfter == 3 ' "$golden_root/conflict-report.json" >/dev/null +# Neither retry may have taken a writer epoch, which only the retained ledger +# can show. +cmp "$golden_root/ledger-before-retry.ecwal" "$golden_wal/writer-epochs.ecwal" + # The runtime-owned permitted aperture is bound into the durable request. A # caller cannot broaden it between claim recovery and adapter construction. aperture_root="$effect_root/aperture-substitution" @@ -345,6 +431,11 @@ run_phase \ "$unknown_root/wal" \ "$unknown_root/unknown-report.json" assert_posture "$unknown_root/unknown-report.json" unknown settled 3 +# The uncertainty settlement is a separate write entrypoint from settle. The +# every-write-phase epoch guarantee has to be shown here too. +assert_chained_writer_epoch \ + "$unknown_root/unknown-report.json" \ + "$unknown_root/claim-report.json" jq -e ' .settlement.kind == "outcomeUnknown" and .settlement.observation.status == "outcomeUnknown" diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh new file mode 100755 index 0000000..d10374f --- /dev/null +++ b/tests/lib/writer-epoch-assertions.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Shared writer-epoch assertions for the runtime witnesses. +# +# Source this file; it defines functions and runs nothing on its own. +# +# Echo owns writer-epoch derivation and fencing. These assertions are how the +# witnesses prove a host consumed that contract rather than minting an epoch +# identity of its own. Exercised by tests/writer-epoch-assertions.sh. + +# A write phase must run under a fresh Echo-derived writer epoch. Hello Echo +# supplies no epoch identity, so the reported epoch proves the producer chained +# it to the persisted predecessor rather than reusing a static fencing identity +# across host restarts. +assert_first_writer_epoch() { + report_file=$1 + # jq reads a missing property as null, so every field the assertion relies on + # must be required to exist. Otherwise a host that stopped emitting the + # predecessor fields would satisfy the null comparisons below. + jq -e ' + has("writerEpoch") + and (.writerEpoch | has("epochId")) + and (.writerEpoch | has("previousEpochId")) + and (.writerEpoch | has("previousEpochFinalCommitDigest")) + and (.writerEpoch | has("startedAtLsn")) + and (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + and .writerEpoch.previousEpochId == null + and .writerEpoch.previousEpochFinalCommitDigest == null + and (.writerEpoch.startedAtLsn | type) == "number" + and (.writerEpoch.startedAtLsn | floor) == .writerEpoch.startedAtLsn + and .writerEpoch.startedAtLsn == 0 + ' "$report_file" >/dev/null +} + +# Each later write phase is a separate host process. Its epoch must be new and +# must name the previous epoch and that epoch's final commit digest. +assert_chained_writer_epoch() { + report_file=$1 + previous_report=$2 + jq -e \ + --slurpfile previous "$previous_report" \ + ' + has("writerEpoch") + and (.writerEpoch | has("epochId")) + and (.writerEpoch | has("previousEpochId")) + and (.writerEpoch | has("previousEpochFinalCommitDigest")) + and (.writerEpoch | has("startedAtLsn")) + # The predecessor report is input to this assertion, not a trusted + # source. Without these, a predecessor carrying no writerEpoch yields + # null and a successor reporting previousEpochId null satisfies the + # linkage by null == null. + and ($previous[0] | has("writerEpoch")) + and ($previous[0].writerEpoch | has("epochId")) + and ($previous[0].writerEpoch | has("startedAtLsn")) + and ($previous[0].writerEpoch.epochId | test("^[0-9a-f]{64}$")) + and ($previous[0] | has("wal")) + and ($previous[0].wal | has("lastCommitDigest")) + and ($previous[0].wal.lastCommitDigest | test("^[0-9a-f]{64}$")) + and (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + and (.writerEpoch.previousEpochFinalCommitDigest | test("^[0-9a-f]{64}$")) + and .writerEpoch.epochId != $previous[0].writerEpoch.epochId + and .writerEpoch.previousEpochId == $previous[0].writerEpoch.epochId + and .writerEpoch.previousEpochFinalCommitDigest + == $previous[0].wal.lastCommitDigest + # An LSN is a discrete u64 position. A type check alone admits 0.5, + # which compares greater than a predecessor 0 and would let malformed + # epoch evidence through. + and (.writerEpoch.startedAtLsn | type) == "number" + and ($previous[0].writerEpoch.startedAtLsn | type) == "number" + and (.writerEpoch.startedAtLsn | floor) == .writerEpoch.startedAtLsn + and ($previous[0].writerEpoch.startedAtLsn | floor) + == $previous[0].writerEpoch.startedAtLsn + # An Lsn is a u64. 1e100 is a nonnegative integer to jq and compares + # greater than any predecessor, but cannot represent a position. + and .writerEpoch.startedAtLsn >= 0 + and $previous[0].writerEpoch.startedAtLsn >= 0 + and .writerEpoch.startedAtLsn <= 18446744073709551615 + and $previous[0].writerEpoch.startedAtLsn <= 18446744073709551615 + and .writerEpoch.startedAtLsn > $previous[0].writerEpoch.startedAtLsn + ' \ + "$report_file" >/dev/null +} + +# Read-only phases take no writer lease and therefore acquire no epoch. +assert_no_writer_epoch() { + report_file=$1 + # jq reads a missing property as null, so requiring the field to be present + # keeps this an assertion about a reported absence rather than one satisfied + # by a host that stopped reporting. + jq -e 'has("writerEpoch") and .writerEpoch == null' "$report_file" >/dev/null +} diff --git a/tests/patch-build-request.jsonl b/tests/patch-build-request.jsonl new file mode 100644 index 0000000..a07960e --- /dev/null +++ b/tests/patch-build-request.jsonl @@ -0,0 +1 @@ +{"schema":"edict.compiler.settings/v1","type":"compilerSettings","operation":"build","application":"edict.patch.application.json"} diff --git a/tests/patch-build.sh b/tests/patch-build.sh new file mode 100755 index 0000000..bd711ce --- /dev/null +++ b/tests/patch-build.sh @@ -0,0 +1,94 @@ +#!/bin/sh +set -eu + +: "${EDICT_REPO:?set EDICT_REPO to the compatible Edict checkout}" +: "${ECHO_REPO:?set ECHO_REPO to the compatible Echo checkout}" + +# The producer pair is pinned in-repository, not chosen by the caller. +./tests/producer-lock.sh + +command -v jq >/dev/null + +EDICT_REPO=$(CDPATH='' cd -- "$EDICT_REPO" && pwd -P) +ECHO_REPO=$(CDPATH='' cd -- "$ECHO_REPO" && pwd -P) + +test -f "$EDICT_REPO/crates/edict-cli/Cargo.toml" +test -f "$ECHO_REPO/crates/warp-core/Cargo.toml" + +fixture_source="$EDICT_REPO/fixtures/lawpack/workspace-patch" +cmp patch/src/apply-validated-patch.edict "$fixture_source/apply-validated-patch.edict" +for artifact in \ + manifest.cbor \ + manifest.sha256 \ + exports.cbor \ + exports.sha256 \ + adapter.cbor \ + adapter.sha256 \ + request-profile-configuration.cbor \ + request-profile-configuration.sha256 \ + input-schema.cbor \ + input-schema.sha256 \ + settlement-schema.cbor \ + settlement-schema.sha256 \ + reconciliation-law.cbor \ + reconciliation-law.sha256 +do + cmp "patch/vendor/workspace-patch/$artifact" "$fixture_source/$artifact" +done + +mkdir -p .build/patch +provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" +test -f "$provider_source/provider-manifest.echo.json" +test -f "$provider_source/generated/primary/target-profile.echo-dpo.cbor" +if find "$provider_source" -type l -print -quit | grep -q .; then + echo "provider package must not contain symlinks" >&2 + exit 1 +fi +rm -rf .build/patch/echo-provider +mkdir -p .build/patch/echo-provider +cp -RL "$provider_source/." .build/patch/echo-provider/ +test ! -d .build/patch/echo-provider/.git +if find .build/patch/echo-provider -type l -print -quit | grep -q .; then + echo "copied provider package must not contain symlinks" >&2 + exit 1 +fi + +rm -rf .build/patch/application +cargo run \ + --quiet \ + --manifest-path "$EDICT_REPO/Cargo.toml" \ + -p edict-cli \ + --bin edict \ + .build/patch/host/Cargo.toml +cargo build \ + --quiet \ + --manifest-path .build/patch/host/Cargo.toml \ + --target-dir .build/patch/host-target +test -x .build/patch/host-target/debug/hello-effect-patch-host diff --git a/tests/patch-run.sh b/tests/patch-run.sh new file mode 100755 index 0000000..bbd4314 --- /dev/null +++ b/tests/patch-run.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +if test "$#" -lt 3; then + echo "usage: tests/patch-run.sh PHASE REQUEST_JSON WAL_DIRECTORY [PHASE_ARGUMENT]" >&2 + exit 2 +fi + +project_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd -P) +phase=$1 +request_file=$2 +wal_dir=$3 +shift 3 + +case "$request_file" in + /*) ;; + *) request_file="$project_root/$request_file" ;; +esac +case "$wal_dir" in + /*) ;; + *) wal_dir="$project_root/$wal_dir" ;; +esac + +core_file=${PATCH_CORE_FILE:-"$project_root/.build/patch/application/core.cbor"} +target_ir_file=${PATCH_TARGET_IR_FILE:-"$project_root/.build/patch/application/target-ir.cbor"} +case "$core_file" in + /*) ;; + *) core_file="$project_root/$core_file" ;; +esac +case "$target_ir_file" in + /*) ;; + *) target_ir_file="$project_root/$target_ir_file" ;; +esac + +exec "$project_root/.build/patch/host-target/debug/hello-effect-patch-host" \ + "$phase" \ + "$request_file" \ + "$wal_dir" \ + "$core_file" \ + "$target_ir_file" \ + "$@" diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh new file mode 100755 index 0000000..b7e1b0f --- /dev/null +++ b/tests/patch-runtime.sh @@ -0,0 +1,1047 @@ +#!/bin/sh +set -eu + +: "${EDICT_REPO:?set EDICT_REPO to the compatible Edict checkout}" +: "${ECHO_REPO:?set ECHO_REPO to the compatible Echo checkout}" + +command -v jq >/dev/null +# The witness binds the reported content digests to the witnessed bytes, which +# needs the same hash the adapter uses. +command -v b3sum >/dev/null +# The binary property cases hash from hex, because a binary body cannot survive +# a shell variable. +command -v xxd >/dev/null + +if ! test -x ./tests/patch-build.sh; then + echo "Hello Effect patch build boundary is not implemented" >&2 + exit 2 +fi +if ! test -x ./tests/patch-run.sh; then + echo "Hello Effect patch runtime boundary is not implemented" >&2 + exit 2 +fi + +./tests/patch-build.sh + +patch_root=.build/patch-tests +rm -rf "$patch_root" +mkdir -p "$patch_root" + +core_file=.build/patch/application/core.cbor +target_ir_file=.build/patch/application/target-ir.cbor +expected_core="$EDICT_REPO/fixtures/lawpack/workspace-patch/apply-validated-patch.core.cbor" +expected_target_ir="$EDICT_REPO/fixtures/lawpack/workspace-patch/apply-validated-patch.target-ir.cbor" + +test -s "$core_file" +test -s "$target_ir_file" +cmp "$core_file" "$expected_core" +cmp "$target_ir_file" "$expected_target_ir" +test ! -e .build/patch/application/executable-operation-package.cbor +test ! -e .build/patch/application/verification-report.cbor + +hex_bytes() { + printf '%s' "$1" | od -An -tx1 | tr -d ' \n' +} + +# The content digest the adapter reports for a given file body. +content_digest() { + printf '%s' "$1" | b3sum --no-names | tr -d ' \n' +} + +# The same, for a body supplied as hex on stdin. A binary body cannot survive a +# shell variable, which is why the property cases carry hex. +hex_digest() { + xxd -r -p | b3sum --no-names | tr -d ' \n' +} + +make_case() { + case_file=$1 + worldline_byte=$2 + path=$3 + before_hex=$4 + replacement_hex=$5 + permitted_path=$6 + max_settlement_bytes=$7 + # File bodies are carried as hex, and a body at the file budget is 131,072 + # hex characters. Linux caps a single argument at MAX_ARG_STRLEN, which is + # exactly that, so passing one through --arg fails with "Argument list too + # long". macOS permits far larger arguments, which is why this only appears + # off a developer machine. --rawfile has no such limit. + make_case_dir=$(dirname "$case_file") + mkdir -p "$make_case_dir" + printf '%s' "$before_hex" >"$make_case_dir/.before.hex" + printf '%s' "$replacement_hex" >"$make_case_dir/.replacement.hex" + jq -n \ + --argjson worldline_byte "$worldline_byte" \ + --arg path "$path" \ + --rawfile before_hex "$make_case_dir/.before.hex" \ + --rawfile replacement_hex "$make_case_dir/.replacement.hex" \ + --arg permitted_path "$permitted_path" \ + --argjson max_settlement_bytes "$max_settlement_bytes" \ + '{ + worldlineByte: $worldline_byte, + intent: "applyValidated", + proposal: { + path: $path, + replacementBytesHex: $replacement_hex + }, + observation: { + path: $path, + bytesHex: $before_hex + }, + permittedPaths: [$permitted_path], + maxSettlementBytes: $max_settlement_bytes + }' >"$case_file" +} + +run_phase() { + phase=$1 + case_file=$2 + wal_dir=$3 + report_file=$4 + shift 4 + ./tests/patch-run.sh \ + "$phase" \ + "$case_file" \ + "$wal_dir" \ + "$@" >"$report_file" +} + +assert_identity() { + report_file=$1 + jq -e ' + (.requestId | test("^[0-9a-f]{64}$")) + and (.compiler.coreDigest | test("^sha256:[0-9a-f]{64}$")) + and (.compiler.targetIrDigest | test("^sha256:[0-9a-f]{64}$")) + and .compiler.operation == "workspace.patch.applyValidated@1" + and .compiler.intent == "applyValidated" + ' "$report_file" >/dev/null +} + +# Writer-epoch assertions are shared by both runtime witnesses and are +# themselves covered by tests/writer-epoch-assertions.sh. +. tests/lib/writer-epoch-assertions.sh + +assert_posture() { + report_file=$1 + phase=$2 + posture=$3 + commits=$4 + jq -e \ + --arg phase "$phase" \ + --arg posture "$posture" \ + --argjson commits "$commits" \ + '.phase == $phase + and .posture == $posture + and .wal.commitCount == $commits' \ + "$report_file" >/dev/null + assert_identity "$report_file" +} + +complete_success_case() { + case_name=$1 + worldline_byte=$2 + before=$3 + replacement=$4 + path=$5 + max_settlement_bytes=$6 + case_root="$patch_root/$case_name" + workspace_root="$case_root/workspace" + wal_dir="$case_root/wal" + case_file="$case_root/request.json" + mkdir -p "$workspace_root/$(dirname "$path")" + printf '%s' "$before" >"$workspace_root/$path" + make_case \ + "$case_file" \ + "$worldline_byte" \ + "$path" \ + "$(hex_bytes "$before")" \ + "$(hex_bytes "$replacement")" \ + "$path" \ + "$max_settlement_bytes" + run_phase request "$case_file" "$wal_dir" "$case_root/request-report.json" + run_phase claim "$case_file" "$wal_dir" "$case_root/claim-report.json" + run_phase apply "$case_file" "$wal_dir" "$case_root/settlement-report.json" "$workspace_root" + assert_posture "$case_root/request-report.json" request requested 1 + assert_posture "$case_root/claim-report.json" claim claimed 2 + assert_posture "$case_root/settlement-report.json" apply settled 3 + test "$(cat "$workspace_root/$path")" = "$replacement" + jq -e \ + --arg path "$path" \ + --arg before_digest "$(content_digest "$before")" \ + --arg after_digest "$(content_digest "$replacement")" \ + '.settlement.kind == "succeeded" + and .settlement.patch.status == "succeeded" + and .settlement.patch.path == $path + and .settlement.patch.beforeContentDigest == $before_digest + and .settlement.patch.afterContentDigest == $after_digest + and (.settlement.patch.resultingBasis | test("^[0-9a-f]{64}$")) + and (.settlement.attemptId | test("^[0-9a-f]{64}$")) + and (.settlement.basisDigest | test("^[0-9a-f]{64}$")) + and (.settlement.externalEvidenceDigest | test("^[0-9a-f]{64}$")) + and .settlement.basisDigest == .settlement.patch.requestBasis + and .settlement.externalEvidenceDigest == .settlement.patch.evidence + and .settlement.patch.evidence == .settlement.patch.resultingBasis + and .settlement.patch.obstruction == null + and (.settlement.commitDigest | test("^[0-9a-f]{64}$")) + and (.settlement.resultDigest | test("^[0-9a-f]{64}$")) + and .ordering.requestCommit < .ordering.claimCommit + and .ordering.claimCommit < .ordering.settlementCommit + and .publication.settlementCommittedBeforeResult == true' \ + "$case_root/settlement-report.json" >/dev/null +} + +# Returns the basis the host derives over an exact (path, bytes) pair. +# +# Echo derives a basis by domain-separated hashing. Recomputing that here would +# rebuild producer logic in the consumer, so the host is asked instead: a patch +# whose declared observation is those bytes reports a request basis over them. +# Comparing a retained basis against this is a two-route derivation, not a +# self-agreement. +# +# The fourth argument is the worldline of the case this probe will be compared +# against, and it must match. A probe on a different worldline cannot isolate +# the byte input: a producer deriving a basis from request context would +# satisfy the comparison while ignoring the bytes entirely. Every comparison +# below therefore varies only the path and the bytes. +request_basis_for() { + probe_root="$patch_root/basis-probe-$1" + probe_path=$2 + probe_bytes=$3 + mkdir -p "$probe_root/workspace/$(dirname "$probe_path")" + printf '%s' "$probe_bytes" >"$probe_root/workspace/$probe_path" + make_case \ + "$probe_root/request.json" \ + "$4" \ + "$probe_path" \ + "$(hex_bytes "$probe_bytes")" \ + "$(hex_bytes 'basis probe replacement')" \ + "$probe_path" \ + 65536 + run_phase request "$probe_root/request.json" "$probe_root/wal" "$probe_root/req.json" + run_phase claim "$probe_root/request.json" "$probe_root/wal" "$probe_root/clm.json" + run_phase \ + apply \ + "$probe_root/request.json" \ + "$probe_root/wal" \ + "$probe_root/settle.json" \ + "$probe_root/workspace" + jq -r '.settlement.patch.requestBasis' "$probe_root/settle.json" +} + +# Golden path: proposal data and compiler artifacts are admitted before the +# separately invoked adapter mutates the workspace. +golden_root="$patch_root/golden" +golden_workspace="$golden_root/workspace" +golden_wal="$golden_root/wal" +golden_case="$golden_root/request.json" +golden_path=notes/greeting.txt +golden_before='hello' +golden_replacement='hello from a validated patch' +mkdir -p "$golden_workspace/notes" +printf '%s' "$golden_before" >"$golden_workspace/$golden_path" +make_case \ + "$golden_case" \ + 81 \ + "$golden_path" \ + "$(hex_bytes "$golden_before")" \ + "$(hex_bytes "$golden_replacement")" \ + "$golden_path" \ + 65536 + +run_phase request "$golden_case" "$golden_wal" "$golden_root/request-report.json" +assert_posture "$golden_root/request-report.json" request requested 1 +test "$(cat "$golden_workspace/$golden_path")" = "$golden_before" + +# The first write phase opens the epoch chain on a fresh WAL, and Echo persists +# the ledger and the writer lease that fence it. +assert_first_writer_epoch "$golden_root/request-report.json" +test -s "$golden_wal/writer-epochs.ecwal" +test -e "$golden_wal/writer-epoch.lock" + +run_phase inspect "$golden_case" "$golden_wal" "$golden_root/request-recovery.json" +assert_posture "$golden_root/request-recovery.json" inspect requested 1 +assert_no_writer_epoch "$golden_root/request-recovery.json" + +run_phase claim "$golden_case" "$golden_wal" "$golden_root/claim-report.json" +assert_posture "$golden_root/claim-report.json" claim claimed 2 +assert_chained_writer_epoch \ + "$golden_root/claim-report.json" \ + "$golden_root/request-report.json" +test "$(cat "$golden_workspace/$golden_path")" = "$golden_before" + +run_phase inspect "$golden_case" "$golden_wal" "$golden_root/claim-recovery.json" +assert_posture "$golden_root/claim-recovery.json" inspect claimed 2 +assert_no_writer_epoch "$golden_root/claim-recovery.json" + +run_phase \ + apply \ + "$golden_case" \ + "$golden_wal" \ + "$golden_root/settlement-report.json" \ + "$golden_workspace" +golden_settlement="$golden_root/settlement-report.json" +assert_posture "$golden_settlement" apply settled 3 +assert_chained_writer_epoch "$golden_settlement" "$golden_root/claim-report.json" +test "$(cat "$golden_workspace/$golden_path")" = "$golden_replacement" + +# No writer epoch is reused anywhere in the ordered golden path. +test "$( + jq -r '.writerEpoch.epochId' \ + "$golden_root/request-report.json" \ + "$golden_root/claim-report.json" \ + "$golden_settlement" | + sort -u | + wc -l | + tr -d ' ' +)" = 3 + +# Retained epoch state must stay bounded across restarts rather than growing +# with each one. A size ceiling checked after a handful of epochs cannot show +# that, because an append-only ledger also fits any ceiling early on. This +# drives many fresh write phases on one WAL, each a separate host process +# taking a new epoch, and requires the ledger to stop changing size. +ledger_root="$patch_root/ledger-plateau" +mkdir -p "$ledger_root/workspace/notes" +printf 'hello' >"$ledger_root/workspace/notes/ledger.txt" +ledger_wal="$ledger_root/wal" +ledger_sizes="$ledger_root/sizes" +: >"$ledger_sizes" +ledger_worldline=100 +while test "$ledger_worldline" -lt 116 +do + make_case \ + "$ledger_root/request-$ledger_worldline.json" \ + "$ledger_worldline" \ + notes/ledger.txt \ + "$(hex_bytes hello)" \ + "$(hex_bytes "patched by $ledger_worldline")" \ + notes/ledger.txt \ + 65536 + run_phase \ + request \ + "$ledger_root/request-$ledger_worldline.json" \ + "$ledger_wal" \ + "$ledger_root/report-$ledger_worldline.json" + wc -c <"$ledger_wal/writer-epochs.ecwal" | tr -d ' ' >>"$ledger_sizes" + ledger_worldline=$((ledger_worldline + 1)) +done + +# Sixteen distinct epochs were taken on this WAL. +test "$( + jq -sr '[.[].writerEpoch.epochId] | unique | length' \ + "$ledger_root"/report-*.json +)" = 16 + +# The final eight observations must all be the same size. An append-only +# ledger would still be growing by then. +ledger_plateau=$(tail -n 8 "$ledger_sizes" | sort -u | wc -l | tr -d ' ') +if test "$ledger_plateau" -ne 1; then + echo "retained writer-epoch ledger did not stop growing across restarts" >&2 + tail -n 8 "$ledger_sizes" >&2 + exit 1 +fi + +# Exact retry is effect-free; a conflicting retry obstructs without WAL growth. +# +# A null writerEpoch in the retry report cannot show that retry took no epoch: +# retry_phase supplies that null itself, and acquiring an epoch would change +# the persisted ledger without changing the commit count. The ledger bytes are +# snapshotted instead. +cp "$golden_wal/writer-epochs.ecwal" "$golden_root/ledger-before-retry.ecwal" +run_phase retry "$golden_case" "$golden_wal" "$golden_root/retry-report.json" exact +jq -e ' + .phase == "retry" + and .retry == "idempotent" + and .posture == "settled" + and .writerEpoch == null + and .wal.commitCountBefore == 3 + and .wal.commitCountAfter == 3 + and .settlement.commitDigest == .retryCommitDigest +' "$golden_root/retry-report.json" >/dev/null + +if run_phase \ + retry \ + "$golden_case" \ + "$golden_wal" \ + "$golden_root/conflict-report.json" \ + conflict-kind +then + echo "conflicting patch settlement retry unexpectedly passed" >&2 + exit 1 +fi +jq -e ' + .phase == "retry" + and .retry == "obstructed" + and .obstruction == "conflictingSettlement" + and .writerEpoch == null + and .wal.commitCountBefore == 3 + and .wal.commitCountAfter == 3 +' "$golden_root/conflict-report.json" >/dev/null + +# Neither retry may have taken a writer epoch, which only the retained ledger +# can show. +cmp "$golden_root/ledger-before-retry.ecwal" "$golden_wal/writer-epochs.ecwal" + +# The permitted aperture is part of request identity. A caller cannot broaden +# it after the request and claim are durable. +aperture_root="$patch_root/aperture-substitution" +aperture_workspace="$aperture_root/workspace" +aperture_case="$aperture_root/request.json" +aperture_tampered="$aperture_root/tampered-request.json" +mkdir -p "$aperture_workspace" +printf '%s' secret >"$aperture_workspace/secret.txt" +make_case \ + "$aperture_case" \ + 109 \ + secret.txt \ + "$(hex_bytes secret)" \ + "$(hex_bytes replaced)" \ + allowed.txt \ + 65536 +run_phase request "$aperture_case" "$aperture_root/wal" "$aperture_root/request-report.json" +run_phase claim "$aperture_case" "$aperture_root/wal" "$aperture_root/claim-report.json" +jq '.permittedPaths = ["secret.txt"]' "$aperture_case" >"$aperture_tampered" +if ./tests/patch-run.sh \ + apply \ + "$aperture_tampered" \ + "$aperture_root/wal" \ + "$aperture_workspace" \ + >"$aperture_root/tampered-report.json" \ + 2>"$aperture_root/tampered-error.txt" +then + echo "post-claim patch aperture substitution unexpectedly passed" >&2 + exit 1 +fi +test ! -s "$aperture_root/tampered-report.json" +test "$(cat "$aperture_root/tampered-error.txt")" = \ + "claim recovery failed: MissingRequest" +run_phase inspect "$aperture_case" "$aperture_root/wal" "$aperture_root/recovery-report.json" +assert_posture "$aperture_root/recovery-report.json" inspect claimed 2 +test "$(cat "$aperture_workspace/secret.txt")" = secret + +# Replay accepts no workspace authority and cannot reapply the settled patch. +post_settlement='changed after settlement' +printf '%s' "$post_settlement" >"$golden_workspace/$golden_path" +run_phase replay "$golden_case" "$golden_wal" "$golden_root/replay-report.json" +assert_posture "$golden_root/replay-report.json" replay settled 3 +test "$(cat "$golden_workspace/$golden_path")" = "$post_settlement" +jq -e ' + .publication.replayedFromRetainedSettlement == true + and .settlement.patch.status == "succeeded" +' "$golden_root/replay-report.json" >/dev/null + +# A crash after mutation but before settlement is reconciled from the observed +# postcondition. The reconciler does not manufacture a witnessed pre-state. +reconcile_root="$patch_root/reconcile-success" +reconcile_workspace="$reconcile_root/workspace" +reconcile_case="$reconcile_root/request.json" +reconcile_path=src/reconcile.txt +mkdir -p "$reconcile_workspace/src" +printf '%s' before >"$reconcile_workspace/$reconcile_path" +make_case \ + "$reconcile_case" \ + 82 \ + "$reconcile_path" \ + "$(hex_bytes before)" \ + "$(hex_bytes after)" \ + "$reconcile_path" \ + 65536 +run_phase request "$reconcile_case" "$reconcile_root/wal" "$reconcile_root/request-report.json" +run_phase claim "$reconcile_case" "$reconcile_root/wal" "$reconcile_root/claim-report.json" +printf '%s' after >"$reconcile_workspace/$reconcile_path" +run_phase \ + reconcile \ + "$reconcile_case" \ + "$reconcile_root/wal" \ + "$reconcile_root/reconcile-report.json" \ + "$reconcile_workspace" +assert_posture "$reconcile_root/reconcile-report.json" reconcile settled 3 +# The reconciler is a distinct implementation from the adapter, so its retained +# evidence needs its own binding. Without this the three agreeing fields could +# all carry one arbitrary value from a regression confined to +# ValidatedWorkspacePatchReconcilerV1. +reconciled_basis=$( + jq -r '.settlement.patch.resultingBasis' "$reconcile_root/reconcile-report.json" +) +test "$reconciled_basis" = "$(request_basis_for reconciled "$reconcile_path" after 82)" +# And not a basis over the pre-state, so the equality above cannot be met by a +# reconciler that retained the wrong bytes. +test "$reconciled_basis" != "$(request_basis_for reconciled-pre "$reconcile_path" before 82)" +# Reconciliation is a separate write entrypoint from apply. The every-write- +# phase epoch guarantee has to be shown here too, not only on the golden path. +assert_chained_writer_epoch \ + "$reconcile_root/reconcile-report.json" \ + "$reconcile_root/claim-report.json" +jq -e \ + --arg after_digest "$(content_digest after)" \ + ' + .settlement.kind == "succeeded" + and .settlement.patch.beforeContentDigest == null + and .settlement.patch.afterContentDigest == $after_digest + and .settlement.basisDigest == .settlement.patch.requestBasis + and .settlement.externalEvidenceDigest == .settlement.patch.evidence + and .settlement.patch.evidence == .settlement.patch.resultingBasis +' "$reconcile_root/reconcile-report.json" >/dev/null +test "$(cat "$reconcile_workspace/$reconcile_path")" = after + +# A crash whose postcondition is neither the requested before nor after state +# settles as outcomeUnknown and preserves the externally observed bytes. +unknown_root="$patch_root/outcome-unknown" +unknown_workspace="$unknown_root/workspace" +unknown_case="$unknown_root/request.json" +mkdir -p "$unknown_workspace" +printf '%s' before >"$unknown_workspace/ambiguous.txt" +make_case \ + "$unknown_case" \ + 83 \ + ambiguous.txt \ + "$(hex_bytes before)" \ + "$(hex_bytes intended)" \ + ambiguous.txt \ + 65536 +run_phase request "$unknown_case" "$unknown_root/wal" "$unknown_root/request-report.json" +run_phase claim "$unknown_case" "$unknown_root/wal" "$unknown_root/claim-report.json" +printf '%s' ambiguous >"$unknown_workspace/ambiguous.txt" +run_phase \ + reconcile \ + "$unknown_case" \ + "$unknown_root/wal" \ + "$unknown_root/reconcile-report.json" \ + "$unknown_workspace" +assert_posture "$unknown_root/reconcile-report.json" reconcile settled 3 +assert_chained_writer_epoch \ + "$unknown_root/reconcile-report.json" \ + "$unknown_root/claim-report.json" +jq -e ' + .settlement.kind == "outcomeUnknown" + and .settlement.patch.status == "outcomeUnknown" + and .settlement.patch.obstruction == "postcondition-not-observed" + and (.settlement.attemptId | test("^[0-9a-f]{64}$")) + and .settlement.basisDigest == .settlement.patch.requestBasis + and .settlement.externalEvidenceDigest == .settlement.patch.evidence +' "$unknown_root/reconcile-report.json" >/dev/null +test "$(cat "$unknown_workspace/ambiguous.txt")" = ambiguous + +# This is the one settlement family where the declared replacement and the +# observed post-state differ: the request asks for one thing and the crash +# leaves another. In every success case those two byte strings are identical, +# so only here can a test tell which of them the retained evidence describes. +# +# Echo derives this evidence by wrapping the observed basis in a further +# domain-separated hash. Recomputing that would rebuild producer logic in the +# consumer. The property is pinned instead: the evidence must vary with the +# observed bytes and must not vary with the declared replacement. A producer +# deriving it from the declared replacement fails the first check; one ignoring +# the observation fails the second. +# The worldline is held constant across these probes. Varying it would leave +# the comparison unable to isolate the dependency: a producer keying off the +# worldline byte alone could produce the same A/B/A pattern. +unknown_evidence_worldline=97 +unknown_evidence_for() { + probe_root="$patch_root/unknown-evidence-$1" + probe_workspace="$probe_root/workspace" + mkdir -p "$probe_workspace" + printf '%s' before >"$probe_workspace/ambiguous.txt" + make_case \ + "$probe_root/request.json" \ + "$unknown_evidence_worldline" \ + ambiguous.txt \ + "$(hex_bytes before)" \ + "$(hex_bytes "$2")" \ + ambiguous.txt \ + 65536 + run_phase request "$probe_root/request.json" "$probe_root/wal" "$probe_root/req.json" + run_phase claim "$probe_root/request.json" "$probe_root/wal" "$probe_root/clm.json" + printf '%s' "$3" >"$probe_workspace/ambiguous.txt" + run_phase \ + reconcile \ + "$probe_root/request.json" \ + "$probe_root/wal" \ + "$probe_root/settle.json" \ + "$probe_workspace" + jq -e '.settlement.kind == "outcomeUnknown"' "$probe_root/settle.json" >/dev/null + jq -r '.settlement.patch.evidence' "$probe_root/settle.json" +} + +# Same declared replacement, different observed post-states. +evidence_observed_one=$(unknown_evidence_for one intended observedOne) +evidence_observed_two=$(unknown_evidence_for two intended observedTwo) +test "$evidence_observed_one" != "$evidence_observed_two" + +# Different declared replacement, same observed post-state. +evidence_other_request=$(unknown_evidence_for three requested observedOne) +test "$evidence_other_request" = "$evidence_observed_one" + +# In an outcomeUnknown settlement `beforeContentDigest` carries the digest of +# the bytes that were actually observed, not of the declared pre-state: the +# reconciler has no witnessed pre-state to report, so the field describes what +# it found. Pinning it here records that reading, which the field name alone +# does not convey. +jq -e \ + --arg observed_digest "$(content_digest ambiguous)" \ + --arg declared_digest "$(content_digest before)" \ + '.settlement.patch.beforeContentDigest == $observed_digest + and .settlement.patch.beforeContentDigest != $declared_digest' \ + "$unknown_root/reconcile-report.json" >/dev/null + +assert_rejected_case() { + case_name=$1 + worldline_byte=$2 + path=$3 + permitted_path=$4 + before=$5 + replacement=$6 + refusal=$7 + setup_kind=$8 + case_root="$patch_root/$case_name" + workspace_root="$case_root/workspace" + case_file="$case_root/request.json" + mkdir -p "$workspace_root/$(dirname "$path" 2>/dev/null || printf '.')" + case "$setup_kind" in + regular) + printf '%s' "$before" >"$workspace_root/$path" + ;; + stale) + printf '%s' changed >"$workspace_root/$path" + ;; + symlink) + printf '%s' outside >"$case_root/outside.txt" + ln -s "$case_root/outside.txt" "$workspace_root/$path" + ;; + absent) ;; + *) + echo "unknown patch refusal setup: $setup_kind" >&2 + exit 2 + ;; + esac + make_case \ + "$case_file" \ + "$worldline_byte" \ + "$path" \ + "$(hex_bytes "$before")" \ + "$(hex_bytes "$replacement")" \ + "$permitted_path" \ + 65536 + run_phase request "$case_file" "$case_root/wal" "$case_root/request-report.json" + run_phase claim "$case_file" "$case_root/wal" "$case_root/claim-report.json" + run_phase \ + apply \ + "$case_file" \ + "$case_root/wal" \ + "$case_root/settlement-report.json" \ + "$workspace_root" + assert_posture "$case_root/settlement-report.json" apply settled 3 + jq -e \ + --arg refusal "$refusal" \ + '.settlement.kind == "rejected" + and .settlement.patch.status == "rejected" + and .settlement.patch.obstruction == $refusal + and .settlement.patch.afterContentDigest == null + and .settlement.patch.resultingBasis == null' \ + "$case_root/settlement-report.json" >/dev/null +} + +# Known failure modes obstruct before mutation. +assert_rejected_case stale-basis 84 stale.txt stale.txt before after stale-basis stale +assert_rejected_case unauthorized 85 secret.txt allowed.txt secret replaced unauthorized-path regular +assert_rejected_case symlink 87 link.txt link.txt before after symlink-refused symlink +assert_rejected_case ci-workflow 88 .github/workflows/ci.yml allowed.txt before after ci-workflow-refused regular + +test "$(cat "$patch_root/stale-basis/workspace/stale.txt")" = changed +test "$(cat "$patch_root/unauthorized/workspace/secret.txt")" = secret +test "$(cat "$patch_root/symlink/outside.txt")" = outside +test -L "$patch_root/symlink/workspace/link.txt" +test "$(cat "$patch_root/ci-workflow/workspace/.github/workflows/ci.yml")" = before + +# Parent escape is rejected while deterministically validating proposal data, +# before a request can enter the WAL or an adapter can receive authority. +parent_root="$patch_root/parent-escape" +mkdir -p "$parent_root" +make_case \ + "$parent_root/request.json" \ + 86 \ + ../secret.txt \ + "$(hex_bytes absent)" \ + "$(hex_bytes replaced)" \ + allowed.txt \ + 65536 +if run_phase \ + request \ + "$parent_root/request.json" \ + "$parent_root/wal" \ + "$parent_root/request-report.json" +then + echo "parent-escaped patch proposal unexpectedly passed" >&2 + exit 1 +fi +jq -e ' + .phase == "request" + and .obstruction == "requestRejected" + and .wal.commitCount == 0 +' "$parent_root/request-report.json" >/dev/null + +# Model output is closed-schema data. Extra authority-shaped fields cannot be +# smuggled through the proposal and fail before the first WAL commit. +malformed_root="$patch_root/malformed-proposal" +mkdir -p "$malformed_root" +make_case \ + "$malformed_root/request.json" \ + 110 \ + source.txt \ + "$(hex_bytes source)" \ + "$(hex_bytes target)" \ + source.txt \ + 65536 +jq '.proposal.command = "git push --force"' \ + "$malformed_root/request.json" >"$malformed_root/tampered-request.json" +if run_phase \ + request \ + "$malformed_root/tampered-request.json" \ + "$malformed_root/wal" \ + "$malformed_root/request-report.json" +then + echo "proposal with an undeclared field unexpectedly passed" >&2 + exit 1 +fi +jq -e ' + .phase == "request" + and .obstruction == "requestRejected" + and .wal.commitCount == 0 +' "$malformed_root/request-report.json" >/dev/null + +# Replacement-size boundary. The host advertises a 65,536-byte file cap, but +# the encoded patch carries the path, the expected content digest, and canonical +# framing inside the same bounded request carrier, so the reachable replacement +# size is smaller and depends on the path. A replacement the host accepts must +# either be admitted or refused for a stated reason, never accepted here and +# then rejected as an indistinguishable malformed request. +oversize_root="$patch_root/oversize-replacement" +mkdir -p "$oversize_root/workspace/notes" +printf 'hello' >"$oversize_root/workspace/notes/big.txt" +oversize_hex=$( + awk 'BEGIN { while (i++ < 65536) printf "41" }' +) +make_case \ + "$oversize_root/request.json" \ + 93 \ + notes/big.txt \ + "$(hex_bytes hello)" \ + "$oversize_hex" \ + notes/big.txt \ + 65536 +if run_phase \ + request \ + "$oversize_root/request.json" \ + "$oversize_root/wal" \ + "$oversize_root/report.json" +then + echo "a replacement above the encodable ceiling was admitted" >&2 + exit 1 +fi +# The refusal must name the budget, not masquerade as a malformed request. +jq -e ' + .phase == "request" + and .obstruction == "replacementExceedsRequestBudget" + and .wal.commitCount == 0 +' "$oversize_root/report.json" >/dev/null +# A refusal must leave the workspace alone, not merely skip the WAL commit. +test "$(cat "$oversize_root/workspace/notes/big.txt")" = hello + +# The retained workspace-root evidence must describe the witnessed post-state, +# not merely agree with itself. The three fields the success assertion compares +# (evidence, externalEvidenceDigest, resultingBasis) would all pass if a +# producer replaced them with one arbitrary value. +# +# Echo derives a basis by domain-separated hashing over the path and bytes. +# Recomputing that here would rebuild producer logic in the consumer, which is +# the boundary this witness exists to hold. Instead the host is asked for the +# same value twice by two independent routes: a second patch that declares the +# first patch's post-state as its own observed pre-state derives a request +# basis over those exact bytes, and that must equal the resulting basis the +# first settlement retained. +basis_root="$patch_root/resulting-basis" +basis_workspace="$basis_root/workspace" +basis_path=notes/basis.txt +basis_before='basis before' +basis_after='basis after' +basis_final='basis final' +mkdir -p "$basis_workspace/notes" +printf '%s' "$basis_before" >"$basis_workspace/$basis_path" + +make_case \ + "$basis_root/first.json" \ + 95 \ + "$basis_path" \ + "$(hex_bytes "$basis_before")" \ + "$(hex_bytes "$basis_after")" \ + "$basis_path" \ + 65536 +run_phase request "$basis_root/first.json" "$basis_root/wal1" "$basis_root/first-request.json" +run_phase claim "$basis_root/first.json" "$basis_root/wal1" "$basis_root/first-claim.json" +run_phase \ + apply \ + "$basis_root/first.json" \ + "$basis_root/wal1" \ + "$basis_root/first-settlement.json" \ + "$basis_workspace" +assert_posture "$basis_root/first-settlement.json" apply settled 3 +test "$(cat "$basis_workspace/$basis_path")" = "$basis_after" + +# The workspace now holds the first post-state. The second patch observes it, +# on the same worldline, so the comparisons below vary only the bytes. +make_case \ + "$basis_root/second.json" \ + 95 \ + "$basis_path" \ + "$(hex_bytes "$basis_after")" \ + "$(hex_bytes "$basis_final")" \ + "$basis_path" \ + 65536 +run_phase request "$basis_root/second.json" "$basis_root/wal2" "$basis_root/second-request.json" +run_phase claim "$basis_root/second.json" "$basis_root/wal2" "$basis_root/second-claim.json" +run_phase \ + apply \ + "$basis_root/second.json" \ + "$basis_root/wal2" \ + "$basis_root/second-settlement.json" \ + "$basis_workspace" +assert_posture "$basis_root/second-settlement.json" apply settled 3 + +# One basis over (path, basis_after), reached two ways. +test "$( + jq -r '.settlement.patch.requestBasis' "$basis_root/second-settlement.json" +)" = "$( + jq -r '.settlement.patch.resultingBasis' "$basis_root/first-settlement.json" +)" + +# And the two settlements must not share a basis, so the equality above is not +# satisfied by a constant. +test "$( + jq -r '.settlement.patch.resultingBasis' "$basis_root/second-settlement.json" +)" != "$( + jq -r '.settlement.patch.resultingBasis' "$basis_root/first-settlement.json" +)" + +# An oversized observation is a different fault from an oversized replacement. +# Reporting both as a replacement-budget refusal would name the wrong input. +oversize_before_root="$patch_root/oversize-observation" +mkdir -p "$oversize_before_root/workspace/notes" +printf 'hello' >"$oversize_before_root/workspace/notes/before.txt" +oversize_before_hex=$( + awk 'BEGIN { while (i++ < 65537) printf "42" }' +) +make_case \ + "$oversize_before_root/request.json" \ + 94 \ + notes/before.txt \ + "$oversize_before_hex" \ + "$(hex_bytes small)" \ + notes/before.txt \ + 65536 +if run_phase \ + request \ + "$oversize_before_root/request.json" \ + "$oversize_before_root/wal" \ + "$oversize_before_root/report.json" +then + echo "an observation above the file budget was admitted" >&2 + exit 1 +fi +jq -e ' + .phase == "request" + and .obstruction == "observationExceedsFileBudget" + and .wal.commitCount == 0 +' "$oversize_before_root/report.json" >/dev/null +test "$(cat "$oversize_before_root/workspace/notes/before.txt")" = hello + +# Settlement-size boundary: the request-only settlement floor (the encoded +# result size, never below the host minimum) succeeds; one byte less refuses +# before mutation. +complete_success_case boundary-probe 89 before boundary exact.txt 65536 +boundary_result_bytes=$( + jq -r '.settlement.canonicalResultByteCount' \ + "$patch_root/boundary-probe/settlement-report.json" +) +case "$boundary_result_bytes" in + '' | *[!0-9]*) + echo "settlement report omitted numeric canonicalResultByteCount" >&2 + exit 1 + ;; +esac +boundary_floor=$boundary_result_bytes +if test "$boundary_floor" -lt 1024; then + boundary_floor=1024 +fi +complete_success_case exact-boundary 90 before boundary exact.txt "$boundary_floor" + +under_root="$patch_root/under-boundary" +mkdir -p "$under_root/workspace" +printf '%s' before >"$under_root/workspace/exact.txt" +make_case \ + "$under_root/request.json" \ + 91 \ + exact.txt \ + "$(hex_bytes before)" \ + "$(hex_bytes boundary)" \ + exact.txt \ + "$((boundary_floor - 1))" +if run_phase \ + request \ + "$under_root/request.json" \ + "$under_root/wal" \ + "$under_root/request-report.json" +then + echo "under-floor patch request unexpectedly passed" >&2 + exit 1 +fi +jq -e ' + .phase == "request" + and .obstruction == "requestRejected" + and .wal.commitCount == 0 +' "$under_root/request-report.json" >/dev/null +test "$(cat "$under_root/workspace/exact.txt")" = before + +# A substituted compiler artifact fails before the first WAL commit, and the +# same substitution after request admission cannot append another fact. +mutated_core="$patch_root/mutated-core.cbor" +cp "$core_file" "$mutated_core" +printf '\000' | dd of="$mutated_core" bs=1 seek=0 conv=notrunc 2>/dev/null +if cmp -s "$core_file" "$mutated_core"; then + echo "compiler-artifact substitution fixture did not alter the artifact" >&2 + exit 1 +fi +mutated_root="$patch_root/mutated-artifact" +mkdir -p "$mutated_root" +make_case \ + "$mutated_root/request.json" \ + 92 \ + source.txt \ + "$(hex_bytes source)" \ + "$(hex_bytes target)" \ + source.txt \ + 65536 +if env PATCH_CORE_FILE="$mutated_core" ./tests/patch-run.sh \ + request \ + "$mutated_root/request.json" \ + "$mutated_root/wal" \ + >"$mutated_root/report.json" +then + echo "substituted patch compiler artifact unexpectedly passed" >&2 + exit 1 +fi +jq -e ' + .phase == "request" + and .obstruction == "compilerArtifactRejected" + and .wal.commitCount == 0 +' "$mutated_root/report.json" >/dev/null + +post_request_root="$patch_root/post-request-mutated-artifact" +mkdir -p "$post_request_root" +make_case \ + "$post_request_root/request.json" \ + 93 \ + source.txt \ + "$(hex_bytes source)" \ + "$(hex_bytes target)" \ + source.txt \ + 65536 +run_phase \ + request \ + "$post_request_root/request.json" \ + "$post_request_root/wal" \ + "$post_request_root/request-report.json" +if env PATCH_CORE_FILE="$mutated_core" ./tests/patch-run.sh \ + claim \ + "$post_request_root/request.json" \ + "$post_request_root/wal" \ + >"$post_request_root/claim-report.json" +then + echo "post-request patch artifact substitution unexpectedly passed" >&2 + exit 1 +fi +jq -e ' + .phase == "claim" + and .obstruction == "compilerArtifactRejected" + and .wal.commitCount == 1 +' "$post_request_root/claim-report.json" >/dev/null + +# Fixed-seed property corpus, including non-text replacement bytes. +property_seed=71111 +property_ordinal=0 +for replacement_hex in \ + "$(hex_bytes "seed-$property_seed-alpha")" \ + "$(hex_bytes "seed-$property_seed-unicode-Ω")" \ + 0001027fff +do + property_ordinal=$((property_ordinal + 1)) + case_root="$patch_root/property-$property_ordinal" + mkdir -p "$case_root/workspace" + printf '%s' before >"$case_root/workspace/value.bin" + make_case \ + "$case_root/request.json" \ + "$((93 + property_ordinal))" \ + value.bin \ + "$(hex_bytes before)" \ + "$replacement_hex" \ + value.bin \ + 65536 + run_phase request "$case_root/request.json" "$case_root/wal" "$case_root/request-report.json" + run_phase claim "$case_root/request.json" "$case_root/wal" "$case_root/claim-report.json" + run_phase \ + apply \ + "$case_root/request.json" \ + "$case_root/wal" \ + "$case_root/settlement-report.json" \ + "$case_root/workspace" + assert_posture "$case_root/settlement-report.json" apply settled 3 + test "$(od -An -tx1 "$case_root/workspace/value.bin" | tr -d ' \n')" = "$replacement_hex" + # Binary bodies are where a hash that stops at an embedded NUL would still + # write the right bytes, so the reported digests are bound here too. + jq -e \ + --arg before_digest "$(printf '%s' "$before_hex" | hex_digest)" \ + --arg after_digest "$(printf '%s' "$replacement_hex" | hex_digest)" \ + '.settlement.patch.beforeContentDigest == $before_digest + and .settlement.patch.afterContentDigest == $after_digest' \ + "$case_root/settlement-report.json" >/dev/null +done + +# Bounded stress: eight isolated request/claim/apply worldlines. +stress_count=8 +stress_ordinal=1 +while test "$stress_ordinal" -le "$stress_count"; do + complete_success_case \ + "stress-$stress_ordinal" \ + "$((100 + stress_ordinal))" \ + "before-$stress_ordinal" \ + "after-$stress_ordinal" \ + "stress-$stress_ordinal.txt" \ + 65536 + stress_ordinal=$((stress_ordinal + 1)) +done + +# Retained evidence must not disclose host checkout paths. +edict_repo_root=$(CDPATH='' cd -- "$EDICT_REPO" && pwd -P) +echo_repo_root=$(CDPATH='' cd -- "$ECHO_REPO" && pwd -P) +project_root=$(pwd -P) +set +e +grep -R -F \ + -e "$edict_repo_root" \ + -e "$echo_repo_root" \ + -e "$project_root" \ + --include='*.json' \ + "$patch_root" \ + >/dev/null +leak_status=$? +set -e +if test "$leak_status" -gt 1; then + echo "Hello Effect patch witness leak check failed to run" >&2 + exit 1 +fi +if test "$leak_status" -eq 0; then + echo "Hello Effect patch witness disclosed a producer checkout path" >&2 + exit 1 +fi + +printf '%s\n' \ + "Hello Effect patch suite passed: 1 ordered golden, 1 retry, 1 conflict, 1 aperture substitution, 1 replay, 2 reconciliation outcomes, 5 refusals, 1 malformed proposal, 1 boundary probe, 2 boundaries, 2 artifact refusals, 3 fixed-seed property, 8 stress" diff --git a/tests/producer-lock.sh b/tests/producer-lock.sh new file mode 100755 index 0000000..7d6be6b --- /dev/null +++ b/tests/producer-lock.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +# Requires the selected producer checkouts to be exactly the commits this +# repository pins. +# +# The compatible producer pair was an operator convention: the witnesses took +# whatever EDICT_REPO and ECHO_REPO pointed at. A consumer sitting on a stale +# producer was undetectable, which is what made the advance to Edict df80f92a +# and Echo c354d531 look like a repository failure rather than a pin change. + +: "${EDICT_REPO:?set EDICT_REPO to the compatible Edict checkout}" +: "${ECHO_REPO:?set ECHO_REPO to the compatible Echo checkout}" + +command -v jq >/dev/null +command -v git >/dev/null + +lock=producers.lock.json +test -f "$lock" + +test "$(jq -r '.version' "$lock")" = 1 + +check_producer() { + name=$1 + checkout=$2 + expected=$(jq -r ".$name.commit" "$lock") + case "$expected" in + ????????????????????????????????????????) ;; + *) + echo "$lock does not pin a full $name commit" >&2 + exit 1 + ;; + esac + actual=$(git -C "$checkout" rev-parse HEAD) + if test "$actual" != "$expected"; then + echo "$name checkout is $actual but $lock pins $expected" >&2 + exit 1 + fi + # A commit id says nothing about the working tree. Uncommitted edits to the + # producer leave rev-parse reporting the pinned commit while the build + # compiles different sources, so the run would claim a pin it is not + # honouring. The vendored artifact comparisons do not cover this: they check + # the fixtures, not the compiler and host crates the build actually uses. + if test -n "$(git -C "$checkout" status --porcelain)"; then + echo "$name checkout has uncommitted changes and is not the pinned $expected" >&2 + git -C "$checkout" status --porcelain >&2 + exit 1 + fi +} + +check_producer edict "$EDICT_REPO" +check_producer echo "$ECHO_REPO" diff --git a/tests/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh new file mode 100755 index 0000000..c397eec --- /dev/null +++ b/tests/writer-epoch-assertions.sh @@ -0,0 +1,196 @@ +#!/bin/sh +set -eu + +# Hermetic test of tests/lib/writer-epoch-assertions.sh. +# +# Both runtime witnesses rely on those assertions to prove that each write +# phase runs under a fresh, durably chained writer epoch. An assertion that has +# never been shown to reject a bad report proves nothing, so this feeds them +# reports mutated to carry exactly the defects the assertions exist to catch. +# +# No producer checkout, no host binary, and no cargo: this runs anywhere. + +command -v jq >/dev/null + +. tests/lib/writer-epoch-assertions.sh + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +first=57645ea6d8294d4177531f813035c926d051bae68c0ce6d0a74afc08bf612d55 +second=a3b974bd428109b96ea26087630b9562c51a08f7ac498d93bb04c253ee6bb85a +commit=3aa349ca0d266dc02cc80fc40fd68e3082fe278bdd5afc6b38f633a05b383e09 +other=8bd0f1c2e4a6957038d1b5c7e9f2a4b6c8d0e2f4a6b8c0d2e4f60718293a4b5c + +cat >"$work/request.json" <"$work/claim.json" <"$work/inspect.json" <<'EOF' +{"phase":"inspect","writerEpoch":null} +EOF + +# Derives a mutated claim report by applying a jq edit to the valid one. +mutate() { + jq "$2" "$work/claim.json" >"$work/mutant.json" + if assert_chained_writer_epoch "$work/mutant.json" "$work/request.json" \ + 2>/dev/null + then + printf 'FAIL assertion accepted: %s\n' "$1" >&2 + exit 1 + fi + printf 'ok rejected: %s\n' "$1" +} + +# Controls. If these fail the assertions are simply broken, not strict. +assert_first_writer_epoch "$work/request.json" +printf 'ok accepted: first epoch on a fresh WAL\n' +assert_chained_writer_epoch "$work/claim.json" "$work/request.json" +printf 'ok accepted: claim chained to request\n' +assert_no_writer_epoch "$work/inspect.json" +printf 'ok accepted: read-only phase reports no epoch\n' + +# The defect this PR fixed: a host that reuses one static epoch across +# restarts reports the same id in consecutive write phases. +mutate "reused epoch id" \ + '.writerEpoch.epochId = "'"$first"'"' + +# The old call site passed previous_epoch_id: None, so nothing linked an epoch +# to its predecessor. +mutate "no predecessor linkage" \ + '.writerEpoch.previousEpochId = null + | .writerEpoch.previousEpochFinalCommitDigest = null' + +# Predecessor named without the commit digest that closes it. +mutate "predecessor named but no final commit digest" \ + '.writerEpoch.previousEpochFinalCommitDigest = null' + +# An epoch chained to some other epoch than the one that actually preceded it. +mutate "predecessor is not the preceding epoch" \ + '.writerEpoch.previousEpochId = "'"$second"'"' + +# A successor must own LSNs after its predecessor. +mutate "start LSN does not advance" \ + '.writerEpoch.startedAtLsn = 0' +mutate "start LSN moves backwards" \ + '.writerEpoch.startedAtLsn = -1' + +# A write phase that reports no epoch at all must not read as chained. +mutate "write phase reports no epoch" \ + '.writerEpoch = null' + +# A well-formed digest that is not the predecessor's actual final commit. The +# shape check alone accepts this, so only comparing the value catches a +# producer that links the right epoch id to the wrong commit. +mutate "predecessor final commit digest is well-formed but wrong" \ + '.writerEpoch.previousEpochFinalCommitDigest = "'"$other"'"' + +# jq orders across types, so "oops" > 0 and {} > 0 are both true. A start LSN +# replaced by malformed data would satisfy a bare > comparison. +mutate "start LSN is a string" \ + '.writerEpoch.startedAtLsn = "oops"' +mutate "start LSN is an object" \ + '.writerEpoch.startedAtLsn = {}' +mutate "start LSN is absent" \ + 'del(.writerEpoch.startedAtLsn)' + +# An LSN is a discrete position, so a fractional value is malformed evidence +# even though it is a number greater than its predecessor. +mutate "start LSN is fractional" \ + '.writerEpoch.startedAtLsn = 0.5' +mutate "start LSN is negative" \ + '.writerEpoch.startedAtLsn = -1' +mutate "start LSN exceeds u64" \ + '.writerEpoch.startedAtLsn = 1e100' + +# A malformed identity is not an epoch. +mutate "epoch id is not a digest" \ + '.writerEpoch.epochId = "not-a-digest"' + +# jq reads a missing property as null, so an assertion written only as +# "== null" also passes when the host stops emitting the field. A report that +# omits the evidence entirely must fail rather than read as proof of absence. +jq 'del(.writerEpoch)' "$work/inspect.json" >"$work/absent.json" +if assert_no_writer_epoch "$work/absent.json" 2>/dev/null; then + echo 'FAIL assertion accepted: report omitting writerEpoch entirely' >&2 + exit 1 +fi +printf 'ok rejected: report omitting writerEpoch entirely\n' + +# The predecessor report is input as well. A mutation loop that only damages +# the successor never reaches the path where a missing predecessor field is +# read as null and satisfies the linkage. +for damage in 'del(.writerEpoch)' 'del(.writerEpoch.epochId)' \ + 'del(.writerEpoch.startedAtLsn)' 'del(.wal)' 'del(.wal.lastCommitDigest)' \ + '.writerEpoch.epochId = "not-a-digest"' '.wal.lastCommitDigest = "short"' +do + jq "$damage" "$work/request.json" >"$work/prev-damaged.json" + if assert_chained_writer_epoch "$work/claim.json" "$work/prev-damaged.json" \ + 2>/dev/null + then + printf 'FAIL assertion accepted a damaged predecessor: %s\n' "$damage" >&2 + exit 1 + fi + printf 'ok rejected damaged predecessor: %s\n' "$damage" +done + +# The specific null == null path: neither side names an epoch. +jq 'del(.writerEpoch)' "$work/request.json" >"$work/prev-none.json" +jq '.writerEpoch.previousEpochId = null' "$work/claim.json" >"$work/succ-null.json" +if assert_chained_writer_epoch "$work/succ-null.json" "$work/prev-none.json" \ + 2>/dev/null +then + echo 'FAIL assertion accepted null linked to null' >&2 + exit 1 +fi +printf 'ok rejected: null predecessor linked to null successor\n' + +# A read-only phase that acquired a lease would be a silent authority +# escalation, so the null assertion must not accept a populated epoch. +if assert_no_writer_epoch "$work/claim.json" 2>/dev/null; then + echo 'FAIL assertion accepted: read-only check passed a populated epoch' >&2 + exit 1 +fi +printf 'ok rejected: read-only check against a populated epoch\n' + +# jq reads a missing property as null, so a first-epoch report that simply +# stops emitting the predecessor fields would satisfy an assertion written only +# as "== null". Absence of evidence must not read as evidence of absence. +for missing in previousEpochId previousEpochFinalCommitDigest startedAtLsn epochId +do + jq "del(.writerEpoch.$missing)" "$work/request.json" >"$work/first-missing.json" + if assert_first_writer_epoch "$work/first-missing.json" 2>/dev/null; then + printf 'FAIL assertion accepted: first epoch missing %s\n' "$missing" >&2 + exit 1 + fi + printf 'ok rejected: first epoch missing %s\n' "$missing" +done + +# The same for the chained assertion. +for missing in previousEpochId previousEpochFinalCommitDigest startedAtLsn epochId +do + jq "del(.writerEpoch.$missing)" "$work/claim.json" >"$work/chain-missing.json" + if assert_chained_writer_epoch "$work/chain-missing.json" "$work/request.json" \ + 2>/dev/null + then + printf 'FAIL assertion accepted: chained epoch missing %s\n' "$missing" >&2 + exit 1 + fi + printf 'ok rejected: chained epoch missing %s\n' "$missing" +done + +# The first-epoch assertion must not accept an epoch that already has a +# predecessor, which would hide a WAL that was not actually fresh. +if assert_first_writer_epoch "$work/claim.json" 2>/dev/null; then + echo 'FAIL assertion accepted: first-epoch check passed a chained epoch' >&2 + exit 1 +fi +printf 'ok rejected: first-epoch check against a chained epoch\n' + +echo "writer epoch assertions: all cases passed"