Mission Specification: Bare-Prose Requirements Are Silently Uncounted by the Coverage Gate
Mission Branch: bare-prose-requirements-uncounted-01KZYV3C Created: 2026-08-14 Status: Draft Input: GitHub issue #3396, filed as a deliberate follow-up to #3394 / PR #3395 (op/3394-requirement-citation-scope). This mission branches from PR #3395's branch rather than main — see Clarifications below for the binding operator decision and its accepted consequences.
Context (informative)
PR #3395 fixed #3394: parse_requirement_ids_from_spec_md (src/specify_cli/requirement_mapping.py) now scopes requirement-ID extraction to four declared shapes — table row, id-naming heading, bulleted/numbered list item, bold-led paragraph — so a requirement ID merely cited in prose (e.g. "...see FR-021's default-pack materialization") is no longer mistaken for a requirement the citing spec itself defines.
That fix leaves a gap #3395 deliberately did not close, filed separately as #3396: a spec that declares some requirements correctly and writes others as bare, unbulleted, unbolded prose sentences —
### Functional Requirements
FR-001 the loader must reject an unknown pack.
FR-002 the error must name the offending path.
| ID | Requirement |
|----|-------------|
| NFR-001 | Resolution completes within 200ms |
— produces FR-001/FR-002 that match none of the four declared shapes. They are therefore excluded from _declared_ids()'s output entirely: not merely "unmapped," but never added to functional_requirement_ids in the first place. finalize-tasks and map-requirements can report full FR coverage while these two requirements were never counted, mapped, or checked against any work package. spec-kitty next's readiness path (_check_requirement_mapping_ready → runtime_bridge_cores._evaluate_requirement_mapping) inherits the same blindness, because it consumes the same declared-id extraction.
The obvious "fix" — treat any raw FR-NNN token that is not in the declared set as unmapped, doc-wide — is precisely the bug #3394 reported and #3395 fixed; it would hard-fail every spec that legitimately cites a foreign requirement ID. find_undeclared_requirement_citations (added in #3395, currently advisory-only) fires only when a scope's declared-id set is empty (if section_raw_tokens and not _declared_ids(section)) — its own docstring targets a spec whose requirements are written in none of the four declared shapes. #3396's case is a section that declares some requirements correctly and writes others as bare prose, so _declared_ids(section) is non-empty and the function never fires — verified directly against the issue's own repro, which returns []. Promoting that function to blocking, or reusing its zero-declared trigger under a new name, would ship a gate that never blocks on the case this mission exists to catch; this mission's fix is therefore a new, per-token predicate (FR-001), not a promotion. A narrow blocking attempt on an adjacent seam (_zero_declared_requirement_block, commit 3823f2b00 on this checkout — see Clarifications for the hash discrepancy versus the issue text's ae7eba9b2) was already tried and reverted for being provably inert at the CLI level, for an unrelated wiring reason (Story 3). This mission's central engineering risk is repeating a dead-path shape under a different name — this time by picking a detector that is structurally blind to the mixed case, not merely disconnected from the guard.
This mission is explicitly framed, per operator direction, as another instance of this repository's silent-success failure class (tracked incidents #3133, #3212, #3282, #3336) — a code path that counts 0 uncovered requirements and calls it clean, when the true count is nonzero and simply invisible to the counter.
Clarifications
Session 2026-08-14
A (operator, 2026-08-14): "Branch from #3395's branch now." Accepted consequence, stated at decision time: the mission's diff carries #3395's ~863-line unreviewed parser rewrite; #3395 has no reviewDecision and could still change shape; this repo has no protected staging branch, so if #3395 changes, this mission eats the rebase.
- Q: #3396's defect only exists once PR #3395 lands, and #3395 is still open with no review decision. How should this mission sequence against it?
A: 3823f2b00 is the correct hash for this checkout — verified directly: git cat-file -t 3823f2b00 resolves (commit) and git merge-base --is-ancestor 3823f2b00 HEAD confirms it is an ancestor of this branch's tip. ae7eba9b2, by contrast, does not resolve to any object in this checkout's history (git cat-file -t ae7eba9b2 fails with "Not a valid object name"). The most plausible explanation is that the commit the issue text originally referenced was rewritten (amended, rebased, or squashed) between when the GitHub issue was authored and when this checkout's branch (op/3394-requirement-citation-scope) was created — the same class of risk already disclosed in C-005 (rebase-on-#3395-change). This spec's Context section and all other references in this document use 3823f2b00 as the verified, checkout-accurate hash; treat ae7eba9b2 as a stale reference from the issue text, not a second real commit.
- Q: The Context section cites commit
3823f2b00for the reverted_zero_declared_requirement_block, but the GitHub issue #3396 text itself citesae7eba9b2for the same commit. Why the discrepancy, and which is correct for this checkout?
Session 2026-08-14 (post-implementation, pre-merge squad)
A (pre-merge squad finding PR-CONTRACT-002, confirmed and applied): No — map-requirements is a mapping-registration command, not a blocking gate. Verified directly: map-requirements --json returns "result": "success" and exit code 0 even when bare_prose_requirement_ids is non-empty (pinned by tests/specify_cli/cli/commands/agent/test_tasks_mapping_core.py:431-462); it surfaces the ids in its JSON payload so a caller can see them, but never fails the command on their presence. Only finalize-tasks and spec-kitty next's tasks-boundary decision actually block. This is correct design for a mapping-registration command, not a defect — the defect was SC-001 overclaiming blocking behavior for a surface that only surfaces. SC-001 is corrected above to state each surface's actual behavior. This mirrors, and must be held to the same disclosure standard as, this spec's other deliberate narrowing already disclosed above (the Constraints-heading blind spot, C-008) — a scope distinction stated explicitly rather than left to be discovered by a reader comparing the spec against the code.
- Q: SC-001 says all three surfaces —
finalize-tasks,map-requirements, andspec-kitty next— block on the issue's repro shape. Is that what the shipped code does?
User Scenarios & Testing (mandatory)
> "User" here is the maintainer / agent-runtime driving spec-kitty agent tasks finalize-tasks, map-requirements, and spec-kitty next's tasks-boundary readiness check; value is that a spec's real, uncovered functional requirements cannot silently ride an "all mapped" report or an advisory-only log line into an advanced mission.
User Story 1 - A spec mixing declared and bare-prose requirements is blocked, not merely logged (Priority: P1)
A spec.md that declares some requirements in a recognized shape (table row / heading / bullet / bold paragraph) and writes others as bare, unbulleted, unbolded prose sentences inside a heading section whose title names "Requirements" surfaces those bare-prose requirements through a blocking path — not only the existing non-blocking requirement_extraction_warnings advisory.
Why this priority: This is the mission's whole reason to exist. Today the exact repro in the issue body (FR-001 the loader must reject... / FR-002 the error must name... alongside a correctly declared NFR-001 table row) produces zero declared FR ids for FR-001/FR-002, so nothing about the mapping decision, coverage report, or spec-kitty next gate can ever notice them — only an advisory log line does, and advisories are opt-in reading.
Independent Test: A fixture spec.md matching the issue's exact repro shape (mixed declared NFR + bare-prose FR-001/FR-002 inside a "Functional Requirements"-titled section) drives finalize-tasks, map-requirements, and spec-kitty next's tasks-boundary readiness check; all three surface a blocking failure naming FR-001 and FR-002, not only a warning.
Acceptance Scenarios:
1. Given the issue's exact repro spec.md (declared NFR-001 table row + bare-prose FR-001/FR-002 under a "Functional Requirements" heading), When spec-kitty agent tasks finalize-tasks runs, Then it fails (non-zero exit / blocking JSON result), naming FR-001 and FR-002 as uncounted — not merely appending them to requirement_extraction_warnings. 2. Given the same fixture, When spec-kitty agent tasks map-requirements computes coverage, Then it reports FR-001/FR-002 as requiring resolution before coverage can be considered complete, not silent 100% coverage over the (smaller) declared set. 3. Given the same fixture and a mission at the tasks_packages/tasks_finalize step boundary, When spec-kitty next evaluates advance-vs-stay, Then the returned Decision is blocked (or equivalent non-advancing kind) with a failure message naming FR-001 and FR-002, reachable through the mechanism verified in Story 3 (independent of tasks_wp_files presence/ordering).
User Story 2 - #3394's repro shape stays green (negative-space regression) (Priority: P1)
A spec.md that declares its own requirements correctly and merely cites a foreign, already-shipped requirement ID in prose elsewhere in the document (#3394's original repro) continues to pass every gate named in Story 1, exactly as #3395 already fixed it.
Why this priority: This is the negative-space pin without which Story 1 cannot ship — any implementation that widens detection without honoring the declared scoping decision from #3394/#3395 reintroduces the exact bug that mission fixed. The two stories are inseparable: a change that only satisfies Story 1 and breaks Story 2 is not an acceptable outcome of this mission.
Independent Test: The existing #3394/#3395 negative-space test fixtures and cases in tests/specify_cli/test_requirement_mapping.py, tests/next/test_runtime_bridge_unit.py, and tests/runtime/test_bridge_cores.py remain green, unmodified in their pinned assertions, after this mission's implementation lands. Exception, recorded post-implementation (operator ruling 2026-08-14, #3396 supersedes #3395's advisory-only decision for the target repro shape, per DIRECTIVE_041): exactly two pre-existing tests in tests/next/test_runtime_bridge_unit.py — test_requirement_mapping_3394_repro_shape_does_not_block and test_requirement_mapping_f4_repro_shape_stays_non_blocking_but_logs_advisory — pinned the pre-#3396 non-blocking behavior for the exact mixed-declaration shape Story 1 exists to newly block (one of the two, the F4 repro, IS this mission's own target repro). Both were re-pinned and renamed (commit 1b5b86e0f) to test_requirement_mapping_foreign_citation_shape_now_blocks_per_3396 and test_requirement_mapping_mixed_declared_and_bare_prose_now_blocks_per_3396 to assert the behavior #3396 deliberately supersedes #3395 with. Neither test in tests/specify_cli/test_requirement_mapping.py or tests/runtime/test_bridge_cores.py was touched — this exception is scoped to exactly these two tests in exactly this one file. (A third, unrelated re-pin — test_bare_sentence_frs_surface_a_non_blocking_warning_on_success → test_bare_sentence_frs_now_block_finalize_tasks_per_3396 in tests/specify_cli/cli/commands/agent/test_feature_finalize_bootstrap.py, commit b66884766 — is NOT in this Story's named three-file list and needs no exception recorded here.)
Acceptance Scenarios:
1. Given a spec.md whose own requirements are all declared in a recognized shape, and whose prose cites a foreign FR id (e.g. "...easy to miss, see FR-021's default-pack materialization") outside any Requirements-named section or not matching a declared shape inside one, When any of the three surfaces from Story 1 run, Then none of them block on the cited foreign id. 2. Given the full pre-existing #3394/#3395 test suite (declared-shape extraction, negative-space citation cases, the reverted-block regression tests), When this mission's implementation lands, Then every pre-existing assertion in those files still passes unmodified — this mission may add tests but must not weaken or delete an existing #3394/#3395 assertion to make Story 1 pass. 3. Given a table row whose description column cites an external convention or foreign id (the exact shape that drove the rejected doc-wide prototype's ~6% false-positive rate in #3395's own measurement), When the new blocking detector runs, Then it does not block — the row's ID cell is still recognized as declared and the description-column citation is not treated as a second, undeclared requirement.
User Story 3 - The blocking signal actually reaches spec-kitty next's advance-vs-stay decision (Priority: P1)
The new blocking signal changes what spec-kitty next actually decides at the tasks boundary — for a spec containing bare-prose requirements, in both of the following configurations: (a) zero work-package files exist yet, and (b) work-package files exist and none of them (correctly, since the ids aren't declared) reference the bare-prose requirement ids.
Why this priority: This is the constraint the operator named as non-negotiable and the exact shape of the prior failure. _zero_declared_requirement_block (commit 3823f2b00 on this checkout) was reverted because all three guards that consume requirement_mapping_failures — _evaluate_tasks_packages_guard, _evaluate_composed_tasks_packages_guard, _evaluate_composed_tasks_terminal_guard (src/runtime/next/runtime_bridge_cores.py) — check _tasks_dir_ready (which requires tasks_wp_files to be present) before ever reading requirement_mapping_failures. With zero WP files, the guard short-circuits to MISSING_TASK_FILES_MESSAGE and never sees the new signal at all; with ≥1 WP file, the pre-existing missing/unknown-ref checks already caught the block's own precondition whenever it held — so the block changed nothing reachable. Shipping a signal with that same shape again, under a new name, would repeat a change already proven to be dead code.
This mission's fix must therefore do one of two things, and must say explicitly which: (a) make the new signal's evaluation independent of, or ordered before, the tasks_wp_files-first check in whichever guard(s) it needs to reach spec-kitty next's decision, or (b) gate a different step than the one those three guards protect (e.g. block earlier, at tasks_finalize itself, or at the point the CLI computes coverage before any WP exists) — and explain why that step change is sufficient to satisfy Story 1's acceptance scenarios.
A fourth call site the issue text did not name, discovered during this spec's investigation: _evaluate_tasks_finalize_guard (runtime_bridge_cores.py, the CLI-native vocabulary's handler for step_id == "tasks_finalize") does not read requirement_mapping_failures at all today — only the composed "tasks" vocabulary's terminal guard (_evaluate_composed_tasks_terminal_guard) does, via legacy_step_id. If production spec-kitty next dispatches the CLI-native tasks_finalize step_id at the point missions actually reach this boundary, a fix wired only into the three guards the issue named would be silently dead for a fourth reason, independent of the tasks_wp_files-ordering trap. The plan phase MUST audit which step_id vocabulary is live for the CLI's actual tasks_finalize dispatch before choosing where to wire the new signal, and must wire it into whichever vocabulary (or both) spec-kitty next actually exercises in production.
Independent Test: A regression test (or set of tests) exercises spec-kitty next's tasks-boundary decision directly (not just the pure _evaluate_requirement_mapping/evaluate_guards core in isolation) against both configurations above — zero WP files, and ≥1 WP file none of which reference the bare-prose ids — and asserts the returned Decision.kind does not advance past the tasks boundary in either case, with the failure detail naming the specific uncounted FR ids.
Acceptance Scenarios:
1. Given a spec.md with bare-prose FR-001/FR-002 and zero WP files materialized yet, When spec-kitty next is asked what happens next at the tasks-packages boundary, Then the decision does not advance, and the failure detail is traceable to the bare-prose requirements (not only the generic "materialize WP packages first" message that would fire regardless). 2. Given the same spec.md with ≥1 WP file present, none of which declare requirement_refs for FR-001/FR-002 (because those ids were never offered by map-requirements, since they are undeclared), When spec-kitty next evaluates the tasks-finalize/terminal boundary, Then the decision does not advance, and the failure detail names FR-001/FR-002 specifically — proving this is not merely the pre-existing missing/unknown-ref check incidentally catching the same case. 3. Given the plan phase's audit of which step_id vocabulary (tasks_finalize CLI-native vs. composed "tasks" with legacy_step_id) is live for production spec-kitty next dispatch at this boundary, When the implementation wires the new signal, Then plan.md documents the finding and the wiring covers the live vocabulary — with a test proving the guard actually invoked in production reads the new signal, not only a guard reachable solely via test-only construction. 4. Given the set of guard functions FR-003's step_id-vocabulary audit identifies as actually adding or modifying a read of the new blocking signal (potentially more than one — e.g. _evaluate_tasks_packages_guard, _evaluate_tasks_finalize_guard, _evaluate_composed_tasks_packages_guard, _evaluate_composed_tasks_terminal_guard are all plausible call sites per Story 3's own investigation), When the implementation ships, Then it includes one synthetic reversion ("teeth") test PER such guard function — not a single existence-proof test anywhere in the suite — and each teeth test, run individually, fails when that specific guard's wiring to the new signal is removed, proving that guard's reachability is actually exercised rather than merely present in source. A single passing teeth test for one call site does not satisfy this scenario for the others.
User Story 4 - The false-positive rate is measured, recorded, and protected by a frozen, shrink-only CI fixture (Priority: P1)
Correction (post-implementation, recorded 2026-08-14 — see find_bare_prose_requirement_ids's "Reconciliation" docstring in src/specify_cli/requirement_mapping.py for the full account): the figures below (9/368 = 2.45% document-scoped, 139/368 = 37.77% section-scoped) were measured during design against an earlier, broader prototype that lacked the shipped function's per-line declared-shape skip rule. Re-verified independently twice against the shipped function itself, the actual rate is 1/368 = 0.27% (sole hit: egress-refusal-consolidation-3110-01KYW895/spec.md, a foreign-id citation, zero true positives) — roughly 9x lower than the design-time figure. The 9/368 and 139/368 numbers are retained below, unedited, as the historical basis for the document-vs-section-scoped design choice (C-006, itself unaffected — the 15x ratio between the two prototype measurements held the same design lesson regardless of which prototype was scanned); they must not be read as this function's own measured false-positive rate. Every Acceptance Scenario, Functional/Success Criterion, and Key Entity below that states "9/368" is a record of that earlier prototype's measurement, not a live claim about the shipped detector.
The false-positive measurement is not deferred to implementation time — it has already been taken, twice, independently, with identical results, against the document-scoped predicate (C-006): *9 of 368 kitty-specs//spec.md specs (2.45%) newly flag, and all 9 are foreign-id citations — ids belonging to another ID space or another mission, not requirements the citing spec itself declares. The flagged occurrences appear in both table-row description cells (the shape Story 2 AC3 already pins as non-blocking) and, for at least one flagged spec, running prose under a requirement-named heading that is not a table — verified directly in kitty-specs/egress-refusal-consolidation-3110-01KYW895/spec.md, whose ### Requirement-level falsifiers heading matches _is_requirement_heading's substring test while containing no requirements table, and whose flagging C-1/C-3 occurrences there are running-prose citations of orchestrator corrections, not table cells — an instance of the over-match risk the Edge Cases section already names. Zero true positives.** That figure, plus the rejected section-scoped alternative rate (C-006), is recorded in-repo. Going forward, CI is protected against a detector regression by a frozen, shrink-only corpus fixture (FR-005) — not by an invented acceptance ceiling or a live re-run against the corpus's future contents.
Why this priority: This is the reason the all-undeclared hard-fail prototype was rejected in #3395 (~6% FP rate, same description-column class). The detector's predicate scope changes the measured rate by 15x depending on one design decision: whether "declared" means declared in this section (139/368 = 37.77%) or declared anywhere in the document (9/368 = 2.45%). The document-scoped reading is correct — a token declared elsewhere in the document is already counted, so citing it again is not a lost requirement — and C-006 makes that choice a binding design constraint so an implementer cannot reasonably build the 37.77% version. Because the real rate is now a known, recorded fact (2.45%) rather than a projected risk, an invented acceptance ceiling and a three-bin (true-positive/false-positive/ambiguous-block) classification scheme are unneeded apparatus: they existed to gate an unmeasured risk, and the measured rate is already above the invented 2% ceiling they would have enforced — keeping them would make the spec actively wrong, not merely redundant. This mission's own detection mechanism (_is_requirement_heading) also has a known blind spot for the corpus's dominant "Constraints" heading (see Edge Cases); a false-negative sample (below) is retained specifically because it catches a defect class the false-positive measurement cannot.
Independent Test: The shipped function's measured rate (1/368, 0.27%, document-scoped, zero true positives) is recorded in the new detector's module docstring, re-verified independently twice against the corpus, alongside the earlier, broader design-time prototype's figures (9/368 = 2.45% document-scoped, 139/368 = 37.77% section-scoped — see the Correction note above), explicitly labeled as the prototype's figures and not this function's own rate — following the precedent already set by _DECLARED_ID_PATTERNS's own docstring in src/specify_cli/requirement_mapping.py (~lines 43-53), which records #3395's 6% figure the same way. CI is protected against a future regression by a frozen, shrink-only fixture (FR-005): the per-spec detector signatures for the 368-spec snapshot are committed under tests/, and a test asserts the live detector's output against that snapshot on every run — the flagged set may only shrink or stay equal; any growth fails CI until the fixture is deliberately re-snapshotted with a recorded reason. This is charter Standing Order 5's frozen-baseline-shrink-only-ratchet: a concrete floor, self-mutation-testable, and NOT a live re-run over the growing kitty-specs/ tree — a live re-run would let an unrelated future mission's spec.md determine this gate's colour, and would ship already-red at 2.45% (both horns of squad finding SPEC-FRESH2-002). Separately, a false-negative sample checks specs containing genuine bare-prose FR-/NFR-/C-XXX-shaped requirements the detector misses, specifically including the C-XXX-under-a-"Constraints"-heading case (see Edge Cases), and records the finding alongside the false-positive rate — informational, not gating (it measures whether the detector under-fires, a different property than the false-positive rate).
Acceptance Scenarios:
1. Given the full kitty-specs/*/spec.md corpus, When the new blocking detector runs against every file with the document-scoped declared-id predicate (C-006), Then the recorded false-positive rate is 1/368 = 0.27% (the shipped function's own measured rate; re-verified independently twice — see the Correction note above), and the sole flagged spec is a foreign-id citation — an id belonging to another ID space or another mission, not a requirement the citing spec declares — zero true positives — recorded in the detector's module docstring by explicit numeric value, corpus size, and measurement date. The earlier, broader design-time prototype's rate (9/368 = 2.45%, measured without the shipped function's per-line declared-shape skip rule) is recorded alongside it, explicitly labeled as the prototype's figure, not this function's. 2. Given the same corpus, When the alternative, rejected section-scoped predicate is also measured, Then its rate (139/368 = 37.77%) is recorded alongside the document-scoped rate, in the same location, so a future maintainer sees both numbers and the reason document-scope was chosen (C-006) rather than only the smaller figure. 3. Given the frozen corpus fixture committed under tests/ at implementation time, When the live detector's output for any snapshotted spec changes to newly flag a spec absent from the fixture, Then CI fails until the fixture is deliberately re-snapshotted with a recorded reason — proving the gate is non-vacuous (a broadened detector trips it) without depending on kitty-specs/'s future, growing contents. 4. Given the false-negative sample, When it runs against specs containing bare-prose C-XXX items under a "Constraints" heading (a heading invisible to _is_requirement_heading's current scoping — see Edge Cases), Then it records how many sampled specs contain such an undetected bare-prose C-XXX/NFR-XXX item, alongside the false-positive rate — informational, not a shipping gate.
User Story 5 - The detector never silently reports clean when it cannot classify (Priority: P1)
When the new detection path encounters a case it genuinely cannot classify with confidence — an ambiguous shape, a parse edge case, a malformed heading, an exception during section-scoping — it raises, reports, or refuses; it never silently returns 0 uncounted requirements, None, or an unknown-and-therefore-ignored classification that a caller would read as "coverage is clean."
Why this priority: This is the operator's explicit framing: #3396 IS an instance of this repository's silent-success failure class (#3133, #3212, #3282, #3336), not an ordinary feature gap. The fix for a silent-success defect that itself silently swallows its own edge cases is not a fix — it is the same defect class recurring one layer deeper. This governs Stories 1-4's implementation, not a separable feature.
Independent Test: A fault-injection test forces the new detector's section-scoping or shape-classification logic into a state it cannot cleanly resolve (e.g. a monkeypatched exception mid-computation, or a constructed input at the edge of the heading-section boundary logic) and asserts the caller-visible result is a surfaced failure/report — not a quietly empty result indistinguishable from "nothing to flag."
Acceptance Scenarios:
1. Given the new blocking-detector computation raises an unexpected exception, When finalize-tasks/map-requirements/spec-kitty next catch it, Then the surfaced result is an explicit failure naming what could not be classified and why — never a swallowed exception that falls through to "0 uncounted, proceed." 2. Given an ambiguous input the detector's shape rules were not designed to resolve confidently (documented as such in the new code's own comments, mirroring the existing declared-shape docstring's edge-case documentation style), When the detector runs, Then it treats the ambiguous case as requiring review, and "requiring review" is defined, unambiguously, as blocking spec-kitty next's advance-vs-stay decision the same way a confirmed bare-prose finding does — it is not a separate, non-blocking-but-visible signal. This is a deliberate application of Story 1's "blocked, not merely logged" thesis to the hardest-to-classify (and, empirically, likely highest true-positive) bucket: fail loud by blocking, rather than silently downgrading an unresolved case to advisory. Boundary — "ambiguous" vs. a normal (non-flagged) miss: a case is "ambiguous" (and therefore blocks per this scenario) only when the detector's own section-scoping or shape-classification logic reaches a state it cannot resolve — e.g. an exception mid-computation, a malformed or unterminated heading/section boundary, or a token whose shape partially matches more than one classification rule with no tiebreak. It is NOT "ambiguous" when a token or section is simply outside the detector's declared scope by design (e.g. a heading that does not match the requirement-heading predicate at all, or a table-row description-column citation per Story 2 Scenario 3) — those are deliberate non-matches, not classification failures, and must not be forced into the ambiguous/blocking bucket merely to inflate coverage. 3. Given this new fail-loud behavior, When it is contrasted against the deliberately fail-safe, never-crash-into-a-gate design of the existing _log_requirement_extraction_warnings_safely advisory wrapper (src/runtime/next/runtime_bridge.py, ~line 835), Then the spec/plan explicitly distinguish the two: the advisory computation must never crash into a false gate failure (existing, unchanged behavior); the new blocking detector, when it genuinely cannot classify, must not silently report clean — these are two different failure-handling contracts for two different code paths, and the implementation must not conflate them (e.g. by routing the new detector through the same swallow-and-log-at-DEBUG wrapper).
User Story 6 - Reflexivity: missions mid-flight when this ships (Priority: P2)
The mission states plainly what happens to every other mission currently running when this change lands — including this mission itself.
Why this priority: This change touches spec-kitty next's advance-vs-stay guard, the machinery every other running mission (including the one authoring this spec) depends on to know whether it can proceed. An unstated behavior change to shared control-plane machinery is a process risk independent of whether the code is correct.
Independent Test: The plan/tasks phases document, and the implementing work package's PR description states, which currently-in-flight missions (if any, at merge time) have a spec.md containing bare-prose requirement shapes that would newly block at the tasks boundary, and what the operator's remediation path is (rewrite the offending requirements into a declared shape; no code-level grandfathering is proposed by this spec).
Acceptance Scenarios:
1. Given this change ships and a mission currently at or approaching the tasks_packages/tasks_finalize boundary has a spec.md containing bare-prose requirements, When that mission's spec-kitty next is next invoked, Then it newly blocks where it previously advanced (or advanced silently past uncounted requirements) — this is the intended, disclosed behavior change, not a regression to be hidden. 2. Given this mission's own spec.md (this document), When the new detector runs against it, Then it does not block — every requirement in this document is written in one of the four declared shapes (table row | ID | Title | ... |), which the author has verified by construction while writing this spec. 3. Given the operator merges this change, When any other in-flight mission is newly blocked by it, Then the remediation is documented (rewrite bare-prose requirements into a declared shape) rather than silently defaulted around.
Edge Cases
- Description-column citations in correctly-declared tables (Story 2, Scenario 3): the #3395-measured 6% false-positive driver. The new detector must not re-trigger on a table row whose ID cell is properly declared but whose description/body column happens to mention a foreign or malformed id-shaped token.
- Heading-title matching for "Requirements" sections:
_is_requirement_heading(src/specify_cli/requirement_mapping.py) matches any heading whose text contains "requirement" case-insensitively — this could over-match a heading like "Non-Requirements Notes" or under-match a section titled without the literal substring (e.g. "What This Spec Must Do"). The corpus validation in Story 4 is the empirical check on this; the plan phase should record whether any corpus specs hit this boundary. - The Constraints-heading blind spot (this mission's own model's stated scope, structurally unreachable today): This mission's Key Entities model explicitly includes
C-ids as a requirement type it must protect against bare-prose loss, but_is_requirement_heading's substring match on "requirement" does NOT match the literal heading text "Constraints" (### Constraints/## Constraints) — the canonical heading the real corpus uses for this section. Measured directly against this checkout's corpus: 325 of 368kitty-specs/*/spec.mdfiles (88.32%) use a### Constraintsor## Constraintsheading — so the detector is structurally blind toC-NNNdeclarations in roughly nine of every ten specs in the corpus. A bare-proseC-XXXitem written under such a heading is never scoped into any_requirement_named_sectionsresult at all today — not flagged, not warned, not blocked — and Story 4's false-positive-only corpus validation, as originally scoped, cannot detect this false-negative class (Story 4 now requires a false-negative sample specifically covering it). The plan phase MUST resolve this per C-008: either broaden the heading-match predicate to also match "constraint" (not only "requirement"), or explicitly narrow FR-001/Key Entities to state that C-XXX bare-prose detection is out of scope for this mission, with justification. Silently shipping FR-/NFR-XXX blocking while leaving C-XXX structurally unreachable, with no stated decision either way, is not an acceptable outcome. - Section boundary edge cases:
_requirement_named_sectionsscopes a section from its heading to the next heading of any level. A bare-prose requirement written just before a lower-level sub-heading that re-enters the same logical section may fall outside the detected scope; Story 5 (never silently clean) governs whether that's flagged as ambiguous or genuinely excluded. - Zero WP files vs. ≥1 WP file with none referencing the bare-prose ids: two structurally different reasons
spec-kitty nextmight already be "blocked" or "not blocked" today; Story 3 requires both configurations to be tested, since the reverted_zero_declared_requirement_blockonly ever addressed (and only in a way proven inert for) something adjacent to the first. - The CLI-native
tasks_finalizevs. composed"tasks"/legacy_step_idvocabulary split:_evaluate_tasks_finalize_guarddoes not readrequirement_mapping_failurestoday at all; only the composed terminal guard does. This is a pre-existing asymmetry this mission's wiring choice must not paper over silently (Story 3's fourth-call-site note). - The
main-branch red-first impossibility: this mission'splanning_base_branchis PR #3395's branch (op/3394-requirement-citation-scope), notmain. Onmain, the declared-shape extraction mechanism this mission extends does not exist yet at all, so a red-first ATDD test for this mission's blocking behavior cannot be run RED againstmain— only against the actualplanning_base_branch. The plan phase must state this explicitly rather than silently assuming amain-relative RED baseline (C-011 binding; see Constraints). - Pre-existing test baseline: issue #3284 tracks ~23 known-red tests on
main; this mission must not assume a clean baseline in any acceptance criterion, and any newly-discovered pre-existing failure must be reported per the charter's Pre-existing Failure Reporting Rule before being treated as accepted baseline.
Requirements (mandatory)
Functional Requirements
| ID | Title | User Story | Priority | Status |
|---|---|---|---|---|
| FR-001 | Add a NEW per-token blocking predicate — a ref-shaped token (FR-/NFR-/C-XXX) inside a requirement-named section (reusing _requirement_named_sections's heading-scoping only) that is absent from the document's declared-id set (C-006) — explicitly NOT a promotion of find_undeclared_requirement_citations, whose zero-declared trigger (not _declared_ids(section)) is structurally blind to a section that declares some requirements and bare-prose-writes others: verified directly, it returns [] against the issue's own repro. The issue's exact repro (Story 1) is this requirement's direct acceptance case. | As a maintainer, I want the mixed-declaration case #3396 actually describes detected, not a promotion of a detector already proven not to fire on it. | High | Open |
| FR-002 | Wire the new blocking signal into spec-kitty next's tasks-boundary advance-vs-stay decision in a way that is demonstrably NOT gated behind the tasks_wp_files/_tasks_dir_ready short-circuit that made _zero_declared_requirement_block provably inert | As a maintainer, I want the new signal to actually change spec-kitty next's decision, not repeat dead code under a new name. | High | Open |
| FR-003 | Audit which step_id vocabulary (tasks_finalize CLI-native vs. composed "tasks"/legacy_step_id) production spec-kitty next actually dispatches at the finalize boundary, and wire the new signal into the live vocabulary (documented finding, not an assumption) | As a maintainer, I want the fix to reach the guard spec-kitty next actually invokes in production, not only the guard named in the issue text. | High | Open |
| FR-004 | Preserve #3394's repro shape as non-blocking: a spec whose own requirements are all declared correctly, citing a foreign id in prose, must not block under the new detector | As a maintainer, I want the #3394 fix to remain intact while #3396 closes. | High | Open |
| FR-005 | Record the corpus measurement against the full kitty-specs/*/spec.md corpus (368 specs, document-scoped predicate per C-006): shipped-function rate 1/368 = 0.27% flagged (re-verified independently twice against the shipped find_bare_prose_requirement_ids), the sole flag a foreign-id citation — an id belonging to another ID space or another mission, not a requirement this spec declares — zero true positives. The earlier, broader design-time prototype's rate (9/368 = 2.45%, measured without the shipped function's per-line declared-shape skip rule) is recorded alongside it, explicitly labeled as the prototype's figure — see spec.md's Story 4 Correction note. Record the figure, corpus size, and measurement date in the new detector's module docstring, following the precedent of _DECLARED_ID_PATTERNS's existing #3395 6% figure. Protect CI against a future detector regression with a frozen, shrink-only corpus fixture — snapshotted per-spec detector signatures committed under tests/, asserted against the live detector on every run, re-snapshotted only on a deliberate, reasoned decision (charter Standing Order 5, frozen-baseline-shrink-only-ratchet) — not a live re-run over the growing kitty-specs/ tree. Separately sample the corpus for false negatives (real bare-prose FR-/NFR-/C-XXX items the detector misses), specifically including the C-XXX-under-"Constraints"-heading case, and record that finding in-repo alongside the false-positive rate. | As a maintainer, I want the gate's real-world noise level on record before it ships, and CI protected against regressions without depending on the corpus's future, growing contents. | High | Open |
| FR-007 | Ensure the new detector never silently reports "0 uncounted" or "clean" when it cannot confidently classify an input; ambiguous or exceptional cases surface as an explicit failure/report AND block spec-kitty next's advance-vs-stay decision the same way a confirmed bare-prose finding does (Story 5 AC2's blocking/boundary definition) — "requires review" is not a separate non-blocking-but-visible signal | As a maintainer, I want this fix to not itself become another silent-success defect, and I want the hardest-to-classify bucket to fail loud rather than quietly downgrade to advisory. | High | Open |
| FR-008 | Keep the new blocking detector's failure-handling contract distinct from the existing advisory wrapper's fail-safe (_log_requirement_extraction_warnings_safely) contract — do not route the new blocking computation through the same swallow-and-log-at-DEBUG path | As a maintainer, I want the advisory's "never crash into a gate" guarantee to stay intact while the new detector's "never silently clean" guarantee is independently enforced. | High | Open |
| FR-009 | Document, in plan.md and the implementing PR description, which currently in-flight missions (if any at merge time) would newly block under this change, and the operator-facing remediation | As an operator, I want to know what breaks for in-flight missions before this ships, not discover it live. | Medium | Open |
| FR-010 | For EVERY guard function the implementation adds or modifies to read the new blocking signal (as identified by FR-003's step_id-vocabulary audit — potentially _evaluate_tasks_packages_guard, _evaluate_tasks_finalize_guard, _evaluate_composed_tasks_packages_guard, and/or _evaluate_composed_tasks_terminal_guard), add a per-guard non-vacuity ("teeth") test proving that specific guard's reachability is actually exercised — removing/reverting the wiring at that guard alone must fail that guard's test. One existence-proof test anywhere in the suite does not satisfy this for the other wired guards. | As a maintainer, I want proof EVERY new call site is load-bearing, not just one, so a second live guard cannot ship silently unverified (mirrors this repo's architectural-gate-non-vacuity doctrine). | High | Open |
Non-Functional Requirements
| ID | Title | Requirement | Category | Priority | Status |
|---|---|---|---|---|---|
| NFR-001 | No doc-wide regression | The new detector must remain section-scoped (Requirements-named headings only); it must never fall back to doc-wide raw-token scanning as a blocking mechanism — that is precisely the #3394 bug. | Reliability | High | Open |
| NFR-002 | Silent-success prohibition | No new code path may return an empty/zero/None/unknown result for "cannot classify" in a way indistinguishable from "nothing to flag" (FR-007/FR-008). An "ambiguous, cannot classify" result MUST block spec-kitty next's advance-vs-stay decision (per Story 5 AC2's boundary definition) — a result that is merely surfaced-but-non-blocking does not satisfy this requirement. | Reliability | High | Open |
| NFR-003 | Static-analysis cleanliness | All changed/added code passes ruff and mypy --strict with zero new issues and zero new suppressions. | Maintainability | High | Open |
| NFR-004 | New-code coverage | Every new branch/helper (section-scoped blocking classifier, guard-wiring change, corpus-validation script/test) has a focused test in the same work package. | Maintainability | High | Open |
| NFR-005 | Guard non-vacuity, per guard | For each individual guard function the new blocking wiring touches, that guard must fail its own synthetic reversion (teeth) test when its wiring alone is reverted — proving every targeted guard is load-bearing, not only the first one tested (FR-010, Story 3 AC4). | Reliability | High | Open |
| NFR-006 | Performance | Detection remains pure regex/string-splitting over already-read spec.md content — no new filesystem or network I/O added to the hot spec-kitty next path. | Performance | Medium | Open |
Constraints
| ID | Title | Constraint | Category | Priority | Status |
|---|---|---|---|---|---|
| C-001 | No widening of declared-shape scoping | The four _DECLARED_ID_PATTERNS shapes settled by #3394/#3395 are not touched or widened by this mission; #3396 adds a distinct, NEW per-token blocking predicate (FR-001) — not a promotion of find_undeclared_requirement_citations's zero-declared trigger — and it does not reopen what counts as "declared." | Technical | High | Open |
| C-002 | ATDD red-first, base-branch caveat | A failing-first test must exist as a separate commit before any implementation commit (charter C-011). This mission's planning_base_branch is PR #3395's branch (op/3394-requirement-citation-scope), not main — the red-first test cannot go RED against main, because the declared-shape mechanism this mission extends does not exist there yet. Plan/tasks must state this explicitly and verify RED against the actual planning_base_branch. | Process | High | Open |
| C-003 | No clean-baseline assumption | Issue #3284 tracks ~23 known-red tests on main. No acceptance criterion in this mission may assume a clean baseline; any newly-discovered pre-existing failure is reported via a GitHub issue per the charter's Pre-existing Failure Reporting Rule before being treated as accepted baseline. | Process | Medium | Open |
| C-004 | Terminology canon | "Mission," never "Feature," in all new/changed user-facing text, error messages, and code identifiers introduced by this mission. | Process | Medium | Open |
| C-005 | Rebase-on-#3395-change risk | This mission's diff sits on top of #3395's unreviewed, still-open (reviewDecision empty) parser rewrite. If #3395 changes shape before merge, this mission absorbs the rebase — an accepted, explicitly acknowledged consequence of the operator's branch-now decision (see Clarifications), not a defect to work around silently. | Process | Medium | Open |
| C-006 | Document-scoped declared-id set, not section-scoped (15x measurement) | The blocking predicate's "declared" set (FR-001) MUST be the document-wide declared-id set — any id declared anywhere in the document in one of the four _DECLARED_ID_PATTERNS shapes — never the section-local declared set. Measured directly against this checkout's corpus (368 kitty-specs/*/spec.md files) at design time, against an earlier, broader prototype (corrected post-implementation 2026-08-14 — see Story 4's Correction note; the shipped function's own rate is 1/368 = 0.27%, not the figures below, which are retained only as the historical basis for this 15x design ratio): document-scoped flags 9/368 (2.45%); section-scoped flags 139/368 (37.77%) — a 15x difference from this one design choice. Document-scoped is correct: a token declared elsewhere in the document is already counted as a requirement, so a section merely citing it is not a lost requirement. Both measured rates MUST be recorded together (Story 4/FR-005) so an implementer cannot reasonably build the section-scoped version and ship a gate that fires on over a third of the corpus. | Technical | High | Open |
| C-007 | runtime_bridge_cores.py import-boundary preservation | runtime_bridge_cores.py documents itself as a stdlib-only "zero-dependency leaf" (module docstring) and imports nothing but stdlib + runtime.next.decision today; no architectural test currently pins this by construction. The plan phase must gather any new blocking-detection result as plain data in the residual (runtime_bridge.py) and thread it into runtime_bridge_cores.py only via RequirementMappingFacts (or an equivalent fact object) — never via a new cross-package import inside runtime_bridge_cores.py. The plan phase should consider adding an architectural test pinning runtime_bridge_cores.py's import set to catch a future regression. | Technical | High | Open |
| C-008 | Constraints-heading (C-XXX) scoping decision required | Per the Edge Cases entry on the Constraints-heading blind spot: the plan phase MUST do one of (a) broaden _is_requirement_heading's match predicate to also match "constraint" (not only "requirement"), or (b) explicitly narrow FR-001 and Key Entities to state that C-XXX bare-prose detection is out of scope for this mission, with justification. Leaving this undecided — shipping FR-/NFR-XXX blocking with C-XXX silently unreachable — is not an acceptable plan-phase outcome. | Technical | High | Open |
| C-009 | Composed/step-contract guard vocabulary scope statement required | The composed guard family FR-002/FR-003's audit may direct wiring into is reached through the mission step-contract execution surface (StepContractExecutor, src/specify_cli/mission_step_contracts/executor.py, via _check_composed_action_guard → runtime_bridge_composition._check_composed_action_guard), the surface the charter's Contracts lens names as needing explicit preservation/versioning treatment for behavior changes. The plan phase must state explicitly whether this vocabulary is in scope for this mission's wiring, and if so, whether src/specify_cli/mission_step_contracts/ schemas or orchestrator-api-facing documentation need a corresponding note for the new guard-failure message class. | Technical | Medium | Open |
Key Entities (include if feature involves data)
- Bare-prose requirement candidate: a raw
FR-/NFR-/C--shaped token found inside a heading section whose title matches the (plan-phase-resolved, per C-008) requirement-heading predicate, that matches none of the four declared shapes — the object this mission's new detector classifies and, when genuine, blocks on. This is NOT currently symmetric across the three id types:_is_requirement_heading's substring match on "requirement" reaches sections titled e.g. "Functional Requirements" or "Non-Functional Requirements," but does NOT reach the corpus's canonical "Constraints" heading (measured 325/368 = 88.32% ofkitty-specs/*/spec.mdfiles) — so a bare-proseC-XXXcandidate is, as of this spec, structurally invisible to the reused scoping mechanism even though the definition above namesC-as an in-scope prefix. C-008 requires the plan phase to either close this asymmetry or explicitly document it as an accepted scope narrowing. - Requirements-named section: the
(heading_text, body)scoping already produced by_requirement_named_sections(src/specify_cli/requirement_mapping.py) — the boundary within which this mission's blocking detection operates, deliberately narrower than the whole document. - Blocking signal / guard-consumption point: the as-yet-undetermined (plan-phase decision, FR-002/FR-003) location(s) in
runtime_bridge.py/runtime_bridge_cores.pywhere the new signal must be read so it actually changesspec-kitty next's advance-vs-stayDecision— the central risk object of this mission, given the3823f2b00revert precedent. Per C-007, any such signal reachingruntime_bridge_cores.pymust arrive as plain data (e.g. viaRequirementMappingFactsor an equivalent fact object gathered in the residualruntime_bridge.py), never via a new cross-package import insideruntime_bridge_cores.py, which documents itself as a stdlib-only zero-dependency leaf. - Corpus false-positive record and frozen fixture: the durable, in-repo (module docstring/comment) statement of the new detector's measured false-positive rate — shipped rate 1/368 = 0.27% (document-scoped, C-006, re-verified independently twice against the shipped function — corrected post-implementation 2026-08-14, see Story 4's Correction note), zero true positives, the sole flag a foreign-id citation — an id belonging to another ID space or another mission, not a requirement this spec declares. The earlier, broader design-time prototype's rates (9/368 = 2.45% document-scoped, 139/368 = 37.77% section-scoped, C-006) are recorded alongside it, explicitly labeled as that prototype's figures, plus the false-negative sample findings against the
kitty-specs/*/spec.mdcorpus, corpus size, and measurement date, following the precedent of the existing #3395 6% figure recorded insrc/specify_cli/requirement_mapping.py. Protected going forward not by a live re-run against the growingkitty-specs/corpus but by a frozen, shrink-only fixture (FR-005, charter Standing Order 5) committed undertests/and re-snapshotted only on a deliberate, reasoned decision.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: The issue's exact repro spec.md (declared NFR + bare-prose FR-001/FR-002) blocks
finalize-tasksandspec-kitty next's tasks-boundary decision, both naming FR-001/FR-002 explicitly.map-requirementsis a mapping-registration command, not a blocking gate: it surfacesbare_prose_requirement_idsin its JSON payload (naming FR-001/FR-002 explicitly) without failing the command — it does not block. Corrected post-implementation (pre-merge squad finding PR-CONTRACT-002, 2026-08-14): this criterion previously stated all three surfaces block; that overclaimed whatmap-requirementsdoes. See Clarifications below. - SC-002: The full pre-existing #3394/#3395 test suite (
tests/specify_cli/test_requirement_mapping.py,tests/next/test_runtime_bridge_unit.py,tests/runtime/test_bridge_cores.py) passes unmodified in its pinned assertions after this mission lands; #3394's repro shape stays green. Exception (see Story 2's Independent Test note above): exactly two tests intests/next/test_runtime_bridge_unit.pywere re-pinned under the operator's 2026-08-14 ruling that #3396 supersedes #3395's advisory-only decision for the target repro shape — the other two named files are unmodified in their pinned assertions. - SC-003: A regression test proves the new signal reaches
spec-kitty next's decision in both the zero-WP-files and the ≥1-WP-files-none-referencing configurations, independent of thetasks_wp_files-first guard ordering that made the reverted_zero_declared_requirement_blockinert; a per-guard teeth test proves EACH guard function the implementation wires (per FR-003's audit) is individually load-bearing — fails when that specific guard's wiring alone is reverted (FR-010, NFR-005, Story 3 AC4). - SC-004: The plan phase documents which step_id vocabulary (
tasks_finalizeCLI-native vs. composed"tasks") is live for productionspec-kitty nextdispatch at the finalize boundary, and the implementation's wiring covers it. - SC-005: The new detector's false-positive rate is measured and recorded in-repo: 1/368 = 0.27% (document-scoped declared-id set, C-006, the shipped function's own re-verified rate), zero true positives, the sole flag a foreign-id citation — an id belonging to another ID space or another mission, not a requirement this spec declares — recorded by explicit numeric value, corpus size, and measurement date, co-located in the detector's module docstring. The earlier, broader design-time prototype's rates (9/368 = 2.45% document-scoped, 139/368 = 37.77% section-scoped — both measured without the shipped function's per-line declared-shape skip rule) are recorded alongside it, explicitly labeled as the prototype's figures, not this function's — see Story 4's Correction note; a false-negative sample (including the C-XXX-under-"Constraints"-heading case) is recorded too.
- SC-006: CI is protected against detector regressions by a frozen, shrink-only corpus fixture (FR-005) committed under
tests/— not a live re-run overkitty-specs/at CI time. The flagged set may only shrink or stay equal between snapshots; any growth fails CI until the fixture is deliberately re-snapshotted with a recorded reason. This satisfies charter Standing Order 5'sfrozen-baseline-shrink-only-ratchetand avoids both horns of squad finding SPEC-FRESH2-002 (a live-corpus dependency, and shipping already-red at 2.45%). - SC-007: No new code path returns an empty/zero/silent result for a case the detector cannot confidently classify; a fault-injection test proves ambiguous/exceptional cases surface as an explicit failure that BLOCKS
spec-kitty next's advance-vs-stay decision (Story 5 AC2's boundary definition) — not silent "clean," and not a merely-surfaced, non-blocking advisory. - SC-008: Plan.md and the implementing PR description name any currently in-flight missions that would newly block under this change (including this mission's own spec.md, verified clean by construction) and state the operator-facing remediation.
- SC-009: All changed/added code passes
ruffandmypy --strictwith zero new issues/suppressions; every new branch/helper has a focused test in its work package.