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 slugDEPRECATED). No new field, parameter, route, flag or example containsfeature. - 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
StatusLanevocabulary (the glossarylane). Code lane: a lane of a Mission'slanes.jsonthat resolves to a lane worktree and branch. Descriptions say which sense they use; the Drift properties arepersistedStatusLaneandderivedStatusLane. - Snapshot: the file
status.json. Reducer output: whatmaterialize_snapshot(src/specify_cli/status/reducer.py) computes from the event logstatus.events.jsonlwithout writing. The scan never calls the writingmaterialize. - 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 thestartedevent, a later line may be thecompletedevent (OpStartedEvent,OpCompletedEvent,src/specify_cli/invocation/record.py). Every other file inkitty-ops/is not an Op file. - Closure spine:
kitty-ops/op-closures.jsonl, closures written bydoctor 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_eventraisesLegacyRecordError(src/specify_cli/invocation/errors.py). Unreadable record: an Op file that was read and whose first line is not JSON or not a v2startedevent, whose file-name stem differs from itsinvocation_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.jsonthat is a JSON object with keyfeature_slugand nomission_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) andmalformed_artifact_pathincontracts/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.yamlfiles,CHANGELOG.md, and the three Project filesschemas/Project.yaml,examples/Project.example.yaml,paths/project.yaml; (3) incontracts/tools/:leak_scan.py,fixture_builder.py(STRICT_FIELDS),negative_cases.json,enum_pins.json; (4) undertests/contract/: new reference-reader and test modules, the frozen pre-slice copies of the three Project files (FR-022), and edits to the existingtest_mission_status_contract_1_1.py(FR-029),test_mission_status_payloads.pyandtest_mission_status_reality.pywith_mission_status_payloads.py(thederive_projectequality of two keys and the reality check's Project build turn red whenProjectgains 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.ymland.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
Operator rulings of 2026-10-05 (binding; "Accept all 10 (Recommended)"; not re-opened, no alternative offered)
Question, ruling and rationale of each are in tracer-design-decisions.md.
- OR-1 The slice lands before the 1.0.0 tag;
Projectis additive under1.0.0-SNAPSHOTand its branch line leaves CHANGELOG "Deferred". OR-2lane_branch_missingandderived_view_staleare provisional kinds that ship only with anx-derivedrule and a reality-check example;derived_view_staleis dropped, with a CHANGELOG line, if both cannot be served. OR-3laneComparisonis kept, derived from the reducer, provisional. OR-4 The provenance-only and terminal warnings (SNAPSHOT_DRIFT_PROVENANCE,SNAPSHOT_DRIFT_TERMINAL) are included aswarningwithsourceCodeset. OR-5 An Op isclosedwhen acompletedevent exists in its own file or on the closure spine (closed_invocation_ids); the contract does not copy the CLI reader's gap. OR-6totalCountis 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 withtruncated. OR-8healthdoes not count drift errors. OR-9 No badge-code namespace; the screen showskindandsourceCode. OR-10 (amended by FRESH-005, round 3) The contract promises thatGET /projectis 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_branchofmeta.json, nevermission_branch; the product creates only that one there, andmeta.jsonis already opened. Fixture pair: present, absent. - ARCH-002 Does
DriftSeveritykeepinfo? A: no;error,warning, pinned, CHANGELOG line thatinfois not carried; no dead values (AD-6). - GOV-007 / ARCH-008 Legacy or malformed
lanes.json? A manifest predatingmission_slugis "not evaluated for kind 3" (raw JSON pre-read, counted on the named list); every other corrupt manifest is 500drift_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?
OSErroris 500ops_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.jsonthat is not JSON or not an object is anerrorkind-1 finding,sourceCodeCORRUPT_JSON,remedymaterialize_status,laneComparison[](the snapshot is the derived side; the classifier already has the code). AnOSErrorreading it and a reducer that raises stay 500. - OQ-5 A credential in
evidence_ref: withhold; the Op is served withevidence: 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
skippedCountonOpsInvocationPage, over the same candidate set astotalCount, independent ofprofile(a skipped record has no reliable profile): the client-facing "examined plus skipped equals discovered". - GOV-001 Kind-1
errorfindings on a Mission merged tomainhaveremedy: null(archive-frozen); the slug for--missioncomes fromMissionHead.slug(AD-4). COMPLETE-001 The branch source is at most onegit for-each-ref refs/headslisting, 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 500drift_scan_unreadable, none a fallback (FR-015). Amended by the round-4 ruling below:CoordinationBranchDeletedis the one named fallback. - FRESH-005
lastActivityAtandmissionCountkeep their meaning; "scans nothing" and "cheap" are dropped; NFR-009 bounds the time over this checkout and theProjectdescription says the cost grows with the Mission count (OR-10 amended, FR-007). - FRESH-007 The completion test comes first;
lanes.jsonis read and validated only for a Mission that is not completed (FR-012, AD-10), with a fixture pair. - FRESH-014
missionIdandartifactPathofDriftFindingare 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_completedandmaterialize_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 500drift_scan_unreadable(FR-015). Reason: on this checkout, a normal clone with nokitty/*branches, 37 of 569 Missions raise it, so the strict rule madeGET /drifta permanent 500 here and on every fresh CI clone. The corpus outcome is stated:GET /driftsucceeds 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):
lastActivityAtstays resolver-based, the same definition asGET /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
fallbackfield with the one-value vocabularycoordination_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 ofGET /driftare network-dependent; offline,GET /driftis 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 /projecthas 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 "nokitty/*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
missionIdlookup 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
skippedCountequality 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 intracer-approach.md, A-7); an offline run stays no evidence for SC-002 or SC-007. - Plan-round 2 (
specKittyVersionread). The reader pre-checks the YAML shape (top-level mapping,spec_kittymapping), callsProjectMetadata.loadonly when the shape is safe, and treats a residualAttributeErrororTypeErroras 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.pywith 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 realkitty-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 intracer-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_ALLOWEDlists 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.versionstays1.0.0-SNAPSHOT, CHANGELOG additions merge into that section, the proof is "additive against the tree onmain". - PR-2 Enums are snake_case (
enum-caserule, 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, neverEMAIL_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 toWorkspace.laneBranch(FR-005);get_project_versionraises on two shapes and returns the sentinelunknownon a third (FR-002);healthmirrorscheck_compatibilityonly while the two schema constants are equal (FR-004);mission_not_foundis a new code of the module'sDriftRefusalCode(FR-008);lastActivityAtis 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 repairskitty-specs/<slug>/status.json; the doctor recommendsspec-kitty agent status materialize(check_driftinsrc/specify_cli/status/doctor.py; option--missioninagent/status.py).materialize_statusmaps 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_viewsandinfohave 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_DRIFTandCORRUPT_JSONerror, the two variantswarning(OR-4).snapshot_or_event_log_missingandlane_branch_missingarewarning(the doctor wraps the first asSeverity.WARNING).DriftSeverityiserror,warningonly: the audit'sinfois 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 wheremeta.jsonnames one, elsemission_branch(ARCH-001), local branches only (OQ-3). - AD-4
DriftRemedyhas one value,materialize_status, which maps tospec-kitty agent status materialize --mission <slug>(the doctor's recommended action incheck_drift), not tospec-kitty materialize.remedyis null for: the two warning variants (the audit calls them not actionable and the archive gate forbids repairing a frozen snapshot); a kind-1errorfinding on a Mission merged tomain(the own directory'smeta.jsonhasmerged_at; itsstatus.jsonis archive-frozen); kind 2 when the log is missing (no shipped action restores a log; this diverges on purpose fromcheck_drift, which attaches the action to that case too); kind 3. A kind-2 finding for a missingstatus.jsononly adds a file, which is add-only, and keeps the remedy. The client builds the command text; the slug comes fromMissionHead.slugof the finding'smissionId. - AD-5
totalCountcounts served Ops matchingprofile, from the same candidate set as the items (index when present, else directory).skippedCountcounts skipped records over that candidate set, independent ofprofile(OQ-6). - AD-6 No dead enum value:
DriftAuthorityisevent_log,git;DriftSideisstatus_json,lanes_json;DriftSeverityiserror,warning. - AD-7 Provisional marker on the Ops operation. The legacy handling has no property to carry
x-provisional, solistOpsInvocationscarries 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 andskippedCountare settled, at or before release. - AD-8 Evidence class by shape:
repo_pathis a single-line, whitespace-free value passing the artifact-path rule; existence is never checked. - AD-9 Timestamps: a
started_atorcompleted_atthat 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 exceptCoordinationBranchDeleted, the named fallback);status.jsoncannot be read (OSError); the event log cannot be reduced, which includes an event log that is not valid UTF-8 (the audit'sclassify_status_jsonreports a raising reducer as aSNAPSHOT_DRIFTerror 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.jsonexists but is corrupt, not an object, not valid UTF-8 or unreadable (MissionMetaReadError);lanes.jsonof 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'slanes.jsonis never read); or the branch listing fails while a Mission has an expected lane.missionIdisolates the Mission. Defined outcomes, not failures: a legacy-shapedlanes.json(counted); astatus.jsonthat 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_kittynot a mapping,versionmissing, numeric or text-shaped,schema_versiona 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:
lastActivityAtnull. 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.jsonnot valid UTF-8, not JSON or not an object:CORRUPT_JSON. Provenance-only and all-donedifferences: the warning variants. Nolanes.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, nevermission_branch; one whose declared coordination branch is gone is read from its own directory (the named fallback, 37 here). A completed Mission'slanes.jsonis 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.jsonisCORRUPT_JSON;lanes.jsonof a non-completed Mission,meta.jsonand the event log are 500. A work package on one side only: the other side null. Agenesisoruninitializedlane: null. - Ops. Empty file; non-Op file name; stem differing from
invocation_id; first line not JSON or acompletedevent; 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). EqualstartedAt: ties byinvocationIddescending.mission_idandwp_idare 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
| ID | Title | User Story | Priority | Status | Delivery | No-op passable? |
|---|---|---|---|---|---|---|
| FR-001 | Project gains five properties | As a desktop user, I want a project card with version, health, branch and activity so that I can judge a project unopened. | High | Open | [build] | no |
| FR-002 | specKittyVersion | As a desktop user, I want the version a project recorded so that I see which Spec Kitty made it. | High | Open | [build] | no |
| FR-003 | schemaVersion | As a desktop user, I want the recorded schema so that I know why a project is unhealthy. | High | Open | [build] | no |
| FR-004 | health | As a desktop user, I want a two-value badge so that the card stays stable. | High | Open | [build] | no |
| FR-005 | currentBranch | As a desktop user, I want the current branch so that I know where work lands. | High | Open | [build] | no |
| FR-006 | lastActivityAt | As a desktop user, I want the last activity so that I can sort by recency. | High | Open | [build] | no |
| FR-007 | /project is read-only, side-effect-free and independent of drift | As 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. | Medium | Open | [build] | no |
| FR-008 | Drift operation | As a user, I want GET /drift so that the Drift screen has data. | High | Open | [build] | no |
| FR-009 | Drift schemas, order and cap | As a user, I want a stable bounded list so that Rescan is comparable. | High | Open | [build] | no |
| FR-010 | Kind snapshot_disagrees_with_event_log | As a user, I want a disagreeing snapshot shown so that I can repair it. | High | Open | [build] | no |
| FR-011 | Kind snapshot_or_event_log_missing | As a user, I want a Mission lacking a status file shown so that I notice damage. | High | Open | [build] | no |
| FR-012 | Kind lane_branch_missing | As a user, I want a started Mission with a lost lane branch shown so that I can recover work. | Medium | Open | [build] | no |
| FR-013 | derived_view_stale decision procedure | As a maintainer, I want the fourth kind decided by evidence so that no kind ships without a rule and a real example. | High | Open | [build] | no |
| FR-014 | Fixed summaries, remedy, sourceCode, leak surface | As a user, I want findings that cannot leak a host path so that the report is shareable. | High | Open | [build] | no |
| FR-015 | Scan time, read directory, no-store, read-only, failure semantics | As a user, I want Rescan to be a real read that changes nothing so that the report is trustworthy. | High | Open | [build] | no |
| FR-016 | Ops operation, paging and filter | As a user, I want GET /ops/invocations so that the Ops screen has data. | High | Open | [build] | no |
| FR-017 | OpsInvocation and closure semantics | As a user, I want a swept Op shown closed so that the screen agrees with the record. | High | Open | [build] | no |
| FR-018 | Order, cursor, totalCount, candidate set, empty page | As a user, I want stable newest-first paging and an honest total so that paging never skips or repeats. | High | Open | [build] | no |
| FR-019 | Legacy and unreadable records, skippedCount | As a user, I want archive records skipped by a stated, counted rule so that the list does not fail and nothing vanishes silently. | Medium | Open | [build] | no |
| FR-020 | Evidence classification, redaction and withholding | As a user, I want evidence free of host paths, addresses and credentials so that the screen is shareable. | High | Open | [build] | no |
| FR-021 | Fields left out on purpose | As a maintainer, I want the omitted fields named so that projecting one later is visible. | Medium | Open | [build] | no |
| FR-022 | Version unchanged and the additive proof | As a consumer, I want the proof of what changes for me so that I can pin the contract. | High | Open | [build] | no |
| FR-023 | Citations, examples, closed schemas, enums, Problem responses, provisional list | As a maintainer, I want every v1 check to pass so that the contract stays verifiable. | High | Open | [build] | no |
| FR-024 | Loud failure catalogue | As a consumer, I want every read to say what it does when it cannot work so that no silent default exists. | High | Open | [build] | no |
| FR-025 | Reality-check extension and reference reader | As a maintainer, I want the reads projected over this repository so that a contract that misfits real data fails early. | High | Open | [build] | no |
| FR-026 | Synthetic-repository tests | As a maintainer, I want fixtures for states the corpus lacks so that every rule has a control. | High | Open | [build] | no |
| FR-027 | Planted negatives, leak scan, registrations | As a maintainer, I want a violating input per rule so that a removed rule turns a test red. | High | Open | [build] | no |
| FR-028 | JVM replay J-1 | As a maintainer, I want the JVM jobs re-run over the final shapes so that lint and client accept them. | High | Open | [build] | yes, paired with the recorded-command checks of AC-VERSION (cited commit, job set) and the squad review of the conclusions |
| FR-029 | The pinned deferral test follows OR-1 | As a maintainer, I want the three remaining deferred gaps pinned so that the moved line is not silently dropped. | Medium | Open | [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):
| Input | Output |
|---|---|
| file absent, empty or not YAML | null |
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 string | that 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 withread_lanes_json/LanesManifest.from_dict); evaluated; - legacy shape: a JSON object with
feature_slugand nomission_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. TheDriftKinddescription states: "a manifest predatingmission_slugis 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_jsonrefuses withCorruptLanesError): 500drift_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.jsonandboard-summary.jsonwith the reducer output, ignoringmaterialized_atand the fieldsvalidate_materialization_driftignores.progress.jsonandlifecycle.jsoncannot be compared (derive_mission_lifecyclereads 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-ignoreconfirms), untracked (git ls-files .kittify/derivedis empty), and written only on an operator machine byspec-kitty materializeand the status refresh hook (write_derived_viewsinsrc/specify_cli/status/views.py, called bysrc/specify_cli/cli/commands/materialize.py). A fresh clone, so every CI run, has none. Gate B fails. - Supporting evidence:
validate_derived_viewsis a permanent no-op returning an empty list andcheck_driftsays 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).
DriftKindhas three values;derived_viewsis not aDriftSide; 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
DriftKindset to the three; a fixture with a stale derived directory and a clean Mission yields no finding, while the same fixture with astatus.jsonmismatch 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:
| kind | sourceCode | fixed summary |
|---|---|---|
snapshot_disagrees_with_event_log | SNAPSHOT_DRIFT | The status snapshot disagrees with the event log. |
snapshot_disagrees_with_event_log | SNAPSHOT_DRIFT_PROVENANCE | The status snapshot differs from the event log only in provenance fields. |
snapshot_disagrees_with_event_log | SNAPSHOT_DRIFT_TERMINAL | The status snapshot differs from the event log and every work package is done. |
snapshot_disagrees_with_event_log | CORRUPT_JSON | The status snapshot is not a valid JSON object. |
snapshot_or_event_log_missing | null | One of the status snapshot and the event log is missing. |
lane_branch_missing | null | An 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):
| Element | Kind | CHANGELOG token |
|---|---|---|
health of Project | property | health |
DriftKind | schema | DriftKind (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 DriftFinding | property (carries the provisional DriftKind) | kind (qualified DriftFinding.kind, below) |
remedy of DriftFinding; DriftRemedy | property; schema | remedy; DriftRemedy |
laneComparison of DriftFinding | property | laneComparison |
truncated of DriftReport | property (the cap of 1000 may be replaced by paging) | truncated (qualified DriftReport.truncated) |
DriftRefusalCode; code of DriftRefusal | schema; property (as ArtifactRefusalCode) | DriftRefusalCode; code (qualified DriftRefusal.code) |
OpsRefusalCode; code of OpsRefusal | schema; property | OpsRefusalCode; code (qualified OpsRefusal.code) |
evidence of OpsInvocation | property | evidence |
totalCount, skippedCount of OpsInvocationPage | properties | totalCount, skippedCount |
listOpsInvocations | operation (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".
| Read | Cannot do its job | Outcome |
|---|---|---|
Project version, schema | file absent, unreadable, wrong shape | explicit null (FR-002, FR-003); health schema_drift |
Project.currentBranch | detached, no git, unreadable HEAD, unrepresentable name | explicit null (FR-005) |
Project.lastActivityAt | no Mission, or none with activity | explicit null (FR-006) |
Project.lastActivityAt | a Mission's status.json cannot be read | that Mission adds no activity; the others still count (FR-006) |
Project | a Mission's event log cannot be read, decoded or parsed | 500 under the default Problem, no partial Project (FR-006) |
GET /drift | unknown Mission | 404 mission_not_found |
GET /drift | the 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 corrupt | 500 drift_scan_unreadable, no partial report |
GET /drift | the 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 /drift | a 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 /drift | status.json not valid UTF-8, not JSON or not an object | CORRUPT_JSON finding, not a failure (OQ-4) |
GET /drift | legacy-shaped lanes.json | not evaluated for kind 3, counted on the named list (FR-012) |
GET /drift | branch listing fails (no git, no repository, git error) while a Mission has an expected lane | 500 drift_scan_unreadable, never kind-3 findings |
GET /drift | nothing to report | 200, findings: [] |
GET /ops/invocations | kitty-ops/ absent | 200 empty page |
GET /ops/invocations | kitty-ops/ not listable; an Op file or the spine that cannot be opened; the spine holding invalid UTF-8 | 500 ops_unreadable |
GET /ops/invocations | bad cursor | 400 invalid_page_cursor |
GET /ops/invocations | one 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.evidence | no evidence_ref; credential | explicit 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_jsonfiltered 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 throughMissionStatus.loaditself, from the shared memo, and from the own directory for a Mission on the independently derived fallback list below; theCORRUPT_JSONthe classifier emits for an unreadable file is dropped, the reader answers 500; the oracle wraps the classifier so thatUnicodeDecodeErrorbecomesCORRUPT_JSONas in the reader, the wrapper itself checked by a unit test over an undecodablestatus.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 whosematerialize_snapshotthe oracle's own probe sees raise is dropped from the oracle and is a 500 in the reader (named list, expected empty);validate_materialization_driftis used only as a one-way check (every drift it reports implies a classify finding), because it compares fewer fields. Kind 3: the product'sis_mission_completedon 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_atin the ownmeta.jsonas raw JSON;derive_mission_lifecycleof the read directory), with the product'smaterialize_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), themeta.jsonand manifest JSON read directly (the manifest only for a non-completed Mission, completion first), and branch presence by onegit 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-shapedlanes.jsonof 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);skippedCountof 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 /driftover 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, theskippedCountequality, 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 runninggit ls-remote --headson each remote itself, outside the reader, and expects the matching outcome). With reachable remotes, no resolver outcome other thanCoordinationBranchDeletedoccurs 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.jsonnames acoordination_branch, holds nomerged_at(the resolver re-anchors a merged Mission on its own directory), and the branch is in neithergit for-each-ref refs/headsnorgit worktree list --porcelain. The stale-entry check runs both ways: every listed Mission raisesCoordinationBranchDeletedthrough 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;
url0), 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_failuresas 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:urlevidence, a redacted address, a withheld credential. - Read-only proof:
tree_fingerprintofkitty-specs/andkitty-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
| ID | Title | Requirement | Category | Priority | Status |
|---|---|---|---|---|---|
| NFR-001 | Corpus runtime | The 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). | Performance | High | Open |
| NFR-002 | Bounded reads | The 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. | Performance | High | Open |
| NFR-003 | Read-only | Zero 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. | Safety | High | Open |
| NFR-004 | Leak-free artefacts | Zero 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. | Security | High | Open |
| NFR-005 | Deterministic | Two reads of an unchanged tree give byte-identical Project, findings (same refs, same clock), Ops pages and cursors; two bundle builds are byte-identical. | Reliability | Medium | Open |
| NFR-006 | Test discipline | New 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. | Quality | High | Open |
| NFR-007 | No product change | No file under src/ changes. | Locality | Medium | Open |
| NFR-008 | Linear redaction | Redacting 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. | Performance | Medium | Open |
| NFR-009 | Project cost | One 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. | Performance | Medium | Open |
Constraints
| ID | Title | Constraint | Category | Priority | Status |
|---|---|---|---|---|---|
| C-001 | Operator rulings bind | OR-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. | Governance | High | Open |
| C-002 | Version and additive scope | info.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). | Technical | High | Open |
| C-003 | Slice file set | Only the files of the slice file set change. | Technical | High | Open |
| C-004 | Archive freeze | kitty-specs/ on main is archive-frozen: record commits are add-only; the reality check reads read-only. | Governance | High | Open |
| C-005 | Public repository | No 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. | Security | High | Open |
| C-006 | No src/ change | The reads are specified over existing readers; a seam that would need a change is escalated to the operator, not silently edited. | Technical | High | Open |
| C-007 | Tooling | Node-free; no new third-party dependency. | Technical | Medium | Open |
| C-008 | Terminology | Mission, never feature; no feature* alias in any new field, parameter, route or flag. | Governance | High | Open |
| C-009 | Read-only | No POST, no materialize, no repair, no branch creation, no Op invocation; the Ops read never starts an agent. | Technical | High | Open |
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.sourceCodevalues:SNAPSHOT_DRIFT,SNAPSHOT_DRIFT_PROVENANCE,SNAPSHOT_DRIFT_TERMINAL,CORRUPT_JSON, null (the closed pair table of FR-014). - OpsInvocationPage (with
totalCountand the provisionalskippedCount), 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) andOpsRefusalCode(ops_unreadable), each carried by its refusal schema, aProblem;invalid_page_cursoris the existing code ofPageCursorRefusal.
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).
| Property | Citation | Source or rule |
|---|---|---|
Project.specKittyVersion | x-source src/specify_cli/upgrade/metadata.py ProjectMetadata | spec_kitty.version, FR-002 projection rules in the description |
Project.schemaVersion | x-source src/specify_cli/migration/schema_version.py get_project_schema_version | FR-003 |
Project.health | x-derived; inputs schemaVersion; src/specify_cli/migration/schema_version.py MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA, check_compatibility | FR-004 range rule; provisional |
Project.currentBranch | x-source src/specify_cli/core/git_ops.py get_current_branch | FR-005 |
Project.lastActivityAt | x-derived; input MissionHead.lastActivityAt | FR-006 |
DriftReport.scannedAt | x-derived; input src/kernel/clock.py now_utc | the service clock at the scan |
DriftReport.findings | x-derived; inputs DriftFinding.missionId, DriftFinding.kind, DriftFinding.artifactPath | sorted and capped, FR-009 |
DriftReport.truncated | x-derived; input findings | more than 1000 findings existed |
DriftFinding.kind | x-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_completed | FR-010 to FR-012; provisional with DriftKind |
DriftFinding.severity | x-source src/specify_cli/audit/models.py Severity | AD-1 (error, warning) |
DriftFinding.missionId | x-derived; input MissionHead.missionId | the Mission scanned |
DriftFinding.artifactPath | x-source src/specify_cli/audit/models.py MissionFinding | relative path invariant, FR-014 |
DriftFinding.summary | x-derived; inputs kind, sourceCode | fixed text, FR-014 |
DriftFinding.authority | x-derived; input kind | FR-010 to FR-012 |
DriftFinding.derivedSide | x-derived; input kind | FR-010 to FR-012 |
DriftFinding.laneComparison | x-derived; inputs src/specify_cli/status/reducer.py materialize_snapshot, src/specify_cli/status/models.py StatusSnapshot | FR-010; provisional |
DriftFinding.remedy | x-derived; inputs kind, sourceCode, src/specify_cli/status/doctor.py check_drift | AD-4; provisional |
DriftFinding.sourceCode | x-source src/specify_cli/audit/models.py MissionFinding | the audit code |
DriftRefusal.code | x-derived; input src/specify_cli/status/aggregate.py MissionStatus | the reason that ended the read: mission_not_found or drift_scan_unreadable (AD-10), the status fixed by the code; provisional |
LaneComparison.wpId | x-source src/specify_cli/status/wp_metadata.py WPMetadata | as WpId |
LaneComparison.persistedStatusLane | x-source src/specify_cli/status/models.py StatusSnapshot | status.json work_packages lane |
LaneComparison.derivedStatusLane | x-derived; input src/specify_cli/status/reducer.py materialize_snapshot | reducer lane |
OpsInvocationPage.items | x-derived; inputs OpsInvocation.startedAt, OpsInvocation.invocationId | FR-018 order |
OpsInvocationPage.pageInfo | x-derived; input items | as MissionOverviewPage |
OpsInvocationPage.totalCount | x-derived; inputs items, src/specify_cli/cli/commands/invocations_cmd.py _iter_index_reverse, _iter_records_from_dir | FR-018; provisional |
OpsInvocationPage.skippedCount | x-derived; inputs items, src/specify_cli/cli/commands/invocations_cmd.py _iter_index_reverse, _iter_records_from_dir | FR-019: skipped records of the totalCount candidate set, independent of profile; provisional |
OpsRefusal.code | x-derived; input src/specify_cli/invocation/writer.py read_op_closures | ops_unreadable (FR-019), status 500; provisional |
OpsInvocation.invocationId | x-source src/specify_cli/invocation/record.py OpStartedEvent.invocation_id | started.invocation_id |
OpsInvocation.profileId | x-source src/specify_cli/invocation/record.py OpStartedEvent.profile_id | started.profile_id |
OpsInvocation.action | x-source src/specify_cli/invocation/record.py OpStartedEvent.action | started.action |
OpsInvocation.actor | x-source src/specify_cli/invocation/record.py OpStartedEvent.actor | started.actor, shape rule |
OpsInvocation.modeOfWork | x-source src/specify_cli/invocation/record.py OpStartedEvent.mode_of_work | started.mode_of_work |
OpsInvocation.startedAt | x-source src/specify_cli/invocation/record.py OpStartedEvent.started_at | started.started_at |
OpsInvocation.missionId | x-source src/specify_cli/invocation/record.py OpStartedEvent.mission_id | started.mission_id |
OpsInvocation.wpId | x-source src/specify_cli/invocation/record.py OpStartedEvent.wp_id | started.wp_id |
OpsInvocation.status | x-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent, src/specify_cli/invocation/writer.py closed_invocation_ids | FR-017 |
OpsInvocation.outcome | x-source src/specify_cli/invocation/record.py OpCompletedEvent.outcome | completed.outcome, or the spine record |
OpsInvocation.closedBy | x-source src/specify_cli/invocation/record.py OpCompletedEvent.closed_by | completed.closed_by, or the spine record |
OpsInvocation.completedAt | x-source src/specify_cli/invocation/record.py OpCompletedEvent.completed_at | completed.completed_at, or the spine record |
OpsInvocation.evidence | x-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_PATTERNS | FR-020; provisional |
OpsEvidence.kind | x-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent.evidence_ref, src/specify_cli/invocation/writer.py normalise_ref | FR-020 step 1 and 2 classification |
OpsEvidence.value | x-derived; inputs src/specify_cli/invocation/record.py OpCompletedEvent.evidence_ref, contracts/tools/leak_patterns.py redact_emails | FR-020 pipeline, at most 512 characters |
OpsEvidence.redacted | x-derived; inputs value, src/specify_cli/invocation/record.py OpCompletedEvent.evidence_ref | true 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)
| Rule | Plant, with its control | Red when the rule is removed |
|---|---|---|
list-shaped or non-mapping spec_kitty metadata is null | a YAML list; spec_kitty a list. Control: a mapping file in the same directory | an unwrapped get_project_version raises |
the unknown sentinel never escapes; non-string and non-matching versions are null | no version; numeric version; path-shaped or sentence-shaped version. Control: 4.0.0 | the sentinel or the text appears in the payload |
| non-integer schema is null and unhealthy; the CLI coercion is the contract's | schema_version: three (null); "3" gives 3, 3.9 gives 3, true gives 1, as get_project_schema_version. Control: 3 | health stays healthy on three; a coercion differing from the CLI's |
| range boundaries, and range not equality | schema MIN-1, MAX+1, null; constants patched to 2 and 4 with schema 3. Control: MIN, MAX | a 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 resolves | each repository state, a branch home/user1/x. Control: attached branch topic/x | a null on unborn, an invented name, a crash, a missed linked .git file, a host path served |
| activity is compared as instants; null cases | offsets where text order and instant order differ; zero Missions; all-null Missions. Control: one Mission with activity | a string maximum, or a default replacing null |
/project starts no process of its own and writes nothing | subprocess.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 cost | the 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 file | a description saying "a name and a count only", or silent on cost |
AC-DRIFT (FR-008 to FR-015, FR-024)
| Rule | Plant, with its control | Red when the rule is removed |
|---|---|---|
| a differing snapshot is a finding; variant and order | WP01 planned in status.json, approved in the log; a provenance-only edit; an all-done edit. Control: the unmodified Mission gives none | nothing found, or variants confused |
| a corrupt snapshot is a finding; an unreadable one and a raising reducer are 500 | status.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 corruption | a 500 or a missing finding for the first two, a finding or an empty 200 for the others |
laneComparison rows and nulls | a work package on one side only; a genesis lane. Control: a plain lane difference | a 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-4 | log without snapshot (remedy materialize_status); snapshot without log (remedy null); neither. Control: both present | a finding for neither, none for one, the wrong path or remedy |
remedy null on a Mission merged to main | a kind-1 SNAPSHOT_DRIFT finding on a Mission whose meta.json has merged_at. Control: the same difference on an unmerged Mission keeps materialize_status | a remedy on a frozen Mission |
| expected lane branch absent | active Mission, lane in_progress, no branch. Control: branch created | no finding on a real absence |
| coordination topology expects the coordination branch only | meta.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_branch | a finding for the unmade mission_branch, or none for a lost coordination branch |
| local branches only | a branch present only as a remote-tracking ref. Control: the same branch local | a remote ref counted as present |
| not expected: planned-only lane, completed Mission, absent manifest | each beside the flagged Mission | a finding for a merged or unstarted Mission (the 488-finding noise) |
lanes.json three-way; completion first | on 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 manifest | a 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-closed | meta.json as [] and as invalid JSON (500). Control: the same Mission before; an absent meta.json is no coordination branch | a skipped Mission or a crash |
| git failure is never kind 3 | a 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 failed | a 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 it | a 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 file | a 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 run | a shared entry across repositories |
the read directory is the coordination-aware one; CoordinationBranchDeleted is the named fallback; every other resolver outcome is 500 | a 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 directory | the 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 directory | a 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 manifest | a 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 clean | an uncaught UnicodeDecodeError, a 500 for status.json, a finding for the others |
| the fourth kind is absent | stale .kittify/derived/ beside a clean and beside a mismatching Mission. Control: the mismatch gives kind 1 only | a derived-view read, or a fourth DriftKind value (enum pin) |
| order and cap | 1001 findings created in shuffled order; exactly 1000. Control: 1000 gives truncated: false | unsorted output, 1001 returned, wrong truncated |
| fixed summaries, closed pair table, no leak | a 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 text | a forwarded source string, an unknown pair accepted |
scannedAt is the scan time; no-store declared | injected clock advanced between reads; a bundle without the header. Control: the first read, the bundle with it | a cached scannedAt, a missing header |
| 404, 400, per-Mission equality | unknown ULID; non-ULID; project-wide against per-Mission. Control: a known ULID | wrong status or inconsistent findings |
| read-only | materialize and file creation raising; a control that writes. Control: fingerprint equal after the scan | a writing call; and the control shows the probe sees writes |
| no dead value | each 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)
| Rule | Plant, with its control | Red when the rule is removed |
|---|---|---|
| newest first, ties by id descending | three Ops with equal startedAt; Ops with different offsets. Control: distinct times | ascending or insertion order |
| paging once, cursor bound to the filter | an Op written between two page reads; a cursor from profile=a used with profile=b and without; a forged cursor. Control: unchanged tree, same filter | duplicates, a gap, a cursor accepted across filters |
filter; totalCount equals the served count | a profile with no Op; a malformed profile; skipped and filtered records present. Control: a matching profile | a non-empty page, a 200 on a malformed value, a count including skipped records |
skippedCount over the candidate set, independent of profile | skipped records of several profiles with a profile filter set. Control: no skipped record gives 0 | a count that follows the filter, or a skipped record counted in totalCount |
| index candidates and lag | lagging 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 scan | items or counts from the wrong source, a stale entry missing from skippedCount, the index's profile used |
| closure spine | a 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 instants | open 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 invariants | an Op carrying an outcome while open. Control: consistent Ops | an inconsistent payload validates |
| legacy and unreadable are skipped; non-Op files are not records | legacy 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 them | a skipped record appears or a non-Op file is counted |
| empty and failing directory; unreadable files are 500 | no 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 Op | a 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 URL | a leaked credential, address or path; wrong kind; unbounded value; redacted false on a changed value |
| credential withheld before redaction, per shape | a 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 value | a 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 included | a 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 shape | an Op whose actor is e-mail-shaped: served in items with actor null and status, startedAt unchanged. Control: actor claude keeps its value | an address in the payload; or a dropped record or failed read, which would also pass an "absent address" check |
| omitted fields | a reader adding request_text; the description without one of the five names. Control: the real description | the closed schema or the description test passes it |
| read-only, no agent; linear redaction | InvocationWriter and append_to_index raising; three 256 KiB shapes (no whitespace; repeated a@; repeated host-path-root runs). Control: normal listing, a short value | a 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-ancestoroforigin/mainholds (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 planted409on an existing operation fails. - The lowered-major run reports exactly the non-provisional
Projectadditions inbreaking=(control: a planted extra property onWorkPackageraises the count by one); the same-major run failsBREAKING_WITHOUT_MAJORon 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 failsBUNDLE_CHANGED_VERSION_SAME). info.versionequals the version onmain; everyx-provisionalelement of the FR-023 table is named in the CHANGELOG (a planted unnamed one fails for the unique tokens; the qualified namesDriftFinding.kind,DriftReport.truncated,DriftRefusal.codeandOpsRefusal.codeare 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, thatinfois not carried and thatmissionIdandartifactPathare 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-statusand the CHANGELOG entry) finds nofeature; thefeature_slugliteral of the reference reader's legacy-shape test is the sole accepted occurrence outside that scope, pinned by a test (planted: a secondfeatureuse in the reader fails). - FR-028 record (J-1), checked at the wrap-up by recorded commands: the cited commit exists (
git cat-file -tgivescommit), is an ancestor ofHEAD, and is the last commit touchingcontracts/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 commandgit 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)
| Rule | Plant, with its control | Red 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 only | a 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 set | an 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 set | a 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 directory | an 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 /driftover this checkout is a 200 with no resolver outcome other thanCoordinationBranchDeleted(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
skippedCountequals the oracle's skipped list;kitty-specs/andkitty-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,skippedCountequals the oracle's skipped list, thekitty-specs/andkitty-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/andkitty-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 ofGET /drift; there is no drift event onGET /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 (
Categoryinsrc/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
cliVersionfield, 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). ExtendingSECRET_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 theProjectchange 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
currentBranchis null there and the corpus assertion is against the oracle. - A-4
kitty-ops/ops-index.jsonlis 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_missingdepends 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.jsondoes not, because that file is never read (FR-012). Mitigation:missionIdisolates; the 500 is loud by design. - R-3
healthandcheck_compatibilitycan diverge if the two schema constants differ;healthalso 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
skippedCountare settled, at or before release; the CHANGELOG names it. - R-5
x-provisionalremovesProject.healthfrom 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_snapshotignores 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 whoseagentkeeps the prior reviewer after a fresh claim is a realSNAPSHOT_DRIFTthat can survive the product's own repair. And by design the replay runs at the generation recorded in the existingstatus.json(FR-010), so an older snapshot lacking a newer field is no disagreement. Kind 1 therefore reports disagreement withmaterialize_snapshot's output at the snapshot's own generation, not with the true event log; theDriftFindingdescription says so, no new check is added, and a fixture pair pins the generation gate (FR-026). - R-9 The cost of
GET /projectand 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 networkgit ls-remote --headsprobes, 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 itsgit worktreeregistry reads are the stock ones. Mitigation: the memo (FR-015), the NFR bounds set on that measurement, the stated cost in theProjectdescription. - 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
Projectfor 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 /driftover 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, withexamined + skipped == discoveredfor 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
DriftKindenum 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/invocationsover the realkitty-ops/walks every served Op exactly once instartedAt,invocationIddescending order, serves the 6 spine-closed Ops as closed, skips the legacy records by rule withskippedCountequal 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_PATTERNSkinds, 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-provisionalelement 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 thatderived_view_stalewas 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.ymlover the final shapes; its record intracer-design-decisions.mdcites the commit of the finalcontracts/change and one line per job with its conclusion, and the wrap-up commandgit diff --exit-code <cited commit> HEAD -- contracts/mission-statusis 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
skippedCountrows — [folded] · no-op passable: no