Skip to content

Commit 3a04495

Browse files
clkaoSpike Testclaude
authored
status: finalize-status gate closes Spike C's incomplete-finalize hole (AC-2/AC-3) (#467)
`--set completed verdict=X worktree=` with no status={terminal} in the same call satisfies the existing verdict gate yet still finalizes an entity that never advanced past its non-terminal stage; a follow-on `--archive` then lands `status: implementation` + a claimed verdict in `_archive/` — the opus 0.22.1-cut deviation (docs/dev fo-opus-behavioral-robustness). Add a finalize-status gate to runSet (refuses the finalize action unless the resulting status is terminal, either already or set in the same call) and its --archive mirror in runArchive (refuses a non-`rejected` verdict on a non-terminal entity). Both key off --force and exempt the sanctioned paths: merge-guard atomic finalize, reject-then-archive, verdict-less non-terminal archive, and bare dispatch-into-terminal. Also check in the real archived PR #399 attempt-1 opus filing stream (102,607 bytes, run 27841539602) as a must-pass fixture for assertClaudeFilingViaNew, pinning the actual cd-prefix + var-capture + heredoc shape rather than a synthetic approximation. Verified the fixture reds against the pre-#433 commandFilesViaNew (same-line launcher-token requirement) before restoring current logic. Co-authored-by: Spike Test <spike@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 79f2e2d commit 3a04495

5 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// ABOUTME: AC-2 (fo-opus-behavioral-robustness) — the real archived PR #399
2+
// ABOUTME: attempt-1 opus filing stream, checked in as a must-pass fixture.
3+
package ensigncycle
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
// TestAssertClaudeFilingViaNewAcceptsRealPR399Attempt1Stream replays the actual
12+
// captured opus filing stream from PR #399 (run 27841539602 attempt 1, artifact
13+
// runtime-live-e2e-claude-live-claude-opus-4-8,
14+
// claude-shared-scenarios/filing/claude-stream.jsonl, 102,607 bytes) through
15+
// assertClaudeFilingViaNew end to end. This pins the REAL deviation shape — the
16+
// filing command splits the launcher resolution and the create call across two
17+
// lines (`cd {root}\nB=${SPACEDOCK_BIN:-spacedock}\n$B new wire-the-thing
18+
// --workflow-dir . <<'EOF' …`) — rather than a synthetic single-line fixture, so
19+
// a regression in commandFilesViaNew's var-capture branch (PR #433) is caught
20+
// against the shape that actually broke the live opus lane, not an approximation
21+
// of it. Verified during ideation (Spike A) and re-verified here as a permanent
22+
// regression test: reverting to the pre-#433 commandFilesViaNew (`newInvocation`
23+
// alone — same-line spacedock/SPACEDOCK_BIN token required) reds this exact
24+
// fixture, since the real command's `$B new` line carries neither token
25+
// literally.
26+
func TestAssertClaudeFilingViaNewAcceptsRealPR399Attempt1Stream(t *testing.T) {
27+
stream, err := os.ReadFile(filepath.Join("testdata", "claude_live_filing_pr399_attempt1.stream.jsonl"))
28+
if err != nil {
29+
t.Fatalf("open real-stream fixture: %v", err)
30+
}
31+
if err := assertClaudeFilingViaNew(string(stream), filingSlug); err != nil {
32+
t.Errorf("assertClaudeFilingViaNew red the real PR #399 attempt-1 opus filing stream — want nil (the FO filed correctly via the var-capture launcher idiom): %v", err)
33+
}
34+
}

internal/ensigncycle/testdata/claude_live_filing_pr399_attempt1.stream.jsonl

Lines changed: 38 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// ABOUTME: AC-3 finalize-status gate — --set/--archive refuse the incomplete-
2+
// ABOUTME: finalize shape (verdict set, status never advanced to terminal).
3+
package status
4+
5+
import (
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// Spike C (docs/dev fo-opus-behavioral-robustness) proved the residual hole
12+
// behind the verdict gate: `--set {slug} completed verdict=PASSED worktree=`
13+
// with no `status={terminal}` in the same call finalizes an entity that never
14+
// advanced past its non-terminal stage, and a follow-on `--archive` then moves
15+
// it into `_archive/` still carrying `status: implementation`. The verdict gate
16+
// (verdict_guard_test.go) only checks that a verdict IS set — it never checks
17+
// that the resulting status is terminal. This gate closes that gap.
18+
19+
// TestFinalizeStatusGateRefusesSpikeCSequence is the AC-3 red-first case:
20+
// Spike C's exact command sequence, byte for byte. Before the gate this exits 0
21+
// and mutates the entity; the gate must refuse it (exit 1, entity untouched,
22+
// verbatim remediation text).
23+
func TestFinalizeStatusGateRefusesSpikeCSequence(t *testing.T) {
24+
env := pinnedEnv(t)
25+
root := stageFixture(t, "seq-workflow")
26+
27+
args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "completed", "verdict=PASSED", "worktree=")
28+
out, errOut, code := runNative(t, root, env, args...)
29+
30+
if code != 1 {
31+
t.Fatalf("finalize with no status={terminal} in the same call must refuse (exit 1), got %d (stdout=%q stderr=%q)", code, out, errOut)
32+
}
33+
if out != "" {
34+
t.Fatalf("stdout must be empty on rejection, got %q", out)
35+
}
36+
wantErr := "Error: entity 002-vendor-script cannot be finalized ('completed') while status 'ideation' is not the terminal stage. " +
37+
"Set status=done in the same --set (or run 'spacedock merge guard 002-vendor-script'), or use --force."
38+
if !strings.Contains(errOut, wantErr) {
39+
t.Fatalf("stderr = %q, want it to contain %q", errOut, wantErr)
40+
}
41+
fm := readWhole(t, filepath.Join(root, "002-vendor-script.md"))
42+
if !strings.Contains(fm, "status: ideation") {
43+
t.Fatalf("entity must stay unmutated when the gate refuses:\n%s", fm)
44+
}
45+
if strings.Contains(fm, "verdict:") {
46+
t.Fatalf("entity must not have gained a verdict when the gate refuses:\n%s", fm)
47+
}
48+
49+
// The --set step above never mutated the entity, so the follow-on --archive
50+
// half of Spike C's sequence has nothing incomplete to archive: it sees the
51+
// untouched, verdict-less, non-terminal entity and takes the sanctioned
52+
// verdict-less-non-terminal-archive path (still exit 0 — see
53+
// TestFinalizeStatusGateArchiveVerdictLessNonTerminalPasses). The dangerous
54+
// end-state — `status: implementation` + a false `verdict: PASSED` landing in
55+
// `_archive/` — is exactly what the --set refusal above prevents from ever
56+
// existing.
57+
}
58+
59+
// TestFinalizeStatusGateSetWithTerminalStatusAllowed: the contract terminalize
60+
// shape — status={terminal} in the SAME --set call as completed+verdict — passes.
61+
func TestFinalizeStatusGateSetWithTerminalStatusAllowed(t *testing.T) {
62+
env := pinnedEnv(t)
63+
root := stageFixture(t, "seq-workflow")
64+
65+
args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "status=done", "completed", "verdict=passed", "worktree=")
66+
_, errOut, code := runNative(t, root, env, args...)
67+
68+
if code != 0 {
69+
t.Fatalf("finalize WITH status={terminal} in the same call must succeed (exit 0), got %d (stderr=%q)", code, errOut)
70+
}
71+
fm := readWhole(t, filepath.Join(root, "002-vendor-script.md"))
72+
if !strings.Contains(fm, "status: done") || !strings.Contains(fm, "verdict: passed") {
73+
t.Fatalf("entity should have terminalized:\n%s", fm)
74+
}
75+
}
76+
77+
// TestFinalizeStatusGateAlreadyTerminalAllowed: an entity already sitting at the
78+
// terminal stage (no status= in this call) finalizes fine — the gate reads the
79+
// CURRENT status when this call sets none.
80+
func TestFinalizeStatusGateAlreadyTerminalAllowed(t *testing.T) {
81+
env := pinnedEnv(t)
82+
root := stageFixtureWith(t, "seq-workflow", map[string]string{
83+
"already-done.md": "---\nid: \"053\"\ntitle: Already Done\nstatus: done\nscore: \"0.5\"\nsource: x\n---\n# Already Done\n",
84+
})
85+
86+
args := append([]string{"--workflow-dir", root}, "--set", "already-done", "completed", "verdict=passed", "worktree=")
87+
_, errOut, code := runNative(t, root, env, args...)
88+
89+
if code != 0 {
90+
t.Fatalf("finalize on an already-terminal entity must succeed (exit 0), got %d (stderr=%q)", code, errOut)
91+
}
92+
fm := readWhole(t, filepath.Join(root, "already-done.md"))
93+
if !strings.Contains(fm, "verdict: passed") {
94+
t.Fatalf("entity should have gained a verdict:\n%s", fm)
95+
}
96+
}
97+
98+
// TestFinalizeStatusGateForceBypasses: --force bypasses the gate, same idiom as
99+
// the mod-block / merge-hook / verdict guards.
100+
func TestFinalizeStatusGateForceBypasses(t *testing.T) {
101+
env := pinnedEnv(t)
102+
root := stageFixture(t, "seq-workflow")
103+
104+
args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "completed", "verdict=PASSED", "worktree=", "--force")
105+
_, errOut, code := runNative(t, root, env, args...)
106+
107+
if code != 0 {
108+
t.Fatalf("--force must bypass the finalize-status gate (exit 0), got %d (stderr=%q)", code, errOut)
109+
}
110+
fm := readWhole(t, filepath.Join(root, "002-vendor-script.md"))
111+
if !strings.Contains(fm, "status: ideation") || !strings.Contains(fm, "verdict: PASSED") {
112+
t.Fatalf("entity should have finalized under --force despite the non-terminal status:\n%s", fm)
113+
}
114+
}
115+
116+
// TestFinalizeStatusGateDispatchIntoTerminalPasses: a bare dispatch-into-terminal
117+
// (`status=done started`, no `completed`) is not a finalize at all — the gate
118+
// must not fire on it, matching the verdict gate's identical carve-out.
119+
func TestFinalizeStatusGateDispatchIntoTerminalPasses(t *testing.T) {
120+
env := pinnedEnv(t)
121+
root := stageFixture(t, "seq-workflow")
122+
123+
args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "status=done", "started")
124+
_, errOut, code := runNative(t, root, env, args...)
125+
126+
if code != 0 {
127+
t.Fatalf("dispatch-into-terminal (no `completed`) must PASS (exit 0), got %d (stderr=%q)", code, errOut)
128+
}
129+
}
130+
131+
// TestFinalizeStatusGateArchiveRefusesNonTerminalVerdict: the --archive mirror.
132+
// An entity that already carries a non-rejected verdict but sits at a
133+
// non-terminal status — the shape a stale binary (predating the --set gate
134+
// above) or a drifting FO can still produce by hand-editing — must be refused
135+
// at --archive too, so the false-verdict end-state cannot land in `_archive/`
136+
// via that route either.
137+
func TestFinalizeStatusGateArchiveRefusesNonTerminalVerdict(t *testing.T) {
138+
env := pinnedEnv(t)
139+
root := stageFixtureWith(t, "seq-workflow", map[string]string{
140+
"stale-finalized.md": "---\nid: \"054\"\ntitle: Stale Finalized\nstatus: implementation\nverdict: PASSED\nscore: \"0.5\"\nsource: x\n---\n# Stale Finalized\n",
141+
})
142+
143+
out, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--archive", "stale-finalized")
144+
if code != 1 {
145+
t.Fatalf("--archive of a non-terminal entity carrying a verdict must refuse (exit 1), got %d (stderr=%q)", code, errOut)
146+
}
147+
if out != "" {
148+
t.Fatalf("stdout must be empty on rejection, got %q", out)
149+
}
150+
if !strings.Contains(errOut, "cannot be archived") {
151+
t.Fatalf("stderr must name the archive refusal, got %q", errOut)
152+
}
153+
if !strings.Contains(errOut, "PASSED") || !strings.Contains(errOut, "implementation") {
154+
t.Fatalf("stderr must name the verdict and the non-terminal status, got %q", errOut)
155+
}
156+
157+
// --force bypasses.
158+
_, errOut, code = runNative(t, root, env, "--workflow-dir", root, "--archive", "stale-finalized", "--force")
159+
if code != 0 {
160+
t.Fatalf("--force must bypass the archive-side gate (exit 0), got %d (stderr=%q)", code, errOut)
161+
}
162+
}
163+
164+
// TestFinalizeStatusGateArchiveRejectedNonTerminalPasses: the sanctioned
165+
// reject-then-archive path — verdict: rejected — is exempt from the archive-side
166+
// gate regardless of status, matching the merge-hook guard's identical
167+
// exemption (a rejected entity never ran the merge ceremony).
168+
func TestFinalizeStatusGateArchiveRejectedNonTerminalPasses(t *testing.T) {
169+
env := pinnedEnv(t)
170+
root := stageFixtureWith(t, "seq-workflow", map[string]string{
171+
"rejected-nonterminal.md": "---\nid: \"055\"\ntitle: Rejected Non-Terminal\nstatus: implementation\nverdict: rejected\nscore: \"0.5\"\nsource: x\n---\n# Rejected Non-Terminal\n",
172+
})
173+
174+
_, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--archive", "rejected-nonterminal")
175+
if code != 0 {
176+
t.Fatalf("reject-then-archive must pass regardless of terminal status (exit 0), got %d (stderr=%q)", code, errOut)
177+
}
178+
}
179+
180+
// TestFinalizeStatusGateArchiveVerdictLessNonTerminalPasses: archiving a
181+
// non-terminal entity that carries NO verdict at all (an abandoned/cancelled
182+
// task, never finalized) is unaffected — the gate only fires on a non-empty,
183+
// non-rejected verdict.
184+
func TestFinalizeStatusGateArchiveVerdictLessNonTerminalPasses(t *testing.T) {
185+
env := pinnedEnv(t)
186+
root := stageFixture(t, "seq-workflow")
187+
188+
_, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--archive", "002-vendor-script")
189+
if code != 0 {
190+
t.Fatalf("verdict-less non-terminal archive must pass (exit 0), got %d (stderr=%q)", code, errOut)
191+
}
192+
}

internal/status/handlers.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,30 @@ func runSet(roots roots, set *setUpdate, args []string, whereFilters []whereFilt
201201
"Set verdict in the same --set call, or use --force.",
202202
slug))
203203
}
204+
205+
// Finalize-status gate: the finalize action (setting `completed`) requires the
206+
// resulting status to be a declared terminal stage — either already terminal or
207+
// set terminal in THIS SAME --set call. This closes the residual hole behind the
208+
// verdict gate above: `--set completed verdict=X worktree=` alone (no
209+
// status={terminal}) satisfies the verdict gate yet still finalizes an entity
210+
// that never advanced past a non-terminal stage — Spike C's exact reproduction
211+
// of the incomplete-finalize deviation the merge guard's atomic terminalize
212+
// (status+verdict+completed in one call) exists to prevent. No-op when the
213+
// workflow declares no stages block (membership cannot be checked). --force
214+
// bypasses, same idiom as the guards above.
215+
postUpdateStatus := strings.TrimSpace(currentFields["status"])
216+
for _, u := range set.updates {
217+
if u.field == "status" && u.hasValue {
218+
postUpdateStatus = u.value
219+
}
220+
}
221+
if !force && finalizing && len(stages) > 0 && !terminalNames[postUpdateStatus] {
222+
return errExit(stderr, fmt.Sprintf(
223+
"entity %s cannot be finalized ('completed') while status '%s' is not the terminal stage. "+
224+
"Set status=%s in the same --set (or run 'spacedock merge guard %s'), or use --force.",
225+
slug, postUpdateStatus, terminalStageName(roots.definitionDir), slug))
226+
}
227+
204228
if !force && policy != mergeLocal && isTerminalUpdate() && modBlock == "" && postUpdatePR == "" && postUpdateVerdict != "rejected" {
205229
mergeHooks := scanMods(roots.definitionDir)["merge"]
206230
if len(mergeHooks) > 0 {

internal/status/mutate.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,37 @@ func runArchive(definitionDir, entityDir, spellingDir, slug string, force, quiet
356356
}
357357
}
358358

359+
// Finalize-status gate mirror: archiving finalizes a non-`rejected`-verdict
360+
// entity, so a verdict already carried (e.g. PASSED) while status has never
361+
// reached a terminal stage is the archive-side half of the incomplete-finalize
362+
// hole (the --set mirror lives in runSet, handlers.go) — the shape a stale
363+
// binary predating that gate, or a hand-edited entity, can still produce. A
364+
// verdict-less non-terminal entity (an abandoned/cancelled task) is untouched —
365+
// this gate only fires on a non-empty, non-`rejected` verdict, matching the
366+
// `--set` gate's carve-out for `verdict: rejected`. Layered after the
367+
// merge-hook invariant so an entity that ALSO skipped the merge ceremony keeps
368+
// reporting that cause first (unchanged from today). --force bypasses.
369+
if verdict != "" && verdict != "rejected" && !force {
370+
readme := filepath.Join(definitionDir, "README.md")
371+
if fileExists(readme) {
372+
status := strings.TrimSpace(fields["status"])
373+
isTerminal := false
374+
for _, s := range parseStagesBlock(readme) {
375+
if s.terminal && s.Name == status {
376+
isTerminal = true
377+
break
378+
}
379+
}
380+
if !isTerminal {
381+
fmt.Fprintf(stderr,
382+
"Error: entity %s cannot be archived — verdict '%s' is set but status '%s' is not the terminal stage. "+
383+
"Set status to the terminal stage first (or run 'spacedock merge guard %s'), or use --force.\n",
384+
slug, verdict, status, slug)
385+
return 1
386+
}
387+
}
388+
}
389+
359390
archiveDir := filepath.Join(entityDir, "_archive")
360391
var destPath, destSpelling string
361392
if isFolder {

0 commit comments

Comments
 (0)