@kontourai/flow-agents 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env bash
2
+ # test_takeover_protocol.sh — takeover protocol integration eval (issue #294, ADR 0021 §5).
3
+ #
4
+ # Exercises `runTakeoverPreflight()`/`takeover-preflight` CLI subcommand and its companion
5
+ # `ensure-session --supersede-stale` takeover-as-resumption path (src/cli/workflow-sidecar.ts),
6
+ # reusing the SAME assignment ⋈ liveness join #290/#291 already built (computeEffectiveState/
7
+ # readLocalAssignmentStatus/freshHolders) — no second computation is invented here.
8
+ #
9
+ # Follows evals/integration/test_verify_hold.sh's harness conventions VERBATIM (set -uo
10
+ # pipefail / ROOT / node.sh / WRITER / TMPDIR_EVAL / trap / ARTIFACT_ROOT / pass|fail|errors /
11
+ # json_query / flow_agents_node) and reuses its write_assignment_record / append_liveness_event
12
+ # fixture-writer helpers verbatim — no new plumbing invented where an existing helper already
13
+ # does the job.
14
+ #
15
+ # Sections (1-to-1 with the plan's acceptance criteria):
16
+ # 1. AC1 — takeover offered + continues the incumbent's branch (the headline): a STALE record
17
+ # with a specific branch resolves `grace-then-supersede`/`reclaimable`; `--supersede-stale`
18
+ # as a different successor prints SupersedeTakeover with resumed_branch == the incumbent's
19
+ # branch (NOT a new branch) and records a `supersede` audit entry ("resuming from trust
20
+ # bundle").
21
+ # 2. AC2a — revive during grace -> back off: a fresh incumbent liveness heartbeat flips the
22
+ # same fixture to `back-off`/`held`/ok:false; a follow-on `--supersede-stale` REFUSES.
23
+ # 3. AC2b — a superseded incumbent is blocked at publish (compose with verify-hold, #293): the
24
+ # woken zombie incumbent's verify-hold on the AC1 subject exits non-zero.
25
+ # 4. free / self / human quick cases: no record -> `claim`/free; self+fresh -> `proceed`.
26
+ # 5. Injection discipline: hostile control bytes + >64 chars in actor_key/branch never leak raw
27
+ # into holder.actor / resume_branch, and are length-capped (<=64). Modeled on
28
+ # test_verify_hold.sh's AC7 / section-6 injection check.
29
+ #
30
+ # Deterministic, no model spend, self-cleaning, no network, no `gh` process anywhere.
31
+ # Usage: bash evals/integration/test_takeover_protocol.sh
32
+
33
+ set -uo pipefail
34
+
35
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
36
+ source "$ROOT/evals/lib/node.sh"
37
+
38
+ CLI="$ROOT/build/src/cli.js"
39
+ WRITER="workflow-sidecar"
40
+
41
+ TMPDIR_EVAL="$(mktemp -d)"
42
+ trap 'rm -rf "$TMPDIR_EVAL"' EXIT
43
+ ARTIFACT_ROOT="$TMPDIR_EVAL/artifact-root"
44
+
45
+ errors=0
46
+ pass() { echo " [PASS] $1"; }
47
+ fail() { echo " [FAIL] $1"; errors=$((errors + 1)); }
48
+
49
+ json_query() {
50
+ node -e 'const fs=require("fs"); let cur=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); for (const part of process.argv[2].split(".")) cur=part==="length" ? cur.length : (Array.isArray(cur) ? cur[Number(part)] : cur[part]); console.log(cur);' "$1" "$2"
51
+ }
52
+
53
+ # append_liveness_event <root> <subjectId> <actor> <at-iso> [ttlSeconds]
54
+ # Reused verbatim from test_verify_hold.sh's own helper.
55
+ append_liveness_event() {
56
+ local root="$1" subject="$2" actor="$3" at="$4" ttl="${5:-1800}"
57
+ mkdir -p "$root/liveness"
58
+ node -e '
59
+ const fs = require("fs");
60
+ const evt = { type: "claim", subjectId: process.argv[1], actor: process.argv[2], at: process.argv[3], ttlSeconds: Number(process.argv[4]) };
61
+ fs.appendFileSync(process.argv[5], JSON.stringify(evt) + "\n");
62
+ ' "$subject" "$actor" "$at" "$ttl" "$root/liveness/events.jsonl"
63
+ }
64
+
65
+ # write_assignment_record <artifactRoot> <slug> <sessionId> <actorKey> [claimedAt]
66
+ # Reused verbatim from test_verify_hold.sh's own helper. Writes a minimal, well-formed,
67
+ # ALREADY-CLAIMED assignment record directly (branch:"main", claimed_at defaults to now). For a
68
+ # staleness fixture with NO liveness event, computeEffectiveState classifies it reclaimable.
69
+ write_assignment_record() {
70
+ local root="$1" slug="$2" sessionId="$3" actorKey="$4" claimedAt="${5:-}"
71
+ mkdir -p "$root/assignment"
72
+ node -e '
73
+ const fs = require("fs");
74
+ const [dest, slug, sessionId, actorKey, claimedAtOverride] = process.argv.slice(1);
75
+ const now = new Date().toISOString();
76
+ const claimedAt = claimedAtOverride || now;
77
+ const actor = { runtime: "claude-code", session_id: sessionId, host: "eval-host" };
78
+ const rec = {
79
+ schema_version: "1.0", role: "AssignmentClaimRecord", subject_id: slug,
80
+ actor, actor_key: actorKey, claimed_at: claimedAt, ttl_seconds: 1800, branch: "main",
81
+ artifact_dir: slug, status: "claimed",
82
+ audit_trail: [{ at: now, transition: "claim", from_actor: null, to_actor: actor, reason: "claim" }],
83
+ };
84
+ fs.writeFileSync(dest, JSON.stringify(rec, null, 2));
85
+ ' "$root/assignment/$slug.json" "$slug" "$sessionId" "$actorKey" "$claimedAt"
86
+ }
87
+
88
+ # patch_record_branch <assignmentRecord.json> <branch> — read/modify/write the record's `branch`
89
+ # field so takeover-preflight/SupersedeTakeover surface a SPECIFIC resume branch (write_assignment_record
90
+ # always sets branch:"main"). This is the incumbent's branch the successor must CONTINUE (AC1).
91
+ patch_record_branch() {
92
+ node -e '
93
+ const fs = require("fs");
94
+ const [file, branch] = process.argv.slice(1);
95
+ const rec = JSON.parse(fs.readFileSync(file, "utf8"));
96
+ rec.branch = branch;
97
+ fs.writeFileSync(file, JSON.stringify(rec, null, 2));
98
+ ' "$1" "$2"
99
+ }
100
+
101
+ if [[ ! -f "$CLI" ]]; then
102
+ echo "build/src/cli.js not found — run 'npm run build' first" >&2
103
+ exit 1
104
+ fi
105
+ flow_agents_build_ts || { echo "build failed" >&2; exit 1; }
106
+
107
+ echo "=== takeover protocol (#294) ==="
108
+
109
+ # ─── 1. AC1 — takeover offered + continues the incumbent's branch (the headline) ───────────
110
+ echo "--- 1. AC1: stale claim -> grace-then-supersede; --supersede-stale continues the incumbent's branch (not a new one) ---"
111
+
112
+ AC1_SLUG="takeover-ac1-resume-branch"
113
+ AC1_DIR="$ARTIFACT_ROOT/$AC1_SLUG"
114
+ INCUMBENT_ACTOR="eval-actor-incumbent-a"
115
+ SUCCESSOR_ACTOR="eval-actor-successor"
116
+ AC1_BRANCH="agent/a/$AC1_SLUG"
117
+ AC1_RECORD="$ARTIFACT_ROOT/assignment/$AC1_SLUG.json"
118
+
119
+ # Seed a STALE reclaimable record: write_assignment_record (branch:"main", claimed_at=now) then
120
+ # patch its branch to the incumbent's real branch. NO liveness event -> no fresh heartbeat, so
121
+ # the join classifies it reclaimable (assignment_present_liveness_stale_or_absent).
122
+ write_assignment_record "$ARTIFACT_ROOT" "$AC1_SLUG" "incumbent-a-session" "$INCUMBENT_ACTOR"
123
+ patch_record_branch "$AC1_RECORD" "$AC1_BRANCH"
124
+ mkdir -p "$AC1_DIR"
125
+
126
+ if flow_agents_node "$WRITER" takeover-preflight "$AC1_DIR" --actor "$SUCCESSOR_ACTOR" \
127
+ >"$TMPDIR_EVAL/tp-ac1.out" 2>"$TMPDIR_EVAL/tp-ac1.err"; then
128
+ pass "takeover-preflight on a reclaimable subject exits 0 (ok:true — a takeover is offered) (AC1)"
129
+ else
130
+ fail "takeover-preflight on a reclaimable subject should have exited 0: $(cat "$TMPDIR_EVAL/tp-ac1.out" "$TMPDIR_EVAL/tp-ac1.err")"
131
+ fi
132
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "role")" == "TakeoverPreflight" ]] && pass "takeover-preflight JSON role is TakeoverPreflight (AC1)" || fail "takeover-preflight JSON role was not TakeoverPreflight: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
133
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "action")" == "grace-then-supersede" ]] && pass "takeover-preflight action is grace-then-supersede for a reclaimable subject (AC1)" || fail "takeover-preflight action was not grace-then-supersede: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
134
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "effective_state")" == "reclaimable" ]] && pass "takeover-preflight effective_state is reclaimable (AC1)" || fail "takeover-preflight effective_state was not reclaimable: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
135
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "resume_branch")" == "$AC1_BRANCH" ]] && pass "takeover-preflight resume_branch is the incumbent's branch ($AC1_BRANCH), never a new one (AC1 headline)" || fail "takeover-preflight resume_branch was not the incumbent's branch: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
136
+ AC1_GRACE="$(json_query "$TMPDIR_EVAL/tp-ac1.out" "grace_seconds")"
137
+ if [[ "$AC1_GRACE" =~ ^[0-9]+$ && "$AC1_GRACE" -gt 0 ]]; then
138
+ pass "takeover-preflight grace_seconds is a positive number ($AC1_GRACE) (AC1)"
139
+ else
140
+ fail "takeover-preflight grace_seconds was not a positive number: '$AC1_GRACE'"
141
+ fi
142
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1.out" "holder.actor")" == "$INCUMBENT_ACTOR" ]] && pass "takeover-preflight holder.actor names the incumbent ($INCUMBENT_ACTOR) (AC1)" || fail "takeover-preflight holder.actor did not name the incumbent: $(cat "$TMPDIR_EVAL/tp-ac1.out")"
143
+
144
+ # Now the successor takes over via ensure-session --supersede-stale — takeover-as-resumption:
145
+ # resumed_branch must be the incumbent's branch (NOT a fresh branch), and a `supersede` audit
146
+ # entry naming the incumbent + "resuming from trust bundle" must be recorded.
147
+ flow_agents_node "$WRITER" ensure-session \
148
+ --flow-id builder.build \
149
+ --task-slug "$AC1_SLUG" \
150
+ --work-item "kontourai/flow-agents#294" \
151
+ --title "Takeover AC1" \
152
+ --artifact-root "$ARTIFACT_ROOT" \
153
+ --supersede-stale \
154
+ --actor "$SUCCESSOR_ACTOR" \
155
+ >"$TMPDIR_EVAL/tp-ac1-supersede.out" 2>"$TMPDIR_EVAL/tp-ac1-supersede.err"
156
+ AC1_SUPERSEDE_EXIT=$?
157
+ [[ $AC1_SUPERSEDE_EXIT -eq 0 ]] && pass "ensure-session --supersede-stale as the successor exits 0 (AC1)" || fail "ensure-session --supersede-stale unexpectedly failed: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out" "$TMPDIR_EVAL/tp-ac1-supersede.err")"
158
+ # ensure-session's stdout carries the single-line SupersedeTakeover JSON AND a trailing
159
+ # `console.log(dir)` line — isolate the JSON object line (printJson emits it single-line) before
160
+ # json_query'ing it.
161
+ grep -F '"role": "SupersedeTakeover"' "$TMPDIR_EVAL/tp-ac1-supersede.out" > "$TMPDIR_EVAL/tp-ac1-supersede.json" || true
162
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1-supersede.json" "role")" == "SupersedeTakeover" ]] && pass "supersede stdout JSON role is SupersedeTakeover (AC1)" || fail "supersede stdout JSON role was not SupersedeTakeover: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out")"
163
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1-supersede.json" "resumed_branch")" == "$AC1_BRANCH" ]] && pass "supersede resumed_branch is the incumbent's branch ($AC1_BRANCH), NOT a new branch (AC1 headline)" || fail "supersede resumed_branch was not the incumbent's branch: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out")"
164
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac1-supersede.json" "superseded_actor")" == "$INCUMBENT_ACTOR" ]] && pass "supersede superseded_actor names the incumbent ($INCUMBENT_ACTOR) (AC1)" || fail "supersede superseded_actor did not name the incumbent: $(cat "$TMPDIR_EVAL/tp-ac1-supersede.out")"
165
+
166
+ # The record now carries a `supersede` audit_trail entry whose reason resumes from the trust
167
+ # bundle and names the superseded incumbent.
168
+ AC1_SUPERSEDE_REASON="$(node -e '
169
+ const fs = require("fs");
170
+ const rec = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
171
+ const trail = Array.isArray(rec.audit_trail) ? rec.audit_trail : [];
172
+ const entry = trail.find((e) => e && e.transition === "supersede");
173
+ process.stdout.write(entry && typeof entry.reason === "string" ? entry.reason : "");
174
+ ' "$AC1_RECORD")"
175
+ [[ -n "$AC1_SUPERSEDE_REASON" ]] && pass "the assignment record now contains a supersede audit_trail entry (AC1)" || fail "no supersede audit_trail entry found on the record: $(cat "$AC1_RECORD")"
176
+ [[ "$AC1_SUPERSEDE_REASON" == *"resuming from trust bundle"* ]] && pass "the supersede audit reason contains 'resuming from trust bundle' (AC1)" || fail "supersede audit reason did not contain 'resuming from trust bundle': '$AC1_SUPERSEDE_REASON'"
177
+ [[ "$AC1_SUPERSEDE_REASON" == *"$INCUMBENT_ACTOR"* ]] && pass "the supersede audit reason names the superseded incumbent ($INCUMBENT_ACTOR) (AC1)" || fail "supersede audit reason did not name the superseded incumbent: '$AC1_SUPERSEDE_REASON'"
178
+
179
+ # ─── 2. AC2a — revive during grace -> back off; --supersede-stale then REFUSES ─────────────
180
+ echo "--- 2. AC2a: a fresh incumbent liveness heartbeat flips reclaimable -> back-off/held; --supersede-stale then refuses ---"
181
+
182
+ AC2A_SLUG="takeover-ac2a-revive"
183
+ AC2A_DIR="$ARTIFACT_ROOT/$AC2A_SLUG"
184
+ AC2A_INCUMBENT="eval-actor-incumbent-revived"
185
+ write_assignment_record "$ARTIFACT_ROOT" "$AC2A_SLUG" "incumbent-revived-session" "$AC2A_INCUMBENT"
186
+ mkdir -p "$AC2A_DIR"
187
+
188
+ # A FRESH liveness heartbeat for the INCUMBENT — the incumbent revived during the grace window,
189
+ # so the join now classifies the subject held(holder=incumbent), not reclaimable.
190
+ append_liveness_event "$ARTIFACT_ROOT" "$AC2A_SLUG" "$AC2A_INCUMBENT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
191
+
192
+ if flow_agents_node "$WRITER" takeover-preflight "$AC2A_DIR" --actor "$SUCCESSOR_ACTOR" \
193
+ >"$TMPDIR_EVAL/tp-ac2a.out" 2>"$TMPDIR_EVAL/tp-ac2a.err"; then
194
+ fail "takeover-preflight against a revived incumbent should have exited non-zero (back off) (AC2a)"
195
+ else
196
+ pass "takeover-preflight against a revived incumbent exits non-zero — do NOT supersede a live holder (AC2a)"
197
+ fi
198
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2a.out" "ok")" == "false" ]] && pass "takeover-preflight reports ok:false for the revived incumbent (AC2a)" || fail "takeover-preflight did not report ok:false: $(cat "$TMPDIR_EVAL/tp-ac2a.out")"
199
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2a.out" "action")" == "back-off" ]] && pass "takeover-preflight action is back-off for the revived incumbent (AC2a)" || fail "takeover-preflight action was not back-off: $(cat "$TMPDIR_EVAL/tp-ac2a.out")"
200
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2a.out" "effective_state")" == "held" ]] && pass "takeover-preflight effective_state is held (incumbent revived) (AC2a)" || fail "takeover-preflight effective_state was not held: $(cat "$TMPDIR_EVAL/tp-ac2a.out")"
201
+
202
+ # The successor's --supersede-stale must now REFUSE (the state is held, not reclaimable) and never
203
+ # write a supersede audit entry.
204
+ flow_agents_node "$WRITER" ensure-session \
205
+ --flow-id builder.build \
206
+ --task-slug "$AC2A_SLUG" \
207
+ --work-item "kontourai/flow-agents#294" \
208
+ --title "Takeover AC2a" \
209
+ --artifact-root "$ARTIFACT_ROOT" \
210
+ --supersede-stale \
211
+ --actor "$SUCCESSOR_ACTOR" \
212
+ >"$TMPDIR_EVAL/tp-ac2a-supersede.out" 2>"$TMPDIR_EVAL/tp-ac2a-supersede.err"
213
+ AC2A_SUPERSEDE_EXIT=$?
214
+ [[ $AC2A_SUPERSEDE_EXIT -ne 0 ]] && pass "ensure-session --supersede-stale as the successor REFUSES (exits non-zero) because the incumbent revived (AC2a)" || fail "ensure-session --supersede-stale should have refused against a revived incumbent: $(cat "$TMPDIR_EVAL/tp-ac2a-supersede.out" "$TMPDIR_EVAL/tp-ac2a-supersede.err")"
215
+ AC2A_HAS_SUPERSEDE="$(node -e '
216
+ const fs = require("fs");
217
+ const rec = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
218
+ const trail = Array.isArray(rec.audit_trail) ? rec.audit_trail : [];
219
+ process.stdout.write(trail.some((e) => e && e.transition === "supersede") ? "yes" : "no");
220
+ ' "$ARTIFACT_ROOT/assignment/$AC2A_SLUG.json")"
221
+ [[ "$AC2A_HAS_SUPERSEDE" == "no" ]] && pass "no supersede audit entry was written for the refused takeover — the live incumbent's record is untouched (AC2a)" || fail "a supersede audit entry was written despite the refusal (AC2a)"
222
+
223
+ # ─── 3. AC2b — a superseded incumbent is blocked at publish (compose with verify-hold, #293) ─
224
+ echo "--- 3. AC2b: the woken zombie incumbent is blocked by verify-hold on the AC1 subject (takeover + #293 gate compose) ---"
225
+
226
+ # After AC1's takeover the AC1 record is held by the successor (successor's actor_key). The
227
+ # original incumbent, if it wakes, is no longer the holder — verify-hold (#293) must block it.
228
+ if flow_agents_node "$WRITER" verify-hold "$AC1_DIR" --actor "$INCUMBENT_ACTOR" \
229
+ >"$TMPDIR_EVAL/tp-ac2b.out" 2>"$TMPDIR_EVAL/tp-ac2b.err"; then
230
+ fail "verify-hold for the superseded (zombie) incumbent should have exited non-zero — it no longer holds the subject (AC2b)"
231
+ else
232
+ pass "verify-hold blocks the woken zombie incumbent (exit non-zero) — takeover + the #293 gate compose (AC2b)"
233
+ fi
234
+ [[ "$(json_query "$TMPDIR_EVAL/tp-ac2b.out" "ok")" == "false" ]] && pass "verify-hold reports ok:false for the superseded incumbent (AC2b)" || fail "verify-hold did not report ok:false for the superseded incumbent: $(cat "$TMPDIR_EVAL/tp-ac2b.out")"
235
+
236
+ # ─── 4. free / self quick cases ────────────────────────────────────────────────────────────
237
+ echo "--- 4. free (no record) -> claim; self+fresh -> proceed ---"
238
+
239
+ # free: no assignment record at all for this fresh slug.
240
+ FREE_SLUG="takeover-free-subject"
241
+ FREE_DIR="$ARTIFACT_ROOT/$FREE_SLUG"
242
+ mkdir -p "$FREE_DIR"
243
+ if flow_agents_node "$WRITER" takeover-preflight "$FREE_DIR" --actor "$SUCCESSOR_ACTOR" \
244
+ >"$TMPDIR_EVAL/tp-free.out" 2>"$TMPDIR_EVAL/tp-free.err"; then
245
+ pass "takeover-preflight on a free subject (no record) exits 0 (AC free)"
246
+ else
247
+ fail "takeover-preflight on a free subject unexpectedly refused: $(cat "$TMPDIR_EVAL/tp-free.out" "$TMPDIR_EVAL/tp-free.err")"
248
+ fi
249
+ [[ "$(json_query "$TMPDIR_EVAL/tp-free.out" "action")" == "claim" ]] && pass "takeover-preflight action is claim for a free subject (AC free)" || fail "takeover-preflight action was not claim: $(cat "$TMPDIR_EVAL/tp-free.out")"
250
+ [[ "$(json_query "$TMPDIR_EVAL/tp-free.out" "effective_state")" == "free" ]] && pass "takeover-preflight effective_state is free for a free subject (AC free)" || fail "takeover-preflight effective_state was not free: $(cat "$TMPDIR_EVAL/tp-free.out")"
251
+
252
+ # self_is_holder: a fresh record + fresh liveness for the SAME actor passed as --actor.
253
+ SELF_SLUG="takeover-self-holder"
254
+ SELF_DIR="$ARTIFACT_ROOT/$SELF_SLUG"
255
+ SELF_ACTOR="eval-actor-self-holder"
256
+ write_assignment_record "$ARTIFACT_ROOT" "$SELF_SLUG" "self-holder-session" "$SELF_ACTOR"
257
+ append_liveness_event "$ARTIFACT_ROOT" "$SELF_SLUG" "$SELF_ACTOR" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
258
+ mkdir -p "$SELF_DIR"
259
+ if flow_agents_node "$WRITER" takeover-preflight "$SELF_DIR" --actor "$SELF_ACTOR" \
260
+ >"$TMPDIR_EVAL/tp-self.out" 2>"$TMPDIR_EVAL/tp-self.err"; then
261
+ pass "takeover-preflight when the current actor IS the fresh holder exits 0 (AC self)"
262
+ else
263
+ fail "takeover-preflight for the self holder unexpectedly refused: $(cat "$TMPDIR_EVAL/tp-self.out" "$TMPDIR_EVAL/tp-self.err")"
264
+ fi
265
+ [[ "$(json_query "$TMPDIR_EVAL/tp-self.out" "action")" == "proceed" ]] && pass "takeover-preflight action is proceed when self is the holder (AC self)" || fail "takeover-preflight action was not proceed: $(cat "$TMPDIR_EVAL/tp-self.out")"
266
+
267
+ # ─── 5. Injection discipline — hostile control bytes + >64 chars are stripped AND capped ─────
268
+ echo "--- 5. injection: hostile actor_key/branch never leak raw control/ANSI bytes; holder.actor & resume_branch are length-capped (<=64) ---"
269
+
270
+ HOSTILE_SLUG="takeover-hostile-injection"
271
+ HOSTILE_DIR="$ARTIFACT_ROOT/$HOSTILE_SLUG"
272
+ HOSTILE_RECORD="$ARTIFACT_ROOT/assignment/$HOSTILE_SLUG.json"
273
+ # actor_key and branch: embed raw ESC (\x1b) + BEL (\x07) control bytes AND >64 chars each.
274
+ HOSTILE_ACTOR=$'hostile-actor\x1b[31;1mFAKE\x07-admin-'"$(printf 'A%.0s' {1..300})"
275
+ HOSTILE_BRANCH=$'agent/hostile\x1b[31;1mFAKE\x07-branch-'"$(printf 'B%.0s' {1..300})"
276
+ write_assignment_record "$ARTIFACT_ROOT" "$HOSTILE_SLUG" "hostile-session" "$HOSTILE_ACTOR"
277
+ patch_record_branch "$HOSTILE_RECORD" "$HOSTILE_BRANCH"
278
+ mkdir -p "$HOSTILE_DIR"
279
+
280
+ # No liveness event -> reclaimable, so both holder.actor (from the record's actor_key) and
281
+ # resume_branch (from the record's branch) are populated and pass through sanitize().
282
+ flow_agents_node "$WRITER" takeover-preflight "$HOSTILE_DIR" --actor eval-actor-hostile-reader \
283
+ >"$TMPDIR_EVAL/tp-hostile.out" 2>"$TMPDIR_EVAL/tp-hostile.err"
284
+
285
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/tp-hostile.out" "$TMPDIR_EVAL/tp-hostile.err" || grep -qF $'\x07' "$TMPDIR_EVAL/tp-hostile.out" "$TMPDIR_EVAL/tp-hostile.err"; then
286
+ fail "hostile actor_key/branch's raw ANSI/control bytes leaked into takeover-preflight's output (stdout+stderr) (injection AC)"
287
+ else
288
+ pass "hostile actor_key/branch's raw ANSI/control bytes never leak into takeover-preflight's output (injection AC)"
289
+ fi
290
+
291
+ HOSTILE_HOLDER_ACTOR="$(json_query "$TMPDIR_EVAL/tp-hostile.out" "holder.actor")"
292
+ if [[ "$HOSTILE_HOLDER_ACTOR" == *$'\x1b'* || "$HOSTILE_HOLDER_ACTOR" == *$'\x07'* ]]; then
293
+ fail "holder.actor leaked raw control/ANSI bytes (injection AC): $HOSTILE_HOLDER_ACTOR"
294
+ else
295
+ pass "holder.actor is control-char-stripped (injection AC)"
296
+ fi
297
+ HOSTILE_HOLDER_ACTOR_LEN="${#HOSTILE_HOLDER_ACTOR}"
298
+ [[ "$HOSTILE_HOLDER_ACTOR_LEN" -le 64 ]] && pass "holder.actor is length-capped at <=64 chars despite a 300+ char source (len=$HOSTILE_HOLDER_ACTOR_LEN) (injection AC)" || fail "holder.actor exceeded the 64-char cap (len=$HOSTILE_HOLDER_ACTOR_LEN): $HOSTILE_HOLDER_ACTOR"
299
+
300
+ HOSTILE_RESUME_BRANCH="$(json_query "$TMPDIR_EVAL/tp-hostile.out" "resume_branch")"
301
+ if [[ "$HOSTILE_RESUME_BRANCH" == *$'\x1b'* || "$HOSTILE_RESUME_BRANCH" == *$'\x07'* ]]; then
302
+ fail "resume_branch leaked raw control/ANSI bytes (injection AC): $HOSTILE_RESUME_BRANCH"
303
+ else
304
+ pass "resume_branch is control-char-stripped (injection AC)"
305
+ fi
306
+ HOSTILE_RESUME_BRANCH_LEN="${#HOSTILE_RESUME_BRANCH}"
307
+ # Branch uses the 240-char FREE-TEXT tier (not the 64-char id tier holder.actor uses): a realistic
308
+ # `agent/<actor>/<slug>` branch legitimately exceeds 64 chars, so capping the branch at 64 would
309
+ # truncate a VALID branch into a non-existent `git checkout` target (review finding #2). It is still
310
+ # capped (bounded at 240) and control-stripped (asserted above).
311
+ [[ "$HOSTILE_RESUME_BRANCH_LEN" -le 240 ]] && pass "resume_branch is length-capped at <=240 (free-text tier) despite a 300+ char source (len=$HOSTILE_RESUME_BRANCH_LEN) (injection AC)" || fail "resume_branch exceeded the 240-char free-text cap (len=$HOSTILE_RESUME_BRANCH_LEN): $HOSTILE_RESUME_BRANCH"
312
+
313
+ # ─── 6. A legitimate LONG branch (>64 chars, no control bytes) survives INTACT (review finding #2) ─
314
+ echo "--- 6. a realistic >64-char branch is NOT truncated (resume path would otherwise checkout a bad target) ---"
315
+ LONG_SLUG="takeover-long-branch"
316
+ LONG_DIR="$ARTIFACT_ROOT/$LONG_SLUG"
317
+ LONG_RECORD="$ARTIFACT_ROOT/assignment/$LONG_SLUG.json"
318
+ # 78 chars, well over 64, entirely valid git-branch charset — the exact case that would break AC1 if
319
+ # the branch were capped at 64.
320
+ LONG_BRANCH="agent/claude-code-sess-abcdef0123456789-hostname/flow-agents-294-takeover-xyz"
321
+ write_assignment_record "$ARTIFACT_ROOT" "$LONG_SLUG" "long-session" "long-actor-key"
322
+ patch_record_branch "$LONG_RECORD" "$LONG_BRANCH"
323
+ mkdir -p "$LONG_DIR"
324
+ flow_agents_node "$WRITER" takeover-preflight "$LONG_DIR" --actor eval-actor-long-reader \
325
+ >"$TMPDIR_EVAL/tp-long.out" 2>"$TMPDIR_EVAL/tp-long.err"
326
+ LONG_RESUME_BRANCH="$(json_query "$TMPDIR_EVAL/tp-long.out" "resume_branch")"
327
+ [[ "$LONG_RESUME_BRANCH" == "$LONG_BRANCH" ]] && pass "a legitimate ${#LONG_BRANCH}-char branch survives INTACT in resume_branch (never truncated into a bad checkout target — AC1 / review finding #2)" || fail "a legitimate long branch was altered/truncated in resume_branch: got '$LONG_RESUME_BRANCH' expected '$LONG_BRANCH'"
328
+ # And through the real supersede (resumed_branch) path.
329
+ flow_agents_node "$WRITER" ensure-session --flow-id builder.build --task-slug "$LONG_SLUG" --work-item 294 --title t --artifact-root "$ARTIFACT_ROOT" --supersede-stale --actor eval-actor-long-reader \
330
+ >"$TMPDIR_EVAL/tp-long-supersede.out" 2>"$TMPDIR_EVAL/tp-long-supersede.err"
331
+ grep -F '"role": "SupersedeTakeover"' "$TMPDIR_EVAL/tp-long-supersede.out" >"$TMPDIR_EVAL/tp-long-supersede.json"
332
+ [[ "$(json_query "$TMPDIR_EVAL/tp-long-supersede.json" "resumed_branch")" == "$LONG_BRANCH" ]] && pass "supersede resumed_branch preserves the full ${#LONG_BRANCH}-char incumbent branch (review finding #2)" || fail "supersede resumed_branch truncated the long branch: $(cat "$TMPDIR_EVAL/tp-long-supersede.json")"
333
+
334
+ echo ""
335
+ if [[ "$errors" -eq 0 ]]; then
336
+ echo "test_takeover_protocol: all checks passed."
337
+ else
338
+ echo "test_takeover_protocol: $errors check(s) failed."
339
+ fi
340
+ exit "$errors"
@@ -40,6 +40,7 @@
40
40
  set -uo pipefail
41
41
 
42
42
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
43
+ source "$ROOT/evals/lib/node.sh"
43
44
  RECONCILE="$ROOT/scripts/ci/trust-reconcile.js"
44
45
  FX="$ROOT/evals/fixtures/trust-reconcile-exploits"
45
46
  WS3="$ROOT/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json"
@@ -749,6 +750,266 @@ else
749
750
  _fail "push-event-failing-verify: expected the push-event no-op line -- output: $out7v"
750
751
  fi
751
752
 
753
+ # 8. #379 per-session delivery paths — concurrent-delivery collision resolution.
754
+ # resolveDeliveryCandidates() now discovers delivery/<slug>/trust.bundle per-session dirs
755
+ # in addition to the flat path; discoverBundle() selects the candidate whose checkpoint
756
+ # attests THIS change (ancestor-or-equal, same bundleAttestsThisChange() semantics the flat
757
+ # staleness gate uses), ignoring stale siblings from OTHER concurrent sessions. A shared
758
+ # flat path guaranteed a git conflict between any two concurrent deliveries → a DIRTY PR →
759
+ # NO pull_request workflows → the required check silently never ran (field #330/#358/#378).
760
+ echo ""
761
+ echo "=== 8. #379 per-session delivery paths (concurrent-delivery collision) ==="
762
+
763
+ PERSESSION_TMPROOT="$(mktemp -d)"
764
+ # Chain per-session cleanup onto the existing DECLARED cleanup without clobbering the trap.
765
+ trap 'cleanup_declared; rm -rf "$PERSESSION_TMPROOT"' EXIT
766
+
767
+ # write_session_seal <repo_root> <slug> <label> <commit_sha>
768
+ # Writes delivery/<slug>/trust.bundle (a well-formed schemaVersion-5 bundle whose only
769
+ # claimed-pass command is <label>) + delivery/<slug>/trust.checkpoint.json (naming
770
+ # <commit_sha>) — the per-session analogue of write_fresh_bundle + write_stale_checkpoint.
771
+ write_session_seal() {
772
+ local repo_root="$1" slug="$2" label="$3" sha="$4"
773
+ local sdir="$repo_root/delivery/$slug"
774
+ mkdir -p "$sdir"
775
+ cat > "$sdir/trust.bundle" << EOF
776
+ {
777
+ "schemaVersion": 5,
778
+ "source": "test-fixture:per-session:$slug",
779
+ "claims": [
780
+ {
781
+ "id": "c1", "claimType": "workflow.check.build", "value": "pass", "status": "verified",
782
+ "subjectId": "$slug/build", "facet": "flow-agents.workflow", "subjectType": "workflow-check",
783
+ "fieldOrBehavior": "build", "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z",
784
+ "impactLevel": "high", "verificationPolicyId": "policy:workflow.check.build"
785
+ }
786
+ ],
787
+ "evidence": [
788
+ {
789
+ "id": "ev1", "claimId": "c1", "evidenceType": "test_output", "method": "validation",
790
+ "sourceRef": "$slug/command-log.jsonl", "excerptOrSummary": "build",
791
+ "observedAt": "2026-07-01T00:00:00Z", "collectedBy": "flow-agents/evidence-capture",
792
+ "passing": true,
793
+ "execution": { "runner": "bash", "label": "$label", "isError": false, "exitCode": 0 }
794
+ }
795
+ ],
796
+ "policies": [], "events": []
797
+ }
798
+ EOF
799
+ printf '{"schema_version":"1.0","slug":"%s","work_item":null,"status":"delivered","phase":"release","sealed_at":"2026-01-01T00:00:00Z","commit_sha":"%s","checkpoint":{"asOf":"2026-01-01T00:00:00.000Z","statusByClaimId":{}}}' \
800
+ "$slug" "$sha" > "$sdir/trust.checkpoint.json"
801
+ }
802
+
803
+ FRESH_LABEL_8="node -e 'process.exit(0)'"
804
+
805
+ # 8a. OWNER WINS: two sibling session dirs — one OWNING (checkpoint commit_sha == this
806
+ # change's sha), one STALE (a different sha). The reconciler MUST pick the owner and
807
+ # reconcile it; the stale sibling is ignored, NOT a failure.
808
+ CASE8A="$PERSESSION_TMPROOT/collision-owner-wins"
809
+ write_session_seal "$CASE8A" "owning-session" "$FRESH_LABEL_8" "$OURS_SHA"
810
+ write_session_seal "$CASE8A" "stale-sibling" "echo stale-should-never-be-reconciled" "$STALE_SHA"
811
+ out8a="$(TRUST_RECONCILE_SHA="$OURS_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
812
+ node "$RECONCILE" --repo-root "$CASE8A" 2>&1)"
813
+ code8a=$?
814
+ if [[ $code8a -eq 0 ]]; then
815
+ _pass "persession-owner-wins: reconciler exits 0 (owning per-session candidate selected, stale sibling ignored)"
816
+ else
817
+ _fail "persession-owner-wins: expected exit 0, got $code8a -- output: $out8a"
818
+ fi
819
+ if echo "$out8a" | grep -qF "selected delivery candidate delivery/owning-session/trust.bundle"; then
820
+ _pass "persession-owner-wins: emitted the #379 selection line naming the owning session dir"
821
+ else
822
+ _fail "persession-owner-wins: expected the #379 selection line for delivery/owning-session/trust.bundle -- output: $out8a"
823
+ fi
824
+ if echo "$out8a" | grep -qF "RECONCILED"; then
825
+ _pass "persession-owner-wins: Step 2 reconciled the owning candidate's claimed-pass command"
826
+ else
827
+ _fail "persession-owner-wins: expected RECONCILED -- output: $out8a"
828
+ fi
829
+ if echo "$out8a" | grep -qF "bundle-required-no-declared-marker"; then
830
+ _fail "persession-owner-wins: must NOT fail closed — an owning candidate exists"
831
+ else
832
+ _pass "persession-owner-wins: does not fail closed (owning candidate found)"
833
+ fi
834
+
835
+ # 8b. NO OWNER: two sibling session dirs, BOTH stale (neither attests this change), no
836
+ # DECLARED marker -> fail closed EXACTLY as bundle-absence, plus the #379 concurrency
837
+ # hint so the next agent can diagnose a collision rather than a plain stale bundle.
838
+ CASE8B="$PERSESSION_TMPROOT/collision-no-owner"
839
+ OTHER_STALE_SHA="1111111111111111111111111111111111111111"
840
+ write_session_seal "$CASE8B" "session-a" "echo a" "$STALE_SHA"
841
+ write_session_seal "$CASE8B" "session-b" "echo b" "$OTHER_STALE_SHA"
842
+ out8b="$(TRUST_RECONCILE_SHA="$OURS_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
843
+ node "$RECONCILE" --repo-root "$CASE8B" 2>&1)"
844
+ code8b=$?
845
+ if [[ $code8b -ne 0 ]]; then
846
+ _pass "persession-no-owner: reconciler exits non-zero ($code8b) -- no candidate attests this change, fail closed"
847
+ else
848
+ _fail "persession-no-owner: expected non-zero exit, got 0 -- output: $out8b"
849
+ fi
850
+ if echo "$out8b" | grep -qF "none attests this change $OURS_SHA"; then
851
+ _pass "persession-no-owner: emitted the #379 concurrency hint (none attests this change)"
852
+ else
853
+ _fail "persession-no-owner: expected the #379 'none attests this change' hint -- output: $out8b"
854
+ fi
855
+ if echo "$out8b" | grep -qF "bundle-required-no-declared-marker"; then
856
+ _pass "persession-no-owner: emitted 'bundle-required-no-declared-marker' (same fail-closed path as bundle-absence)"
857
+ else
858
+ _fail "persession-no-owner: expected 'bundle-required-no-declared-marker' -- output: $out8b"
859
+ fi
860
+
861
+ # 8c. BACK-COMPAT COEXISTENCE: a flat legacy owner (delivery/trust.bundle) alongside a stale
862
+ # per-session sibling -> the flat owner is still selected and reconciled (the flat path
863
+ # stays supported; per-session discovery does not break it).
864
+ CASE8C="$PERSESSION_TMPROOT/flat-owner-persession-stale"
865
+ write_fresh_bundle "$CASE8C" "$FRESH_LABEL_8" # flat delivery/trust.bundle
866
+ write_stale_checkpoint "$CASE8C" "$OURS_SHA" # flat delivery/trust.checkpoint.json (owns)
867
+ write_session_seal "$CASE8C" "stale-session" "echo ignored" "$STALE_SHA" # per-session stale sibling
868
+ out8c="$(TRUST_RECONCILE_SHA="$OURS_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
869
+ node "$RECONCILE" --repo-root "$CASE8C" 2>&1)"
870
+ code8c=$?
871
+ if [[ $code8c -eq 0 ]]; then
872
+ _pass "flat-owner-coexist: reconciler exits 0 (flat legacy owner selected despite a stale per-session sibling)"
873
+ else
874
+ _fail "flat-owner-coexist: expected exit 0, got $code8c -- output: $out8c"
875
+ fi
876
+ if echo "$out8c" | grep -qF "RECONCILED"; then
877
+ _pass "flat-owner-coexist: Step 2 reconciled the flat owner (back-compat preserved)"
878
+ else
879
+ _fail "flat-owner-coexist: expected RECONCILED -- output: $out8c"
880
+ fi
881
+
882
+ # 8d. PREFER-NEWEST among multiple OWNING candidates (the merge-commit-repo / concurrent-PR
883
+ # coexistence case). An inherited FLAT bundle can attest a REAL ANCESTOR of this change
884
+ # (committed on the trunk before this branch), AND this session's per-session bundle attests
885
+ # a NEWER ancestor. "First-fresh-wins" would wrongly pick the stale flat bundle because it
886
+ # sorts first; the reconciler must instead pick the NEWEST-owning candidate (the per-session
887
+ # one). Uses a REAL git repo so `git merge-base --is-ancestor` resolves the parent→child
888
+ # relationship (synthetic shas cannot exercise the ancestor comparison).
889
+ NEWEST_REPO="$PERSESSION_TMPROOT/prefer-newest-git"
890
+ mkdir -p "$NEWEST_REPO"
891
+ git -C "$NEWEST_REPO" init -q
892
+ git -C "$NEWEST_REPO" config user.email "test@example.com"
893
+ git -C "$NEWEST_REPO" config user.name "Test"
894
+ echo "base" > "$NEWEST_REPO/f.txt"; git -C "$NEWEST_REPO" add f.txt; git -C "$NEWEST_REPO" commit -q -m "base (inherited flat seal sealed here)"
895
+ FLAT_ANCESTOR_SHA="$(git -C "$NEWEST_REPO" rev-parse HEAD)"
896
+ echo "delivery" >> "$NEWEST_REPO/f.txt"; git -C "$NEWEST_REPO" add f.txt; git -C "$NEWEST_REPO" commit -q -m "this session's delivery commit"
897
+ PERSESSION_OWNER_SHA="$(git -C "$NEWEST_REPO" rev-parse HEAD)"
898
+ echo "head" >> "$NEWEST_REPO/f.txt"; git -C "$NEWEST_REPO" add f.txt; git -C "$NEWEST_REPO" commit -q -m "seal commit (HEAD)"
899
+ NEWEST_HEAD_SHA="$(git -C "$NEWEST_REPO" rev-parse HEAD)"
900
+ # Flat owner attesting the OLD ancestor; per-session owner attesting the NEWER ancestor.
901
+ write_fresh_bundle "$NEWEST_REPO" "$FRESH_LABEL_8" # flat delivery/trust.bundle
902
+ write_stale_checkpoint "$NEWEST_REPO" "$FLAT_ANCESTOR_SHA" # flat checkpoint -> OLD ancestor
903
+ write_session_seal "$NEWEST_REPO" "this-session" "$FRESH_LABEL_8" "$PERSESSION_OWNER_SHA" # per-session -> NEWER ancestor
904
+ out8d="$(TRUST_RECONCILE_SHA="$NEWEST_HEAD_SHA" TRUST_RECONCILE_COMMANDS="$FRESH_LABEL_8" \
905
+ node "$RECONCILE" --repo-root "$NEWEST_REPO" 2>&1)"
906
+ code8d=$?
907
+ if [[ $code8d -eq 0 ]]; then
908
+ _pass "prefer-newest: reconciler exits 0 (both flat and per-session own; newest selected)"
909
+ else
910
+ _fail "prefer-newest: expected exit 0, got $code8d -- output: $out8d"
911
+ fi
912
+ if echo "$out8d" | grep -qF "selected delivery candidate delivery/this-session/trust.bundle"; then
913
+ _pass "prefer-newest: selected the NEWER per-session bundle over the older inherited flat bundle"
914
+ else
915
+ _fail "prefer-newest: expected the per-session bundle to be selected (newest-owning) -- output: $out8d"
916
+ fi
917
+ if echo "$out8d" | grep -qF "owning, newest wins"; then
918
+ _pass "prefer-newest: emitted the 'owning, newest wins' selection detail"
919
+ else
920
+ _fail "prefer-newest: expected 'owning, newest wins' in the selection line -- output: $out8d"
921
+ fi
922
+
923
+ # 9. [iteration-1 F1 CRITICAL regression guard] CI trust-reconcile.js MUST stay fail-closed
924
+ # when scripts/ci/derive-claim-status.mjs is unavailable (Surface unresolvable, spawn
925
+ # failure, malformed bundle, etc.) -- deriveClaimStatuses() returns null in that case, and
926
+ # BEFORE #356's extraction the inline session-local loop turned every session-local
927
+ # pass-asserting claim into a `status-underivable` divergence (hard FAIL). The extraction's
928
+ # shared sessionLocalShapeIssues() briefly made that a caller-controlled `onUnderivable` mode
929
+ # (see reconcile-shape.js) -- this section proves CI still defaults/forces the safe 'fail'
930
+ # mode, and (paired) that the LOCAL reconcile-preflight intentionally diverges into the
931
+ # 'reduce' mode on the exact same bundle. Without the fix, case 9a below FAILS (exits 0
932
+ # instead of 1) -- this is the single regression eval that let the CRITICAL through
933
+ # code-review-356 iteration 0.
934
+ echo ""
935
+ echo "=== 9. derive-claim-status.mjs unavailable: CI fail-closed vs local-preflight reduced-coverage (iteration-1 F1) ==="
936
+
937
+ DERIVE_HELPER="$ROOT/scripts/ci/derive-claim-status.mjs"
938
+ DERIVE_HELPER_HIDDEN="$DERIVE_HELPER.hidden-by-test-9"
939
+
940
+ # hide_derive_helper / restore_derive_helper: rename the real ESM helper out of the way so
941
+ # scripts/ci/trust-reconcile.js's deriveClaimStatuses() -- which does fs.existsSync(helper)
942
+ # before ever spawning it -- returns null exactly as it would if Surface could not resolve.
943
+ # Always restored via trap, including on a hard failure of this script.
944
+ hide_derive_helper() {
945
+ if [[ -f "$DERIVE_HELPER" ]]; then
946
+ mv "$DERIVE_HELPER" "$DERIVE_HELPER_HIDDEN"
947
+ fi
948
+ }
949
+ restore_derive_helper() {
950
+ if [[ -f "$DERIVE_HELPER_HIDDEN" ]]; then
951
+ mv "$DERIVE_HELPER_HIDDEN" "$DERIVE_HELPER"
952
+ fi
953
+ }
954
+ trap 'cleanup_declared; rm -rf "$PERSESSION_TMPROOT"; restore_derive_helper' EXIT
955
+
956
+ # A one-claim, session-local, pass-asserting bundle (reuses the finding-3 exploit fixture --
957
+ # a human_attestation-backed claim asserting status "verified"; any session-local
958
+ # pass-asserting claim exercises this path since the whole derivedStatus map is null, not a
959
+ # single claim's lookup).
960
+ CASE9_BUNDLE="$FX/status-misassertion.json"
961
+
962
+ hide_derive_helper
963
+
964
+ # 9a. CI (real scripts/ci/trust-reconcile.js): derive-claim-status.mjs unavailable -> MUST
965
+ # still exit non-zero (status-underivable, fail-closed) -- the pre-#356 guarantee.
966
+ out9a="$(TRUST_RECONCILE_COMMANDS="node -e 'process.exit(0)'" \
967
+ node "$RECONCILE" --bundle "$CASE9_BUNDLE" --repo-root "$ROOT" 2>&1)"
968
+ code9a=$?
969
+ if [[ $code9a -ne 0 ]]; then
970
+ _pass "derive-unavailable-ci: reconciler exits non-zero ($code9a) -- fail-closed preserved when re-derivation is unavailable"
971
+ else
972
+ _fail "derive-unavailable-ci: expected non-zero exit (fail-closed), got 0 -- output: $out9a"
973
+ fi
974
+ if echo "$out9a" | grep -qF "[status-underivable]"; then
975
+ _pass "derive-unavailable-ci: emitted the status-underivable divergence"
976
+ else
977
+ _fail "derive-unavailable-ci: expected a [status-underivable] divergence -- output: $out9a"
978
+ fi
979
+ if echo "$out9a" | grep -qF "refusing to trust a self-reported status (fail-closed)"; then
980
+ _pass "derive-unavailable-ci: emitted the verbatim fail-closed message"
981
+ else
982
+ _fail "derive-unavailable-ci: expected the verbatim fail-closed message -- output: $out9a"
983
+ fi
984
+
985
+ # 9b. Paired proof the modes diverge intentionally: the LOCAL reconcile-preflight, on the
986
+ # SAME bundle with the SAME helper hidden, DEGRADES -- it does not hard-fail on the
987
+ # underivable-status dimension (it opts into reduce mode explicitly; see
988
+ # src/cli/workflow-sidecar.ts's runReconcilePreflight).
989
+ # Nested under a subdirectory (not the raw mktemp -d path) -- mktemp -d's own
990
+ # "tmp.XXXXXXXXXX" basename contains a dot that path.extname()/artifactDirFrom() would
991
+ # misparse as a file extension, incorrectly stripping the directory segment.
992
+ CASE9_TMPROOT="$(mktemp -d)"
993
+ CASE9_SESSION="$CASE9_TMPROOT/session"
994
+ mkdir -p "$CASE9_SESSION"
995
+ cp "$CASE9_BUNDLE" "$CASE9_SESSION/trust.bundle"
996
+ out9b="$(flow_agents_node workflow-sidecar reconcile-preflight "$CASE9_SESSION" --repo-root "$ROOT" 2>&1)"
997
+ code9b=$?
998
+ rm -rf "$CASE9_TMPROOT"
999
+ if [[ $code9b -eq 0 ]]; then
1000
+ _pass "derive-unavailable-local-preflight: reconcile-preflight exits 0 (reduced-coverage degrade, not a hard fail) -- proves the CI/local modes diverge intentionally"
1001
+ else
1002
+ _fail "derive-unavailable-local-preflight: expected exit 0 (reduced-coverage degrade), got $code9b -- output: $out9b"
1003
+ fi
1004
+ if echo "$out9b" | grep -qF "status-underivable"; then
1005
+ _fail "derive-unavailable-local-preflight: must NOT emit status-underivable in reduced-coverage mode -- output: $out9b"
1006
+ else
1007
+ _pass "derive-unavailable-local-preflight: does not emit status-underivable (reduced-coverage checks only)"
1008
+ fi
1009
+
1010
+ restore_derive_helper
1011
+
1012
+
752
1013
  echo ""
753
1014
  if [[ $errors -eq 0 ]]; then
754
1015
  echo "test_trust_reconcile_negatives: all checks passed."