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 tokeniserequirement_refs. This is enforced bytests/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, superseding01M3P07HV88QNVVKP3E2W28VB6):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 forsrc/runtime/next/runtime_bridge_cores.py(_REQUIREMENT_REF_PATTERN, markedtransitional: "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 withbaseline: 2. - Migrated, not allowlisted:
src/specify_cli/consolidation/retention.py. WP01 replaces_CONSTRAINT_ROW_IDwith a grammar check: a table's first cell counts as a retention constraint row whenparse(cell)yields an unqualified ID withkind == "C"(kind input case-insensitive).C-001,c-001andC-007acount;other-mission#C-001,C-S1andIC-01do 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_alland to the declared-shape scan): - A token must not be preceded or followed by an ASCII word character (
[A-Za-z0-9_], ASCII\bsemantics; compile withre.ASCIIif 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\bat both ends of the (optionally qualified) core. No consumed group, no lookbehind, no lookahead.group(0)is exactly the token.find_allthen 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\bbehaviour underkernel._safe_rein 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 frozentest_bare_prose_false_negative_sample.pyreimplements the legacy scan withfinditer/group(0), so its figures must not move. Production code never uses it; production usesfind_all.- Negative controls:
IC-01never yieldsC-01;XFR-001yields nothing;FR-00Nnever yieldsFR-00;FR-008-mandatedyields nothing;FR-0011xyieldsFR-0011xwhole, never a truncatedFR-0011orFR-001. - Declared positions: the lead token of a markdown table's first cell, of a heading, of a
-//N.list item, or of aboldparagraph lead. Wrappers*and~~are tolerated. HTML comments are skipped (blanked byblank_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 withFR,NFR,SCorC, 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…004capture a well-formed ID and do not error. The known corpus refusal set stays exactly 4 specs. - Verdicts:
malformedfails;unknown_spec_idfails;foreign_qualifiednever 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_parsednow 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_refsnow lists every ref whose verdict fails (malformedorunknown_spec_id), not only undeclared well-formed IDs.requirement_extraction_warningsno longer contains the "SC … DROPPED, not traced" entry. The key stays.- Pass/fail: the command fails if any
rejected_requirement_refsentry has a reason in{malformed, unknown_spec_id}, or if any declared functional ID is unmapped. It never fails onsuccess_criteria_coverage.unreferenced.
spec-kitty agent tasks map-requirements --json
- Input tokenisation: each
--refsvalue and each--batchlist 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 (
--refsor--batchholding an unusable ID): the existingmalformed_refs/unknown_refskeys are kept, andparsed_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_reasonsgains aforeign_qualifiedbucket next tomalformedandunknown_spec_id. Foreign-qualified refs are reported only there: they never appear instale_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-001ais no longer malformed. If it is undeclared in the fixture spec it becomesunknown_spec_id; the fixture is re-pinned to a genuinely malformed token.map_requirements_stale_frontmatter_error:FR-002amoves frommalformedtounknown_spec_id.map_requirements_unknown_spec_ref_error: gainsparsed_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.
--replacesuccess (FR-005):--replaceis 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--replaceruns, so the default-modemap_requirements_successbyte 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).