Data Model: Mission Status contract, health, drift and ops reads

Mission mission-status-health-drift-ops-01M464D3 (#5776). The entities of the three reads, the derivation rules in their order of decision, the closed vocabularies, the status and refusal pairings, and the floors with the independent counts they are compared against. The product contract is the OpenAPI module itself once built; this note fixes the shapes the work packages build from. Every rule is the spec's (FR-001 to FR-029, AD-1 to AD-10 and the operator rulings); nothing here adds a rule. Plain text fences only.

Entities

EntityMembers (all required; nullable where stated)Closed
Project (extended)name, missionCount, specKittyVersion (string or null), schemaVersion (integer or null), health (ProjectHealth; provisional), currentBranch (string or null), lastActivityAt (date-time or null)yes
DriftReportscannedAt (date-time), findings (array of DriftFinding, at most 1000), truncated (boolean; provisional)yes
DriftFindingkind (DriftKind; provisional), severity (DriftSeverity), missionId (MissionId, non-null), artifactPath (the artifact-path rule, non-null), summary (fixed text), authority (DriftAuthority), derivedSide (DriftSide), laneComparison (array of LaneComparison; provisional), remedy (DriftRemedy or null; provisional), sourceCode (string matching ^[A-Z][A-Z0-9_]*$ or null)yes
LaneComparisonwpId (WpId), persistedStatusLane (StatusLane or null), derivedStatusLane (StatusLane or null)yes
DriftRefusala Problem with code (DriftRefusalCode; provisional) and one if/then per code pinning status (mission_not_found 404, drift_scan_unreadable 500)as Problem plus code
OpsInvocationPageitems (array of OpsInvocation), pageInfo (shared PageInfo), totalCount (integer at least 0; provisional), skippedCount (integer at least 0; provisional)yes
OpsInvocationinvocationId (ULID), profileId, action (both ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$), actor (ActorHandle, nullable), modeOfWork (OpsModeOfWork), startedAt (date-time), missionId (nullable), wpId (nullable), status (OpsInvocationStatus), outcome (OpsOutcome, nullable), closedBy (OpsClosedBy, nullable), completedAt (nullable), evidence (OpsEvidence, nullable; provisional)yes
OpsEvidencekind (OpsEvidenceKind), value (string, at most 512 characters), redacted (boolean)yes
OpsRefusala Problem with code (OpsRefusalCode; provisional), status pinned (500)as Problem plus code

The record fields left out on purpose are named in the OpsInvocation description: request_text, model_id, governance_context_hash, governance_context_available, router_confidence (FR-021). The Project description names the five properties, no longer says "a name and a count only", and states the cost (it grows with the Mission count and with the network, FR-007).

The memo and the fallback (FR-015)

memo key      (resolved repository root, Mission directory name)
memo value    raw resolver outcome: a directory, or the exception it raised
              plus the number of subprocesses the resolver call started
consumers     strict drift sibling   CoordinationBranchDeleted -> own directory, reason coordination_branch_deleted
                                     every other exception     -> unreadable (500 drift_scan_unreadable)
                                     directory outside the root -> unreadable
              Project builder        the three exceptions and the outside-root case -> own directory (v1's four fallbacks)
              oracles                the memo's raw outcome (never the reader)

Own directory: kitty-specs/<name>/. Read directory: the directory the resolver returns (the own directory except for an unmerged coordination-topology Mission, whose read directory is the coordination surface). meta.json, lanes.json and the merge marker are read in the own directory; status.json and status.events.jsonl in the read directory.

Project: the order of decision per property

specKittyVersion  .kittify/metadata.yaml via ProjectMetadata.load (never the CLI wrapper)
  absent, empty, not YAML                              -> null
  guard (plan D-P4): the parsed YAML is checked first; ProjectMetadata.load is called only when the top level is a mapping
  and spec_kitty is a mapping; a residual AttributeError or TypeError from load (environment a list or scalar, a
  non-mapping migrations entry) is read as null
  top level not a mapping, or spec_kitty not a mapping -> null (the read does not raise, never a 500)
  no spec_kitty.version (the product's sentinel)       -> null
  version not a string                                 -> null
  string not matching ^[0-9A-Za-z][0-9A-Za-z.+!_-]{0,63}$ -> null
  otherwise                                            -> the string (strict field: never redacted)
schemaVersion     get_project_schema_version(repo root): its integer, or null when it returns None
                  the CLI's coercion is the contract's ("3" gives 3, 3.9 gives 3, true gives 1)
health            healthy exactly when schemaVersion is not null and
                  MIN_SUPPORTED_SCHEMA <= schemaVersion <= MAX_SUPPORTED_SCHEMA (the serving build's range); else schema_drift
                  never depends on a drift finding (OR-8)
currentBranch     HEAD file (the gitdir when .git is a file); no subprocess
  ref: refs/heads/<name>      -> <name> (an unborn branch is its name)
  detached, no .git, unreadable or unparseable HEAD -> null
  name failing ^[A-Za-z0-9][A-Za-z0-9._/-]*$, longer than 255 characters,
  or matching the strict host-path patterns         -> null
lastActivityAt    the latest MissionHead.lastActivityAt over exactly the Missions GET /missions lists,
                  compared as instants, emitted as that Mission's own string;
                  null when no Mission is listed or every listed Mission has null activity

Drift: the order of decision per Mission (FR-015, AD-10)

0  the Mission directory under kitty-specs/        (a directory with an unreadable identity matches no ULID; it does not fail another Mission's report)
1  read directory: memo outcome
     directory                                    -> read it
     CoordinationBranchDeleted                    -> own directory; fallback Mission, reason coordination_branch_deleted
     any other resolver exception, outside root   -> 500 drift_scan_unreadable (no partial report)
2  meta.json in the own directory (load_meta_fail_closed)
     absent                                       -> names no coordination branch
     exists but corrupt, not an object, not UTF-8 -> 500 (MissionMetaReadError)
3  completion (the two arms, applied by the reader):
     own meta.json holds a merged_at instant, OR derive_mission_lifecycle(read directory) is recently_completed or archived
4  kind 1 / kind 2 over the read directory (see below)
5  kind 3, only for a Mission that step 3 reports not completed, and only now is lanes.json opened

Status files (kinds 1 and 2).

status.jsonstatus.events.jsonlOutcome
absentabsentno finding (nothing to compare; 2 of 569 here)
absentpresentkind 2, artifactPath status.json, warning, remedy materialize_status
presentabsentkind 2, artifactPath status.events.jsonl, warning, remedy null
not valid UTF-8, not JSON, or JSON but not an objectpresent (the population of kind 1 holds both files)kind 1, CORRUPT_JSON, error, laneComparison []; with the log absent the Mission is a kind-2 finding instead
unreadable (OSError)any500
validpresent, reducer raises (including an event log that is not UTF-8)500
validpresent, equal under the classifier's normalisationno finding
validpresent, differskind 1; SNAPSHOT_DRIFT_PROVENANCE when the difference is provenance-only (actor, last_event_id, last_transition_at per work package), else SNAPSHOT_DRIFT_TERMINAL when every work package of the reducer output is done, else SNAPSHOT_DRIFT (error)

Kind 1 compares status.json with materialize_snapshot(<read directory>), which replays at the generation recorded in the existing status.json; never materialize. laneComparison: 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; [] when the files differ only outside lane.

remedy is materialize_status for SNAPSHOT_DRIFT and CORRUPT_JSON and for a kind-2 finding whose log exists; null for the two warning variants, for a kind-1 error on a Mission merged to main (the own meta.json has merged_at), for kind 2 with a missing log and for kind 3. materialize_status is spec-kitty agent status materialize --mission <slug>; the client builds the text and takes the slug from MissionHead.slug of the finding's missionId.

Kind 3 (lane_branch_missing; provisional).

population     every Mission step 3 reports not completed, in any lifecycle state, that has a lanes.json
lanes.json     raw JSON first:
                 object with mission_slug                       -> current shape: read_lanes_json / LanesManifest.from_dict
                 object with feature_slug and no mission_slug   -> legacy shape: not evaluated for kind 3; kinds 1 and 2 still apply; counted on the named list
                 anything else (not UTF-8, not JSON, not an object, neither key, current-shaped but refused by read_lanes_json) -> 500
code lane      every lane other than lane-planning
expected lane  one of its work packages is, in the reducer output, claimed, in_progress, for_review, in_review or approved
expected names code_lane_branch_name(mission_slug, lane_id) per expected lane, and, when any lane is expected,
               the coordination_branch that meta.json names, else the manifest's mission_branch (never both)
source         at most one `git for-each-ref refs/heads`, run once when the first Mission with an expected lane needs it
               non-zero exit, missing git, not a repository: 500 (never "every branch is absent"); zero listings when no Mission has an expected lane
finding        one per Mission when any expected name is not a local head: artifactPath lanes.json, warning, authority git,
               derivedSide lanes_json, laneComparison [], sourceCode null, remedy null; no branch name appears

The fixed summaries (the pair set is closed; a pair outside it is a reference-reader failure with a planted case):

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

Fixed fields by kind: kind 1 and kind 2: authority event_log, derivedSide status_json; kind 3: authority git, derivedSide lanes_json. severity: SNAPSHOT_DRIFT and CORRUPT_JSON error, the two variants and kinds 2 and 3 warning.

Order and cap. Findings sort by (missionId, kind, artifactPath) in UTF-8 byte order; the key is unique (one finding per Mission, kind and artifact path), so the order is total. More than 1000: exactly the first 1000 and truncated true; exactly 1000: false. scannedAt is the service clock (now_utc) at the scan; the 200 documents Cache-Control: no-store.

The 500 line (AD-10), as a table. The report is refused 500 drift_scan_unreadable, never partial, when, for any scanned Mission: a resolver outcome other than a directory or CoordinationBranchDeleted; a read directory outside the repository; an OSError on status.json; an event log that cannot be reduced; meta.json corrupt, not an object, not UTF-8 or unreadable; lanes.json of a non-completed Mission neither current nor legacy shaped; or the branch listing failing while a Mission has an expected lane. Defined outcomes, not failures: a legacy-shaped manifest (counted); a status.json that is not UTF-8, not JSON or not an object (a finding); a fallback Mission.

Ops: the order of decision per record (FR-016 to FR-019)

candidate set   the distinct invocation ids of kitty-ops/ops-index.jsonl when it exists and can be opened (a corrupt line is skipped),
                else the Op files of the directory (names matching the 26-character Crockford ULID plus .jsonl);
                the index only supplies candidates: the profile filter, totalCount and the cursor binding use the profile_id of the Op's own started event
directory       absent                          -> 200, items [], totalCount 0, skippedCount 0, hasNextPage false
                exists but not listable / not a directory -> 500 ops_unreadable
spine           kitty-ops/op-closures.jsonl: OSError or invalid UTF-8 -> 500; a corrupt line is skipped
per candidate   no file (index entry without a file, or a file gone after listing)  -> skipped and counted
                OSError opening the file (other than FileNotFoundError)            -> 500
                first line empty, not JSON, not a v2 started event, stem differs from invocation_id,
                legacy started, started_at not an RFC 3339 instant with an offset, a required field failing its shape,
                invalid UTF-8 anywhere in the file                                  -> skipped and counted
completion      first own-file completed line that is valid -> closed from it
                else the first spine record in file order for the id  -> closed from it
                the own file wins when both exist; a legacy own completion with a spine record is served closed from the spine;
                a legacy own completion with no spine record skips the Op; a spine record for an id with no served Op is ignored
status          closed when a completion was found, else open

totalCount is the number of served Ops matching profile (the description says it can lag the newest records); skippedCount counts skipped records over the same candidate set, independent of profile. Order: startedAt descending as instants, ties by invocationId descending. The cursor is opaque, bound to profile and to the keyset position; a cursor issued for another profile, malformed or forged is 400 invalid_page_cursor.

Invariants checked by the reference validator: open implies outcome, closedBy, completedAt and evidence null; closed implies the first three non-null. A mission_id or wp_id failing its pattern makes the record unreadable (skipped), not null; only actor degrades to null.

Evidence: the pipeline (FR-020)

0  credential check on the stored evidence_ref, before everything, for every shape (SECRET_PATTERNS kinds): a match withholds the evidence:
   the Op is served, evidence null, nothing else changes, no marker, no failure
1  classify the stored string
     file:// URL (any case)                            -> kind text, value [path]
     other URL scheme (^[A-Za-z][A-Za-z0-9+.-]+://)    -> kind url; userinfo, query and fragment removed
     starts with /, ~/, two backslashes, or drive letter + colon -> kind text, the whole value [path] (also with whitespace)
     single line, no whitespace, passes the artifact-path rule   -> candidate kind repo_path
     otherwise                                         -> kind text
2  redact what step 1 did not replace: each embedded scheme:// run loses userinfo, query and fragment;
   each human host-path run becomes [path]; each address becomes [email] (redact_emails, never EMAIL_PATTERN);
   a repo_path candidate changed here becomes text; a url not parseable after 1(b) becomes text
3  a result over 512 code points is cut to 512, its kind becomes text, redacted is true
redacted       true exactly when value differs from the stored string

The evidence description says null means "no evidence, or withheld because it holds a credential", names the credential kinds checked (GitHub classic and fine-grained tokens, AWS access key id, private-key header) and says other secret shapes are not detected. request_text and model_id never leave the service.

Status and refusal pairing

OperationStatusCodeBody
getDriftReport200DriftReport (Cache-Control: no-store)
404mission_not_foundDriftRefusal
500drift_scan_unreadableDriftRefusal
400 (a non-ULID missionId)the shared Problem under default
listOpsInvocations200OpsInvocationPage
400invalid_page_cursorthe existing PageCursorRefusal response
400 (a profile failing its pattern)the shared Problem under default
500ops_unreadableOpsRefusal

Closed vocabularies (pinned in contracts/tools/enum_pins.json under mission-status; all snake_case)

EnumValuesProducer (no dead value, AD-6)
ProjectHealthhealthy, schema_driftproject fixtures
DriftKindsnapshot_disagrees_with_event_log, snapshot_or_event_log_missing, lane_branch_missingone fixture each; derived_view_stale is not a value
DriftSeverityerror, warninginfo of the audit vocabulary is not carried
DriftAuthorityevent_log, gitmeta has no producer
DriftSidestatus_json, lanes_jsonderived_views has no producer
DriftRemedymaterialize_status
DriftRefusalCodemission_not_found, drift_scan_unreadable
OpsRefusalCodeops_unreadable
OpsModeOfWorktask_execution, advisory, mission_step, queryone Op per mode
OpsInvocationStatusopen, closed
OpsOutcomedone, failed, abandoned
OpsClosedByagent, doctor_sweep
OpsEvidenceKindrepo_path, url, text

Thirteen new enums, 29 values; the pin check line moves from counts: enums=7 values=43 to counts: enums=20 values=72 once all are pinned (planned; the figure is re-computed by the work package that adds the last pin). Titles are the schema file stems.

Floors and the independent counts (plan D-P13; fixed at plan time, never re-pinned)

QuantityPlan-time measureFloorIndependent count it is compared with
Missions examined569540a scan for meta.json
Missions declaring no coordination branch, compared in the completion equality (AC-DRIFT 19, reality module)518 (569 less 51 declaring one)490a meta.json scan for Missions with no coordination_branch
Ops served473440a direct scan of every Op file and of the spine
Spine-closed Ops65the spine's records whose Op is open in its own file
Own-file evidence: none / absolute / free text / relative351 / 32 / 56 / 28330 / 28 / 50 / 25a regex classification of the stored strings
Evidence of kind url, a redacted address, a withheld credential0fixture-onlyplanted fixtures
Kind-1 findings44 (43 terminal, 1 provenance, 0 plain)39file presence plus classify_status_json filtered to the four codes
Kind-2 findings3127the file-presence scan
Kind 3not measured under the final rulenoneequality with the oracle only
Fallback Missions37 (clone and network dependent)nonethe independent derivation, with the stale-entry check both ways

Named lists (examined plus skipped equals discovered; a 500 outside a list or a stale entry fails): Ops whose own completion line lacks closed_by and with no spine record (7); the legacy-shaped lanes.json of a non-completed Mission, counted for kind 3 only (1, 064-complete-mission-identity-cutover); the fallback Missions (37). skippedCount of the whole Ops walk equals the oracle's skipped list. A named anomaly fixture (tests/contract/fixtures/mission_status_health_drift_ops_expected.json) lists the 7 Op ids and the one legacy Mission by name as a subset check; the fallback Missions are never pinned.