Mission Specification: Mission Status contract: health, drift and ops reads

Mission Branch: issue-5776-mission-status-contract-health-drift-ops Created: 2026-10-05 Status: Draft Input: Issue #5776 - "Mission Status contract: drift findings, Ops invocation records and project health reads". Part of epic #5528. Binding decisions: the operator answer "Accept all 10 (Recommended)" of 2026-10-05 (OR-1 to OR-10) and the operator rulings of round 2 and round 3, 2026-10-05 (round 2: ARCH-001, ARCH-002, GOV-007/ARCH-008, COMPLETE-004, COMPLETE-013, OQ-1 to OQ-6; round 3: FRESH-001, FRESH-005, FRESH-007, FRESH-014; round 4: FRESH2-001, which amends FRESH-001), recorded below.

Summary

The Mission Status contract (contracts/mission-status/, #5558, extended by mission-status-contract-1-1-01M42XJC) serves five of the seven screens of the Kitty Desktop prototype. Three screens still lack data: Projects (version, health, branch and last activity on each project card), Drift (no read at all) and Ops (no read at all). This Mission adds the last read slice, read-only, in the same contract tree and the same unreleased release:

1. GET /project gains five properties: specKittyVersion, schemaVersion, health (healthy or schema_drift, provisional), currentBranch and lastActivityAt. They are additive only because no contract-mission-status-v* tag exists (OR-1). 2. GET /drift (getDriftReport): where derived state disagrees with the authoritative sources, for the project or one Mission. Findings of three kinds are shipped: snapshot_disagrees_with_event_log, snapshot_or_event_log_missing and lane_branch_missing (provisional). The fourth proposed kind, derived_view_stale, is not shipped: the rule-and-example gate of FR-013 settles it, on evidence verified on this checkout. 3. GET /ops/invocations (listOpsInvocations): one page of the Op invocation records of the project, newest first, with a profile filter, a totalCount, a provisional skippedCount, a closed-or-open status that counts the closure spine, and redacted or withheld evidence.

No route changes state. info.version stays 1.0.0-SNAPSHOT (PR-1). The proof, as in the previous slice, is a test-tree reference reader and the reality check (#5581) over this repository's own kitty-specs/ and kitty-ops/, with a planted negative for every rule that refuses or redacts. No file under src/ changes (C-006); the service is #5528.

Terminology (binding for this spec and for the contract)

  • Mission, never "feature" (charter Terminology Canon; the glossary marks feature slug DEPRECATED). No new field, parameter, route, flag or example contains feature.
  • Op: the glossary entry op (.kittify/glossaries/planning-and-tracking.yaml, status draft): a bounded, doctrine-governed agent action with a durable git-tracked record; the Ops schema descriptions use that definition. The glossaries have no entry for invocation (one Op's record, below), drift or health; they are defined here.
  • Project: in this contract (as in v1) the repository the service reports on, not the glossary's project (the SaaS collaboration surface).
  • Status lane: a value of the v1 StatusLane vocabulary (the glossary lane). Code lane: a lane of a Mission's lanes.json that resolves to a lane worktree and branch. Descriptions say which sense they use; the Drift properties are persistedStatusLane and derivedStatusLane.
  • Snapshot: the file status.json. Reducer output: what materialize_snapshot (src/specify_cli/status/reducer.py) computes from the event log status.events.jsonl without writing. The scan never calls the writing materialize.
  • Drift finding: one record of a derived artifact that disagrees with, or is missing against, its authoritative source (the event log for status, git for a branch). It is not a defect verdict.
  • Op file: kitty-ops/<26-character Crockford ULID>.jsonl, the append-only record of one invocation (InvocationWriter, EVENTS_DIR, src/specify_cli/invocation/writer.py); its first line is the started event, a later line may be the completed event (OpStartedEvent, OpCompletedEvent, src/specify_cli/invocation/record.py). Every other file in kitty-ops/ is not an Op file.
  • Closure spine: kitty-ops/op-closures.jsonl, closures written by doctor ops (read_op_closures, closed_invocation_ids). Index: kitty-ops/ops-index.jsonl, gitignored, lines {invocation_id, profile_id, started_at}; a performance aid that may lag.
  • Legacy record: a pre-v2 line for which parse_op_event raises LegacyRecordError (src/specify_cli/invocation/errors.py). Unreadable record: an Op file that was read and whose first line is not JSON or not a v2 started event, whose file-name stem differs from its invocation_id, or whose fields fail FR-017. Both are skipped (FR-019). A file that cannot be opened is not a record fact but an environment fault (FR-019, ops_unreadable).
  • Legacy-shaped manifest: a lanes.json that is a JSON object with key feature_slug and no mission_slug; it is not evaluated for kind 3 (FR-012).
  • Artifact-path rule, malformed artifact path: as in mission-status-contract-1-1-01M42XJC (Terminology) and malformed_artifact_path in contracts/tools/leak_scan.py.
  • Reference reader: the test-tree helper implementing these rules over this repository's Missions and Ops; the executable statement of the rules, not a product surface. Planted negative: a deliberately violating input paired with a same-fixture control (tactic acceptance-criteria-non-vacuity).
  • Slice file set: (1) new files under contracts/mission-status/; (2) in existing files there: openapi.yaml (path map, tags), the _index.yaml files, CHANGELOG.md, and the three Project files schemas/Project.yaml, examples/Project.example.yaml, paths/project.yaml; (3) in contracts/tools/: leak_scan.py, fixture_builder.py (STRICT_FIELDS), negative_cases.json, enum_pins.json; (4) under tests/contract/: new reference-reader and test modules, the frozen pre-slice copies of the three Project files (FR-022), and edits to the existing test_mission_status_contract_1_1.py (FR-029), test_mission_status_payloads.py and test_mission_status_reality.py with _mission_status_payloads.py (the derive_project equality of two keys and the reality check's Project build turn red when Project gains five properties: both are ratchet edits, reviewed as such, R-6); (5) the job registrations, only in .github/workflows/ci-router.yml, .github/ci-module-registry.yml and .github/workflows/contracts.yml, where a new test module or path needs one (the plan lists each edit). FR-022, C-003 and AC-VERSION refer to it.
  • Own directory, read directory, fallback Mission: the own directory of a Mission is kitty-specs/<name>/; its read directory is the one the coordination-aware resolver returns (FR-015), which is the own directory except for an unmerged coordination-topology Mission, whose read directory is the coordination surface. A fallback Mission is one whose declared coordination branch no longer exists (CoordinationBranchDeleted): the scan reads its own directory and names the reason (FR-015).
  • Provisional, x-source, x-derived: as defined by contracts/README.md.

Clarifications

Question, ruling and rationale of each are in tracer-design-decisions.md.

  • OR-1 The slice lands before the 1.0.0 tag; Project is additive under 1.0.0-SNAPSHOT and its branch line leaves CHANGELOG "Deferred". OR-2 lane_branch_missing and derived_view_stale are provisional kinds that ship only with an x-derived rule and a reality-check example; derived_view_stale is dropped, with a CHANGELOG line, if both cannot be served. OR-3 laneComparison is kept, derived from the reducer, provisional. OR-4 The provenance-only and terminal warnings (SNAPSHOT_DRIFT_PROVENANCE, SNAPSHOT_DRIFT_TERMINAL) are included as warning with sourceCode set. OR-5 An Op is closed when a completed event exists in its own file or on the closure spine (closed_invocation_ids); the contract does not copy the CLI reader's gap. OR-6 totalCount is counted from the index when present, else from the directory; the description says it can lag. OR-7 Drift findings are not paged: cap 1000 with truncated. OR-8 health does not count drift errors. OR-9 No badge-code namespace; the screen shows kind and sourceCode. OR-10 (amended by FRESH-005, round 3) The contract promises that GET /project is read-only, side-effect-free and safe to call once per recent project, with its cost stated: it reads every listed Mission's event log, so the cost grows with the Mission count (FR-007, NFR-009).

Operator rulings (2026-10-05, round 2; binding; all arbiter recommendations accepted; not re-opened)

Principle: a broken derived file is a finding; a broken authority makes the read a 500; an older but valid shape is "not evaluated" under a stated rule and counted on the named list (PR-4). It is the precedent's line (fail closed, never a silent empty answer), applied to a scan of the whole project instead of one Mission's detail read. Each item: question, ruling, rationale (full text in tracer-design-decisions.md).

  • ARCH-001 Which Mission-level branch does kind 3 expect for a coordination-topology Mission? A: the coordination_branch of meta.json, never mission_branch; the product creates only that one there, and meta.json is already opened. Fixture pair: present, absent.
  • ARCH-002 Does DriftSeverity keep info? A: no; error, warning, pinned, CHANGELOG line that info is not carried; no dead values (AD-6).
  • GOV-007 / ARCH-008 Legacy or malformed lanes.json? A manifest predating mission_slug is "not evaluated for kind 3" (raw JSON pre-read, counted on the named list); every other corrupt manifest is 500 drift_scan_unreadable. Why drift differs from v1: the v1 detail read answers 500 for the legacy manifest (a known defect of the legacy lanes manifest reader, which refuses that shape), and that refuses one Mission's detail; here it would fail the project-wide read permanently (archive-frozen file).
  • COMPLETE-004 Credential check before or after redaction? Before, on the stored evidence_ref; one plant per shape with a clean control (precedent AD-17, WP05 F1).
  • COMPLETE-013 An Op file that cannot be opened? OSError is 500 ops_unreadable; legacy or shape failures and a file listed then gone are skipped: "could not read" is an environment fact and stays loud.
  • OQ-1 Kind 3 covers every non-completed Mission, whatever its lifecycle state: completion does not depend on the clock, so a finding never changes with time alone. OQ-2 No branch name in a finding (a name can carry a handle; the client resolves names from the workspace read). OQ-3 Local branches only, wording "no local branch" (remote-tracking refs can be stale).
  • OQ-4 A status.json that is not JSON or not an object is an error kind-1 finding, sourceCode CORRUPT_JSON, remedy materialize_status, laneComparison [] (the snapshot is the derived side; the classifier already has the code). An OSError reading it and a reducer that raises stay 500.
  • OQ-5 A credential in evidence_ref: withhold; the Op is served with evidence: null, described as "no evidence, or withheld because it holds a credential"; never masked, no [secret] token (precedent AD-17; one field in a list is withheld, as a v1 title is).
  • OQ-6 A provisional skippedCount on OpsInvocationPage, over the same candidate set as totalCount, independent of profile (a skipped record has no reliable profile): the client-facing "examined plus skipped equals discovered".
  • GOV-001 Kind-1 error findings on a Mission merged to main have remedy: null (archive-frozen); the slug for --mission comes from MissionHead.slug (AD-4). COMPLETE-001 The branch source is at most one git for-each-ref refs/heads listing, run once when the first Mission with an expected lane needs it (zero listings when no Mission has an expected lane, FR-012), failing closed; the oracle reads refs another way (FR-012, FR-025).

Operator rulings (2026-10-05, round 3; binding; the four fresh-sweep forks, all option A; not re-opened)

Question, ruling and rationale of each are in tracer-design-decisions.md.

  • FRESH-001 Which directory do kinds 1 to 3 read status from? A: the coordination-aware resolver of the v1 reads (MissionStatus.load); each resolver exception is 500 drift_scan_unreadable, none a fallback (FR-015). Amended by the round-4 ruling below: CoordinationBranchDeleted is the one named fallback.
  • FRESH-005 lastActivityAt and missionCount keep their meaning; "scans nothing" and "cheap" are dropped; NFR-009 bounds the time over this checkout and the Project description says the cost grows with the Mission count (OR-10 amended, FR-007).
  • FRESH-007 The completion test comes first; lanes.json is read and validated only for a Mission that is not completed (FR-012, AD-10), with a fixture pair.
  • FRESH-014 missionId and artifactPath of DriftFinding are non-null; the null-ordering clause is dropped (FR-009).
  • Accepted round-1 fixer choices: COMPLETE-012 (the kind-3 oracle shares the product's is_mission_completed and materialize_snapshot, no corpus floor for kind 3, named plants; FR-025, which reads the merge marker from the own directory where the read directory differs) and the unreadable-spine 500 rule (FR-019).

Operator rulings (2026-10-05, round 4; binding; FRESH2-001 option B, which amends FRESH-001; not re-opened)

Question, ruling and rationale are in tracer-design-decisions.md.

  • FRESH2-001 What does the scan do when the resolver raises CoordinationBranchDeleted? B: for that outcome only, it reads the Mission's own directory, as v1 does, and attaches a named reason; every other resolver exception stays 500 drift_scan_unreadable (FR-015). Reason: on this checkout, a normal clone with no kitty/* branches, 37 of 569 Missions raise it, so the strict rule made GET /drift a permanent 500 here and on every fresh CI clone. The corpus outcome is stated: GET /drift succeeds on this repository, and the fallback Missions are listed by an independent derivation in the reality check (FR-025).
  • Ruled with it (FRESH2-002, FRESH2-003): lastActivityAt stays resolver-based, the same definition as GET /missions; NFR-009, NFR-001 and NFR-002 are re-measured with the resolver included (command and figures in the tracer); the resolver runs once per Mission in a run (a memo); FR-007's no-subprocess test is restricted to the reader's own code and exempts the resolver's git calls explicitly.
  • Decision made under the ruling (where the named reason is visible): in the existing conventions, with no new source and no contract property: the reference reader's result carries it as a fallback field with the one-value vocabulary coordination_branch_deleted, and the reality check reports it as v1 reports its coordination fallbacks (FR-015).

Operator rulings (2026-10-05, round 5; binding; FRESH3-001 option A; not re-opened)

Question, ruling and rationale are in tracer-design-decisions.md.

  • FRESH3-001 Keep the stock coordination resolver and state its facts, or ask for a seam that decides "branch deleted" without remote probing? A: keep it. The resolver may run git ls-remote --heads (5 s timeout per call, per remote); the fallback count and the 200 of GET /drift are network-dependent; offline, GET /drift is a 200 read from each Mission's own directory with no fallback entry (amended by the operator ruling at WP07, 2026-10-06: an unreachable remote does not end the scan); GET /project has the same network behaviour; NFR-001 and NFR-009 hold with the remotes reachable; AC-REALITY is conditioned on reachable remotes; AC-DRIFT gains an offline row with a real-git fixture whose remote is unreachable (a 200 from the own directory, no fallback entry; operator ruling at WP07, 2026-10-06); the wording "read-only git queries" and "no kitty/* refs" is corrected (FR-007, FR-015, NFR-002, NFR-009, R-9). Purely internal behaviour of the resolver that changes no observable outcome is a stated assumption (R-9), not a rule.
  • Design note for the read service (#5528), out of scope for this slice, no src/ change: the service resolves coordination locally, with local refs and remote-tracking refs only, so that its read never depends on the network.
  • Decisions made under the ruling (fixer round 4): the Project build reaches the resolver through the same memo as the scan (FR-015, one statement); the memo is keyed by (resolved repository root, Mission directory name); the reality job bound is the measured scan time plus headroom, not a separate 180 seconds figure (NFR-001); the fallback-list derivation is an agreement check on this corpus, with one fixture per resolver arm it does not mirror (FR-025, FR-026); NFR-002 is exact per case (an expected lane or none); a missionId lookup uses the v1 identity read (FR-008).

Operator rulings (2026-10-06, plan round; binding; not re-opened)

Question, ruling and rationale are in tracer-design-decisions.md ("Plan-round operator rulings").

  • Plan-round 1 (offline scope of AC-REALITY). Offline skips only the assertions that depend on the coordination resolver, each by a named skip; the Ops walk, the skippedCount equality and the read-only fingerprint need no network and run in every environment. AC-REALITY bullets 2 and 3 and the FR-025 "Corpus outcome" sentence are edited accordingly (add-only record in tracer-approach.md, A-7); an offline run stays no evidence for SC-002 or SC-007.
  • Plan-round 2 (specKittyVersion read). The reader pre-checks the YAML shape (top-level mapping, spec_kitty mapping), calls ProjectMetadata.load only when the shape is safe, and treats a residual AttributeError or TypeError as null; "read as load reads it" stays.
  • Plan-round 3. The D-0 sync, the baseline and the synced environment are an orchestrator step before any dispatch; the IC-01 and IC-02 parallel window stays.
  • Plan-round 4. The corpus-sized cases (AC-DRIFT row 19, AC-CROSS 4) live in test_mission_status_reality.py with a discovered-count guard; the new drift, ops and project modules stay fixture-built only. Offline, the completion equality of row 19 (with its floor) and the real kitty-ops/ listing bound run; the one-Project-build, one-scan and combined timing cases depend on the resolver and take a named skip, counted as skipped (add-only record in tracer-approach.md, A-9).
  • Plan-round 5. The wrap-up keeps the procedure order for dev-assist cleanup and the issue verdict; the terminal verdict for #5776 comes after the squad; only consolidate-before-squad departs.
  • Plan-round 6. Absolute timing bounds (0.1 s, 5 s, 30 s) are timed as the minimum of at least 5 in-test repeats, the control timed the same way, and the planted quadratic mutation must still fail.
  • Plan-round 7 (PD-2). The widened registration file set (packs.yml, the corpus-trigger registry, ci-router.yml) is approved by the operator, not merely requested; SLICE_ALLOWED lists every such file.
  • Plan-round 8. The proxy escalation threshold moves to about 70 s local (the 120 s bound divided by the 1.7 local-to-CI factor); the W-7 pass rule stays the final arbiter.
  • Plan-round 9. The cache-the-result mutation is added to the AC-CROSS 4 row and to the reality module's red-first list, so the real 5 s listing case is covered.
  • Plan-round 10. The combined in-test case and the job gain are different quantities (the completion equality and the listing repeats sit in the job gain only); the W-7 pass rule is the arbiter.

Precedent rulings applied (from mission-status-contract-1-1-01M42XJC, tracer-design-decisions.md)

  • PR-1 No new contract version (2026-10-05): info.version stays 1.0.0-SNAPSHOT, CHANGELOG additions merge into that section, the proof is "additive against the tree on main".
  • PR-2 Enums are snake_case (enum-case rule, 2026-10-04).
  • PR-3 The JVM replay (J-1) runs once the contract shape is final (FR-028).
  • PR-4 A corpus check counts a named refused (skipped) list, derived independently, never an empty skip list (WP06 ruling).
  • PR-5 Redaction uses the linear redact_emails / email_matches, never EMAIL_PATTERN (2026-10-04). Also applied: oracle independence (D-P14), plan-time floors (D-P15), and credentials refused or withheld before redaction, never masked (AD-17, WP05 F1).

Corrections to the issue, verified against this checkout

  • Five Project properties, not four: the screen's four plus schemaVersion; only "The project branch" leaves CHANGELOG "Deferred" (FR-029). An unborn branch is its name, not null (FR-005); the branch pattern belongs to Workspace.laneBranch (FR-005); get_project_version raises on two shapes and returns the sentinel unknown on a third (FR-002); health mirrors check_compatibility only while the two schema constants are equal (FR-004); mission_not_found is a new code of the module's DriftRefusalCode (FR-008); lastActivityAt is also null when no Mission has activity (FR-006).
  • The remedy command. The prototype's spec-kitty materialize --mission <slug> writes only the gitignored .kittify/derived/ views and never repairs kitty-specs/<slug>/status.json; the doctor recommends spec-kitty agent status materialize (check_drift in src/specify_cli/status/doctor.py; option --mission in agent/status.py). materialize_status maps to the latter (AD-4).
  • Branch name and count. No branch name travels (OQ-2); a runtime count cannot sit in a static description, so skippedCount (OQ-6).
  • Evidence statistics. 0 url-kind values in the corpus; the one URL is embedded in free text (FR-020).
  • Dead enum values meta, derived_views and info have no producer and are removed (AD-6).
  • Sources. Every path and symbol the issue cites exists in this checkout.

Author decisions (scrutinised by squad R1-R3; amended by the round-2 rulings)

None contradicts OR-1 to OR-10.

  • AD-1 Severity. Kind 1 takes the audit severity of its sourceCode: SNAPSHOT_DRIFT and CORRUPT_JSON error, the two variants warning (OR-4). snapshot_or_event_log_missing and lane_branch_missing are warning (the doctor wraps the first as Severity.WARNING). DriftSeverity is error, warning only: the audit's info is not carried (ARCH-002).
  • AD-2 One finding per Mission, kind and artifact path, so the ordering key (missionId, kind, artifactPath) is unique. Kind 2 names the missing file. Kind 3 is one finding per Mission with no branch name (OQ-2).
  • AD-3 Population of lane_branch_missing. The literal rule ("lanes.json names a branch git lacks") gives one finding per manifest whose branch is absent (488 current manifests here, 370 of them completed): noise, not drift. The rule expects a branch only where the product would have created one (FR-012): non-completed Missions in any lifecycle state (OQ-1), the coordination branch where meta.json names one, else mission_branch (ARCH-001), local branches only (OQ-3).
  • AD-4 DriftRemedy has one value, materialize_status, which maps to spec-kitty agent status materialize --mission <slug> (the doctor's recommended action in check_drift), not to spec-kitty materialize. remedy is null for: the two warning variants (the audit calls them not actionable and the archive gate forbids repairing a frozen snapshot); a kind-1 error finding on a Mission merged to main (the own directory's meta.json has merged_at; its status.json is archive-frozen); kind 2 when the log is missing (no shipped action restores a log; this diverges on purpose from check_drift, which attaches the action to that case too); kind 3. A kind-2 finding for a missing status.json only adds a file, which is add-only, and keeps the remedy. The client builds the command text; the slug comes from MissionHead.slug of the finding's missionId.
  • AD-5 totalCount counts served Ops matching profile, from the same candidate set as the items (index when present, else directory). skippedCount counts skipped records over that candidate set, independent of profile (OQ-6).
  • AD-6 No dead enum value: DriftAuthority is event_log, git; DriftSide is status_json, lanes_json; DriftSeverity is error, warning.
  • AD-7 Provisional marker on the Ops operation. The legacy handling has no property to carry x-provisional, so listOpsInvocations carries it, and the legacy-record rule is stated in its description, not as its own element; the breaking-change check ignores that operation while the marker stays (R-4). Exit condition: the marker is dropped once the legacy handling and skippedCount are settled, at or before release.
  • AD-8 Evidence class by shape: repo_path is a single-line, whitespace-free value passing the artifact-path rule; existence is never checked.
  • AD-9 Timestamps: a started_at or completed_at that is not an RFC 3339 instant with an offset makes the record unreadable; ordering is by instant; the value is emitted as stored.
  • AD-10 Whole-scan loudness (the line principle). The report is refused 500 drift_scan_unreadable, never partial, when for any scanned Mission: the read directory (FR-015) cannot be resolved or read (every resolver outcome except CoordinationBranchDeleted, the named fallback); status.json cannot be read (OSError); the event log cannot be reduced, which includes an event log that is not valid UTF-8 (the audit's classify_status_json reports a raising reducer as a SNAPSHOT_DRIFT error finding; the contract departs from it because the reducer is the authority, and a finding would put the broken authority on the derived side); meta.json exists but is corrupt, not an object, not valid UTF-8 or unreadable (MissionMetaReadError); lanes.json of a Mission that is not completed is neither current- nor legacy-shaped, which includes a file that is not valid UTF-8 (FR-012; the completion test comes first, so a completed Mission's lanes.json is never read); or the branch listing fails while a Mission has an expected lane. missionId isolates the Mission. Defined outcomes, not failures: a legacy-shaped lanes.json (counted); a status.json that is not valid UTF-8, not JSON or not an object (a finding, OQ-4); a fallback Mission (FR-015).

User Scenarios & Testing (mandatory)

User Story 1 - The Projects screen shows version, health, branch and last activity (Priority: P1)

A desktop app reads GET /project once per recent project; each card shows version, health badge, branch and last activity. The project list and folder picker are host-side.

Why this priority: it is the whole Projects screen.

Independent Test: build Project for a fixture repository per outcome of FR-002 to FR-006 (healthy; schema drift; no metadata; detached HEAD; unborn branch; no Missions), validate each, and run the builder over this repository.

Acceptance Scenarios:

1. Given metadata recording 4.0.0 and schema 3, branch main, two Missions with activity, When read, Then the five properties are 4.0.0, 3, healthy, main and the later activity instant. 2. Given no .kittify/metadata.yaml, When read, Then version and schema are null, health is schema_drift, and the read does not fail. 3. Given a detached HEAD, Then currentBranch is null; given an unborn branch trunk, Then it is trunk.

User Story 2 - The Drift screen lists where derived state disagrees (Priority: P1)

A user opens Drift, sees findings with severity and kind and the lane table of a snapshot that disagrees with its log, and presses Rescan (a second GET /drift).

Why this priority: the screen has no data source.

Independent Test: for a fixture repository with one planted defect per kind and a clean control Mission, GET /drift returns exactly the planted findings in byte order with the fixed summaries and writes nothing.

Acceptance Scenarios:

1. Given status.json showing WP01 planned while the log says approved, When read, Then one snapshot_disagrees_with_event_log finding, error, SNAPSHOT_DRIFT, remedy materialize_status, and a laneComparison row planned / approved. 2. Given status.events.jsonl without status.json, Then one snapshot_or_event_log_missing finding with artifactPath status.json. 3. Given two reads with a change between, Then the second scannedAt is the second read's time and the response carries Cache-Control: no-store; an unknown missionId is 404 mission_not_found.

User Story 3 - The Ops screen pages the Op invocation records (Priority: P1)

A user opens Ops, sees the newest Ops with profile, action, actor, started, completed and evidence, pages back, and filters by profile.

Why this priority: the screen has no data source.

Independent Test: for a fixture kitty-ops/ with open, closed, swept, legacy and unreadable records, the listing walks every served Op once in FR-018 order, the count and skippedCount match, skipped records are absent, and evidence follows FR-020.

Acceptance Scenarios:

1. Given an Op with a started line and a spine closure, Then status is closed, closedBy doctor_sweep, completedAt the closure's time. 2. Given no kitty-ops/, Then 200, items: [], totalCount: 0, skippedCount: 0. 3. Given a cursor issued for profile=a presented without it, Then 400 invalid_page_cursor.

User Story 4 - A consumer pins 1.x and is not broken (Priority: P1)

A consumer adopts the unreleased 1.0.0 additions with no client change for any existing operation except GET /project, whose response gains five properties.

Why this priority: the Project change is breaking the day the tag exists; the proof must say why it is allowed now.

Independent Test: the observables of FR-022 against a committed ancestor of origin/main: existing files other than the three Project files byte-identical; the lowered-major run reports exactly the non-provisional Project additions; the same-major run fails on them.

User Story 5 - The contract is proven against this repository's own Missions and Ops (Priority: P1)

The reality check builds Project, the project-wide and per-Mission DriftReport and the whole Ops listing from this repository and validates them with floors, controls and the leak scan.

Independent Test: FR-025 and AC-REALITY.

User Story 6 - A maintainer sees what is deferred, dropped and provisional (Priority: P2)

The CHANGELOG states the three remaining deferred gaps, the provisional elements by name, that derived_view_stale was not shipped, with the reason, and that info is not carried.

Independent Test: the CHANGELOG checks of FR-023 and FR-029 pass on the real entry and fail on planted entries that each break one rule.

Edge Cases

  • Project. Metadata absent, empty, a list, spec_kitty not a mapping, version missing, numeric or text-shaped, schema_version a float, boolean or numeric string; a non-git directory; a linked worktree; a branch name failing the pattern or matching a strict host-path pattern (null).
  • Missions. No Missions, or all with null activity: lastActivityAt null. Offsets differing: the maximum is by instant.
  • Drift. Neither status file (2 of 569 directories here): no finding. One of the two (31 here, all "log without snapshot"): a finding. A status.json not valid UTF-8, not JSON or not an object: CORRUPT_JSON. Provenance-only and all-done differences: the warning variants. No lanes.json (80 of 569) or a legacy-shaped one: no kind-3 expectation. A coordination-topology Mission reads status in its coordination surface and expects its coordination branch, never mission_branch; one whose declared coordination branch is gone is read from its own directory (the named fallback, 37 here). A completed Mission's lanes.json is not read; completion reads the merge marker in the Mission's own directory. A snapshot recorded at an older generation replays at that generation. Invalid UTF-8: status.json is CORRUPT_JSON; lanes.json of a non-completed Mission, meta.json and the event log are 500. A work package on one side only: the other side null. A genesis or uninitialized lane: null.
  • Ops. Empty file; non-Op file name; stem differing from invocation_id; first line not JSON or a completed event; legacy started or completed line (7 completions here, none with a spine closure); a closure with no Op file (ignored); several closures for one id (the first serves); own completion and spine closure both (the own file serves); a legacy own completion with a spine closure (served closed from the spine); a file that cannot be opened, or invalid UTF-8 in the spine (500); invalid UTF-8 in an Op file (skipped); a file gone after listing and an index entry without a file (both skipped and counted, FR-019); an Op absent from a present index (not listed). Equal startedAt: ties by invocationId descending. mission_id and wp_id are absent on all 480 real Ops.

Reflexivity: Missions in flight

kitty-specs/ on main is archive-frozen: record commits are add-only and no existing file is modified. This Mission's own directory joins the corpus once merged; while in flight it holds status.events.jsonl and no status.json, so it is itself a snapshot_or_event_log_missing finding until its status is bootstrapped. No check asserts "zero findings" or an exact count, only floors and equality with an independent oracle (AC-REALITY). kitty-ops/ grows with every Op of this Mission, so Op counts are floors. The reference reader must produce a defined outcome over this checkout, and over a fixture for each, for: no kitty-ops/ (empty page), no .kittify/metadata.yaml (null version and schema, schema_drift), a detached HEAD (every pull request CI checkout is detached), an unborn branch (its name), a Mission with only one status file (a finding) and legacy pre-v2 Op lines (skipped).

Requirements (mandatory)

Functional Requirements

IDTitleUser StoryPriorityStatusDeliveryNo-op passable?
FR-001Project gains five propertiesAs a desktop user, I want a project card with version, health, branch and activity so that I can judge a project unopened.HighOpen[build]no
FR-002specKittyVersionAs a desktop user, I want the version a project recorded so that I see which Spec Kitty made it.HighOpen[build]no
FR-003schemaVersionAs a desktop user, I want the recorded schema so that I know why a project is unhealthy.HighOpen[build]no
FR-004healthAs a desktop user, I want a two-value badge so that the card stays stable.HighOpen[build]no
FR-005currentBranchAs a desktop user, I want the current branch so that I know where work lands.HighOpen[build]no
FR-006lastActivityAtAs a desktop user, I want the last activity so that I can sort by recency.HighOpen[build]no
FR-007/project is read-only, side-effect-free and independent of driftAs a host developer, I want one read per project that starts no drift scan, no process of its own and no write so that listing projects is safe.MediumOpen[build]no
FR-008Drift operationAs a user, I want GET /drift so that the Drift screen has data.HighOpen[build]no
FR-009Drift schemas, order and capAs a user, I want a stable bounded list so that Rescan is comparable.HighOpen[build]no
FR-010Kind snapshot_disagrees_with_event_logAs a user, I want a disagreeing snapshot shown so that I can repair it.HighOpen[build]no
FR-011Kind snapshot_or_event_log_missingAs a user, I want a Mission lacking a status file shown so that I notice damage.HighOpen[build]no
FR-012Kind lane_branch_missingAs a user, I want a started Mission with a lost lane branch shown so that I can recover work.MediumOpen[build]no
FR-013derived_view_stale decision procedureAs a maintainer, I want the fourth kind decided by evidence so that no kind ships without a rule and a real example.HighOpen[build]no
FR-014Fixed summaries, remedy, sourceCode, leak surfaceAs a user, I want findings that cannot leak a host path so that the report is shareable.HighOpen[build]no
FR-015Scan time, read directory, no-store, read-only, failure semanticsAs a user, I want Rescan to be a real read that changes nothing so that the report is trustworthy.HighOpen[build]no
FR-016Ops operation, paging and filterAs a user, I want GET /ops/invocations so that the Ops screen has data.HighOpen[build]no
FR-017OpsInvocation and closure semanticsAs a user, I want a swept Op shown closed so that the screen agrees with the record.HighOpen[build]no
FR-018Order, cursor, totalCount, candidate set, empty pageAs a user, I want stable newest-first paging and an honest total so that paging never skips or repeats.HighOpen[build]no
FR-019Legacy and unreadable records, skippedCountAs a user, I want archive records skipped by a stated, counted rule so that the list does not fail and nothing vanishes silently.MediumOpen[build]no
FR-020Evidence classification, redaction and withholdingAs a user, I want evidence free of host paths, addresses and credentials so that the screen is shareable.HighOpen[build]no
FR-021Fields left out on purposeAs a maintainer, I want the omitted fields named so that projecting one later is visible.MediumOpen[build]no
FR-022Version unchanged and the additive proofAs a consumer, I want the proof of what changes for me so that I can pin the contract.HighOpen[build]no
FR-023Citations, examples, closed schemas, enums, Problem responses, provisional listAs a maintainer, I want every v1 check to pass so that the contract stays verifiable.HighOpen[build]no
FR-024Loud failure catalogueAs a consumer, I want every read to say what it does when it cannot work so that no silent default exists.HighOpen[build]no
FR-025Reality-check extension and reference readerAs a maintainer, I want the reads projected over this repository so that a contract that misfits real data fails early.HighOpen[build]no
FR-026Synthetic-repository testsAs a maintainer, I want fixtures for states the corpus lacks so that every rule has a control.HighOpen[build]no
FR-027Planted negatives, leak scan, registrationsAs a maintainer, I want a violating input per rule so that a removed rule turns a test red.HighOpen[build]no
FR-028JVM replay J-1As a maintainer, I want the JVM jobs re-run over the final shapes so that lint and client accept them.HighOpen[build]yes, paired with the recorded-command checks of AC-VERSION (cited commit, job set) and the squad review of the conclusions
FR-029The pinned deferral test follows OR-1As a maintainer, I want the three remaining deferred gaps pinned so that the moved line is not silently dropped.MediumOpen[ratchet]yes, paired with the planted entry lacking a remaining gap on the same entry text

Requirement details

FR-001 Project gains five properties

Project keeps name and missionCount and gains, all required: specKittyVersion (string, nullable), schemaVersion (integer, nullable), health (ProjectHealth: healthy, schema_drift; titled snake_case enum), currentBranch (string, nullable) and lastActivityAt (date-time, nullable). The schema stays closed. Required-and-nullable is how the contract says "determined or not"; an absent key is never a valid answer. schemas/Project.yaml rewrites its description (today "It carries a name and a count only", false after this slice) and its required list; paths/project.yaml changes its description only. A committed test asserts the new Project description names the five properties and no longer says "a name and a count only". The updated Project.example.yaml and two more examples (all-null; schema_drift) validate.

FR-002 specKittyVersion

Source: .kittify/metadata.yaml key spec_kitty.version, read as ProjectMetadata.load reads it (src/specify_cli/upgrade/metadata.py; CLI wrapper get_project_version, src/specify_cli/core/version_checker.py). The service never calls the CLI. A strict field (projected or null, never redacted):

InputOutput
file absent, empty or not YAMLnull
top level not a mapping, or spec_kitty not a mapping (the product raises AttributeError)null; the read does not raise
no spec_kitty.version (the product returns the sentinel unknown)null; the sentinel never leaves the service
version not a string (the product returns a number as is)null
string not matching ^[0-9A-Za-z][0-9A-Za-z.+!_-]{0,63}$null
a matching string that holds a SECRET_PATTERNS credential (a token of any kind the leak patterns name)null (the Project promise "never carries a credential"; as currentBranch, FR-005)
any other matching stringthat string
FR-003 schemaVersion

spec_kitty.schema_version exactly as get_project_schema_version (src/specify_cli/migration/schema_version.py) reads it; null when that function returns None (file absent or unreadable, field absent, not an integer after coercion). The reference reader calls that public function, so its coercion is the contract's: a numeric string "3" gives 3, a float 3.9 gives 3 and a boolean true gives 1 (int(raw)); no divergence from the CLI is introduced. The schemaVersion description says the value is the CLI's coercion, so a float or boolean in the file is served as an integer.

FR-004 health (provisional)

healthy exactly when schemaVersion is not null and MIN_SUPPORTED_SCHEMA <= schemaVersion <= MAX_SUPPORTED_SCHEMA (src/specify_cli/migration/schema_version.py, 3 and 3 today); otherwise schema_drift. The range is that of the serving build: the same project reads healthy or schema_drift depending on which build serves it, and the health description and the CHANGELOG say so. It rolls up the three non-compatible CompatibilityStatus values (unmigrated, outdated, cli_outdated); the finer split stays in schemaVersion. check_compatibility compares the schema with REQUIRED_SCHEMA_VERSION (equal to MIN_SUPPORTED_SCHEMA) by less-than and greater-than, so the two agree only while MIN and MAX are equal: a differential test over {null, MIN-1, MIN, MAX, MAX+1}, passing REQUIRED_SCHEMA_VERSION, pins agreement now; a test with the constants patched apart shows the range rule. x-derived; x-provisional.

FR-005 currentBranch

The service reads the project's HEAD file (.git/HEAD; when .git is a file naming a gitdir, that gitdir's HEAD) and starts no subprocess. ref: refs/heads/<name> gives <name>, with or without a commit (an unborn branch is its name, correcting the issue). Null only for: a detached HEAD; no .git; an unreadable or unparseable HEAD; a name failing ^[A-Za-z0-9][A-Za-z0-9._/-]*$ or over 255 characters (the Workspace.laneBranch rule); a name matching the strict host-path patterns of contracts/tools/leak_patterns.py (for example home/user1/x: the name satisfies the pattern but is a host path in a strict field). The description says null therefore means "no branch name this contract can carry" and does not separate those cases, and that a served name is user-authored text served as stored (it can carry a handle, as Workspace.laneBranch can). A parity test against get_current_branch (src/specify_cli/core/git_ops.py) on attached, unborn, detached and linked fixtures holds wherever the name is served.

FR-006 lastActivityAt

The latest MissionHead.lastActivityAt over exactly the Missions GET /missions lists, compared as instants (different offsets are ordered by instant, not text), emitted as that Mission's own string. MissionHead is built through the coordination resolver, as in GET /missions (v1's rules and fallbacks; FR-007 states the cost). Null when no Mission is listed and when every listed Mission has null activity; the description says both. A Mission whose status.json cannot be read (OSError, invalid UTF-8, not YAML or JSON) still counts and adds no activity, because the event log decides the activity and the derived file is only read for its schema generation (operator ruling after the pre-merge squad, 2026-10-07). An event log that cannot be read, decoded or parsed fails the whole Project read as a 500 under the default Problem, as it fails GET /missions and GET /drift (FR-024), and the read never serves an older Mission's instant for it. x-derived.

FR-007 /project is read-only, side-effect-free and independent of drift

GET /project runs no drift scan, starts no process of its own, writes nothing, and reads only .kittify/config.yaml, .kittify/metadata.yaml, HEAD and what GET /missions already reads. health never depends on a finding (OR-8). Per OR-10 (amended) the contract promises read-only, side-effect-free and safe to call once per recent project, and states the cost: lastActivityAt and missionCount need every listed Mission, so the read resolves each Mission's read directory through the coordination resolver (MissionStatus.load, which starts git queries per Mission: 784 subprocesses over this checkout, among them git ls-remote --heads probes that reach the network for a Mission whose declared coordination branch is absent locally, FR-015) and reduces its event log, and the cost grows with the Mission count and, for those Missions, with the network. Measured with the resolver included and the remotes reachable over this checkout, 2026-10-05: 44.4 seconds for 569 directories (tracer; an earlier figure of 1.5 seconds omitted the resolver). The Project description says so; NFR-009 bounds it with the remotes reachable. Test (FRESH2-002): the reference reader's own code starts no subprocess and writes nothing: a stub replaces subprocess.run and raises unless the resolver call of the memo helper (FR-015) is on the stack, so the resolver's git calls (its ls-remote probes included) are exempt explicitly and counted, every writing function raises, and the new Project builder, which reaches the resolver only through that memo helper (FR-015), still yields a valid Project.

FR-008 Drift operation

paths/drift.yaml, getDriftReport, GET /drift, tag Drift, optional query parameter missionId (a new query parameter file over the MissionId schema; the existing MissionId parameter is a path parameter). Responses: 200 DriftReport; 404 for a ULID naming no Mission (code mission_not_found) and 500 code drift_scan_unreadable, both DriftRefusal; default the shared Problem. A non-ULID missionId is refused 400 under default, as every malformed query parameter of v1. DriftRefusal is a module refusal schema, a Problem with its code narrowed (the ArtifactRefusal pattern: an if/then per code pins the status), whose code references the titled enum DriftRefusalCode, exactly mission_not_found (404) and drift_scan_unreadable (500); OpsRefusalCode is exactly ops_unreadable (500, FR-016). No value of either enum lacks a producer (AD-6). The 200 documents Cache-Control: no-store.

FR-009 Drift schemas, order and cap

DriftReport (closed): scannedAt (date-time), findings (DriftFinding, at most 1000), truncated. DriftFinding (closed): kind (DriftKind), severity (DriftSeverity: error, warning), missionId (MissionId), artifactPath (the artifact-path rule), summary, authority (DriftAuthority: event_log, git), derivedSide (DriftSide: status_json, lanes_json), laneComparison (LaneComparison, [] unless kind 1 with a lane difference), remedy (DriftRemedy, nullable), sourceCode (nullable). LaneComparison (closed): wpId, persistedStatusLane, derivedStatusLane (StatusLane, nullable). All required. missionId and artifactPath are non-null (FRESH-014): every shipped kind is Mission-scoped and names a file, so no null branch exists; a project-wide kind would be a later, breaking change. Order: (missionId, kind, artifactPath) in UTF-8 byte order; by AD-2 the key is unique, so the order is total. More than 1000 findings: exactly the first 1000, truncated true; exactly 1000: truncated false (OR-7, no paging). The CHANGELOG states that info of the audit Severity is not carried (ARCH-002) and that missionId and artifactPath are non-null.

FR-010 Kind snapshot_disagrees_with_event_log

Population: a Mission whose read directory (FR-015) holds both status.json and status.events.jsonl. Rule: status.json and materialize_snapshot(<read directory>) (never materialize; it replays at the generation recorded in the existing status.json, so a snapshot that lacks a field introduced after its own generation is by design not a disagreement), normalised and compared exactly as classify_status_json (src/specify_cli/audit/classifiers/status_json.py) does, by the reference reader's own comparison over materialize_snapshot and materialize_to_json. When they differ, one finding: SNAPSHOT_DRIFT, error, unless the difference is provenance-only (actor, last_event_id, last_transition_at per work package: SNAPSHOT_DRIFT_PROVENANCE, warning) or every work package of the reducer output is done (SNAPSHOT_DRIFT_TERMINAL, warning), tested in that order (OR-4). A status.json that is not valid UTF-8, not JSON, or JSON but not an object, is one finding: CORRUPT_JSON, error, laneComparison [] (OQ-4; the classifier's own code and severity; the classifier catches only OSError and JSONDecodeError, so it raises UnicodeDecodeError for undecodable bytes, which the reader maps to this finding and the oracle's wrapper maps identically). artifactPath status.json; authority event_log; derivedSide status_json; remedy materialize_status for SNAPSHOT_DRIFT and CORRUPT_JSON, null for the warnings, and null for either error on a Mission merged to main (AD-4). laneComparison (OR-3): one row per work package of the union whose lane differs, by wpId byte order; a work package on one side only has the other side null; a lane outside the nine display lanes (genesis, uninitialized) reads null (the v1 rule); [] when the files differ only outside lane. Stays a 500 (AD-10, FR-015): an OSError reading status.json, and a reducer that raises (the audit maps that case to a SNAPSHOT_DRIFT error finding; the contract does not, because the reducer is the authority). The finding says the snapshot disagrees with materialize_snapshot's output at the snapshot's own generation, not with the true event log (R-8); the DriftFinding description says so.

FR-011 Kind snapshot_or_event_log_missing

Population: exactly one of status.json and status.events.jsonl exists in the read directory (FR-015; the outcome of _validate_materialization_files, src/specify_cli/status/validate.py). Neither exists: no finding (nothing to compare; 2 of 569 directories here), and the description says so. One finding: artifactPath the missing file; warning (AD-1); authority event_log; derivedSide status_json; laneComparison []; sourceCode null (the audit has no code); remedy per AD-4 (materialize_status when the log exists, null when the log is missing).

FR-012 Kind lane_branch_missing (provisional; OR-2, AD-3)

The nearest shipped check covers the coordination branch only (COORDINATION_WORKTREE_NEVER_CREATED, src/specify_cli/cli/commands/_coordination_doctor.py); this rule is new and x-derived, with inputs LanesManifest (src/specify_cli/lanes/models.py), code_lane_branch_name (src/specify_cli/lanes/branch_naming.py), the completion test (the two arms of is_mission_completed, src/specify_cli/status/lifecycle.py, below), the reducer lanes, and the local-ref test of branch_exists (src/specify_cli/lanes/_git.py), which the reader applies to one listing, not per branch.

Population (OQ-1). Every Mission that the completion test reports not completed, in any lifecycle state (abandoned, active, stale, recoverable, reopened), that has a lanes.json. Order (FRESH-007): the completion test comes first; lanes.json is opened only for a Mission it reports not completed, so a defect in a completed Mission's frozen manifest can neither fail the scan nor be counted. Completion test (round-3 verification of FRESH-001). is_mission_completed reads meta.json and the event log from the one directory it is given. Called on the read directory of an unmerged coordination Mission it would read the merge marker from the coordination surface's copy of meta.json, which can disagree with the own directory's. The reader therefore applies the two arms of that function itself: a Mission is completed when its own directory's meta.json (read with load_meta_fail_closed) holds a merged_at instant, or when derive_mission_lifecycle of the read directory is recently_completed or archived. For a Mission whose read directory is its own directory the result equals is_mission_completed(<own directory>), and a test asserts that equality over every such Mission of the corpus. A merge stamp that exists only in the coordination copy is not a completion for this scan.

For a Mission that is not completed the reader reads lanes.json as raw JSON first and classifies it three ways (GOV-007, ARCH-008):

  • current shape: a JSON object with mission_slug (read with read_lanes_json/LanesManifest.from_dict); evaluated;
  • legacy shape: a JSON object with feature_slug and no mission_slug (1 of 489 here, 064-complete-mission-identity-cutover, a Mission that is not completed): not evaluated for kind 3, no finding, counted on the named list (FR-025); kinds 1 and 2 are still evaluated for that Mission. The DriftKind description states: "a manifest predating mission_slug is not evaluated for this kind";
  • anything else (not valid UTF-8, not JSON, not an object, neither key, a current-shaped object that read_lanes_json refuses with CorruptLanesError): 500 drift_scan_unreadable (AD-10).

Why drift differs from v1: the v1 detail read answers 500 for the legacy manifest (a known defect of the legacy lanes manifest reader, which refuses that shape), which refuses one Mission's detail; the same 500 here would make the project-wide GET /drift fail permanently on this repository, whose frozen kitty-specs/ can never repair the file.

Rule for a Mission with a current-shaped manifest:

1. A code lane is a lane other than lane-planning (a planning lane has no branch of its own). 2. It is expected when one of its work packages is, in the reducer output, claimed, in_progress, for_review, in_review or approved: the product creates a lane branch when the lane is first allocated (allocate_lane_worktree), so an all-planned lane has none yet. 3. Expected branches: code_lane_branch_name(mission_slug, lane_id) per expected lane, and, when any lane is expected, the Mission-level branch the product created for that topology (ARCH-001): the coordination_branch that meta.json names when it names one, else the manifest's mission_branch. Never both: for a coordination-topology Mission the allocator creates the coordination branch and no mission_branch. meta.json is read with load_meta_fail_closed semantics (AD-10); an absent meta.json names no coordination branch. 4. The branch source is at most one git for-each-ref refs/heads listing, run once when the first Mission with an expected lane needs it (COMPLETE-001; zero listings when no Mission has an expected lane; NFR-002 counts it). A non-zero exit, a missing git binary or a directory that is not a repository is 500 drift_scan_unreadable (never "every branch is absent"); a scan in which no Mission has an expected lane never needs the listing and is not failed by it. 5. One finding when any expected branch is not in the listing (local branches only, OQ-3: a branch present only as refs/remotes/<remote>/<name> is absent; the description says "no local branch"): artifactPath lanes.json; warning; authority git; derivedSide lanes_json; laneComparison []; sourceCode and remedy null. No branch name appears (OQ-2).

No lanes.json, a legacy-shaped one, or no expected lane: no finding (the reality check counts the legacy shape). Evidence, re-measure required: on 2026-10-05, local branches, before the ARCH-001 and COMPLETE-001 fixes: of 488 current manifests, 370 Missions are completed (excluded) and 118 are not; 93 of those have an expected lane, all with a missing branch; the 118 are 89 abandoned, 22 active, 4 stale, 3 recoverable. These kind-3 counts include the coordination false positive that ARCH-001 removes; the plan's first task re-measures them under the final rule and records the result, and no plan-time floor or assertion rests on the old 93. The count depends on the clone, which is why the kind is provisional. The rule is honest when every finding names a Mission an independent oracle also flags and every control passes, not when the count is small.

FR-013 derived_view_stale: decision procedure and outcome (OR-2)

A kind ships only when both gates hold (the OR-2 requirement "a rule and an example from the reality check"); they are evaluated on this checkout.

  • Gate A, honest rule: a deterministic function of (event log, derived file), independent of clock, host and file times. Candidate: compare .kittify/derived/<slug>/status.json and board-summary.json with the reducer output, ignoring materialized_at and the fields validate_materialization_drift ignores. progress.json and lifecycle.json cannot be compared (derive_mission_lifecycle reads the current time); file modification time is rejected (a checkout resets it). Gate A holds for the two comparable views.
  • Gate B, a real example in the reality check: at least one .kittify/derived/<slug>/ directory in the corpus. None exists: .kittify/derived/ is absent here, ignored by .gitignore (git check-ignore confirms), untracked (git ls-files .kittify/derived is empty), and written only on an operator machine by spec-kitty materialize and the status refresh hook (write_derived_views in src/specify_cli/status/views.py, called by src/specify_cli/cli/commands/materialize.py). A fresh clone, so every CI run, has none. Gate B fails.
  • Supporting evidence: validate_derived_views is a permanent no-op returning an empty list and check_drift says derived-view drift is no longer checked (src/specify_cli/status/validate.py, src/specify_cli/status/doctor.py); the product retired the check.
  • Outcome: not shipped. The drop rests on the requirement that a kind has both a rule and a real example (Gate B fails), not on the absence of a rule (Gate A holds). DriftKind has three values; derived_views is not a DriftSide; the CHANGELOG records that the kind was considered and not shipped, and why (FR-023). The scan never reads .kittify/derived/. The prototype's stale-derived-views panel therefore has no contract read; the host decides how it renders.
  • Guards: a committed test pins the DriftKind set to the three; a fixture with a stale derived directory and a clean Mission yields no finding, while the same fixture with a status.json mismatch yields the kind-1 finding. The plan's first task re-measures Gate B and the no-op; if either changed, the decision returns to the squad.
FR-014 Fixed summaries, remedy, sourceCode, leak surface

summary is contract-owned text, one fixed sentence per (kind, sourceCode) pair, written in the schema description and compared literally by the reference reader; no source prose, exception text, doctor message, branch name or path is forwarded (doctor messages embed host paths; format_exception_detail embeds exception text). The pair set is closed:

kindsourceCodefixed summary
snapshot_disagrees_with_event_logSNAPSHOT_DRIFTThe status snapshot disagrees with the event log.
snapshot_disagrees_with_event_logSNAPSHOT_DRIFT_PROVENANCEThe status snapshot differs from the event log only in provenance fields.
snapshot_disagrees_with_event_logSNAPSHOT_DRIFT_TERMINALThe status snapshot differs from the event log and every work package is done.
snapshot_disagrees_with_event_logCORRUPT_JSONThe status snapshot is not a valid JSON object.
snapshot_or_event_log_missingnullOne of the status snapshot and the event log is missing.
lane_branch_missingnullAn expected lane or Mission branch has no local branch.

A pair outside the table is a reference-reader failure, with a planted case. sourceCode is the audit code or null, matching ^[A-Z][A-Z0-9_]*$. remedy is the provisional closed enum DriftRemedy with the single value materialize_status (AD-4); no command string travels. The DriftRemedy description gives the mapping once: materialize_status is spec-kitty agent status materialize --mission <slug>, the doctor's recommended action; the client builds the text, takes the slug from MissionHead.slug of the finding's missionId (GET /missions, or the Mission's detail read), and it is not spec-kitty materialize, which writes only derived views. artifactPath always satisfies the artifact-path rule (the MissionFinding.artifact_path invariant). No property is named like a path on the forbidden-property-name list and no worktree location appears. Drift forwards no source text, so it has no redacted flag. The DriftFinding description qualifies the kind-1 summaries: the disagreement is with the reducer replay at the snapshot's own generation (R-8). OR-9: no badge code namespace.

FR-015 Scan time, read directory, no-store, read-only, failure semantics

Read directory (FRESH-001, amended by FRESH2-001, round 4). Kinds 1 to 3 resolve each Mission's read directory through MissionStatus.load (src/specify_cli/status/aggregate.py), the coordination-aware resolver of the v1 reads: status.json, status.events.jsonl, the reducer lanes and the all-terminal arm of the completion test use the directory it returns, which for an unmerged coordination-topology Mission is the coordination surface, not the possibly stale or absent primary copy (the resolver re-anchors a merged Mission on its own directory). meta.json, lanes.json and the merge marker of the completion test are read in the Mission's own directory (FR-012). One outcome is a named fallback: CoordinationBranchDeleted (the declared coordination branch is gone and no coordination worktree exists; 37 of 569 Missions on this checkout, which has no local kitty/* branches, with its remotes reachable; the count depends on the network, see Network behaviour below). The scan reads that Mission's own directory, as v1 does, and the Mission is a fallback Mission with the reason coordination_branch_deleted. Every other outcome that is not a directory is 500 drift_scan_unreadable, no partial report: CoordAuthorityUnavailable (coordination worktree missing), MissionMetadataUnavailable (meta.json corrupt, AD-10), InvalidMissionSlug (directory name not a safe path segment), WorktreeRegistryUnavailable (git worktree registry unreadable), and a read directory outside the repository root. v1 also falls back for the first two and for the outside-root case; a drift scan does not, because a fallback there would compare stale files and report them as findings or as none. "The read directory" of AD-10, FR-010 to FR-012 and FR-024 is this definition.

Not the v1 helper (FRESH2-004). The test-tree helper resolve_read_dir (tests/contract/_mission_status_payloads.py) catches CoordinationBranchDeleted, CoordAuthorityUnavailable and MissionMetadataUnavailable and returns the own directory with a reason, and it falls back for a read directory outside the checkout: three of those outcomes are 500s here. The reference reader must not reuse it for them. It adds a strict sibling in the new reader module (the name is the plan's call) that catches CoordinationBranchDeleted only and returns the own directory with the reason coordination_branch_deleted, lets every other resolver exception propagate and raises for a read directory outside the root; for CoordinationBranchDeleted alone it behaves as v1's helper. v1's helper stays untouched (a ratchet file) and is the control of the plant (AC-DRIFT). GET /missions keeps v1's helper and its fallbacks (FR-006); the new Project builder keeps all four of v1's fallbacks too (the three exceptions and a read directory outside the checkout), applied to the raw resolver outcome the memo returns (Memo, below).

Where the reason is visible. Within the existing conventions, with no new source and no contract property: v1 reports the reason of a coordination fallback as a reader result field and a reality-check report line, never in a schema. The reference reader's result carries the fallback Missions and the reason, a closed vocabulary of one value (coordination_branch_deleted); the reality check reports drift coordination fallbacks (environment-dependent, never floored): <count> <Mission names> as v1 reports its own; the DriftReport description states the rule in words (a Mission whose declared coordination branch no longer exists is scanned from its own directory, which can lag the coordination surface). The service (#5528) may surface the reason; the contract does not.

Network behaviour (FRESH3-001, round 5; facts of the stock resolver, src/specify_cli/coordination/surface_resolver.py _coord_branch_exists, src/specify_cli/git/remote_probes.py remote_branch_lookup). The resolver judges a declared coordination branch present when it is a local head, or a remote-tracking ref refs/remotes/<remote>/<name>, or listed by git ls-remote --heads on any configured remote (git remote and each ls-remote have a 5 second timeout, prompting is disabled, and the probe is memoised per process by repository root and branch). A hit or an error counts as present; only a clean miss on every remote, or no remote at all, makes the branch deleted. Observable consequences, all stated here and in the DriftReport and Project descriptions: (1) the fallback Missions, and the 200 of a project-wide GET /drift, depend on the network and on remote refs, not only on the checked-out tree; (2) with a remote unreachable, timed out or erroring, a Mission whose declared branch is absent locally is judged present and has no coordination worktree, so MissionStatus.load keeps the Mission's own directory: GET /drift is a 200 read from the own directory with no fallback entry (CoordAuthorityUnavailable fires only when a coordination worktree root exists without the Mission directory); the Project build also reads the own directory, but still pays up to 5 seconds per remote per such Mission (amended by the operator ruling at WP07, 2026-10-06: the first text said 500, which the WP07 review disproved on a real repository); (3) a branch that exists only on a remote or only as a remote-tracking ref is judged present in the same way and has no fallback entry either; (4) GET /project has the same network behaviour, so "safe to call once per recent project" means side-effect-free, not network-free; (5) NFR-001 and NFR-009 are bounds with the remotes reachable and give no offline figure; a scan that meets an erroring remote reads that Mission from its own directory, slower but not failed. These are facts of the stock resolver, not rules this slice adds; its other internals are assumptions (R-9).

Design note for the read service (#5528; out of scope, no src/ change). A service that resolves coordination for a read should do so locally, from local refs and remote-tracking refs only, so that its read never depends on the network. That seam is a separate decision.

Memo (FRESH2-003, FRESH2-002/FRESH3-003, FRESH3-004). The resolver runs once per Mission per run of this slice's new reader code: a small helper module that is neither the reader nor an oracle holds an object memo, created per run (one reality run, or one fixture build) and keyed by (resolved repository root, Mission directory name), so fixture repositories that reuse a Mission name, and a control followed by a plant on one Mission, never share an entry. It records the raw resolver outcome (the directory or the exception) and the number of subprocesses the call started. The new reader exposes it and has two consumers of the same entry: the strict drift sibling (below) maps CoordinationBranchDeleted to the own directory and lets the rest propagate; the new Project builder, a function of the new reader module that calls the memo helper and never load_source of v1, reproduces all four fallbacks of v1's helper exactly as v1 does: the three exceptions and a read directory outside the checkout map to the own directory (FRESH4-003); the outside-root case is part of the equality control with v1's build in AC-PROJECT. v1's resolve_read_dir, load_source and the v1 reality test are not edited; their own pass over the corpus stays a separate pass, and they serve as the control (the new Project builder equals the v1 build on the fixtures). The memo's users are the project-wide scan, every per-Mission report, the Project build and the oracles of FR-025. A test builds two repositories with the same Mission name and different resolver outcomes and asserts each gets its own result. A service holds the same property by resolving once per scan.

scannedAt is the service clock at this scan (now_utc, src/kernel/clock.py); a caching service must still set it to the real scan time, and the contract has no cached form. The 200 carries Cache-Control: no-store. Per Mission the scan opens read-only status.json and status.events.jsonl in the read directory, meta.json in the Mission's own directory and lanes.json there only for a Mission that is not completed (FR-012), plus at most one git for-each-ref refs/heads listing for the whole scan, made only when a Mission has an expected lane (FR-012) and the resolver's git queries (the ls-remote probes included, counted, NFR-002); it opens no artifact, derived view or work package file, never calls materialize (the reference reader's test makes it raise) and creates no file, branch or directory. Rescan is the client calling again; there is no POST, scan action or event on GET /events. A missionId naming no Mission is 404 mission_not_found. The lookup maps the ULID to a Mission directory with the v1 identity read (load_meta(own, on_malformed="raise") and resolve_mission_identity, the pair that supplies MissionHead.missionId, tests/contract/_mission_status_payloads.py load_source); a directory whose identity cannot be read does not match any ULID and, by AD-10 (missionId isolates the Mission), neither fails nor 404s the report of another Mission (plant: unreadable meta.json in Mission B, ?missionId= of Mission A is a 200; an unknown ULID is a 404 when every identity is readable). The whole report is refused 500 drift_scan_unreadable on exactly the AD-10 conditions, never partially. findings: [] with truncated: false is a success meaning "the scan ran and found none".

FR-016 Ops operation, paging and filter

paths/ops_invocations.yaml, listOpsInvocations, GET /ops/invocations, tag Ops, x-provisional (AD-7); parameters the shared PageSize and PageCursor and a new optional query parameter profile (the pattern of profileId). Responses: 200 OpsInvocationPage; 400 the existing PageCursorRefused; 500 code ops_unreadable (module refusal schema OpsRefusal, titled enum OpsRefusalCode, status pinned); default the shared Problem. A profile violating its pattern is 400 under default; one matching no Op is an empty page (200, totalCount 0). The description states the skipped-record rule (FR-019), that totalCount can lag the newest records (OR-6), and that the operation never starts an agent or writes.

FR-017 OpsInvocation schema and closure semantics

Closed, all members required: invocationId (ULID ^[0-9A-HJKMNP-TV-Z]{26}$), profileId and action (strict, ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$), actor (ActorHandle, nullable: null on a shape failure or when the stored value holds a credential), modeOfWork (OpsModeOfWork: task_execution, advisory, mission_step, query), startedAt (date-time), missionId (nullable), wpId (nullable), status (OpsInvocationStatus: open, closed), outcome (OpsOutcome: done, failed, abandoned; nullable), closedBy (OpsClosedBy: agent, doctor_sweep; nullable), completedAt (nullable), evidence (FR-020; nullable: "no evidence, or withheld because it holds a credential"). Closure (OR-5): closed when the Op's own file holds a valid completed event, else when the spine holds a record for its id (closed_invocation_ids); the first own-file completed line serves, else the first spine record in file order; the own file wins when both exist; an own completed line that is legacy is ignored when the spine holds a record for the id, and the Op is served closed from that record (OR-5: the contract does not copy the CLI gap, _apply_completion_status), while a legacy own completion with no spine record skips the Op (FR-019); a spine record for an id with no served Op is ignored. The record that would close an Op (its own first completed line, else the first spine record) is judged before it serves: when its completed_at is not an RFC 3339 instant with an offset, the Op is skipped and counted (FR-019), whichever source the record is from; a bad spine record is never ignored in favour of serving the Op open, and the read does not fall through to the other source (operator ruling at WP08, 2026-10-07). Invariants (checked by the reference reader's validator): open implies outcome, closedBy, completedAt, evidence null; closed implies the first three non-null. A mission_id or wp_id failing its pattern makes the record unreadable (FR-019), not null. A profileId or action whose stored value matches a SECRET_PATTERNS kind also makes the record unreadable (a field defect, FR-019), checked on the stored value. An actor that matches a SECRET_PATTERNS kind is served as actor: null: the Op stays in items and in totalCount, skippedCount is unchanged, and the token is never in the payload (operator ruling after the pre-merge squad, 2026-10-07, which corrects the WP08 ruling: actor is required but nullable in the schema, ActorHandle being [string, null]). Why actor alone degrades: ActorHandle is attribution, whose own rule projects a malformed handle to null rather than forwarding it, while profileId, action, missionId and wpId identify what the record is about and a malformed one makes the record unreliable. Here the CLI shows 6 Ops open that the contract serves closed (each has a spine closure); none of the 7 legacy completions has one.

FR-018 Order, cursor, totalCount, candidate set, empty page

OpsInvocationPage (closed): items, pageInfo (shared), totalCount (integer, at least 0; provisional), skippedCount (integer, at least 0; provisional, FR-019). Order: startedAt descending as instants, ties by invocationId descending (a total key, as GET /missions). One candidate set serves items, totalCount and skippedCount: the distinct invocation ids of the index when it exists and can be opened, else the Op files of the directory (AD-5, OR-6). An Op absent from a present index is not listed; an index entry without a file is skipped and counted (the absent-file rule of FR-019); an index that exists but cannot be opened falls back to the directory; a corrupt index line is skipped. The ordering key is the started_at of the Op's own started event; the index only supplies candidates, so the profile filter, totalCount and the cursor binding use the profile_id of the Op's own started event, never the index line's (a lagging or edited index may disagree; the CLI's index arm filters on the index value, the contract does not). totalCount is the number of served Ops matching profile, and the description says it can lag the newest records. The cursor is opaque; one presented with a different profile than it was issued for, malformed or forged is 400 invalid_page_cursor; with the data unchanged, walking pages at a fixed pageSize returns every served Op exactly once in order; an Op appearing at the head between two page reads neither duplicates nor skips an older one (keyset position). No kitty-ops/: 200, items: [], totalCount: 0, skippedCount: 0, hasNextPage: false.

FR-019 Legacy and unreadable records, skippedCount (provisional)

A record is skipped when legacy or unreadable (Terminology): an own-file started line that is legacy; a legacy own completed line when the spine holds no record for the id (FR-017); an empty file; a first line that is not JSON or not a started event (a partial first line from a concurrent writer is such a shape failure); a file-name stem differing from invocation_id; a started_at or completed_at that is not an RFC 3339 instant with an offset (AD-9), the completed_at being that of the record that would close the Op, own or spine (FR-017; operator ruling at WP08, 2026-10-07); a required field failing its shape, or a profileId or action holding a SECRET_PATTERNS credential (FR-017; operator ruling at WP08, 2026-10-07; an actor holding one is served null, not skipped); invalid UTF-8 anywhere in the file (a shape failure); an absent file, the one rule for both cases: a candidate naming no file, whether an index entry without a file or a file gone after listing (FileNotFoundError), is skipped and counted, because it is in the candidate set (FR-018) and examined plus skipped equals discovered (FR-018, FR-024 and the edge cases refer to this rule). Skipped records are in neither items nor totalCount; they are counted in skippedCount, over the candidate set of FR-018, independent of profile (a skipped record has no reliable profile; the description says so). Files in kitty-ops/ that are not Op file names (the index, the spine, lifecycle.jsonl, the .yaml files, the evidence directory) are not records and are not counted. The description states the rule and names skippedCount; the reference reader also returns the skipped ids for the reality check. Not skipped, 500 ops_unreadable (COMPLETE-013): kitty-ops/ that exists but cannot be listed or is not a directory; an OSError on any candidate Op file other than a vanished one; an OSError reading the spine when it exists, or invalid UTF-8 in it (an unreadable authority would serve closed Ops as open; read_op_closures decodes outside its own handler and would raise UnicodeDecodeError). A corrupt spine line (not JSON, not an event) is skipped as read_op_closures skips it.

FR-020 Evidence classification, redaction and withholding

evidence is null when the completion has no evidence_ref and when the value is withheld (step 0), else a closed object {kind, value, redacted}: kind in OpsEvidenceKind (repo_path, url, text), value at most 512 characters, redacted true exactly when value differs from the stored string (the ArtifactContent.redacted meaning). The evidence description says null means "no evidence, or withheld because it holds a credential", names the credential kinds checked (the SECRET_PATTERNS kinds: GitHub classic and fine-grained tokens, AWS access key id, private-key header) and says other secret shapes are not detected. Pipeline, in order:

0. Credential check (COMPLETE-004; OQ-5): on the stored evidence_ref, before every step below and for every shape; a match withholds the evidence: the Op is served with evidence null, nothing else changes, no marker and no failure. So redaction can never mask a credential. 1. Classify the stored string. (a) A file:// URL (any case): a host path, kind text, value [path]. (b) Any other URL scheme (^[A-Za-z][A-Za-z0-9+.-]+://, as normalise_ref): kind url, userinfo, query and fragment removed. (c) A value beginning /, ~/, two backslashes, or a drive letter and colon: a host path, kind text, the whole value replaced by [path] (also when it holds whitespace). (d) A single line with no whitespace passing the artifact-path rule: candidate kind repo_path. (e) Otherwise kind text. 2. Redact every value that step 1 did not already replace: each embedded scheme:// run in a url or text value loses userinfo, query and fragment; the human-text host-path redaction replaces each matching run by [path] (as the v1 projector); the linear redact_emails replaces each address by [email] (never EMAIL_PATTERN, PR-5). A repo_path candidate changed by this step becomes text; a url not parseable after step 1(b) becomes text. 3. A result over 512 code points is cut to 512, its kind becomes text, redacted is true.

A password in userinfo that matches no SECRET_PATTERNS kind is still removed by step 1(b), with redacted: true; that residual is accepted. actor follows ActorHandle, and is null when it holds a credential. request_text and model_id never leave the service. Corpus, 2026-10-05: of 467 own-file completions, 351 have no evidence, 32 an absolute host path, 56 free text, 28 a relative reference; 16 exceed 512 characters (all free text); 0 are url-kind, and one free-text value embeds a URL without a query; no real e-mail is redacted. Kinds and cases with no corpus example (url, a redacted address, an over-length repo_path, withheld credential) have corpus floor 0 and are covered by fixtures only (FR-025, FR-026).

FR-021 Fields left out on purpose

The OpsInvocation description names the record fields left out, as StatusTransitionEvent does: request_text, model_id, governance_context_hash, governance_context_available, router_confidence. A committed test fails when one name is missing from the description; the closed schema rejects a payload carrying any of them.

FR-022 Version unchanged and the additive proof

info.version stays 1.0.0-SNAPSHOT and the CHANGELOG additions merge into the existing 1.0.0-SNAPSHOT section (PR-1). Baseline (SPEC-ARCH-005): the merge-base of the branch with origin/main at the time of the run, recorded as a full SHA in tracer-design-decisions.md; the proof asserts it is an ancestor of origin/main (git merge-base --is-ancestor), never a named hash that is on no branch. Each observable has a planted negative, and each is either a committed test or a recorded wrap-up command (SPEC-ARCH-006):

1. Committed pure test. Every existing path, schema, parameter and response file except schemas/Project.yaml, examples/Project.example.yaml and paths/project.yaml (description only) is byte-identical, and the new operations reference existing schemas (MissionId, WpId, ActorHandle, StatusLane, PageInfo, PageCursorRefusal, Problem) without changing them. The comparator is the tree itself for the files outside the slice file set plus frozen pre-slice copies of the three Project files under tests/contract/; the existing scratch-pair fixture ("this tree minus the new operations") is extended with a Project override so the old Project is visible to it. A recorded wrap-up command repeats the check against the baseline commit (git diff --exit-code <baseline> -- contracts/mission-status limited to the paths outside the slice file set), with the planted byte change. 2. Committed pure test over the same extended pair: the candidate bundle with the new operations and schemas removed equals the baseline bundle except Project; no other existing operation gains a property, status code, header, media type or default (planted: a 409 on an existing operation). 3. Recorded wrap-up commands (they need oasdiff, a full history and a scratch copy, which the pure-Python test jobs lack; breaking_check.py refuses a same-version baseline, BUNDLE_CHANGED_VERSION_SAME, SHALLOW_CHECKOUT; the module docstring of test_mission_status_contract_1_1.py is updated to say which proofs are tests and which are wrap-up commands). breaking_check.py raises an added response property to breaking (response-required-property-added, in RESPONSE_ADDITION_IDS). Against a scratch copy of the baseline tree whose info.version is lowered to a lower major, the run exits 0 (a snapshot of a later major excuses a breaking change) and its counts: line shows breaking= equal to the non-provisional Project additions (four: specKittyVersion, schemaVersion, currentBranch, lastActivityAt), all on GET /project 200, with provisional_changes counting health. The previous slice's breaking=0 is replaced by this count. Against a scratch baseline of the same major (1.0.0) the run fails BREAKING_WITHOUT_MAJOR naming those four: what a tag changes, and why the Project fields are free only now. The plan measures and pins the counts; both runs and their counts lines are recorded in tracer-design-decisions.md, with a planted control (an extra property on WorkPackage raises the count by one). 4. CI's breaking-change job finds no contract-mission-status-v* tag and prints NO_BASELINE_INITIAL_VERSION; it proves nothing about additivity here, the observables above do. The branch is rebased onto upstream main; no tag is needed. 5. Committed test: the existing permanent test that info.version equals the version on main stays green.

FR-023 Citations, examples, closed schemas, enums, Problem responses, provisional list

Every property of every new schema carries exactly one of x-source and x-derived (field catalogue below), resolved by the citation check (contracts/tools/citation_check.py): a code input of x-derived is {path, symbol}, a contract-field input a bare path. Every new schema has validating examples covering its empty and unknown shapes (Project: healthy, all-null, schema_drift; DriftReport: clean, populated with the three kinds, CORRUPT_JSON and a lane comparison, truncated; each DriftRefusal code; Ops page: first, last, empty; Ops items: open, closed by agent, closed by doctor sweep, each evidence kind with and without redaction, and evidence: null; each OpsRefusal code). Response object schemas are closed; every new operation keeps the shared Problem for default. New enums are titled schemas pinned in contracts/tools/enum_pins.json, in snake_case (enum-case lint; PR-2). The x-provisional carriers are exactly the table below (contracts/tools/provisional_check.py demands each token in the CHANGELOG Provisional section as a whole word, and the existing _reported_provisional_tokens test fails until the two agree):

ElementKindCHANGELOG token
health of Projectpropertyhealth
DriftKindschemaDriftKind (with the text: the two checks behind lane_branch_missing, a manifest predating mission_slug not evaluated for it, no local branch, and the decision on derived_view_stale)
kind of DriftFindingproperty (carries the provisional DriftKind)kind (qualified DriftFinding.kind, below)
remedy of DriftFinding; DriftRemedyproperty; schemaremedy; DriftRemedy
laneComparison of DriftFindingpropertylaneComparison
truncated of DriftReportproperty (the cap of 1000 may be replaced by paging)truncated (qualified DriftReport.truncated)
DriftRefusalCode; code of DriftRefusalschema; property (as ArtifactRefusalCode)DriftRefusalCode; code (qualified DriftRefusal.code)
OpsRefusalCode; code of OpsRefusalschema; propertyOpsRefusalCode; code (qualified OpsRefusal.code)
evidence of OpsInvocationpropertyevidence
totalCount, skippedCount of OpsInvocationPagepropertiestotalCount, skippedCount
listOpsInvocationsoperation (carries the legacy-record rule, AD-7)/ops/invocations

Qualified names (FRESH2-008). The tool matches the bare property name anywhere in the Provisional section, and kind, code and truncated are already named there by the previous slice (ArtifactKind, ArtifactRefusalCode, ArtifactListing), so the tool check cannot fail for the four new properties that bear those names. A committed test asserts that the new CHANGELOG lines contain DriftFinding.kind, DriftReport.truncated, DriftRefusal.code and OpsRefusal.code literally, with a plant that removes each; the tool check keeps the unique tokens (health, remedy, laneComparison, evidence, totalCount, skippedCount, DriftKind, DriftRemedy, DriftRefusalCode, OpsRefusalCode, /ops/invocations).

The CHANGELOG also states: the new operations and schemas under Added; the Project change under Changed as a pre-release shape change; the three remaining deferred gaps (FR-029); the cap of 1000; the not-shipped decision of FR-013; that info of the audit Severity is not carried by DriftSeverity; and that missionId and artifactPath of DriftFinding are non-null (no example holds a null for either). A committed test fails when a value of DriftKind, DriftSeverity, DriftAuthority, DriftSide, DriftRemedy or any other new enum has no fixture that produces it through the production entry point (no dead value, AD-6).

FR-024 Loud failure catalogue

Every new read says what it does when it cannot do its job; none reports null, zero, an empty array or an empty string as success when the truth is "could not read".

ReadCannot do its jobOutcome
Project version, schemafile absent, unreadable, wrong shapeexplicit null (FR-002, FR-003); health schema_drift
Project.currentBranchdetached, no git, unreadable HEAD, unrepresentable nameexplicit null (FR-005)
Project.lastActivityAtno Mission, or none with activityexplicit null (FR-006)
Project.lastActivityAta Mission's status.json cannot be readthat Mission adds no activity; the others still count (FR-006)
Projecta Mission's event log cannot be read, decoded or parsed500 under the default Problem, no partial Project (FR-006)
GET /driftunknown Mission404 mission_not_found
GET /driftthe Mission directory or its read directory (any resolver outcome of FR-015 except CoordinationBranchDeleted), status.json (OSError), event log, meta.json, or lanes.json of a non-completed Mission that is neither current- nor legacy-shaped (each also when not valid UTF-8): unreadable or corrupt500 drift_scan_unreadable, no partial report
GET /driftthe declared coordination branch of a Mission is gone (CoordinationBranchDeleted)not a failure: the Mission is read from its own directory, named coordination_branch_deleted on the reader's result and the reality report (FR-015)
GET /drifta remote unreachable or erroring while a Mission's declared coordination branch is absent locally (the resolver judges it present; FR-015 Network behaviour)not a failure: the Mission is read from its own directory, a 200 with no fallback entry (amended by the operator ruling at WP07, 2026-10-06); a reachable remote that lacks the branch gives the coordination_branch_deleted fallback; any other resolver error is the 500 above
GET /driftstatus.json not valid UTF-8, not JSON or not an objectCORRUPT_JSON finding, not a failure (OQ-4)
GET /driftlegacy-shaped lanes.jsonnot evaluated for kind 3, counted on the named list (FR-012)
GET /driftbranch listing fails (no git, no repository, git error) while a Mission has an expected lane500 drift_scan_unreadable, never kind-3 findings
GET /driftnothing to report200, findings: []
GET /ops/invocationskitty-ops/ absent200 empty page
GET /ops/invocationskitty-ops/ not listable; an Op file or the spine that cannot be opened; the spine holding invalid UTF-8500 ops_unreadable
GET /ops/invocationsbad cursor400 invalid_page_cursor
GET /ops/invocationsone record legacy or unreadable (bad shape, invalid UTF-8), or its file absent (vanished, index entry without a file)skipped by rule and counted in skippedCount (FR-019, provisional)
Ops.evidenceno evidence_ref; credentialexplicit null (the description tells which)

Every check this Mission adds fails when it cannot do its job (zero Missions, zero Ops, zero findings in a fixture that plants one, a missing oracle, an injected fault that did not fire), with a floor and a planted negative.

FR-025 Reality-check extension and reference reader

The reality check (tests/contract/test_mission_status_reality.py over tests/contract/_mission_status_payloads.py: projector, derive_project, floors, fingerprint) is edited in its own conventions, and new reference-reader modules are added under tests/contract/ (names are the plan's call), built from public readers only (get_project_schema_version, ProjectMetadata, materialize_snapshot, materialize_to_json, read_lanes_json, load_meta_fail_closed, is_mission_completed, parse_op_event, read_op_closures, redact_emails, the v1 projector); the branch listing is its own git for-each-ref refs/heads call, not branch_exists. No file under src/ changes (C-006). Over the checkout it builds and validates Project, the project-wide DriftReport and a per-Mission DriftReport for every Mission directory (the per-Mission findings equal the project-wide findings filtered to that Mission, so per-Mission reads follow the same 500 rules), and the Ops listing walked page by page. It carries:

  • Independent oracles, asserted by an AST test to neither call nor import the reader (D-P14). Kinds 1 and 2: the file-presence scan plus classify_status_json filtered to the four kind-1 codes (SNAPSHOT_DRIFT, SNAPSHOT_DRIFT_PROVENANCE, SNAPSHOT_DRIFT_TERMINAL, CORRUPT_JSON) and restricted to the reader's population (Missions holding both files in the read directory, which the oracle resolves through MissionStatus.load itself, from the shared memo, and from the own directory for a Mission on the independently derived fallback list below; the CORRUPT_JSON the classifier emits for an unreadable file is dropped, the reader answers 500; the oracle wraps the classifier so that UnicodeDecodeError becomes CORRUPT_JSON as in the reader, the wrapper itself checked by a unit test over an undecodable status.json; the findings it emits for a snapshot without a log belong to kind 2); the oracle runs over the corpus only, and fixtures assert their expected findings directly; a Mission whose materialize_snapshot the oracle's own probe sees raise is dropped from the oracle and is a 500 in the reader (named list, expected empty); validate_materialization_drift is used only as a one-way check (every drift it reports implies a classify finding), because it compares fewer fields. Kind 3: the product's is_mission_completed on the own directory where the read directory is the own directory, and for a Mission with a distinct read directory the two arms read directly (merged_at in the own meta.json as raw JSON; derive_mission_lifecycle of the read directory), with the product's materialize_snapshot (public product readers, not the reference reader; planted fixtures cover them separately: a merged marker, an all-terminal Mission, a reopened Mission, an expected and a planned-only lane), the meta.json and manifest JSON read directly (the manifest only for a non-completed Mission, completion first), and branch presence by one git rev-parse --verify --quiet refs/heads/<name> per expected name, a different read than the reader's listing. Ops: a direct scan of every Op file and of the spine.
  • Examined plus skipped equals discovered for Missions and Ops, with named lists derived independently (Ops whose own completion line lacks closed_by: 7 today; the legacy-shaped lanes.json of a non-completed Mission: 1 today, counted for kind 3 only; the fallback Missions, below), failing on any 500 outside a list and on a stale entry (PR-4, #5625 WP06 ruling); skippedCount of the whole walk equals the oracle's skipped Op list.
  • Corpus outcome and the fallback list (FRESH2-001, FRESH3-001). With every configured remote reachable and no non-merged Mission declaring a coordination branch present only on a remote or as a remote-tracking ref (a corpus condition, not a network one: the resolver judges that Mission present even with the network up, so it has no fallback entry although the independent derivation lists it, FRESH4-004; the check names any such Mission when it reports the disagreement), the project-wide GET /drift over this repository is a 200; on a clone without network access it is still a 200 read from each Mission's own directory with no fallback entry (amended by the operator ruling at WP07, 2026-10-06; the first text said 500), which is the expected network-less outcome and is asserted as such, alone: offline only the assertions that depend on the coordination resolver (the project-wide report equality with the oracles, the Missions examined-plus-skipped identity and Missions floors, the kind 1 to 3 oracles, the fallback list, per-Mission equality) are skipped, each by a named skip (never a silent pass); the Ops walk, the skippedCount equality, the Ops floors and the read-only fingerprints need no network and run in every environment (plan round ruling, 2026-10-06); an offline run is not evidence for SC-002 or SC-007 (FRESH4-001) (the check detects an unreachable remote by running git ls-remote --heads on each remote itself, outside the reader, and expects the matching outcome). With reachable remotes, no resolver outcome other than CoordinationBranchDeleted occurs over the corpus (any other fails the check; that list is expected empty). The fallback Missions are derived independently of the resolver, as an agreement check on this corpus, not as a mirror of every resolver gate: the derivation does not reproduce the resolver's reopen-aware merged test, its stored-topology gate, or its remote-tracking and remote existence arms (FRESH3-002); each of those arms is pinned by its own fixture (FR-026), and a disagreement on a later corpus is a reality-check failure to be explained by a new fixture or a derivation arm, never hidden. The derivation: meta.json names a coordination_branch, holds no merged_at (the resolver re-anchors a merged Mission on its own directory), and the branch is in neither git for-each-ref refs/heads nor git worktree list --porcelain. The stale-entry check runs both ways: every listed Mission raises CoordinationBranchDeleted through the memo, and every Mission that raises it is listed. At measurement time (2026-10-05, this clone, remotes reachable) the derivation and the resolver agree on the same 37 of 569 Missions; the count is clone- and network-dependent, so it has no floor. The reality report prints the list (FR-015), the per-Mission report of each fallback Mission equals the project-wide findings filtered to it, and the resolver ran once per Mission (the memo holds one entry per directory).
  • Floors fixed at plan time and never re-pinned (D-P15): Missions examined, Ops served, spine-closed Ops, evidence of each kind and redacted that the corpus has (own-file: none 351, absolute path 32, free text 56, relative 28; url 0), findings of kinds 1 and 2, and the completion-equality guard of AC-DRIFT row 19 (Missions declaring no coordination branch compared: 490 of 518; plan-round ruling 4); floor_failures as in v1. Kind 3 has no corpus floor: its count is clone-dependent (0 to the 93 before the fixes), so it is taken from the synthetic fixtures only and the corpus is asserted equal to the oracle. Fixture-only floors: url evidence, a redacted address, a withheld credential.
  • Read-only proof: tree_fingerprint of kitty-specs/ and kitty-ops/ unchanged; a last test asserts every generated case executed.

Environment-dependent values (currentBranch, kind 3) are asserted against the oracle for the environment, never a pinned value; fixtures cover both outcomes (FR-026).

FR-026 Synthetic-repository tests

Fixture repositories (real git where git matters, built with write_fixture_mission and its siblings) provide every plant and control of Acceptance Criteria, covering what the corpus lacks: every metadata shape of FR-002, schema versions around the range including a float and a boolean; every HEAD state, a strict host-path name; activity at different offsets; one Mission per drift kind plus a clean control; the status.json, meta.json and lanes.json shapes of AD-10 and FR-012, each on a completed and on a non-completed Mission (FR-012 order); the coordination-topology pair (branch present, branch absent, mission_branch absent with the coordination branch present); a coordination Mission whose primary status copy is stale beside one whose coordination copy is stale; a coordination Mission with merged_at in the own copy of meta.json only (completed: its malformed lanes.json is not read) beside one with merged_at in the coordination copy only (not completed: its malformed lanes.json is a 500); a deleted coordination branch as the fallback (a 200, read from the own directory, the reason named, with a reachable remote that lacks the branch, a local bare repository, or no remote) and every other resolver outcome of FR-015 as a 500; the offline row, a real-git fixture whose remote is unreachable (its URL a non-existent local path, so the ls-remote fails at once; the probe cache is reset between control and plant with _reset_remote_branch_lookup_cache, src/specify_cli/git/remote_probes.py): the resolver judges the absent branch present and GET /drift is a 200 read from the own directory with no fallback entry (operator ruling at WP07, 2026-10-06); one fixture per resolver arm the fallback derivation does not mirror: a reopened Mission whose meta.json holds merged_at and whose log holds a later MissionReopened (the resolver can raise; the derivation lists nothing), a Mission with a declared branch whose stored topology does not use the coordination surface (no raise), a branch present only as a remote-tracking ref and one present only on a reachable remote (both judged present, so a 200 from the own directory with no fallback entry; operator ruling at WP07, 2026-10-06), each with a control; a snapshot recorded at an older generation, in step with its replay at that generation (no finding), beside the same Mission at the current generation with a field differing (a finding); invalid UTF-8 in status.json, lanes.json, meta.json and the event log, one fixture per file with the clean control; a directory with no .git beside one with the branch; a merged and an unmerged Mission with a kind-1 error; a stale .kittify/derived/; 1001 and exactly 1000 findings; every kitty-ops/ shape of FR-017 to FR-020 (absent, with and without index, own and spine closures, legacy lines, unreadable files beside a readable one, a vanished file, an index entry naming no file, an index line whose profile differs from its file's, invalid UTF-8 in an Op file and in the spine, an unreadable spine); every evidence class and credential shape; equal startedAt; mission_id and wp_id present; each modeOfWork.

FR-027 Planted negatives, leak scan, registrations

Every rule that refuses, redacts or withholds has a deliberately violating input and a test that goes red when the rule is removed, each with a same-fixture control (Acceptance Criteria). The leak scan (contracts/tools/leak_scan.py) covers the new examples: a host path, an e-mail address, a credential and an unredacted evidence value planted in an example each make it fail; the clean examples pass. STRICT_FIELDS matches by exact property name (leak_scan.py: str(child) in STRICT_FIELDS), so it names every property this spec calls strict: specKittyVersion, currentBranch, profileId, action, invocationId, sourceCode (the existing profile entry does not cover profileId); a committed test asserts each of these is in it, and a planted profileId example holding a bare absolute path fails the scan. New test modules are registered only in the three files of the slice file set (item 5), in the router jobs tests (contract tools) and tests (corpus-blocking) as the previous slice's were; negative_cases.json gains the plants that need the JVM or lint toolchain.

FR-028 JVM replay J-1

Once the contract tree is final (the last approved work package that edits contracts/), the orchestrator re-runs the JVM-dependent jobs of .github/workflows/contracts.yml over the new shapes, as the previous slice's J-1 did: the bundle with JVM validation and Java view, the tamper plant, the TypeScript client smoke, resolver parity, the vacuum lint with its plants, the tag-less release dry run, negative-tests, and breaking_check.py --baseline-root with the two scratch baselines of FR-022. The Python gates do not run the lint; this replay catches what they cannot (the previous slice's kebab-case ArtifactKind). The record in tracer-design-decisions.md cites the commit of the final contracts/ change and gives one line per job with its conclusion; a defect in an approved work package returns by a rejection transition; the replay repeats for any later change to contracts/mission-status/. The record is checked against git and the workflow file as AC-VERSION states.

FR-029 The pinned deferral test follows OR-1

contracts/mission-status/CHANGELOG.md "Deferred to the next major version" loses the line "The project branch", and its intro changes from "Four gaps" to "Three gaps" (per-Mission staleness on MissionOverview, an actor on WorkPackageSummary, the lane weights behind weightedPercentage). The existing test module tests/contract/test_mission_status_contract_1_1.py was written for the previous slice, whose baseline was the tree without its three operations, and is edited in the same change. The edit is not a pure removal; the previous slice's file-set data is replaced, not extended; each item is a ratchet edit that weakens no assertion (R-6):

1. Deferral: DEFERRED_GAPS loses the key "project branch"; test_a_gap_named_only_outside_the_deferred_section_is_refused, which indexes DEFERRED_GAPS["project branch"], is re-pointed to a surviving gap ("lane weights"); test_the_entry_names_the_four_deferred_gaps_in_its_deferred_section is renamed to three. 2. Scratch pair: NEW_PATH_KEYS becomes the two new paths, /drift and /ops/invocations (the three of the previous slice are on main now); _make_pair removes those two and, per FR-022, swaps in the frozen pre-slice Project files; CHANGED_ALLOWED gains schemas/Project.yaml, examples/Project.example.yaml and paths/project.yaml; test_the_scratch_baseline_is_this_tree_without_exactly_the_three_new_operations is renamed for two operations and still asserts the candidate paths minus the baseline paths equal NEW_PATH_KEYS. 3. Scope data: SLICE_ALLOWED, EXTRA_ALLOWED_REGISTRATIONS, IN_SCOPE_CHANGES and REGISTRATION_CHANGES become this slice's file set, and test_the_allowed_data_has_the_shape_the_proof_needs is re-pointed to the new registration list; its structural asserts (no deletion, no wildcard) stay. 4. Docstring: updated as FR-022 item 3 says.

Every rule and planted-entry test not named stays: an entry missing one of the three gaps is still refused, a gap named outside the section is still refused. Control: the real entry passes the edited test; the planted entry that lacks "lane weights" fails it. The frozen archived specs of earlier Missions keep saying "four"; they are immutable snapshots.

Non-Functional Requirements

IDTitleRequirementCategoryPriorityStatus
NFR-001Corpus runtimeThe corpus reality job (10-minute timeout) gains at most 120 seconds with the remotes reachable (FR-015 Network behaviour), measured on the CI runner and recorded in the plan and staying inside the job timeout; the new reader code adds one memoised resolver pass shared by the Project build, the scan and the oracles (the unchanged v1 test keeps its own pass, FR-015 Memo), so the gain is bounded by the measured scan time plus the Project build's remaining reduction (about 7 seconds measured: 44.4 seconds for a v1 Project build against 37.9 for the resolver alone); one project-wide drift scan over this checkout (569 directories) takes at most 120 seconds with at most two reductions per Mission (the materialize_snapshot snapshot serves kinds 1 and 3; the completion test's derive_mission_lifecycle reduces once more) and the resolver memoised, so it runs once per Mission per run of the new reader code (FR-015); no new test case exceeds 120 seconds. Measured over this checkout with the resolver included, 2026-10-05 (command and figures in tracer-design-decisions.md): 40.3, 48.0 and 52.0 seconds for one scan in three runs of the recorded command (resolver, comparison, completion test, meta.json and lanes.json reads, one branch listing); the resolver alone 36.7 to 37.9 seconds. The 120 second bounds are about 2.3 times the slowest measured scan (52.0 seconds), the stated headroom for a slower runner; no 180 second figure is used, because the job gain was not measured separately. They hold with the remotes reachable; there is no offline bound (a scan that meets an erroring remote reads that Mission from its own directory and is slower, not failed; operator ruling at WP07, 2026-10-06).PerformanceHighOpen
NFR-002Bounded readsThe reader's own code opens only meta.json (own directory), status.json, status.events.jsonl (read directory) and lanes.json (own directory) per Mission (a counting-opener assertion); the claim covers the reader's own opens only, not those of the coordination resolver, which also reads lanes.json of the Mission it resolves, completed or not, and whose refusal of a manifest it cannot read ends the scan with a 500 before the reader's completion test; the resolver and the product readers open what they read, counted apart, which includes the second copy of meta.json of a coordination Mission. The reader's own code starts at most one subprocess: exactly one, the git for-each-ref refs/heads listing, when any Mission has an expected lane (FR-012), and none otherwise; the scan's total equals the subprocesses the memo recorded for the resolver (784 over this checkout, 2026-10-05, the git ls-remote --heads probes included) plus that listing when it runs (785 measured), exactly 1 on a fixture with an expected lane whose memo records zero resolver subprocesses, and exactly the memo's count on a fixture with no expected lane. One listing of the real 480-file kitty-ops/ takes at most 5 seconds on the CI runner, and of a synthetic 10,000-file directory without index at most 30 seconds.PerformanceHighOpen
NFR-003Read-onlyZero writes under the repository by any new read, proven by the corpus fingerprint and by writers (materialize, InvocationWriter, append_to_index) replaced with ones that raise.SafetyHighOpen
NFR-004Leak-free artefactsZero host paths, e-mail addresses and values matching the SECRET_PATTERNS kinds in the new contract files, examples and corpus-built payload strings; other secret shapes are not detected and the evidence description says so.SecurityHighOpen
NFR-005DeterministicTwo reads of an unchanged tree give byte-identical Project, findings (same refs, same clock), Ops pages and cursors; two bundle builds are byte-identical.ReliabilityMediumOpen
NFR-006Test disciplineNew Python passes ruff check and ruff format; every assertion compares against an independent count, asserts a minimum input count and has a same-fixture control.QualityHighOpen
NFR-007No product changeNo file under src/ changes.LocalityMediumOpen
NFR-008Linear redactionRedacting a 256 KiB evidence value takes at most 0.1 second in each of three shapes (no whitespace; repeated a@; repeated host-path-root runs), by timed tests (linear redact_emails, PR-5), each with a short-value control.PerformanceMediumOpen
NFR-009Project costOne Project build over this checkout, on its real corpus and with the resolver included, takes at most 120 seconds on the CI runner with the remotes reachable (44.4 seconds measured over 569 directories with 784 git subprocesses, 2026-10-05; the earlier 1.5 seconds omitted the resolver); the plan re-measures and records it; the Project description says the cost grows with the Mission count and that each Mission's read directory is resolved through git queries that can include git ls-remote --heads network probes (FR-007, FR-015); with an unreachable remote the build still succeeds (each such Mission is read from its own directory, FR-015 Network behaviour) but costs up to 5 seconds per remote per affected Mission, and no bound is claimed.PerformanceMediumOpen

Constraints

IDTitleConstraintCategoryPriorityStatus
C-001Operator rulings bindOR-1 to OR-10 (OR-10 as amended), the round-2 rulings (ARCH-001, ARCH-002, GOV-007/ARCH-008, COMPLETE-004, COMPLETE-013, OQ-1 to OQ-6) the round-3 rulings (FRESH-001, FRESH-005, FRESH-007, FRESH-014) the round-4 ruling (FRESH2-001, which amends FRESH-001) and the round-5 ruling (FRESH3-001) are decided and not re-opened.GovernanceHighOpen
C-002Version and additive scopeinfo.version stays 1.0.0-SNAPSHOT; the only existing response that changes is Project; nothing else adds a property, status code, media type, header, default or range response to an existing operation (RESPONSE_ADDITION_IDS, RESPONSE_KEY_ADDED_ID).TechnicalHighOpen
C-003Slice file setOnly the files of the slice file set change.TechnicalHighOpen
C-004Archive freezekitty-specs/ on main is archive-frozen: record commits are add-only; the reality check reads read-only.GovernanceHighOpen
C-005Public repositoryNo home path, user name, e-mail address, chat mention, private tracker or ledger id, or other private reference in any artifact; a known defect is described by its class, with no pointer to a private list.SecurityHighOpen
C-006No src/ changeThe reads are specified over existing readers; a seam that would need a change is escalated to the operator, not silently edited.TechnicalHighOpen
C-007ToolingNode-free; no new third-party dependency.TechnicalMediumOpen
C-008TerminologyMission, never feature; no feature* alias in any new field, parameter, route or flag.GovernanceHighOpen
C-009Read-onlyNo POST, no materialize, no repair, no branch creation, no Op invocation; the Ops read never starts an agent.TechnicalHighOpen

Key Entities (include if the mission involves data)

  • Project (extended): the project header; now also version, schema, health, branch and last activity. ProjectHealth: enum healthy | schema_drift (provisional property).
  • DriftReport, DriftFinding, LaneComparison: the drift response, one finding, and one work package's persisted-versus-derived status lane.
  • DriftKind (three values, provisional), DriftSeverity (error, warning), DriftAuthority, DriftSide, DriftRemedy (provisional), DriftRefusal, DriftRefusalCode. sourceCode values: SNAPSHOT_DRIFT, SNAPSHOT_DRIFT_PROVENANCE, SNAPSHOT_DRIFT_TERMINAL, CORRUPT_JSON, null (the closed pair table of FR-014).
  • OpsInvocationPage (with totalCount and the provisional skippedCount), OpsInvocation: the Ops response and one Op; OpsModeOfWork, OpsInvocationStatus, OpsOutcome, OpsClosedBy, OpsEvidence (the closed {kind, value, redacted} object of FR-020), OpsEvidenceKind, OpsRefusal, OpsRefusalCode.
  • Refusal codes: DriftRefusalCode (mission_not_found, drift_scan_unreadable) and OpsRefusalCode (ops_unreadable), each carried by its refusal schema, a Problem; invalid_page_cursor is the existing code of PageCursorRefusal.

Where each field comes from (field catalogue)

Every property has exactly one citation. Paths are repository-relative. Every code input of an x-derived is {path, symbol} (written path symbol below); a bare name is a contract-field path (contracts/tools/citation_check.py).

PropertyCitationSource or rule
Project.specKittyVersionx-source src/specify_cli/upgrade/metadata.py ProjectMetadataspec_kitty.version, FR-002 projection rules in the description
Project.schemaVersionx-source src/specify_cli/migration/schema_version.py get_project_schema_versionFR-003
Project.healthx-derived; inputs schemaVersion; src/specify_cli/migration/schema_version.py MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA, check_compatibilityFR-004 range rule; provisional
Project.currentBranchx-source src/specify_cli/core/git_ops.py get_current_branchFR-005
Project.lastActivityAtx-derived; input MissionHead.lastActivityAtFR-006
DriftReport.scannedAtx-derived; input src/kernel/clock.py now_utcthe service clock at the scan
DriftReport.findingsx-derived; inputs DriftFinding.missionId, DriftFinding.kind, DriftFinding.artifactPathsorted and capped, FR-009
DriftReport.truncatedx-derived; input findingsmore than 1000 findings existed
DriftFinding.kindx-derived; inputs src/specify_cli/audit/classifiers/status_json.py classify_status_json, src/specify_cli/status/validate.py _validate_materialization_files, src/specify_cli/lanes/models.py LanesManifest, src/specify_cli/lanes/_git.py branch_exists, src/specify_cli/status/lifecycle.py is_mission_completedFR-010 to FR-012; provisional with DriftKind
DriftFinding.severityx-source src/specify_cli/audit/models.py SeverityAD-1 (error, warning)
DriftFinding.missionIdx-derived; input MissionHead.missionIdthe Mission scanned
DriftFinding.artifactPathx-source src/specify_cli/audit/models.py MissionFindingrelative path invariant, FR-014
DriftFinding.summaryx-derived; inputs kind, sourceCodefixed text, FR-014
DriftFinding.authorityx-derived; input kindFR-010 to FR-012
DriftFinding.derivedSidex-derived; input kindFR-010 to FR-012
DriftFinding.laneComparisonx-derived; inputs src/specify_cli/status/reducer.py materialize_snapshot, src/specify_cli/status/models.py StatusSnapshotFR-010; provisional
DriftFinding.remedyx-derived; inputs kind, sourceCode, src/specify_cli/status/doctor.py check_driftAD-4; provisional
DriftFinding.sourceCodex-source src/specify_cli/audit/models.py MissionFindingthe audit code
DriftRefusal.codex-derived; input src/specify_cli/status/aggregate.py MissionStatusthe reason that ended the read: mission_not_found or drift_scan_unreadable (AD-10), the status fixed by the code; provisional
LaneComparison.wpIdx-source src/specify_cli/status/wp_metadata.py WPMetadataas WpId
LaneComparison.persistedStatusLanex-source src/specify_cli/status/models.py StatusSnapshotstatus.json work_packages lane
LaneComparison.derivedStatusLanex-derived; input src/specify_cli/status/reducer.py materialize_snapshotreducer lane
OpsInvocationPage.itemsx-derived; inputs OpsInvocation.startedAt, OpsInvocation.invocationIdFR-018 order
OpsInvocationPage.pageInfox-derived; input itemsas MissionOverviewPage
OpsInvocationPage.totalCountx-derived; inputs items, src/specify_cli/cli/commands/invocations_cmd.py _iter_index_reverse, _iter_records_from_dirFR-018; provisional
OpsInvocationPage.skippedCountx-derived; inputs items, src/specify_cli/cli/commands/invocations_cmd.py _iter_index_reverse, _iter_records_from_dirFR-019: skipped records of the totalCount candidate set, independent of profile; provisional
OpsRefusal.codex-derived; input src/specify_cli/invocation/writer.py read_op_closuresops_unreadable (FR-019), status 500; provisional
OpsInvocation.invocationIdx-source src/specify_cli/invocation/record.py OpStartedEvent.invocation_idstarted.invocation_id
OpsInvocation.profileIdx-source src/specify_cli/invocation/record.py OpStartedEvent.profile_idstarted.profile_id
OpsInvocation.actionx-source src/specify_cli/invocation/record.py OpStartedEvent.actionstarted.action
OpsInvocation.actorx-source src/specify_cli/invocation/record.py OpStartedEvent.actorstarted.actor, shape rule
OpsInvocation.modeOfWorkx-source src/specify_cli/invocation/record.py OpStartedEvent.mode_of_workstarted.mode_of_work
OpsInvocation.startedAtx-source src/specify_cli/invocation/record.py OpStartedEvent.started_atstarted.started_at
OpsInvocation.missionIdx-source src/specify_cli/invocation/record.py OpStartedEvent.mission_idstarted.mission_id
OpsInvocation.wpIdx-source src/specify_cli/invocation/record.py OpStartedEvent.wp_idstarted.wp_id
OpsInvocation.statusx-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent, src/specify_cli/invocation/writer.py closed_invocation_idsFR-017
OpsInvocation.outcomex-source src/specify_cli/invocation/record.py OpCompletedEvent.outcomecompleted.outcome, or the spine record
OpsInvocation.closedByx-source src/specify_cli/invocation/record.py OpCompletedEvent.closed_bycompleted.closed_by, or the spine record
OpsInvocation.completedAtx-source src/specify_cli/invocation/record.py OpCompletedEvent.completed_atcompleted.completed_at, or the spine record
OpsInvocation.evidencex-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent.evidence_ref, src/specify_cli/invocation/writer.py normalise_ref, contracts/tools/leak_patterns.py redact_emails, contracts/tools/leak_patterns.py SECRET_PATTERNSFR-020; provisional
OpsEvidence.kindx-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent.evidence_ref, src/specify_cli/invocation/writer.py normalise_refFR-020 step 1 and 2 classification
OpsEvidence.valuex-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent.evidence_ref, contracts/tools/leak_patterns.py redact_emailsFR-020 pipeline, at most 512 characters
OpsEvidence.redactedx-derived; inputs value, src/specify_cli/invocation/record.py OpCompletedEvent.evidence_reftrue exactly when value differs from the stored string

Acceptance Criteria

Each group names the requirements it proves. Every row is a test whose three parts share one fixture (tactic acceptance-criteria-non-vacuity): the control (clean input, proving the probe can see the thing), the plant (violating input) and the assertion that goes red when the rule is removed. Tests call the reference reader's production entry point, never only a helper.

AC-PROJECT (FR-001 to FR-007, FR-024)

RulePlant, with its controlRed when the rule is removed
list-shaped or non-mapping spec_kitty metadata is nulla YAML list; spec_kitty a list. Control: a mapping file in the same directoryan unwrapped get_project_version raises
the unknown sentinel never escapes; non-string and non-matching versions are nullno version; numeric version; path-shaped or sentence-shaped version. Control: 4.0.0the sentinel or the text appears in the payload
non-integer schema is null and unhealthy; the CLI coercion is the contract'sschema_version: three (null); "3" gives 3, 3.9 gives 3, true gives 1, as get_project_schema_version. Control: 3health stays healthy on three; a coercion differing from the CLI's
range boundaries, and range not equalityschema MIN-1, MAX+1, null; constants patched to 2 and 4 with schema 3. Control: MIN, MAXa one-sided or equality test passes a boundary
unborn branch is its name; detached, no repository, unreadable HEAD, bad name, strict host-path name are null; linked worktree resolveseach repository state, a branch home/user1/x. Control: attached branch topic/xa null on unborn, an invented name, a crash, a missed linked .git file, a host path served
activity is compared as instants; null casesoffsets where text order and instant order differ; zero Missions; all-null Missions. Control: one Mission with activitya string maximum, or a default replacing null
/project starts no process of its own and writes nothingsubprocess.run raising whenever the resolver call of the memo helper is not on the stack, writers raising, and a plant that starts a subprocess from the reader's own code (fails). Control: the normal build through the memo succeeds, the resolver's git calls are counted, and the build equals v1's load_source build on the fixtures, among them one whose read directory resolves outside the checkout (FRESH4-003)the build starts git itself or writes
Project description and required list match the five properties and state the costthe schema file with the old description, a short required, or a description without the sentence that the cost grows with the Mission count. Control: the real filea description saying "a name and a count only", or silent on cost

AC-DRIFT (FR-008 to FR-015, FR-024)

RulePlant, with its controlRed when the rule is removed
a differing snapshot is a finding; variant and orderWP01 planned in status.json, approved in the log; a provenance-only edit; an all-done edit. Control: the unmodified Mission gives nonenothing found, or variants confused
a corrupt snapshot is a finding; an unreadable one and a raising reducer are 500status.json not JSON; status.json a list (both: CORRUPT_JSON, error, laneComparison [], materialize_status); an OSError on status.json; a corrupt event log. Control: the same Mission before the corruptiona 500 or a missing finding for the first two, a finding or an empty 200 for the others
laneComparison rows and nullsa work package on one side only; a genesis lane. Control: a plain lane differencea row missing, a non-display lane forwarded
exactly one of the two files missing; neither is no finding; artifactPath names the missing file; remedy per AD-4log without snapshot (remedy materialize_status); snapshot without log (remedy null); neither. Control: both presenta finding for neither, none for one, the wrong path or remedy
remedy null on a Mission merged to maina kind-1 SNAPSHOT_DRIFT finding on a Mission whose meta.json has merged_at. Control: the same difference on an unmerged Mission keeps materialize_statusa remedy on a frozen Mission
expected lane branch absentactive Mission, lane in_progress, no branch. Control: branch createdno finding on a real absence
coordination topology expects the coordination branch onlymeta.json names a coordination branch: absent gives a finding, present gives none even though mission_branch is absent. Control: a Mission without a coordination branch expects mission_brancha finding for the unmade mission_branch, or none for a lost coordination branch
local branches onlya branch present only as a remote-tracking ref. Control: the same branch locala remote ref counted as present
not expected: planned-only lane, completed Mission, absent manifesteach beside the flagged Missiona finding for a merged or unstarted Mission (the 488-finding noise)
lanes.json three-way; completion firston a non-completed Mission: legacy-shaped (no finding, on the named list, kinds 1 and 2 still evaluated); not valid UTF-8, not JSON, not an object, neither key, current-shaped missing a required key (each 500). On a completed Mission the same malformed and the legacy-shaped manifests are not read (no 500, no finding, not on the list). Control: a current manifesta 500 for the legacy shape, a finding or an empty 200 for the others, or a 500 or a list entry for a completed Mission
meta.json is read fail-closedmeta.json as [] and as invalid JSON (500). Control: the same Mission before; an absent meta.json is no coordination brancha skipped Mission or a crash
git failure is never kind 3a directory with no .git and a Mission with an expected lane (500). Control: the same Mission in a repository with the branch; a scan with no expected lane is not faileda full set of kind-3 findings as 200
at most one git for-each-ref refs/heads listing, run once when the first Mission with an expected lane needs ita project of many Missions with expected lanes: the reader's own subprocess count is exactly one; a project with no expected lane: zero (the resolver's are counted apart, NFR-002)a per-branch subprocess, or a listing with no expected lane
a remote that cannot be asked (FRESH3-001)the real-git fixture of FR-026 whose remote URL is a non-existent local path and whose Mission declares a coordination branch absent locally: a 200 read from the own directory with no fallback entry (operator ruling at WP07, 2026-10-06). Control: the same Mission with a reachable remote that lacks the branch (the coordination_branch_deleted fallback, a 200)a 500 or a fallback entry for an unreachable remote, or no fallback for a clean miss
the missionId lookup uses the v1 identity read (FRESH3-006)unreadable meta.json in Mission B: ?missionId= of Mission A is a 200; an unknown ULID with all identities readable is 404. Control: no corrupt filea 500 or a 404 caused by another Mission's file
the memo is keyed by repository root and directory (FRESH3-004)two fixture repositories with the same Mission name and different resolver outcomes each get their own result. Control: one repository read twice gives one resolver runa shared entry across repositories
the read directory is the coordination-aware one; CoordinationBranchDeleted is the named fallback; every other resolver outcome is 500a coordination Mission whose coordination copy agrees with its log and whose primary copy is stale (no finding), and one whose coordination copy is stale (kind 1); a deleted coordination branch (CoordinationBranchDeleted) on a real git fixture: a 200, the Mission read from its own directory, the result naming coordination_branch_deleted; a missing coordination worktree (CoordAuthorityUnavailable) on a real git fixture; MissionMetadataUnavailable, InvalidMissionSlug, WorktreeRegistryUnavailable and a read directory outside the repository from a patched resolver (each 500). Control: the same Mission before; a Mission without coordination topology reads its own directorythe stale primary copy compared, a fallback for any outcome but the deleted branch, a 500 for the deleted branch, a partial report or an empty 200
the strict sibling does not fall back; v1's helper does (FRESH2-004)on the same fixtures of CoordAuthorityUnavailable, MissionMetadataUnavailable and a read directory outside the root, the strict sibling raises while the unmodified v1 resolve_read_dir returns the own directory with a reason, asserted in one test so the control shows the plant is real. Control: for CoordinationBranchDeleted both return the own directorya reader that reuses the falling helper, or a strict sibling that raises for the deleted branch
completion reads the own directory's meta.json (round-3 verification of FRESH-001)a coordination Mission with merged_at in the own copy only: completed, its malformed lanes.json not read (no 500); with merged_at in the coordination copy only: not completed, the malformed lanes.json is a 500; for every non-coordination Mission of the corpus the reader's completion equals is_mission_completed(<own directory>). Control: no marker in either copy, not completed, a current manifesta merge marker read from the coordination copy, or a completed Mission's manifest read
the generation gate (FRESH2-007)a snapshot recorded at an older generation, equal to the replay at that generation and lacking a newer field (no finding). Control: the same Mission with a snapshot at the current generation and a field differing (kind 1)a finding for an older-generation snapshot, or none for a current one
undecodable files (FRESH2-005)invalid UTF-8 in status.json (CORRUPT_JSON, error, laneComparison [], materialize_status); in lanes.json of a non-completed Mission (500) and of a completed one (not read); in meta.json (500); in the event log (500). Control: the same Mission cleanan uncaught UnicodeDecodeError, a 500 for status.json, a finding for the others
the fourth kind is absentstale .kittify/derived/ beside a clean and beside a mismatching Mission. Control: the mismatch gives kind 1 onlya derived-view read, or a fourth DriftKind value (enum pin)
order and cap1001 findings created in shuffled order; exactly 1000. Control: 1000 gives truncated: falseunsorted output, 1001 returned, wrong truncated
fixed summaries, closed pair table, no leaka manifest slug shaped like a host path and an event log holding an address; a reader producing a pair outside the table (fails). Control: the leak scan over the payload is clean and summary is the fixed texta forwarded source string, an unknown pair accepted
scannedAt is the scan time; no-store declaredinjected clock advanced between reads; a bundle without the header. Control: the first read, the bundle with ita cached scannedAt, a missing header
404, 400, per-Mission equalityunknown ULID; non-ULID; project-wide against per-Mission. Control: a known ULIDwrong status or inconsistent findings
read-onlymaterialize and file creation raising; a control that writes. Control: fingerprint equal after the scana writing call; and the control shows the probe sees writes
no dead valueeach DriftKind, DriftSeverity (error, warning; info absent from the pinned enum), DriftAuthority, DriftSide, DriftRemedy value produced by a fixture; missionId and artifactPath non-null in the schema (an example with either null fails validation)a value or a null branch without a producer survives

AC-OPS (FR-016 to FR-021, FR-024)

RulePlant, with its controlRed when the rule is removed
newest first, ties by id descendingthree Ops with equal startedAt; Ops with different offsets. Control: distinct timesascending or insertion order
paging once, cursor bound to the filteran Op written between two page reads; a cursor from profile=a used with profile=b and without; a forged cursor. Control: unchanged tree, same filterduplicates, a gap, a cursor accepted across filters
filter; totalCount equals the served counta profile with no Op; a malformed profile; skipped and filtered records present. Control: a matching profilea non-empty page, a 200 on a malformed value, a count including skipped records
skippedCount over the candidate set, independent of profileskipped records of several profiles with a profile filter set. Control: no skipped record gives 0a count that follows the filter, or a skipped record counted in totalCount
index candidates and laglagging index, a stale entry naming no file (skipped, counted), an entry whose profile_id differs from its file's (the file's value filters and counts), unreadable index, corrupt line, no index. Control: an index in step equals the directory scanitems or counts from the wrong source, a stale entry missing from skippedCount, the index's profile used
closure spinea started line with a spine record; own completion and spine both; two spine records; a legacy own completion with a spine record (served closed from the spine) and one without (skipped). a spine record, and an own completion, with a completed_at that is not RFC 3339 (the Op is skipped and counted; a good record after a bad first one does not rescue it; operator ruling at WP08, 2026-10-07). Control: own-file closure, and the same files with good instantsopen for a swept Op (the CLI gap), a skipped Op that the spine closes, a bad spine instant ignored so the Op is served open, or the wrong record serving
open and closed invariantsan Op carrying an outcome while open. Control: consistent Opsan inconsistent payload validates
legacy and unreadable are skipped; non-Op files are not recordslegacy started, empty file, bad JSON, invalid UTF-8, mismatched stem, bad timestamp, bad wp_id, a file vanished after listing; the index, spine and lifecycle.jsonl in the directory. Control: a clean Op beside thema skipped record appears or a non-Op file is counted
empty and failing directory; unreadable files are 500no kitty-ops/; kitty-ops a file or unlistable; a directory of Op files that cannot be opened; one unreadable Op file beside readable ones; an unreadable spine; a spine holding invalid UTF-8. Control: one Opa 500 on absence, or an empty or partial 200 on a failure
evidence classes, each with its expected (kind, value, redacted)URL with userinfo, query and fragment (url, the value without them, true); a file:///opt/x URL, a UNC path, an absolute path with whitespace (each text, [path], true); e-mail text and a relative path holding an address (each text, the address replaced by [email], true); a text value with an embedded URL carrying userinfo and query (text, that run without them, true); 600-character text (text, 512 characters, true); a path with .. (text, unchanged, false: it fails the artifact-path rule); a path with a space (text, unchanged, false). Control: a clean relative path is repo_path, redacted false; a clean URLa leaked credential, address or path; wrong kind; unbounded value; redacted false on a changed value
credential withheld before redaction, per shapea SECRET_PATTERNS token in a URL query, in userinfo, in an absolute path, in free text and in a bare relative path (each: the Op is in items, every other field intact, evidence null). Control: the same shapes with a clean valuea masked or forwarded token, a failure, a dropped Op, or a token removed silently by redaction with redacted true
credential in a handle (operator rulings at WP08, 2026-10-07, and after the pre-merge squad, 2026-10-07)a SECRET_PATTERNS token of each kind in profileId and in action (each: the record is skipped as a field defect and counted, absent from items and totalCount, the token absent from the payload, a clean neighbour served intact), and in actor (the Op is served in items and totalCount with actor null, skippedCount unchanged, the token absent from the payload). Control: a value one character short of each kind is served, an actor includeda token served, an actor credential skipping the record instead of being nulled, a profileId or action credential nulled or served instead of skipping, or a dropped neighbour
actor shapean Op whose actor is e-mail-shaped: served in items with actor null and status, startedAt unchanged. Control: actor claude keeps its valuean address in the payload; or a dropped record or failed read, which would also pass an "absent address" check
omitted fieldsa reader adding request_text; the description without one of the five names. Control: the real descriptionthe closed schema or the description test passes it
read-only, no agent; linear redactionInvocationWriter and append_to_index raising; three 256 KiB shapes (no whitespace; repeated a@; repeated host-path-root runs). Control: normal listing, a short valuea write, an agent start, quadratic time

AC-VERSION (FR-022, FR-023, FR-027 to FR-029, NFR-004, NFR-006)

  • The baseline is a full SHA recorded in the tracer file and git merge-base --is-ancestor of origin/main holds (planted: a commit on no branch fails the assertion).
  • Existing files other than the three Project files are byte-identical to the baseline; a planted byte change in an existing schema fails. The bundle minus the new operations and schemas equals the baseline bundle except Project; a planted 409 on an existing operation fails.
  • The lowered-major run reports exactly the non-provisional Project additions in breaking= (control: a planted extra property on WorkPackage raises the count by one); the same-major run fails BREAKING_WITHOUT_MAJOR on those only (control: with the Project change reverted and the new operations and their tags removed it passes; with the Project change reverted alone it fails BUNDLE_CHANGED_VERSION_SAME).
  • info.version equals the version on main; every x-provisional element of the FR-023 table is named in the CHANGELOG (a planted unnamed one fails for the unique tokens; the qualified names DriftFinding.kind, DriftReport.truncated, DriftRefusal.code and OpsRefusal.code are asserted literally by a committed test, each with a plant that removes it, FRESH2-008); the Deferred section holds exactly the three remaining gaps (FR-029); the CHANGELOG states the not-shipped decision, that info is not carried and that missionId and artifactPath are non-null.
  • Every new enum is pinned and snake_case; a planted camel-case value fails the pin check and, in J-1, the JVM lint. The leak scan passes the tree and fails each planted example of FR-027. The terminology scan (the existing scan's scope: the YAML files of contracts/mission-status and the CHANGELOG entry) finds no feature; the feature_slug literal of the reference reader's legacy-shape test is the sole accepted occurrence outside that scope, pinned by a test (planted: a second feature use in the reader fails).
  • FR-028 record (J-1), checked at the wrap-up by recorded commands: the cited commit exists (git cat-file -t gives commit), is an ancestor of HEAD, and is the last commit touching contracts/mission-status; the record holds exactly one line per job of .github/workflows/contracts.yml, none missing and none invented (planted: a record naming a job absent from the workflow fails); the squad checks each conclusion against a re-run of the job. Keep the SC-009 command git diff --exit-code <cited commit> HEAD -- contracts/mission-status.

AC-CROSS (FR-026, NFR-002, NFR-003, NFR-005, NFR-007, NFR-009, C-003, C-006, C-007)

RulePlant, with its controlRed when the rule is removed
determinism (NFR-005)two reads of an unchanged tree: byte-equal Project, findings, pages and cursors, and two bundle builds. Control: a clock tick changes scannedAt onlya nondeterministic order or cursor
scope (NFR-007, C-003, C-006)git diff --name-only against the baseline limited to the slice file set; a planted extra file and a planted src/ path (both fail). Control: the real change setan out-of-set or src/ change passes
dependencies (C-007)no change in pyproject.toml and the lock file; a planted added dependency fails. Control: the real change seta new dependency passes
bounded reads and time (NFR-001, NFR-002, NFR-009)the counting opener over a scan; the scan's subprocess total equals the memo's recorded resolver subprocesses plus the listing (one with an expected lane, none without); a synthetic 10,000-file kitty-ops/ without index timed at most 30 seconds; the real directory at most 5 seconds; one Project build over this checkout at most 120 seconds and one project-wide scan at most 120 seconds, resolver included. Control: a small directoryan unbounded read or a slow listing, scan or build, or a resolver run more than once per Mission
writers (NFR-003)covered by the read-only rows of AC-DRIFT and AC-OPS

AC-REALITY (FR-025, NFR-001, NFR-002)

  • With the remotes reachable (and the corpus condition of FR-025), the project-wide GET /drift over this checkout is a 200 with no resolver outcome other than CoordinationBranchDeleted (without network access the expected outcome is a 200 with no fallback entry, FR-025; operator ruling at WP07, 2026-10-06); the fallback Missions (37 at measurement time, clone-dependent, no floor) equal the independently derived list, with the stale-entry check both ways (FR-025); the resolver ran once per Mission.
  • Examined plus skipped equals discovered for Missions and Ops, with independently derived named lists and no stale entry; floors are met and a floor loosened to one fails (D-P15); the oracle neither calls nor imports the reader (AST test); there is no kind-3 corpus floor and the corpus is equal to the oracle. The Mission-side assertions (examined plus skipped for Missions, the Mission floors, the kind 1 to 3 oracles and the fallback named lists) hold with the remotes reachable and the corpus condition of FR-025; offline only those resolver-dependent assertions are skipped, each by a named skip (never a silent pass). The Ops half (examined plus skipped for Ops, the Ops floors, the AST test) needs no network and runs in every environment. An offline run is not evidence for SC-002 or SC-007 (FRESH4-001).
  • Per-Mission reports equal the project-wide findings filtered; the Ops walk equals the oracle's served set in order and skippedCount equals the oracle's skipped list; kitty-specs/ and kitty-ops/ are unchanged after the run; every generated case executed. Per-Mission equality (which needs the resolver) holds with the remotes reachable and the corpus condition of FR-025; offline it is skipped by a named skip (never a silent pass). The Ops walk equals the oracle's served set, skippedCount equals the oracle's skipped list, the kitty-specs/ and kitty-ops/ fingerprints and the executed-case count need no network and run in every environment (a case skipped by name offline is counted as skipped, not as executed). An offline run is not evidence for SC-002 or SC-007 (FRESH4-001).

Out of Scope

  • Host-side parts (Kitty Desktop, not this contract): the recent-projects list; the folder picker and its "valid project contains .kittify/ and kitty-specs/" test; the project's folder path on a card; the "local only" marker; one health lookup per recent project (OR-10).
  • Writes. No route changes state; no materialize, repair, branch creation or Op invocation.
  • The Rescan POST. There is none; Rescan is a re-read of GET /drift; there is no drift event on GET /events.
  • Remedy command strings. Only the closed enum travels; the client builds the command text (spec-kitty agent status materialize --mission <slug>, AD-4).
  • Drift kinds beyond the three shipped and the dropped fourth: stale claims, orphan workspaces, sparse checkout and the other doctor categories (Category in src/specify_cli/status/doctor.py); the stale-derived-views panel of the prototype has no contract read.
  • Governance reads (charter, glossary): unchanged, #5533. The event stream: unchanged.
  • The read service implementation (#5528) and any src/ change, including a fix of the CLI reader's closure gap (OR-5).
  • A cliVersion field, and a badge code namespace (OR-9). Paging of drift findings (OR-7). Remote-only branches and a branch-name property (OQ-2, OQ-3). Extending SECRET_PATTERNS (NFR-004).

Assumptions

  • A-1 The contract is unreleased and untagged on main; if a tag appears before this lands, OR-1 no longer applies and the Project change needs the issue's fallback, which this spec does not design.
  • A-2 The corpus counts quoted here (569 directories under kitty-specs/, 489 manifests, 480 Op files, 6 spine records, 7 legacy completions) are measurements of this checkout on 2026-10-05 and move; only floors set at plan time bind, the fallback count (37), the timings and the subprocess counts of NFR-001, NFR-002 and NFR-009 are measurements of this clone, and the kind-3 counts are re-measured after the ruled fixes (FR-012).
  • A-3 The reference reader runs in an environment with git; CI pull-request checkouts are detached, so currentBranch is null there and the corpus assertion is against the oracle.
  • A-4 kitty-ops/ops-index.jsonl is gitignored and absent from the corpus; the index paths are proven by fixtures only.
  • A-5 The existing helpers named here (write_fixture_mission, tree_fingerprint, require_cases, the v1 projector) are stable until the plan.

Risks and Dependencies

  • R-1 The kind lane_branch_missing depends on the clone's local branches and is noisy on a clone of a long-lived repository (a count of the order of 93 Missions was measured before the ARCH-001 fix and is re-measured, FR-012). Mitigation: provisional, honest-rule criterion, "no local branch" wording, no corpus floor.
  • R-2 One unreadable Mission fails the whole project-wide report (AD-10); a frozen defect in a completed Mission's lanes.json does not, because that file is never read (FR-012). Mitigation: missionId isolates; the 500 is loud by design.
  • R-3 health and check_compatibility can diverge if the two schema constants differ; health also depends on the serving build. Mitigation: the differential test of FR-004 and the description.
  • R-4 The provisional marker on the Ops operation hides every future breaking change of that operation from the breaking-change check until it is removed (AD-7). Mitigation: the marker is dropped once the legacy handling and skippedCount are settled, at or before release; the CHANGELOG names it.
  • R-5 x-provisional removes Project.health from the comparison but not the other four properties; the lowered-major evidence of FR-022 depends on a measured count. Mitigation: the plan measures and pins it.
  • R-6 The existing tests this slice edits (FR-029, test_mission_status_payloads.py, the reality check) are tests the previous slice owned; each edit is a ratchet, not a relaxation, and is reviewed as such.
  • R-7 The JVM replay needs a JDK and network access; it is an orchestrator step (FR-028).
  • R-8 The reducer is not always the true authority. Known defects: materialize_snapshot ignores schema-5 finalizer events, so a persisted file can agree with an empty reduction while the CLI's task listing disagrees (a clean report is then wrong); a snapshot whose agent keeps the prior reviewer after a fresh claim is a real SNAPSHOT_DRIFT that can survive the product's own repair. And by design the replay runs at the generation recorded in the existing status.json (FR-010), so an older snapshot lacking a newer field is no disagreement. Kind 1 therefore reports disagreement with materialize_snapshot's output at the snapshot's own generation, not with the true event log; the DriftFinding description says so, no new check is added, and a fixture pair pins the generation gate (FR-026).
  • R-9 The cost of GET /project and of a drift scan is the cost of the coordination resolver, which starts git queries per Mission (784 subprocesses over this checkout, 40.3 to 52.0 seconds for a scan with the remotes reachable), among them network git ls-remote --heads probes, and grows with the Mission count. Assumptions about the stock resolver that change no contract outcome beyond FR-015 Network behaviour: its probe memo is per process, its arms run in the order local head, remote-tracking refs, remote, and its git worktree registry reads are the stock ones. Mitigation: the memo (FR-015), the NFR bounds set on that measurement, the stated cost in the Project description.
  • Depends on: the merged 1.0.0-SNAPSHOT tree (#5558, #5625), the breaking-change and closed-response tooling (#5578), the reality check (#5581). Unblocks: the Projects, Drift and Ops screens of the prototype, so the shared UI work (#5531) can build against them; the prototype's stale-derived-views panel has no contract read (FR-013) and the host decides how it renders; the service is #5528.

Open questions for the operator

None. OQ-1 to OQ-6 are closed by the round-2 rulings above and folded in: OQ-1 every non-completed Mission (FR-012); OQ-2 no branch name (FR-012); OQ-3 local branches only, "no local branch" (FR-012); OQ-4 CORRUPT_JSON finding (FR-010, FR-014); OQ-5 evidence: null withheld (FR-020); OQ-6 provisional skippedCount (FR-018, FR-019).

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: A desktop client reads Project for any project state in FR-002 to FR-006 and never receives a missing key, a sentinel string or an exception; every row of AC-PROJECT goes red when its rule is removed — [build] · no-op passable: no
  • SC-002: With every configured remote reachable (FR-025), GET /drift over this checkout succeeds (a 200; the fallback Missions, 37 at measurement time and clone-dependent, are listed by an independent derivation with a stale-entry check) and returns the findings of the shipped kinds that the independent oracles also report, and none they do not, with examined + skipped == discovered for every Mission and the named skipped list matching — [build] · no-op passable: no
  • SC-003: The fourth kind is decided by FR-013 with recorded evidence: the DriftKind enum holds exactly three values, and a fixture with a stale derived directory yields no finding while the same fixture with a snapshot mismatch yields one — [build] · no-op passable: no
  • SC-004: GET /ops/invocations over the real kitty-ops/ walks every served Op exactly once in startedAt, invocationId descending order, serves the 6 spine-closed Ops as closed, skips the legacy records by rule with skippedCount equal to the oracle's list, and lists 0 non-Op files as records — [build] · no-op passable: no
  • SC-005: No evidence value, summary, path or example anywhere in the new contract files or the corpus-built payloads contains a host path, an e-mail address or a value matching the SECRET_PATTERNS kinds, and no finding carries a branch name: the leak scan reports zero and each planted leak is detected — [build] · no-op passable: no
  • SC-006: Every existing contract file other than the three Project files is byte-identical to the baseline commit; the lowered-major breaking-change run reports exactly the non-provisional Project additions and the same-major run fails on them — [build] · no-op passable: no
  • SC-007: With every configured remote reachable (FR-025), the reality check finishes within the NFR-001 budget with every floor met, the corpus fingerprint unchanged and every generated case executed — [build] · no-op passable: no
  • SC-008: Every x-provisional element of the FR-023 table is named in the CHANGELOG (the four qualified names literally, FRESH2-008); the Deferred section lists exactly the three remaining gaps; the CHANGELOG states that derived_view_stale was considered and not shipped, and why — [ratchet] · no-op passable: yes — paired with the planted CHANGELOG entry that omits one of them, on the same entry text
  • SC-009: The JVM replay J-1 passes every job of contracts.yml over the final shapes; its record in tracer-design-decisions.md cites the commit of the final contracts/ change and one line per job with its conclusion, and the wrap-up command git diff --exit-code <cited commit> HEAD -- contracts/mission-status is clean — [ratchet] · no-op passable: yes — a record can be written without the run; reviewed by the squad against the cited commit and job conclusions
  • SC-010: Each round-2 ruling OQ-1 to OQ-6 is asserted by named rows: OQ-1 to OQ-3 by the kind-3 rows of AC-DRIFT, OQ-4 by the corrupt-snapshot row, OQ-5 by the credential row of AC-OPS, OQ-6 by the skippedCount rows — [folded] · no-op passable: no