@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,910 @@
1
+ #!/usr/bin/env bash
2
+ # test_verify_hold.sh — verify-hold gate integration eval (issue #293, ADR 0021 §3).
3
+ #
4
+ # Exercises `runVerifyHold()`/`NotFreshHolderError`/`verify-hold` CLI subcommand and its
5
+ # composition into `publishDelivery()` (src/cli/workflow-sidecar.ts), reusing the SAME
6
+ # assignment ⋈ liveness join #290/#291 already built (computeEffectiveState/
7
+ # readLocalAssignmentStatus/freshHolders) — no second computation is invented here, and this
8
+ # eval never asserts a second one either. Also exercises `supersessionSteering()`
9
+ # (scripts/hooks/workflow-steering.js), the every-turn companion to the CLI/skill hard-stop.
10
+ #
11
+ # Follows evals/integration/test_ensure_session_ownership_guard.sh's actor/fixture idiom
12
+ # (pass/fail/json_query helpers, append_liveness_event, actor_key_from_record) and
13
+ # evals/integration/test_publish_delivery.sh's bundle-fixture-builder idiom (write_bundle_to)
14
+ # exactly — no new plumbing invented where an existing helper already does the job.
15
+ #
16
+ # AC5 (steering surfaces supersession every turn) is asserted HERE (not duplicated into
17
+ # test_workflow_steering_hook.sh) by driving scripts/hooks/workflow-steering.js directly via
18
+ # stdin JSON, matching that file's own existing direct-invocation convention
19
+ # (`node scripts/hooks/workflow-steering.js <<JSON ... JSON`).
20
+ #
21
+ # Sections (1-to-1 with the plan's Wave 4 task + acceptance.json's 8 ACs):
22
+ # 1. Superseded A->B (AC1): actor A's verify-hold blocks (held, holder=B); a manufactured
23
+ # STALE self-claim (reclaimable) ALSO blocks for its own original actor (the Stop-short
24
+ # risk regression — reclaimable is never silently treated as pass).
25
+ # 2. Composition (AC1, AC3 partial): publish-delivery / record-release called as actor A on
26
+ # the superseded fixture exit non-zero and never write delivery/<slug>/.
27
+ # 3. Fresh holder + free subject pass (AC2): actor B's verify-hold passes; a free subject
28
+ # passes for any actor.
29
+ # 4. #356 shape-gate composition ordering (AC3): shape-invalid AND not-held -> the SHAPE
30
+ # error specifically; shape-valid AND not-held -> the HOLD error specifically. Both
31
+ # reachable, distinct, and delivery/<slug>/ is never created in either case.
32
+ # 5. github-provider precomputed gate (AC6): --effective-state-json evaluates without any
33
+ # `gh` process; absent both local-file and github fixture -> not_evaluated, exit 0.
34
+ # 6. Injection discipline (AC7): hostile liveness holder string never leaks raw control/ANSI
35
+ # bytes into verify-hold's JSON/stderr or the steering supersession notice.
36
+ # 7. Steering every-turn (AC5): supersessionSteering fires on UserPromptSubmit AND
37
+ # SessionStart for a fixture with a fresh other-actor holder; absent for a fixture with
38
+ # none.
39
+ # 8. Registration (AC8) is verified by diffs of evals/run.sh, evals/ci/run-baseline.sh, and
40
+ # .github/workflows/ci.yml (not by this file) plus the parity check in
41
+ # test_trust_reconcile_manifest.sh.
42
+ #
43
+ # Deterministic, no model spend, self-cleaning, no network, no `gh` process anywhere.
44
+ # Usage: bash evals/integration/test_verify_hold.sh
45
+
46
+ set -uo pipefail
47
+
48
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
49
+ source "$ROOT/evals/lib/node.sh"
50
+
51
+ CLI="$ROOT/build/src/cli.js"
52
+ WRITER="workflow-sidecar"
53
+
54
+ TMPDIR_EVAL="$(mktemp -d)"
55
+ trap 'rm -rf "$TMPDIR_EVAL"' EXIT
56
+ ARTIFACT_ROOT="$TMPDIR_EVAL/artifact-root"
57
+
58
+ errors=0
59
+ pass() { echo " [PASS] $1"; }
60
+ fail() { echo " [FAIL] $1"; errors=$((errors + 1)); }
61
+
62
+ json_query() {
63
+ 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"
64
+ }
65
+
66
+ ACTOR_IDENTITY_HELPER="$ROOT/scripts/hooks/lib/actor-identity.js"
67
+ # actor_key_from_record <assignment-record.json> — recomputes the SAME holderActorKey
68
+ # computeEffectiveState (src/cli/assignment-provider.ts) itself derives (record.actor_key when
69
+ # present, else serializeActor(record.actor) back-compat fallback). Reused verbatim from
70
+ # test_ensure_session_ownership_guard.sh's own helper — never hand-derived.
71
+ actor_key_from_record() {
72
+ node -e '
73
+ const fs = require("fs");
74
+ const { serializeActor } = require(process.argv[1]);
75
+ const rec = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
76
+ process.stdout.write(rec.actor_key || serializeActor(rec.actor));
77
+ ' "$ACTOR_IDENTITY_HELPER" "$1"
78
+ }
79
+
80
+ # append_liveness_event <root> <subjectId> <actor> <at-iso> [ttlSeconds]
81
+ append_liveness_event() {
82
+ local root="$1" subject="$2" actor="$3" at="$4" ttl="${5:-1800}"
83
+ mkdir -p "$root/liveness"
84
+ node -e '
85
+ const fs = require("fs");
86
+ const evt = { type: "claim", subjectId: process.argv[1], actor: process.argv[2], at: process.argv[3], ttlSeconds: Number(process.argv[4]) };
87
+ fs.appendFileSync(process.argv[5], JSON.stringify(evt) + "\n");
88
+ ' "$subject" "$actor" "$at" "$ttl" "$root/liveness/events.jsonl"
89
+ }
90
+
91
+ # write_assignment_record <artifactRoot> <slug> <sessionId> <actorKey> [claimedAt]
92
+ # Writes a minimal, well-formed, ALREADY-CLAIMED assignment record directly (bypassing
93
+ # assignment-provider claim's --branch/--artifact-dir plumbing where the eval only needs a
94
+ # fixed actor_key/holder shape for runVerifyHold's join, matching how
95
+ # test_ensure_session_ownership_guard.sh's own human-held fixture writes a record directly).
96
+ # [claimedAt] (F1 fix, fix-plan iteration 1): optional override for `claimed_at` -- defaults to
97
+ # real wall-clock `now` (unchanged behavior for every existing caller) so section 6b below can
98
+ # inject a hostile/oversized value into this exact attacker-writable field without adding a
99
+ # second, parallel fixture-writer helper.
100
+ write_assignment_record() {
101
+ local root="$1" slug="$2" sessionId="$3" actorKey="$4" claimedAt="${5:-}"
102
+ mkdir -p "$root/assignment"
103
+ node -e '
104
+ const fs = require("fs");
105
+ const [dest, slug, sessionId, actorKey, claimedAtOverride] = process.argv.slice(1);
106
+ const now = new Date().toISOString();
107
+ const claimedAt = claimedAtOverride || now;
108
+ const actor = { runtime: "claude-code", session_id: sessionId, host: "eval-host" };
109
+ const rec = {
110
+ schema_version: "1.0", role: "AssignmentClaimRecord", subject_id: slug,
111
+ actor, actor_key: actorKey, claimed_at: claimedAt, ttl_seconds: 1800, branch: "main",
112
+ artifact_dir: slug, status: "claimed",
113
+ audit_trail: [{ at: now, transition: "claim", from_actor: null, to_actor: actor, reason: "claim" }],
114
+ };
115
+ fs.writeFileSync(dest, JSON.stringify(rec, null, 2));
116
+ ' "$root/assignment/$slug.json" "$slug" "$sessionId" "$actorKey" "$claimedAt"
117
+ }
118
+
119
+ # write_bundle_to <dest> <label> <passing> — minimal bundle fixture, reused verbatim from
120
+ # test_publish_delivery.sh's own helper (a command-backed claim naming <label> as the
121
+ # evidence.execution.label; shape-VALID only when <label> resolves against the manifest, e.g.
122
+ # via TRUST_RECONCILE_COMMANDS).
123
+ write_bundle_to() {
124
+ local dest="$1" label="$2" passing="$3"
125
+ local helper="$TMPDIR_EVAL/bundle-writer.js"
126
+ if [[ ! -f "$helper" ]]; then
127
+ python3 - "$helper" << 'PY'
128
+ import sys
129
+ out = sys.argv[1]
130
+ code_lines = [
131
+ "const fs = require('fs');",
132
+ "const [,, dest, label, passingStr] = process.argv;",
133
+ "const passing = passingStr === 'true';",
134
+ "const b = { schemaVersion: 5, source: 'test-fixture',",
135
+ " claims: [{ id: 'c1', claimType: 'workflow.check.build',",
136
+ " value: passing ? 'pass' : 'fail', status: passing ? 'verified' : 'disputed',",
137
+ " subjectId: 'ts/build', facet: 'flow-agents.workflow',",
138
+ " subjectType: 'workflow-check', fieldOrBehavior: 'build',",
139
+ " createdAt: '2026-06-27T00:00:00Z', updatedAt: '2026-06-27T00:00:00Z',",
140
+ " impactLevel: 'high', verificationPolicyId: 'policy:wf.build' }],",
141
+ " evidence: [{ id: 'ev1', claimId: 'c1', evidenceType: 'test_output',",
142
+ " method: 'validation', sourceRef: 'ts/cmd.jsonl',",
143
+ " excerptOrSummary: 'build', observedAt: '2026-06-27T00:00:00Z',",
144
+ " collectedBy: 'flow-agents', passing: passing,",
145
+ " execution: { runner: 'bash', label: label, isError: !passing, exitCode: passing ? 0 : 1 } }],",
146
+ " policies: [], events: [] };",
147
+ "fs.writeFileSync(dest, JSON.stringify(b, null, 2));",
148
+ ]
149
+ with open(out, 'w') as fh:
150
+ fh.write('\n'.join(code_lines) + '\n')
151
+ PY
152
+ fi
153
+ node "$helper" "$dest" "$label" "$passing"
154
+ }
155
+
156
+ # setup_session <artifactRoot> <slug> <bundleSrc> — a minimal deliverable session, reused from
157
+ # test_publish_delivery.sh's own helper.
158
+ setup_session() {
159
+ local aroot="$1" slug="$2" bundle_src="$3"
160
+ local session_dir="$aroot/$slug"
161
+ mkdir -p "$aroot"
162
+ flow_agents_node "$WRITER" ensure-session \
163
+ --artifact-root "$aroot" --task-slug "$slug" \
164
+ --title "Verify Hold Test" \
165
+ --summary "Test verify-hold gate." \
166
+ --criterion "Bundle published" \
167
+ --timestamp "2026-07-04T10:00:00Z" >/dev/null 2>&1
168
+ flow_agents_node "$WRITER" init-plan "$session_dir/${slug}--deliver.md" \
169
+ --source-request "Test" --summary "Test" \
170
+ --timestamp "2026-07-04T10:01:00Z" >/dev/null 2>&1
171
+ flow_agents_node "$WRITER" record-evidence "$session_dir" \
172
+ --verdict pass \
173
+ --check-json '{"id":"build","kind":"build","status":"pass","summary":"ok"}' \
174
+ --timestamp "2026-07-04T10:02:00Z" >/dev/null 2>&1
175
+ flow_agents_node "$WRITER" record-critique "$session_dir" \
176
+ --verdict pass --summary "ok." \
177
+ --timestamp "2026-07-04T10:03:00Z" >/dev/null 2>&1
178
+ if [[ -n "$bundle_src" && -f "$bundle_src" ]]; then
179
+ cp "$bundle_src" "$session_dir/trust.bundle"
180
+ fi
181
+ }
182
+
183
+ # write_steering_fixture_repo <repoRoot> <slug> <statusJsonUpdatedAt>
184
+ # Minimal repo scaffold + a bare in_progress state.json so workflow-steering.js's
185
+ # latestWorkflowState() finds an active `current` sidecar for the given slug. Writes the JSON
186
+ # via a node helper (never a raw shell heredoc) matching this file's other fixture-writer
187
+ # helpers above.
188
+ write_steering_fixture_repo() {
189
+ local repoRoot="$1" slug="$2" updatedAt="$3"
190
+ mkdir -p "$repoRoot/.kontourai/flow-agents/$slug" "$repoRoot/docs"
191
+ printf '# Repo\n' > "$repoRoot/AGENTS.md"
192
+ printf '# Context Map\n' > "$repoRoot/docs/context-map.md"
193
+ node "$ROOT_SCRATCH_STATE_WRITER" \
194
+ "$repoRoot/.kontourai/flow-agents/$slug/state.dot.json.tmp" \
195
+ "$slug" "in_progress" "execution" "$updatedAt" "Continue work."
196
+ mv "$repoRoot/.kontourai/flow-agents/$slug/state.dot.json.tmp" "$repoRoot/.kontourai/flow-agents/$slug/state.json"
197
+ }
198
+
199
+ ROOT_SCRATCH_STATE_WRITER="$TMPDIR_EVAL/write-state-json.js"
200
+ cat > "$ROOT_SCRATCH_STATE_WRITER" << 'JSEOF'
201
+ const fs = require("fs");
202
+ const [,, dest, slug, status, phase, updatedAt, summary] = process.argv;
203
+ const state = {
204
+ schema_version: "1.0",
205
+ task_slug: slug,
206
+ status,
207
+ phase,
208
+ updated_at: updatedAt,
209
+ next_action: { status, summary },
210
+ };
211
+ fs.writeFileSync(dest, JSON.stringify(state, null, 2));
212
+ JSEOF
213
+
214
+ if [[ ! -f "$CLI" ]]; then
215
+ echo "build/src/cli.js not found — run 'npm run build' first" >&2
216
+ exit 1
217
+ fi
218
+ flow_agents_build_ts || { echo "build failed" >&2; exit 1; }
219
+
220
+ echo "=== verify-hold gate (#293) ==="
221
+
222
+ # ─── 1. Superseded A->B blocks A; a stale self-claim (reclaimable) also blocks (AC1) ───────
223
+ echo "--- 1. superseded A->B blocks actor A (held, holder=B); stale self-claim (reclaimable) also blocks (AC1) ---"
224
+
225
+ SUPERSEDE_SLUG="verify-hold-superseded"
226
+ SUPERSEDE_DIR="$ARTIFACT_ROOT/$SUPERSEDE_SLUG"
227
+
228
+ flow_agents_node "$WRITER" ensure-session \
229
+ --artifact-root "$ARTIFACT_ROOT" \
230
+ --task-slug "$SUPERSEDE_SLUG" \
231
+ --actor eval-actor-a-hold \
232
+ --source-request "Actor A establishes the original claim." \
233
+ --summary "Actor A original holder." \
234
+ >"$TMPDIR_EVAL/vh-a-ensure.out" 2>"$TMPDIR_EVAL/vh-a-ensure.err"
235
+ A_RECORD="$ARTIFACT_ROOT/assignment/$SUPERSEDE_SLUG.json"
236
+ ACTOR_KEY_A="$(actor_key_from_record "$A_RECORD")"
237
+ [[ -n "$ACTOR_KEY_A" ]] && pass "actor A's claim established (setup)" || fail "actor A's claim setup failed: $(cat "$TMPDIR_EVAL/vh-a-ensure.out" "$TMPDIR_EVAL/vh-a-ensure.err")"
238
+
239
+ # Manufacture staleness for A's own claim (an "at" well in the past, past the 1800s ttl), then
240
+ # actor B supersedes A via --supersede-stale (the same mechanism
241
+ # test_ensure_session_ownership_guard.sh's stale-takeover section already establishes) —
242
+ # performLocalSupersede writes B's actor_key as the canonical bare value (F1 fix), so
243
+ # runVerifyHold's join sees a genuinely fresh, non-self holder for A's re-check.
244
+ append_liveness_event "$ARTIFACT_ROOT" "$SUPERSEDE_SLUG" "$ACTOR_KEY_A" "2026-06-01T10:00:00Z" 1800
245
+
246
+ flow_agents_node "$WRITER" ensure-session \
247
+ --artifact-root "$ARTIFACT_ROOT" \
248
+ --task-slug "$SUPERSEDE_SLUG" \
249
+ --actor eval-actor-b-hold \
250
+ --supersede-stale \
251
+ --source-request "Actor B takes over the stale claim." \
252
+ --summary "Actor B takeover." \
253
+ >"$TMPDIR_EVAL/vh-b-supersede.out" 2>"$TMPDIR_EVAL/vh-b-supersede.err"
254
+ B_SUPERSEDE_EXIT=$?
255
+ [[ $B_SUPERSEDE_EXIT -eq 0 ]] && pass "actor B's --supersede-stale takeover succeeds (setup)" || fail "actor B's takeover unexpectedly failed: $(cat "$TMPDIR_EVAL/vh-b-supersede.out" "$TMPDIR_EVAL/vh-b-supersede.err")"
256
+
257
+ B_RECORD="$ARTIFACT_ROOT/assignment/$SUPERSEDE_SLUG.json"
258
+ ACTOR_KEY_B="$(actor_key_from_record "$B_RECORD")"
259
+ [[ -n "$ACTOR_KEY_B" ]] && pass "actor B's canonical actor_key recovered from the superseded record (setup)" || fail "could not recover actor B's actor_key"
260
+
261
+ # Fresh liveness heartbeat for B so the join classifies A's re-check as held(holder=B), not
262
+ # merely reclaimable (the un-ambiguous "someone else has a fresh claim" row of the mapping
263
+ # table) — matches Conflict #3's resolution (no literal "superseded" state; a superseded-away
264
+ # actor's own re-check naturally resolves to held/holder=successor).
265
+ NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
266
+ append_liveness_event "$ARTIFACT_ROOT" "$SUPERSEDE_SLUG" "$ACTOR_KEY_B" "$NOW_ISO" 1800
267
+
268
+ mkdir -p "$SUPERSEDE_DIR"
269
+ if flow_agents_node "$WRITER" verify-hold "$SUPERSEDE_DIR" --actor "$ACTOR_KEY_A" \
270
+ >"$TMPDIR_EVAL/vh-a-check.out" 2>"$TMPDIR_EVAL/vh-a-check.err"; then
271
+ fail "actor A's verify-hold on the superseded subject should have exited non-zero (AC1)"
272
+ else
273
+ pass "actor A's verify-hold on the superseded subject exits non-zero (AC1)"
274
+ fi
275
+ [[ "$(json_query "$TMPDIR_EVAL/vh-a-check.out" "ok")" == "false" ]] && pass "verify-hold JSON reports ok:false for actor A (AC1)" || fail "verify-hold JSON did not report ok:false: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
276
+ [[ "$(json_query "$TMPDIR_EVAL/vh-a-check.out" "effective_state")" == "held" ]] && pass "verify-hold JSON reports effective_state:held (holder=B) for actor A (AC1)" || fail "verify-hold JSON did not report held: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
277
+ GUIDANCE_LEN_A="$(json_query "$TMPDIR_EVAL/vh-a-check.out" "guidance.length")"
278
+ [[ "$GUIDANCE_LEN_A" -gt 0 ]] && pass "verify-hold's guidance array is non-empty for actor A's block (AC1)" || fail "verify-hold's guidance array was empty for actor A's block"
279
+ grep -qF "$ACTOR_KEY_B" "$TMPDIR_EVAL/vh-a-check.out" && pass "verify-hold JSON names actor B as the current holder (AC1)" || fail "verify-hold JSON did not name actor B as holder: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
280
+ grep -qiF -- "--supersede-stale" "$TMPDIR_EVAL/vh-a-check.out" && pass "verify-hold's guidance names --supersede-stale as the reconcile path (AC1)" || fail "verify-hold's guidance did not name --supersede-stale: $(cat "$TMPDIR_EVAL/vh-a-check.out")"
281
+
282
+ # ─── 1b. A manufactured STALE claim (reclaimable) ALSO blocks a re-checking actor (Stop-short risk) ─
283
+ echo "--- 1b. stale claim (reclaimable) blocks a re-checking actor, never a silent pass (AC1) ---"
284
+
285
+ # computeEffectiveState's self-check (record.actor_key === selfActor) is checked BEFORE
286
+ # freshness (assignment-provider.ts around line 366) — so the ORIGINAL claimant's own re-check
287
+ # always self-recognizes as self_is_holder regardless of staleness (matching
288
+ # enforceEnsureSessionOwnership's own precedent: its reclaimable-takeover eval section always
289
+ # has a DIFFERENT actor re-check the stale claim, never the same stale actor checking itself).
290
+ # The Stop-short risk this guards against is therefore: a DIFFERENT, not-yet-superseded re-checking
291
+ # actor (e.g. a monitoring/handoff tool, or a second session considering takeover) must see
292
+ # reclaimable and be BLOCKED — never silently treated as "no one holds this, proceed" — until an
293
+ # explicit --supersede-stale takeover happens. This is exactly what "reclaimable is never PASS"
294
+ # protects: it would be wrong to auto-pass verify-hold for a reclaimable subject just because the
295
+ # original holder's liveness went stale.
296
+ RECLAIMABLE_SLUG="verify-hold-reclaimable"
297
+ RECLAIMABLE_DIR="$ARTIFACT_ROOT/$RECLAIMABLE_SLUG"
298
+ flow_agents_node "$WRITER" ensure-session \
299
+ --artifact-root "$ARTIFACT_ROOT" \
300
+ --task-slug "$RECLAIMABLE_SLUG" \
301
+ --actor eval-actor-stale-original \
302
+ --source-request "Actor establishes the soon-to-be-stale claim." \
303
+ --summary "Original holder." \
304
+ >"$TMPDIR_EVAL/vh-stale-setup.out" 2>"$TMPDIR_EVAL/vh-stale-setup.err"
305
+ RECLAIMABLE_RECORD="$ARTIFACT_ROOT/assignment/$RECLAIMABLE_SLUG.json"
306
+ ACTOR_KEY_STALE="$(actor_key_from_record "$RECLAIMABLE_RECORD")"
307
+ # A liveness event whose `at` is well past ttlSeconds (1800s) relative to real wall-clock now,
308
+ # with NO fresher heartbeat — the record exists but is no longer backed by a fresh liveness
309
+ # claim, so a DIFFERENT actor's re-check join classifies it reclaimable (not held/free).
310
+ append_liveness_event "$ARTIFACT_ROOT" "$RECLAIMABLE_SLUG" "$ACTOR_KEY_STALE" "2026-06-01T10:00:00Z" 1800
311
+
312
+ mkdir -p "$RECLAIMABLE_DIR"
313
+ if flow_agents_node "$WRITER" verify-hold "$RECLAIMABLE_DIR" --actor eval-actor-stale-rechecker \
314
+ >"$TMPDIR_EVAL/vh-reclaimable.out" 2>"$TMPDIR_EVAL/vh-reclaimable.err"; then
315
+ fail "verify-hold on a reclaimable (stale) claim, re-checked by a DIFFERENT actor, should have exited non-zero — reclaimable must never be silently treated as pass (AC1)"
316
+ else
317
+ pass "verify-hold on a reclaimable (stale) claim, re-checked by a different actor, exits non-zero — reclaimable is never treated as pass (AC1)"
318
+ fi
319
+ [[ "$(json_query "$TMPDIR_EVAL/vh-reclaimable.out" "effective_state")" == "reclaimable" ]] && pass "verify-hold JSON reports effective_state:reclaimable for the stale claim's re-check (AC1)" || fail "verify-hold JSON did not report reclaimable: $(cat "$TMPDIR_EVAL/vh-reclaimable.out")"
320
+ RECLAIMABLE_GUIDANCE_LEN="$(json_query "$TMPDIR_EVAL/vh-reclaimable.out" "guidance.length")"
321
+ [[ "$RECLAIMABLE_GUIDANCE_LEN" -gt 0 ]] && pass "verify-hold's guidance array is non-empty for the reclaimable block (AC1)" || fail "verify-hold's guidance array was empty for the reclaimable block"
322
+
323
+ # Regression: the ORIGINAL claimant's own re-check still self-recognizes (self_is_holder wins
324
+ # over staleness by design — this is NOT the reclaimable case; asserted here so the eval does not
325
+ # accidentally rely on the wrong actor identity to prove the reclaimable-blocks assertion above).
326
+ if flow_agents_node "$WRITER" verify-hold "$RECLAIMABLE_DIR" --actor "$ACTOR_KEY_STALE" \
327
+ >"$TMPDIR_EVAL/vh-reclaimable-self.out" 2>"$TMPDIR_EVAL/vh-reclaimable-self.err"; then
328
+ pass "the original claimant's own re-check on its stale claim still self-recognizes (self_is_holder), confirming reclaimable only applies to a re-check by a DIFFERENT actor (AC1 regression)"
329
+ else
330
+ fail "the original claimant's own re-check unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-reclaimable-self.out" "$TMPDIR_EVAL/vh-reclaimable-self.err")"
331
+ fi
332
+
333
+ # ─── 1c. Liveness-only presence (NO durable assignment record) never blocks a mismatched actor
334
+ # (bug fix, #397) ───────────────────────────────────────────────────────────────────────────
335
+ echo "--- 1c. liveness claim by another actor with NO assignment record passes for a mismatched actor (#397 fix) ---"
336
+
337
+ # This is the exact CI-reproduced false-block: computeEffectiveState's `!isAssigned` branch
338
+ # (assignment-provider.ts) returns held/liveness_claim_present_assignment_lagging when there is
339
+ # a fresh liveness `claim` event for the subject but NO assignment record was ever written
340
+ # (never claimed via ensure-session/assignment-provider claim -- e.g. a liveness-only heartbeat
341
+ # with no durable follow-through, exactly what antigaming-suite.sh's own liveness-seeding does
342
+ # for its own unrelated tests). runVerifyHold must PASS this case: liveness alone is advisory
343
+ # everywhere else in the system, and this is the ONE hard gate, so it must fence the durable
344
+ # ASSIGNMENT hold, never ambient liveness presence.
345
+ LIVENESS_ONLY_SLUG="verify-hold-liveness-only-no-assignment"
346
+ LIVENESS_ONLY_DIR="$ARTIFACT_ROOT/$LIVENESS_ONLY_SLUG"
347
+ mkdir -p "$LIVENESS_ONLY_DIR"
348
+
349
+ # Fresh liveness `claim` event by "other-actor" -- NO write_assignment_record call at all, so
350
+ # readLocalAssignmentStatus resolves no record/no assignee for this slug (isAssigned === false).
351
+ append_liveness_event "$ARTIFACT_ROOT" "$LIVENESS_ONLY_SLUG" "other-actor" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
352
+
353
+ if flow_agents_node "$WRITER" verify-hold "$LIVENESS_ONLY_DIR" --actor "ci-mismatched-actor" \
354
+ >"$TMPDIR_EVAL/vh-liveness-only.out" 2>"$TMPDIR_EVAL/vh-liveness-only.err"; then
355
+ pass "verify-hold PASSES for a mismatched actor when only ambient liveness (no assignment record) exists (#397 fix)"
356
+ else
357
+ fail "verify-hold false-blocked a mismatched actor on liveness-only presence (NO assignment record) -- #397 regression: $(cat "$TMPDIR_EVAL/vh-liveness-only.out" "$TMPDIR_EVAL/vh-liveness-only.err")"
358
+ fi
359
+ [[ "$(json_query "$TMPDIR_EVAL/vh-liveness-only.out" "ok")" == "true" ]] && pass "verify-hold JSON reports ok:true for the liveness-only, no-assignment case (#397 fix)" || fail "verify-hold JSON did not report ok:true for the liveness-only case: $(cat "$TMPDIR_EVAL/vh-liveness-only.out")"
360
+ [[ "$(json_query "$TMPDIR_EVAL/vh-liveness-only.out" "effective_state")" == "held" ]] && pass "verify-hold JSON still reports effective_state:held for the liveness-only case (computeEffectiveState's own label is unchanged -- only the publish-decision mapping changed) (#397 fix)" || fail "verify-hold JSON did not report held: $(cat "$TMPDIR_EVAL/vh-liveness-only.out")"
361
+ [[ "$(json_query "$TMPDIR_EVAL/vh-liveness-only.out" "reason")" == "liveness_claim_present_assignment_lagging" ]] && pass "verify-hold JSON's reason is liveness_claim_present_assignment_lagging (the precise reason this fix targets) (#397 fix)" || fail "verify-hold JSON's reason was not liveness_claim_present_assignment_lagging: $(cat "$TMPDIR_EVAL/vh-liveness-only.out")"
362
+
363
+ # ─── 1d. Unstable (ancestry/unresolved) current actor is advisory-only, never hard-blocked;
364
+ # the SAME seed still hard-blocks a STABLE, differing FLOW_AGENTS_ACTOR (SECOND CI-blocking
365
+ # false-block fix, this iteration) ──────────────────────────────────────────────────────────
366
+ echo "--- 1d. unstable current actor (ancestry fallback) is advisory-only (ok:true); a STABLE differing actor still BLOCKS (AC1) ---"
367
+
368
+ # This pins the advisory-degradation path for an UNSTABLE actor identity: with no explicit override,
369
+ # no native runtime session id, AND no CI-provider identity (#398), resolveActor() falls through to
370
+ # the process-ancestry (or unresolved) layer -- an identity that is NOT guaranteed to match the
371
+ # identity that created the claim below, so the gate must degrade to advisory (never hard-block).
372
+ # NOTE (#398): this case must EXPLICITLY neutralize the CI-provider markers. When this suite runs in
373
+ # real CI (GitHub Actions etc.), those markers are set and resolveActor() would now resolve a STABLE
374
+ # `ci-runtime:*` actor -- which correctly ENFORCES (that IS the #398 payoff, covered by
375
+ # test_ci_actor_identity.sh). Reproducing the ancestry/unstable scenario therefore requires the test
376
+ # to control ALL identity inputs, not rely on the ambient absence of CI env vars. Seed a fresh
377
+ # assignment claim held by a clearly different, stable actor ("eval-actor-unstable-holder"), then:
378
+ # (a) run verify-hold with NO override, NO native session id, and NO CI markers (ancestry fallback)
379
+ # -- must be advisory-only: ok:true, exit 0, reason actor-identity-unstable-advisory-only.
380
+ # (b) run verify-hold with a STABLE FLOW_AGENTS_ACTOR that legitimately differs from the holder
381
+ # -- must still BLOCK exactly as before (zombie protection intact under a stable identity).
382
+ UNSTABLE_SLUG="verify-hold-unstable-actor"
383
+ UNSTABLE_DIR="$ARTIFACT_ROOT/$UNSTABLE_SLUG"
384
+ write_assignment_record "$ARTIFACT_ROOT" "$UNSTABLE_SLUG" "unstable-holder-session" "eval-actor-unstable-holder"
385
+ append_liveness_event "$ARTIFACT_ROOT" "$UNSTABLE_SLUG" "eval-actor-unstable-holder" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
386
+ mkdir -p "$UNSTABLE_DIR"
387
+
388
+ # (a) Unstable current actor: unset the explicit override, the native runtime session id, AND every
389
+ # CI-provider marker (#398) so resolveActor() genuinely falls through to process-ancestry/unresolved
390
+ # regardless of whether this suite is running inside a CI job.
391
+ if (unset FLOW_AGENTS_ACTOR CLAUDE_CODE_SESSION_ID CODEX_SESSION_ID OPENCODE_SESSION_ID PI_SESSION_ID CLAUDECODE \
392
+ GITHUB_ACTIONS GITLAB_CI CIRCLECI JENKINS_URL TF_BUILD BUILDKITE; \
393
+ flow_agents_node "$WRITER" verify-hold "$UNSTABLE_DIR") >"$TMPDIR_EVAL/vh-unstable.out" 2>"$TMPDIR_EVAL/vh-unstable.err"; then
394
+ pass "verify-hold with an unstable (ancestry/unresolved) current actor does NOT hard-block a subject held by a different actor (advisory-only, AC1 SECOND fix)"
395
+ else
396
+ fail "verify-hold with an unstable current actor incorrectly hard-blocked -- this IS the CI false-block this fix targets: $(cat "$TMPDIR_EVAL/vh-unstable.out" "$TMPDIR_EVAL/vh-unstable.err")"
397
+ fi
398
+ [[ "$(json_query "$TMPDIR_EVAL/vh-unstable.out" "ok")" == "true" ]] && pass "unstable-actor verify-hold JSON reports ok:true (AC1 SECOND fix)" || fail "unstable-actor verify-hold JSON did not report ok:true: $(cat "$TMPDIR_EVAL/vh-unstable.out")"
399
+ [[ "$(json_query "$TMPDIR_EVAL/vh-unstable.out" "reason")" == "actor-identity-unstable-advisory-only" ]] && pass "unstable-actor verify-hold JSON's reason is actor-identity-unstable-advisory-only (AC1 SECOND fix)" || fail "unstable-actor verify-hold JSON's reason was not actor-identity-unstable-advisory-only: $(cat "$TMPDIR_EVAL/vh-unstable.out")"
400
+ grep -qiF "advisory only for unstable identities" "$TMPDIR_EVAL/vh-unstable.err" && pass "unstable-actor verify-hold logs a visible stderr advisory note, never silent (AC1 SECOND fix)" || fail "unstable-actor verify-hold did not log the advisory stderr note: $(cat "$TMPDIR_EVAL/vh-unstable.err")"
401
+
402
+ # (b) STABLE but differing actor (explicit FLOW_AGENTS_ACTOR override) on the SAME seed -- must
403
+ # still hard-block exactly as before this fix (zombie protection intact under a stable identity).
404
+ if FLOW_AGENTS_ACTOR="eval-actor-unstable-rechecker" flow_agents_node "$WRITER" verify-hold "$UNSTABLE_DIR" >"$TMPDIR_EVAL/vh-stable-differs.out" 2>"$TMPDIR_EVAL/vh-stable-differs.err"; then
405
+ fail "verify-hold with a STABLE, differing FLOW_AGENTS_ACTOR should have hard-blocked on the same seed (AC1 SECOND fix, zombie-protection regression)"
406
+ else
407
+ pass "verify-hold with a STABLE, differing FLOW_AGENTS_ACTOR still hard-blocks on the same seed (AC1, zombie protection intact under stable identity)"
408
+ fi
409
+ [[ "$(json_query "$TMPDIR_EVAL/vh-stable-differs.out" "ok")" == "false" ]] && pass "stable-differing-actor verify-hold JSON reports ok:false (AC1 SECOND fix regression guard)" || fail "stable-differing-actor verify-hold JSON did not report ok:false: $(cat "$TMPDIR_EVAL/vh-stable-differs.out")"
410
+ grep -qF "eval-actor-unstable-holder" "$TMPDIR_EVAL/vh-stable-differs.out" && pass "stable-differing-actor verify-hold JSON still names the current holder (AC1 SECOND fix regression guard)" || fail "stable-differing-actor verify-hold JSON did not name the holder: $(cat "$TMPDIR_EVAL/vh-stable-differs.out")"
411
+
412
+ # ─── 2. Composition: record-release / publish-delivery as actor A refuse, no delivery/ write (AC1, AC3) ─
413
+ echo "--- 2. publishDelivery()/record-release as actor A (superseded) refuse with NotFreshHolderError, never write delivery/ (AC1, AC3) ---"
414
+
415
+ REPO_A="$TMPDIR_EVAL/repo-a"
416
+ mkdir -p "$REPO_A/kits"
417
+ AROOT_A="$REPO_A/.kontourai/flow-agents"
418
+ COMPOSE_SLUG="verify-hold-compose-block"
419
+ COMPOSE_DIR="$AROOT_A/$COMPOSE_SLUG"
420
+
421
+ BUNDLE_A="$TMPDIR_EVAL/bundle-a.json"
422
+ write_bundle_to "$BUNDLE_A" "node --version" "true"
423
+ setup_session "$AROOT_A" "$COMPOSE_SLUG" "$BUNDLE_A"
424
+
425
+ # Seed a fresh, un-ambiguous superseded-A->B fixture directly (write_assignment_record +
426
+ # append_liveness_event, matching section 1's shape), then run record-release/publish-delivery
427
+ # AS ACTOR A (FLOW_AGENTS_ACTOR override) with a manifest that makes the bundle shape-VALID —
428
+ # isolating the hold gate specifically here (the shape gate's ordering is proven separately in
429
+ # section 4 below).
430
+ write_assignment_record "$AROOT_A" "$COMPOSE_SLUG" "eval-actor-b-compose" "eval-actor-b-compose"
431
+ append_liveness_event "$AROOT_A" "$COMPOSE_SLUG" "eval-actor-b-compose" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
432
+
433
+ rr_out=$(FLOW_AGENTS_ACTOR="eval-actor-a-compose" TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" record-release "$COMPOSE_DIR" \
434
+ --decision merge \
435
+ --gate-json '{"name":"merge","status":"pass","summary":"Ready."}' \
436
+ --summary "Release." --repo-root "$REPO_A" \
437
+ --timestamp "2026-07-04T10:04:00Z" 2>&1)
438
+ rr_exit=$?
439
+ if [[ $rr_exit -ne 0 ]]; then
440
+ pass "record-release as superseded-away actor A exits non-zero (AC1)"
441
+ else
442
+ fail "record-release as superseded-away actor A should have exited non-zero: $rr_out"
443
+ fi
444
+ echo "$rr_out" | grep -qiF "verify-hold gate" && pass "record-release's refusal names the verify-hold gate (AC1, AC3)" || fail "record-release's refusal did not name the verify-hold gate: $rr_out"
445
+ [[ ! -d "$REPO_A/delivery/$COMPOSE_SLUG" ]] && pass "record-release's verify-hold refusal never created delivery/$COMPOSE_SLUG/ (AC1)" || fail "delivery/$COMPOSE_SLUG/ was created despite the verify-hold refusal"
446
+
447
+ pd_out=$(FLOW_AGENTS_ACTOR="eval-actor-a-compose" TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" publish-delivery "$COMPOSE_DIR" --repo-root "$REPO_A" 2>&1)
448
+ pd_exit=$?
449
+ if [[ $pd_exit -ne 0 ]]; then
450
+ pass "publish-delivery as superseded-away actor A exits non-zero (AC1, AC3)"
451
+ else
452
+ fail "publish-delivery as superseded-away actor A should have exited non-zero: $pd_out"
453
+ fi
454
+ echo "$pd_out" | grep -qiF "verify-hold refused publish" && pass "publish-delivery's thrown error message is the NotFreshHolderError text (AC3)" || fail "publish-delivery's error was not the NotFreshHolderError message: $pd_out"
455
+ [[ ! -d "$REPO_A/delivery/$COMPOSE_SLUG" ]] && pass "publish-delivery's verify-hold refusal never created delivery/$COMPOSE_SLUG/ (AC1)" || fail "delivery/$COMPOSE_SLUG/ was created despite the publish-delivery refusal"
456
+
457
+ # ─── 3. Fresh holder + free subject pass (AC2) ─────────────────────────────────────────────
458
+ echo "--- 3. actor B (fresh holder) passes; a free subject passes for any actor (AC2) ---"
459
+
460
+ if flow_agents_node "$WRITER" verify-hold "$SUPERSEDE_DIR" --actor "$ACTOR_KEY_B" \
461
+ >"$TMPDIR_EVAL/vh-b-check.out" 2>"$TMPDIR_EVAL/vh-b-check.err"; then
462
+ pass "actor B's (fresh holder) verify-hold on the same subject exits 0 (AC2)"
463
+ else
464
+ fail "actor B's verify-hold unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-b-check.out" "$TMPDIR_EVAL/vh-b-check.err")"
465
+ fi
466
+ [[ "$(json_query "$TMPDIR_EVAL/vh-b-check.out" "ok")" == "true" ]] && pass "verify-hold JSON reports ok:true for actor B (AC2)" || fail "verify-hold JSON did not report ok:true for actor B: $(cat "$TMPDIR_EVAL/vh-b-check.out")"
467
+
468
+ FREE_SLUG="verify-hold-free-subject"
469
+ FREE_DIR="$ARTIFACT_ROOT/$FREE_SLUG"
470
+ mkdir -p "$FREE_DIR"
471
+ if flow_agents_node "$WRITER" verify-hold "$FREE_DIR" --actor "eval-actor-any-reader" \
472
+ >"$TMPDIR_EVAL/vh-free.out" 2>"$TMPDIR_EVAL/vh-free.err"; then
473
+ pass "verify-hold on a free (no assignment record) subject exits 0 for any actor (AC2)"
474
+ else
475
+ fail "verify-hold on a free subject unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-free.out" "$TMPDIR_EVAL/vh-free.err")"
476
+ fi
477
+ [[ "$(json_query "$TMPDIR_EVAL/vh-free.out" "effective_state")" == "free" ]] && pass "verify-hold JSON reports effective_state:free for the untracked subject (AC2)" || fail "verify-hold JSON did not report free: $(cat "$TMPDIR_EVAL/vh-free.out")"
478
+
479
+ # ─── 4. #356 shape-gate composition ordering: shape runs BEFORE hold (AC3) ─────────────────
480
+ echo "--- 4. composition ordering: shape-invalid+not-held -> InvalidBundleShapeError; shape-valid+not-held -> NotFreshHolderError (AC3) ---"
481
+
482
+ REPO4="$TMPDIR_EVAL/repo4"
483
+ mkdir -p "$REPO4/kits"
484
+ AROOT4="$REPO4/.kontourai/flow-agents"
485
+
486
+ # 4a. BOTH shape-invalid AND not-held: no TRUST_RECONCILE_COMMANDS env, so "node --version"
487
+ # resolves to no manifest entry (shape-invalid, matching test_publish_delivery.sh TEST 6's own
488
+ # not-run fixture); PLUS not-held via a superseded-away FLOW_AGENTS_ACTOR override.
489
+ SLUG_4A="verify-hold-both-invalid"
490
+ DIR_4A="$AROOT4/$SLUG_4A"
491
+ BUNDLE_4A="$TMPDIR_EVAL/bundle-4a.json"
492
+ write_bundle_to "$BUNDLE_4A" "node --version" "true"
493
+ setup_session "$AROOT4" "$SLUG_4A" "$BUNDLE_4A"
494
+
495
+ write_assignment_record "$AROOT4" "$SLUG_4A" "eval-actor-holder-4a" "eval-actor-holder-4a"
496
+ append_liveness_event "$AROOT4" "$SLUG_4A" "eval-actor-holder-4a" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
497
+
498
+ both_invalid_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4a" flow_agents_node "$WRITER" publish-delivery "$DIR_4A" --repo-root "$REPO4" 2>&1)
499
+ both_invalid_exit=$?
500
+ if [[ $both_invalid_exit -ne 0 ]]; then
501
+ pass "shape-invalid+not-held bundle: publish-delivery exits non-zero (AC3)"
502
+ else
503
+ fail "shape-invalid+not-held bundle should have exited non-zero: $both_invalid_out"
504
+ fi
505
+ echo "$both_invalid_out" | grep -qiF "reconcile-preflight shape check" && pass "shape-invalid+not-held bundle: SHAPE error fires specifically (InvalidBundleShapeError text), never the hold error (AC3, ordering proof)" || fail "shape-invalid+not-held bundle did not surface the shape-check error text: $both_invalid_out"
506
+ if echo "$both_invalid_out" | grep -qiF "verify-hold refused publish"; then
507
+ fail "shape-invalid+not-held bundle incorrectly surfaced the NotFreshHolderError text — the shape gate must run FIRST and refuse before the hold gate is ever reached (AC3 ordering violation)"
508
+ else
509
+ pass "shape-invalid+not-held bundle never surfaces the NotFreshHolderError text — confirms the shape gate runs strictly before the hold gate (AC3)"
510
+ fi
511
+ [[ ! -d "$REPO4/delivery/$SLUG_4A" ]] && pass "shape-invalid+not-held bundle: delivery/$SLUG_4A/ was never created (AC3)" || fail "delivery/$SLUG_4A/ was created despite the shape refusal"
512
+
513
+ # 4b. shape-VALID (manifest-matched) but not-held: proves the hold gate IS reachable once the
514
+ # shape gate passes — genuinely exercises the SECOND, distinct error type, not just "shape
515
+ # always wins".
516
+ SLUG_4B="verify-hold-shape-valid-not-held"
517
+ DIR_4B="$AROOT4/$SLUG_4B"
518
+ BUNDLE_4B="$TMPDIR_EVAL/bundle-4b.json"
519
+ write_bundle_to "$BUNDLE_4B" "node --version" "true"
520
+ setup_session "$AROOT4" "$SLUG_4B" "$BUNDLE_4B"
521
+
522
+ write_assignment_record "$AROOT4" "$SLUG_4B" "eval-actor-holder-4b" "eval-actor-holder-4b"
523
+ append_liveness_event "$AROOT4" "$SLUG_4B" "eval-actor-holder-4b" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
524
+
525
+ shape_valid_not_held_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4b" TRUST_RECONCILE_COMMANDS="node --version" flow_agents_node "$WRITER" publish-delivery "$DIR_4B" --repo-root "$REPO4" 2>&1)
526
+ shape_valid_not_held_exit=$?
527
+ if [[ $shape_valid_not_held_exit -ne 0 ]]; then
528
+ pass "shape-valid+not-held bundle: publish-delivery exits non-zero (AC3)"
529
+ else
530
+ fail "shape-valid+not-held bundle should have exited non-zero: $shape_valid_not_held_out"
531
+ fi
532
+ echo "$shape_valid_not_held_out" | grep -qiF "verify-hold refused publish" && pass "shape-valid+not-held bundle: HOLD error fires specifically (NotFreshHolderError text) — the hold gate IS reachable once shape passes (AC3, ordering proof)" || fail "shape-valid+not-held bundle did not surface the NotFreshHolderError text: $shape_valid_not_held_out"
533
+ if echo "$shape_valid_not_held_out" | grep -qiF "reconcile-preflight shape check"; then
534
+ fail "shape-valid+not-held bundle incorrectly surfaced the shape-check error text — the bundle is shape-valid, only the hold gate should fire"
535
+ else
536
+ pass "shape-valid+not-held bundle never surfaces the shape-check error text — confirms the two gates are genuinely distinct, not conflated (AC3)"
537
+ fi
538
+ [[ ! -d "$REPO4/delivery/$SLUG_4B" ]] && pass "shape-valid+not-held bundle: delivery/$SLUG_4B/ was never created (AC3)" || fail "delivery/$SLUG_4B/ was created despite the hold refusal"
539
+
540
+ # ─── 4c. AC3 composition distinctness via .code/.instanceof (F3 fix, fix-plan iteration 1) ──
541
+ # The plan's own Stop-short risks section named this exact gap: the two error classes could be
542
+ # collapsed into one while keeping distinct message text, and a pure stderr string-match (4a/4b
543
+ # above) would never catch it. This harness imports the COMPILED build/src/cli/workflow-sidecar.js
544
+ # directly (never re-implements a parallel check) and calls the exported publishDelivery()
545
+ # against the SAME two fixtures 4a/4b already built above, asserting the CAUGHT error's `.code`
546
+ # discriminator and `instanceof` — a genuine type-discriminator defense, not a message-text match.
547
+ echo "--- 4c. composition distinctness: .code/instanceof assertion on the SAME 4a/4b fixtures (F3, AC3) ---"
548
+
549
+ # Compiled output is a real ES module (package.json "type": "module") -- require() cannot
550
+ # load it. Use dynamic import() via a file:// URL, matching this repo's own established
551
+ # dynamic-import-of-build-output convention (test_bundle_lifecycle.sh / test_checkpoint_signing.sh).
552
+ CODE_HARNESS="$TMPDIR_EVAL/verify-hold-code-harness.mjs"
553
+ python3 - "$CODE_HARNESS" << 'PY'
554
+ import sys
555
+ out = sys.argv[1]
556
+ code_lines = [
557
+ "import { pathToFileURL } from 'node:url';",
558
+ "",
559
+ "async function run() {",
560
+ " const buildJsPath = process.env.WORKFLOW_SIDECAR_BUILD_JS;",
561
+ " const mod = await import(pathToFileURL(buildJsPath).href);",
562
+ " const { publishDelivery, InvalidBundleShapeError, NotFreshHolderError } = mod;",
563
+ " const dir = process.argv[2];",
564
+ " const repoRoot = process.argv[3];",
565
+ " try {",
566
+ " await publishDelivery(dir, repoRoot);",
567
+ " console.log(JSON.stringify({ threw: false }));",
568
+ " } catch (err) {",
569
+ " console.log(JSON.stringify({",
570
+ " threw: true,",
571
+ " code: err && err.code,",
572
+ " name: err && err.name,",
573
+ " isInvalidBundleShapeError: err instanceof InvalidBundleShapeError,",
574
+ " isNotFreshHolderError: err instanceof NotFreshHolderError,",
575
+ " }));",
576
+ " }",
577
+ "}",
578
+ "",
579
+ "run();",
580
+ ]
581
+ with open(out, "w") as f:
582
+ f.write("\n".join(code_lines) + "\n")
583
+ PY
584
+
585
+ WORKFLOW_SIDECAR_BUILD_JS="$ROOT/build/src/cli/workflow-sidecar.js"
586
+ export WORKFLOW_SIDECAR_BUILD_JS
587
+
588
+ # extract_json_field <jsonLine> <fieldName> -- the harness prints exactly one JSON line to
589
+ # stdout; parse it with a tiny inline node -p (no fragile shell string-splitting).
590
+ extract_json_field() {
591
+ node -p "(() => { try { return JSON.parse(process.argv[1])[process.argv[2]]; } catch (e) { return ''; } })()" "$1" "$2" 2>/dev/null
592
+ }
593
+
594
+ code_4a_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4a" node "$CODE_HARNESS" "$DIR_4A" "$REPO4" 2>&1)
595
+ code_4a_json=$(echo "$code_4a_out" | tail -1)
596
+ code_4a_code=$(extract_json_field "$code_4a_json" code)
597
+ code_4a_is_shape=$(extract_json_field "$code_4a_json" isInvalidBundleShapeError)
598
+ if [[ "$code_4a_code" == "RECONCILE_PREFLIGHT_INVALID_SHAPE" ]]; then
599
+ pass "shape-invalid+not-held bundle: caught error .code === RECONCILE_PREFLIGHT_INVALID_SHAPE (F3, AC3 type-discriminator)"
600
+ else
601
+ fail "shape-invalid+not-held bundle: caught error .code was '$code_4a_code', expected RECONCILE_PREFLIGHT_INVALID_SHAPE: $code_4a_out"
602
+ fi
603
+ if [[ "$code_4a_is_shape" == "true" ]]; then
604
+ pass "shape-invalid+not-held bundle: caught error is instanceof InvalidBundleShapeError (F3, AC3)"
605
+ else
606
+ fail "shape-invalid+not-held bundle: caught error is NOT instanceof InvalidBundleShapeError: $code_4a_out"
607
+ fi
608
+
609
+ code_4b_out=$(FLOW_AGENTS_ACTOR="eval-actor-not-holder-4b" TRUST_RECONCILE_COMMANDS="node --version" node "$CODE_HARNESS" "$DIR_4B" "$REPO4" 2>&1)
610
+ code_4b_json=$(echo "$code_4b_out" | tail -1)
611
+ code_4b_code=$(extract_json_field "$code_4b_json" code)
612
+ code_4b_is_hold=$(extract_json_field "$code_4b_json" isNotFreshHolderError)
613
+ if [[ "$code_4b_code" == "VERIFY_HOLD_NOT_FRESH_HOLDER" ]]; then
614
+ pass "shape-valid+not-held bundle: caught error .code === VERIFY_HOLD_NOT_FRESH_HOLDER (F3, AC3 type-discriminator)"
615
+ else
616
+ fail "shape-valid+not-held bundle: caught error .code was '$code_4b_code', expected VERIFY_HOLD_NOT_FRESH_HOLDER: $code_4b_out"
617
+ fi
618
+ if [[ "$code_4b_is_hold" == "true" ]]; then
619
+ pass "shape-valid+not-held bundle: caught error is instanceof NotFreshHolderError (F3, AC3)"
620
+ else
621
+ fail "shape-valid+not-held bundle: caught error is NOT instanceof NotFreshHolderError: $code_4b_out"
622
+ fi
623
+ [[ "$code_4a_code" != "$code_4b_code" ]] && pass "F3: the two composition-gate error codes are genuinely distinct strings ('$code_4a_code' != '$code_4b_code'), not the same class with different message text" || fail "F3: the two composition-gate error codes are IDENTICAL — this is exactly the stop-short risk the plan named"
624
+
625
+ # ─── 5. github-provider precomputed gate (AC6) ─────────────────────────────────────────────
626
+ echo "--- 5. github-provider read-only precomputed gate: no gh process, not_evaluated fallback (AC6) ---"
627
+
628
+ GITHUB_NOT_HELD_FIXTURE="$TMPDIR_EVAL/github-not-held.json"
629
+ node -e '
630
+ const fs = require("fs");
631
+ const fixture = { effective: { effective_state: "held", reason: "fresh_liveness_heartbeat", holder: { actor: "github-holder-actor", idle_days: 0 } } };
632
+ fs.writeFileSync(process.argv[1], JSON.stringify(fixture, null, 2));
633
+ ' "$GITHUB_NOT_HELD_FIXTURE"
634
+ GITHUB_DIR="$ARTIFACT_ROOT/verify-hold-github-not-held"
635
+ mkdir -p "$GITHUB_DIR"
636
+ if flow_agents_node "$WRITER" verify-hold "$GITHUB_DIR" \
637
+ --actor eval-actor-github-reader \
638
+ --assignment-provider github \
639
+ --effective-state-json "$GITHUB_NOT_HELD_FIXTURE" \
640
+ >"$TMPDIR_EVAL/vh-github-not-held.out" 2>"$TMPDIR_EVAL/vh-github-not-held.err"; then
641
+ fail "verify-hold --assignment-provider github with a not-held precomputed state should have exited non-zero (AC6)"
642
+ else
643
+ pass "verify-hold --assignment-provider github with a not-held precomputed state exits non-zero (AC6)"
644
+ fi
645
+ [[ "$(json_query "$TMPDIR_EVAL/vh-github-not-held.out" "ok")" == "false" ]] && pass "github-provider precomputed not-held state reports ok:false (AC6)" || fail "github-provider precomputed not-held state did not report ok:false: $(cat "$TMPDIR_EVAL/vh-github-not-held.out")"
646
+
647
+ GITHUB_HELD_SELF_FIXTURE="$TMPDIR_EVAL/github-held-self.json"
648
+ node -e '
649
+ const fs = require("fs");
650
+ const fixture = { effective: { effective_state: "held", reason: "self_is_holder", holder: { actor: "eval-actor-github-reader", idle_days: 0 } } };
651
+ fs.writeFileSync(process.argv[1], JSON.stringify(fixture, null, 2));
652
+ ' "$GITHUB_HELD_SELF_FIXTURE"
653
+ if flow_agents_node "$WRITER" verify-hold "$GITHUB_DIR" \
654
+ --actor eval-actor-github-reader \
655
+ --assignment-provider github \
656
+ --effective-state-json "$GITHUB_HELD_SELF_FIXTURE" \
657
+ >"$TMPDIR_EVAL/vh-github-held-self.out" 2>"$TMPDIR_EVAL/vh-github-held-self.err"; then
658
+ pass "verify-hold --assignment-provider github with a held-self precomputed state exits 0 (AC6)"
659
+ else
660
+ fail "verify-hold --assignment-provider github with a held-self precomputed state unexpectedly refused: $(cat "$TMPDIR_EVAL/vh-github-held-self.out" "$TMPDIR_EVAL/vh-github-held-self.err")"
661
+ fi
662
+
663
+ # No `gh` process is ever spawned by this eval's own invocations (the fixtures above are read
664
+ # directly from disk via --effective-state-json — render-don't-execute). Additionally assert
665
+ # the CLI source itself never shells `gh` from workflow-sidecar.ts's build output (matching the
666
+ # render-don't-execute convention's own self-check pattern): only fail if a literal live-process
667
+ # invocation (execFileSync/spawn/exec) targets "gh" — a "gh" token elsewhere (e.g. render-claim's
668
+ # emitted gh_commands prose) is not itself a live invocation.
669
+ if grep -qE '(execFileSync|spawnSync|spawn|execFile|exec)\(\s*["'"'"']gh["'"'"']' "$ROOT/build/src/cli/workflow-sidecar.js" 2>/dev/null; then
670
+ fail "workflow-sidecar.js contains a live 'gh' process invocation — violates render-don't-execute (AC6)"
671
+ else
672
+ pass "workflow-sidecar.js never shells out to a live 'gh' process (render-don't-execute preserved) (AC6)"
673
+ fi
674
+
675
+ # Neither github handling (no --effective-state-json) nor a resolvable local-file join
676
+ # (--assignment-provider github, no local assignment record for this fresh slug) -> not_evaluated,
677
+ # never a silent block.
678
+ NOT_EVAL_DIR="$ARTIFACT_ROOT/verify-hold-not-evaluated"
679
+ mkdir -p "$NOT_EVAL_DIR"
680
+ if flow_agents_node "$WRITER" verify-hold "$NOT_EVAL_DIR" --actor eval-actor-not-eval --assignment-provider github \
681
+ >"$TMPDIR_EVAL/vh-not-evaluated.out" 2>"$TMPDIR_EVAL/vh-not-evaluated.err"; then
682
+ pass "verify-hold with neither github fixture nor local-file resolution exits 0 (not_evaluated, never a silent block) (AC6)"
683
+ else
684
+ fail "verify-hold with no resolvable join should PASS-through (not_evaluated), not block: $(cat "$TMPDIR_EVAL/vh-not-evaluated.out" "$TMPDIR_EVAL/vh-not-evaluated.err")"
685
+ fi
686
+ [[ "$(json_query "$TMPDIR_EVAL/vh-not-evaluated.out" "effective_state")" == "not_evaluated" ]] && pass "verify-hold JSON reports effective_state:not_evaluated for the unresolvable-provider case (AC6)" || fail "verify-hold JSON did not report not_evaluated: $(cat "$TMPDIR_EVAL/vh-not-evaluated.out")"
687
+ grep -qiF "not evaluated" "$TMPDIR_EVAL/vh-not-evaluated.err" && pass "verify-hold's not_evaluated case logs a visible stderr reason (AC6)" || fail "verify-hold's not_evaluated case did not log a visible stderr reason: $(cat "$TMPDIR_EVAL/vh-not-evaluated.err")"
688
+
689
+ # ─── 6. Injection discipline (AC7) ─────────────────────────────────────────────────────────
690
+ echo "--- 6. hostile holder actor string never leaks raw control/ANSI bytes (AC7) ---"
691
+
692
+ HOSTILE_SLUG="verify-hold-hostile-liveness"
693
+ HOSTILE_DIR="$ARTIFACT_ROOT/$HOSTILE_SLUG"
694
+ HOSTILE_HOLDER_ACTOR=$'hostile-holder-actor\x1b[31;1mFAKE\x07-admin'
695
+ write_assignment_record "$ARTIFACT_ROOT" "$HOSTILE_SLUG" "hostile-session" "$HOSTILE_HOLDER_ACTOR"
696
+ append_liveness_event "$ARTIFACT_ROOT" "$HOSTILE_SLUG" "$HOSTILE_HOLDER_ACTOR" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 1800
697
+ mkdir -p "$HOSTILE_DIR"
698
+
699
+ if flow_agents_node "$WRITER" verify-hold "$HOSTILE_DIR" --actor eval-actor-hostile-reader \
700
+ >"$TMPDIR_EVAL/vh-hostile.out" 2>"$TMPDIR_EVAL/vh-hostile.err"; then
701
+ fail "verify-hold against a hostile holder should have refused (AC1 shape, AC7)"
702
+ else
703
+ pass "verify-hold against a hostile holder actor refuses (held, not-self) (AC7 setup)"
704
+ fi
705
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/vh-hostile.out" "$TMPDIR_EVAL/vh-hostile.err" || grep -qF $'\x07' "$TMPDIR_EVAL/vh-hostile.out" "$TMPDIR_EVAL/vh-hostile.err"; then
706
+ fail "hostile holder actor's raw ANSI/control bytes leaked into verify-hold's JSON output or stderr (AC7)"
707
+ else
708
+ pass "hostile holder actor's raw ANSI/control bytes never leak into verify-hold's JSON output or stderr (AC7)"
709
+ fi
710
+ grep -qF "hostile-holder-actor" "$TMPDIR_EVAL/vh-hostile.out" && pass "the sanitized (non-control-byte) portion of the hostile holder actor string still appears in verify-hold's output (AC7)" || fail "sanitized portion of the hostile holder actor string was unexpectedly dropped entirely: $(cat "$TMPDIR_EVAL/vh-hostile.out")"
711
+
712
+ # Steering hook side of AC7: the SAME hostile holder, surfaced via the supersession notice.
713
+ STEERING_REPO="$TMPDIR_EVAL/steering-hostile-repo"
714
+ STEERING_HOSTILE_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
715
+ write_steering_fixture_repo "$STEERING_REPO" "$HOSTILE_SLUG" "$STEERING_HOSTILE_NOW"
716
+ append_liveness_event "$STEERING_REPO/.kontourai/flow-agents" "$HOSTILE_SLUG" "$HOSTILE_HOLDER_ACTOR" "$STEERING_HOSTILE_NOW" 1800
717
+
718
+ if FLOW_AGENTS_ACTOR="eval-actor-hostile-reader" node "$ROOT/scripts/hooks/workflow-steering.js" \
719
+ >"$TMPDIR_EVAL/steering-hostile.out" 2>"$TMPDIR_EVAL/steering-hostile.err" <<STEERJSON
720
+ {"hook_event_name":"UserPromptSubmit","cwd":"$STEERING_REPO","prompt":"continue"}
721
+ STEERJSON
722
+ then
723
+ pass "steering hook processes the hostile-holder fixture without failing (AC7 setup)"
724
+ else
725
+ fail "steering hook should not fail for the hostile-holder fixture"
726
+ fi
727
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/steering-hostile.out" || grep -qF $'\x07' "$TMPDIR_EVAL/steering-hostile.out"; then
728
+ fail "hostile holder actor's raw ANSI/control bytes leaked into the steering supersession notice (AC7)"
729
+ else
730
+ pass "hostile holder actor's raw ANSI/control bytes never leak into the steering supersession notice (AC7)"
731
+ fi
732
+
733
+ # ─── 6b. holder.last_at injection via claimed_at/liveness `at` (F1 fix, fix-plan iteration 1) ──
734
+ # Section 6 above only injects hostile bytes into the ACTOR field -- it would pass even with
735
+ # the F1 bug present (runVerifyHold's held/reclaimable/human-held branches spread
736
+ # effective.holder and only sanitized actor/assignee, leaving last_at raw+uncapped). This
737
+ # section injects a hostile+oversized value into (a) the assignment record's claimed_at
738
+ # (forcing `reclaimable`, no fresh liveness event) and (b) a liveness event's `at` (forcing
739
+ # `held`), asserting the resulting `holder.last_at` in verify-hold's JSON is BOTH control-char-
740
+ # stripped AND length-capped (<=64, matching sanitize()'s stripControlCharsForDisplay(...).slice(0,64)).
741
+ echo "--- 6b. holder.last_at (claimed_at / liveness 'at') never leaks raw/oversized bytes (F1, AC7) ---"
742
+
743
+ HOSTILE_LAST_AT_RAW=$'2026-07-04T23:59:00Z-INJECTED-\x1b[31;1mFAKE\x07-'"$(printf 'Z%.0s' {1..300})"
744
+
745
+ # 6b-i. reclaimable: hostile claimed_at, NO liveness event at all (assignment present, no fresh
746
+ # heartbeat -> reclaimable per computeEffectiveState).
747
+ RECLAIM_INJECT_SLUG="verify-hold-hostile-claimed-at"
748
+ RECLAIM_INJECT_DIR="$ARTIFACT_ROOT/$RECLAIM_INJECT_SLUG"
749
+ write_assignment_record "$ARTIFACT_ROOT" "$RECLAIM_INJECT_SLUG" "hostile-claimed-at-session" "eval-actor-hostile-claimed-at-holder" "$HOSTILE_LAST_AT_RAW"
750
+ mkdir -p "$RECLAIM_INJECT_DIR"
751
+
752
+ if flow_agents_node "$WRITER" verify-hold "$RECLAIM_INJECT_DIR" --actor eval-actor-hostile-claimed-at-reader \
753
+ >"$TMPDIR_EVAL/vh-hostile-claimed-at.out" 2>"$TMPDIR_EVAL/vh-hostile-claimed-at.err"; then
754
+ fail "verify-hold against a hostile claimed_at fixture should have refused (reclaimable) (F1 setup)"
755
+ else
756
+ pass "verify-hold against a hostile claimed_at fixture refuses (reclaimable, not-self) (F1 setup)"
757
+ fi
758
+ [[ "$(json_query "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "effective_state")" == "reclaimable" ]] && pass "hostile claimed_at fixture classifies reclaimable, exercising the claimed_at->last_at path specifically (F1 setup)" || fail "hostile claimed_at fixture did not classify reclaimable: $(cat "$TMPDIR_EVAL/vh-hostile-claimed-at.out")"
759
+ RECLAIM_LAST_AT="$(json_query "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "holder.last_at")"
760
+ if [[ "$RECLAIM_LAST_AT" == *$'\x1b'* || "$RECLAIM_LAST_AT" == *$'\x07'* ]]; then
761
+ fail "holder.last_at (from claimed_at) leaked raw control/ANSI bytes into verify-hold's JSON (F1 HIGH regression): $RECLAIM_LAST_AT"
762
+ else
763
+ pass "holder.last_at (from claimed_at) is control-char-stripped in verify-hold's JSON (F1)"
764
+ fi
765
+ RECLAIM_LAST_AT_LEN="${#RECLAIM_LAST_AT}"
766
+ if [[ "$RECLAIM_LAST_AT_LEN" -le 64 ]]; then
767
+ pass "holder.last_at (from claimed_at) is length-capped at <=64 chars in verify-hold's JSON (F1) (len=$RECLAIM_LAST_AT_LEN)"
768
+ else
769
+ fail "holder.last_at (from claimed_at) exceeded the 64-char cap in verify-hold's JSON (F1 HIGH regression): len=$RECLAIM_LAST_AT_LEN value=$RECLAIM_LAST_AT"
770
+ fi
771
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "$TMPDIR_EVAL/vh-hostile-claimed-at.err" || grep -qF $'\x07' "$TMPDIR_EVAL/vh-hostile-claimed-at.out" "$TMPDIR_EVAL/vh-hostile-claimed-at.err"; then
772
+ fail "hostile claimed_at's raw ANSI/control bytes leaked ANYWHERE in verify-hold's output (stdout+stderr) (F1 HIGH regression)"
773
+ else
774
+ pass "hostile claimed_at's raw ANSI/control bytes never leak anywhere in verify-hold's output (F1)"
775
+ fi
776
+
777
+ # 6b-ii. held: OVERSIZED (but Date.parse-valid) liveness event `at`, no assignment record at
778
+ # all (liveness-only -> `held`/`liveness_claim_present_assignment_lagging` per
779
+ # computeEffectiveState's no-assignment branch), exercising the SECOND last_at source (the
780
+ # liveness event, not claimed_at). Unlike 6b-i's claimed_at (copied through with NO freshness
781
+ # gate -- control chars and any length pass straight through to reclaimable), this `at` value
782
+ # MUST survive freshHolders()'s `Date.parse(g.lastAt)` freshness check to remain in the fresh
783
+ # set and reach the `held` branch at all -- embedding raw control/ANSI bytes here makes
784
+ # Date.parse return NaN, which drops the event as stale/absent (verified directly: the fixture
785
+ # then resolves free/not-held, never reaching this code path, so control bytes are not a
786
+ # reachable injection vector via THIS source). What IS reachable and still exercises the exact
787
+ # length-cap half of the AC7 sanitize() contract on this second last_at source: an
788
+ # oversized-but-parseable timestamp (300+ garbage digits crammed into the fractional-seconds
789
+ # field, which Date.parse tolerates and rounds into a valid instant) -- proving the cap applies
790
+ # regardless of which of the two last_at sources fed it.
791
+ #
792
+ # NOTE (#397 fix): this fixture has NO assignment record at all (liveness-only), so per the
793
+ # #397 fix runVerifyHold now PASSES it (reason liveness_claim_present_assignment_lagging is no
794
+ # longer a publish-block -- liveness alone is never a durable ownership conflict). The
795
+ # effective_state/holder shape is UNCHANGED by that fix (computeEffectiveState's own
796
+ # classification and holder payload are untouched; only the publish-decision mapping in
797
+ # runVerifyHold changed) -- so the AC7 sanitize()/length-cap assertions on holder.last_at below
798
+ # remain fully valid and are asserted unchanged, just against an `ok:true` result now instead of
799
+ # `ok:false`.
800
+ HELD_INJECT_SLUG="verify-hold-hostile-liveness-at"
801
+ HELD_INJECT_DIR="$ARTIFACT_ROOT/$HELD_INJECT_SLUG"
802
+ OVERSIZED_FRESH_AT="$(node -e 'const iso = new Date().toISOString(); process.stdout.write(iso.replace(/\.\d+Z$/, "." + "1".repeat(300) + "Z"));')"
803
+ append_liveness_event "$ARTIFACT_ROOT" "$HELD_INJECT_SLUG" "eval-actor-hostile-liveness-at-holder" "$OVERSIZED_FRESH_AT" 1800
804
+ mkdir -p "$HELD_INJECT_DIR"
805
+
806
+ if flow_agents_node "$WRITER" verify-hold "$HELD_INJECT_DIR" --actor eval-actor-hostile-liveness-at-reader \
807
+ >"$TMPDIR_EVAL/vh-hostile-liveness-at.out" 2>"$TMPDIR_EVAL/vh-hostile-liveness-at.err"; then
808
+ pass "verify-hold against an oversized-liveness-at, no-assignment-record fixture PASSES (liveness-only is never a publish block) (#397 fix)"
809
+ else
810
+ fail "verify-hold against an oversized-liveness-at, no-assignment-record fixture should have PASSED (liveness-only, no durable assignment -- #397 regression): $(cat "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "$TMPDIR_EVAL/vh-hostile-liveness-at.err")"
811
+ fi
812
+ [[ "$(json_query "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "effective_state")" == "held" ]] && pass "oversized-liveness-at fixture classifies held, exercising the liveness-event->last_at path specifically (F1 setup)" || fail "oversized-liveness-at fixture did not classify held (Date.parse-valid timestamp should stay in the fresh set): $(cat "$TMPDIR_EVAL/vh-hostile-liveness-at.out")"
813
+ HELD_LAST_AT="$(json_query "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "holder.last_at")"
814
+ if [[ "$HELD_LAST_AT" == *$'\x1b'* || "$HELD_LAST_AT" == *$'\x07'* ]]; then
815
+ fail "holder.last_at (from liveness 'at') leaked raw control/ANSI bytes into verify-hold's JSON (F1 HIGH regression): $HELD_LAST_AT"
816
+ else
817
+ pass "holder.last_at (from liveness 'at') has no raw control/ANSI bytes in verify-hold's JSON (F1, regression guard -- this source is Date.parse-gated so control bytes are unreachable here, see comment above)"
818
+ fi
819
+ HELD_LAST_AT_LEN="${#HELD_LAST_AT}"
820
+ if [[ "$HELD_LAST_AT_LEN" -le 64 ]]; then
821
+ pass "holder.last_at (from liveness 'at') is length-capped at <=64 chars in verify-hold's JSON despite a 300+ char source value (F1) (len=$HELD_LAST_AT_LEN)"
822
+ else
823
+ fail "holder.last_at (from liveness 'at') exceeded the 64-char cap in verify-hold's JSON (F1 HIGH regression): len=$HELD_LAST_AT_LEN value=$HELD_LAST_AT"
824
+ fi
825
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "$TMPDIR_EVAL/vh-hostile-liveness-at.err" || grep -qF $'\x07' "$TMPDIR_EVAL/vh-hostile-liveness-at.out" "$TMPDIR_EVAL/vh-hostile-liveness-at.err"; then
826
+ fail "oversized liveness 'at' leaked raw control/ANSI bytes ANYWHERE in verify-hold's output (stdout+stderr) (F1 HIGH regression)"
827
+ else
828
+ pass "oversized liveness 'at' never leaks raw control/ANSI bytes anywhere in verify-hold's output (F1, regression guard)"
829
+ fi
830
+
831
+ # ─── 7. Steering surfaces supersession on EVERY turn, not just SessionStart (AC5) ──────────
832
+ echo "--- 7. supersessionSteering fires on UserPromptSubmit AND SessionStart; absent with no other-actor holder (AC5) ---"
833
+
834
+ STEERING_SUP_REPO="$TMPDIR_EVAL/steering-supersede-repo"
835
+ SUP_SLUG="steering-superseded-demo"
836
+ SUP_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
837
+ write_steering_fixture_repo "$STEERING_SUP_REPO" "$SUP_SLUG" "$SUP_NOW"
838
+ append_liveness_event "$STEERING_SUP_REPO/.kontourai/flow-agents" "$SUP_SLUG" "eval-actor-steering-holder-b" "$SUP_NOW" 1800
839
+
840
+ # 7a. UserPromptSubmit on a SECOND, unrelated turn (not the takeover turn itself) — the
841
+ # every-turn requirement (AC5's core assertion, distinguishing this from SessionStart-only).
842
+ if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
843
+ >"$TMPDIR_EVAL/steering-sup-prompt.out" 2>"$TMPDIR_EVAL/steering-sup-prompt.err" <<STEERJSON
844
+ {"hook_event_name":"UserPromptSubmit","cwd":"$STEERING_SUP_REPO","prompt":"what is the status of this work?"}
845
+ STEERJSON
846
+ then
847
+ pass "steering hook processes UserPromptSubmit for the superseded fixture without failing (AC5 setup)"
848
+ else
849
+ fail "steering hook should not fail for UserPromptSubmit on the superseded fixture"
850
+ fi
851
+ grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-sup-prompt.out" && pass "supersession notice appears at UserPromptSubmit (every-turn, not just SessionStart) (AC5)" || fail "supersession notice missing at UserPromptSubmit: $(cat "$TMPDIR_EVAL/steering-sup-prompt.out")"
852
+ grep -qF "eval-actor-steering-holder-b" "$TMPDIR_EVAL/steering-sup-prompt.out" && pass "UserPromptSubmit supersession notice names the current (other-actor) holder (AC5)" || fail "UserPromptSubmit supersession notice did not name the holder: $(cat "$TMPDIR_EVAL/steering-sup-prompt.out")"
853
+ grep -qiF "verify-hold" "$TMPDIR_EVAL/steering-sup-prompt.out" && pass "UserPromptSubmit supersession notice references the verify-hold gate (AC5)" || fail "UserPromptSubmit supersession notice did not reference verify-hold: $(cat "$TMPDIR_EVAL/steering-sup-prompt.out")"
854
+
855
+ # 7b. SessionStart — regression check: still present there too.
856
+ if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
857
+ >"$TMPDIR_EVAL/steering-sup-start.out" 2>"$TMPDIR_EVAL/steering-sup-start.err" <<STEERJSON
858
+ {"hook_event_name":"SessionStart","cwd":"$STEERING_SUP_REPO"}
859
+ STEERJSON
860
+ then
861
+ pass "steering hook processes SessionStart for the superseded fixture without failing (AC5 setup)"
862
+ else
863
+ fail "steering hook should not fail for SessionStart on the superseded fixture"
864
+ fi
865
+ grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-sup-start.out" && pass "supersession notice still appears at SessionStart (regression check) (AC5)" || fail "supersession notice missing at SessionStart: $(cat "$TMPDIR_EVAL/steering-sup-start.out")"
866
+
867
+ # 7c. No other-actor fresh holder -> no notice at either event (no false positive).
868
+ NO_SUP_REPO="$TMPDIR_EVAL/steering-no-supersede-repo"
869
+ NO_SUP_SLUG="steering-not-superseded-demo"
870
+ NO_SUP_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
871
+ write_steering_fixture_repo "$NO_SUP_REPO" "$NO_SUP_SLUG" "$NO_SUP_NOW"
872
+ # No liveness stream at all for this repo — no other-actor holder can exist.
873
+
874
+ if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
875
+ >"$TMPDIR_EVAL/steering-no-sup-prompt.out" 2>"$TMPDIR_EVAL/steering-no-sup-prompt.err" <<STEERJSON
876
+ {"hook_event_name":"UserPromptSubmit","cwd":"$NO_SUP_REPO","prompt":"what is the status of this work?"}
877
+ STEERJSON
878
+ then
879
+ pass "steering hook processes UserPromptSubmit for the non-superseded fixture without failing (AC5 setup)"
880
+ else
881
+ fail "steering hook should not fail for UserPromptSubmit on the non-superseded fixture"
882
+ fi
883
+ if grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-no-sup-prompt.out"; then
884
+ fail "supersession notice incorrectly appeared at UserPromptSubmit with no other-actor fresh holder (false positive) (AC5)"
885
+ else
886
+ pass "no supersession notice at UserPromptSubmit when there is no other-actor fresh holder (AC5, no false positive)"
887
+ fi
888
+
889
+ if FLOW_AGENTS_ACTOR="eval-actor-steering-self-a" node "$ROOT/scripts/hooks/workflow-steering.js" \
890
+ >"$TMPDIR_EVAL/steering-no-sup-start.out" 2>"$TMPDIR_EVAL/steering-no-sup-start.err" <<STEERJSON
891
+ {"hook_event_name":"SessionStart","cwd":"$NO_SUP_REPO"}
892
+ STEERJSON
893
+ then
894
+ pass "steering hook processes SessionStart for the non-superseded fixture without failing (AC5 setup)"
895
+ else
896
+ fail "steering hook should not fail for SessionStart on the non-superseded fixture"
897
+ fi
898
+ if grep -qF "[SUPERSEDED:" "$TMPDIR_EVAL/steering-no-sup-start.out"; then
899
+ fail "supersession notice incorrectly appeared at SessionStart with no other-actor fresh holder (false positive) (AC5)"
900
+ else
901
+ pass "no supersession notice at SessionStart when there is no other-actor fresh holder (AC5, no false positive)"
902
+ fi
903
+
904
+ echo ""
905
+ if [[ "$errors" -eq 0 ]]; then
906
+ echo "test_verify_hold: all checks passed."
907
+ else
908
+ echo "test_verify_hold: $errors check(s) failed."
909
+ fi
910
+ exit "$errors"