ADR-0032: Plan Consistency Gate — Verified Pattern References and Contradiction Detection
- Status: Accepted
- Date: 2026-08-10
- Supersedes: None
- Related: ADR-0019 (Plan-side Trust Layer, mechanical linters), ADR-0018 (produces the RESEARCH.md this ADR finally routes), ADR-0011 (the swarm behind it)
Context and Problem Statement
A shipped slice plan (S011) demanded two things at once:
"fully mirror the segment-share pattern" and "a duplicate share to the same grantee fails"
The mirrored reference, ShareSegmentAction, used firstOrCreate: idempotent by construction, and it never updated the permission flag. Re-sharing an already-shared resource with different permissions silently did nothing. The two requirements could not both hold. The defect surfaced during implementation — after planning, after the 2-iteration plan-checker loop, after plan-lint, and after a three-researcher swarm had run.
Every existing gate was passed legitimately:
The research artefact never reached the planner.
/np:research-phasewritesM<NNN>-RESEARCH.md, but thenp-plannerspawn inplan-phase.mdhad no<files_to_read>block at all — unlike thearchitect-phase,execute-phase,research-phaseandverify-workspawn sites it sits between, each of which names its inputs explicitly. Theinit plan-milestone initpayload did not even expose the path, and both agent docs cited a slice-levelS<NNN>-RESEARCH.mdthat nothing writes. A swarm of three researchers plus a reconciler plus two hard gates produced a document that the planner never opened and the plan-checker never audited.Other workflows (
discuss-phase,validate-phase,simplify-debt,add-todo,note) also spawn agents without a<files_to_read>block. Those are out of scope here — this ADR covers the planning pipeline, where the missing block discards the output of the most expensive phase in the system. Whether the remaining five need the same treatment is an open question, not a settled one.No layer asked whether the plan could be true at once. The 14 canonical plan-checker categories covered coverage, atomicity, dependencies, hygiene, and grounded assumptions. None covered "these two requirements contradict each other".
"Mirror pattern X" carried no checkable claim. The Reality-Check block (ADR-0019 / Dimension 12) verified that a cited path exists and explicitly stated the checker "does NOT need to confirm the line content". So
ShareSegmentAction.phpbeing real was confirmed;firstOrCreatebeing idempotent was never written down, and an unwritten behaviour cannot contradict anything.
The common shape: the planning phase was rich in artefacts and poor in transfer. Agents produced evidence for each other and then did not read it.
Decision Drivers
- An agent reads what its prompt names. Anything else is hope. A research phase whose output is not in a downstream
<files_to_read>block is pure cost. - A behavioural claim must be falsifiable to be checkable. "Mirror X" is unfalsifiable; "X uses
firstOrCreate, so a second call is a no-op" is falsifiable and collides audibly with "the second call must fail". - Contradictions fail silently, which makes them expensive. The executor picks a side, the verifier measures the side that was picked, and the losing requirement disappears with no artefact recording that it was dropped. Compare a
criticalfinding at plan time: one revision iteration. - Choosing between two locked decisions is the user's call. Locked means locked (
np-planner<context_fidelity>); an agent silently resolving a collision between twoD-XXdecisions overrides the user without telling them. - Mechanical where possible, LLM where necessary (ADR-0019). "Is there a mirror-instruction without a pattern reference?" is a regex. "Do these two acceptance criteria contradict each other?" is judgment.
Considered Options
- A: Status quo — rely on plan-checker's existing dimensions. Rejected: all of them passed on the S011 plan.
- B: Wire the evidence through, nothing else. Rejected as insufficient: RESEARCH.md would be read, but the S011 contradiction was against repo code, not against research. Evidence reaching an agent that is not asked to cross-check it changes nothing.
- C: Consistency checks only, no plumbing. Rejected: Dimension 13.4 (plan vs. verified research / ADR decisions) is unimplementable when the checker never receives those files.
- D: Ask the user on every detected contradiction. Rejected: most contradictions have one derived side, and the planner resolving those is strictly better than interrupting. Escalation must be reserved for genuinely user-owned collisions or it becomes noise and gets clicked through.
- E: Plumbing +
<pattern_refs>+ mechanical lint + plan-checker Dimension 13, with hybrid escalation. Chosen.
Decision Outcome
Chosen: the evidence reaches both planning agents; every mirror-instruction carries a verified behaviour claim; a contradiction is a critical finding; a collision between two locked decisions stops at a user gate.
1. Evidence plumbing
lib/layout.cjsgainsmilestoneResearchPath()/milestoneArchitecturePath()— the two paths that were previously built inline inresearch-phase.cjsandresearcher-reconciler.cjs.init plan-milestone initexposesmilestone_research_path,has_research,milestone_architecture_path,has_architecture.plan-phase.mdassembles$FILES_TO_READonce and passes it to thenp-plannerand thenp-plan-checkerspawn, native and off-host alike. A path enters the block only when its artefact exists; whenhas_researchistruethe path MUST be in the block.tests/plan-phase-inputs.test.cjsis the regression guard: it asserts the block exists, that both spawn contracts name it, that both off-host branches render it, and that no agent doc references a slice-level RESEARCH.md again.
2. <pattern_refs> — an observed-behaviour claim per mirrored implementation
<reality_check> gains a conditional sub-block:
<pattern_refs>
<pattern_ref symbol="ShareSegmentAction::handle"
at="app/Actions/ShareSegmentAction.php:34"
behavior="firstOrCreate — idempotent; a second share is a no-op and never updates can_edit"/>
<deviation ref="ShareSegmentAction::handle"
from="firstOrCreate (idempotent)"
to="updateOrCreate"
reason="AC-3 requires that re-sharing updates can_edit"/>
</pattern_refs>behavior= states what the code does, not what the plan wants. at="path:line" must also appear in <files_read>. A <deviation> is mandatory whenever an acceptance criterion needs behaviour the reference lacks — that is what makes "mirror X" and a differing criterion compatible instead of contradictory. The block is conditional: a slice that mirrors nothing does not need it.
3. Mechanical linter — pattern-claim-unverified (critical)
lib/plan-lint.cjs::lintPatternClaims, wired into lintPlan, fires on:
A sentence containing a mirror-phrase and a concrete code token (backticked token,
Foo::bar/->method(), PascalCase-with-internal-cap, or a path with an extension), where no<pattern_ref>names that token. The trigger is deliberately narrow — a phrase alone never fires, so ordinary prose ("wie in Phase 3 beschrieben") stays silent. Phrase list is EN and DE, becauseresponse_languagegoverns plan prose.Granularity is per sentence, not per paragraph, and that is load-bearing: a
<task>block contains no blank lines, so paragraph-level scanning made it one unit — a single documented pattern then hid every further mirror claim in the same task. Sentence boundaries are.!?followed by whitespace or<, a closing XML tag, or a blank line. Coverage matching compares distinctive identities (full path, basename without extension, symbol and its head/tail), never substrings: a sharedapp/segment or.phpextension is not evidence about a different class.A
<pattern_ref>missingat=, missing the line number inat=, or missingbehavior=.A
<deviation>missingref=,from=,to=orreason=.
Scope: milestone and slice plans only. Scaffolded T<NNNN>-PLAN.md files carry the <action> prose but never the <pattern_refs> block, so linting them would be a guaranteed false positive; _planKind() in bin/np-tools/plan-lint.cjs gates it. The executor already receives the slice plan, so nothing is lost.
4. Plan-checker Dimension 13 + contradictory-requirements (critical)
Four steps per slice: pairwise consistency of the acceptance-criteria set; reading the code at every <pattern_ref> at= and verifying the behavior= claim; holding each criterion against the verified behaviour (missing <deviation> → finding); and checking the plan against [VERIFIED] research claims, M<NNN>-ARCHITECTURE.md ADRs and locked D-XX.
Step 2 is a deliberate, documented exception to Dimension 12.3 ("you do not verify line content"). The narrower claim gets the stricter rule; skipping it reproduces the defect this ADR exists for.
5. Hybrid escalation
- One side derived (an inferred pattern, a convention, the planner's reading of the goal) → the planner resolves it, records a
<deviation>, names it in the task<action>. No interruption. - Both sides locked
D-XX→ the planner returns## PLAN CONTRADICTIONwith both verbatim quotes, the mechanism, 2–3 resolutions and a recommendation.plan-phasesurfaces anaskusergate, feeds the choice back as<locked_resolution>, and re-plans. The gate refuses to fall through on an unrecognised answer.
6. Merge fix (ADR-0019 correction)
The Trust-Layer merge in plan-phase.md wrote lint findings into the verdict only when critical > 0, so every major / minor mechanical finding was silently discarded and never reached the planner in iteration 2 — the severity levels below critical were decorative. Findings are now merged unconditionally; status = "issues_found" is still forced only by a critical finding.
Consequences
Positive
- The S011 shape is caught twice: mechanically (no
<pattern_ref>) and semantically (criterion vs. verified behaviour). - The researcher swarm's output is finally consumed by the two agents that were documented as its consumers.
- Behavioural claims about existing code become auditable artefacts, useful to the executor and the verifier as well.
major/minormechanical findings become effective rather than decorative.
Negative / accepted costs
- Mirror-instructions cost the planner one extra
Readplus one attribute line. That is the point. - The mirror-phrase list is heuristic: an unusual formulation can slip through (false negative). Preferred over false positives, which would block planning on ordinary prose.
- Token extraction reads at most the first 4000 characters of a sentence, and the token regexes carry bounded quantifiers. Without both, a pathological body (100 KB with no sentence boundary) backtracked for ~6 s; with them the same input costs ~1 ms. A mirror claim buried past 4000 characters of one unbroken sentence is therefore not seen — an accepted bound, since no real instruction has that shape.
- A contradiction between two locked decisions now interrupts the run. Intended: it is the user's decision, and the alternative is discovering it after implementation.
- Dimension 13.2 makes the plan-checker read referenced source files, costing context. Bounded by the number of
<pattern_ref>entries, which is bounded by the number of mirrored implementations.
Verification
lib/plan-lint.test.cjs—PL-PC-1..9: the S011 body fires; the fixed body is clean; German phrasing fires; prose without a code token does not; attribute-completeness cases; a mirror-phrase inside<pattern_refs>does not trigger itself.bin/np-tools/plan-lint.test.cjs—PLCLI-14: slice plan flagged, scaffolded task plan exempt, exit code 2.lib/plan-checker-contract.test.cjs— canonical categories present; Dimension 13 heading present.bin/np-tools/plan-milestone.test.cjs—PM-2b: the four new payload fields.tests/plan-phase-inputs.test.cjs—PPI-1..5: evidence block, both spawn contracts, off-host branches, agent-doc paths, contradiction gate.
