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..96e39b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ 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. 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 + 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 +324,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/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/.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/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..6c97939 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 @@ -1700,12 +1700,18 @@ 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 - | .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 +1841,19 @@ 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. + # 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 - 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..6c97939 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 @@ -1700,12 +1700,18 @@ 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 - | .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 +1841,19 @@ 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. + # 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 - 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..6c97939 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 @@ -1700,12 +1700,18 @@ 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 - | .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 +1841,19 @@ 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. + # 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 - 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..6c97939 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 @@ -1700,12 +1700,18 @@ 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 - | .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 +1841,19 @@ 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. + # 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 - 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..41ab120 100755 --- a/tests/attempt.sh +++ b/tests/attempt.sh @@ -345,6 +345,60 @@ 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)" + + # 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 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="" ;; 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" \