Skip to content

gate-guard prints a refusal for update-epic --status archived but applies the change anyway #80

Description

@cfdude

What happened

Archiving an openspec-lane epic with no Gate 2 verdict printed a clear refusal — and then archived it anyway.

$ node conductor.mjs update-epic taxonomy-model-v2 --status archived
conductor: cannot archive openspec-lane epic 'taxonomy-model-v2' — missing a passing Gate 2
(implementation review) verdict. Run 'record-gate-review taxonomy-model-v2 --gate 2 --verdict pass'
after a real fresh-context implementation review before archiving.

State after that command:

$ jq -r '.epics[] | select(.id=="taxonomy-model-v2") | {status, gateReview}' .conductor/state.json
{ "status": "archived", "gateReview": null }

The diff confirms the write landed on the same invocation:

$ git diff .conductor/state.json | rg '^[+-].*"status"'
-      "status": "paused",
+      "status": "archived",

So the guard detects the violation, explains it correctly, tells you the exact remediation command — and does not enforce it. gateReview is still null, so nothing downstream can tell this epic was archived without a review.

Why it matters

This is worse than having no guard. A refusal message that isn't a refusal trains you to trust the output rather than verify the state. I only caught it because I re-read state.json afterward; the message reads exactly like a hard stop, so the natural reading is that nothing changed.

The specific damage: an openspec epic can reach archived with gateReview: null, which is indistinguishable in PROJECT.md from one that passed a real implementation review. The two-gate discipline the guard exists to enforce becomes advisory without any signal that it was skipped.

Repro

# any openspec-lane epic with no recorded Gate 2
node conductor.mjs update-epic <openspec-epic-id> --status archived
# observe: refusal printed, exit 0, status changed to archived, gateReview still null
jq -r '.epics[] | select(.id=="<id>") | {status, gateReview}' .conductor/state.json

Note gate-guard was never explicitly configured in this repo (jq '.gateGuard' → absent), so this appears to be default-on behavior.

Suggested fix

  1. Make it enforce: on refusal, do not write, and exit non-zero so scripted callers fail loudly.
  2. Or make it honest: if the guard is advisory by design, word it as a warning ("archiving without a Gate 2 verdict") and record the skip durably — e.g. gateReview: {gate2: {verdict: "skipped", at: ...}} — so a future reader can tell. Silent null is the one outcome that should not be possible.
  3. Either way, an --allow-missing-gate style opt-in would make the exception explicit and auditable instead of accidental.

Engine 0.25.0. Related: #79 (add-epic --notes accepted and discarded) — same shape of defect, a flag/guard that reports success while not doing the thing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions