From ade9266e4d8dff55240ee1fe3589f327123affb3 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Mon, 28 Sep 2026 15:35:38 +0700 Subject: [PATCH 1/4] fix: refuse to accept an attempt whose plan edit was restored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer B F2 on a real host: after a crash, the pickup host saw the worker's forged [x] and "Dispatched: … accepted" line (plan_touched), restored the plan, re-inspected, and accepted with --scope-override, blaming "an interrupted host session". The helper's own refusal had suggested "restore, re-inspect and pass --scope-override", which design §12.3 rule 5 still allowed from the PDN-0003 rule, while dispatch step 14 said to reject plan_tampered. Each inspection now records plan_touched (an optional member of the receipt's inspection entries), and accept is refused once any inspection saw the plan touched and the latest no longer does. The refusal message no longer suggests restoring. The skill states that a plan change seen while the verdict is pending is never the host's own: the host edits the plan only after the verdict. The reference host rejects on the history too; tests/attempt.sh and Layer A case F2d reproduce the real host's steps and fail without the guard. Co-Authored-By: Claude Opus 5.5 --- .../PDN-0004-v05-acceptance-recovery.md | 28 ++++++++++++++++--- .../skills/passdown-dispatch/SKILL.md | 7 ++++- .../schemas/receipt.v1.schema.json | 3 +- .../scripts/passdown-attempt | 11 ++++++-- .../schemas/receipt.v1.schema.json | 3 +- .../passdown-handoff/scripts/passdown-attempt | 11 ++++++-- .../schemas/receipt.v1.schema.json | 3 +- .../passdown-pickup/scripts/passdown-attempt | 11 ++++++-- schemas/protocol/receipt.v1.schema.json | 3 +- scripts/passdown-attempt | 11 ++++++-- tests/attempt.sh | 20 +++++++++++++ tests/harness/ref-host | 5 ++-- tests/interrupt.sh | 25 ++++++++++++++++- tests/skills.sh | 4 +++ 14 files changed, 121 insertions(+), 24 deletions(-) diff --git a/docs/design/PDN-0004-v05-acceptance-recovery.md b/docs/design/PDN-0004-v05-acceptance-recovery.md index 001c7f7..11a1c24 100644 --- a/docs/design/PDN-0004-v05-acceptance-recovery.md +++ b/docs/design/PDN-0004-v05-acceptance-recovery.md @@ -1178,10 +1178,14 @@ The helper refuses unless **all** hold: `settle_seconds > 0`, `inspect` must also have run twice, at least that far apart, with equal digests. This is **artifact-stability** evidence. It is required in addition to safe stop evidence and never replaces it (§8.3). -5. `artifact.out_of_scope` is empty and `plan_touched` is false — or the host - has already restored the plan per the PDN-0003 rule and re-run `inspect`, - and the verdict carries the `--scope-override ` flag, which is - recorded. (Default: reject `scope_violation` / `plan_tampered`.) +5. `artifact.out_of_scope` is empty and `plan_touched` is false — or the + verdict carries the `--scope-override ` flag, which is recorded. + (Default: reject `scope_violation` / `plan_tampered`.) *Revised in S7 + (Layer B F2):* restoring the plan never earns an accept. Each inspection + records `plan_touched`, and the helper refuses an accept once any + inspection saw the plan touched and the latest no longer does; the host + edits the plan only after the verdict, so that edit was the worker's. + The override records only a plan change the owner made and keeps. 6. At least one `--check`, **every** `--check` with exit code 0, each with its output file in `checks/`. (Refined in S4: r3 said "at least one whose exit code is 0", which accepted a passing lint next to a failing test.) @@ -2191,6 +2195,22 @@ ships it. - Independent merge: this is the release PR (`release/v0.5.0` → `main`). - Rollback: do not merge; `main` stays at v0.4.x. +**Implementation notes (S7).** Layer B found two protocol gaps on the real +host, each fixed before the gate reran: + +- *F1, beta.1.* After rejecting an attempt `plan_tampered`, the host + verified the rejected worker's file in place and appended `Dispatched: + main — accepted`, although the owner's route named no fallback. Step 16 + now says a host line never accepts a rejected attempt's output: an + owner-mandated task stays pending, and any other task is finished only + through salvage or after the output is given up. +- *F2, beta.2.* After a crash, the pickup host restored the worker's forged + plan lines, re-inspected, and accepted with `--scope-override`, blaming + "an interrupted host session". The helper's own refusal message had + suggested restoring and overriding. Rule 5 of §12.3 is revised above; the + helper enforces it, and the skill states that a plan change seen while + the verdict is pending is never the host's. + ## 27. Open questions r1 listed five open questions. The first review resolved all of them, and r2 diff --git a/plugins/passdown/skills/passdown-dispatch/SKILL.md b/plugins/passdown/skills/passdown-dispatch/SKILL.md index c044097..919dd4d 100644 --- a/plugins/passdown/skills/passdown-dispatch/SKILL.md +++ b/plugins/passdown/skills/passdown-dispatch/SKILL.md @@ -384,7 +384,12 @@ otherwise. `plan_tampered`. If you cannot attribute the change safely (the user or another session may have edited the plan meanwhile), do not overwrite it and do not record a verdict: stop and report the conflict so the user - can reconcile it; + can reconcile it. **A plan change seen while the verdict is pending is + never the host's own,** not even an interrupted earlier session's: the + host edits the plan only at step 16, after the verdict. Restoring the + plan is always followed by `plan_tampered`, never by an accept (the + helper refuses one). `--scope-override` records only a plan change the + owner made and keeps, on the owner's word; - `out_of_scope` not empty → reject `scope_violation`; - otherwise run every verification command the task names yourself, saving each command's output to a file. Every one must exit `0`; if any diff --git a/plugins/passdown/skills/passdown-dispatch/schemas/receipt.v1.schema.json b/plugins/passdown/skills/passdown-dispatch/schemas/receipt.v1.schema.json index 5e9c769..8bed45c 100644 --- a/plugins/passdown/skills/passdown-dispatch/schemas/receipt.v1.schema.json +++ b/plugins/passdown/skills/passdown-dispatch/schemas/receipt.v1.schema.json @@ -313,7 +313,8 @@ "additionalProperties": false, "properties": { "at": { "$ref": "#/$defs/timestamp" }, - "digest": { "$ref": "#/$defs/digest" } + "digest": { "$ref": "#/$defs/digest" }, + "plan_touched": { "type": "boolean" } } } }, diff --git a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt index 0afc79c..c4b810b 100755 --- a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt @@ -1705,7 +1705,7 @@ cmd_inspect() { | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched - | .artifact.inspections += [{at: $now, digest: $a.digest}] + | .artifact.inspections += [{at: $now, digest: $a.digest, plan_touched: $a.plan_touched}] | if .claim == null then .artifact.target_after = $a.digest else . end' --argjson a "$art" if [ "$JSON_OUT" = 1 ]; then jq -c --slurpfile r "$STORE/$id/receipt.json" '{id: $r[0].id, rev: $r[0].rev} + (. | del(.entries)) + {paths: [.entries[].path]}' <<<"$art" @@ -1835,9 +1835,14 @@ verdict_accept() { and (($i[-1].at | fromdateiso8601) - ($i[-2].at | fromdateiso8601)) >= $s' <<<"$r" >/dev/null || die $E_STATE "accept: the card asks for two equal inspections at least ${settle}s apart (artifact stability)" fi - # 5. scope and plan + # 5. scope and plan. The host edits the plan only after the verdict, so a + # plan change an inspection saw belongs to the worker (or to the owner). + # Restoring it undoes the worker's edit; it never earns an accept. + if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" + fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then - die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered, or restore, re-inspect and pass --scope-override" + die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered (--scope-override only records a change the owner made and keeps, on the owner's word)" fi # 6. at least one host check, and every one of them passed [ "$#" -gt 0 ] || die $E_STATE "accept: at least one --check is required (I-10)" diff --git a/plugins/passdown/skills/passdown-handoff/schemas/receipt.v1.schema.json b/plugins/passdown/skills/passdown-handoff/schemas/receipt.v1.schema.json index 5e9c769..8bed45c 100644 --- a/plugins/passdown/skills/passdown-handoff/schemas/receipt.v1.schema.json +++ b/plugins/passdown/skills/passdown-handoff/schemas/receipt.v1.schema.json @@ -313,7 +313,8 @@ "additionalProperties": false, "properties": { "at": { "$ref": "#/$defs/timestamp" }, - "digest": { "$ref": "#/$defs/digest" } + "digest": { "$ref": "#/$defs/digest" }, + "plan_touched": { "type": "boolean" } } } }, diff --git a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt index 0afc79c..c4b810b 100755 --- a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt @@ -1705,7 +1705,7 @@ cmd_inspect() { | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched - | .artifact.inspections += [{at: $now, digest: $a.digest}] + | .artifact.inspections += [{at: $now, digest: $a.digest, plan_touched: $a.plan_touched}] | if .claim == null then .artifact.target_after = $a.digest else . end' --argjson a "$art" if [ "$JSON_OUT" = 1 ]; then jq -c --slurpfile r "$STORE/$id/receipt.json" '{id: $r[0].id, rev: $r[0].rev} + (. | del(.entries)) + {paths: [.entries[].path]}' <<<"$art" @@ -1835,9 +1835,14 @@ verdict_accept() { and (($i[-1].at | fromdateiso8601) - ($i[-2].at | fromdateiso8601)) >= $s' <<<"$r" >/dev/null || die $E_STATE "accept: the card asks for two equal inspections at least ${settle}s apart (artifact stability)" fi - # 5. scope and plan + # 5. scope and plan. The host edits the plan only after the verdict, so a + # plan change an inspection saw belongs to the worker (or to the owner). + # Restoring it undoes the worker's edit; it never earns an accept. + if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" + fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then - die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered, or restore, re-inspect and pass --scope-override" + die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered (--scope-override only records a change the owner made and keeps, on the owner's word)" fi # 6. at least one host check, and every one of them passed [ "$#" -gt 0 ] || die $E_STATE "accept: at least one --check is required (I-10)" diff --git a/plugins/passdown/skills/passdown-pickup/schemas/receipt.v1.schema.json b/plugins/passdown/skills/passdown-pickup/schemas/receipt.v1.schema.json index 5e9c769..8bed45c 100644 --- a/plugins/passdown/skills/passdown-pickup/schemas/receipt.v1.schema.json +++ b/plugins/passdown/skills/passdown-pickup/schemas/receipt.v1.schema.json @@ -313,7 +313,8 @@ "additionalProperties": false, "properties": { "at": { "$ref": "#/$defs/timestamp" }, - "digest": { "$ref": "#/$defs/digest" } + "digest": { "$ref": "#/$defs/digest" }, + "plan_touched": { "type": "boolean" } } } }, diff --git a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt index 0afc79c..c4b810b 100755 --- a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt @@ -1705,7 +1705,7 @@ cmd_inspect() { | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched - | .artifact.inspections += [{at: $now, digest: $a.digest}] + | .artifact.inspections += [{at: $now, digest: $a.digest, plan_touched: $a.plan_touched}] | if .claim == null then .artifact.target_after = $a.digest else . end' --argjson a "$art" if [ "$JSON_OUT" = 1 ]; then jq -c --slurpfile r "$STORE/$id/receipt.json" '{id: $r[0].id, rev: $r[0].rev} + (. | del(.entries)) + {paths: [.entries[].path]}' <<<"$art" @@ -1835,9 +1835,14 @@ verdict_accept() { and (($i[-1].at | fromdateiso8601) - ($i[-2].at | fromdateiso8601)) >= $s' <<<"$r" >/dev/null || die $E_STATE "accept: the card asks for two equal inspections at least ${settle}s apart (artifact stability)" fi - # 5. scope and plan + # 5. scope and plan. The host edits the plan only after the verdict, so a + # plan change an inspection saw belongs to the worker (or to the owner). + # Restoring it undoes the worker's edit; it never earns an accept. + if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" + fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then - die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered, or restore, re-inspect and pass --scope-override" + die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered (--scope-override only records a change the owner made and keeps, on the owner's word)" fi # 6. at least one host check, and every one of them passed [ "$#" -gt 0 ] || die $E_STATE "accept: at least one --check is required (I-10)" diff --git a/schemas/protocol/receipt.v1.schema.json b/schemas/protocol/receipt.v1.schema.json index 5e9c769..8bed45c 100644 --- a/schemas/protocol/receipt.v1.schema.json +++ b/schemas/protocol/receipt.v1.schema.json @@ -313,7 +313,8 @@ "additionalProperties": false, "properties": { "at": { "$ref": "#/$defs/timestamp" }, - "digest": { "$ref": "#/$defs/digest" } + "digest": { "$ref": "#/$defs/digest" }, + "plan_touched": { "type": "boolean" } } } }, diff --git a/scripts/passdown-attempt b/scripts/passdown-attempt index 0afc79c..c4b810b 100755 --- a/scripts/passdown-attempt +++ b/scripts/passdown-attempt @@ -1705,7 +1705,7 @@ cmd_inspect() { | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched - | .artifact.inspections += [{at: $now, digest: $a.digest}] + | .artifact.inspections += [{at: $now, digest: $a.digest, plan_touched: $a.plan_touched}] | if .claim == null then .artifact.target_after = $a.digest else . end' --argjson a "$art" if [ "$JSON_OUT" = 1 ]; then jq -c --slurpfile r "$STORE/$id/receipt.json" '{id: $r[0].id, rev: $r[0].rev} + (. | del(.entries)) + {paths: [.entries[].path]}' <<<"$art" @@ -1835,9 +1835,14 @@ verdict_accept() { and (($i[-1].at | fromdateiso8601) - ($i[-2].at | fromdateiso8601)) >= $s' <<<"$r" >/dev/null || die $E_STATE "accept: the card asks for two equal inspections at least ${settle}s apart (artifact stability)" fi - # 5. scope and plan + # 5. scope and plan. The host edits the plan only after the verdict, so a + # plan change an inspection saw belongs to the worker (or to the owner). + # Restoring it undoes the worker's edit; it never earns an accept. + if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" + fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then - die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered, or restore, re-inspect and pass --scope-override" + die $E_STATE "accept: out-of-scope paths $(jq -c .artifact.out_of_scope <<<"$r") / plan touched $(jq -r .artifact.plan_touched <<<"$r"); reject scope_violation/plan_tampered (--scope-override only records a change the owner made and keeps, on the owner's word)" fi # 6. at least one host check, and every one of them passed [ "$#" -gt 0 ] || die $E_STATE "accept: at least one --check is required (I-10)" diff --git a/tests/attempt.sh b/tests/attempt.sh index 7ba0ed0..c9063ae 100755 --- a/tests/attempt.sh +++ b/tests/attempt.sh @@ -345,6 +345,26 @@ group_acceptance() { [ "$(field "$id" .claim.state)" = released ] || fail "plan_tampered is not claim-holding" pass "a worker checkbox edit sets plan_touched and is rejected (F1)" + # Restoring the worker's plan edit never earns an accept, with or without + # --scope-override (Layer B F2: a real host restored the plan, re-inspected + # and accepted with an override blaming an interrupted host session). + git -C "$repo" checkout -q docs/plan.md + rm -f "$repo/src/hello.txt" + new_write "$repo" + drive "$id" "perl -pi -e 's/- \\[ \\] 1.1/- [x] 1.1/' '$repo/docs/plan.md'; echo hello > '$repo/src/hello.txt'" + ok "result" --store "$store" result "$id" --rev "$(rev "$id")" --payload "$(result_payload "$id" submitted)" + ok "inspect" --store "$store" inspect "$id" --rev "$(rev "$id")" + [ "$(field "$id" '.artifact.inspections[-1].plan_touched')" = true ] || fail "the inspection does not record plan_touched" + git -C "$repo" checkout -q docs/plan.md + ok "re-inspect after restore" --store "$store" inspect "$id" --rev "$(rev "$id")" + [ "$(field "$id" .artifact.plan_touched)" = false ] || fail "restored plan still touched" + run 3 "accept after restoring the plan" --store "$store" verdict "$id" accept --rev "$(rev "$id")" --check "t=0:$check_ok" + printf '%s' "$err" | grep -q plan_tampered || fail "refusal does not name plan_tampered: $err" + run 3 "accept after restoring the plan, with an override" --store "$store" verdict "$id" accept --rev "$(rev "$id")" \ + --check "t=0:$check_ok" --scope-override "plan change attributable to an interrupted host session" + ok "reject plan_tampered" --store "$store" verdict "$id" reject --rev "$(rev "$id")" --reason-code plan_tampered --reason "ticked its own box" + pass "a restored worker plan edit is rejected plan_tampered, never accepted (Layer B F2)" + # An edit to the attempt's own task text is not plan_touched: the task # digest goes stale instead, whoever made the edit (F3, F6). git -C "$repo" checkout -q docs/plan.md diff --git a/tests/harness/ref-host b/tests/harness/ref-host index 2739e49..12ec9cb 100755 --- a/tests/harness/ref-host +++ b/tests/harness/ref-host @@ -186,9 +186,10 @@ decide() { reject "$id" reemit_wrote "the re-emit changed the chain artifact" return fi - if [ "$(field "$id" .artifact.plan_touched)" = true ]; then + if [ "$(field "$id" '.artifact.plan_touched or any(.artifact.inspections[]; .plan_touched == true)')" = true ]; then # PDN-0003: restore the worker's plan edit (the plan was clean at the - # baseline in this fixture), then reject. + # baseline in this fixture), then reject. An inspection that saw the + # plan touched counts even if the plan was restored since. git -C "$repo" checkout -q -- "$plan_rel" reject "$id" plan_tampered "the worker edited the plan" return diff --git a/tests/interrupt.sh b/tests/interrupt.sh index a58c94b..88f1eda 100755 --- a/tests/interrupt.sh +++ b/tests/interrupt.sh @@ -214,6 +214,29 @@ F2() { pass "forged accepted + [x] then a host crash: pickup counts nothing accepted (unresolved attempt)" } +F2d() { + local a code=0 + setup F2d + REF_ATTEST=owner REF_CRASH_AT=after-result host dispatch --mode forge-accepted + crashed || fail "host did not crash" + a="$(attempt_of)" + # What the Layer B F2 host did after the crash: inspect, restore the + # forged plan lines, re-inspect, then accept with an override that blamed + # an interrupted host session. + H inspect "$a" --rev "$(rev "$a")" >/dev/null || fail "inspect" + [ "$(field "$a" .artifact.plan_touched)" = true ] || fail "the forged plan lines were not seen" + git -C "$repo" checkout -q docs/plan.md + H inspect "$a" --rev "$(rev "$a")" >/dev/null || fail "re-inspect" + printf 'ok\n' >"$scratch/F2d/check.txt" + H verdict "$a" accept --rev "$(rev "$a")" --check "grep -q hello src/hello.txt=0:$scratch/F2d/check.txt" \ + --scope-override "plan change attributable to an interrupted host session" >/dev/null 2>&1 || code=$? + [ "$code" = 3 ] || fail "accept after restoring the plan: exit $code" + host finish --id "$a" + [ "$(outcome_of)" = rejected:plan_tampered ] || fail "finish: $(outcome_of)" + oracle + pass "a forged plan restored after a crash is still rejected plan_tampered; an override cannot accept it" +} + F2c() { local a setup F2c @@ -949,7 +972,7 @@ mutation() { pass "with the claim check stubbed out, F11, F15, F19b and F22 all fail (the suite detects the missing guard)" } -all="F1 F2 F2b F2c F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F14c F14d F15 F16 F16b F17 F18 F18b F19 F19b F20 F21 F22 F23 F24 F25 F26 STEPS PICKUP HANDOFF mutation" +all="F1 F2 F2b F2c F2d F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F14c F14d F15 F16 F16b F17 F18 F18b F19 F19b F20 F21 F22 F23 F24 F25 F26 STEPS PICKUP HANDOFF mutation" [ "$#" -gt 0 ] || read -r -a all_list <<<"$all" [ "$#" -gt 0 ] || set -- "${all_list[@]}" for s in "$@"; do diff --git a/tests/skills.sh b/tests/skills.sh index 7eaffe5..ca3c7d8 100755 --- a/tests/skills.sh +++ b/tests/skills.sh @@ -163,6 +163,10 @@ require_text_block "$dispatch" "or its attempt was rejected" \ "the owner route forbids relabeling a rejected task main" require_text_block "$dispatch" "any other +rejection already released it" \ "dispatch runs release-claim only when the rejection kept the claim" +require_text_block "$dispatch" "A plan change seen while the verdict is pending is +never the host's own" \ + "a plan change before the verdict is never attributed to the host (Layer B F2)" +require_text_block "$dispatch" "Restoring the +plan is always followed by +\`plan_tampered\`, never by an accept" \ + "restoring the plan leads to plan_tampered, never to an accept" require_text_block "$dispatch" "always lists +\`owner-attested\`" \ "dispatch branches on safe machine evidence, not on probe's owner-attested entry" require_text_block "$dispatch" "Never attest on the user's behalf" \ From d1de11c09d9fce091effacbf37134f066012753e Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Mon, 28 Sep 2026 15:35:51 +0700 Subject: [PATCH 2/4] test: keep the Layer B pickup session on task 1.1 In F2 the pickup session also did task 1.2, and its new file made the oracle's final-tree check fail for a reason unrelated to the scenario. Co-Authored-By: Claude Opus 5.5 --- tests/harness/layer-b/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/harness/layer-b/run.sh b/tests/harness/layer-b/run.sh index 8221513..88d9772 100755 --- a/tests/harness/layer-b/run.sh +++ b/tests/harness/layer-b/run.sh @@ -40,7 +40,7 @@ events="$out/run-events.txt" : >"$events" DISPATCH_PROMPT="You are the host session for this repository. Execute task 1.1 of docs/plan.md. Follow the passdown workflow exactly: invoke the passdown-dispatch skill first and do what it says, using this repository's AGENTS.md for configuration and routing. If the skill tells you to ask the user something, stop and ask instead of assuming." -PICKUP_PROMPT="You are a new host session for this repository; an earlier session was interrupted. Invoke the passdown-pickup skill and brief me. Then carry out what it proposes through the Reconcile section of the passdown-dispatch skill, using this repository's AGENTS.md. If a skill tells you to ask the user something, stop and ask instead of assuming." +PICKUP_PROMPT="You are a new host session for this repository; an earlier session was interrupted. Invoke the passdown-pickup skill and brief me. Then carry out what it proposes for task 1.1 through the Reconcile section of the passdown-dispatch skill, using this repository's AGENTS.md. Only task 1.1 is in scope for this session; do not start other tasks. If a skill tells you to ask the user something, stop and ask instead of assuming." case "$CRASH_MARKER" in none) marker="" ;; From cd5b25bc344726f63f7d0f804b8ad344f90f0b30 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Mon, 28 Sep 2026 15:35:51 +0700 Subject: [PATCH 3/4] chore: bump the beta to 0.5.0-beta.3 The Layer B host loads passdown from the plugin channel pinned to release/v0.5.0; the new beta makes the plugin update pick up the restored-plan guard before F2 is run again. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 4 ++-- CHANGELOG.md | 20 +++++++++++++++++++ VERSION | 2 +- plugins/passdown/.claude-plugin/plugin.json | 2 +- plugins/passdown/.codex-plugin/plugin.json | 2 +- .../scripts/passdown-attempt | 2 +- .../passdown-handoff/scripts/passdown-attempt | 2 +- .../passdown-pickup/scripts/passdown-attempt | 2 +- scripts/passdown-attempt | 2 +- 9 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 98b442b..4021205 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Shift-handover workflow for AI agents: intake captured ideas, dispatch tasks to the right executor with recoverable attempt receipts, and hand work down between sessions and agents.", - "version": "0.5.0-beta.2" + "version": "0.5.0-beta.3" }, "plugins": [ { "name": "passdown", "displayName": "Passdown", "description": "Intake, dispatch, handoff, and pickup skills for multi-agent, multi-session work.", - "version": "0.5.0-beta.2", + "version": "0.5.0-beta.3", "author": { "name": "vunm", "url": "https://github.com/vunm-io" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3756f8d..2a4f42c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0-beta.3] - 2026-09-28 + +Beta build for the `release/v0.5.0` testing window — not a GitHub release. + +### Fixed + +- The attempt helper refuses to accept an attempt whose plan edit was + restored. Found by the Layer B release gate (scenario F2): after a crash, + a real host restored the worker's forged `[x]` and `Dispatched:` line, + re-inspected, and accepted with `--scope-override`, following a hint in + the helper's own refusal message. Each inspection now records + `plan_touched` (a new optional member of the receipt's inspection + entries), an accept after a restore is refused, and the refusal message + no longer suggests restoring and overriding. The dispatch skill states + that a plan change seen while the verdict is pending is never the + host's own. +- The Layer B pickup session is limited to task 1.1, so the oracle's + final-tree check is not confused by unrelated host work. + ## [0.5.0-beta.2] - 2026-09-27 Beta build for the `release/v0.5.0` testing window — not a GitHub release. @@ -302,6 +321,7 @@ Initial dogfooding snapshot. tags. [Unreleased]: https://github.com/vunm-io/passdown/compare/v0.4.0...HEAD +[0.5.0-beta.3]: https://github.com/vunm-io/passdown/compare/v0.4.0...release/v0.5.0 [0.5.0-beta.2]: https://github.com/vunm-io/passdown/compare/v0.4.0...release/v0.5.0 [0.5.0-beta.1]: https://github.com/vunm-io/passdown/compare/v0.4.0...release/v0.5.0 [0.4.0]: https://github.com/vunm-io/passdown/compare/v0.3.0...v0.4.0 diff --git a/VERSION b/VERSION index 5b56215..ac77bb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0-beta.2 +0.5.0-beta.3 diff --git a/plugins/passdown/.claude-plugin/plugin.json b/plugins/passdown/.claude-plugin/plugin.json index 554b872..3ceea6b 100644 --- a/plugins/passdown/.claude-plugin/plugin.json +++ b/plugins/passdown/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "passdown", "displayName": "Passdown", - "version": "0.5.0-beta.2", + "version": "0.5.0-beta.3", "description": "Intake, dispatch, handoff, and pickup skills for multi-agent, multi-session work.", "author": { "name": "vunm", diff --git a/plugins/passdown/.codex-plugin/plugin.json b/plugins/passdown/.codex-plugin/plugin.json index a917247..1fd1cf3 100644 --- a/plugins/passdown/.codex-plugin/plugin.json +++ b/plugins/passdown/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "passdown", - "version": "0.5.0-beta.2", + "version": "0.5.0-beta.3", "description": "Intake, dispatch, handoff, and pickup skills for multi-agent, multi-session work.", "author": { "name": "vunm", diff --git a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt index c4b810b..0d19518 100755 --- a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt @@ -16,7 +16,7 @@ # shellcheck disable=SC2016,SC2015 set -euo pipefail -PASSDOWN_VERSION="0.5.0-beta.2" +PASSDOWN_VERSION="0.5.0-beta.3" # jq >= 1.7 is required on Windows for its -b (binary output) flag. # Git Bash on Windows rewrites every argument that looks like a POSIX path diff --git a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt index c4b810b..0d19518 100755 --- a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt @@ -16,7 +16,7 @@ # shellcheck disable=SC2016,SC2015 set -euo pipefail -PASSDOWN_VERSION="0.5.0-beta.2" +PASSDOWN_VERSION="0.5.0-beta.3" # jq >= 1.7 is required on Windows for its -b (binary output) flag. # Git Bash on Windows rewrites every argument that looks like a POSIX path diff --git a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt index c4b810b..0d19518 100755 --- a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt @@ -16,7 +16,7 @@ # shellcheck disable=SC2016,SC2015 set -euo pipefail -PASSDOWN_VERSION="0.5.0-beta.2" +PASSDOWN_VERSION="0.5.0-beta.3" # jq >= 1.7 is required on Windows for its -b (binary output) flag. # Git Bash on Windows rewrites every argument that looks like a POSIX path diff --git a/scripts/passdown-attempt b/scripts/passdown-attempt index c4b810b..0d19518 100755 --- a/scripts/passdown-attempt +++ b/scripts/passdown-attempt @@ -16,7 +16,7 @@ # shellcheck disable=SC2016,SC2015 set -euo pipefail -PASSDOWN_VERSION="0.5.0-beta.2" +PASSDOWN_VERSION="0.5.0-beta.3" # jq >= 1.7 is required on Windows for its -b (binary output) flag. # Git Bash on Windows rewrites every argument that looks like a POSIX path From 7aa1abd08dd0a03354227327e4204a89d971eed4 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Mon, 28 Sep 2026 17:24:33 +0700 Subject: [PATCH 4/4] fix: keep plan-tampering evidence a beta.2 helper recorded A receipt inspected by a beta.2 helper has inspection entries without plan_touched; the evidence lives only in artifact.plan_touched and in inspect.json. A beta.3 re-inspection after restoring the plan overwrote it with false and appended a false entry, so the new guard found no earlier true and accepted. Inspect now moves the previous artifact.plan_touched onto the latest entry before overwriting it, and the accept guard also reads inspect.json, which has always held every inspection's full record. tests/attempt.sh covers both paths with receipts shaped like beta.2's; each half of the fix is caught by its own check when removed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++- .../scripts/passdown-attempt | 15 ++++++-- .../passdown-handoff/scripts/passdown-attempt | 15 ++++++-- .../passdown-pickup/scripts/passdown-attempt | 15 ++++++-- scripts/passdown-attempt | 15 ++++++-- tests/attempt.sh | 34 +++++++++++++++++++ 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4f42c..96e39b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,10 @@ Beta build for the `release/v0.5.0` testing window — not a GitHub release. the helper's own refusal message. Each inspection now records `plan_touched` (a new optional member of the receipt's inspection entries), an accept after a restore is refused, and the refusal message - no longer suggests restoring and overriding. The dispatch skill states + no longer suggests restoring and overriding. A receipt inspected by a + beta.2 helper keeps its evidence across the upgrade: the helper carries + the earlier `plan_touched` onto its entry and also reads `inspect.json`. + The dispatch skill states that a plan change seen while the verdict is pending is never the host's own. - The Layer B pickup session is limited to task 1.1, so the oracle's diff --git a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt index 0d19518..6c97939 100755 --- a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt @@ -1700,8 +1700,14 @@ cmd_inspect() { else jq -c -n --argjson rec "$record" '[$rec]' | write_atomic "$STORE/$id/inspect.json" fi + # A receipt written before inspections carried plan_touched (beta.2) keeps + # that evidence only in artifact.plan_touched, the latest inspection's + # value: move it onto that entry before this inspection overwrites it. mutate "$id" "$REV" inspect ' - .execution.reconciled_at = (.execution.reconciled_at // $now) + .artifact.plan_touched as $prev + | .artifact.inspections |= (if length > 0 and (.[-1] | has("plan_touched") | not) and $prev != null + then .[-1].plan_touched = $prev else . end) + | .execution.reconciled_at = (.execution.reconciled_at // $now) | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched @@ -1838,7 +1844,12 @@ verdict_accept() { # 5. scope and plan. The host edits the plan only after the verdict, so a # plan change an inspection saw belongs to the worker (or to the owner). # Restoring it undoes the worker's edit; it never earns an accept. - if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + # inspect.json holds every inspection's full record, including those a + # beta.2 helper made before the receipt's entries carried plan_touched. + local seen=false + if jq -e 'any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then seen=true + elif [ -f "$STORE/$id/inspect.json" ] && jq -e 'any(.[]; .plan_touched == true)' "$STORE/$id/inspect.json" >/dev/null 2>&1; then seen=true; fi + if [ "$seen" = true ] && [ "$(jq -r .artifact.plan_touched <<<"$r")" = false ]; then die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then diff --git a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt index 0d19518..6c97939 100755 --- a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt @@ -1700,8 +1700,14 @@ cmd_inspect() { else jq -c -n --argjson rec "$record" '[$rec]' | write_atomic "$STORE/$id/inspect.json" fi + # A receipt written before inspections carried plan_touched (beta.2) keeps + # that evidence only in artifact.plan_touched, the latest inspection's + # value: move it onto that entry before this inspection overwrites it. mutate "$id" "$REV" inspect ' - .execution.reconciled_at = (.execution.reconciled_at // $now) + .artifact.plan_touched as $prev + | .artifact.inspections |= (if length > 0 and (.[-1] | has("plan_touched") | not) and $prev != null + then .[-1].plan_touched = $prev else . end) + | .execution.reconciled_at = (.execution.reconciled_at // $now) | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched @@ -1838,7 +1844,12 @@ verdict_accept() { # 5. scope and plan. The host edits the plan only after the verdict, so a # plan change an inspection saw belongs to the worker (or to the owner). # Restoring it undoes the worker's edit; it never earns an accept. - if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + # inspect.json holds every inspection's full record, including those a + # beta.2 helper made before the receipt's entries carried plan_touched. + local seen=false + if jq -e 'any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then seen=true + elif [ -f "$STORE/$id/inspect.json" ] && jq -e 'any(.[]; .plan_touched == true)' "$STORE/$id/inspect.json" >/dev/null 2>&1; then seen=true; fi + if [ "$seen" = true ] && [ "$(jq -r .artifact.plan_touched <<<"$r")" = false ]; then die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then diff --git a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt index 0d19518..6c97939 100755 --- a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt @@ -1700,8 +1700,14 @@ cmd_inspect() { else jq -c -n --argjson rec "$record" '[$rec]' | write_atomic "$STORE/$id/inspect.json" fi + # A receipt written before inspections carried plan_touched (beta.2) keeps + # that evidence only in artifact.plan_touched, the latest inspection's + # value: move it onto that entry before this inspection overwrites it. mutate "$id" "$REV" inspect ' - .execution.reconciled_at = (.execution.reconciled_at // $now) + .artifact.plan_touched as $prev + | .artifact.inspections |= (if length > 0 and (.[-1] | has("plan_touched") | not) and $prev != null + then .[-1].plan_touched = $prev else . end) + | .execution.reconciled_at = (.execution.reconciled_at // $now) | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched @@ -1838,7 +1844,12 @@ verdict_accept() { # 5. scope and plan. The host edits the plan only after the verdict, so a # plan change an inspection saw belongs to the worker (or to the owner). # Restoring it undoes the worker's edit; it never earns an accept. - if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + # inspect.json holds every inspection's full record, including those a + # beta.2 helper made before the receipt's entries carried plan_touched. + local seen=false + if jq -e 'any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then seen=true + elif [ -f "$STORE/$id/inspect.json" ] && jq -e 'any(.[]; .plan_touched == true)' "$STORE/$id/inspect.json" >/dev/null 2>&1; then seen=true; fi + if [ "$seen" = true ] && [ "$(jq -r .artifact.plan_touched <<<"$r")" = false ]; then die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then diff --git a/scripts/passdown-attempt b/scripts/passdown-attempt index 0d19518..6c97939 100755 --- a/scripts/passdown-attempt +++ b/scripts/passdown-attempt @@ -1700,8 +1700,14 @@ cmd_inspect() { else jq -c -n --argjson rec "$record" '[$rec]' | write_atomic "$STORE/$id/inspect.json" fi + # A receipt written before inspections carried plan_touched (beta.2) keeps + # that evidence only in artifact.plan_touched, the latest inspection's + # value: move it onto that entry before this inspection overwrites it. mutate "$id" "$REV" inspect ' - .execution.reconciled_at = (.execution.reconciled_at // $now) + .artifact.plan_touched as $prev + | .artifact.inspections |= (if length > 0 and (.[-1] | has("plan_touched") | not) and $prev != null + then .[-1].plan_touched = $prev else . end) + | .execution.reconciled_at = (.execution.reconciled_at // $now) | .artifact.digest = $a.digest | .artifact.changed = $a.changed | .artifact.out_of_scope = $a.out_of_scope | .artifact.overlaps_baseline = $a.overlaps_baseline | .artifact.plan_touched = $a.plan_touched @@ -1838,7 +1844,12 @@ verdict_accept() { # 5. scope and plan. The host edits the plan only after the verdict, so a # plan change an inspection saw belongs to the worker (or to the owner). # Restoring it undoes the worker's edit; it never earns an accept. - if jq -e '.artifact.plan_touched == false and any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then + # inspect.json holds every inspection's full record, including those a + # beta.2 helper made before the receipt's entries carried plan_touched. + local seen=false + if jq -e 'any(.artifact.inspections[]; .plan_touched == true)' <<<"$r" >/dev/null; then seen=true + elif [ -f "$STORE/$id/inspect.json" ] && jq -e 'any(.[]; .plan_touched == true)' "$STORE/$id/inspect.json" >/dev/null 2>&1; then seen=true; fi + if [ "$seen" = true ] && [ "$(jq -r .artifact.plan_touched <<<"$r")" = false ]; then die $E_STATE "accept: an earlier inspection saw the plan touched and it has since been restored; the host edits the plan only after the verdict, so that edit was the worker's: reject plan_tampered" fi if [ "$(jq -r '(.artifact.out_of_scope | length) > 0 or .artifact.plan_touched' <<<"$r")" = true ] && [ -z "$override" ]; then diff --git a/tests/attempt.sh b/tests/attempt.sh index c9063ae..41ab120 100755 --- a/tests/attempt.sh +++ b/tests/attempt.sh @@ -365,6 +365,40 @@ group_acceptance() { ok "reject plan_tampered" --store "$store" verdict "$id" reject --rev "$(rev "$id")" --reason-code plan_tampered --reason "ticked its own box" pass "a restored worker plan edit is rejected plan_tampered, never accepted (Layer B F2)" + # The same guard across the beta.2 -> beta.3 upgrade: a beta.2 helper kept + # plan_touched only in artifact.plan_touched (its inspection entries have + # no plan_touched) and in inspect.json. strip_history makes a receipt look + # like one it wrote. + strip_history() { jq '.artifact.inspections |= map(del(.plan_touched))' "$store/$1/receipt.json" >"$scratch/r.json" && + mv "$scratch/r.json" "$store/$1/receipt.json"; } + # (a) one beta.2 inspection saw the plan touched; beta.3 restores and re-inspects. + git -C "$repo" checkout -q docs/plan.md + rm -f "$repo/src/hello.txt" + new_write "$repo" + drive "$id" "perl -pi -e 's/- \\[ \\] 1.1/- [x] 1.1/' '$repo/docs/plan.md'; echo hello > '$repo/src/hello.txt'" + ok "result" --store "$store" result "$id" --rev "$(rev "$id")" --payload "$(result_payload "$id" submitted)" + ok "inspect (beta.2)" --store "$store" inspect "$id" --rev "$(rev "$id")" + strip_history "$id" + rm "$store/$id/inspect.json" + git -C "$repo" checkout -q docs/plan.md + ok "re-inspect after restore (beta.3)" --store "$store" inspect "$id" --rev "$(rev "$id")" + [ "$(field "$id" '.artifact.inspections[0].plan_touched')" = true ] || fail "the beta.2 evidence was not carried onto its entry" + run 3 "accept a restored beta.2 plan edit" --store "$store" verdict "$id" accept --rev "$(rev "$id")" --check "t=0:$check_ok" + ok "reject plan_tampered" --store "$store" verdict "$id" reject --rev "$(rev "$id")" --reason-code plan_tampered --reason "ticked its own box" + # (b) beta.2 already restored and re-inspected; only inspect.json remembers. + git -C "$repo" checkout -q docs/plan.md + rm -f "$repo/src/hello.txt" + new_write "$repo" + drive "$id" "perl -pi -e 's/- \\[ \\] 1.1/- [x] 1.1/' '$repo/docs/plan.md'; echo hello > '$repo/src/hello.txt'" + ok "result" --store "$store" result "$id" --rev "$(rev "$id")" --payload "$(result_payload "$id" submitted)" + ok "inspect (beta.2)" --store "$store" inspect "$id" --rev "$(rev "$id")" + git -C "$repo" checkout -q docs/plan.md + ok "re-inspect after restore (beta.2)" --store "$store" inspect "$id" --rev "$(rev "$id")" + strip_history "$id" + run 3 "accept after a restore made under beta.2" --store "$store" verdict "$id" accept --rev "$(rev "$id")" --check "t=0:$check_ok" + ok "reject plan_tampered" --store "$store" verdict "$id" reject --rev "$(rev "$id")" --reason-code plan_tampered --reason "ticked its own box" + pass "a plan edit a beta.2 helper inspected still blocks the accept after an upgrade" + # An edit to the attempt's own task text is not plan_touched: the task # digest goes stale instead, whoever made the edit (F3, F6). git -C "$repo" checkout -q docs/plan.md