> Superseded in part (operator ruling 2026-10-05). No new contract version: info.version stays 1.0.0-SNAPSHOT, no 1.1.0 and no release tag. The 1.1.0 and tag passages below (R-1, R-7 and the spike rows at 1.1.0-SNAPSHOT and 1.1.0) are the planning-time record; the spec (CL-2, FR-017, FR-018) and plan prevail. The R-7 finding that a same-version bundle change fails with BUNDLE_CHANGED_VERSION_SAME is why the scratch baseline carries a lower version.

Research: Mission Status contract 1.1

Mission mission-status-contract-1-1-01M42XJC (issue #5625, part of epic #5528). Phase 0 output of the plan. Every entry gives the question, the evidence that was produced on this checkout, the decision and what it costs. Everything marked run was executed during planning against the branch as merged with main (the branch head is the merge of origin/main at its tip when the plan started; git merge-base HEAD origin/main equals that tip). Scratch copies of the contract tree, a git archive copy of the repository and a scratch virtual environment lived outside the repository; nothing under kitty-specs/ other than this directory was written, and the corpus reality check was run once (R-5).

Command notation: <scratch> is a scratch directory outside the repository. Commands are shown as run, with the checkout's own interpreter (.venv/bin/python), never a bare uv run.

R-1 OQ-1: the structure check and a fifth CHANGELOG heading

1. structure_check is file-wide, not entry-wide: the four section names already exist in the 1.0.0 entry, so a 1.1.0 entry that dropped Removed would still pass. A test in the contract-proof module reads the ## 1.1.0* entry itself and requires all five headings in it. 2. provisional_check.provisional_sections returns the text of every Provisional section of the file, joined. A word that the 1.0.0 entry already uses (code, staleness, ...) therefore satisfies a 1.1.0 element of that name even when the 1.1.0 entry omits it. The same test requires every AD-14 element name inside the 1.1.0 entry's own Provisional section.

  • Question. Does contracts/tools/structure_check.py accept a fifth heading for the deferred-gaps record, or must it sit under an existing heading (FR-018)?
  • Read. check() collects every heading of CHANGELOG.md at any level (code fences ignored) and fails only when one of the four names in CHANGELOG_SECTIONS (Added, Changed, Removed, Provisional) is absent from that file, or when no heading equals the module's current info.version (has_version_heading: the version text optionally followed by a space and a note, so 1.1.0-SNAPSHOT needs a heading of that text). It forbids no extra heading.
  • Run (accept). A scratch copy of contracts/ with info.version rewritten to 1.1.0-SNAPSHOT and a new top entry ## 1.1.0-SNAPSHOT holding ### Added, ### Changed, ### Removed, ### Provisional and a fifth ### Deferred to the next major version: python contracts/tools/structure_check.py --root contracts exits 0, last line counts: readme_headings=20 changelog_headings=12.
  • Run (control). The same copy with the entry heading renamed to ## Next exits 1 with CHANGELOG_VERSION_HEADING_MISSING ... no heading for the current info.version '1.1.0-SNAPSHOT'.
  • Decision. The deferred-gaps record is a fifth level-3 heading ### Deferred to the next major version, a sibling of the four standard headings inside the ## 1.1.0-SNAPSHOT entry, placed after ### Provisional. It needs no tool change. The two behaviours of the new reads that FR-018 asks for (invalid UTF-8 is a 415; root status records are no artifacts) sit under ### Added as reader notes, and ### Changed and ### Removed say "Nothing.".
  • Two weaknesses found, both closed by a committed test of this Mission (plan D-P11).

R-2 OQ-4: the caps, measured before they are kept

  • Run. A read-only script (standard library plus PyYAML, no write under the repository) walked every Mission directory that holds a meta.json and applied the eligible-file rule of the spec (no symlink component, regular file, root status.events.jsonl and status.json excluded, path not malformed).
  • Measurement (548 Missions, 0 directories without meta.json).
QuantityValue
Eligible files, all Missions15,561 (plus 1,061 root status records excluded; 16,622 files in all)
Largest Mission tree126 files (the Mission of the v1 contract itself); p99 100; median 24; six Missions have more than 100
Entry cap 1000 against the largest tree7.9 times headroom; no Mission is truncated
File size: median, p99, p99.9, maximum4,721 B; 45,324 B; 116,053 B; 619,090 B
Files over 100 KiB / over 200 KiB / over 262,144 B20 / 2 / 2
Not UTF-8 / with a NUL byte2 (CSV files) / 3 (markdown files)
Holding a credential pattern0
Symlinks0
Names with a space, non-ASCII, an at sign or a home/<x>/ segment0 (hidden files: 640)
Malformed-path files0
Files with a sub-second modification timeall 15,561 (so the AD-22 floor test is meaningful)
Files with a human-class host-path pattern / an e-mail pattern859 / 193 (redaction is exercised on real data)
  • Decision. Keep 1000 entries and 262,144 bytes. The cap is 7.9 times the largest tree and 0.01 percent of files exceed the size cap; neither moves, so no squad note is raised for a cap change. The over-cap files of the corpus are charter-contract-cleanup-tranche-1-01KQATS4/research/_artifacts/test-gate-5-ruff.txt and unblock-sync-identity-boundary-canary-01KRZJ07/canary-evidence/full-pytest.txt (413); the not-UTF-8 files are 004-modular-code-refactoring/research/evidence-log.csv and .../source-register.csv (415); the NUL files are 003-auto-protect-agent/data-model.md, 004-modular-code-refactoring/research.md and 005-refactor-mission-system/data-model.md (415). They are the named files of the pinned fixture (R-5).

R-3 The owned-files matcher: call it or reproduce it

  • Read. _matches_any_glob(filepath, patterns) in src/specify_cli/policy/commit_guard.py and _mt_matches_owned_file(path, patterns) in src/specify_cli/cli/commands/agent/tasks_move_task.py run the same loop: fnmatch.fnmatch(path, pattern) or, for the pattern with every /* and then every / removed and a trailing / stripped, a non-empty prefix P with path.startswith(P + "/").
  • The exact difference. _mt_matches_owned_file first normalises the path (replace("\\", "/"), then removeprefix("./")). _matches_any_glob normalises nothing. Neither normalises the pattern: the production caller of the first, _mt_owned_file_patterns, does that before the call (same two operations, blank entries dropped), and _matches_any_glob has no such caller step.
  • Importability. Both are private (leading underscore). Neither is public API. Both import without a src/ change: specify_cli.policy.commit_guard in 0.12 s, specify_cli.cli.commands.agent.tasks_move_task in 0.46 s (it pulls the CLI layer in); the second is also re-exported by specify_cli.cli.commands.agent.tasks and its presence is pinned by tests/specify_cli/cli/commands/agent/test_tasks_compat_surface.py.
  • Run (19 cases). Every FR-022 glob case (15) plus four normalisation cases, through both copies: 17 agree; the two cases with a path written ./src/foo/x.py or src\foo\x.py against src/foo/ give False from _matches_any_glob and True from _mt_matches_owned_file; the two cases with a pattern written ./src/foo/ or src\foo\* give False from both (the function does not normalise patterns). All 15 FR-022 outcomes equal the spec's statements (.py matches src/x.py; src//x.py does not match src/x.py; /x.py does not match x.py; a//b//c does not match a/b/c; [x]/ matches [x]/y by the prefix step; a//c matches a/c/z).
  • Private import from a test. No architectural gate forbids it: grep over tests/architectural finds no rule on private names, the ruff families selected in pyproject.toml are E F W C90 C4 ARG B SIM UP ASYNC S TID251 and the PT0xx weak-oracle rules (no private-import rule), and tests/ already imports underscore names from src/ in many files (for example tests/acceptance/test_accept_gate_rejection_cycle.py).
  • Corpus effect of the normalisation. Of 14,638 owned-file entries, 0 start with ./, 1 holds a backslash (an archived Mission, a stray literal) and 6 hold a brace; 1,078 are globs (7.4 percent). Every corpus changeState is unknown regardless (host without worktrees), so the choice below cannot change a corpus assertion.
  • Decision: reproduce, with a parity test. The reader's matches_owned_file(path, pattern) (in the reader module, a pure function, no src/ import) normalises the path like _mt_matches_owned_file and uses the pattern as authored (spec FR-007 rule 3). A parity test (test code only) imports both private functions and requires equal results over every FR-022 glob case and the four normalisation cases, with the two known path-normalisation divergences pinned as such. Why not call one: (1) AD-18, acknowledged by the operator, refused a private seam for review cycles on the projector's public-readers-only rule; calling a private matcher from the reader would reintroduce that exception; (2) the two copies disagree, so "call" would have to pick one and say why; (3) the reader is the executable statement of the rules that a service is built from, and importing the CLI layer into it is the wrong direction. The parity test is the drift alarm (tracer F-2).

R-4 The rename-names seam for FR-007 rule 2

  • Finding. A public seam exists, so NFR-007 holds without a src/ change and no operator decision is needed. kernel.git.changed_paths(cwd, *revs, renames=False) (src/kernel/git/listing.py, exported in kernel.git.__all__) lists a rename as a delete plus an add, so both names appear; kernel.git.changed_entries(..., renames=True) returns NameStatusEntry(status, path, orig_path) (new and old name). merge_base_changed_files_checked (specify_cli.core.vcs.git) cannot serve rule 2: it calls changed_paths(..., renames=True) and returns only the new name.
  • Run. Scratch repository, branch lane-a off main with one commit that renames src/old_name.py to src/new_name.py, deletes gone.txt and leaves untracked.txt untracked: merge_base_changed_files_checked(wt, "main") gives ('gone.txt', 'src/new_name.py') (old name lost); changed_paths(wt, merge_base, "HEAD", renames=False) gives ['gone.txt', 'src/new_name.py', 'src/old_name.py']; changed_entries(..., renames=True) gives [('D','gone.txt',None), ('R100','src/new_name.py','src/old_name.py')]; the untracked file is absent (committed changes only); specify_cli.core.git_ops.get_current_branch(wt) gives lane-a and None after git checkout --detach (the detached-HEAD test of rule 1).
  • Decision. The reader composes git_merge_base (specify_cli.core.vcs.git) with changed_paths(..., renames=False) and treats a None merge base or a GitCommandError as unknown. The field catalogue's x-derived inputs for changeState therefore name git_merge_base and changed_paths (plan "Departs from the spec", item 2).

R-5 Corpus floors re-measured on the merged tree (A-5)

  • Run, once. PWHEADLESS=1 .venv/bin/python -m pytest -m "corpus and not windows_ci" tests/contract/test_mission_status_reality.py -p no:cacheprovider -s --durations=15 -q (the selection of the router job tests-corpus-blocking): 577 passed in 91.18 s (wall clock 2:15 including interpreter start; the first case carries a 19.8 s setup, the tests/conftest.py isolated-install fixture). Report lines: 548 Missions projected, 3,197 work package payloads validated, 24,458 events validated; own-directory pass 3,222 snapshot work packages and 3,197 work package files; disagreement list 8 Missions, ratchet ceiling 8 (the issue, owner and drain date are in the header of tests/contract/fixtures/mission_status_expected.json); 37 coordination fallbacks (environment-dependent, never floored); 27 redactions; slowest case 2.02 s. Not rerun.
  • CI baseline (read from the Actions API for the last three ci-router.yml runs on main). tests (corpus-blocking) 178 s and 183 s (skipped on the third); tests (contract tools) 230 s, 156 s and 177 s; the advisory packs corpus suite on the latest push 15 min 12 s against its 20 minute timeout. The spec quoted 3 min 43 s before #5581; the merged job is shorter.
  • Run (read-only measurement script, standard library plus PyYAML plus the leak patterns; second script: status-log-only ids through materialize_snapshot).
CounterMeasuredProposed floor
Missions with meta.json548500 (existing, unchanged)
Files selected by wp_task_files3,1973,000 (existing)
Distinct valid work_package_id (the detail universe)3,1933,000
Files skipped (unparseable frontmatter or no valid id)0none in the corpus; planted fixtures only
Ids present only in the status log (404 on the detail)29 in 7 Missions26
Work package ids held by more than one file4 ids in one Missionnamed, subset check, anomaly floor 1
Work packages with no prompt file1 (sync-diagnose-canonical-allowlist-01KS4F8H, tasks/WP01.md)named, anomaly floor 1
Work packages with an empty roster9anomaly floor 1
Subtask ids in frontmatter15,19214,000
tasks.md table rows / checkbox rows12,666 / 8,74712,000 / 8,300
Missions without tasks.md / without lanes.json12 / 80anomaly floor 1 / 72
Dependency entries3,3053,100
Owned-file entries (globs; braces)14,638 (1,078; 6)13,000 (1,000; anomaly floor 1)
Review-cycle files, Missions holding them1,322 across 2251,200 across 210
Cycle files the product cannot parse (ReviewCycleArtifact.from_file raises)168150
Parseable cycles with a date-only reviewed_at / with a non-Z offset1 / 36 (of 1,154 parseable)anomaly floor 1 / 32
Cycles whose pointer equals an event reference of the same work package (verdict non-null)306290
Eligible files (listing entries)15,56114,000
readable: true / readable: false15,554 / 714,000 / the 7 named files
Refused 413 / 415 (not UTF-8) / 415 (NUL) / 4222 / 2 / 3 / 0named files; 422 by a planted fixture Mission
Files where redaction changes the text1,023970
Kinds present in the corpusall twelveevery kind at least once
  • Margin rule. Floor = 95 percent of the measurement rounded down to two significant figures; a count under 100 uses 90 percent rounded down to an integer; an anomaly class (measurement under 20) has floor 1 and is pinned by name with a subset check (every named file must still be present and refused or classified as stated; an unnamed new anomaly is reported, not a failure, because an archived Mission cannot be edited and a new Mission may legitimately add one). The first guard is always examined equals discovered on an independent scan; the numeric floors are the second guard (FR-021). The existing floors (500, 3,000, 2,800) stay. Two numbers differ from the spec's orientation figures and are the re-measured ones: 80 Missions without lanes.json (spec: 77 of 544) and 168 unparseable cycle files (the spec's "about 1.14 thousand ... 36 ... 3" counts raw reviewed_at strings by regular expression; counted through the product reader, 1,154 parse, of which 1 is date-only, the others are unparseable and so null for the parse reason).
  • A finding the spec did not know (plan D-P6). Four work package ids of the Mission cli-boundary-robustness-01M2NQCB (WP02, WP03, WP04, WP06) are each held by two files (for example WP02-canonical-json-contract.md and WP02-shared-json-contract.md). The 3,197 files hold 3,193 distinct ids. v1's reality check builds one WorkPackage payload per file (3,197), so two payloads there carry one wpId.

R-6 CI placement and the cost of the new tests

  • Which job selects tests/contract. tests-corpus-blocking (ci-router.yml, 10 minute timeout, gated on the router group corpus, which includes contracts/) runs exactly three modules: test_example_round_trip.py, test_mission_status_payloads.py, test_mission_status_reality.py. tests-contract-tools (15 minute timeout, -n 4 --dist loadfile, environment uv sync --frozen --no-install-project, gated on corpus or contract_tools, the latter including tests/contract/) runs the whole directory minus those three, so a new corpus-marked module lands there with no workflow edit. .github/ci-module-registry.yml excludes tests/contract from the module matrix by design.
  • Run (environment). In a scratch virtual environment built with uv sync --frozen --no-install-project (the tool job's form) the helper unit tests tests/contract/test_mission_status_payloads.py pass: 157 passed. A new module that imports the payload helper therefore runs in the tool job's environment.
  • Run (two registrations a new corpus-marked module needs). In a scratch git repository holding a git archive of the branch head, a one-test module tests/contract/test_zz_planted_new_module.py marked corpus: (a) tests/ci/test_contracts_workflows.py "one home" tests pass (the tool job runs it); (b) tests/architectural/test_ci_corpus_trigger_completeness.py::test_corpus_marked_modules_match_the_curated_registry fails ("newly marked pytest.mark.corpus but absent from the curated registry"); (c) tests/architectural/test_same_tier_uniqueness.py::test_no_per_change_overlap_outside_the_allowlist fails (ci-router.yml::tests-contract-tools x packs.yml::built-in-corpus-suite: 1 node). After one added --deselect tests/contract/test_zz_planted_new_module.py in the packs corpus-suite command (.github/workflows/packs.yml), (c) and tests/ci/test_corpus_blocking_home.py and tests/architectural/test_no_duplicate_suite_execution.py pass again (200 passed, 1 skipped). Both registrations lie outside the spec's slice file set: operator decision OD-1.
  • Run (cost model of the listing and content readers over the whole corpus). A throwaway script (not the reader) walked, stat-ed and read in full every returned entry (the listing's work), then re-read each readable file, redacted it, scanned it and validated a closed envelope with jsonschema (the content read's work): listing 3.4 s plus content 19.8 s, 23.2 s for 15,561 files in 548 Missions; worst Mission 0.31 s (117 entries); 1,023 files with a replacement.
  • Run (the 1000 times 262,144 byte worst case). A scratch directory of 1000 entries of 262,144 bytes (261,881,858 bytes returned: meta.json sorts first, so 999 task files) listed with the same whole-file checks: 6.2 s, identical warm. That is the one timed synthetic case of NFR-001 (limit 60 s).
  • Run (synthetic git repositories). One scratch repository with a lane worktree, two commits including a rename and a name-status diff takes 0.017 s on average, so the change-state matrix is far below a second in total.
  • Budget. NFR-001 allows the corpus job 8 minutes. Baseline 2 min 58 s and 3 min 03 s (R-5). The modelled extension is 23 s locally; with the real reader and the CI factor (the same module took 91 s of pytest here against 178 s on the runner) the plan expects +40 to +90 s, so the job lands near 4 to 5 minutes. Re-plan threshold: a post-implementation corpus job above 6 minutes (75 percent of the limit) stops the Mission for a squad note before the PR. No new case may exceed 60 s; the planned maximum is the 6.2 s synthetic case.
  • Re-read for the plan fix. The tool job's installation is uv sync --frozen --no-install-project followed by uv run --frozen --no-sync pytest; the project is not installed in that environment and pytest.ini sets pythonpath = src. The claim above covers the payload helper's imports; the larger import chain of the matcher parity test is proven separately in R-13.
  • Decision. No new CI job. The reality-check extension lives in test_mission_status_reality.py (job tests-corpus-blocking); the reader and synthetic tests live in new modules that the tool job runs. .github/ci-module-registry.yml and docs/development/reference/ci-gate-mechanics.md need no edit. If OD-1 is decided "new modules", the only workflow edit is one --deselect per new module in packs.yml and one registry row per module in test_ci_corpus_trigger_completeness.py.

R-7 Release dependency and the --baseline-root recipe (run)

  • Setup. <scratch>/baseline holds mission-status/ and _shared/ extracted with git archive HEAD contracts/mission-status contracts/_shared (the Mission's base: v1 at 1.0.0-SNAPSHOT). oasdiff 1.32.1 was installed into the scratch directory with the repository's own contracts/tools/install_tools.py --pins contracts/tools/pins.json --dest <scratch>/tools --only oasdiff (checksum verified by the installer). The candidate is a scratch copy of the repository tree carrying one additive operation (a spike of the planned listArtifacts: one path file missions_missionId_artifacts.yaml, five schemas, one response file, two examples, index entries, the path map entry, info.version: 1.1.0-SNAPSHOT and a ## 1.1.0-SNAPSHOT CHANGELOG entry).
  • Command and results. PATH=<scratch>/tools/oasdiff-1.32.1:$PATH .venv/bin/python contracts/tools/breaking_check.py --root contracts --baseline-root <scratch>/baseline:
CandidateExitLast line
1.1.0-SNAPSHOT0counts: modules=1 baselines=1 breaking=0 provisional_changes=0 no_baseline_initial=0 preview_ref=skipped
1.1.00same
1.0.10same (a non-breaking change passes at any higher version: why FR-017 needs the extra observables)
2.0.00same
1.0.0-SNAPSHOT (bundle changed, version not moved)1BUNDLE_CHANGED_VERSION_SAME
1.1.0-SNAPSHOT plus a property added to the WorkPackage schema1BREAKING_WITHOUT_MAJOR ... added the optional property ... (closed response schemas ...), breaking=1
1.1.0-SNAPSHOT plus a 409 response on GET /missions/{missionId}1BREAKING_WITHOUT_MAJOR ... added the non-success response with the status 409, breaking=1
  • Observable 3 (a), byte identity (run). diff -rq <scratch>/baseline/mission-status contracts/mission-status lists exactly the new files plus CHANGELOG.md, openapi.yaml and the three _index.yaml files that gained entries (schemas, responses, examples; parameters/_index.yaml joins when the query parameter is added); diff -rq over _shared reports no difference.
  • Observable 3 (b), bundle comparison (run). With the Python resolver (contract_resolver.resolve(...).tree, the tree the bundle is written from): the candidate tree minus the new path keys and with info.version set to the baseline's equals the baseline tree (True); a planted 409 added to an existing operation makes the comparison differ. Because the tree compared includes tags and servers, the three new operations reuse the existing Missions tag: a new tag would make the equality false.
  • Other checks on the same spike (run, <scratch> git repository for the checks that read git). layout_check, example_check (36 examples validated), enum_pin_check, leak_scan, structure_check exit 0; citation_check reports no failure (only the informational CITATION_REUSE lines; the limit of five properties per citation is a report, not a gate); provisional_check reported kind is not named in the Provisional section: a property that $refs a provisional schema is itself counted as a provisional element named after the property, so the CHANGELOG must name kind as a whole word (R-1, weakness 2); the bundle (bundle.py --bundle-only) and the vacuum lint (lint_check.py with the pinned vacuum binary, 9 rules) pass: violations=0. JVM-only steps (Gradle validation, the Java view, the TypeScript client smoke, the resolver parity) and release_check over a real tag could not run here.
  • When the 1.0.0 tag matters. (1) Never for the contract, tooling and test WPs, which use the pure functions and the --baseline-root recipe. (2) For the live baseline test and the PR-time evidence: the live test takes the baseline from git archive of the tag contract-mission-status-v1.0.0 and fails (never skips) while the tag is missing, with the recipe in its message. (3) The wrap-up rebase: the release commit edits info.version (to 1.0.0) and the CHANGELOG heading (## 1.0.0) that this branch also touches (it adds 1.1.0-SNAPSHOT above the old heading), so the branch is brought level with that commit during wrap-up before the draft PR (CL-2, A-4); the expected conflict is two lines and resolves by keeping the release's 1.0.0 heading and this branch's 1.1.0-SNAPSHOT version and entry. The operator is asked for the tag at implement start (plan D-0), because it is a maintainer action with unknown lead time; it is needed from the wrap-up rebase (W-6) and for the final evidence (W-7), which is the only evidence run on the final tree: the compaction (W-5) and the rebase (W-6) both change the tree after the aggregate squad (W-4), so earlier runs are pre-tag or pre-rebase evidence and are labelled so.

R-8 Leak classes, redaction and the planted negatives

  • Read. contracts/tools/leak_patterns.py already returns three codes (HOST_PATH, EMAIL, SECRET) and lists FORBIDDEN_PROPERTY_NAMES; no pattern is added or weakened by this Mission. None of the planned property names is a forbidden name (run: is_forbidden_property_name over all 38 planned names returns nothing).
  • Run (existing content survives the new strict names). In a scratch copy, adding the ten names of FR-015 (a) (id, laneId, laneBranch, planningBranch, pattern, feedbackReference, reviewer, kind, mediaType, changeState) to STRICT_FIELDS in fixture_builder.py: leak_scan.py --root contracts still exits 0 and values_strict rises from 333 to 518 (re-measured at the plan fix on a scratch copy: values_human reads 7952 instead of 8137 and values_all 23902; a pinned count is always taken on the final tree), so every existing file passes. Benign collision recorded: the JSON Schema keyword pattern is also a key, so its regular-expression string is now scanned as a strict value (it cannot match the strict host-path patterns, which are anchored on a leading slash, tilde or drive letter or on a home segment; a future regular expression starting with a slash would be flagged, which is the correct side to err on).
  • Note on the raw-text pass (added at the plan fix). The class edit changes the structured pass only; the raw-text pass is a second scan that still reports an at-sign name as EMAIL (R-14, operator decision OD-4).
  • Run (the artifact-path class has no existing victim). Every string under a key named path or artifactPath in any YAML or JSON file of contracts/, outside an x-source or x-derived ancestor: 0 values, 0 malformed. Under x-source the key path is a repository path (226 occurrences) and must stay on the existing scan, so the class skips an x-source or x-derived ancestor.
  • Run (the artifact-path rule as a schema pattern). ^(?!~/)(?![A-Za-z]:)(?!\.\.?(?:/|$))[^/\\\x00]+(?:/(?!\.\.?(?:/|$))[^/\\\x00]+)*$ with minLength: 1, maxLength: 512 equals the negation of the malformed-path predicate on 30 cases (empty, 512 and 513 characters, /abs, ~/x, C:/x, c:x, a//b, a/b/, ., .., a/./b, a/../b, backslash, NUL, ~x/y, ~, names with a space, accents, a leading dot, an at sign or a home/<x>/ segment): 0 mismatches. The contract can therefore say the rule in the schema (examples with a malformed path fail validation) and the scanner and the reader implement the same predicate.
  • Mechanism for the planted negatives. negative_cases.json cases of the form {"tool": "leak_scan.py", "build": "leak:<kind>", ...} call fixture_builder.build(kind, ...), which writes the plant and a clean control at run time from string fragments (no leaking text is committed). run_negative_cases.build_leak needs no change for a new kind, but a new kind needs a new entry in fixture_builder.KINDS and _plant, so the edit of fixture_builder.py covers KINDS and the plants as well as STRICT_FIELDS (plan "Departs from the spec", item 3). The slice file set forbids new fixture files under contracts/tools/fixtures/, so every planted negative of this Mission is either a runtime-built leak kind or an in-test case in tests/contract/.
  • Existing tests whose pinned numbers move (edited in this Mission). tests/contract/test_enum_pin_check.py (counts: enums=3 values=19 becomes enums=7 values=43: ChangeState 3, ArtifactKind 12, ArtifactRefusalCode 7, WorkPackageDetailRefusalCode 2), tests/contract/test_fixture_builder.py (the exact KINDS set), tests/contract/test_leak_scan.py (strict-name and class cases), tests/contract/test_mission_status_examples.py (EXPECTED_PATH_KEYS and the "five paths" test, MIN_EXAMPLES, and the required-example cases for the new schemas), tests/contract/test_run_negative_cases.py only through the manifest length it reads.

R-9 The read-only fingerprint

  • Read. tree_fingerprint(repo_root, subpath) hashes the bytes of every file git ls-files lists under kitty-specs/ (tracked, untracked and ignored) plus git status --porcelain --ignored; the module-scoped autouse fixture kitty_specs_is_left_untouched of test_mission_status_reality.py compares it before the first case and after the last. The new readers run inside that module, so they are covered with no further wiring.
  • Gap found. FR-014 says no file's content and no directory listing differs. git ls-files lists files only, so an empty directory created by a reader is invisible. The plan adds the directory names (a sorted os.walk of directories under kitty-specs/) to Fingerprint and one planted control (a writing reader that only creates an empty directory) to the existing fingerprint tests. The reader's own opens go through the injectable file-system seam; a counting test asserts that the seam saw no write mode.

R-10 Examples and the checks that consume them

  • example_check.py validates every example (inline or a lone $ref to a file under examples/) against the schema that lists it, in the resolved tree, with the shared date-time checker; reports an orphan file; and checks a required-example manifest (contracts/tools/fixtures/example_check/required_examples.json, outside the slice file set). tests/contract/test_mission_status_examples.py already validates every example through both the resolver path and a referencing library path and holds the in-test required cases (for example "every refusal code has an example"); the plan extends that module instead of the manifest (OD-2).
  • layout_check.py fixes path-file names (contract_resolver.path_file_name: the three planned names were computed: missions_missionId_work-packages_wpId_detail.yaml, missions_missionId_artifacts.yaml, missions_missionId_artifacts_content.yaml), the _index.yaml files: lists and the schema title equal to the file stem. citation_check.py requires one x-source or x-derived per property at any depth (a citation on a referenced schema's root is inherited). provisional_check.py requires the marker's open_decision of at least four words and the CHANGELOG naming (R-1). enum_pin_check.py reads a pin by schema title and fails ENUM_UNREADABLE when a pinned title is absent, so the pin entries land in the work package that adds the enum schema.

R-11 Baselines of the gate set (run)

  • .venv/bin/ruff check .: all checks passed; .venv/bin/ruff format --check .: 3,103 files already formatted.
  • Targeted gate files (tests/ci/test_contracts_workflows.py, test_contracts_routing.py, test_corpus_blocking_home.py; tests/architectural/test_ci_corpus_trigger_completeness.py, test_same_tier_uniqueness.py, test_no_duplicate_suite_execution.py, test_module_shard_registry.py, test_workflow_coherence.py, test_no_legacy_terminology.py): 368 passed, 1 skipped in 116 s, 0 failed.
  • The tool-test job's selection (-m "corpus and not windows_ci" tests/contract without the three router-owned modules, -n 4 --dist loadfile): 1,016 passed, 37 skipped in 39 s, 0 failed (the skips were not itemised; the tool-dependent cases that need a JVM, vacuum or oasdiff skip on a machine without them, so the recorded skip count is the local baseline, not a CI figure).
  • spec-kitty cutover-guard --base-ref origin/main: one Mission touched, 0 un-cut-over.
  • Re-run at the plan fix on the same tree (the plan commit adds only planning files): ruff check . clean; ruff format --check . 3,103 files formatted; tests/contract/test_mission_status_payloads.py 157 passed; the nine named gate files 368 passed, 1 skipped in 80 s; the tool-job selection 1,016 passed, 37 skipped in 38 s; the reality module 577 passed in 45 s. Corpus counts re-taken with a plain walk: 548 Missions, 3,197 work package files, 3,193 distinct ids with 4 duplicated, 1,322 review-cycle files, 80 Missions without lanes.json, 0 symlinks. A plain walk of eligible files gives 15,575 against the 15,561 of R-2: the 14 more are files of this Mission's own directory added since that measurement (reviews and notes; the Mission is part of its own corpus, plan Reflexivity item 3); the floors keep their margin.
  • No pre-existing red was found, so no tracker issue is owed under the Pre-existing Failure Reporting Rule.

R-12 Symbols and paths cited by the plan (checked)

Every (path, symbol) below was resolved with a syntax-tree scan of the file on this checkout (0 missing): wp_task_files, WPMetadata, read_authored_wp_frontmatter, read_wp_frontmatter (src/specify_cli/status/wp_metadata.py, plus display_title as a property); AuthoredGroup, ResolvedGroup, reconstruct_wp_view (wp_view.py); normalize_authored_subtask_roster, iter_wp_section_subtask_rows (src/specify_cli/core/subtask_rows.py); dependency_readiness_for_wp (core/dependency_graph.py); build_review_cycle_pointer, validate_review_cycle_pointer, _REVIEW_CYCLE_FILE_RE (review/cycle.py); ReviewCycleArtifact (review/artifacts.py); read_lanes_json, CorruptLanesError, MissingLanesError (lanes/persistence.py); LanesManifest, lane_for_wp (lanes/models.py); lane_branch_name, PLANNING_LANE_ID (lanes/branch_naming.py); resolve_workspace_for_wp, ResolvedWorkspace with runs_in_checkout_root and exists (workspace/context.py); is_glob_pattern (ownership/validation.py); _matches_any_glob (policy/commit_guard.py); _mt_matches_owned_file, _mt_owned_file_patterns (cli/commands/agent/tasks_move_task.py); git_merge_base, merge_base_changed_files_checked (core/vcs/git.py); changed_paths, changed_entries, NameStatusEntry (src/kernel/git/listing.py); get_current_branch (core/git_ops.py); GitPath with as_posix and parse (src/kernel/git/paths.py), parse_paths_z (src/kernel/git/listing.py); ensure_within_directory (core/utils.py); FrontmatterError (specify_cli/frontmatter.py); is_rfc3339_date_time (contracts/tools/schema_formats.py); SECRET_PATTERNS, FORBIDDEN_PROPERTY_NAMES, leak_codes (contracts/tools/leak_patterns.py). The spec's line numbers for the two matchers (141 to 149 and 838 to 847) are correct on this checkout; the plan cites symbols.

R-13 Architectural job baselines, and the parity import in the tool job's environment (run, plan fix)

  • Router jobs that apply to every pull request and were missing from the first gate table (read from ci-router.yml): architectural-fast (10-minute timeout, tests/architectural minus a few modules, -n 4, battery part fast), layer-rules (tests/architectural/test_layer_rules.py and test_pyproject_shape.py, 8-minute timeout), import-linter (job name "import boundaries (TID251)", ruff check --select TID251 ., 5-minute timeout, a job separate from ruff); all three are needs of router-gate together with architectural-heavy and tests-contract-tools. architectural-heavy runs when the router groups architectural (tests/architectural/) or ci_config (.github/workflows/), among others, see a change; two shards, 30-minute timeout.
  • Baseline from CI (read with gh run view for the last three ci-router.yml runs on main, runs 37189655602, 37187714056, 37186818148, all conclusion success). architectural-fast 3 min 24 s, 3 min 55 s, 4 min 10 s. architectural-heavy shards 1/2 and 2/2: 10 min 07 s and 9 min 58 s (first run), skipped (second run), 7 min 02 s and 10 min 18 s (third run); durations are start to completion of the job. A run where it is skipped shows that a diff selecting none of its groups does not run it.
  • Baseline local (run). The eight architectural files that scan or census all of tests/ (test_no_manual_global_state_mutation.py, test_global_state_allowlist_sealed.py, test_spec_kitty_home_pin_census.py, test_spec_kitty_home_pin_guard.py, test_spec_kitty_home_pin_budget.py, test_home_pin_gate_verdict.py, test_no_dead_symbols.py, test_dead_symbol_allowlist_contract.py): 171 passed in 102 s. test_layer_rules.py and test_pyproject_shape.py: 83 passed in 17 s. ruff check --select TID251 .: all checks passed. No red, so no issue is owed.
  • The parity import under --no-install-project (run). A scratch checkout from git archive of the branch head, uv sync --frozen --no-install-project: import specify_cli fails there (the project is not installed). With PYTHONPATH=src (what pytest.ini's pythonpath = src provides) specify_cli.policy.commit_guard._matches_any_glob and specify_cli.cli.commands.agent.tasks_move_task._mt_matches_owned_file both import (0.6 s) and the 19-case table (the 15 FR-022 glob cases and the four normalisation cases) gives 17 agreeing and exactly the two known path-normalisation divergences (./src/foo/x.py and src\foo\x.py against src/foo/**). A pytest module doing the same import, marked corpus, passed under the tool job's selection expression in that environment (1 passed; 28 s of that is the session's isolated-install fixture). The test file lived only in the scratch checkout.

R-14 OD-4: the raw-text pass of the leak scan and a path with an at sign (run on a scratch copy of contracts/)

  • Read. leak_scan._Scanner.run calls scan_text and scan_structured for each file; scan_text runs leak_codes(line, HUMAN) over every non-blank raw line (host path, e-mail, SECRET); run_self_test builds every fixture_builder kind into a temporary root and runs on every leak_scan invocation (the Contracts workflow, the negative-tests job, the router tool job).
  • Run (the defect). The probe name was a retina-style image path (directory data, stem icon, an at sign, the scale 2x, extension .png), assembled at run time from fragments with chr(64) for the at sign, never typed as a literal. leak_codes(<probe name>, HUMAN) returns ('EMAIL',): the first alternative of EMAIL_PATTERN (a local part, an at sign and a dotted domain) matches the stem, the at sign and 2x.png. A YAML file holding that probe name under path is reported EMAIL by the text pass and by the structured pass today. So a control or example holding such a path would fail every run through CONTROL_FLAGGED, and a planned edit of _Scanner.walk alone does not change the text pass. A name with a home/<x>/ segment, a space or an accented letter passes both passes (the human host-path patterns need a leading slash).
  • Run (option X prototype: blank the artifact-path value spans before the text scan). Added to the scratch copy: artifact_path_spans(text) (yaml.compose, scalar value start and end indexes for a key path or artifactPath reached without an x-source or x-derived key), mask_spans, a masked argument to scan_text with SECRET taken from the unmasked line, and the structured artifact-path class with a stand-in predicate. Results: (1) the probe name under path, data/home/ann/notes.md and a flow mapping with a space and accent in path give no finding in text; (2) a flow-mapping line holding the probe name under path and an address under title reports the title in text and in structure, not the path; (3) an x-source mapping whose path holds an address is still reported in text and structure; (4) a path value holding a GitHub-token-shaped string is reported SECRET in text; (5) path: /abs/x is reported ARTIFACT_PATH_MALFORMED in structure; (6) leak_scan.py --root contracts over the full copy exits 0 with files=1009 values_strict=333 values_human=8137, the same strict and human counts as the real tree (the values_all count differs only because the patched tool file is itself scanned). The real tools were not edited.
  • Reading of AD-19 under each option. AD-19 says a path with an at sign is listed and checked by the artifact-path rule, and that credentials and e-mail addresses inside a file name are not detected (accepted residual). X delivers the first half in the scanner and, because SECRET stays scanned, detects more than the residual requires. Y delivers it for the reader and payload_leaks only. The choice is operator decision OD-4.

R-15 Which planted kinds the unchanged scanner already detects (run on the planning base)

Each plant was written to a scratch root and scanned with the unmodified leak_scan.py.

Kind familyResult on the planning baseConsequence
content with a host path and an e-mail address; content with a token; title with a tokendetected (HOST_PATH and EMAIL; SECRET; SECRET)pass on the base and would pass if the FR-015 edit were reverted: regression controls, not red-first
a leading-tilde value under id and under kind (the strict names are not yet in STRICT_FIELDS)not detectedred-first; the tilde is a host path in the strict class only
the twelve artifact-path forms (empty, 513 characters, absolute, tilde, drive letter, backslash, NUL, empty segment, trailing slash, . segment, .. segment, and the same malformation under the key artifactPath)none detectedred-first