From b0a796dc3dbb6e3a762eb7c842b2ca4052fb26a8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 03:53:11 -0700 Subject: [PATCH 01/48] test: require basis-bound patch witness --- tests/patch-runtime.sh | 538 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100755 tests/patch-runtime.sh diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh new file mode 100755 index 0000000..00ef0bf --- /dev/null +++ b/tests/patch-runtime.sh @@ -0,0 +1,538 @@ +#!/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 + +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' +} + +make_case() { + case_file=$1 + worldline_byte=$2 + path=$3 + before_hex=$4 + replacement_hex=$5 + permitted_path=$6 + max_settlement_bytes=$7 + max_file_bytes=$8 + jq -n \ + --argjson worldline_byte "$worldline_byte" \ + --arg path "$path" \ + --arg before_hex "$before_hex" \ + --arg replacement_hex "$replacement_hex" \ + --arg permitted_path "$permitted_path" \ + --argjson max_settlement_bytes "$max_settlement_bytes" \ + --argjson max_file_bytes "$max_file_bytes" \ + '{ + worldlineByte: $worldline_byte, + intent: "applyValidated", + scope: ("patch-scope-" + ($worldline_byte | tostring)), + proposal: { + path: $path, + replacementBytesHex: $replacement_hex + }, + observation: { + path: $path, + bytesHex: $before_hex + }, + permittedPaths: [$permitted_path], + maxSettlementBytes: $max_settlement_bytes, + maxFileBytes: $max_file_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 +} + +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" \ + 65536 + 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" \ + '.settlement.kind == "succeeded" + and .settlement.patch.status == "succeeded" + and .settlement.patch.path == $path + and (.settlement.patch.beforeContentDigest | test("^[0-9a-f]{64}$")) + and (.settlement.patch.afterContentDigest | test("^[0-9a-f]{64}$")) + and (.settlement.patch.resultingBasis | test("^[0-9a-f]{64}$")) + 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 +} + +# 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 \ + 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" + +run_phase inspect "$golden_case" "$golden_wal" "$golden_root/request-recovery.json" +assert_posture "$golden_root/request-recovery.json" inspect requested 1 + +run_phase claim "$golden_case" "$golden_wal" "$golden_root/claim-report.json" +assert_posture "$golden_root/claim-report.json" claim claimed 2 +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 + +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 +test "$(cat "$golden_workspace/$golden_path")" = "$golden_replacement" + +# Exact retry is effect-free; a conflicting retry obstructs without WAL growth. +run_phase retry "$golden_case" "$golden_wal" "$golden_root/retry-report.json" exact +jq -e ' + .phase == "retry" + and .retry == "idempotent" + and .posture == "settled" + 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 .wal.commitCountBefore == 3 + and .wal.commitCountAfter == 3 +' "$golden_root/conflict-report.json" >/dev/null + +# 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 \ + 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 +jq -e ' + .settlement.kind == "succeeded" + and .settlement.patch.beforeContentDigest == null + and (.settlement.patch.afterContentDigest | test("^[0-9a-f]{64}$")) +' "$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 \ + 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 +jq -e ' + .settlement.kind == "outcomeUnknown" + and .settlement.patch.status == "outcomeUnknown" + and .settlement.patch.obstruction == "postcondition-not-observed" +' "$unknown_root/reconcile-report.json" >/dev/null +test "$(cat "$unknown_workspace/ambiguous.txt")" = ambiguous + +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 \ + 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 parent-escape 86 ../secret.txt allowed.txt absent replaced invalid-path absent +assert_rejected_case symlink 87 link.txt link.txt before after symlink-refused symlink +assert_rejected_case ci-workflow 88 .github/workflows/ci.yml .github/workflows/ci.yml 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/ci-workflow/workspace/.github/workflows/ci.yml")" = before + +# Settlement-size boundary: the exact encoded result size 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" +) +complete_success_case exact-boundary 90 before boundary exact.txt "$boundary_result_bytes" + +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_result_bytes - 1))" \ + 65536 +run_phase request "$under_root/request.json" "$under_root/wal" "$under_root/request-report.json" +run_phase claim "$under_root/request.json" "$under_root/wal" "$under_root/claim-report.json" +run_phase \ + apply \ + "$under_root/request.json" \ + "$under_root/wal" \ + "$under_root/settlement-report.json" \ + "$under_root/workspace" +jq -e ' + .settlement.kind == "rejected" + and .settlement.patch.obstruction == "settlement-budget-exceeded" +' "$under_root/settlement-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 +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 \ + 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 \ + 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 \ + 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" +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 producer checkout paths. +if grep -R -F \ + -e "$(CDPATH='' cd -- "$EDICT_REPO" && pwd -P)" \ + -e "$(CDPATH='' cd -- "$ECHO_REPO" && pwd -P)" \ + "$patch_root"/*/*.json \ + >/dev/null +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 replay, 2 reconciliation outcomes, 5 refusals, 1 boundary probe, 2 boundaries, 2 artifact refusals, 3 fixed-seed property, 8 stress" From 2d03db5cc13ead507acd552a0fdd2ce33d3d59f9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:00:17 -0700 Subject: [PATCH 02/48] test: bind patch refusal boundaries --- tests/patch-runtime.sh | 59 ++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 00ef0bf..40ae6eb 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -371,14 +371,41 @@ assert_rejected_case() { # 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 parent-escape 86 ../secret.txt allowed.txt absent replaced invalid-path absent assert_rejected_case symlink 87 link.txt link.txt before after symlink-refused symlink -assert_rejected_case ci-workflow 88 .github/workflows/ci.yml .github/workflows/ci.yml before after ci-workflow-refused regular +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/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 \ + 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 + # Settlement-size boundary: the exact encoded result size succeeds; one byte # less refuses before mutation. complete_success_case boundary-probe 89 before boundary exact.txt 65536 @@ -386,7 +413,11 @@ boundary_result_bytes=$( jq -r '.settlement.canonicalResultByteCount' \ "$patch_root/boundary-probe/settlement-report.json" ) -complete_success_case exact-boundary 90 before boundary exact.txt "$boundary_result_bytes" +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" @@ -398,20 +429,22 @@ make_case \ "$(hex_bytes before)" \ "$(hex_bytes boundary)" \ exact.txt \ - "$((boundary_result_bytes - 1))" \ + "$((boundary_floor - 1))" \ 65536 -run_phase request "$under_root/request.json" "$under_root/wal" "$under_root/request-report.json" -run_phase claim "$under_root/request.json" "$under_root/wal" "$under_root/claim-report.json" -run_phase \ - apply \ +if run_phase \ + request \ "$under_root/request.json" \ "$under_root/wal" \ - "$under_root/settlement-report.json" \ - "$under_root/workspace" + "$under_root/request-report.json" +then + echo "under-floor patch request unexpectedly passed" >&2 + exit 1 +fi jq -e ' - .settlement.kind == "rejected" - and .settlement.patch.obstruction == "settlement-budget-exceeded" -' "$under_root/settlement-report.json" >/dev/null + .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 From 4aeb1b88356b9eb94fd3c450ab523e25f97d0085 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:01:03 -0700 Subject: [PATCH 03/48] feat: prove basis-bound patch application --- edict.patch.application.json | 21 + patch-host/Cargo.toml.template | 16 + patch-host/src/main.rs | 770 ++++++++++++++++++ patch/src/apply-validated-patch.edict | 34 + patch/vendor/workspace-patch/adapter.cbor | Bin 0 -> 458 bytes patch/vendor/workspace-patch/adapter.sha256 | 1 + patch/vendor/workspace-patch/exports.cbor | 1 + patch/vendor/workspace-patch/exports.sha256 | 1 + patch/vendor/workspace-patch/manifest.cbor | Bin 0 -> 808 bytes patch/vendor/workspace-patch/manifest.sha256 | 1 + .../request-profile-configuration.cbor | 1 + .../request-profile-configuration.sha256 | 1 + tests/patch-build-request.jsonl | 1 + tests/patch-build.sh | 85 ++ tests/patch-run.sh | 41 + 15 files changed, 974 insertions(+) create mode 100644 edict.patch.application.json create mode 100644 patch-host/Cargo.toml.template create mode 100644 patch-host/src/main.rs create mode 100644 patch/src/apply-validated-patch.edict create mode 100644 patch/vendor/workspace-patch/adapter.cbor create mode 100644 patch/vendor/workspace-patch/adapter.sha256 create mode 100644 patch/vendor/workspace-patch/exports.cbor create mode 100644 patch/vendor/workspace-patch/exports.sha256 create mode 100644 patch/vendor/workspace-patch/manifest.cbor create mode 100644 patch/vendor/workspace-patch/manifest.sha256 create mode 100644 patch/vendor/workspace-patch/request-profile-configuration.cbor create mode 100644 patch/vendor/workspace-patch/request-profile-configuration.sha256 create mode 100644 tests/patch-build-request.jsonl create mode 100755 tests/patch-build.sh create mode 100755 tests/patch-run.sh diff --git a/edict.patch.application.json b/edict.patch.application.json new file mode 100644 index 0000000..77e90b4 --- /dev/null +++ b/edict.patch.application.json @@ -0,0 +1,21 @@ +{ + "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" + } + ], + "target": { + "profile": "echo.dpo@1", + "providerPackage": ".build/patch/echo-provider" + }, + "outputDirectory": ".build/patch/application" +} 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..c335b8a --- /dev/null +++ b/patch-host/src/main.rs @@ -0,0 +1,770 @@ +// 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, WriterEpochId, WriterEpochRequest, +}; +use warp_core::external_action::{ + claim_external_action, reconcile_external_action_settlement_retry, + record_external_action_request, ExternalActionAdapterBindingV1, ExternalActionAdapterIdV1, + ExternalActionAdapterRegistryV1, ExternalActionCoordinatorV1, + 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, + ValidatedWorkspacePatchProfileV1, ValidatedWorkspacePatchReconcilerV1, +}; +use warp_core::{Hash, WorldlineId}; + +const SEGMENT_ID: WalSegmentId = WalSegmentId::from_raw(1); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RequestCase { + worldline_byte: u8, + intent: String, + proposal: PatchProposal, + observation: WorkspaceObservation, + permitted_paths: Vec, + max_settlement_bytes: u64, + max_file_bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PatchProposal { + path: String, + replacement_bytes_hex: String, +} + +#[derive(Debug, Deserialize)] +#[serde(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_case: RequestCase = serde_json::from_slice( + &fs::read(&invocation.request_file).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + 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(_) => { + 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 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) +} + +fn application_input(request_case: &RequestCase) -> Result, String> { + if request_case.proposal.path != request_case.observation.path { + return Err("proposal path did not match the witnessed observation path".to_owned()); + } + let before = decode_hex(&request_case.observation.bytes_hex)?; + let replacement = decode_hex(&request_case.proposal.replacement_bytes_hex)?; + let patch = encode_validated_workspace_patch_input_v1( + request_case.proposal.path.clone(), + blake3::hash(&before).into(), + replacement, + ) + .map_err(|error| format!("validated patch encoding failed: {error:?}"))?; + 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(|error| format!("application input encoding failed: {error:?}")) +} + +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 = open_write_store(&invocation.wal_dir)?; + let mut coordinator = recover(&store)?; + record_external_action_request( + &mut store, + &mut coordinator, + transaction_context("request", admitted), + admitted.request(), + ) + .map_err(|error| format!("request admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + ) +} + +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 = 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, request_case.max_file_bytes); + 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), + 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, + ) +} + +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)?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + ) +} + +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 = 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, request_case.max_file_bytes), + ) + .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), + admitted, + grant, + candidate, + ) + .map_err(|error| format!("settlement admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + ) +} + +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 = 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, request_case.max_file_bytes), + ) + .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), + admitted, + grant, + candidate, + ) + .map_err(|error| format!("reconciled settlement admission failed: {error:?}"))?; + print_report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + ) +} + +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, + )?; + 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)) + } + Err(_) if retry_mode == "conflict-kind" => { + let mut report = report( + &invocation.phase, + request_case, + admitted, + &store, + &coordinator, + )? + .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, +) -> Result<(), String> { + print_json(&report(phase, request_case, admitted, store, coordinator)?) +} + +fn report( + phase: &str, + request_case: &RequestCase, + admitted: &AdmittedEdictExternalActionRequestV1, + store: &FilesystemWalStore, + coordinator: &ExternalActionCoordinatorV1, +) -> 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), + "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", + }; + Ok(json!({ + "phase": phase, + "requestId": hex(&request.request_id().as_hash()), + "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()}, + "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")?, + "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_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, + max_file_bytes: u64, +) -> ValidatedWorkspacePatchProfileV1 { + let request = admitted.request(); + ValidatedWorkspacePatchProfileV1 { + operation_id: request.operation_id, + input_schema_digest: request.input_schema_digest, + settlement_schema_digest: request.settlement_schema_digest, + reconciliation_law_digest: request.reconciliation_law_digest, + authority_scope_digest: request.authority_scope_digest, + adapter_id: adapter_id(), + max_file_bytes, + } +} + +fn open_write_store(path: &Path) -> Result { + let mut store = open_read_store(path)?; + store + .acquire_writer_epoch(WriterEpochRequest { + epoch_id: epoch_id(), + storage_fencing_token: digest("hello-effect-patch:fencing"), + process_identity: digest("hello-effect-patch:process"), + host_identity: digest("hello-effect-patch: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-patch:lease"), + }) + .map_err(|error| format!("writer epoch acquisition failed: {error:?}"))?; + Ok(store) +} + +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 epoch_id() -> WriterEpochId { + WriterEpochId::from_hash(digest("hello-effect-patch:epoch")) +} + +fn transaction_context( + phase: &str, + admitted: &AdmittedEdictExternalActionRequestV1, +) -> ExternalActionTransactionContextV1 { + ExternalActionTransactionContextV1 { + writer_epoch: epoch_id(), + 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..43d0822 --- /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:9999999999999999999999999999999999999999999999999999999999999999" + settlement schema workspace.patch.settlement@1 + digest "sha256:8888888888888888888888888888888888888888888888888888888888888888" + authority input.authority + basis input.basis + budget + maxSettlementBytes input.maxSettlementBytes + maxAttempts input.maxAttempts + reconcile workspace.patch.reconcile@1 + digest "sha256:7777777777777777777777777777777777777777777777777777777777777777"; + return pending; +} diff --git a/patch/vendor/workspace-patch/adapter.cbor b/patch/vendor/workspace-patch/adapter.cbor new file mode 100644 index 0000000000000000000000000000000000000000..2d6f09a7e2fab381b97a05cfad1e692902e878c3 GIT binary patch literal 458 zcma*jy-EZz5C`z(Y;5h+%Cos@S5MGdPb>uCMDfthxny=PUN*^YlDl0&5b;6$d;p(9 z8xec~v9wVvEETi6hlMs)8HOSM-^?`<4qEqV@upzd0Hdy@5gL7-)rT_eYXt-Y1%~vN zn}J~>-z@4@Ids{JA=@^n^wgLqT-~-tS<}i5>S?d$d5P<-a4rcLq@(t@=N*7z9ZWTo zBAZ0YhzU3hT~gl%3d&$QH)tkOVL_5&GnFCZ=tzyfBv6Xy9pH=>8C#fG)aHs3nPOHa zQaG};aiFR{g0vd&rQo>>ks4!gGh{6ckr;ifs`cAl&Z08>A7{`8Qg^adDMA+6%6!pf zG@Jd!{Yf8b93Z4Da^RCt_h4ahX?LdaHBa_F_V1qj literal 0 HcmV?d00001 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/manifest.cbor b/patch/vendor/workspace-patch/manifest.cbor new file mode 100644 index 0000000000000000000000000000000000000000..77e56c7525f0e64ab191f0adfeac12b540c87848 GIT binary patch literal 808 zcmZ3Ll$nxWo?nz*T#%TYs#lO$lAMvAT2YW+R8qVMC|4ncCaDLN)GsqkOUX=6EiP$F zE6zwXGBt}($Xl`An9ox{<-$Rc_3{&f%9naeq;UGITYJ{GBS?4E+`{y-)S}|d{JccN z3?M5rEi<)fQEGBdVsUYH3WzRBEXgcOO)o0VNi9w-0b8$#Zaqw+E?5;dS4<%QvJwk2 z!ypbRNKMI1F442N8)#TgN@_uBUP@|Sa%O6ALvC_@E-*wglQMHMODe(cQ9ySO ziV|$jnMwfUl_VCWrqaUDdcaJrTkL+b551UutqjzAjV+HtSYRQ*V(^PCl)1 z!1Z2u{3?m5r&5h>*SP#z^#1kU^hrUTK#wM;0&PkO0bA=?1hOU{WU3x83FPT!7GX1$ zAzZ93<15e1JLfp6?PRW%U#y9hJ!G+Wt8S;;JdvV5`lYC*1{CF|W#*)UjLrfZol=nR zfSF$ORHg_?z4`iV(M^S~zgrzTR4-2{i(l*L;p3P*{iMQgmVB70&iO^Dj!Bt~xsdou w26BO}tdK~~&r8cM%1z8mPIb$yC@C#U1*$A4g``|*^q@*CjbBd literal 0 HcmV?d00001 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/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/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..ddb002c --- /dev/null +++ b/tests/patch-build.sh @@ -0,0 +1,85 @@ +#!/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 + +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 +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" \ + "$@" From 2b66446c1ab5285e12de6b17e15890627f128055 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:02:07 -0700 Subject: [PATCH 04/48] docs: record validated patch witness --- README.md | 53 ++++++++++++++++++++++++++ docs/roadmap.md | 17 +++++++-- patch/vendor/workspace-patch/SOURCE.md | 26 +++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 patch/vendor/workspace-patch/SOURCE.md diff --git a/README.md b/README.md index e5851db..9dd40f6 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,56 @@ 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 consumes the completed observation boundary +as basis evidence and applies one compiler-authored validated patch: + +```sh +EDICT_REPO=/path/to/edict \ +ECHO_REPO=/path/to/echo \ +./tests/patch-runtime.sh +``` + +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 admitted +`observation` basis. The host uses Echo's generic validated-patch encoder and +authority functions; it does not reconstruct patch policy or perform native +application semantics. Echo durably records the request and claim before only +the bounded adapter receives a workspace root. + +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; +- 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; +- compiler-artifact substitution fails at request and recovery boundaries + without hidden WAL growth; and +- fixed-seed text, Unicode, and binary replacements plus eight bounded stress + worldlines pass. + +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..f1ff128 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,11 +132,22 @@ 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 admitted +observation evidence and an exact writable aperture. 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`; +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/patch/vendor/workspace-patch/SOURCE.md b/patch/vendor/workspace-patch/SOURCE.md new file mode 100644 index 0000000..03f8c59 --- /dev/null +++ b/patch/vendor/workspace-patch/SOURCE.md @@ -0,0 +1,26 @@ +# Compiler-Owned Workspace-Patch Closure + +The checked artifacts in this directory were copied without modification from +Edict merge commit `cf8c17f917b7262be2c89fa136898e01dab7f40a`: + +- `manifest.cbor` and `manifest.sha256`; +- `exports.cbor` and `exports.sha256`; +- `adapter.cbor` and `adapter.sha256`; and +- `request-profile-configuration.cbor` and + `request-profile-configuration.sha256`. + +Edict owns these bytes. Regenerate them in Edict with: + +```sh +cargo xtask lawpack-goldens --write +cargo xtask lawpack-goldens +``` + +Then copy the eight 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. + +Hello Echo does not regenerate, reinterpret, or replace the closure. Echo owns +dynamic admission, bounded mutation, durable settlement, reconciliation, and +effect-free replay. From 40c65072e2a1a2e35b757f617464cf8d36be835b Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:02:19 -0700 Subject: [PATCH 05/48] docs: note basis-bound patch application --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 389b133..dc9344f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ 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. ### Changed From b03318f08176628675b7c1435e29e6dafe990070 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:04:46 -0700 Subject: [PATCH 06/48] test: seal patch proposal authority --- tests/patch-runtime.sh | 84 +++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 40ae6eb..29de710 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -45,7 +45,6 @@ make_case() { replacement_hex=$5 permitted_path=$6 max_settlement_bytes=$7 - max_file_bytes=$8 jq -n \ --argjson worldline_byte "$worldline_byte" \ --arg path "$path" \ @@ -53,11 +52,9 @@ make_case() { --arg replacement_hex "$replacement_hex" \ --arg permitted_path "$permitted_path" \ --argjson max_settlement_bytes "$max_settlement_bytes" \ - --argjson max_file_bytes "$max_file_bytes" \ '{ worldlineByte: $worldline_byte, intent: "applyValidated", - scope: ("patch-scope-" + ($worldline_byte | tostring)), proposal: { path: $path, replacementBytesHex: $replacement_hex @@ -67,8 +64,7 @@ make_case() { bytesHex: $before_hex }, permittedPaths: [$permitted_path], - maxSettlementBytes: $max_settlement_bytes, - maxFileBytes: $max_file_bytes + maxSettlementBytes: $max_settlement_bytes }' >"$case_file" } @@ -132,8 +128,7 @@ complete_success_case() { "$(hex_bytes "$before")" \ "$(hex_bytes "$replacement")" \ "$path" \ - "$max_settlement_bytes" \ - 65536 + "$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" @@ -176,7 +171,6 @@ make_case \ "$(hex_bytes "$golden_before")" \ "$(hex_bytes "$golden_replacement")" \ "$golden_path" \ - 65536 \ 65536 run_phase request "$golden_case" "$golden_wal" "$golden_root/request-report.json" @@ -232,6 +226,39 @@ jq -e ' and .wal.commitCountAfter == 3 ' "$golden_root/conflict-report.json" >/dev/null +# 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 run_phase \ + apply \ + "$aperture_tampered" \ + "$aperture_root/wal" \ + "$aperture_root/tampered-report.json" \ + "$aperture_workspace" +then + echo "post-claim patch aperture substitution unexpectedly passed" >&2 + exit 1 +fi +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" @@ -258,7 +285,6 @@ make_case \ "$(hex_bytes before)" \ "$(hex_bytes after)" \ "$reconcile_path" \ - 65536 \ 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" @@ -291,7 +317,6 @@ make_case \ "$(hex_bytes before)" \ "$(hex_bytes intended)" \ ambiguous.txt \ - 65536 \ 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" @@ -347,7 +372,6 @@ assert_rejected_case() { "$(hex_bytes "$before")" \ "$(hex_bytes "$replacement")" \ "$permitted_path" \ - 65536 \ 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" @@ -389,7 +413,6 @@ make_case \ "$(hex_bytes absent)" \ "$(hex_bytes replaced)" \ allowed.txt \ - 65536 \ 65536 if run_phase \ request \ @@ -406,6 +429,35 @@ jq -e ' 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 + # Settlement-size boundary: the exact encoded result size succeeds; one byte # less refuses before mutation. complete_success_case boundary-probe 89 before boundary exact.txt 65536 @@ -429,8 +481,7 @@ make_case \ "$(hex_bytes before)" \ "$(hex_bytes boundary)" \ exact.txt \ - "$((boundary_floor - 1))" \ - 65536 + "$((boundary_floor - 1))" if run_phase \ request \ "$under_root/request.json" \ @@ -461,7 +512,6 @@ make_case \ "$(hex_bytes source)" \ "$(hex_bytes target)" \ source.txt \ - 65536 \ 65536 if env PATCH_CORE_FILE="$mutated_core" ./tests/patch-run.sh \ request \ @@ -487,7 +537,6 @@ make_case \ "$(hex_bytes source)" \ "$(hex_bytes target)" \ source.txt \ - 65536 \ 65536 run_phase \ request \ @@ -528,7 +577,6 @@ do "$(hex_bytes before)" \ "$replacement_hex" \ value.bin \ - 65536 \ 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" @@ -568,4 +616,4 @@ then fi printf '%s\n' \ - "Hello Effect patch suite passed: 1 ordered golden, 1 retry, 1 conflict, 1 replay, 2 reconciliation outcomes, 5 refusals, 1 boundary probe, 2 boundaries, 2 artifact refusals, 3 fixed-seed property, 8 stress" + "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" From c92989d4a217b8480b94a5266bdba7a5a4f35f94 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:05:22 -0700 Subject: [PATCH 07/48] fix: seal patch host authority --- patch-host/src/main.rs | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index c335b8a..89fac4f 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -31,9 +31,10 @@ use warp_core::validated_workspace_patch::{ 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(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct RequestCase { worldline_byte: u8, intent: String, @@ -41,18 +42,17 @@ struct RequestCase { observation: WorkspaceObservation, permitted_paths: Vec, max_settlement_bytes: u64, - max_file_bytes: u64, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct PatchProposal { path: String, replacement_bytes_hex: String, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct WorkspaceObservation { path: String, bytes_hex: String, @@ -79,10 +79,19 @@ fn main() { fn run() -> Result<(), String> { let invocation = parse_invocation()?; - let request_case: RequestCase = serde_json::from_slice( - &fs::read(&invocation.request_file).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; + 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())?; @@ -168,6 +177,11 @@ fn application_input(request_case: &RequestCase) -> Result, String> { } let before = decode_hex(&request_case.observation.bytes_hex)?; let replacement = decode_hex(&request_case.proposal.replacement_bytes_hex)?; + let max_file_bytes = + usize::try_from(MAX_FILE_BYTES_V1).map_err(|_| "file budget was not representable")?; + if before.len() > max_file_bytes || replacement.len() > max_file_bytes { + return Err("patch data exceeded the host file budget".to_owned()); + } let patch = encode_validated_workspace_patch_input_v1( request_case.proposal.path.clone(), blake3::hash(&before).into(), @@ -247,7 +261,7 @@ fn claim_phase( let recorded = coordinator .recorded_request(request.request_id()) .map_err(|error| format!("request recovery failed: {error:?}"))?; - let profile = adapter_profile(admitted, request_case.max_file_bytes); + let profile = adapter_profile(admitted); let binding = ExternalActionAdapterBindingV1 { adapter_id: profile.adapter_id, operation_id: profile.operation_id, @@ -317,7 +331,7 @@ fn apply_phase( let adapter = ValidatedWorkspacePatchAdapterV1::open( workspace_root, request_case.permitted_paths.clone(), - adapter_profile(admitted, request_case.max_file_bytes), + adapter_profile(admitted), ) .map_err(|error| format!("adapter open failed: {error:?}"))?; let candidate = adapter @@ -360,7 +374,7 @@ fn reconcile_phase( let reconciler = ValidatedWorkspacePatchReconcilerV1::open( workspace_root, request_case.permitted_paths.clone(), - adapter_profile(admitted, request_case.max_file_bytes), + adapter_profile(admitted), ) .map_err(|error| format!("reconciler open failed: {error:?}"))?; let candidate = reconciler @@ -615,7 +629,6 @@ fn adapter_id() -> ExternalActionAdapterIdV1 { fn adapter_profile( admitted: &AdmittedEdictExternalActionRequestV1, - max_file_bytes: u64, ) -> ValidatedWorkspacePatchProfileV1 { let request = admitted.request(); ValidatedWorkspacePatchProfileV1 { @@ -625,7 +638,7 @@ fn adapter_profile( reconciliation_law_digest: request.reconciliation_law_digest, authority_scope_digest: request.authority_scope_digest, adapter_id: adapter_id(), - max_file_bytes, + max_file_bytes: MAX_FILE_BYTES_V1, } } From fd21e84f1b6408f77b91de9ee97a2bfd3c48e109 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:05:43 -0700 Subject: [PATCH 08/48] docs: define patch host authority --- README.md | 6 ++++-- docs/roadmap.md | 14 ++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9dd40f6..73b1e26 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,10 @@ package. The request JSON separates untrusted `proposal` data from the admitted `observation` basis. The host uses Echo's generic validated-patch encoder and authority functions; it does not reconstruct patch policy or perform native -application semantics. Echo durably records the request and claim before only -the bounded adapter receives a workspace root. +application semantics. The proposal and observation are closed schemas, and +the adapter's 65,536-byte file cap is host-owned rather than caller-selected. +Echo durably records the request and claim before only the bounded adapter +receives a workspace root. The runtime witness proves: diff --git a/docs/roadmap.md b/docs/roadmap.md index f1ff128..ca76045 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -135,12 +135,14 @@ Add `ApplyValidatedPatch` only after read-only observation is green: 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 admitted -observation evidence and an exact writable aperture. 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`; -and path, basis, symlink, CI-workflow, budget, and compiler-artifact violations -fail closed at their owning boundaries. +observation evidence and an exact writable aperture. Model-facing fields are a +closed schema, while the file cap remains host-owned and cannot be substituted +after claim. 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`; 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. From 48e82a0302c033aef342fea0548a0e45c2f71c49 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:06:32 -0700 Subject: [PATCH 09/48] test: require patch settlement bindings --- tests/patch-runtime.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 29de710..5f25abe 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -144,6 +144,12 @@ complete_success_case() { and (.settlement.patch.beforeContentDigest | test("^[0-9a-f]{64}$")) and (.settlement.patch.afterContentDigest | test("^[0-9a-f]{64}$")) 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}$")) @@ -300,6 +306,9 @@ jq -e ' .settlement.kind == "succeeded" and .settlement.patch.beforeContentDigest == null and (.settlement.patch.afterContentDigest | test("^[0-9a-f]{64}$")) + 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 @@ -332,6 +341,9 @@ 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 From 1619b1b852d61e1e6e5eb82b3bf522c71ebc4189 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:07:04 -0700 Subject: [PATCH 10/48] fix: expose patch settlement bindings --- patch-host/src/main.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index 89fac4f..1061220 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -519,6 +519,12 @@ fn report( .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(), @@ -568,6 +574,8 @@ fn decode_patch_settlement(bytes: &[u8]) -> Result { 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", @@ -604,6 +612,13 @@ fn canonical_text_field(value: &CanonicalValueV1, field: &str) -> Result(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), From 4c16dcfab68cd6c72bede0d062a4fb066bddd33b Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:07:22 -0700 Subject: [PATCH 11/48] docs: define patch settlement evidence --- README.md | 4 +++- docs/roadmap.md | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 73b1e26..6791baa 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ The runtime witness proves: authority; - the adapter can mutate only an exact permitted path under the admitted observation basis; -- the canonical settlement commits before the result is reported; +- 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index ca76045..6e1020d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -140,9 +140,10 @@ closed schema, while the file cap remains host-owned and cannot be substituted after claim. 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`; and path, basis, symlink, CI-workflow, -budget, and compiler-artifact violations fail closed at their owning -boundaries. +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. From d8fcafdafa373a6d664f6e2690f7886e91f16607 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:10:18 -0700 Subject: [PATCH 12/48] docs: scope patch observation input --- README.md | 22 +++++++++++++--------- docs/roadmap.md | 23 +++++++++++++---------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6791baa..09f4487 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,8 @@ and no native Hello Echo callback may implement application semantics. ## Hello Effect validated patch application -The second external-effect proof consumes the completed observation boundary -as basis evidence and applies one compiler-authored validated patch: +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 \ @@ -178,13 +178,17 @@ sidecars, Core artifact, and Target IR artifact for provider receives no filesystem authority and emits no executable-operation package. -The request JSON separates untrusted `proposal` data from the admitted -`observation` basis. The host uses Echo's generic validated-patch encoder and -authority functions; it does not reconstruct patch policy or perform native -application semantics. The proposal and observation are closed schemas, and -the adapter's 65,536-byte file cap is host-owned rather than caller-selected. -Echo durably records the request and claim before only the bounded adapter -receives a workspace root. +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. +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: diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e1020d..44cf62b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -134,16 +134,19 @@ Add `ApplyValidatedPatch` only after read-only observation is green: 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 admitted -observation evidence and an exact writable aperture. Model-facing fields are a -closed schema, while the file cap remains host-owned and cannot be substituted -after claim. 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. +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. From e99a9224ea75a96e40e16134c42a01e4ea8e0c7e Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 30 Jul 2026 04:28:22 -0700 Subject: [PATCH 13/48] test: harden patch consumer witness --- README.md | 2 +- tests/patch-runtime.sh | 51 +++++++++++++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 09f4487..3688ab1 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ The runtime witness proves: policy failures obstruct before mutation; - the exact request-only settlement floor passes and one byte less refuses before a WAL commit; -- compiler-artifact substitution fails at request and recovery boundaries +- compiler-artifact substitution fails at request and claim boundaries without hidden WAL growth; and - fixed-seed text, Unicode, and binary replacements plus eight bounded stress worldlines pass. diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 5f25abe..e8debe5 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -251,16 +251,20 @@ make_case \ 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 run_phase \ +if ./tests/patch-run.sh \ apply \ "$aperture_tampered" \ "$aperture_root/wal" \ - "$aperture_root/tampered-report.json" \ - "$aperture_workspace" + "$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 @@ -412,6 +416,8 @@ assert_rejected_case ci-workflow 88 .github/workflows/ci.yml allowed.txt before 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, @@ -470,13 +476,20 @@ jq -e ' and .wal.commitCount == 0 ' "$malformed_root/request-report.json" >/dev/null -# Settlement-size boundary: the exact encoded result size succeeds; one byte -# less refuses before mutation. +# 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 @@ -515,6 +528,10 @@ test "$(cat "$under_root/workspace/exact.txt")" = before 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 \ @@ -616,13 +633,25 @@ while test "$stress_ordinal" -le "$stress_count"; do stress_ordinal=$((stress_ordinal + 1)) done -# Retained evidence must not disclose producer checkout paths. -if grep -R -F \ - -e "$(CDPATH='' cd -- "$EDICT_REPO" && pwd -P)" \ - -e "$(CDPATH='' cd -- "$ECHO_REPO" && pwd -P)" \ - "$patch_root"/*/*.json \ +# 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 -then +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 From 5c977c317844abd7ed4cc7b502db0a7a898db182 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 03:52:18 -0700 Subject: [PATCH 14/48] test: require resolved schema identities and chained writer epochs Both witnesses accepted the two defects CodeRabbit found on #24. The build boundaries compared the vendored closure without checking that any external-action schema identity resolved, so the all-9/8/7 and all-b/c/d sentinel digests in the compiler sources passed. The build now refuses a malformed or sentinel identity, and requires each vendored resource sidecar identity to appear in the compiler source it claims to bind. The runtime boundaries never observed the writer epoch, so a static epoch identity with no predecessor linkage was indistinguishable from a durably fenced one. The golden paths now require a fresh epoch per write phase, exact predecessor and final-commit-digest linkage, a strictly advancing start LSN, no epoch on read-only phases, no epoch reused across the ordered path, and a persisted ledger and writer lease that stay bounded. --- tests/effect-build.sh | 34 +++++++++++++++++++++- tests/effect-runtime.sh | 63 ++++++++++++++++++++++++++++++++++++++++ tests/patch-build.sh | 43 ++++++++++++++++++++++++++- tests/patch-runtime.sh | 64 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 2 deletions(-) diff --git a/tests/effect-build.sh b/tests/effect-build.sh index eedcbac..c3aa4ab 100755 --- a/tests/effect-build.sh +++ b/tests/effect-build.sh @@ -18,11 +18,43 @@ 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 +# Every external-action resource identity in the compiler source must resolve to +# a vendored artifact whose generator-owned digest sidecar carries that exact +# identity. An unresolved or sentinel identity fails the build closed. +for resource in input-schema settlement-schema reconciliation-law +do + identity=$(tr -d '[:space:]' <"effect/vendor/workspace-snapshot/$resource.sha256") + case "$identity" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) + echo "resource $resource has a malformed identity digest" >&2 + exit 1 + ;; + esac + grep -qF "$identity" effect/src/observe-workspace.edict || { + echo "compiler source does not pin the vendored $resource identity" >&2 + exit 1 + } +done + +# The compiler source must not retain any unresolved schema reference. +if grep -nE 'digest "sha256:(([0-9a-f])\2{63})"' effect/src/observe-workspace.edict +then + echo "compiler source retains a sentinel schema digest" >&2 + exit 1 +fi + mkdir -p .build/effect provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" test -f "$provider_source/provider-manifest.echo.json" diff --git a/tests/effect-runtime.sh b/tests/effect-runtime.sh index 0d38571..679e77c 100755 --- a/tests/effect-runtime.sh +++ b/tests/effect-runtime.sh @@ -100,6 +100,43 @@ assert_identity() { ' "$report_file" >/dev/null } +# 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 -e ' + (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + and .writerEpoch.previousEpochId == null + and .writerEpoch.previousEpochFinalCommitDigest == null + 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" \ + ' + (.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.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 -e '.writerEpoch == null' "$report_file" >/dev/null +} + assert_posture() { report_file=$1 phase=$2 @@ -182,6 +219,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 +240,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 +262,21 @@ 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, and the +# retained ledger stays bounded rather than growing per restart. +test "$( + jq -r '.writerEpoch.epochId' \ + "$golden_root/request-report.json" \ + "$golden_root/claim-report.json" \ + "$golden_settlement" | + sort -u | + wc -l | + tr -d ' ' +)" = 3 +test "$(wc -c <"$golden_wal/writer-epochs.ecwal" | tr -d ' ')" -le 4096 + jq -e \ --arg path "$golden_path" \ --arg bytes_hex "$(hex_bytes "$golden_value")" \ diff --git a/tests/patch-build.sh b/tests/patch-build.sh index ddb002c..d5340f7 100755 --- a/tests/patch-build.sh +++ b/tests/patch-build.sh @@ -22,11 +22,52 @@ for artifact in \ adapter.cbor \ adapter.sha256 \ request-profile-configuration.cbor \ - request-profile-configuration.sha256 + 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 +# Every external-action resource identity in the compiler source must resolve to +# a vendored artifact whose generator-owned digest sidecar carries that exact +# identity. An unresolved or sentinel identity fails the build closed. +for resource in input-schema settlement-schema reconciliation-law +do + identity=$(tr -d '[:space:]' <"patch/vendor/workspace-patch/$resource.sha256") + case "$identity" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) + echo "resource $resource has a malformed identity digest" >&2 + exit 1 + ;; + esac + case "$identity" in + *0000000000000000|*1111111111111111|*2222222222222222|*3333333333333333|\ + *4444444444444444|*5555555555555555|*6666666666666666|*7777777777777777|\ + *8888888888888888|*9999999999999999) + echo "resource $resource still pins a sentinel identity digest" >&2 + exit 1 + ;; + esac + grep -qF "$identity" patch/src/apply-validated-patch.edict || { + echo "compiler source does not pin the vendored $resource identity" >&2 + exit 1 + } +done + +# The compiler source must not retain any unresolved schema reference. +if grep -nE 'digest "sha256:(0{64}|1{64}|2{64}|3{64}|4{64}|5{64}|6{64}|7{64}|8{64}|9{64})"' \ + patch/src/apply-validated-patch.edict +then + echo "compiler source retains a sentinel schema digest" >&2 + exit 1 +fi + mkdir -p .build/patch provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" test -f "$provider_source/provider-manifest.echo.json" diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index e8debe5..c05701d 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -92,6 +92,43 @@ assert_identity() { ' "$report_file" >/dev/null } +# 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 -e ' + (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + and .writerEpoch.previousEpochId == null + and .writerEpoch.previousEpochFinalCommitDigest == null + 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" \ + ' + (.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.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 -e '.writerEpoch == null' "$report_file" >/dev/null +} + assert_posture() { report_file=$1 phase=$2 @@ -183,15 +220,26 @@ 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 \ @@ -201,14 +249,29 @@ run_phase \ "$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, and the +# retained ledger stays bounded rather than growing per restart. +test "$( + jq -r '.writerEpoch.epochId' \ + "$golden_root/request-report.json" \ + "$golden_root/claim-report.json" \ + "$golden_settlement" | + sort -u | + wc -l | + tr -d ' ' +)" = 3 +test "$(wc -c <"$golden_wal/writer-epochs.ecwal" | tr -d ' ')" -le 4096 + # Exact retry is effect-free; a conflicting retry obstructs without WAL growth. 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 @@ -228,6 +291,7 @@ 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 From c6ec2961e4da6becfa79cc8da73a24b20a3aee20 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 03:52:28 -0700 Subject: [PATCH 15/48] fix: bind external requests to vendored schema resources Advances both vendored closures to Edict df80f92a, which owns canonical edict.external-action-resource/v1 artifacts for the request input, settlement, and reconciliation identities and pins their exact digests in the generator's own source. Hello Echo byte-corroborates those artifacts rather than manufacturing them: the three new artifacts and their generator-owned identity sidecars are vendored per closure, and both application configs supply the exact artifact paths through externalActionResources so Edict recomputes and validates the complete closure before publishing Core or Target IR. Replacing the sentinel identities locally would have invented the compiler contract in the consumer, which is why #24 deferred this to Edict #180. Closed #180 upstream. --- edict.effect.application.json | 11 ++++++++ edict.patch.application.json | 11 ++++++++ effect/src/observe-workspace.edict | 6 ++--- .../workspace-snapshot/input-schema.cbor | 1 + .../workspace-snapshot/input-schema.sha256 | 1 + .../reconciliation-law.cbor | 1 + .../reconciliation-law.sha256 | 1 + .../workspace-snapshot/settlement-schema.cbor | 1 + .../settlement-schema.sha256 | 1 + patch/src/apply-validated-patch.edict | 6 ++--- patch/vendor/workspace-patch/SOURCE.md | 25 +++++++++++++++---- .../vendor/workspace-patch/input-schema.cbor | 1 + .../workspace-patch/input-schema.sha256 | 1 + .../workspace-patch/reconciliation-law.cbor | 1 + .../workspace-patch/reconciliation-law.sha256 | 1 + .../workspace-patch/settlement-schema.cbor | 1 + .../workspace-patch/settlement-schema.sha256 | 1 + 17 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 effect/vendor/workspace-snapshot/input-schema.cbor create mode 100644 effect/vendor/workspace-snapshot/input-schema.sha256 create mode 100644 effect/vendor/workspace-snapshot/reconciliation-law.cbor create mode 100644 effect/vendor/workspace-snapshot/reconciliation-law.sha256 create mode 100644 effect/vendor/workspace-snapshot/settlement-schema.cbor create mode 100644 effect/vendor/workspace-snapshot/settlement-schema.sha256 create mode 100644 patch/vendor/workspace-patch/input-schema.cbor create mode 100644 patch/vendor/workspace-patch/input-schema.sha256 create mode 100644 patch/vendor/workspace-patch/reconciliation-law.cbor create mode 100644 patch/vendor/workspace-patch/reconciliation-law.sha256 create mode 100644 patch/vendor/workspace-patch/settlement-schema.cbor create mode 100644 patch/vendor/workspace-patch/settlement-schema.sha256 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 index 77e90b4..5774992 100644 --- a/edict.patch.application.json +++ b/edict.patch.application.json @@ -13,6 +13,17 @@ "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" 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/src/apply-validated-patch.edict b/patch/src/apply-validated-patch.edict index 43d0822..6e11a3d 100644 --- a/patch/src/apply-validated-patch.edict +++ b/patch/src/apply-validated-patch.edict @@ -20,15 +20,15 @@ intent applyValidated(input: ApplyPatchInput) request pending: ExternalActionRequest> = patch(input.patch) input schema workspace.patch.input@1 - digest "sha256:9999999999999999999999999999999999999999999999999999999999999999" + digest "sha256:a815f7baa77c260f9c84a73552b6cab244900fcf27db7d8384d473a59c7e8607" settlement schema workspace.patch.settlement@1 - digest "sha256:8888888888888888888888888888888888888888888888888888888888888888" + digest "sha256:b74398fa5a7a997ccf3af3ee225bb2ef6eb776182d32789d7f8252eadb983a4d" authority input.authority basis input.basis budget maxSettlementBytes input.maxSettlementBytes maxAttempts input.maxAttempts reconcile workspace.patch.reconcile@1 - digest "sha256:7777777777777777777777777777777777777777777777777777777777777777"; + digest "sha256:efa7abd9a5f485994aab71ca796c9762b0f7676262b847750d5310e435da3194"; return pending; } diff --git a/patch/vendor/workspace-patch/SOURCE.md b/patch/vendor/workspace-patch/SOURCE.md index 03f8c59..cec6e31 100644 --- a/patch/vendor/workspace-patch/SOURCE.md +++ b/patch/vendor/workspace-patch/SOURCE.md @@ -1,13 +1,27 @@ # Compiler-Owned Workspace-Patch Closure The checked artifacts in this directory were copied without modification from -Edict merge commit `cf8c17f917b7262be2c89fa136898e01dab7f40a`: +Edict merge commit `df80f92ad6242c6da31a64224666fd37aa43b0d0`: - `manifest.cbor` and `manifest.sha256`; - `exports.cbor` and `exports.sha256`; -- `adapter.cbor` and `adapter.sha256`; and +- `adapter.cbor` and `adapter.sha256`; - `request-profile-configuration.cbor` and - `request-profile-configuration.sha256`. + `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: @@ -16,10 +30,11 @@ cargo xtask lawpack-goldens --write cargo xtask lawpack-goldens ``` -Then copy the eight exact files and +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. +selected Edict checkout before invoking the public application build, and +refuses any unresolved or sentinel schema identity. Hello Echo does not regenerate, reinterpret, or replace the closure. Echo owns dynamic admission, bounded mutation, durable settlement, reconciliation, and 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/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/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 From 61f41f61d4e42ffc5e04b7cfd1dd136f3165ead0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 03:52:40 -0700 Subject: [PATCH 16/48] fix: acquire fresh writer epochs from the producer Both hosts hard-coded one writer epoch: a static epoch id, fixed fencing, process, host, and lease digests, and previous_epoch_id: None, re-acquired identically on every phase. Each phase is a separate process, so that handed the same fencing identity to restarted and overlapping hosts and defeated the point of epoch fencing for durable settlement. Echo c354d531 now persists the writer-epoch ledger across restarts and exposes FilesystemWalStore::acquire_fresh_writer_epoch, which takes the filesystem writer lease, rereads the ledger, closes an epoch left by a terminated process, and derives the successor from that predecessor's identity and final commit digest. Both hosts now call it and bind the acquired epoch into every transaction context instead of a constant. Hello Echo derives no epoch identity and reuses no fencing token. Write phases project the acquired epoch chain into their reports so the invariant is observable; read-only phases take no lease and report none. Against the advanced Echo pin the previous code no longer even starts: the persisted ledger rejects re-acquiring the static epoch with WriterEpochAlreadyActive. --- effect-host/src/main.rs | 89 ++++++++++++++++++++++++++++------------- patch-host/src/main.rs | 89 ++++++++++++++++++++++++++++------------- 2 files changed, 122 insertions(+), 56 deletions(-) diff --git a/effect-host/src/main.rs b/effect-host/src/main.rs index 312a358..5dcd8b1 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(), @@ -619,21 +651,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 +689,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/patch-host/src/main.rs b/patch-host/src/main.rs index 1061220..3431ea5 100644 --- a/patch-host/src/main.rs +++ b/patch-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, @@ -229,12 +229,12 @@ fn request_phase( if invocation.argument.is_some() { return Err("request does not accept workspace 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)?; 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:?}"))?; @@ -244,6 +244,7 @@ fn request_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -255,7 +256,7 @@ fn claim_phase( if invocation.argument.is_some() { return Err("claim does not accept workspace 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 request = admitted.request(); let recorded = coordinator @@ -274,7 +275,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, @@ -288,6 +289,7 @@ fn claim_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -304,12 +306,14 @@ fn inspect_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, ) } @@ -323,7 +327,7 @@ fn apply_phase( .as_ref() .map(Path::new) .ok_or_else(|| "apply requires a workspace root".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()) @@ -341,7 +345,7 @@ fn apply_phase( .admit_settlement( &mut store, &mut coordinator, - transaction_context("settlement", admitted), + transaction_context("settlement", admitted, writer_epoch.epoch_id), admitted, grant, candidate, @@ -353,6 +357,7 @@ fn apply_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -366,7 +371,7 @@ fn reconcile_phase( .as_ref() .map(Path::new) .ok_or_else(|| "reconcile requires a workspace root".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()) @@ -384,7 +389,7 @@ fn reconcile_phase( .admit_settlement( &mut store, &mut coordinator, - transaction_context("settlement", admitted), + transaction_context("settlement", admitted, writer_epoch.epoch_id), admitted, grant, candidate, @@ -396,6 +401,7 @@ fn reconcile_phase( admitted, &store, &coordinator, + Some(&writer_epoch), ) } @@ -439,6 +445,7 @@ fn retry_phase( admitted, &store, &coordinator, + None, )?; let mut report = report .as_object() @@ -465,6 +472,7 @@ fn retry_phase( admitted, &store, &coordinator, + None, )? .as_object() .cloned() @@ -492,8 +500,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( @@ -502,6 +518,7 @@ fn report( admitted: &AdmittedEdictExternalActionRequestV1, store: &FilesystemWalStore, coordinator: &ExternalActionCoordinatorV1, + writer_epoch: Option<&WriterEpoch>, ) -> Result { let request = admitted.request(); let recovered = coordinator @@ -544,9 +561,24 @@ 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, "requestId": hex(&request.request_id().as_hash()), + "writerEpoch": writer_epoch, "compiler": { "coreDigest": admitted.source_core_digest(), "targetIrDigest": admitted.target_ir_digest(), @@ -657,21 +689,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-patch:fencing"), - process_identity: digest("hello-effect-patch:process"), - host_identity: digest("hello-effect-patch: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-patch: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 { @@ -691,16 +727,13 @@ fn recover(store: &FilesystemWalStore) -> Result WriterEpochId { - WriterEpochId::from_hash(digest("hello-effect-patch: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-patch:{phase}:{}", From 8f4e30e556ed020bd1d55aec0d051be12586e1cc Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 03:52:46 -0700 Subject: [PATCH 17/48] docs: record resolved schema identities and epoch fencing Updates the vendored closure provenance to the Edict commit that owns the canonical external-action resources, states that the sidecars carry generator-owned resource identities rather than digests of the enclosing files, and records that the build refuses unresolved or sentinel identities. Documents writer-epoch fencing as producer-owned in both witness sections and adds the new witness guarantees to the changelog. --- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 28 ++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9344f..cc81a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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. +- Build refusal for unresolved or sentinel external-action schema identities in + the vendored compiler 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 3688ab1..6f3304e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,15 @@ bounded workspace adapter. It proves: - the exact settlement-size boundary succeeds and one byte less refuses; and - 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`. An unresolved or +sentinel schema identity fails the build closed. The fixed suite contains one ordered golden path, one relative compiler-artifact path probe, one idempotent retry, one conflicting retry, one @@ -213,10 +221,26 @@ The runtime witness proves: - the exact request-only settlement floor passes and one byte less refuses before a WAL commit; - compiler-artifact substitution fails at request and claim boundaries - without hidden WAL growth; and + 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`. An unresolved or sentinel schema identity fails the +build closed. + +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 From 710623f47f7d36764d90b0344a4572672b657e3e Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:07:15 -0700 Subject: [PATCH 18/48] Fix: bind each schema slot to its vendored artifact identity The build guard proved only that a vendored identity appeared somewhere in the compiler source. Swapping the workspace.patch.input@1 and workspace.patch.settlement@1 digests in the source left every identity present and every one resolving to a real artifact, so all three resources passed. A cross-wired closure is exactly what content-addressed identities exist to prevent, and the guard could not see it. Extracts one implementation to tests/lib/check-resource-identities.sh, which reads the digest each slot declares and requires it to equal that slot's own sidecar. Both build boundaries now call it instead of carrying divergent inline copies. Adds tests/resource-identity-guard.sh, which exercises the guard against a matching closure, a two-slot swap, a three-slot rotation, and a foreign identity. It needs no producer checkout and no cargo, so it can run in CI before the pinning work in #25 lands. --- CHANGELOG.md | 7 +- tests/effect-build.sh | 32 ++------ tests/lib/check-resource-identities.sh | 77 +++++++++++++++++++ tests/patch-build.sh | 39 ++-------- tests/resource-identity-guard.sh | 101 +++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 60 deletions(-) create mode 100755 tests/lib/check-resource-identities.sh create mode 100755 tests/resource-identity-guard.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index cc81a15..0b9c24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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. -- Build refusal for unresolved or sentinel external-action schema identities in - the vendored compiler source. +- Build refusal for cross-wired, unresolved, or sentinel external-action schema + identities in the vendored compiler source, enforced by a single shared guard + that binds each schema slot to the vendored artifact it names. +- Hermetic `tests/resource-identity-guard.sh` covering that guard against + crafted closures, requiring no producer checkout and no `cargo`. ### Changed diff --git a/tests/effect-build.sh b/tests/effect-build.sh index c3aa4ab..9856db0 100755 --- a/tests/effect-build.sh +++ b/tests/effect-build.sh @@ -29,31 +29,13 @@ do cmp "effect/vendor/workspace-snapshot/$artifact" "$fixture_source/$artifact" done -# Every external-action resource identity in the compiler source must resolve to -# a vendored artifact whose generator-owned digest sidecar carries that exact -# identity. An unresolved or sentinel identity fails the build closed. -for resource in input-schema settlement-schema reconciliation-law -do - identity=$(tr -d '[:space:]' <"effect/vendor/workspace-snapshot/$resource.sha256") - case "$identity" in - sha256:????????????????????????????????????????????????????????????????) ;; - *) - echo "resource $resource has a malformed identity digest" >&2 - exit 1 - ;; - esac - grep -qF "$identity" effect/src/observe-workspace.edict || { - echo "compiler source does not pin the vendored $resource identity" >&2 - exit 1 - } -done - -# The compiler source must not retain any unresolved schema reference. -if grep -nE 'digest "sha256:(([0-9a-f])\2{63})"' effect/src/observe-workspace.edict -then - echo "compiler source retains a sentinel schema digest" >&2 - exit 1 -fi +# Every external-action schema slot in the compiler source must pin the exact +# identity of the vendored artifact that slot names. A cross-wired, unresolved, +# or sentinel identity fails the build closed. +tests/lib/check-resource-identities.sh \ + workspace.snapshot \ + effect/vendor/workspace-snapshot \ + effect/src/observe-workspace.edict mkdir -p .build/effect provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh new file mode 100755 index 0000000..9e70566 --- /dev/null +++ b/tests/lib/check-resource-identities.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +# Usage: check-resource-identities.sh +# +# Proves that every external-action schema slot in a compiler source pins the +# exact identity of the vendored artifact that slot names. +# +# The identity a slot declares is what Edict resolves against the artifact +# supplied through `externalActionResources`. Checking only that an identity +# appears somewhere in the source would accept a source whose slots are wired +# to each other's artifacts, so each slot is compared to its own sidecar. +# +# Exercised by tests/resource-identity-guard.sh. + +namespace=$1 +vendor=$2 +source_file=$3 + +test -d "$vendor" +test -f "$source_file" + +# Reads the digest a slot declares. The coordinate names the slot and the +# digest follows it on a later line, so this takes the first digest after the +# coordinate and stops. +declared_identity() { + awk -v coordinate="$1" ' + seen { + if (match($0, /digest "[^"]*"/)) { + print substr($0, RSTART + 8, RLENGTH - 9) + exit + } + next + } + index($0, coordinate) > 0 { seen = 1 } + ' "$2" +} + +for slot in input:input-schema settlement:settlement-schema reconcile:reconciliation-law +do + kind=${slot%%:*} + resource=${slot#*:} + coordinate="$namespace.$kind@1" + sidecar="$vendor/$resource.sha256" + + if ! test -f "$sidecar"; then + echo "resource $resource has no vendored identity sidecar" >&2 + exit 1 + fi + identity=$(tr -d '[:space:]' <"$sidecar") + + case "$identity" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) + echo "resource $resource has a malformed identity digest" >&2 + exit 1 + ;; + esac + case "$identity" in + *0000000000000000|*1111111111111111|*2222222222222222|*3333333333333333|\ + *4444444444444444|*5555555555555555|*6666666666666666|*7777777777777777|\ + *8888888888888888|*9999999999999999) + echo "resource $resource still pins a sentinel identity digest" >&2 + exit 1 + ;; + esac + + declared=$(declared_identity "$coordinate" "$source_file") + if test -z "$declared"; then + echo "compiler source declares no digest for $coordinate" >&2 + exit 1 + fi + if test "$declared" != "$identity"; then + echo "$coordinate pins $declared but $resource is $identity" >&2 + exit 1 + fi +done diff --git a/tests/patch-build.sh b/tests/patch-build.sh index d5340f7..e98f491 100755 --- a/tests/patch-build.sh +++ b/tests/patch-build.sh @@ -33,40 +33,13 @@ do cmp "patch/vendor/workspace-patch/$artifact" "$fixture_source/$artifact" done -# Every external-action resource identity in the compiler source must resolve to -# a vendored artifact whose generator-owned digest sidecar carries that exact -# identity. An unresolved or sentinel identity fails the build closed. -for resource in input-schema settlement-schema reconciliation-law -do - identity=$(tr -d '[:space:]' <"patch/vendor/workspace-patch/$resource.sha256") - case "$identity" in - sha256:????????????????????????????????????????????????????????????????) ;; - *) - echo "resource $resource has a malformed identity digest" >&2 - exit 1 - ;; - esac - case "$identity" in - *0000000000000000|*1111111111111111|*2222222222222222|*3333333333333333|\ - *4444444444444444|*5555555555555555|*6666666666666666|*7777777777777777|\ - *8888888888888888|*9999999999999999) - echo "resource $resource still pins a sentinel identity digest" >&2 - exit 1 - ;; - esac - grep -qF "$identity" patch/src/apply-validated-patch.edict || { - echo "compiler source does not pin the vendored $resource identity" >&2 - exit 1 - } -done - -# The compiler source must not retain any unresolved schema reference. -if grep -nE 'digest "sha256:(0{64}|1{64}|2{64}|3{64}|4{64}|5{64}|6{64}|7{64}|8{64}|9{64})"' \ +# Every external-action schema slot in the compiler source must pin the exact +# identity of the vendored artifact that slot names. A cross-wired, unresolved, +# or sentinel identity fails the build closed. +tests/lib/check-resource-identities.sh \ + workspace.patch \ + patch/vendor/workspace-patch \ patch/src/apply-validated-patch.edict -then - echo "compiler source retains a sentinel schema digest" >&2 - exit 1 -fi mkdir -p .build/patch provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh new file mode 100755 index 0000000..116313f --- /dev/null +++ b/tests/resource-identity-guard.sh @@ -0,0 +1,101 @@ +#!/bin/sh +set -eu + +# Hermetic test of tests/lib/check-resource-identities.sh. +# +# The build boundaries trust that guard to prove every external-action schema +# identity in a compiler source resolves to the vendored artifact it names. A +# guard that has never been shown to reject a bad input proves nothing, so this +# exercises it against crafted closures. +# +# No producer checkout, no network, and no cargo: this runs anywhere. + +guard=tests/lib/check-resource-identities.sh +test -x "$guard" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +good_input=sha256:1111111111111111111111111111111111111111111111111111111111111112 +good_settlement=sha256:2222222222222222222222222222222222222222222222222222222222222223 +good_reconcile=sha256:3333333333333333333333333333333333333333333333333333333333333334 + +# Writes a vendor directory whose sidecars carry the three given identities. +write_vendor() { + vendor=$1 + mkdir -p "$vendor" + printf '%s\n' "$2" >"$vendor/input-schema.sha256" + printf '%s\n' "$3" >"$vendor/settlement-schema.sha256" + printf '%s\n' "$4" >"$vendor/reconciliation-law.sha256" +} + +# Writes a compiler source that pins the three given identities to the input, +# settlement, and reconcile slots in that order. +write_source() { + source_file=$1 + cat >"$source_file" <> = + patch(input.patch) + input schema workspace.patch.input@1 + digest "$2" + settlement schema workspace.patch.settlement@1 + digest "$3" + authority input.authority + basis input.basis + reconcile workspace.patch.reconcile@1 + digest "$4"; + return pending; +} +EOF +} + +passes() { + "$guard" workspace.patch "$1" "$2" >/dev/null 2>&1 +} + +expect_accept() { + if passes "$1" "$2"; then + printf 'ok accepted: %s\n' "$3" + else + printf 'FAIL rejected a valid closure: %s\n' "$3" >&2 + exit 1 + fi +} + +expect_reject() { + if passes "$1" "$2"; then + printf 'FAIL accepted an invalid closure: %s\n' "$3" >&2 + exit 1 + else + printf 'ok rejected: %s\n' "$3" + fi +} + +# Control: a closure whose every slot names its own vendored artifact. +write_vendor "$work/good" "$good_input" "$good_settlement" "$good_reconcile" +write_source "$work/good.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_accept "$work/good" "$work/good.edict" "matching identities" + +# Cross-wiring: every identity is present in the source and every one resolves +# to a real vendored artifact, but the input and settlement slots are swapped. +# A guard that only asks whether a digest appears somewhere in the file cannot +# see this. +write_source "$work/crosswired.edict" \ + "$good_settlement" "$good_input" "$good_reconcile" +expect_reject "$work/good" "$work/crosswired.edict" "input and settlement slots swapped" + +# Rotation across all three slots, so no slot keeps its own identity. +write_source "$work/rotated.edict" \ + "$good_settlement" "$good_reconcile" "$good_input" +expect_reject "$work/good" "$work/rotated.edict" "all three slots rotated" + +# A slot pinned to a digest that names no vendored artifact at all. +write_source "$work/foreign.edict" \ + "$good_input" \ + sha256:4444444444444444444444444444444444444444444444444444444444444445 \ + "$good_reconcile" +expect_reject "$work/good" "$work/foreign.edict" "settlement slot pins a foreign identity" + +echo "resource identity guard: all cases passed" From 36f919605b70239c87e34e09727a9ee43c4c9006 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:08:32 -0700 Subject: [PATCH 19/48] Fix: require a lowercase hexadecimal resource identity The identity check matched sha256: followed by any 64 characters, so sha256:gggg... was accepted as a content address. Validates the body as exactly 64 lowercase hexadecimal characters. The character class is written out rather than as the range [!0-9a-f]: a bracket range is resolved by the collating sequence of the current locale, and under en_US.UTF-8 the range a-f collates case-insensitively and admits uppercase. A range would have accepted two spellings of one identity, which is the same defect one layer down. Adds non-hexadecimal, uppercase, and truncated cases to the guard test. Reported by CodeRabbit on tests/patch-build.sh. --- tests/lib/check-resource-identities.sh | 21 ++++++++++++++++----- tests/resource-identity-guard.sh | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 9e70566..7645274 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -49,13 +49,24 @@ do fi identity=$(tr -d '[:space:]' <"$sidecar") + # Only a lowercase 64-character hexadecimal body can name a SHA-256 artifact. + # A character-blind length check would accept sha256:gggg... as an identity. + # + # The character class is written out rather than as the range [!0-9a-f] + # because a bracket range is resolved by the collating sequence of the + # current locale. Under en_US.UTF-8 the range a-f collates case-insensitively + # and admits uppercase, so a range would accept two spellings of one identity. case "$identity" in - sha256:????????????????????????????????????????????????????????????????) ;; - *) - echo "resource $resource has a malformed identity digest" >&2 - exit 1 - ;; + sha256:*) body=${identity#sha256:} ;; + *) body='' ;; + esac + case "$body" in + *[!0123456789abcdef]*) body='' ;; esac + if test "${#body}" -ne 64; then + echo "resource $resource has a malformed identity digest" >&2 + exit 1 + fi case "$identity" in *0000000000000000|*1111111111111111|*2222222222222222|*3333333333333333|\ *4444444444444444|*5555555555555555|*6666666666666666|*7777777777777777|\ diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 116313f..9f364a1 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -98,4 +98,26 @@ write_source "$work/foreign.edict" \ "$good_reconcile" expect_reject "$work/good" "$work/foreign.edict" "settlement slot pins a foreign identity" +# A sidecar whose identity is the right shape and length but not hexadecimal. +# Only 0-9a-f can name a SHA-256 artifact, so anything else is not an identity. +write_vendor "$work/nonhex" \ + sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg \ + "$good_settlement" "$good_reconcile" +write_source "$work/nonhex.edict" \ + sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg \ + "$good_settlement" "$good_reconcile" +expect_reject "$work/nonhex" "$work/nonhex.edict" "non-hexadecimal identity" + +# Uppercase is not the canonical form the generator emits, so accepting it +# would let two spellings of one identity both pass. +upper=sha256:111111111111111111111111111111111111111111111111111111111111111A +write_vendor "$work/upper" "$upper" "$good_settlement" "$good_reconcile" +write_source "$work/upper.edict" "$upper" "$good_settlement" "$good_reconcile" +expect_reject "$work/upper" "$work/upper.edict" "uppercase hexadecimal identity" + +# A truncated identity must not be accepted by a length-blind check. +write_vendor "$work/short" sha256:abc "$good_settlement" "$good_reconcile" +write_source "$work/short.edict" sha256:abc "$good_settlement" "$good_reconcile" +expect_reject "$work/short" "$work/short.edict" "truncated identity" + echo "resource identity guard: all cases passed" From 8e1a143109ce634bb8fbd2f903396ca4740aecba Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:09:15 -0700 Subject: [PATCH 20/48] Fix: detect sentinel identities structurally Sentinel detection enumerated the digit fills 0 through 9. The observation closure's placeholders were all-b, all-c, and all-d, so a letter fill passed the sidecar check. The effect build's only defence against those was a backreference inside grep -E, which POSIX leaves undefined in an extended regular expression; where unsupported it matches nothing and passes silently. Removes both. A sentinel is now found by removing every occurrence of the identity's first character and asking whether anything remains, which holds for any fill and needs no backreference. The guard test exercises all ten fills the two closures could plausibly have used. Both build boundaries reach this through the shared guard, so the observation closure is covered by the same rule as the patch closure rather than a weaker variant. --- tests/lib/check-resource-identities.sh | 18 ++++++++++-------- tests/resource-identity-guard.sh | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 7645274..1b3d266 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -67,14 +67,16 @@ do echo "resource $resource has a malformed identity digest" >&2 exit 1 fi - case "$identity" in - *0000000000000000|*1111111111111111|*2222222222222222|*3333333333333333|\ - *4444444444444444|*5555555555555555|*6666666666666666|*7777777777777777|\ - *8888888888888888|*9999999999999999) - echo "resource $resource still pins a sentinel identity digest" >&2 - exit 1 - ;; - esac + # A sentinel is a placeholder character repeated to fill the field. Detect it + # structurally, by removing every occurrence of the first character and + # asking whether anything is left, rather than by enumerating the fills seen + # so far: the patch closure used all-9/8/7 and the observation closure used + # all-b/c/d, so any enumeration is a list of yesterday's placeholders. + first=${body%"${body#?}"} + if test -z "$(printf '%s' "$body" | tr -d "$first")"; then + echo "resource $resource still pins a sentinel identity digest" >&2 + exit 1 + fi declared=$(declared_identity "$coordinate" "$source_file") if test -z "$declared"; then diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 9f364a1..cdbd913 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -120,4 +120,19 @@ write_vendor "$work/short" sha256:abc "$good_settlement" "$good_reconcile" write_source "$work/short.edict" sha256:abc "$good_settlement" "$good_reconcile" expect_reject "$work/short" "$work/short.edict" "truncated identity" +# Sentinel identities: a placeholder repeated to fill the field. The generator +# emitted all-9/8/7 for the patch closure and all-b/c/d for the observation +# closure before Edict owned real artifacts, so both digit and letter fills +# must be caught rather than an enumerated list of the ones already seen. +for fill in 0 7 8 9 a b c d e f +do + sentinel="sha256:$( + i=0 + while test "$i" -lt 64; do printf '%s' "$fill"; i=$((i + 1)); done + )" + write_vendor "$work/sentinel" "$sentinel" "$good_settlement" "$good_reconcile" + write_source "$work/sentinel.edict" "$sentinel" "$good_settlement" "$good_reconcile" + expect_reject "$work/sentinel" "$work/sentinel.edict" "sentinel identity of all $fill" +done + echo "resource identity guard: all cases passed" From 179e1d50554c38d2101d5dc00389e4490b168674 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:11:22 -0700 Subject: [PATCH 21/48] Fix: share and cover the writer-epoch assertions The three writer-epoch assertions were byte-duplicated across both runtime witnesses, so a correction to one could silently leave the other weaker. Their discrimination had also only ever been checked by hand; nothing in the repository proved they reject a bad report. Moves them to tests/lib/writer-epoch-assertions.sh, sourced by both witnesses, and adds tests/writer-epoch-assertions.sh, which feeds them reports mutated to carry a reused epoch id, absent predecessor linkage, a predecessor without its final commit digest, a wrong predecessor, a non-advancing and a backwards start LSN, an absent epoch, and a malformed epoch id. It also checks that the read-only assertion refuses a populated epoch and that the first-epoch assertion refuses an already-chained one. Like the resource identity guard, this needs no producer checkout and no cargo. --- CHANGELOG.md | 6 +- tests/effect-runtime.sh | 39 +--------- tests/lib/writer-epoch-assertions.sh | 45 ++++++++++++ tests/patch-runtime.sh | 39 +--------- tests/writer-epoch-assertions.sh | 105 +++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 74 deletions(-) create mode 100755 tests/lib/writer-epoch-assertions.sh create mode 100755 tests/writer-epoch-assertions.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9c24b..a671ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,8 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Build refusal for cross-wired, unresolved, or sentinel external-action schema identities in the vendored compiler source, enforced by a single shared guard that binds each schema slot to the vendored artifact it names. -- Hermetic `tests/resource-identity-guard.sh` covering that guard against - crafted closures, requiring no producer checkout and no `cargo`. +- Hermetic `tests/resource-identity-guard.sh` and + `tests/writer-epoch-assertions.sh` covering the build guard and the shared + writer-epoch assertions against crafted closures and mutated reports. Both + require no producer checkout and no `cargo`. ### Changed diff --git a/tests/effect-runtime.sh b/tests/effect-runtime.sh index 679e77c..446cd21 100755 --- a/tests/effect-runtime.sh +++ b/tests/effect-runtime.sh @@ -100,42 +100,9 @@ assert_identity() { ' "$report_file" >/dev/null } -# 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 -e ' - (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) - and .writerEpoch.previousEpochId == null - and .writerEpoch.previousEpochFinalCommitDigest == null - 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" \ - ' - (.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.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 -e '.writerEpoch == null' "$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 diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh new file mode 100755 index 0000000..ea464b5 --- /dev/null +++ b/tests/lib/writer-epoch-assertions.sh @@ -0,0 +1,45 @@ +#!/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 -e ' + (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + and .writerEpoch.previousEpochId == null + and .writerEpoch.previousEpochFinalCommitDigest == null + 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" \ + ' + (.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.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 -e '.writerEpoch == null' "$report_file" >/dev/null +} diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index c05701d..5f7ff06 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -92,42 +92,9 @@ assert_identity() { ' "$report_file" >/dev/null } -# 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 -e ' - (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) - and .writerEpoch.previousEpochId == null - and .writerEpoch.previousEpochFinalCommitDigest == null - 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" \ - ' - (.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.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 -e '.writerEpoch == null' "$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 diff --git a/tests/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh new file mode 100755 index 0000000..c609cba --- /dev/null +++ b/tests/writer-epoch-assertions.sh @@ -0,0 +1,105 @@ +#!/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 + +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 malformed identity is not an epoch. +mutate "epoch id is not a digest" \ + '.writerEpoch.epochId = "not-a-digest"' + +# 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' + +# 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" From da4bfb43a523113f7410874eb5ac7eb4b64b14ac Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:34:41 -0700 Subject: [PATCH 22/48] Fix: read each slot's own digest within its declaration The extractor set its seen flag only after processing the coordinate line, so a slot declaring its digest on that same line was skipped and the search continued into the next slot. It then returned the following slot's digest and failed a valid closure with a mismatch naming the wrong resource. A slot declaring no digest at all inherited its successor's the same way. The byte-exact cmp against the producer fixture does not prevent this: local and producer sources can match exactly while this separate guard rejects their formatting. Searches from the coordinate onward, including the remainder of the coordinate line, and stops at the next coordinate or the end of the request declaration. A slot with no digest now reports nothing and fails loudly. Also requires the character after a coordinate not to be a digit, so workspace.patch.input@1 cannot match inside workspace.patch.input@10. Reported by Codex. --- tests/lib/check-resource-identities.sh | 51 ++++++++++++++++++++++---- tests/resource-identity-guard.sh | 33 +++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 1b3d266..f59580c 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -20,19 +20,56 @@ source_file=$3 test -d "$vendor" test -f "$source_file" -# Reads the digest a slot declares. The coordinate names the slot and the -# digest follows it on a later line, so this takes the first digest after the -# coordinate and stops. +# Reads the digest one slot declares. +# +# The digest may sit on the coordinate line or on a following line, so this +# searches from the coordinate onward. It stops at the next coordinate or at +# the end of the request declaration, so a slot that declares no digest reports +# nothing rather than silently inheriting the next slot's. +# +# A coordinate must not match inside a longer one: workspace.patch.input@1 is a +# prefix of a hypothetical workspace.patch.input@10, so the character after the +# match may not be a digit. declared_identity() { awk -v coordinate="$1" ' - seen { - if (match($0, /digest "[^"]*"/)) { - print substr($0, RSTART + 8, RLENGTH - 9) + function digest_in(text, start) { + if (match(text, /digest "[^"]*"/)) { + return substr(text, RSTART + 8, RLENGTH - 9) + } + return "" + } + function names_slot(line, at, tail) { + at = index(line, coordinate) + if (at == 0) { + return 0 + } + tail = substr(line, at + length(coordinate), 1) + return tail !~ /[0-9]/ + } + !seen && names_slot($0) { + seen = 1 + found = digest_in(substr($0, index($0, coordinate) + length(coordinate))) + if (found != "") { + print found exit } next } - index($0, coordinate) > 0 { seen = 1 } + seen { + # Another coordinate, or the terminator, ends this declaration. + if ($0 ~ /@[0-9]+/ || index($0, ";") > 0) { + found = digest_in($0) + if (found != "") { + print found + } + exit + } + found = digest_in($0) + if (found != "") { + print found + exit + } + } ' "$2" } diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index cdbd913..d3ca3f8 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -135,4 +135,37 @@ do expect_reject "$work/sentinel" "$work/sentinel.edict" "sentinel identity of all $fill" done +# A slot may carry its digest on the coordinate line. The guard must read that +# slot's own digest rather than skipping past it and taking the next slot's, +# which would reject a valid closure with a misleading mismatch. +cat >"$work/inline.edict" <> = + patch(input.patch) + input schema workspace.patch.input@1 digest "$good_input" + settlement schema workspace.patch.settlement@1 digest "$good_settlement" + reconcile workspace.patch.reconcile@1 digest "$good_reconcile"; + return pending; +} +EOF +expect_accept "$work/good" "$work/inline.edict" "digests on the coordinate lines" + +# A slot that declares no digest at all must fail, not silently inherit the +# next slot's digest. +cat >"$work/missing.edict" <> = + patch(input.patch) + input schema workspace.patch.input@1 + settlement schema workspace.patch.settlement@1 + digest "$good_settlement" + reconcile workspace.patch.reconcile@1 + digest "$good_reconcile"; + return pending; +} +EOF +expect_reject "$work/good" "$work/missing.edict" "input slot declares no digest" + echo "resource identity guard: all cases passed" From 9d176c0fe556de8e3a9cd9eb404ac240058eed2b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:36:31 -0700 Subject: [PATCH 23/48] Fix: compare the predecessor commit digest with the actual commit assert_chained_writer_epoch checked that previousEpochFinalCommitDigest was a well-formed 64-character value and never compared it with anything. Any well-formed value passed, so a producer regression linking the correct epoch id to an incorrect commit digest would satisfy both golden paths. The evidence was described as exact linkage; it was shape. Neither report exposed the digest to compare against, so both hosts now emit wal.lastCommitDigest, the digest closing the last commit visible to that phase. The assertion requires a successor's previousEpochFinalCommitDigest to equal the value its predecessor reported. The unit test gains a mutant carrying a well-formed but wrong predecessor digest, which the shape check accepted. Reported by Codex. --- CHANGELOG.md | 6 +++++- effect-host/src/main.rs | 12 +++++++++++- patch-host/src/main.rs | 12 +++++++++++- tests/lib/writer-epoch-assertions.sh | 2 ++ tests/writer-epoch-assertions.sh | 13 +++++++++++-- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a671ce8..e04da53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 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. + 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. +- `wal.lastCommitDigest` in both reports, so a successor epoch's declared + predecessor commit can be compared with the commit that actually closed it. - Build refusal for cross-wired, unresolved, or sentinel external-action schema identities in the vendored compiler source, enforced by a single shared guard that binds each schema slot to the vendored artifact it names. diff --git a/effect-host/src/main.rs b/effect-host/src/main.rs index 5dcd8b1..75bdf9a 100644 --- a/effect-host/src/main.rs +++ b/effect-host/src/main.rs @@ -560,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, diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index 3431ea5..c1d3ae3 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -586,7 +586,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, diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index ea464b5..d6f8a69 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -33,6 +33,8 @@ assert_chained_writer_epoch() { 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 and .writerEpoch.startedAtLsn > $previous[0].writerEpoch.startedAtLsn ' \ "$report_file" >/dev/null diff --git a/tests/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh index c609cba..bce528d 100755 --- a/tests/writer-epoch-assertions.sh +++ b/tests/writer-epoch-assertions.sh @@ -20,13 +20,16 @@ trap 'rm -rf "$work"' EXIT first=57645ea6d8294d4177531f813035c926d051bae68c0ce6d0a74afc08bf612d55 second=a3b974bd428109b96ea26087630b9562c51a08f7ac498d93bb04c253ee6bb85a commit=3aa349ca0d266dc02cc80fc40fd68e3082fe278bdd5afc6b38f633a05b383e09 +other=8bd0f1c2e4a6957038d1b5c7e9f2a4b6c8d0e2f4a6b8c0d2e4f60718293a4b5c cat >"$work/request.json" <"$work/claim.json" <"$work/inspect.json" <<'EOF' @@ -82,6 +85,12 @@ mutate "start LSN moves backwards" \ 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"'"' + # A malformed identity is not an epoch. mutate "epoch id is not a digest" \ '.writerEpoch.epochId = "not-a-digest"' From 433202c3d824e2f6a39b3b246e498c7aaebf803f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:40:43 -0700 Subject: [PATCH 24/48] Fix: refuse an uncarryable replacement for a stated reason The host advertises a 65,536-byte file cap and validated replacements against it, but the encoded patch carries the target path and expected content digest inside the same bounded request carrier. The reachable replacement size is therefore smaller than the advertised cap and varies with the path: measured at 65,366 bytes for a.txt, 65,360 for notes/x.txt, and 65,313 for a 57-character path. Replacements in that band were accepted by the host and then rejected during admission as requestRejected, indistinguishable from a malformed request. A local size check cannot be exact because only the encoder knows the framing cost, and the encoder already holds the same 65,536-byte bound. Its FileBudgetExceeded result is now surfaced as a distinct replacementExceedsRequestBudget obstruction rather than being flattened into the malformed case, and the witness covers it. Reported by Codex. --- CHANGELOG.md | 3 +++ README.md | 12 ++++++++++ patch-host/src/main.rs | 53 +++++++++++++++++++++++++++++++++--------- tests/patch-runtime.sh | 36 ++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e04da53..d9c86a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). writer-epoch assertions against crafted closures and mutated reports. Both require no producer checkout and no `cargo`. +- `replacementExceedsRequestBudget` as a distinct request obstruction, with a + witness case covering a replacement above the encodable ceiling. + ### Changed - Advanced the vendored `workspace.patch@1` closure to Edict diff --git a/README.md b/README.md index 6f3304e..9eb8c9a 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,15 @@ 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: roughly 65,366 bytes for `a.txt` and 65,313 for a +57-character path. The host does not guess that overhead. It surfaces the +compiler's own budget refusal as `replacementExceedsRequestBudget`, so a +replacement that cannot be carried is refused for a stated reason rather than +reported as a malformed request. 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 @@ -220,6 +229,9 @@ The runtime witness proves: 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, distinct from a + malformed request; - 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 diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index c1d3ae3..1123c6e 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -26,7 +26,8 @@ use warp_core::external_action_adapter::{ use warp_core::validated_workspace_patch::{ encode_validated_workspace_patch_input_v1, validated_workspace_patch_authority_v1, validated_workspace_patch_basis_v1, ValidatedWorkspacePatchAdapterV1, - ValidatedWorkspacePatchProfileV1, ValidatedWorkspacePatchReconcilerV1, + ValidatedWorkspacePatchErrorV1, ValidatedWorkspacePatchProfileV1, + ValidatedWorkspacePatchReconcilerV1, }; use warp_core::{Hash, WorldlineId}; @@ -98,11 +99,17 @@ fn run() -> Result<(), String> { let application_input = match application_input(&request_case) { Ok(input) => input, - Err(_) => { + Err(refusal) => { + let obstruction = match refusal { + ApplicationInputRefusal::Malformed => "requestRejected", + ApplicationInputRefusal::ReplacementExceedsRequestBudget => { + "replacementExceedsRequestBudget" + } + }; let commit_count = wal_commit_count(&invocation.wal_dir)?; print_json(&json!({ "phase": invocation.phase, - "obstruction": "requestRejected", + "obstruction": obstruction, "wal": {"commitCount": commit_count} }))?; std::process::exit(3); @@ -171,23 +178,47 @@ fn parse_invocation() -> Result { Ok(invocation) } -fn application_input(request_case: &RequestCase) -> Result, String> { +/// Why a request could not be turned into an application input. +/// +/// An over-budget replacement is kept distinct from a malformed one: the host +/// accepts replacement sizes the encoded request cannot carry, and reporting +/// both as the same obstruction would make a budget refusal indistinguishable +/// from a bad request. +enum ApplicationInputRefusal { + Malformed, + ReplacementExceedsRequestBudget, +} + +fn application_input(request_case: &RequestCase) -> Result, ApplicationInputRefusal> { if request_case.proposal.path != request_case.observation.path { - return Err("proposal path did not match the witnessed observation path".to_owned()); + return Err(ApplicationInputRefusal::Malformed); } - let before = decode_hex(&request_case.observation.bytes_hex)?; - let replacement = decode_hex(&request_case.proposal.replacement_bytes_hex)?; + 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(|_| "file budget was not representable")?; + usize::try_from(MAX_FILE_BYTES_V1).map_err(|_| ApplicationInputRefusal::Malformed)?; if before.len() > max_file_bytes || replacement.len() > max_file_bytes { - return Err("patch data exceeded the host file budget".to_owned()); + 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| format!("validated patch encoding failed: {error:?}"))?; + .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), ); @@ -202,7 +233,7 @@ fn application_input(request_case: &RequestCase) -> Result, String> { ), ("maxAttempts", CanonicalValueV1::Integer(1)), ])) - .map_err(|error| format!("application input encoding failed: {error:?}")) + .map_err(|_| ApplicationInputRefusal::Malformed) } fn is_compiler_artifact_rejection(error: &EdictExternalActionAdmissionErrorV1) -> bool { diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 5f7ff06..efacd29 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -507,6 +507,42 @@ jq -e ' 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 + # Settlement-size boundary: the request-only settlement floor (the encoded # result size, never below the host minimum) succeeds; one byte less refuses # before mutation. From ae76db0dd8b6eccc0f0aed3550f51fe4ec609b2a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:41:03 -0700 Subject: [PATCH 25/48] Docs: correct a duplicated list conjunction --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9eb8c9a..a1a79d6 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ 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; and From f56bcf8cb3243540b2dd6f9e19f4b17e46c59b91 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:43:12 -0700 Subject: [PATCH 26/48] Fix: reject whitespace embedded in an identity sidecar Normalization deleted every whitespace character before validation, so a sidecar reading 'sha256:ab cd...' was compacted into a well-formed identity and accepted whenever the compiler source carried the compacted value. Malformed generator output passed as canonical. Reads the sidecar as a single line, stripping only the trailing terminator, and refuses a multi-line sidecar. read returns non-zero on a final line with no terminator, so its status is discarded rather than allowing set -e to treat a newline-free sidecar as a failure. Adds embedded-whitespace and no-trailing-newline cases. Reported by Codex. --- tests/lib/check-resource-identities.sh | 13 ++++++++++++- tests/resource-identity-guard.sh | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index f59580c..65239e9 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -84,7 +84,18 @@ do echo "resource $resource has no vendored identity sidecar" >&2 exit 1 fi - identity=$(tr -d '[:space:]' <"$sidecar") + # Read the sidecar as one line, stripping only the trailing line terminator. + # Deleting all whitespace would silently compact a malformed identity such as + # "sha256:ab cd..." into a well-formed one and accept it. + # read returns non-zero on a final line with no terminator, which set -e + # would treat as a failure, so its status is discarded and the value printed + # unconditionally. + identity=$(IFS= read -r line <"$sidecar" || true; printf '%s' "$line") + # A sidecar carrying more than one line is not a canonical identity either. + if test "$(wc -l <"$sidecar" | tr -d ' ')" -gt 1; then + echo "resource $resource has a multi-line identity sidecar" >&2 + exit 1 + fi # Only a lowercase 64-character hexadecimal body can name a SHA-256 artifact. # A character-blind length check would accept sha256:gggg... as an identity. diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index d3ca3f8..29302dc 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -168,4 +168,23 @@ intent applyValidated(input: ApplyPatchInput) EOF expect_reject "$work/good" "$work/missing.edict" "input slot declares no digest" +# A sidecar carrying whitespace inside the identity is not a canonical +# identity. Normalizing it away before validation would accept malformed +# generator output whenever the source happens to carry the compacted value. +mkdir -p "$work/spaced" +printf 'sha256:1111111111111111111111111111111111111111 111111111111111111111112\n' \ + >"$work/spaced/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/spaced/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/spaced/reconciliation-law.sha256" +write_source "$work/spaced.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/spaced" "$work/spaced.edict" "whitespace inside the sidecar identity" + +# A trailing newline is the one permitted terminator and must still be accepted. +mkdir -p "$work/nonewline" +printf '%s' "$good_input" >"$work/nonewline/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/nonewline/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/nonewline/reconciliation-law.sha256" +write_source "$work/nonewline.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_accept "$work/nonewline" "$work/nonewline.edict" "sidecar without a trailing newline" + echo "resource identity guard: all cases passed" From e2dc13dfd7b37a28d583edf972b792e0953615a5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:43:56 -0700 Subject: [PATCH 27/48] Fix: require the read-only epoch field to be present jq reads a missing property as null, so assert_no_writer_epoch passed for a report that omitted writerEpoch entirely. A report-construction regression could have dropped the field from both witnesses while both suites stayed green: the assertion would have read an absent field as proof of absence. Requires has("writerEpoch") alongside the null value, and adds a mutant whose field is deleted. Reported by Codex. --- tests/lib/writer-epoch-assertions.sh | 5 ++++- tests/writer-epoch-assertions.sh | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index d6f8a69..efbaf55 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -43,5 +43,8 @@ assert_chained_writer_epoch() { # Read-only phases take no writer lease and therefore acquire no epoch. assert_no_writer_epoch() { report_file=$1 - jq -e '.writerEpoch == null' "$report_file" >/dev/null + # 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/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh index bce528d..ea0defa 100755 --- a/tests/writer-epoch-assertions.sh +++ b/tests/writer-epoch-assertions.sh @@ -95,6 +95,16 @@ mutate "predecessor final commit digest is well-formed but wrong" \ 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' + # 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 From 4038663bdd5dc172fa4472b9c7d4ad8fc1715448 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:44:25 -0700 Subject: [PATCH 28/48] Fix: require numeric writer-epoch start LSNs jq orders values across types, so "oops" > 0 and {} > 0 both evaluate true. A report-schema regression replacing the numeric start LSN with a string or an object would satisfy the bare > comparison and keep both chained-epoch witnesses green. Requires both operands to be numbers before comparing, and applies the same type check to the first-epoch assertion's == 0 comparison. Adds string, object, and absent mutants. Reported by Codex. --- tests/lib/writer-epoch-assertions.sh | 3 +++ tests/writer-epoch-assertions.sh | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index efbaf55..9f9b4e1 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -17,6 +17,7 @@ assert_first_writer_epoch() { (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) and .writerEpoch.previousEpochId == null and .writerEpoch.previousEpochFinalCommitDigest == null + and (.writerEpoch.startedAtLsn | type) == "number" and .writerEpoch.startedAtLsn == 0 ' "$report_file" >/dev/null } @@ -35,6 +36,8 @@ assert_chained_writer_epoch() { and .writerEpoch.previousEpochId == $previous[0].writerEpoch.epochId and .writerEpoch.previousEpochFinalCommitDigest == $previous[0].wal.lastCommitDigest + and (.writerEpoch.startedAtLsn | type) == "number" + and ($previous[0].writerEpoch.startedAtLsn | type) == "number" and .writerEpoch.startedAtLsn > $previous[0].writerEpoch.startedAtLsn ' \ "$report_file" >/dev/null diff --git a/tests/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh index ea0defa..6b33d8a 100755 --- a/tests/writer-epoch-assertions.sh +++ b/tests/writer-epoch-assertions.sh @@ -91,6 +91,15 @@ mutate "write phase reports no epoch" \ 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)' + # A malformed identity is not an epoch. mutate "epoch id is not a digest" \ '.writerEpoch.epochId = "not-a-digest"' From 141a8a9d8c706096d70f8e901f06872313d02e21 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:46:26 -0700 Subject: [PATCH 29/48] Fix: prove the epoch ledger plateaus instead of capping its size The retained-ledger assertion checked that the file was at most 4,096 bytes after three writer epochs. An append-only ledger growing on every restart also satisfies that, because its first three records are small. The comment claimed the ledger stays bounded rather than growing per restart; the check could not establish it. Both witnesses now drive sixteen fresh write phases on one WAL, each a separate host process taking a new epoch, record the ledger size after each, and require the final eight observations to be identical. Measured behaviour: 271, 547, then 611 bytes from the third epoch onward, which matches Echo retaining one closed epoch. A ledger growing 24 bytes per restart would produce eight distinct sizes in that window and fail. Reported by Codex. --- CHANGELOG.md | 3 +++ tests/effect-runtime.sh | 46 ++++++++++++++++++++++++++++++++++--- tests/patch-runtime.sh | 50 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c86a9..c449f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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. - `wal.lastCommitDigest` in both reports, so a successor epoch's declared predecessor commit can be compared with the commit that actually closed it. - Build refusal for cross-wired, unresolved, or sentinel external-action schema diff --git a/tests/effect-runtime.sh b/tests/effect-runtime.sh index 446cd21..de8eb5a 100755 --- a/tests/effect-runtime.sh +++ b/tests/effect-runtime.sh @@ -231,8 +231,7 @@ 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, and the -# retained ledger stays bounded rather than growing per restart. +# No writer epoch is reused anywhere in the ordered golden path. test "$( jq -r '.writerEpoch.epochId' \ "$golden_root/request-report.json" \ @@ -242,7 +241,48 @@ test "$( wc -l | tr -d ' ' )" = 3 -test "$(wc -c <"$golden_wal/writer-epochs.ecwal" | tr -d ' ')" -le 4096 + +# 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" \ diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index efacd29..6c279cf 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -219,8 +219,7 @@ 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, and the -# retained ledger stays bounded rather than growing per restart. +# No writer epoch is reused anywhere in the ordered golden path. test "$( jq -r '.writerEpoch.epochId' \ "$golden_root/request-report.json" \ @@ -230,7 +229,52 @@ test "$( wc -l | tr -d ' ' )" = 3 -test "$(wc -c <"$golden_wal/writer-epochs.ecwal" | tr -d ' ')" -le 4096 + +# 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. run_phase retry "$golden_case" "$golden_wal" "$golden_root/retry-report.json" exact From 83def9fb78aa6f5a025c35445b64210320079092 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 04:46:51 -0700 Subject: [PATCH 30/48] Docs: record the uniform-digest trade-off in the sentinel check Codex noted the sentinel predicate would reject a genuine SHA-256 identity that happens to be one repeated character. That is true and is not being changed: sixteen of 2^256 digests are uniform, against a producer regression to placeholder digests that has already occurred twice in this closure family. Recomputing the identity locally to distinguish the two is not available. The sidecar carries a canonical resource identity owned by the generator, not a hash of the enclosing file, so verifying it independently would mean reimplementing the compiler contract in the consumer. Accepted, not fixed. The reasoning is now in the code rather than in a review thread. --- tests/lib/check-resource-identities.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 65239e9..ab3d39a 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -120,6 +120,14 @@ do # asking whether anything is left, rather than by enumerating the fills seen # so far: the patch closure used all-9/8/7 and the observation closure used # all-b/c/d, so any enumeration is a list of yesterday's placeholders. + # + # This cannot tell a placeholder from a genuine digest that happens to be + # uniform, and would reject one. Sixteen of the 2^256 possible digests are + # uniform, so that is a probability near 10^-76, against a producer + # regression to placeholders that has already happened twice. If it ever does + # fire on real generator output, regenerating the artifact resolves it. + # Recomputing the identity locally is not an option: it is a canonical + # resource identity owned by the generator, not a hash of the file bytes. first=${body%"${body#?}"} if test -z "$(printf '%s' "$body" | tr -d "$first")"; then echo "resource $resource still pins a sentinel identity digest" >&2 From 18e3a1cf30e293d262bd62ceab4acb554b992d4f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 05:25:41 -0700 Subject: [PATCH 31/48] Fix: reject trailing content after an identity sidecar The multi-line sidecar check counted newline characters. A two-line file whose final line carries no terminator contains exactly one newline, so it reported as single-line and the guard silently used its first line. A sidecar holding a second identity was accepted. Compares byte counts instead, admitting only the identity itself or the identity plus one terminator. Adds unterminated two-line, terminated two-line, and trailing-content cases. Found reviewing the round-2 commits before requesting another review; the check this replaces was added in f56bcf8 an hour earlier. --- tests/lib/check-resource-identities.sh | 11 +++++++--- tests/resource-identity-guard.sh | 30 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index ab3d39a..820af93 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -91,9 +91,14 @@ do # would treat as a failure, so its status is discarded and the value printed # unconditionally. identity=$(IFS= read -r line <"$sidecar" || true; printf '%s' "$line") - # A sidecar carrying more than one line is not a canonical identity either. - if test "$(wc -l <"$sidecar" | tr -d ' ')" -gt 1; then - echo "resource $resource has a multi-line identity sidecar" >&2 + # The sidecar must hold exactly the identity and at most one terminator. + # Counting newlines cannot express that: a two-line file whose final line has + # no terminator contains one newline and reports as single-line, so a second + # identity would go unseen. Comparing byte counts admits only the identity + # itself or the identity plus its terminator. + sidecar_bytes=$(wc -c <"$sidecar" | tr -d ' ') + if test "$sidecar_bytes" -gt "$((${#identity} + 1))"; then + echo "resource $resource has trailing content after its identity" >&2 exit 1 fi diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 29302dc..106b393 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -179,6 +179,36 @@ printf '%s\n' "$good_reconcile" >"$work/spaced/reconciliation-law.sha256" write_source "$work/spaced.edict" "$good_input" "$good_settlement" "$good_reconcile" expect_reject "$work/spaced" "$work/spaced.edict" "whitespace inside the sidecar identity" +# A sidecar carrying a second identity is not canonical. Counting newlines is +# not enough to see this: a two-line file whose final line has no terminator +# contains one newline character, so a line count reports it as single-line. +mkdir -p "$work/twoline" +printf '%s\n%s' "$good_input" \ + sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef \ + >"$work/twoline/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/twoline/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/twoline/reconciliation-law.sha256" +write_source "$work/twoline.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/twoline" "$work/twoline.edict" "sidecar carrying a second identity" + +# The same, with the trailing terminator present. +mkdir -p "$work/twoline2" +printf '%s\n%s\n' "$good_input" \ + sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef \ + >"$work/twoline2/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/twoline2/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/twoline2/reconciliation-law.sha256" +write_source "$work/twoline2.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/twoline2" "$work/twoline2.edict" "sidecar with a terminated second identity" + +# Trailing content that is not a whole line must also fail. +mkdir -p "$work/trailing" +printf '%s\n \n' "$good_input" >"$work/trailing/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/trailing/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/trailing/reconciliation-law.sha256" +write_source "$work/trailing.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/trailing" "$work/trailing.edict" "sidecar with trailing content" + # A trailing newline is the one permitted terminator and must still be accepted. mkdir -p "$work/nonewline" printf '%s' "$good_input" >"$work/nonewline/input-schema.sha256" From 751c7d751d6c31a24fc0bc3bf44b10d2562b36ad Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 05:36:46 -0700 Subject: [PATCH 32/48] Fix: exercise the unproven refusal branches Two coverage gaps of the same kind: a rejection path asserted in code but never shown to fire. The oversize-replacement case checked the obstruction and the WAL commit count but never read the target file, unlike every other refusal case in this suite. A refusal must leave the workspace alone, not merely skip the commit. Verified the assertion discriminates by checking it against a mutated file. The resource identity guard has a dedicated branch for a sidecar that does not exist, and no case reached it. Adds missing-sidecar and empty-sidecar cases and confirms each fires its intended branch rather than passing through a later one. Reported by CodeRabbit. --- tests/patch-runtime.sh | 2 ++ tests/resource-identity-guard.sh | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 6c279cf..512abc3 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -586,6 +586,8 @@ jq -e ' 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 # Settlement-size boundary: the request-only settlement floor (the encoded # result size, never below the host minimum) succeeds; one byte less refuses diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 106b393..205ce13 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -217,4 +217,21 @@ printf '%s\n' "$good_reconcile" >"$work/nonewline/reconciliation-law.sha256" write_source "$work/nonewline.edict" "$good_input" "$good_settlement" "$good_reconcile" expect_accept "$work/nonewline" "$work/nonewline.edict" "sidecar without a trailing newline" +# A vendor directory missing a sidecar entirely must fail. Without a case the +# guard's dedicated branch for it is an unproven claim, which is the thing this +# file exists to prevent. +mkdir -p "$work/nosidecar" +printf '%s\n' "$good_settlement" >"$work/nosidecar/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/nosidecar/reconciliation-law.sha256" +write_source "$work/nosidecar.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/nosidecar" "$work/nosidecar.edict" "vendor directory missing a sidecar" + +# An empty sidecar is present but names nothing. +mkdir -p "$work/emptysidecar" +: >"$work/emptysidecar/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/emptysidecar/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/emptysidecar/reconciliation-law.sha256" +write_source "$work/emptysidecar.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/emptysidecar" "$work/emptysidecar.edict" "empty identity sidecar" + echo "resource identity guard: all cases passed" From a41be7445a5e438d942c61c5c83bcb0560480c89 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 05:36:47 -0700 Subject: [PATCH 33/48] Docs: attribute the replacement budget to the producer The README stated two measured replacement ceilings with no source, which reads as a contract this repository maintains. It is neither: the bound belongs to Echo's encode_validated_workspace_patch_input_v1 and its MAX_CANONICAL_PATCH_INPUT_BYTES, and the reachable size is that bound minus canonical framing, which varies with the path. Names the producer function and constant, pins the figures to the Echo commit they were measured against, adds the notes/x.txt measurement, and states plainly that they illustrate the shape of the bound rather than defining it. Nothing in the repository depends on the numbers; the witness probes the refusal, not a threshold. Also hyphenates one compound adjective in the changelog. Reported by CodeRabbit. --- CHANGELOG.md | 2 +- README.md | 22 +++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c449f02..901573b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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. + size, which a fixed-size ceiling could not establish. - `wal.lastCommitDigest` in both reports, so a successor epoch's declared predecessor commit can be compared with the commit that actually closed it. - Build refusal for cross-wired, unresolved, or sentinel external-action schema diff --git a/README.md b/README.md index a1a79d6..ca2c781 100644 --- a/README.md +++ b/README.md @@ -193,11 +193,23 @@ The request JSON separates untrusted `proposal` data from the declared 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: roughly 65,366 bytes for `a.txt` and 65,313 for a -57-character path. The host does not guess that overhead. It surfaces the -compiler's own budget refusal as `replacementExceedsRequestBudget`, so a -replacement that cannot be carried is refused for a stated reason rather than -reported as a malformed request. +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 From 2d3a174a7e43283717b04e596bec770c0fd62e88 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 05:39:57 -0700 Subject: [PATCH 34/48] Fix: validate the sidecar terminator and stop at a new coordinate Two defects Codex reproduced in the guard, both accepting closures the guard claims to reject. A byte count cannot say which byte the permitted extra one is. The shell drops a NUL from the value it reads, so an identity followed by NUL measures as identity-plus-terminator and was admitted. That byte must now actually be a newline. The terminator branch extracted a digest from the line that ended the declaration. When a slot declared no digest and the following coordinate carried one inline, the slot adopted it. Sharing an identity between two resources made the substitution invisible to the value comparison, and the whole guard passed with the input pin absent. A new coordinate now ends the declaration before any digest is taken; only the closing semicolon line may still yield one. Also removes an apostrophe from an awk comment, which closed the single-quoted program and broke the script outright. Reported by Codex. --- tests/lib/check-resource-identities.sh | 26 +++++++++++++++++++------ tests/resource-identity-guard.sh | 27 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 820af93..2eb6d2f 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -56,12 +56,12 @@ declared_identity() { next } seen { - # Another coordinate, or the terminator, ends this declaration. - if ($0 ~ /@[0-9]+/ || index($0, ";") > 0) { - found = digest_in($0) - if (found != "") { - print found - } + # A new coordinate ends this declaration immediately. Extracting a digest + # here would let a slot that declares none adopt the inline digest of the + # slot that follows it, which is invisible when two resources share an + # identity. An apostrophe cannot appear in this comment: the awk program + # is single-quoted by the enclosing shell. + if ($0 ~ /@[0-9]+/) { exit } found = digest_in($0) @@ -69,6 +69,10 @@ declared_identity() { print found exit } + # The semicolon closes the declaration without a digest having appeared. + if (index($0, ";") > 0) { + exit + } } ' "$2" } @@ -101,6 +105,16 @@ do echo "resource $resource has trailing content after its identity" >&2 exit 1 fi + # A count alone does not say which byte the extra one is. The shell drops a + # NUL from the value it reads, so an identity followed by NUL also measures + # as identity-plus-one. Require that byte to be the line terminator. + if test "$sidecar_bytes" -eq "$((${#identity} + 1))"; then + terminator=$(tail -c 1 "$sidecar" | od -An -tu1 | tr -d ' \n') + if test "$terminator" != 10; then + echo "resource $resource is not terminated by a newline" >&2 + exit 1 + fi + fi # Only a lowercase 64-character hexadecimal body can name a SHA-256 artifact. # A character-blind length check would accept sha256:gggg... as an identity. diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 205ce13..64e0a8e 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -234,4 +234,31 @@ printf '%s\n' "$good_reconcile" >"$work/emptysidecar/reconciliation-law.sha256" write_source "$work/emptysidecar.edict" "$good_input" "$good_settlement" "$good_reconcile" expect_reject "$work/emptysidecar" "$work/emptysidecar.edict" "empty identity sidecar" +# A NUL byte is not a line terminator. The shell drops it from the value read, +# so a byte count alone reads the file as identity-plus-terminator. +mkdir -p "$work/nul" +printf '%s\0' "$good_input" >"$work/nul/input-schema.sha256" +printf '%s\n' "$good_settlement" >"$work/nul/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/nul/reconciliation-law.sha256" +write_source "$work/nul.edict" "$good_input" "$good_settlement" "$good_reconcile" +expect_reject "$work/nul" "$work/nul.edict" "sidecar terminated by a NUL byte" + +# A slot with no digest whose successor declares one inline must not adopt it. +# Sharing an identity between two resources makes the substitution invisible to +# a comparison that only checks the value it found. +mkdir -p "$work/adopt" +printf '%s\n' "$good_input" >"$work/adopt/input-schema.sha256" +printf '%s\n' "$good_input" >"$work/adopt/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/adopt/reconciliation-law.sha256" +cat >"$work/adopt.edict" < Date: Sat, 1 Aug 2026 05:40:42 -0700 Subject: [PATCH 35/48] Fix: require every writer-epoch field to be present assert_first_writer_epoch compared previousEpochId and previousEpochFinalCommitDigest to null. jq reads a missing property as null, so a report emitting only epochId and startedAtLsn satisfied it: absence of evidence read as evidence of absence. Codex confirmed the acceptance. This is the same defect fixed for the read-only assertion in e2dc13d, left unfixed one function away. Both assertions now require each field they rely on to exist, including the predecessor commit digest on the previous report. Adds missing-field mutants for all four epoch fields against both assertions. Reported by Codex. --- tests/lib/writer-epoch-assertions.sh | 19 +++++++++++++++++-- tests/writer-epoch-assertions.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index 9f9b4e1..7bd8329 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -13,8 +13,16 @@ # 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 ' - (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + 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" @@ -30,7 +38,14 @@ assert_chained_writer_epoch() { jq -e \ --slurpfile previous "$previous_report" \ ' - (.writerEpoch.epochId | test("^[0-9a-f]{64}$")) + has("writerEpoch") + and (.writerEpoch | has("epochId")) + and (.writerEpoch | has("previousEpochId")) + and (.writerEpoch | has("previousEpochFinalCommitDigest")) + and (.writerEpoch | has("startedAtLsn")) + and ($previous[0] | has("wal")) + and ($previous[0].wal | has("lastCommitDigest")) + 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 diff --git a/tests/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh index 6b33d8a..df42c83 100755 --- a/tests/writer-epoch-assertions.sh +++ b/tests/writer-epoch-assertions.sh @@ -122,6 +122,32 @@ if assert_no_writer_epoch "$work/claim.json" 2>/dev/null; then 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 From 9b29c1907d9586d491d6796c23eed81909f40efa Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 05:42:14 -0700 Subject: [PATCH 36/48] Fix: report an oversized observation as its own refusal One condition covered both the declared pre-state and the proposed replacement, returning ReplacementExceedsRequestBudget for either. An observation above the file budget therefore told the caller its replacement was too large, when the replacement had passed that check. Splits the two, adds observationExceedsFileBudget, and covers it with a witness case that also confirms no mutation and no WAL commit. Reported by Codex. --- CHANGELOG.md | 5 +++-- README.md | 6 ++++-- patch-host/src/main.rs | 24 +++++++++++++++++++----- tests/patch-runtime.sh | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901573b..a859ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). writer-epoch assertions against crafted closures and mutated reports. Both require no producer checkout and no `cargo`. -- `replacementExceedsRequestBudget` as a distinct request obstruction, with a - witness case covering a replacement above the encodable ceiling. +- `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. ### Changed diff --git a/README.md b/README.md index ca2c781..3768572 100644 --- a/README.md +++ b/README.md @@ -242,8 +242,10 @@ The runtime witness proves: - 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, distinct from a - malformed request; + `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 diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index 1123c6e..4230788 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -102,6 +102,9 @@ fn run() -> Result<(), String> { Err(refusal) => { let obstruction = match refusal { ApplicationInputRefusal::Malformed => "requestRejected", + ApplicationInputRefusal::ObservationExceedsFileBudget => { + "observationExceedsFileBudget" + } ApplicationInputRefusal::ReplacementExceedsRequestBudget => { "replacementExceedsRequestBudget" } @@ -180,12 +183,17 @@ fn parse_invocation() -> Result { /// Why a request could not be turned into an application input. /// -/// An over-budget replacement is kept distinct from a malformed one: the host -/// accepts replacement sizes the encoded request cannot carry, and reporting -/// both as the same obstruction would make a budget refusal indistinguishable -/// from a bad request. +/// 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, } @@ -199,7 +207,13 @@ fn application_input(request_case: &RequestCase) -> Result, ApplicationI .map_err(|_| ApplicationInputRefusal::Malformed)?; let max_file_bytes = usize::try_from(MAX_FILE_BYTES_V1).map_err(|_| ApplicationInputRefusal::Malformed)?; - if before.len() > max_file_bytes || replacement.len() > max_file_bytes { + // 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. + 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( diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 512abc3..1327f6d 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -589,6 +589,38 @@ jq -e ' # A refusal must leave the workspace alone, not merely skip the WAL commit. test "$(cat "$oversize_root/workspace/notes/big.txt")" = hello +# 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. From 9b77176f8f694994d0bb30737708b3906c706585 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 05:56:34 -0700 Subject: [PATCH 37/48] Fix: close four acceptance gaps found in round five Codex reproduced each; two are in the original patch witness rather than in this review's edits. A commented coordinate was treated as a declaration. A comment carrying an expected digest above the live slot shadowed it, and a cross-wired live slot passed the whole guard. Comments are now stripped, and a coordinate counts only inside its own clause keyword. An LSN type check admitted a fractional value: 0.5 is a number and compares greater than a predecessor 0. LSNs are discrete positions, so both operands must now be nonnegative integers. The conflicting-retry arm matched any error. An internal validation or recovery regression would have reported conflictingSettlement and satisfied the case meant to prove the kind-only mutation was rejected. It now matches ExternalActionProtocolErrorV1::ConflictingSettlement alone. The success corpus checked that the reported content digests were hexadecimal, never that they described the witnessed bytes. A producer could have corrupted the pre and postcondition evidence throughout without failing the suite. Both fields are now compared against digests computed from the bytes, which makes b3sum a declared dependency of this witness. Reported by Codex. --- README.md | 4 ++ patch-host/src/main.rs | 10 ++++- tests/lib/check-resource-identities.sh | 53 ++++++++++++++++++-------- tests/lib/writer-epoch-assertions.sh | 9 +++++ tests/patch-runtime.sh | 20 ++++++++-- tests/resource-identity-guard.sh | 47 +++++++++++++++++++++++ tests/writer-epoch-assertions.sh | 7 ++++ 7 files changed, 129 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 3768572..f304e9e 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ ECHO_REPO=/path/to/echo \ ./tests/patch-runtime.sh ``` +This witness needs `b3sum` 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 diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index 4230788..ffd1f4d 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -15,7 +15,7 @@ use warp_core::causal_wal::{ use warp_core::external_action::{ claim_external_action, reconcile_external_action_settlement_retry, record_external_action_request, ExternalActionAdapterBindingV1, ExternalActionAdapterIdV1, - ExternalActionAdapterRegistryV1, ExternalActionCoordinatorV1, + ExternalActionAdapterRegistryV1, ExternalActionCoordinatorV1, ExternalActionProtocolErrorV1, ExternalActionSettlementCandidateV1, ExternalActionSettlementKindV1, ExternalActionTransactionContextV1, RecoveredExternalActionPostureV1, }; @@ -510,7 +510,13 @@ fn retry_phase( ); print_json(&Value::Object(report)) } - Err(_) if retry_mode == "conflict-kind" => { + // 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, diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 2eb6d2f..7e18ddb 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -31,24 +31,44 @@ test -f "$source_file" # prefix of a hypothetical workspace.patch.input@10, so the character after the # match may not be a digit. declared_identity() { - awk -v coordinate="$1" ' - function digest_in(text, start) { + awk -v coordinate="$1" -v clause="$2" ' + function strip_comment(text, at) { + at = index(text, "//") + if (at > 0) { + return substr(text, 1, at - 1) + } + return text + } + function digest_in(text) { if (match(text, /digest "[^"]*"/)) { return substr(text, RSTART + 8, RLENGTH - 9) } return "" } - function names_slot(line, at, tail) { - at = index(line, coordinate) - if (at == 0) { + function names_slot(text, at, clause_at, tail) { + # The coordinate must appear as part of its own clause, after the clause + # keyword. A bare occurrence elsewhere in the file does not declare a + # slot. + clause_at = index(text, clause) + if (clause_at == 0) { return 0 } - tail = substr(line, at + length(coordinate), 1) + at = index(text, coordinate) + if (at == 0 || at < clause_at) { + return 0 + } + # A coordinate must not match inside a longer one. + tail = substr(text, at + length(coordinate), 1) return tail !~ /[0-9]/ } - !seen && names_slot($0) { + { + # Comments are not declarations. A commented coordinate carrying an + # expected digest would otherwise shadow the live slot below it. + line = strip_comment($0) + } + !seen && names_slot(line) { seen = 1 - found = digest_in(substr($0, index($0, coordinate) + length(coordinate))) + found = digest_in(substr(line, index(line, coordinate) + length(coordinate))) if (found != "") { print found exit @@ -61,26 +81,29 @@ declared_identity() { # slot that follows it, which is invisible when two resources share an # identity. An apostrophe cannot appear in this comment: the awk program # is single-quoted by the enclosing shell. - if ($0 ~ /@[0-9]+/) { + if (line ~ /@[0-9]+/) { exit } - found = digest_in($0) + found = digest_in(line) if (found != "") { print found exit } # The semicolon closes the declaration without a digest having appeared. - if (index($0, ";") > 0) { + if (index(line, ";") > 0) { exit } } - ' "$2" + ' "$3" } -for slot in input:input-schema settlement:settlement-schema reconcile:reconciliation-law +# slot kind : vendored resource : the clause keyword that introduces it +for slot in "input:input-schema:input schema" "settlement:settlement-schema:settlement schema" "reconcile:reconciliation-law:reconcile" do kind=${slot%%:*} - resource=${slot#*:} + slot_rest=${slot#*:} + resource=${slot_rest%%:*} + clause=${slot_rest#*:} coordinate="$namespace.$kind@1" sidecar="$vendor/$resource.sha256" @@ -153,7 +176,7 @@ do exit 1 fi - declared=$(declared_identity "$coordinate" "$source_file") + declared=$(declared_identity "$coordinate" "$clause" "$source_file") if test -z "$declared"; then echo "compiler source declares no digest for $coordinate" >&2 exit 1 diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index 7bd8329..c97e20c 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -26,6 +26,7 @@ assert_first_writer_epoch() { 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 } @@ -51,8 +52,16 @@ assert_chained_writer_epoch() { 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 + and .writerEpoch.startedAtLsn >= 0 + and $previous[0].writerEpoch.startedAtLsn >= 0 and .writerEpoch.startedAtLsn > $previous[0].writerEpoch.startedAtLsn ' \ "$report_file" >/dev/null diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 1327f6d..2d63c80 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -5,6 +5,9 @@ set -eu : "${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 if ! test -x ./tests/patch-build.sh; then echo "Hello Effect patch build boundary is not implemented" >&2 @@ -37,6 +40,11 @@ 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' +} + make_case() { case_file=$1 worldline_byte=$2 @@ -142,11 +150,13 @@ complete_success_case() { 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 | test("^[0-9a-f]{64}$")) - and (.settlement.patch.afterContentDigest | test("^[0-9a-f]{64}$")) + 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}$")) @@ -381,10 +391,12 @@ run_phase \ "$reconcile_root/reconcile-report.json" \ "$reconcile_workspace" assert_posture "$reconcile_root/reconcile-report.json" reconcile settled 3 -jq -e ' +jq -e \ + --arg after_digest "$(content_digest after)" \ + ' .settlement.kind == "succeeded" and .settlement.patch.beforeContentDigest == null - and (.settlement.patch.afterContentDigest | test("^[0-9a-f]{64}$")) + 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 diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 64e0a8e..7b0465d 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -261,4 +261,51 @@ intent applyValidated(input: ApplyPatchInput) EOF expect_reject "$work/adopt" "$work/adopt.edict" "slot adopting its successor's inline digest" +# A commented coordinate is not a declaration. If it were treated as one it +# would shadow the live slot below it, and a cross-wired live slot would pass. +cat >"$work/comment.edict" <"$work/comment-ok.edict" <"$work/bareword.edict" < Date: Sat, 1 Aug 2026 06:35:38 -0700 Subject: [PATCH 38/48] Fix: bind the remaining self-consistent evidence to witnessed bytes Three gaps of one kind, all reported by Codex after the previous round's partial fixes. An LSN bound to a nonnegative integer still admitted 1e100, which jq compares greater than any predecessor but cannot represent a u64 position. Both operands are now bounded above. The binary property cases checked the written bytes and never the reported digests, so a hash that stopped at an embedded NUL would have written the right file while retaining corrupt postcondition evidence. Those cases now bind both digests, hashing from hex because a binary body cannot survive a shell variable. The success assertion compared evidence, externalEvidenceDigest, and resultingBasis only against each other, so one arbitrary value substituted for all three would have passed. Echo derives a basis by domain-separated hashing, and recomputing that here would rebuild producer logic in the consumer. The host is instead asked for the same value by two independent routes: a second patch declaring the first patch's post-state as its own pre-state derives a request basis over those exact bytes, which must equal the first settlement's retained resulting basis. Verified the two settlements do not share a basis, so the equality cannot be met by a constant. Reported by Codex. --- README.md | 2 +- tests/lib/writer-epoch-assertions.sh | 4 ++ tests/patch-runtime.sh | 88 ++++++++++++++++++++++++++++ tests/writer-epoch-assertions.sh | 2 + 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f304e9e..def4e4d 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ ECHO_REPO=/path/to/echo \ ./tests/patch-runtime.sh ``` -This witness needs `b3sum` in addition to `jq`. It compares the content digests +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. diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index c97e20c..72f59ad 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -60,8 +60,12 @@ assert_chained_writer_epoch() { 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 diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 2d63c80..4f94ea0 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -45,6 +45,12 @@ 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 @@ -601,6 +607,80 @@ jq -e ' # 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. +make_case \ + "$basis_root/second.json" \ + 96 \ + "$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" @@ -774,6 +854,14 @@ do "$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. diff --git a/tests/writer-epoch-assertions.sh b/tests/writer-epoch-assertions.sh index 6af6a41..0e7a75c 100755 --- a/tests/writer-epoch-assertions.sh +++ b/tests/writer-epoch-assertions.sh @@ -106,6 +106,8 @@ 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" \ From 1c7cf27d00eba927b82890a17b653d952e02d433 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 06:46:19 -0700 Subject: [PATCH 39/48] Fix: close the round-seven gaps Five findings from CodeRabbit, the substantive one being a same-line variant of the successor-adoption evasion. declared_identity stopped a following slot from donating its digest across lines but not on one line. A source packing two clauses onto a single line let the successor digest satisfy the slot that declared none, invisible whenever the two vendored identities match. The tail after a coordinate, and each later line, are now truncated at the next coordinate before a digest is taken. The chained-epoch assertion required every successor field to exist and read the predecessor without the same requirement, so a predecessor carrying no writerEpoch yielded null and a successor reporting previousEpochId null satisfied the linkage by null == null. The mutation loop only damaged the successor and never reached it. Both sides are now required, with seven predecessor mutants and the explicit null-to-null case. The README named xxd as a prerequisite that the script never checked, so a missing xxd surfaced as a shell error inside hex_digest rather than as a missing tool. The two budget checks have a precedence the witness pins: an input oversized in both reports observationExceedsFileBudget. That is now stated beside the checks so a reorder cannot silently retarget it. The resulting-basis proof had no negative direction. In every success case the declared replacement and the observed post-state are the same bytes, so a producer deriving evidence from the request rather than the observation passed. The outcomeUnknown family is the only place they differ; the evidence is now required to vary with the observed bytes and not with the declared replacement. While adding that, found beforeContentDigest carries the observed bytes rather than the declared pre-state in an outcomeUnknown settlement. Echo is correct; my assertion was wrong. The reading is now pinned, since the field name does not convey it. Reported by CodeRabbit. --- CHANGELOG.md | 5 +++ patch-host/src/main.rs | 4 ++ tests/lib/check-resource-identities.sh | 33 +++++++++----- tests/lib/writer-epoch-assertions.sh | 9 ++++ tests/patch-runtime.sh | 61 ++++++++++++++++++++++++++ tests/resource-identity-guard.sh | 17 +++++++ tests/writer-epoch-assertions.sh | 28 ++++++++++++ 7 files changed, 145 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a859ed5..f70fa32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 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. +- 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. - Build refusal for cross-wired, unresolved, or sentinel external-action schema diff --git a/patch-host/src/main.rs b/patch-host/src/main.rs index ffd1f4d..33441d8 100644 --- a/patch-host/src/main.rs +++ b/patch-host/src/main.rs @@ -210,6 +210,10 @@ fn application_input(request_case: &RequestCase) -> Result, ApplicationI // 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); } diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh index 7e18ddb..842252d 100755 --- a/tests/lib/check-resource-identities.sh +++ b/tests/lib/check-resource-identities.sh @@ -39,6 +39,14 @@ declared_identity() { } return text } + function trim_at_next_coordinate(text) { + # Any further @N on the line begins another clause. Everything from there + # on belongs to that clause, including its digest. + if (match(text, /@[0-9]+/)) { + return substr(text, 1, RSTART - 1) + } + return text + } function digest_in(text) { if (match(text, /digest "[^"]*"/)) { return substr(text, RSTART + 8, RLENGTH - 9) @@ -68,7 +76,10 @@ declared_identity() { } !seen && names_slot(line) { seen = 1 - found = digest_in(substr(line, index(line, coordinate) + length(coordinate))) + # A newline inside an argument list terminates the statement in awk, so + # the tail is taken into a variable first. + tail_text = substr(line, index(line, coordinate) + length(coordinate)) + found = digest_in(trim_at_next_coordinate(tail_text)) if (found != "") { print found exit @@ -76,21 +87,19 @@ declared_identity() { next } seen { - # A new coordinate ends this declaration immediately. Extracting a digest - # here would let a slot that declares none adopt the inline digest of the - # slot that follows it, which is invisible when two resources share an - # identity. An apostrophe cannot appear in this comment: the awk program - # is single-quoted by the enclosing shell. - if (line ~ /@[0-9]+/) { - exit - } - found = digest_in(line) + # Only the part of the line before any new coordinate belongs to this + # declaration. Taking a digest from beyond it would let a slot that + # declares none adopt the digest of the slot that follows it, which is + # invisible when two resources share an identity. An apostrophe cannot + # appear in this comment: the awk program is single-quoted by the shell. + found = digest_in(trim_at_next_coordinate(line)) if (found != "") { print found exit } - # The semicolon closes the declaration without a digest having appeared. - if (index(line, ";") > 0) { + # A new coordinate, or the semicolon, closes this declaration with no + # digest having appeared. + if (line ~ /@[0-9]+/ || index(line, ";") > 0) { exit } } diff --git a/tests/lib/writer-epoch-assertions.sh b/tests/lib/writer-epoch-assertions.sh index 72f59ad..d10374f 100755 --- a/tests/lib/writer-epoch-assertions.sh +++ b/tests/lib/writer-epoch-assertions.sh @@ -44,8 +44,17 @@ assert_chained_writer_epoch() { 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 diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 4f94ea0..b023251 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -8,6 +8,9 @@ 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 @@ -444,6 +447,64 @@ jq -e ' ' "$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. +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" \ + "$4" \ + 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 97) +evidence_observed_two=$(unknown_evidence_for two intended observedTwo 98) +test "$evidence_observed_one" != "$evidence_observed_two" + +# Different declared replacement, same observed post-state. +evidence_other_request=$(unknown_evidence_for three requested observedOne 99) +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 diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh index 7b0465d..88b455c 100755 --- a/tests/resource-identity-guard.sh +++ b/tests/resource-identity-guard.sh @@ -308,4 +308,21 @@ intent applyValidated(input: ApplyPatchInput) EOF expect_accept "$work/good" "$work/bareword.edict" "coordinate outside its clause is not the slot" +# Two clauses packed onto one line. The successor's digest must not satisfy the +# slot that declares none, even when both resources share an identity, which is +# what makes the substitution invisible to a value comparison. +mkdir -p "$work/oneline" +printf '%s\n' "$good_input" >"$work/oneline/input-schema.sha256" +printf '%s\n' "$good_input" >"$work/oneline/settlement-schema.sha256" +printf '%s\n' "$good_reconcile" >"$work/oneline/reconciliation-law.sha256" +cat >"$work/oneline.edict" </dev/null; then 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 From 52f50a74a139e4cfb11b0a20db488652293cc408 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 07:08:57 -0700 Subject: [PATCH 40/48] Fix: cover the write entrypoints the epoch evidence missed Three findings, all about assertions that could not observe what they claimed. The evidence probes varied the worldline while claiming to vary only the observation or the replacement, so neither dependency was isolated: a producer deriving evidence from the worldline byte alone would have produced the same A/B/A pattern and satisfied both comparisons. The worldline is now held constant across all three. Reconciliation and uncertainty settlements come from write entrypoints distinct from apply and settle, and only their posture was checked. A regression reusing or mislinking an epoch during crash reconciliation would have passed both reconciliation outcomes despite the stated every-write-phase guarantee. Both are now required to chain to their claim. A retry reporting a null writer epoch proves nothing, because the phase supplies that null itself and taking an epoch changes the persisted ledger without changing the commit count. Both witnesses now snapshot the ledger bytes before the exact and conflicting retries and require them unchanged. Confirmed the check discriminates: a read phase leaves the ledger untouched and a write phase changes it. Reported by Codex. --- CHANGELOG.md | 4 ++++ tests/effect-runtime.sh | 15 +++++++++++++++ tests/patch-runtime.sh | 30 ++++++++++++++++++++++++++---- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70fa32..ffacfa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 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. +- 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 diff --git a/tests/effect-runtime.sh b/tests/effect-runtime.sh index de8eb5a..d1589cb 100755 --- a/tests/effect-runtime.sh +++ b/tests/effect-runtime.sh @@ -300,10 +300,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 @@ -324,10 +329,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" @@ -415,6 +425,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/patch-runtime.sh b/tests/patch-runtime.sh index b023251..2bad168 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -296,6 +296,12 @@ if test "$ledger_plateau" -ne 1; then 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" @@ -326,6 +332,10 @@ jq -e ' 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" @@ -400,6 +410,11 @@ run_phase \ "$reconcile_root/reconcile-report.json" \ "$reconcile_workspace" assert_posture "$reconcile_root/reconcile-report.json" reconcile settled 3 +# 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)" \ ' @@ -437,6 +452,9 @@ run_phase \ "$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" @@ -458,6 +476,10 @@ test "$(cat "$unknown_workspace/ambiguous.txt")" = ambiguous # 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" @@ -465,7 +487,7 @@ unknown_evidence_for() { printf '%s' before >"$probe_workspace/ambiguous.txt" make_case \ "$probe_root/request.json" \ - "$4" \ + "$unknown_evidence_worldline" \ ambiguous.txt \ "$(hex_bytes before)" \ "$(hex_bytes "$2")" \ @@ -485,12 +507,12 @@ unknown_evidence_for() { } # Same declared replacement, different observed post-states. -evidence_observed_one=$(unknown_evidence_for one intended observedOne 97) -evidence_observed_two=$(unknown_evidence_for two intended observedTwo 98) +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 99) +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 From 1ec9b2fa867b31dc08e377d55bb12dce7cca5161 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 07:20:38 -0700 Subject: [PATCH 41/48] Fix: bind the reconciled success evidence to the observed bytes The reconciliation assertion checked only that externalEvidenceDigest, evidence, and resultingBasis agreed with one another, and the two-route basis probe added earlier exercised the apply path alone. ValidatedWorkspacePatchReconcilerV1 is a separate implementation, so a regression confined to it could have emitted one arbitrary value for all three fields and passed. Extracts the two-route derivation into request_basis_for and applies it to the reconciled settlement, in both directions: the retained basis must equal one derived over the observed post-state and must differ from one over the pre-state. Reported by Codex. --- CHANGELOG.md | 3 +++ tests/patch-runtime.sh | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffacfa9..5ebf2be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 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 diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 2bad168..ff2901b 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -182,6 +182,38 @@ complete_success_case() { "$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. +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" @@ -410,6 +442,17 @@ run_phase \ "$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 91)" +# 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 92)" # 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 \ From 978bf0260ec649308ee0cbc028e878dcd822e242 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 07:29:42 -0700 Subject: [PATCH 42/48] Fix: hold the worldline constant across every basis comparison The reconciled case runs on worldline 82 while its two basis probes ran on 91 and 92, so neither comparison isolated the byte input: a producer deriving a basis from request context could map 82 and 91 to one value and 92 to another and satisfy both assertions while ignoring the bytes. This is the same confound Codex reported at round eight for the evidence probes. I fixed those and then reintroduced it one round later in the probes added for the reconciled binding. The two-patch chain carried it too, on worldlines 95 and 96, where its negative assertion could be met by the worldline difference rather than the byte difference; that was not reported. Every basis and evidence comparison now varies only the path and the bytes. The contract is stated at request_basis_for, since the worldline parameter is the affordance that keeps producing this. Reported by Codex. --- tests/patch-runtime.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index ff2901b..145aa63 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -189,6 +189,12 @@ complete_success_case() { # 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 @@ -449,10 +455,10 @@ assert_posture "$reconcile_root/reconcile-report.json" reconcile settled 3 reconciled_basis=$( jq -r '.settlement.patch.resultingBasis' "$reconcile_root/reconcile-report.json" ) -test "$reconciled_basis" = "$(request_basis_for reconciled "$reconcile_path" after 91)" +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 92)" +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 \ @@ -773,10 +779,11 @@ run_phase \ 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. +# 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" \ - 96 \ + 95 \ "$basis_path" \ "$(hex_bytes "$basis_after")" \ "$(hex_bytes "$basis_final")" \ From d4d199503b29cae51b774654952ae5de364fac79 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 08:34:42 -0700 Subject: [PATCH 43/48] Remove the consumer-owned Edict parser and pin the producers The resource identity guard had become a second Edict parser: 197 lines of shell and awk covering declaration syntax, comments, coordinates, same-line clauses, sidecar terminators, digest grammar, and placeholder recognition, plus 328 lines of tests for that parser. It produced findings in three consecutive review rounds, each an evasion of its own parsing rather than a defect in the change under review. Every evasion required the producer to ship a malformed compiler source, and in exactly that case the build already compares that source byte-for-byte against the generator-owned fixture. Edict #181 owns canonical resource construction, identity derivation, closure validation, and rejection of malformed, missing, substituted, and sentinel resources, and the build invokes that validator through the public application build. Betting an awk script preserves Edict semantics more faithfully than Edict is not defense in depth. Hello Echo corroborates Edict artifacts and invokes Edict's validator. It does not partially reparse Edict source. If the public build can accept a malformed fixture, that is an Edict defect. The writer-epoch assertions stay: they witness dynamic Echo behaviour, which is the consumer's responsibility. Adds producers.lock.json with the exact Edict and Echo commits, enforced at every build boundary. The compatible pair was an operator convention, so a consumer on a stale producer was undetectable; verified the check rejects one. Adds a CI workflow that reads the lock, checks the producers out at those commits, and runs the complete witness gate with shell syntax, formatting, and strict clippy. Four witnesses have never run on GitHub, which is why real producer-pin failures were invisible on the pull request page. --- .github/workflows/ci.yml | 132 ++++++++++ CHANGELOG.md | 30 ++- README.md | 23 +- patch/vendor/workspace-patch/SOURCE.md | 6 +- producers.lock.json | 11 + tests/build.sh | 3 + tests/effect-build.sh | 11 +- tests/lib/check-resource-identities.sh | 197 --------------- tests/patch-build.sh | 11 +- tests/producer-lock.sh | 42 ++++ tests/resource-identity-guard.sh | 328 ------------------------- 11 files changed, 239 insertions(+), 555 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 producers.lock.json delete mode 100755 tests/lib/check-resource-identities.sh create mode 100755 tests/producer-lock.sh delete mode 100755 tests/resource-identity-guard.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..054eb30 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,132 @@ +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" + + # The producer repositories are private, so a token with read access to + # them is required. Without it the witnesses cannot run at all, which is + # a failure rather than something to skip past. + - 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 }} + token: ${{ secrets.PRODUCER_READ_TOKEN }} + 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 }} + token: ${{ secrets.PRODUCER_READ_TOKEN }} + 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/CHANGELOG.md b/CHANGELOG.md index 5ebf2be..b4b0f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,18 +55,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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. -- Build refusal for cross-wired, unresolved, or sentinel external-action schema - identities in the vendored compiler source, enforced by a single shared guard - that binds each schema slot to the vendored artifact it names. -- Hermetic `tests/resource-identity-guard.sh` and - `tests/writer-epoch-assertions.sh` covering the build guard and the shared - writer-epoch assertions against crafted closures and mutated reports. Both - require no producer checkout and no `cargo`. +- 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 or mismatched + 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`. + +### 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 diff --git a/README.md b/README.md index def4e4d..d96bc56 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,20 @@ 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, so a stale producer fails +loudly instead of producing a misleading result. 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 \ @@ -149,8 +160,8 @@ bounded workspace adapter. It proves: 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`. An unresolved or -sentinel schema identity fails the build closed. +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 @@ -261,8 +272,10 @@ The runtime witness proves: 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`. An unresolved or sentinel schema identity fails the -build closed. +`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 diff --git a/patch/vendor/workspace-patch/SOURCE.md b/patch/vendor/workspace-patch/SOURCE.md index cec6e31..9525caa 100644 --- a/patch/vendor/workspace-patch/SOURCE.md +++ b/patch/vendor/workspace-patch/SOURCE.md @@ -33,8 +33,10 @@ 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, and -refuses any unresolved or sentinel schema identity. +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 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 9856db0..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) @@ -29,14 +32,6 @@ do cmp "effect/vendor/workspace-snapshot/$artifact" "$fixture_source/$artifact" done -# Every external-action schema slot in the compiler source must pin the exact -# identity of the vendored artifact that slot names. A cross-wired, unresolved, -# or sentinel identity fails the build closed. -tests/lib/check-resource-identities.sh \ - workspace.snapshot \ - effect/vendor/workspace-snapshot \ - effect/src/observe-workspace.edict - mkdir -p .build/effect provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" test -f "$provider_source/provider-manifest.echo.json" diff --git a/tests/lib/check-resource-identities.sh b/tests/lib/check-resource-identities.sh deleted file mode 100755 index 842252d..0000000 --- a/tests/lib/check-resource-identities.sh +++ /dev/null @@ -1,197 +0,0 @@ -#!/bin/sh -set -eu - -# Usage: check-resource-identities.sh -# -# Proves that every external-action schema slot in a compiler source pins the -# exact identity of the vendored artifact that slot names. -# -# The identity a slot declares is what Edict resolves against the artifact -# supplied through `externalActionResources`. Checking only that an identity -# appears somewhere in the source would accept a source whose slots are wired -# to each other's artifacts, so each slot is compared to its own sidecar. -# -# Exercised by tests/resource-identity-guard.sh. - -namespace=$1 -vendor=$2 -source_file=$3 - -test -d "$vendor" -test -f "$source_file" - -# Reads the digest one slot declares. -# -# The digest may sit on the coordinate line or on a following line, so this -# searches from the coordinate onward. It stops at the next coordinate or at -# the end of the request declaration, so a slot that declares no digest reports -# nothing rather than silently inheriting the next slot's. -# -# A coordinate must not match inside a longer one: workspace.patch.input@1 is a -# prefix of a hypothetical workspace.patch.input@10, so the character after the -# match may not be a digit. -declared_identity() { - awk -v coordinate="$1" -v clause="$2" ' - function strip_comment(text, at) { - at = index(text, "//") - if (at > 0) { - return substr(text, 1, at - 1) - } - return text - } - function trim_at_next_coordinate(text) { - # Any further @N on the line begins another clause. Everything from there - # on belongs to that clause, including its digest. - if (match(text, /@[0-9]+/)) { - return substr(text, 1, RSTART - 1) - } - return text - } - function digest_in(text) { - if (match(text, /digest "[^"]*"/)) { - return substr(text, RSTART + 8, RLENGTH - 9) - } - return "" - } - function names_slot(text, at, clause_at, tail) { - # The coordinate must appear as part of its own clause, after the clause - # keyword. A bare occurrence elsewhere in the file does not declare a - # slot. - clause_at = index(text, clause) - if (clause_at == 0) { - return 0 - } - at = index(text, coordinate) - if (at == 0 || at < clause_at) { - return 0 - } - # A coordinate must not match inside a longer one. - tail = substr(text, at + length(coordinate), 1) - return tail !~ /[0-9]/ - } - { - # Comments are not declarations. A commented coordinate carrying an - # expected digest would otherwise shadow the live slot below it. - line = strip_comment($0) - } - !seen && names_slot(line) { - seen = 1 - # A newline inside an argument list terminates the statement in awk, so - # the tail is taken into a variable first. - tail_text = substr(line, index(line, coordinate) + length(coordinate)) - found = digest_in(trim_at_next_coordinate(tail_text)) - if (found != "") { - print found - exit - } - next - } - seen { - # Only the part of the line before any new coordinate belongs to this - # declaration. Taking a digest from beyond it would let a slot that - # declares none adopt the digest of the slot that follows it, which is - # invisible when two resources share an identity. An apostrophe cannot - # appear in this comment: the awk program is single-quoted by the shell. - found = digest_in(trim_at_next_coordinate(line)) - if (found != "") { - print found - exit - } - # A new coordinate, or the semicolon, closes this declaration with no - # digest having appeared. - if (line ~ /@[0-9]+/ || index(line, ";") > 0) { - exit - } - } - ' "$3" -} - -# slot kind : vendored resource : the clause keyword that introduces it -for slot in "input:input-schema:input schema" "settlement:settlement-schema:settlement schema" "reconcile:reconciliation-law:reconcile" -do - kind=${slot%%:*} - slot_rest=${slot#*:} - resource=${slot_rest%%:*} - clause=${slot_rest#*:} - coordinate="$namespace.$kind@1" - sidecar="$vendor/$resource.sha256" - - if ! test -f "$sidecar"; then - echo "resource $resource has no vendored identity sidecar" >&2 - exit 1 - fi - # Read the sidecar as one line, stripping only the trailing line terminator. - # Deleting all whitespace would silently compact a malformed identity such as - # "sha256:ab cd..." into a well-formed one and accept it. - # read returns non-zero on a final line with no terminator, which set -e - # would treat as a failure, so its status is discarded and the value printed - # unconditionally. - identity=$(IFS= read -r line <"$sidecar" || true; printf '%s' "$line") - # The sidecar must hold exactly the identity and at most one terminator. - # Counting newlines cannot express that: a two-line file whose final line has - # no terminator contains one newline and reports as single-line, so a second - # identity would go unseen. Comparing byte counts admits only the identity - # itself or the identity plus its terminator. - sidecar_bytes=$(wc -c <"$sidecar" | tr -d ' ') - if test "$sidecar_bytes" -gt "$((${#identity} + 1))"; then - echo "resource $resource has trailing content after its identity" >&2 - exit 1 - fi - # A count alone does not say which byte the extra one is. The shell drops a - # NUL from the value it reads, so an identity followed by NUL also measures - # as identity-plus-one. Require that byte to be the line terminator. - if test "$sidecar_bytes" -eq "$((${#identity} + 1))"; then - terminator=$(tail -c 1 "$sidecar" | od -An -tu1 | tr -d ' \n') - if test "$terminator" != 10; then - echo "resource $resource is not terminated by a newline" >&2 - exit 1 - fi - fi - - # Only a lowercase 64-character hexadecimal body can name a SHA-256 artifact. - # A character-blind length check would accept sha256:gggg... as an identity. - # - # The character class is written out rather than as the range [!0-9a-f] - # because a bracket range is resolved by the collating sequence of the - # current locale. Under en_US.UTF-8 the range a-f collates case-insensitively - # and admits uppercase, so a range would accept two spellings of one identity. - case "$identity" in - sha256:*) body=${identity#sha256:} ;; - *) body='' ;; - esac - case "$body" in - *[!0123456789abcdef]*) body='' ;; - esac - if test "${#body}" -ne 64; then - echo "resource $resource has a malformed identity digest" >&2 - exit 1 - fi - # A sentinel is a placeholder character repeated to fill the field. Detect it - # structurally, by removing every occurrence of the first character and - # asking whether anything is left, rather than by enumerating the fills seen - # so far: the patch closure used all-9/8/7 and the observation closure used - # all-b/c/d, so any enumeration is a list of yesterday's placeholders. - # - # This cannot tell a placeholder from a genuine digest that happens to be - # uniform, and would reject one. Sixteen of the 2^256 possible digests are - # uniform, so that is a probability near 10^-76, against a producer - # regression to placeholders that has already happened twice. If it ever does - # fire on real generator output, regenerating the artifact resolves it. - # Recomputing the identity locally is not an option: it is a canonical - # resource identity owned by the generator, not a hash of the file bytes. - first=${body%"${body#?}"} - if test -z "$(printf '%s' "$body" | tr -d "$first")"; then - echo "resource $resource still pins a sentinel identity digest" >&2 - exit 1 - fi - - declared=$(declared_identity "$coordinate" "$clause" "$source_file") - if test -z "$declared"; then - echo "compiler source declares no digest for $coordinate" >&2 - exit 1 - fi - if test "$declared" != "$identity"; then - echo "$coordinate pins $declared but $resource is $identity" >&2 - exit 1 - fi -done diff --git a/tests/patch-build.sh b/tests/patch-build.sh index e98f491..bd711ce 100755 --- a/tests/patch-build.sh +++ b/tests/patch-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) @@ -33,14 +36,6 @@ do cmp "patch/vendor/workspace-patch/$artifact" "$fixture_source/$artifact" done -# Every external-action schema slot in the compiler source must pin the exact -# identity of the vendored artifact that slot names. A cross-wired, unresolved, -# or sentinel identity fails the build closed. -tests/lib/check-resource-identities.sh \ - workspace.patch \ - patch/vendor/workspace-patch \ - patch/src/apply-validated-patch.edict - mkdir -p .build/patch provider_source="$ECHO_REPO/schemas/edict-provider/package/v1" test -f "$provider_source/provider-manifest.echo.json" diff --git a/tests/producer-lock.sh b/tests/producer-lock.sh new file mode 100755 index 0000000..6215da2 --- /dev/null +++ b/tests/producer-lock.sh @@ -0,0 +1,42 @@ +#!/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 +} + +check_producer edict "$EDICT_REPO" +check_producer echo "$ECHO_REPO" diff --git a/tests/resource-identity-guard.sh b/tests/resource-identity-guard.sh deleted file mode 100755 index 88b455c..0000000 --- a/tests/resource-identity-guard.sh +++ /dev/null @@ -1,328 +0,0 @@ -#!/bin/sh -set -eu - -# Hermetic test of tests/lib/check-resource-identities.sh. -# -# The build boundaries trust that guard to prove every external-action schema -# identity in a compiler source resolves to the vendored artifact it names. A -# guard that has never been shown to reject a bad input proves nothing, so this -# exercises it against crafted closures. -# -# No producer checkout, no network, and no cargo: this runs anywhere. - -guard=tests/lib/check-resource-identities.sh -test -x "$guard" - -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT - -good_input=sha256:1111111111111111111111111111111111111111111111111111111111111112 -good_settlement=sha256:2222222222222222222222222222222222222222222222222222222222222223 -good_reconcile=sha256:3333333333333333333333333333333333333333333333333333333333333334 - -# Writes a vendor directory whose sidecars carry the three given identities. -write_vendor() { - vendor=$1 - mkdir -p "$vendor" - printf '%s\n' "$2" >"$vendor/input-schema.sha256" - printf '%s\n' "$3" >"$vendor/settlement-schema.sha256" - printf '%s\n' "$4" >"$vendor/reconciliation-law.sha256" -} - -# Writes a compiler source that pins the three given identities to the input, -# settlement, and reconcile slots in that order. -write_source() { - source_file=$1 - cat >"$source_file" <> = - patch(input.patch) - input schema workspace.patch.input@1 - digest "$2" - settlement schema workspace.patch.settlement@1 - digest "$3" - authority input.authority - basis input.basis - reconcile workspace.patch.reconcile@1 - digest "$4"; - return pending; -} -EOF -} - -passes() { - "$guard" workspace.patch "$1" "$2" >/dev/null 2>&1 -} - -expect_accept() { - if passes "$1" "$2"; then - printf 'ok accepted: %s\n' "$3" - else - printf 'FAIL rejected a valid closure: %s\n' "$3" >&2 - exit 1 - fi -} - -expect_reject() { - if passes "$1" "$2"; then - printf 'FAIL accepted an invalid closure: %s\n' "$3" >&2 - exit 1 - else - printf 'ok rejected: %s\n' "$3" - fi -} - -# Control: a closure whose every slot names its own vendored artifact. -write_vendor "$work/good" "$good_input" "$good_settlement" "$good_reconcile" -write_source "$work/good.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_accept "$work/good" "$work/good.edict" "matching identities" - -# Cross-wiring: every identity is present in the source and every one resolves -# to a real vendored artifact, but the input and settlement slots are swapped. -# A guard that only asks whether a digest appears somewhere in the file cannot -# see this. -write_source "$work/crosswired.edict" \ - "$good_settlement" "$good_input" "$good_reconcile" -expect_reject "$work/good" "$work/crosswired.edict" "input and settlement slots swapped" - -# Rotation across all three slots, so no slot keeps its own identity. -write_source "$work/rotated.edict" \ - "$good_settlement" "$good_reconcile" "$good_input" -expect_reject "$work/good" "$work/rotated.edict" "all three slots rotated" - -# A slot pinned to a digest that names no vendored artifact at all. -write_source "$work/foreign.edict" \ - "$good_input" \ - sha256:4444444444444444444444444444444444444444444444444444444444444445 \ - "$good_reconcile" -expect_reject "$work/good" "$work/foreign.edict" "settlement slot pins a foreign identity" - -# A sidecar whose identity is the right shape and length but not hexadecimal. -# Only 0-9a-f can name a SHA-256 artifact, so anything else is not an identity. -write_vendor "$work/nonhex" \ - sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg \ - "$good_settlement" "$good_reconcile" -write_source "$work/nonhex.edict" \ - sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg \ - "$good_settlement" "$good_reconcile" -expect_reject "$work/nonhex" "$work/nonhex.edict" "non-hexadecimal identity" - -# Uppercase is not the canonical form the generator emits, so accepting it -# would let two spellings of one identity both pass. -upper=sha256:111111111111111111111111111111111111111111111111111111111111111A -write_vendor "$work/upper" "$upper" "$good_settlement" "$good_reconcile" -write_source "$work/upper.edict" "$upper" "$good_settlement" "$good_reconcile" -expect_reject "$work/upper" "$work/upper.edict" "uppercase hexadecimal identity" - -# A truncated identity must not be accepted by a length-blind check. -write_vendor "$work/short" sha256:abc "$good_settlement" "$good_reconcile" -write_source "$work/short.edict" sha256:abc "$good_settlement" "$good_reconcile" -expect_reject "$work/short" "$work/short.edict" "truncated identity" - -# Sentinel identities: a placeholder repeated to fill the field. The generator -# emitted all-9/8/7 for the patch closure and all-b/c/d for the observation -# closure before Edict owned real artifacts, so both digit and letter fills -# must be caught rather than an enumerated list of the ones already seen. -for fill in 0 7 8 9 a b c d e f -do - sentinel="sha256:$( - i=0 - while test "$i" -lt 64; do printf '%s' "$fill"; i=$((i + 1)); done - )" - write_vendor "$work/sentinel" "$sentinel" "$good_settlement" "$good_reconcile" - write_source "$work/sentinel.edict" "$sentinel" "$good_settlement" "$good_reconcile" - expect_reject "$work/sentinel" "$work/sentinel.edict" "sentinel identity of all $fill" -done - -# A slot may carry its digest on the coordinate line. The guard must read that -# slot's own digest rather than skipping past it and taking the next slot's, -# which would reject a valid closure with a misleading mismatch. -cat >"$work/inline.edict" <> = - patch(input.patch) - input schema workspace.patch.input@1 digest "$good_input" - settlement schema workspace.patch.settlement@1 digest "$good_settlement" - reconcile workspace.patch.reconcile@1 digest "$good_reconcile"; - return pending; -} -EOF -expect_accept "$work/good" "$work/inline.edict" "digests on the coordinate lines" - -# A slot that declares no digest at all must fail, not silently inherit the -# next slot's digest. -cat >"$work/missing.edict" <> = - patch(input.patch) - input schema workspace.patch.input@1 - settlement schema workspace.patch.settlement@1 - digest "$good_settlement" - reconcile workspace.patch.reconcile@1 - digest "$good_reconcile"; - return pending; -} -EOF -expect_reject "$work/good" "$work/missing.edict" "input slot declares no digest" - -# A sidecar carrying whitespace inside the identity is not a canonical -# identity. Normalizing it away before validation would accept malformed -# generator output whenever the source happens to carry the compacted value. -mkdir -p "$work/spaced" -printf 'sha256:1111111111111111111111111111111111111111 111111111111111111111112\n' \ - >"$work/spaced/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/spaced/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/spaced/reconciliation-law.sha256" -write_source "$work/spaced.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/spaced" "$work/spaced.edict" "whitespace inside the sidecar identity" - -# A sidecar carrying a second identity is not canonical. Counting newlines is -# not enough to see this: a two-line file whose final line has no terminator -# contains one newline character, so a line count reports it as single-line. -mkdir -p "$work/twoline" -printf '%s\n%s' "$good_input" \ - sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef \ - >"$work/twoline/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/twoline/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/twoline/reconciliation-law.sha256" -write_source "$work/twoline.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/twoline" "$work/twoline.edict" "sidecar carrying a second identity" - -# The same, with the trailing terminator present. -mkdir -p "$work/twoline2" -printf '%s\n%s\n' "$good_input" \ - sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef \ - >"$work/twoline2/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/twoline2/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/twoline2/reconciliation-law.sha256" -write_source "$work/twoline2.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/twoline2" "$work/twoline2.edict" "sidecar with a terminated second identity" - -# Trailing content that is not a whole line must also fail. -mkdir -p "$work/trailing" -printf '%s\n \n' "$good_input" >"$work/trailing/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/trailing/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/trailing/reconciliation-law.sha256" -write_source "$work/trailing.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/trailing" "$work/trailing.edict" "sidecar with trailing content" - -# A trailing newline is the one permitted terminator and must still be accepted. -mkdir -p "$work/nonewline" -printf '%s' "$good_input" >"$work/nonewline/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/nonewline/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/nonewline/reconciliation-law.sha256" -write_source "$work/nonewline.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_accept "$work/nonewline" "$work/nonewline.edict" "sidecar without a trailing newline" - -# A vendor directory missing a sidecar entirely must fail. Without a case the -# guard's dedicated branch for it is an unproven claim, which is the thing this -# file exists to prevent. -mkdir -p "$work/nosidecar" -printf '%s\n' "$good_settlement" >"$work/nosidecar/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/nosidecar/reconciliation-law.sha256" -write_source "$work/nosidecar.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/nosidecar" "$work/nosidecar.edict" "vendor directory missing a sidecar" - -# An empty sidecar is present but names nothing. -mkdir -p "$work/emptysidecar" -: >"$work/emptysidecar/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/emptysidecar/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/emptysidecar/reconciliation-law.sha256" -write_source "$work/emptysidecar.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/emptysidecar" "$work/emptysidecar.edict" "empty identity sidecar" - -# A NUL byte is not a line terminator. The shell drops it from the value read, -# so a byte count alone reads the file as identity-plus-terminator. -mkdir -p "$work/nul" -printf '%s\0' "$good_input" >"$work/nul/input-schema.sha256" -printf '%s\n' "$good_settlement" >"$work/nul/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/nul/reconciliation-law.sha256" -write_source "$work/nul.edict" "$good_input" "$good_settlement" "$good_reconcile" -expect_reject "$work/nul" "$work/nul.edict" "sidecar terminated by a NUL byte" - -# A slot with no digest whose successor declares one inline must not adopt it. -# Sharing an identity between two resources makes the substitution invisible to -# a comparison that only checks the value it found. -mkdir -p "$work/adopt" -printf '%s\n' "$good_input" >"$work/adopt/input-schema.sha256" -printf '%s\n' "$good_input" >"$work/adopt/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/adopt/reconciliation-law.sha256" -cat >"$work/adopt.edict" <"$work/comment.edict" <"$work/comment-ok.edict" <"$work/bareword.edict" <"$work/oneline/input-schema.sha256" -printf '%s\n' "$good_input" >"$work/oneline/settlement-schema.sha256" -printf '%s\n' "$good_reconcile" >"$work/oneline/reconciliation-law.sha256" -cat >"$work/oneline.edict" < Date: Sat, 1 Aug 2026 08:36:06 -0700 Subject: [PATCH 44/48] ci: fail with a stated reason when the producer token is absent Without the secret, actions/checkout reports 'Input required and not supplied: token', which does not say which token, why it is needed, or what to create. A preflight step now names the secret, the two repositories, and the scope. --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 054eb30..13ecd80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,30 @@ jobs: printf 'echo_commit=%s\n' "$(jq -r '.echo.commit' producers.lock.json)" } >>"$GITHUB_OUTPUT" + - name: Require a producer read token + env: + PRODUCER_READ_TOKEN: ${{ secrets.PRODUCER_READ_TOKEN }} + run: | + set -eu + if test -z "${PRODUCER_READ_TOKEN:-}"; then + cat >&2 <<'MESSAGE' + The PRODUCER_READ_TOKEN secret is not set. + + Edict and Echo are private, so this workflow needs a token with read + access to both in order to check them out at the pinned commits. + Without it the witnesses cannot run, which is a failure rather than + something to skip past: a proof repository whose proofs do not + execute proves nothing. + + Create a fine-grained personal access token with Contents: Read on + flyingrobots/edict and flyingrobots/echo, then add it as the + repository secret PRODUCER_READ_TOKEN. + MESSAGE + exit 1 + fi + # The producer repositories are private, so a token with read access to - # them is required. Without it the witnesses cannot run at all, which is - # a failure rather than something to skip past. + # them is required. - name: Check out Edict at the pinned commit uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: From 26fbb2037436daafc95c4b52f18a871868918677 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 09:05:10 -0700 Subject: [PATCH 45/48] ci: drop the producer token; Edict and Echo are public I assumed the producer repositories were private and required a PRODUCER_READ_TOKEN secret, which does not exist, so the first CI run failed at checkout before running a single witness. Both repositories are public and actions/checkout needs no cross-repository token to read them. The pinned commit remains the whole contract: a moved branch cannot change what a run proves. --- .github/workflows/ci.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13ecd80..01c696f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,36 +32,11 @@ jobs: printf 'echo_commit=%s\n' "$(jq -r '.echo.commit' producers.lock.json)" } >>"$GITHUB_OUTPUT" - - name: Require a producer read token - env: - PRODUCER_READ_TOKEN: ${{ secrets.PRODUCER_READ_TOKEN }} - run: | - set -eu - if test -z "${PRODUCER_READ_TOKEN:-}"; then - cat >&2 <<'MESSAGE' - The PRODUCER_READ_TOKEN secret is not set. - - Edict and Echo are private, so this workflow needs a token with read - access to both in order to check them out at the pinned commits. - Without it the witnesses cannot run, which is a failure rather than - something to skip past: a proof repository whose proofs do not - execute proves nothing. - - Create a fine-grained personal access token with Contents: Read on - flyingrobots/edict and flyingrobots/echo, then add it as the - repository secret PRODUCER_READ_TOKEN. - MESSAGE - exit 1 - fi - - # The producer repositories are private, so a token with read access to - # them is required. - 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 }} - token: ${{ secrets.PRODUCER_READ_TOKEN }} path: .producers/edict - name: Check out Echo at the pinned commit @@ -69,7 +44,6 @@ jobs: with: repository: ${{ steps.producers.outputs.echo_repo }} ref: ${{ steps.producers.outputs.echo_commit }} - token: ${{ secrets.PRODUCER_READ_TOKEN }} path: .producers/echo - name: Install the Rust toolchain From 1a87d3d10faf68cdbff4988443117415b61bcb63 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 09:15:33 -0700 Subject: [PATCH 46/48] Fix: resolve producer symlink targets in the relative-path probe The observation witness links the producer checkouts and re-runs the build through those links, to prove a relative producer path is canonicalized before it reaches the generated Cargo manifest. It linked the path as given. A symlink target is resolved against the directory holding the link, so a relative producer path linked from a nested build directory dangles. Every caller had passed absolute paths, so the probe had never been exercised the way it was written to be. CI passes relative paths and the witness failed at once: fatal: cannot change to '.build/effect-tests/relative-repos/edict' The link path stays relative, which is what the probe exercises. The target is now resolved. Verified every witness passes under both relative and absolute producer paths. Found by CI on its first successful execution. --- CHANGELOG.md | 6 ++++++ tests/effect-runtime.sh | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b0f8b..5d310ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). commits, and runs the complete witness gate plus shell syntax, formatting, and strict clippy on pull requests and pushes to `main`. +- 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 diff --git a/tests/effect-runtime.sh b/tests/effect-runtime.sh index d1589cb..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 From e14abd38d27eb76f49c0efe98c31a0f08fe03d40 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 09:22:20 -0700 Subject: [PATCH 47/48] Fix: carry large file bodies to jq through a file The oversize-replacement and oversize-observation cases build bodies at the file budget, which is 131,072 hex characters. Linux caps a single argument at MAX_ARG_STRLEN, which is exactly 131,072 bytes, so passing one through --arg fails with 'Argument list too long'. macOS permits far larger arguments, so these cases had passed on every developer run and could not fail there. make_case now writes both bodies to files beside the case and reads them with --rawfile, which has no such limit. The generated request is unchanged, which the suite's digest and golden bindings confirm. Only the patch witness builds bodies of that size; the observation witness uses short literals, so it is left alone rather than changed speculatively. Found by CI. --- .gitignore | 3 +++ CHANGELOG.md | 4 ++++ tests/patch-runtime.sh | 13 +++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) 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 5d310ce..878c86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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 diff --git a/tests/patch-runtime.sh b/tests/patch-runtime.sh index 145aa63..b7e1b0f 100755 --- a/tests/patch-runtime.sh +++ b/tests/patch-runtime.sh @@ -62,11 +62,20 @@ make_case() { 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" \ - --arg before_hex "$before_hex" \ - --arg replacement_hex "$replacement_hex" \ + --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" \ '{ From 415cbec8d61d78ffcea7f084b614979c8ccb1ccf Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 1 Aug 2026 09:34:14 -0700 Subject: [PATCH 48/48] Fix: require the pinned producer worktrees to be clean The lock compared rev-parse HEAD, which reports the pinned commit regardless of uncommitted changes. All three build boundaries then compile the modified Edict and Echo sources, so a run could claim the pinned producer pair while testing different code. The vendored artifact comparisons do not cover this. They check the fixtures, not the compiler and host crates the build uses. Verified in both directions: a clean checkout at the pinned commit passes, and a checkout at that commit with one modified file is refused by path. Reported by Codex. --- CHANGELOG.md | 5 +++-- README.md | 7 +++++-- tests/producer-lock.sh | 10 ++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878c86c..0805cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,8 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). 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 or mismatched - producer checkout fails rather than silently changing what is proven. + 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`. diff --git a/README.md b/README.md index d96bc56..0e68293 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,11 @@ bootstrap workload. `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, so a stale producer fails -loudly instead of producing a misleading result. CI reads the same file and +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 diff --git a/tests/producer-lock.sh b/tests/producer-lock.sh index 6215da2..7d6be6b 100755 --- a/tests/producer-lock.sh +++ b/tests/producer-lock.sh @@ -36,6 +36,16 @@ check_producer() { 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"