Bugfix spec: a bugfix-shaped work item cannot clear the gate its own process ships
Phase 1 of 3 for a bug (bugfix → design → tasks). Human approval for this tier-3 change happens at the PR (
autonomy.tiers."3": human-approves-pr).
Summary
the-loop documents two names for the phase-1 spec artifact — requirements.md, or bugfix.md for a bug — in the skill, the workflow reference, the manifest, two command docs and a bundled template. The process graph that gates phase 1 knows only one:
# cli/the_loop/graph/pdlc.yaml
- id: requirements-definition
produces: [requirements.md]validate-artifacts resolves produces literally. An agent that follows the documented process therefore authors bugfix.md and is then blocked by the same process for not having written requirements.md. Ticket: #124.
This spec is itself authored as a bugfix.md, deliberately: the-loop's own gate runs on every spec folder a PR touches, so this file passing CI is the regression test for the headline defect.
Steps to reproduce
Any bug spec in the tree that followed the documented shape reproduces it. On main:
$ uv run the-loop check issue-104 --recompute
issue-104: UNMET (at requirements-definition)
BLOCK requirements-definition
· required artifact is missing (docs/specs/issue-104/requirements.md)
···· 5 node(s) not reached yetdocs/specs/issue-104/ contains bugfix.md, design.md, tasks.md and execution-log.md — a complete, merged, shipped work item. It is blocked at phase 1 for the absence of a file the process told it not to write.
Expected vs actual
- Expected: a work item whose phase-1 artifact is named
bugfix.md— the name the skill, the reference, the manifest and the bundled template all bless — clears therequirements-definitionnode. - Actual:
BLOCK requirements-definition · required artifact is missing (docs/specs/<id>/requirements.md). There is no way to satisfy the gate while following the documentation.
Root cause (confirmed)
One naming disagreement, and three places it bites. Each was found by walking the gate's own code rather than by reading the prose, and the second and third are not in the ticket — they are what the missing parity test would have surfaced alongside the first.
RC1 — produces cannot express an alternative
Node.produces is a flat tuple of names, and both hooks that read it resolve every entry to exactly one path:
# cli/the_loop/graph/hooks/artifacts.py — and, identically, hooks/lint.py
def _artifact_paths(ctx: HookContext) -> List[Path]:
produces = ctx.node.get("produces") or []
...
return [ctx.work_item.spec_dir / str(p) for p in produces]There is no syntax for "this node produces one of these", so the graph could not have been written to accept both names even if #109 had known to try.
RC2 — the security-boundary check silently skips for every bug
The design node's second exit hook names its upstream by the same literal:
- {hook: enforces-boundaries-from, with: {upstream: requirements.md, markers: [...]}}For a bug, requirements.md does not exist, so enforces_boundaries_from takes its not up.is_file() branch and returns skip, not block:
if not up.is_file() or not downs:
return HookResult.skipped(name, "upstream or downstream artifact absent")A skip passes the chain. So for every bugfix.md work item, the check that every trust boundary raised in phase 1 is answered in design.md — a security.design.required gate — has never run, and reported success while not running. That is strictly worse than RC1: RC1 fails loudly, RC2 passes quietly.
RC3 — the bundled bugfix template cannot satisfy the sections the node requires
The requirements-definition node demands two headings:
exit:
- {hook: validate-artifacts, with: {locked: true, sections: ["Requirements", "Security considerations"]}}skills/the-loop/templates/requirements.md has ## Requirements. skills/the-loop/templates/bugfix.md does not — it has ## Acceptance criteria (EARS). So fixing only the filename leaves a bugfix.md authored from the shipped template blocked on required section is missing: Requirements. The headline claim of this ticket stays true until the template is fixed too.
This is also what #119's undocumented workaround actually consisted of: not just naming the file requirements.md, but also retitling its acceptance criteria to ## Requirements. Both halves were needed, and neither was written down.
Why nothing caught it
The graph landed in #109, after every existing bugfix.md (issues 36, 78, 80, 93, 104), so no work item exercised the combination until #119 — which hit it in CI on #120 and worked around it. Nothing asserts that the artifact names and section headings the shipped templates offer are the ones the shipped graph accepts. That parity gap is the real defect; the naming disagreement is only what it let through.
Requirements
Requirement 1 — produces can name alternatives
The graph gains a way to say "this node produces one of these names".
Acceptance criteria (EARS)
- WHEN a
producesentry contains alternatives separated by|THEN the graph SHALL treat every listed name as an accepted name for that one artifact. - WHEN the graph is compiled AND a
producesentry has an empty alternative (a||b,|a,a|) THEN compilation SHALL fail with aGraphConfigErrornaming the offending node and entry — a structural fault is a startup failure, per the graph's own thesis. - WHEN a
producesentry names a single artifact THEN behaviour SHALL be unchanged from today, byte for byte.
Requirement 2 — the phase-1 node accepts both documented names
Acceptance criteria (EARS)
- WHEN a work item's spec folder holds
bugfix.mdand norequirements.mdTHENvalidate-artifactsatrequirements-definitionSHALL validatebugfix.mdand, if it holds up, pass. - WHEN the folder holds
requirements.mdand nobugfix.mdTHEN the node SHALL behave exactly as it does today. - WHEN the folder holds neither THEN the node SHALL block with a message naming every accepted name, so the agent knows what it may write.
- WHEN the folder holds both THEN the node SHALL block as ambiguous. Two phase-1 artifacts in one folder have no defined source of truth, and a gate that silently picks one is how a stale spec gets approved. Fail closed.
Requirement 3 — the security-boundary check runs for bugs
Acceptance criteria (EARS)
- WHEN
enforces-boundaries-fromis given anupstreamnaming alternatives THEN it SHALL resolve it the same wayproducesis resolved. - WHEN a work item's phase-1 artifact is
bugfix.mdAND it names a trust boundary thatdesign.mddoes not answer THEN thedesignnode SHALL block — not skip.
Requirement 4 — a bugfix spec authored from the bundled template passes
Acceptance criteria (EARS)
- WHEN a bugfix spec is authored from
skills/the-loop/templates/bugfix.mdand filled in THEN every section therequirements-definitionnode requires SHALL be present in it. - The template SHALL keep the reproduction / expected-vs-actual / root-cause structure that makes it worth having as a separate template at all.
Requirement 5 — the parity gap is closed by a test
This is the acceptance criterion the ticket calls the actual defect.
Acceptance criteria (EARS)
- WHEN a bundled template declares a phase whose node's
producesdoes not accept that template's filename THEN the test suite SHALL fail. - WHEN a node's
producesaccepts a name that no manifest work-item artifact declares THEN the test suite SHALL fail. - WHEN a node's
validate-artifactsrequires a section that a template for that node's phase does not offer as a heading THEN the test suite SHALL fail. - The test SHALL fail on the tree as it stands before the fix — a parity test that was green on the broken tree would prove nothing.
Requirement 6 — regression coverage and documentation
Acceptance criteria (EARS)
- The fix SHALL include tests that fail before it and pass after, for RC1, RC2 and RC3.
bugfix.mdSHALL remain documented as a first-class phase-1 artifact name in the skill, the workflow reference, the manifest and the command docs — Option 1 keeps both shapes working, so no documentation retires the name.- The
producesalternation syntax SHALL be documented where the graph is documented, and the choice recorded as a decision.
Out of scope
- Retiring
bugfix.md(the ticket's Option 2). Declined by the maintainer: it would change documented plugin behaviour for consuming repos on upgrade. Recorded on the ticket. - Renaming the five existing bug specs (issues 36, 78, 80, 93, 104). Left as a historical record by the maintainer's call — they are closed work items and nothing re-runs their gates. They are not retro-fitted with a
## Requirementsheading either; RC3's fix is to the template, for specs authored from here on. - The review nodes' unenforced sections.
self-review,critic-review,security-review,evidence,capability-docsandreviewer-briefingeach declaresections:but noproduces:, sovalidate-artifactsreturnsskipped("this node declares no artifacts")and those section requirements are never checked againstexecution-log.md. Same family as RC2 — a gate reporting success without running — but a distinct defect with its own blast radius, and folding it in here would bury this fix. Filed as #125. - Generalising alternation to
sections:. RC3 is fixed by making the template match the one section vocabulary the graph already uses; a second alternation mechanism would be cost without a second use.
Security considerations
Threat-model-lite. The change is to a gate's resolution logic, so the security question is not "what new input arrives" but "what can now pass that should not".
- Untrusted actors / inputs. None new.
producesandupstreamcome frompdlc.yaml, which ships with the CLI; a repository cannot define or override the graph (a repo-supplied one is ignored with a warning, R1.4). The alternation syntax is therefore authored only by the-loop's own maintainers, and every value it can take is reviewed as code. No payload text, no network input, no user-supplied path reaches this code path. - Trust boundary: the filesystem. Alternatives are resolved by joining a name to
work_item.spec_dir, exactly as the single-name path is today. The set of names is fixed at graph-compile time, so alternation adds no way to reach a path an operator did not already grant. It cannot widen the resolution surface beyond the spec folder. - Abuse case: weakening a gate to get green. The one real risk. Accepting "any one of N names" is, by construction, easier to satisfy than "this exact name". Two decisions hold the line: ambiguity fails closed (Requirement 2.4 — both present is a block, not a lucky pick, so nobody clears a gate against a stale artifact while the live one sits beside it), and every alternative still runs the full validation —
locked,frontMatter,sections,checkmarks— unchanged. The name is what becomes flexible; the standard the artifact is held to does not move. - Fail-closed, and a gate that gets stricter. Net, this change closes an open hole rather than opening one: RC2 means the trust-boundary check has been silently skipping for every bug work item, and Requirement 3 makes it run and block. A bug spec that names a boundary its design ignores will now fail where it previously passed. That is the intended direction, and it is why "no new attack surface" is the accurate claim here rather than a formality — the attack surface is unchanged and one existing gate goes from advisory to enforced.
- Human sign-off. Risk tier 3, below
security.review.humanSignOffMinTier: 4, so no named security sign-off is required; the security review at the ready-to-ship gate still runs.
Open questions
None. The one open decision this ticket carried — Option 1 versus Option 2, and whether to rename the existing bug specs — was put to the maintainer and answered before the spec was written; the paper trail is on #124.