Contracts

grammar.md

Contract: requirement-ID grammar (single authority)

  • Home: src/specify_cli/requirement_mapping/grammar.py. No other product module may compile a requirement-ID pattern, change an ID's case, or tokenise requirement_refs. This is enforced by tests/architectural/test_requirement_id_grammar_single_source.py.
  • Allowlist (shrink-only; the gate asserts that the entry count equals the recorded baseline, two-sided):
  • Frozen (2 entries, HiC ruling Decision Moment 01M3P2HXKASQY2ZKSEY3MAWA9H, superseding 01M3P07HV88QNVVKP3E2W28VB6): src/specify_cli/missions/_substantive.py, src/specify_cli/retrospective/generator.py. Each entry names its follow-up ticket.
  • Transitional ratchet: WP01 lands the allowlist with 3 entries and baseline: 3: the 2 frozen entries plus 1 transitional entry for src/runtime/next/runtime_bridge_cores.py (_REQUIREMENT_REF_PATTERN, marked transitional: "WP04 removes"). WP04 deletes that pattern, removes the transitional entry and lowers the baseline to 2 in the same edit. At the mission head the allowlist holds exactly the 2 frozen entries with baseline: 2.
  • Migrated, not allowlisted: src/specify_cli/consolidation/retention.py. WP01 replaces _CONSTRAINT_ROW_ID with a grammar check: a table's first cell counts as a retention constraint row when parse(cell) yields an unqualified ID with kind == "C" (kind input case-insensitive). C-001, c-001 and C-007a count; other-mission#C-001, C-S1 and IC-01 do not. After WP01 the file holds no requirement-ID literal.
id         := [qualifier "#"] kind "-" digits [suffix]
kind       := "FR" | "NFR" | "C" | "SC"            ; input case-insensitive, canonical uppercase
digits     := DIGIT+                                ; verbatim; width significant
suffix     := LOWER_LETTER                          ; spec scan: lowercase only
                                                    ; ref matching: either case → lowercase
qualifier  := slug                                  ; foreign citation; never resolved
slug       := [a-z0-9] [a-z0-9-]* ["-" 8[0-9A-Z]]   ; optional mid8 tail: exactly 8 characters
  • Core pattern: (?:FR|NFR|SC|C)-\d+(?-i:[a-z])? (the kind alternation is case-insensitive; the suffix class is case-sensitive). RE2-safe.
  • Token boundary (applies to find_all and to the declared-shape scan):
  • A token must not be preceded or followed by an ASCII word character ([A-Za-z0-9_], ASCII \b semantics; compile with re.ASCII if the stdlib fallback is active). The single lowercase suffix letter is part of the token, and it must itself not be followed by a word character.
  • A trailing -word (a hyphen followed by a letter or digit, e.g. FR-008-mandated, C-007-mission) makes the token malformed in a declared position and not an ID at all in prose.
  • Implementation (analysis F9 ruling): RE2 supports \b, so the pattern uses \b at both ends of the (optionally qualified) core. No consumed group, no lookbehind, no lookahead. group(0) is exactly the token. find_all then applies an in-code check on the text right after each match: a following -<letter> compound (FR-008-mandated) makes it not-an-ID in prose, and malformed in declared positions. Verify \b behaviour under kernel._safe_re in a unit test.
  • _REF_FIND_PATTERN (the legacy-compat alias) is generated from the core as \b(?:FR|NFR|C)-\d+\b: unqualified, unsuffixed, with no compound check. It is kept ONLY because the frozen test_bare_prose_false_negative_sample.py reimplements the legacy scan with finditer/group(0), so its figures must not move. Production code never uses it; production uses find_all.
  • Negative controls: IC-01 never yields C-01; XFR-001 yields nothing; FR-00N never yields FR-00; FR-008-mandated yields nothing; FR-0011x yields FR-0011x whole, never a truncated FR-0011 or FR-001.
  • Declared positions: the lead token of a markdown table's first cell, of a heading, of a -//N. list item, or of a bold paragraph lead. Wrappers * and ~~ are tolerated. HTML comments are skipped (blanked by blank_html_comments, positions preserved). Only the first ID on a line is a declaration.
  • Malformed declared lead (MALFORMED_DECLARED_LEAD, FR-013): detection is uppercase-kind only and case-sensitive: a lead token in a declared position that starts with FR, NFR, SC or C, followed by - or _, then a digit or an uppercase letter, and that does not full-match the core. It runs spec-wide over declared positions (not only inside requirement sections). Negative controls on declared-position lines, none of which may error: - C-style strings, | C-suite |, - c-001 lowercase. Lead-token capture charset (analysis B6): the captured lead is [A-Za-z0-9_.-]+, and it ends at the first other character (', /, whitespace, a non-ASCII dash –, an ellipsis …, ), …). So - FR-009's …, | FR-001/FR-002 |, | FR-002–FR-006 | and - SC-001…004 capture a well-formed ID and do not error. The known corpus refusal set stays exactly 4 specs.
  • Verdicts: malformed fails; unknown_spec_id fails; foreign_qualified never fails. Valid refs on the same WP always count.
  • Not in the grammar: implementation-concern IDs (IC-##), hyphen-qualified compounds (C-007-mission, FR-008-mandated), and dotted numbers (FR-001.1).

json-payload-deltas.md

Contract: JSON payload deltas (additive, NFR-002)

Every key that exists today is kept, with the same type. The changes are listed below; each one also goes in the changelog.

spec-kitty agent mission finalize-tasks --json

Added keys, present on both success and requirement-mapping failure:

{
  "parsed_spec_ids": {
    "functional": ["FR-001", "FR-006a"],
    "non_functional": ["NFR-001"],
    "constraint": ["C-001"],
    "success_criteria": ["SC-001", "SC-002b"]
  },
  "rejected_requirement_refs": {
    "WP02": [
      {"ref": "C-007-mission", "reason": "malformed"},
      {"ref": "SC-009", "reason": "unknown_spec_id"},
      {"ref": "other-mission-01KAAAAA#FR-013", "reason": "foreign_qualified"}
    ]
  },
  "success_criteria_coverage": {
    "referenced": {"SC-001": ["WP01"]},
    "unreferenced": ["SC-002b"]
  }
}
  • unmapped_* and all other existing keys keep their name and type. Two existing keys change meaning (pre-PR squad finding, recorded here and in the changelog):
  • requirement_refs_parsed now lists each WP's refs as authored (raw tokens, case preserved, including malformed and foreign ones), where it previously listed the uppercase-normalised subset the old parser recognised.
  • unknown_requirement_refs now lists every ref whose verdict fails (malformed or unknown_spec_id), not only undeclared well-formed IDs.
  • requirement_extraction_warnings no longer contains the "SC … DROPPED, not traced" entry. The key stays.
  • Pass/fail: the command fails if any rejected_requirement_refs entry has a reason in {malformed, unknown_spec_id}, or if any declared functional ID is unmapped. It never fails on success_criteria_coverage.unreferenced.

spec-kitty agent tasks map-requirements --json

  • Input tokenisation: each --refs value and each --batch list item is split with the grammar's ref tokenizer (commas and/or whitespace), the same split used for stored items, so "FR-001, FR-002" is two refs.
  • Pre-write refusal (--refs or --batch holding an unusable ID): the existing malformed_refs / unknown_refs keys are kept, and parsed_spec_ids (flat list, as on the stale gate) is added to both the malformed and the unknown-ID refusal. The hint names the grammar: kinds FR/NFR/C/SC, a digit string, an optional lowercase letter, and an optional <mission-slug># prefix for another mission's ID.
  • Stale gate: stale_ref_reasons gains a foreign_qualified bucket next to malformed and unknown_spec_id. Foreign-qualified refs are reported only there: they never appear in stale_refs (which the hint tells the operator to --replace), and a WP whose only offenders are foreign is not listed.
  • Byte-contract fixture flips (byte_contracts.json):
  • map_requirements_malformed_ref_error: FR-001a is no longer malformed. If it is undeclared in the fixture spec it becomes unknown_spec_id; the fixture is re-pinned to a genuinely malformed token.
  • map_requirements_stale_frontmatter_error: FR-002a moves from malformed to unknown_spec_id.
  • map_requirements_unknown_spec_ref_error: gains parsed_spec_ids (additive).
  • Hint text: re-pinned.
  • Success: added refs are written in canonical form and existing items stay byte-identical, in their original order.
  • --replace success (FR-005): --replace is the explicit, operator-invoked overwrite. Its success payload gains one additive key, replaced_refs_removed: {WP: [item, …]}: for every WP the run replaced, the previously stored items that the replacement dropped, raw as they were on disk and in their original order (an empty list when nothing was dropped). The key appears only on --replace runs, so the default-mode map_requirements_success byte contract does not move.

spec-kitty agent mission setup-plan --json

  • New refusal (exit 1):

``json {"result": "error", "error_code": "SPEC_REQUIREMENT_IDS_INVALID", "error": "spec.md declares requirement IDs that do not match the requirement-ID grammar", "invalid_requirement_ids": [{"token": "C-007-mission", "line": 142, "rule": "<kind>-<digits>[<lowercase letter>]"}]} ``

  • New additive key on every non-error payload: "requirement_id_warnings": [{"token", "line", "message"}]. The list is empty when clean.

Orchestrator-api plan verb

The envelope error_code is PLAN_SETUP_FAILED, which is already registered in core/upstream_contract.json and needs no contract change. The data carries:

{"reason": "SPEC_REQUIREMENT_IDS_INVALID", "invalid_requirement_ids": [ ... ]}

The shared _classify_delegate_error is not changed for the tasks and specify verbs.

Contract version (NFR-002): CONTRACT_VERSION (orchestrator_api/envelope.py) is bumped once, 1.7.0 → 1.8.0, covering the additive tasks data keys (finalize's parsed_spec_ids, rejected_requirement_refs, success_criteria_coverage), the additive plan key requirement_id_warnings, and the plan remap to PLAN_SETUP_FAILED with data.reason. WP06 makes the bump. core/upstream_contract.json is not changed.

Runtime (spec-kitty next) readiness findings

The findings reuse the same reason vocabulary. For one fixture, the list is non-empty if and only if finalize-tasks --validate-only fails on requirement mapping (SC-007).