Mission Specification: Mission Status Read API, overview (Java service)

Mission: mission-status-read-api-overview-01M4EMSA Mission ID: 01M4EMSAR6HTV5FK2KTHMBTCF8 Mission Branch: issue-5529-mission-status-read-api-overview Created: 2026-10-08 Status: Draft Input: GitHub issue #5529 "Mission Status Read API: overview from the ledger and events", under epic #5528; ADR docs/adr/4.x/2026-10-01-2-mission-status-read-api-and-dashboard-extraction.md including "Amendment 2026-10-03"; contract contracts/mission-status/openapi.yaml (#5558).

Summary

The bundled dashboard that used to show "what is happening across missions" was deleted (#5545) and nothing has replaced its overview. This mission builds the overview half of the replacement: a detached, read-only, loopback-only HTTP service in services/mission-status-api/ (a directory that does not exist on this checkout today) that answers three operations of the contract-first document contracts/mission-status/openapi.yaml:

  • GET /api/v1/project: the project header.
  • GET /api/v1/missions: paged Mission overviews (MissionOverview).
  • GET /api/v1/events: a server-sent-events (SSE) change stream, so a caller refetches only on change.

It computes every answer from the ledger (status.events.jsonl) through a Java port of the status reducer (the reduction materialize_snapshot runs, ordered by (at, event_id); operator answer Q-7), never from work package frontmatter or artifacts, and it must give the same overview as Python's reducer on every Mission. That sameness is a gate that fails on any difference, not a report. The mission detail half (/missions/{id} and work packages beneath) is #957 and is out of scope.

Why it matters: today two Python reduction orderings ship: the wall-clock (at, event_id) reduction that materialize_snapshot and reduce_parsed share, and the in-package reduce_status_events ordered by (lamport_clock, timestamp, event_id), which the overview does not use (Q-7; the ADR and CLAUDE.md wording "Lamport" for the shared one is stale), a third board built by agent tasks status --json, and no overview at all for the new dashboard and Kitty Desktop. A single answer served over a transport a browser can use, with a cursor-keyed cache, also closes the cost side of #4520.

Clarifications / Operator decisions (2026-10-08)

These are settled. They are constraints on the plan, not open questions.

The 2026-10-08 decisions below are labelled D-Q1 to D-Q3. They are a different list from the questions Q-1 to Q-7 under "Questions and their resolutions" below; a bare "Q1" never appears in this specification.

  • Stack: Java 25, Spring Boot 4, Spring MVC on virtual threads (not WebFlux); springdoc-openapi was dropped by the 2026-10-10 decision E-11 below. The shipped artifact is a GraalVM native binary per OS, built with Spring AOT and the GraalVM Native Build Tools plugin (nativeCompile).
  • Build: Gradle with Kotlin DSL (not Maven, not Groovy), a committed wrapper and a version catalog. It is a STANDALONE build in services/mission-status-api/, not a composite build with contracts/.
  • Architecture: domain-driven design with hexagonal ports and adapters. The domain (Mission, Work Package, Lane, status event, status reducer, overview read model) and application layers are framework-free. The inbound port MissionStatusQuery offers the project header, mission overviews and changes since a cursor. The outbound port MissionEventSource reads the ledger and event logs. Adapters are adapter.in.http (REST and SSE) and adapter.out.filesystem. Spring is confined to adapters and config. ArchUnit enforces the dependency direction. The inbound port mirrors the Python read port (#5532) one to one.
  • Scope: GET /api/v1/project, GET /api/v1/missions, GET /api/v1/events (SSE), plus the scaffolding (module, CI workflow, native release matrix Linux x64/arm64, macOS arm64, Windows x64). Detail routes are #957 and out of scope. Artifact, drift and ops routes are out of scope. Loopback-only bind, Host and Origin checks, no secrets in responses. Read-only and drain-independent. The CLI --json verbs stay on the Python read port (out of scope).
  • D-Q1 conformance = goldens plus nightly: commit a small curated synthetic mission set (two interleaved, one coordination-topology, one lifecycle-only, one log-truncated) with expected-overview goldens generated by a Python script. A Python test, in the router corpus job, asserts the goldens equal live Python output. The Java suite, written against the inbound port, asserts Java equals the goldens. A nightly job compares the full corpus both ways. The gate fails on any difference.
  • D-Q2 contract consumption: generate Spring interfaces and models only for /project and /missions from the split contract root at build time (openapi-generator 7.25.0, the same pin as contracts/). Hand-write the SSE controller. A contract test validates every real response against the contract with an OpenAPI 3.1-capable validator. The first work package spikes the generated models and falls back to hand-written DTOs if they are unusable.
  • D-Q3 supply chain: match contracts/: committed wrapper with distributionSha256Sum, the Gradle wrapper-validation action, a strict gradle/verification-metadata.xml, and a version catalog.

Operator answers (2026-10-10)

These are settled and binding. Q-3, Q-4 and Q-5 stay open.

  • Q-7, ordering: port what Python runs. Java sorts by (at, event_id) exactly like materialize_snapshot (spec_kitty_events 10.4.0 diary.reduce_parsed), pinned by the goldens. A move to causal ordering is a Python-side change first (#4941); the Java port follows then. The 2026-10-08 Architecture bullet and the FR-005 title originally read "Lamport reducer"; both were reworded to match this answer. The ADR and CLAUDE.md "Lamport" wording is stale (Known drift, item 4).
  • Q-1, per-Mission failure: keep the behaviour specified in FR-004. No contract change for a per-Mission failure member. This mission makes no edit to contracts/: the Project.lastActivityAt wording error (it wrongly names status.json as an input) is only recorded here (Known drift, item 3) as a note for the contract owners, not a deliverable.
  • Q-2, stream faults: keep the behaviour specified in the FR-004 stream table. A Mission-scoped stream closes on a log fault; the unscoped stream drops only the faulty Mission and keeps the connection.
  • Q-6, torn tail: refuse it (500 ledger_torn_tail), as specified.

Operator decisions on the plan escalations (2026-10-10)

The plan (plan.md, section 14) put escalations to the operator. These four are decided and binding; the other escalations keep the interim behaviour the plan states.

  • E-1, AC-9b Host refusals: split the expectation. A Host value the filter can see is refused with 403 request_refused. A malformed Host value that the container rejects itself keeps the container's 400. The specification fixes the closed list of values that get 400 (AC-9b). Both kinds are refused before any read. There is no custom container valve.
  • E-4, release job: shipped in this mission. FR-015 and AC-15a carry the tag pattern, the job if:, contents: write on that job only, tag-aware concurrency, and a tag pattern disjoint from v.. and contract--v..*. The maintainer cuts the first tag.
  • E-10, nightly exemptions: none. The nightly fails on any Java/Python difference and names the Mission and the class (for a whole-read 500, which cannot be attributed to a Mission over HTTP, the side, the route and the Problem code; AC-8c). D-Q1 and AC-8c are unchanged.
  • E-11, the OpenAPI document: contract-first. The running service serves the committed contracts/mission-status OpenAPI document as it stands, bundled at build time, at the fixed path /api/v1/openapi.yaml. springdoc does not generate or serve a document at runtime and has no remaining role, not even in test scope; FR-009, FR-011 (AC-11d), C-001 and C-004 are reworded to match. The reason: a document generated from the controllers is a second statement of the contract and can drift from the committed one, which the generated interfaces and the response validator already follow. The issue text gets a one-line correction, which the orchestrator makes with the operator's consent.

Known drift (recorded, not resolved here)

This mission does not edit .kittify/charter/charter.md. It records two places where the charter (version 1.5.0, last updated 2026-10-06) is out of step with the work, and two places where other text is out of step with the code. Nothing in this section is fixed here:

1. Languages. Charter section "Technical Standards" > "Languages and Frameworks" reads: "Python 3.11+ is required for all CLI and library code." and lists only Python dependencies (typer, rich, ruamel.yaml, pytest, mypy). "Deployment and Constraints" repeats "Python 3.11+" and "PyPI distribution via automated release workflow". The service is neither CLI nor library code, is not on PyPI and is not in the wheel, so the sentence does not literally forbid it. But the charter names no Java stack, and no charter text authorises one. The authority is the ADR amendment (Accepted, Stijn Dejongh, 2026-10-03), which places a JVM module in this repository (D-7) and accepts the JVM toolchain and native release matrix as new CI cost confined to a path-filtered workflow. The charter's Python-specific coverage rules (pytest 90%+, mypy --strict) do not apply to Java; the Java equivalent is a plan choice (NFR-006). Note that CLAUDE.md and AGENTS.md say nothing about Java; charter wins on any conflict and no conflict was found beyond this silence. 2. Dashboard scale. Charter "Performance and Scale" says: "Dashboard must support 100+ work packages without lag". The ADR lists this as open question 3: it refers to the deleted dashboard and needs a charter amendment restating it as a read-API budget. That amendment is open and is not a gate of this mission. NFR-001 states this mission's own budget so the work is not left without one. 3. Contract text (Project.lastActivityAt). The description in contracts/mission-status/schemas/Project.yaml says a Mission "whose status.json cannot be read leaves its activity out", so it names status.json as an input. The Python reference computes lastActivityAt from the reduced snapshot and the service never reads status.json (FR-001, FR-010). This mission does not edit contracts/ (FR-001, AC-14d, C-012); the wording error is a note for the contract owners. 4. Reducer ordering wording. CLAUDE.md and the ADR call reduce_shared_state "Lamport"; at spec-kitty-events 10.4.0 it sorts by (at, event_id) (Q-7). This mission does not edit CLAUDE.md or the ADR; correcting them is left to their owners.

User Scenarios & Testing

User Story 1 - A dashboard lists every Mission without rescanning (Priority: P1)

A dashboard or desktop shell started against a project calls GET /api/v1/project and pages through GET /api/v1/missions to render a list headed "project, branch, N Missions", each row showing identity, topology, lane counts, progress, lifecycle status and the event-log position.

Why this priority: it is the entire reason the service exists and the first screen of the new dashboard.

Independent Test: start the service on a fixture project, fetch both routes, validate the bodies against the contract, and compare each overview to the golden.

Acceptance Scenarios:

1. Given a fixture project with the curated Mission set, When /api/v1/missions is paged to the end, Then every Mission appears exactly once in the contract order (createdAt descending, missionId ascending) and each overview equals its golden. 2. Given a repeated call with no new events, When the service answers, Then it does not re-reduce any unchanged log (cursor-keyed cache hit, observable through a reducer-invocation counter).

User Story 2 - A client refetches only on change (Priority: P1)

A client opens GET /api/v1/events and receives status-transition, mission-lifecycle and log-truncated events, then refetches the affected Mission.

Why this priority: it replaces the 1 s poll that caused #4520.

Independent Test: append events to a fixture log while a stream is open and assert the framing, ids and ordering.

Acceptance Scenarios:

1. Given an open stream scoped to one Mission, When a status transition is appended, Then exactly one status-transition event arrives with an id: equal to the new cursor. 2. Given a Mission-scoped stream, When the log shrinks, Then a log-truncated event arrives without an id: line, and a reconnect with the pre-truncation id is refused with 409 out_of_range (AC-3c; a same-length rewrite of the last line is content_mismatch, also AC-3c).

User Story 3 - A maintainer trusts that Java and Python agree (Priority: P1)

A maintainer changing the Python reducer or the Java reducer gets a red gate the moment the two overviews differ.

Why this priority: the ADR accepts two reducer implementations "only because the conformance test is a gate, not a report".

Independent Test: perturb one golden field or the Java tie-break and observe the Java suite fail; perturb the Python reducer and observe the Python golden test fail.

Acceptance Scenarios:

1. Given two Missions with interleaved concurrent events, When both reducers run, Then their overviews are identical field by field. 2. Given a deliberately altered sort tie-break in the Java reducer, When the Java suite runs, Then it fails on the interleaved Missions.

User Story 4 - The service cannot be turned against the user (Priority: P2)

A web page in the user's browser tries DNS rebinding or a cross-origin read against the service.

Why this priority: the deleted dashboard had exactly these defects (#4767 to #4769).

Independent Test: one request each with a foreign Host, a foreign Origin, and a non-loopback bind configuration.

Acceptance Scenarios:

1. Given the service running, When a request carries Host: evil.example, Then it is refused before any Mission is read. 2. Given a configuration asking for 0.0.0.0, When the service starts, Then it exits non-zero with a message naming the refused address and never listens.

Edge Cases

  • Unreadable event log (permission denied, I/O error, a symbolic link where the ledger should be): the contract's own rule reads such a Mission as wpTotal 0 and lifecycleStatus draft (MissionHead.yaml, lifecycleStatus description, rule 2: "including an unreadable event log"). The decided behaviour is that contract rule (draft, or discarded when discardedAt is set, rule 1 first) plus a path-free diagnostic, and the operator decided it (Q-1; FR-004 table, row L2). An event log whose bytes were read but cannot be decoded or parsed is a different condition: for GET /api/v1/project the contract itself fails the whole read with a 500 Problem (Project.yaml, lastActivityAt description; FR-004 table, row L3a); for GET /api/v1/missions the contract is silent and the whole-page 500 is this spec's own inference, decided by the operator (row L3b, Q-1), in tension with the "one bad Mission must not deny the others" rule applied to symbolic links in row L9.
  • Truncated or torn final line: live Python gives two answers for the same bytes. materialize_snapshot (through read_events_raw) raises StoreError on an unterminated, unparseable last line and counts an unterminated line that is valid JSON; poll_once leaves any unterminated tail unconsumed and says nothing, so for the valid-JSON case Python's own overview mixes two prefixes (event count with the line, streamCursor without it). The service refuses BOTH cases with the same 500 Problem (FR-004 table, rows L4 and L4b), so it never serves that mixture; a transient torn tail is refused, not tolerated (operator answer Q-6). A log that shrinks under an open stream is a different condition and yields log-truncated (FR-003).
  • Malformed event (invalid JSON, non-object line, missing required key, unknown lane, unknown kind): verified against live Python, read_events_raw and read_event_stream raise StoreError (parse_diary raises DiaryError for an unknown kind), so the whole read is refused, not skipped. Invalid UTF-8 is the exception to the StoreError rule: neither reader catches the decoding error, so Python raises UnicodeDecodeError; the service refuses it the same way (rows L3a and L3b) and the malformed-rows class records the exception class Python really raises. A blank line is skipped by read_events_raw but raises in poll_once (a terminated blank chunk is passed to json.loads); the golden set records both answers (FR-008). Rows that carry a top-level event_type or a retrospective.* name are skipped in place by parse_diary. The Java reducer refuses and skips exactly where Python does, and the malformed-rows class of the golden set records each outcome (FR-004, FR-005, FR-008).
  • Unknown schema version in .kittify/metadata.yaml: health reports the contract value for drift and schemaVersion is the recorded integer, never a defaulted one (FR-001).
  • Absent or unusable meta.json, unlistable directory: a directory under kitty-specs/ without a meta.json is not a Mission (Python's enumerate_missions lists only directories holding one): it is not listed, not counted and writes no diagnostic. A meta.json that exists but is unusable (invalid JSON, not an object, empty, not valid UTF-8, or unreadable at the operating-system level) is row L7: Python's load_meta converts every one of those into one path-named ValueError, and the service answers one 500 Problem. A kitty-specs/ or Mission directory that cannot be listed or stat-ed is row L11 (500 Problem, Java only). None of these is "branch absent" or vanished.
  • Missing Mission directory: a Mission is listed because its directory was enumerated, so a directory missing at read time means it vanished between enumeration and read. The behaviour (omit it from the page, write a path-free diagnostic, keep missionCount equal to the items listed) was decided by the operator (Q-1); GET /api/v1/events?missionId= for an unknown Mission is an explicit 404 before any event (FR-004 table, row L6; FR-003).
  • Lifecycle-only journal (events only of the seven lifecycle types, no work package events): counts are zero and lifecycle status is derived from the lifecycle events; this is one of the curated fixtures. Python's current behaviour here is the reference. PR #5902 ("refuse empty status from lifecycle-only journal") was closed unmerged on this checkout's date, so the reference may change (risk R-2).
  • Coordination-topology Mission whose status lives in .worktrees/<slug>-<mid8>-coord/kitty-specs/... (FR-006). The opposite case is equally an edge: a stale coordination worktree husk beside a Mission whose stored topology is single_branch or lanes must NOT be read (FR-006, the #2062 close).
  • Symbolic link or escaping path where a ledger, meta.json or Mission directory is expected: refused, never read, never followed outside the project root (FR-010, AC-10d). A refused ledger is row L2; a refused meta.json or Mission directory omits that one Mission with a diagnostic (row L9), so one stray link never denies the service for every other Mission.
  • CRLF line endings in a ledger: the overview's counts equal those of the same log with LF endings; streamCursor differs because the cursor is a position in the physical bytes (verified: poll_once counts the \r). The CRLF fixture has its own golden (FR-008).
  • Two Missions with the same createdAt: ordered by missionId ascending.
  • Port already in use: refuse to start, non-zero exit, message naming the port (FR-009).
  • Concurrent writer: the Python CLI appends while the service reads. Every overview the service returns must be consistent with some prefix of complete (newline-terminated) log lines (eventCount, statusLaneCounts, lastEventId and streamCursor all describe the same prefix), or the read is refused per the torn-tail row; it is never a mixture (FR-004, AC-4d).
  • Stream faults (an unreadable ledger, a terminated line that is not a JSON object, a Mission that cannot be resolved, vanishes or whose directory cannot be listed, at connect time or while open, for a Mission-scoped and for an unscoped stream): defined once, in the stream table S1 to S6 after the FR-004 table; the unterminated tail is a non-event on a stream (FR-003).

Requirements

Wall-clock bounds. Every absolute time bound in this specification (NFR-001, NFR-002, NFR-004, the 2-second delivery and close bounds of FR-003, AC-3a, AC-3j, AC-3k, AC-3l, AC-3m and AC-15b, the 10-second bounds of AC-9a and AC-9e) is an INITIAL, PROVISIONAL figure in the sense of ADR 2026-10-05-2 (runner-relative performance budgets): it is a ceiling that stops a test from waiting unbounded, not a claimed measurement. Each is amended by a measurement, never by a retry. Where the ADR's method applies (the NFR-001 and NFR-002 figures, the NFR-004 budgets) the assertion is a ratio to a floor sampled in the same run (the floor is a request to a no-op path of the same service for NFR-001 and NFR-002, and the same fixture with one connection for NFR-004); the absolute figure is the provisional ceiling beside it. A miss is classified against the ADR method (runner noise versus a regression) and is never retried to green.

Delivery labels and no-op marks follow tactic acceptance-criteria-non-vacuity. Every acceptance criterion below states the observable and how it fails. Where an FR can fail in the field, the "When it cannot do its job" column says what the service does instead of returning a null, zero or "unknown" and calling it success.

Functional Requirements

IDTitleUser StoryPriorityStatusDeliveryNo-op passable?
FR-001Project header routeAs a dashboard, I want GET /api/v1/project so that I can head the Mission list.HighOpen[build]no, paired with the missing-file fixture
FR-002Mission overview routeAs a dashboard, I want paged GET /api/v1/missions so that I can list every Mission without rescanning.HighOpen[build]no, paired with the golden of a populated Mission
FR-003Change stream routeAs a client, I want GET /api/v1/events (SSE) so that I refetch only on change.HighOpen[build]no, paired with an append-while-open fixture
FR-004Fail-loud readsAs an operator, I want every unreadable, truncated, missing or malformed input reported so that a wrong overview is never served as healthy.HighOpen[build]no, each failure has its own positive control
FR-005Status reducer port (ordered by (at, event_id), Q-7)As a maintainer, I want a framework-free Java port of the status reduction so that one answer exists while two implementations exist.HighOpen[build]no, interleaved fixtures differ if tie-break is wrong
FR-006Coordination-topology path resolutionAs a user of a coordination-topology Mission, I want its status read from the coordination worktree so that I see live state.HighOpen[build]no, the coord fixture has a stale primary copy
FR-007Cursor-keyed projection cacheAs a user with many clients, I want repeated reads to be cheap so that many clients never cause many rescans.MediumOpen[build]no, cache-hit and cache-invalidation are both asserted
FR-008Conformance gateAs a maintainer, I want Java and Python overviews compared as a failing gate so that drift cannot ship.HighOpen[build]no, a perturbation test proves the gate can go red
FR-009Loopback, Host and Origin guardAs a user, I want the service unreachable except from my machine's own origin so that the #4767 to #4769 class cannot recur.HighOpen[build]no, each refusal has an accepted-request control
FR-010Read allowlistAs a reviewer, I want proof that the service reads only an explicit allowlist of files so that "ledger and events only" is checkable.HighOpen[build]no, a deliberate extra read fails the test
FR-011Contract conformance of responsesAs a UI developer, I want every real response valid against the contract so that generated clients work.HighOpen[build]no, a deliberately invalid response fails the test
FR-012Hexagonal architecture enforcedAs a maintainer, I want the dependency direction and framework-free domain enforced by ArchUnit so that the cut-over to a second adapter stays possible.HighOpen[build]no, a violating import fails the test
FR-013Conformance suite runs on the inbound portAs a maintainer, I want the port-level suite written against MissionStatusQuery so that it runs unchanged against another implementation behind the port.MediumOpen[build]no, a second in-test MissionEventSource runs the same port-level subset
FR-014Standalone Gradle build and supply chainAs a security reviewer, I want pinned, hash-verified build inputs so that the public repo's pin doctrine holds.HighOpen[build]no, tamper test fails the build
FR-015Path-filtered CI and native release matrixAs a maintainer, I want the service built and smoke-tested natively on four targets so that missing reflection hints are caught.HighOpen[build]no, a path-filter test and a native smoke test
FR-016Read-only and drain-independentAs a user mid-flight on a Mission, I want the service to never write or lock so that it cannot disturb running work.HighOpen[build]no, a write-attempt fixture fails the test

Functional requirement detail and acceptance criteria

FR-001. Project header route. GET /api/v1/project returns the contract Project (name, missionCount, specKittyVersion, schemaVersion, health, currentBranch, lastActivityAt). Sources are named by the contract's x-source entries: .kittify/config.yaml (project.slug), .kittify/metadata.yaml (spec_kitty.version, spec_kitty.schema_version) and the HEAD file (read with no process started; when .git is a file holding a gitdir: pointer, as in a linked worktree or a submodule, the HEAD of that git directory, as the Python reference helper head_file in tests/contract/_mission_status_project.py does). missionCount equals the number of items GET /api/v1/missions lists. lastActivityAt is computed from the reduced ledgers (the live Python reference takes the latest last_transition_at over the snapshot's work packages, build_overview in tests/contract/_mission_status_payloads.py), never from a status.json file. The contract's Project.lastActivityAt description also says a Mission "whose status.json cannot be read leaves its activity out"; the service never reads status.json (FR-010), so that clause has no counterpart here, and the text drift is only recorded (Known drift, item 3; Q-1) for the contract owners: this mission makes no edit to contracts/.

  • When it cannot do its job: a missing or unreadable .kittify/metadata.yaml yields specKittyVersion: null, schemaVersion: null and a health that is not healthy, exactly as the contract defines; currentBranch is null, per the contract, for a detached, missing, unreadable or unparseable HEAD, for a name that fails the contract pattern ^[A-Za-z0-9][A-Za-z0-9._/-]*$, for a name longer than 255 characters and for a name that is a host path; an unborn branch is served by its name. A schema version outside the supported range yields the contract's drift health value. Supported range: one constant in the domain (SupportedSchemaRange, minimum and maximum) is the only place the range lives; the goldens manifest (FR-008) records supportedSchemaMin and supportedSchemaMax, the Python golden test asserts they equal MIN_SUPPORTED_SCHEMA and MAX_SUPPORTED_SCHEMA in src/specify_cli/migration/schema_version.py, and a Java test asserts its constant equals the manifest, so a range change is a deliberate, coupled edit on both sides (AC-1f). An error the contract does not map to a null (an unreadable Mission directory that is not "branch absent") is a 500 Problem, not a 200 (FR-004 table, row L11).
  • AC-1a: with a fixture whose metadata names schema 3, the body validates against Project and health is healthy. Fails if any field differs from the golden project header.
  • AC-1b: with .kittify/metadata.yaml deleted, the body has schemaVersion: null and health other than healthy. Fails if health is healthy or the status is not 200.
  • AC-1c: missionCount equals the count of /missions items over the same fixture. Fails on any difference.
  • AC-1d: the response body contains no absolute path, no worktree location and no environment value: scan for the fixture root path, HOME, and every canary of the secret-canaries fixture class (FR-009, AC-9g). Fails if any appears.
  • AC-1e: the project header of each project-header fixture (healthy, .kittify/metadata.yaml absent, unknown schema version, detached HEAD, a branch name failing the pattern, a branch name over 255 characters, a host-path-shaped branch name, an unborn branch, a .git pointer file with a relative gitdir:) equals its golden. Fails on any field difference.
  • AC-1f (schema range, coupled): the manifest's supportedSchemaMin and supportedSchemaMax equal the live Python constants (Python golden test) and the Java SupportedSchemaRange (Java test). Fails when either side differs from the manifest, naming both values.

FR-002. Mission overview route. GET /api/v1/missions returns a MissionOverviewPage: items shaped as MissionOverview (identity, slug, friendlyName, displayNumber, missionType, targetBranch, topology, createdAt, statusLaneCounts, wpTotal, blockedCount, progress, lifecycleStatus, acceptedAt, mergedAt, discardedAt, lastEventId, lastActivityAt, eventCount, streamCursor, and the provisional nextAction/staleness members as the contract defines them). Order is createdAt descending, ties by missionId ascending. Paging uses the shared opaque page cursor and PageSize. Page cursor: a stateless, deterministic text encoding of the position after the last returned item, with a version prefix (v1.) followed by the URL-safe base64 (no padding) of the UTF-8 text <createdAt> LF <missionId> of that last item, with no key, no clock and no per-process state, so equal inputs give equal bytes across runs and OSes (NFR-005). It carries no integrity code: it is opaque, not secret, and a hand-built cursor for a real position is valid by design. A cursor is forged or invalid when it is empty, not the v1. form, not decodable, longer than 512 characters, or decodes to a position that is not a Mission in the current listing with exactly that createdAt. Expired cannot occur, because the service keeps no cursor state; the one case that looks like it, the last returned Mission having since disappeared, falls under the same rule and gets the same 400.

  • Vocabulary is the contract's. The five lifecycle values are active, planned, done, draft, discarded; x-provisional members are served as the contract specifies and are not redefined here (the lifecycle vocabulary and chips remain open on #5528).
  • When it cannot do its job: a malformed, forged or no-longer-valid pageCursor (the contract's words; defined above) is answered 400 with problem code invalid_page_cursor. A Mission whose log cannot be read appears per FR-004, never with zero counts as if healthy.
  • AC-2a: for each curated fixture Mission the item equals its golden. Fails on any field difference.
  • AC-2b: paging with page size 1 through N Missions yields each Mission exactly once in the contract order. Fails on a duplicate, a gap or a wrong order, including for two Missions with equal createdAt.
  • AC-2c (adapter suite, not in the port-level subset; FR-013): each of these pageCursor values returns 400 invalid_page_cursor: the empty string; abc (not the v1. form); v2. plus a valid body; v1. plus text that is not base64; v1. plus valid base64 of bytes with no LF; a well-formed v1. cursor naming a missionId that is not listed; a well-formed v1. cursor naming a listed missionId with a different createdAt; a 600-character value. A control cursor taken from a real nextPageCursor returns 200 with the next page. Fails on 200 for any forged value, any other code, or a refused control.

FR-003. Change stream route. GET /api/v1/events serves text/event-stream with exactly the three event names status-transition, mission-lifecycle, log-truncated, the framing, id rules, heartbeat comment (: heartbeat every 30 seconds), projection rules (only status transitions and the seven lifecycle types are forwarded; annotation, decision, retrospective, self-approval, WP-creation and unknown rows are dropped), resume rules (streamCursor wins over Last-Event-ID) and refusals (negative, misaligned, cursor_without_mission are 400; out_of_range, content_mismatch are 409; unknown missionId is 404 before any event) of contracts/mission-status/paths/events.yaml. The SSE controller is hand-written (decision D-Q2). The stream's framing, heartbeat interval and refusal statuses are x-provisional in the contract; this mission implements them as written and a contract change before release is followed, not pre-empted.

  • Projection scope: the stream classifies each terminated line by its shape only (a JSON object with the lane-row shape is a status transition; a JSON object whose top-level type is one of the seven lifecycle types is a lifecycle event; every other well-formed object is dropped). It does not validate lane vocabulary, so a row with an unknown lane or an unknown kind is dropped by the stream while the same bytes make /missions answer 500 (row L3b): the stream reports changes, the overview refuses a log it cannot reduce. A terminated line that is not a JSON object at all is the stream condition of row L10.
  • When it cannot do its job: a cursor that cannot be trusted is refused before any event is sent. A log that shrinks or changes under an open stream yields log-truncated (the consumer's cue to resynchronise), never silence. Every other stream fault (an unreadable ledger, a terminated line that is not a JSON object, a Mission that cannot be resolved or vanishes, an unlistable kitty-specs/) is defined ONCE, in the stream table (rows S1 to S6, after the FR-004 table), for both stream scopes and for connect time and open time; this section does not restate it. The contract defines only three event names, so no error event exists: where a stream closes, a client tells the end from a quiet period by the connection closing (EOF), and a reconnect then meets the connect-time rows of the same table. Those mechanisms are Decided (operator, Q-2). A stream parses only terminated lines AFTER its start offset (the cursor offset on a resume, otherwise the offset just after the last newline at connect); earlier lines are hashed for the cursor invariant but never parsed, so a bad earlier line is no connect-time condition.
  • Unscoped stream: which Missions are watched (operator-decided, Q-2). An unscoped stream watches every Mission's log and sends no id: lines. Discovery rule: the watcher re-lists kitty-specs/ once per second, so a new Mission is seen well inside the 2-second bound. A directory becomes a watched Mission when it holds a meta.json (a directory without one is not a Mission, as in enumerate_missions); its ledger is read from offset 0 whenever the ledger exists, so the creation order (meta.json first or ledger first) does not matter and all its rows count as newer than the connection. A Mission present at connect is watched from the offset just after the last newline of its ledger at that moment (the offset a cursor taken then would carry, so a half-written row at connect stays an unconsumed tail, next bullet) and delivers none of its history. What the stream does when one Mission faults (drop that Mission, keep the connection) is in the stream table, rows S2 to S5.
  • Unterminated tail under an open stream (either scope). A non-whitespace tail after the last \n is left unconsumed, as poll_once leaves it: no event, no close, no diagnostic, until its newline arrives; the completed row is then classified by shape like any terminated line, and its event's id: is the cursor after the full row including the newline. This is deliberately not the overview's refusal of rows L4 and L4b: the stream reports changes, and a half-written row is not yet one. A tail that never becomes a valid row is a row L10 case once its newline arrives.
  • Latency bound (initial, provisional, see the wall-clock note): an appended row is delivered within 2 seconds on the CI Linux runner. The test fails on its own 2-second timeout; it never waits unbounded, and a timeout is classified under the ADR method rather than retried.
  • AC-3a: append one status-transition row to a fixture log with a Mission-scoped stream open: exactly one status-transition event with id: equal to the new cursor arrives within the latency bound. Fails on zero, two, a different id, or an arrival after the bound.
  • AC-3b: append, to the open stream of a Mission, (i) an annotation row, (ii) an unknown-type row, defined as a well-formed object whose top-level type is outside the seven lifecycle types (for example a decision-point type), and (iii) a well-formed object with an unknown kind: no event arrives for any of the three and the connection stays open. Fails if any is forwarded or the connection closes.
  • AC-3k (unparseable line after the start offset, row S4): append a terminated line that is not valid JSON, then a terminated line that is a JSON array. Mission-scoped, with a fresh stream for each: the connection closes within the initial 2-second bound, no later heartbeat or event follows, and one diagnostic with code ledger_unparseable and the Mission's mid8 is written. A control row (a valid status transition) appended to a fresh stream is delivered. A resume variant connects with a cursor before a valid row followed by a bad row: the valid row is delivered, then the connection closes. Unscoped variant: each unparseable line, appended to one Mission under an open unscoped stream, writes one ledger_unparseable diagnostic and does not close the connection; a valid row appended to an unaffected Mission afterwards is delivered, and no further event of the faulty Mission arrives. Fails if the connection stays open (scoped) or closes (unscoped), a heartbeat or event follows a scoped close, the diagnostic is absent, or a control event is missing.
  • AC-3c: rewrite the log shorter: a log-truncated event without an id: line arrives; reconnect with the old id returns 409 out_of_range. A second fixture rewrites the log to the same length with a different LAST consumed line (the line that ends at the cursor offset, the only line the cursor digest covers, see FR-007): reconnect with the old id returns 409 content_mismatch. Fails if no event, an id: is present, or either reconnect is accepted. A same-length rewrite that changes only an earlier line is outside the cursor's detection model (FR-007, known limit).
  • AC-3g (successful resume; port leg: the MissionStatusQuery changes-since-a-cursor call returns the rows below, each with its cursor; stream leg: the same rows arrive as SSE events with id: equal to that cursor): the contract defines a cursor as the position spec-kitty events tail resumes from, so a valid cursor delivers the forwarded rows after it. With rows 1..N in the log, connect with streamCursor equal to the cursor after row k (k < N): exactly the forwarded rows k+1..N arrive first, in log order, each with id: equal to the cursor after that row, and a row appended afterwards follows them. Fails on a missing row, a replayed row at or before k, a wrong order or a wrong id. A connection WITHOUT a cursor receives none of rows 1..N (live only).
  • AC-3h (precedence): a request that sends both streamCursor=C1 and Last-Event-ID: C2 with C1 != C2 resumes from C1. Fails if the events delivered start after C2.
  • AC-3i (batch order; port leg: one changes-since-a-cursor call over K rows appended in one write; stream leg: the same through SSE): K forwarded rows appended in one write arrive as K events in log order, each with the cursor after its own row. Fails on a missing, duplicated or reordered event, or an id: that is not that row's cursor.
  • AC-3j (ledger unreadable while open, row S3): with a Mission-scoped stream open, make the log unreadable (fault injection in the filesystem port, plus the symlink-swap fixture where the OS allows it). The connection closes within the same initial 2-second bound and no heartbeat or event follows. Fails if the connection stays open or a heartbeat arrives afterwards. The mechanism is operator-decided (Q-2); the test asserts it. Unscoped variant: the same fault in one Mission under an open UNSCOPED stream does not close the connection, writes one diagnostic with that Mission's mid8, and a status-transition appended to an unaffected second Mission afterwards is delivered within the bound; a heartbeat still arrives. Fails if the connection closes, the diagnostic is absent, or the control Mission's event is not delivered.
  • AC-3l (unterminated tail): with a stream open (once Mission-scoped, once unscoped), write the first half of a valid status-transition row without a newline: no event, no close and no diagnostic within the bound; then write the rest and the newline: exactly one status-transition event arrives, with id: equal to the cursor after the full row (scoped), or no id: (unscoped). A second leg opens the stream (unscoped, and Mission-scoped without a cursor) AFTER the half-written row is already in the ledger: nothing arrives for it, and completing the row delivers exactly one event (the start offset is the offset after the last newline, so the fragment is never parsed alone). Fails on an event before the newline, on zero or two events after it, on a wrong id, on a diagnostic or a closed connection.
  • AC-3m (appearing Mission, unscoped; discovery rule of FR-003): with an unscoped stream open, (i) create a new Mission directory with meta.json first and then a ledger holding one status-transition row: exactly one event arrives within the bound; (ii) in a second new Mission create the ledger first and meta.json second: exactly one event arrives within the bound; (iii) in a third directory write a ledger with a row but never a meta.json: no event arrives and no diagnostic is written. A Mission that existed at connect with history delivers none of its history. Fails on a wrong count in (i) to (iii) or any event for the old history.
  • AC-3d: one request for each refusal code returns the status the contract fixes, with no event sent. Fails on any mismatch.
  • AC-3e: an unscoped stream sends no id: lines and replays nothing. Fails if an id: appears or history is replayed.
  • AC-3f: with the heartbeat interval shortened by test configuration, a comment line : heartbeat arrives and is not an event. Fails if it carries data or an event name.
  • AC-3n (connect time, rows S1 and S2): Mission-scoped, a plainly unknown missionId answers 404 with no diagnostic; a Mission whose meta.json is a symbolic link (row L9) answers 404 and writes path_refused; a Mission whose ledger is unreadable (fault injection) answers 500 Problem with ledger_unreadable and no event; a Mission with a malformed meta.json answers 500 Problem with meta_unparseable; a Mission with no ledger (row L1) opens normally (control). Unscoped, the same faults in one of two Missions: the connection opens as text/event-stream, that Mission is not watched, one diagnostic with the row's code is written, and an append to the other Mission is delivered; with kitty-specs/ unlistable the connect answers 500 Problem mission_dir_unreadable. Fails on any other status, a missing or extra diagnostic, or a missing control event.
  • AC-3o (while open, rows S5 and S6): Mission-scoped, removing the Mission directory closes the connection within the bound with one mission_vanished diagnostic. Unscoped, removing one Mission's directory keeps the connection, writes one mission_vanished diagnostic, and an append to the other Mission is delivered; making kitty-specs/ unlistable (fault injection) keeps the connection and every watched Mission, writes one mission_dir_unreadable diagnostic, writes no second one while the failure lasts, and writes a new one if it recovers and fails again. Fails on a closed unscoped connection, a repeated diagnostic, or a missing control event.

FR-004. Fail-loud reads. Each input condition below has a stated observable. The service never returns a null, unknown or zero value as a healthy success without that observable. The contract already answers part of this: its lifecycleStatus rule reads an unreadable event log as wpTotal 0 and draft (MissionHead.yaml, lifecycleStatus description, rule 2), and its Project.lastActivityAt description says an event log that cannot be decoded or parsed fails the whole read with a 500 Problem. The contract has no per-Mission failure member in a 200 page and states the 500 rule only for Project.lastActivityAt; what a parse failure inside one Mission means for a /missions page is not in the contract; the operator kept that reading (Q-1). Per C-012 the service serves the contract as written; rows whose Status is Decided (operator, Q-1 or Q-2) are the contract rule plus a diagnostic, with no contract change. They are not a silent deviation and not a contract change made here. An "unterminated" line below is the bytes after the last \n when they contain a non-whitespace character; a whitespace-only tail is no event and no tear. Two cases the table does not name were decided on 2026-10-11 (reviews/analyze.ruling.md). First, a Mission-scoped request for an id that matches no readable Mission answers by the first unusable Mission met: L9 gives 404 with path_refused, L7 gives 500 meta_unparseable, and an unreadable directory gives 500 mission_dir_unreadable. A plain 404 is given only when no such Mission exists. Second, a createdAt that is not an instant is L7 (meta_unparseable), and an unparseable at in a ledger row is L3a (ledger_unparseable, no line number). Three more cases were decided on 2026-10-12 (operator, reviews/analyze.ruling.md). First, a meta.json that parses but cannot populate the Mission (missing or non-ULID mission_id, missing target_branch or created_at, an unusable mission_number) is L7: 500 with the diagnostic meta_unparseable. A project config without a usable project.slug answers 500 with the diagnostic mission_dir_unreadable. The oracle names both projection_refused; the HTTP status and Problem body are the same, and only the diagnostic code differs. Second, a missing created_at is refused, although the oracle would serve createdAt: null, which is not contract-valid. Third, the service carries mission_number and schema_version as 32-bit integers: a mission_number beyond 31 bits is L7, and a schema_version beyond 31 bits is served as null.

RowConditionLive Python reference (verified)Service observableStatus
L1Ledger file absent (a fresh Mission)read_events_raw returns an empty list200; wpTotal 0, eventCount 0, lifecycleStatus draft unless discardedAt is set in meta.json, in which case discarded (contract rule 1 is evaluated before rule 2), streamCursor at offset 0 with the empty digest; no diagnostic (this is a normal state)Settled by the contract
L2Ledger unreadable: permission denied, I/O error, symbolic link refused (FR-010)an OSError propagates; there is no Python golden for it200 item with wpTotal 0, zero lane counts and lifecycleStatus draft (contract rule 2; discarded when discardedAt is set, rule 1), AND one diagnostic with code ledger_unreadable (AC-4c). A body alone cannot tell this from L1; the diagnostic is the only observable (Q-1 decided: no contract change). Streams: rows S2 and S3Decided (operator, Q-1)
L3aLedger bytes read but not decodable or parseable (invalid UTF-8, a non-JSON line, a non-object line, a lane row missing a required key, an unknown lane, an unknown kind) in ANY Mission, observed through GET /api/v1/projectread_events_raw and read_event_stream raise StoreError for a non-JSON line, a non-object line, a missing key and an unknown lane; parse_diary raises DiaryError for an unknown kind; invalid UTF-8 raises UnicodeDecodeError (neither reader catches it, so it is NOT a StoreError), and Python gives a byte position, not a line numberGET /api/v1/project answers 500 with a Problem body (application/problem+json, the shared default response) that carries no path and no line text (lastActivityAt needs every ledger reduced); diagnostic code ledger_unparseable with the Mission's mid8 and the 1-based line number (none for invalid UTF-8)Settled by the contract (Project.lastActivityAt)
L3bThe same condition observed through GET /api/v1/missionsas L3athe whole GET /api/v1/missions answers 500 with the same Problem body and diagnostic as L3a, whichever Mission holds the bad ledger and whether or not that Mission falls on the requested page. This mirrors Python (a read of the set raises) and the contract's /project rule, but it is this spec's inference: the contract states no /missions rule, and one corrupt ledger denies the overview for every other Mission, which sits uneasily with the row L9 policyDecided (operator, Q-1); alternative considered: omit that Mission with a diagnostic, as L9
L4Torn final line: an unterminated last line that does not parsematerialize_snapshot raises StoreError (invalid JSON on that line); poll_once leaves the tail unconsumed and says nothingsame as L3b (and L3a for /project), diagnostic code ledger_torn_tail; the service never parses the partial line as an event and never serves a 200 for itMirrors the snapshot path; refusal decided (operator, Q-6)
L4bUnterminated last line that IS valid JSON (a writer between the body and its newline)read_events_raw counts it as an event; poll_once leaves it unconsumed, so Python's overview mixes two prefixes (eventCount and statusLaneCounts with the line, streamCursor without it)same as L4 (500 Problem on /missions and /project, ledger_torn_tail): ANY unterminated non-whitespace tail is refused, so eventCount, statusLaneCounts, lastEventId and streamCursor always describe the same prefix of terminated lines. This is deliberately stricter than the snapshot path; the Python outcome is recorded in the golden manifest as a known divergence and the Java assertion is the refusalGolden-pinned divergence; refusal decided (operator, Q-6)
L5Log shrinks or changes under an open streamthe tail reader's truncation signala log-truncated event without id: (FR-003)Settled by the contract
L6Mission directory vanishes between enumeration and read; or an unknown missionId on GET /api/v1/eventsno Python equivalent/missions: the Mission is omitted from that page, missionCount is computed from the same enumeration as the items so the two still agree, and one diagnostic with code mission_vanished is written. Streams: rows S1 (the 404 Problem before any event) and S5Decided (operator, Q-1 and Q-2) for /missions and S5; settled for the /events 404
L7meta.json present but unusable: invalid JSON, not a JSON object, empty, not valid UTF-8, or unreadable at the operating-system level (permission denied, I/O error)the oracle reads it with load_meta(..., on_malformed="raise"); _parse_meta_text in src/specify_cli/mission_metadata.py catches OSError and UnicodeDecodeError on the read and every JSON decode failure alike and re-raises one path-named ValueError, so Python has ONE outcome for the whole class500 Problem as L3b (and L3a for /project), diagnostic code meta_unparseable, mid8 per AC-4c. The service has ONE code for the class (no separate meta_unreadable), mirroring Python's single outcome. Streams: row S2Mirrors the oracle. The malformed-rows golden records the content variants (including non-UTF-8), the exception class being ValueError; the OS-unreadable variant is a Java-only fault-injection test asserting the same observable; the /missions half is operator-decided with L3b (Q-1)
L8Unknown schema version in .kittify/metadata.yamln/a (project header)200; schemaVersion is the recorded integer, health is the contract's drift value (FR-001)Settled by the contract
L9A Mission's own meta.json or Mission directory is refused by the symlink or containment rule (FR-010): a symbolic link in the final component, or a real path outside the project rootno Python equivalent (load_meta follows links)the Mission is omitted from the page exactly as in L6, missionCount still equals the items listed, and one diagnostic with code path_refused is written; no content of the target is read. A refused LEDGER is not this row: it is L2Decided (operator, Q-1); Java only
L10A terminated line after a stream's start offset that is not valid JSON or not a JSON object (a blank terminated line included)poll_once raises json.JSONDecodeError (a non-object raises StoreError)stream-only, no request-route observable: row S4 (diagnostic code ledger_unparseable)Decided (operator, Q-2)
L11OS-level failure listing or stat-ing: kitty-specs/ or a Mission directory cannot be listed or stat-ed (for example permission denied on a parent). Neither a symbolic-link refusal (L9), a vanish (L6) nor an unusable meta.json (L7). (A directory WITHOUT meta.json is not this row: it is not a Mission and is silently not listed, as enumerate_missions does.)Path.is_file and directory iteration let a PermissionError or other OSError from the stat or listing propagate; there is no Python golden500 Problem as L3b (and L3a for /project), no path and no OS message in the body; diagnostic code mission_dir_unreadable, mid8 per AC-4c. The unreadable LEDGER is not this row: it is L2. Streams: rows S2 and S6Decided (operator, Q-1); Java only (fault injection in the filesystem port)

The torn-tail rows exist because live Python itself gives two answers (see Edge Cases). The service mirrors the one the overview oracle uses for the reduction (materialize_snapshot) for an unparseable tail, refuses the parseable unterminated tail that the oracle would count (so the cursor and the counts cannot disagree), and takes streamCursor from the same bytes it reduced.

Stream table (the one authority for stream faults). Streams have no request-route status for a fault that happens after the response has started, so the rows below define, for each fault, what a Mission-scoped and an unscoped stream do at connect time and while open. FR-003, AC-3j to AC-3o, Q-2 and the L-rows refer here and do not restate it. A Mission that an unscoped stream has dropped is not re-watched on that connection; a reconnect watches it again if it is readable by then. Every row except the 404 of S1 is Decided (operator, Q-2).

RowConditionMission-scoped streamUnscoped stream
S1Unknown missionId at connect (L6), or a Mission refused by the symlink rule (L9)404 Problem before any event (contract-settled); a plainly unknown id writes no diagnostic, an L9 Mission writes path_refusednot applicable (no id is named); a Mission found vanished or refused in the connect-time listing is not watched, one diagnostic (mission_vanished or path_refused)
S2At connect, the Mission cannot be resolved or its ledger cannot be opened: unusable meta.json (L7), unlistable Mission directory (L11), unreadable ledger (L2)500 Problem (no path), the row's diagnostic code, no eventthat Mission is not watched, one diagnostic with the row's code, the connection opens; if kitty-specs/ itself is unlistable (L11) the connect is a 500 Problem with mission_dir_unreadable
S3While open, the ledger becomes unreadable (L2)the connection closes, no further heartbeat or event, one ledger_unreadable diagnosticthat Mission is dropped, one ledger_unreadable diagnostic; connection, heartbeat and other Missions kept
S4A terminated line after the start offset is not valid JSON or not a JSON object (L10; blank line included), at connect-with-resume or while openthe connection closes after delivering the rows before it, one ledger_unparseable diagnosticthat Mission is dropped, one ledger_unparseable diagnostic; connection kept
S5While open, the Mission directory vanishes (L6)the connection closes, one mission_vanished diagnosticthat Mission is dropped, one mission_vanished diagnostic; connection kept
S6While open, kitty-specs/ becomes unlistable (L11)not applicable (a scoped stream never lists)nothing is dropped; one mission_dir_unreadable diagnostic when the listing first fails, none again until a listing has succeeded

A log that shrinks under an open stream is not a fault row: it yields log-truncated (L5). A ledger FILE deleted while its Mission directory remains is the same shrink. The unterminated tail (FR-003) is not a fault on either scope. Alternative considered and not chosen (Q-2): close the whole unscoped connection with the same diagnostic.

  • AC-4a: one fixture-driven test per row L1 to L11 (L3a, L3b, L4b each have their own; L10 is asserted by AC-3k), each asserting that row's observable at two levels. Port leg: the typed result, or the typed exception MissionStatusUnavailable carrying the row's diagnostic code (for L3a and L3b the project() and the missions() calls separately). HTTP leg, an adapter test: status code, Problem presence, the listed body members and the diagnostic code (for L3a and L3b each route its own 500 Problem, so the settled and the operator-decided halves cannot hide each other). Only the port legs of L1, L3a, L3b, L4 and L4b are in the port-level subset (FR-013); L2, L6, L7, L9, L11 and all HTTP legs are adapter-suite tests. Fails if any observable differs. For L3a, L3b, L4, L4b, L7 and L11 it also fails if the service answers 200, and for L2 it fails if the diagnostic is absent even though the body is the contract's draft item. L1 and L2 are each also run with discardedAt set in meta.json and require discarded, not draft; fails on draft.
  • AC-4b: the torn-final-line class of the golden set (FR-008) has two fixtures. The unparseable one records live Python's outcome for a log whose last line is half-written: a refusal naming StoreError and the line number. The unterminated-but-parseable one records live Python's actual outcome (the mixed-prefix overview) tagged knownDivergence, and the Java side must refuse it with the L4b observable instead. For the first the Java side must refuse the same input with the L4 observable. Fails if a partial line is parsed as an event, if the parseable unterminated line is counted, if a 200 overview is served, or if the Problem body contains a path or the line text.
  • AC-4c: for every row except L1, L5 and L8, and for every stream row S2 to S6, exactly one diagnostic line is written per occurrence (S6: per transition into failure) to the service log, carrying a stable code from the tables, a mid8 field and no absolute path (scanned for the fixture root, HOME and every canary of FR-009 AC-9g). The mid8 field is the first 8 characters of mission_id from the Mission's meta.json when that file was read; where no meta.json was read (rows L7, L9, L11, and any stream row on a Mission or listing whose meta.json was not read) it is the fixed token none, and the test asserts that token there and the real value everywhere else. The one exception is the 404 for a plainly unknown missionId on GET /api/v1/events (rows L6 and S1, settled): a client error, it writes no diagnostic and the test asserts that none is written. A fault in one Mission under an unscoped stream writes one line per occurrence. Fails if a condition produces no line, a line without a code or without the mid8 field, a mid8 that is not the rule's value, or a line containing a path.
  • AC-4d (concurrent writer), two parts. Latch-controlled: the test writes the first half of a row, releases a latch, reads and requires the L4 refusal, writes the whole row but not its newline, reads and requires the L4b refusal, writes the newline, reads and requires the item of the new prefix. Free-running: while a writer appends valid rows in a loop, a reader issues at least 200 GET /api/v1/missions calls. In both parts every 200 item must match some prefix of complete lines of the final log: its eventCount, statusLaneCounts, lastEventId and streamCursor are exactly those of that prefix, and every non-200 must be the L4 or L4b refusal. Fails on any item that matches no prefix, and on a latch phase that does not observe the refusal (that would make the test vacuous).

FR-005. Status reducer port (ordered by (at, event_id), see Q-7). The domain contains a Java reduction equivalent to status.reducer.materialize_snapshot in src/specify_cli/status/reducer.py (including the cancellation-provenance projection), and the overview derivations: lifecycleStatus, progress (equivalent to compute_weighted_progress in src/specify_cli/status/progress.py), blockedCount, lastActivityAt, topology and streamCursor (equivalent to TailCursor in src/specify_cli/status/tail_reader.py).

  • Reference algorithm. reducer.py holds only the wrapper (materialize_snapshot, which calls reduce_shared_state, imported at the top of the module as from spec_kitty_events.status import reduce as reduce_shared_state) and the cancellation and implementer projections. The reduction itself is in the external package spec-kitty-events, locked at version 10.4.0 in uv.lock (pyproject.toml allows >=10.4.0,<11). spec_kitty_events.status.reduce is a re-export of spec_kitty_events.diary.reduce, which is parse_diary followed by reduce_parsed. At the locked version reduce_parsed applies: (1) deduplicate transitions by event_id, keeping the first occurrence in input order; (2) sort by the key (at, event_id) ascending, where at and event_id are compared as TEXT, the way a Python str compares, that is by Unicode code point (the Java port compares code points, not UTF-16 units and not parsed instants); (3) fold with rollback-aware precedence; (4) fold annotations in a separate later pass; (5) count the display lanes. parse_diary skips in place a row with a top-level event_type, a retrospective.* event_name and the three retrospective lifecycle type values, and raises DiaryError for a non-object row, a malformed lane or annotation row, or an unknown kind.
  • Two comparisons of at, kept apart. The reducer's sort compares at as text (above). The overview's lastActivityAt is the latest last_transition_at compared as an INSTANT (_latest_instant in tests/contract/_mission_status_payloads.py takes the maximum by parsed timestamp, the contract says "compared as instants") and is served as that work package's own string; equal instants keep the first in snapshot order, as Python's max does. A port that sorts by parsed instant, or compares lastActivityAt as text, differs from Python for rows with a non-UTC offset or a different fractional-second length, so the interleaved class carries rows whose text order differs from their instant order (AC-5a, AC-8b).
  • Naming note for the plan. The operator decisions of 2026-10-08 and the ADR/CLAUDE.md call this reduction "Lamport"; that wording is stale (operator answer Q-7, 2026-10-10). The key verified above is wall-clock at then event_id; the in-package function reduce_status_events, which sorts by (lamport_clock, timestamp, event_id), is a different function that materialize_snapshot does not call. This mission ports what materialize_snapshot runs, ordered by (at, event_id), pinned by the goldens. A move to causal ordering is a Python-side change first (#4941, out of scope, see Out of Scope); the Java port follows then, and the golden gate will force it.
  • Version drift. The conformance goldens manifest (FR-008) records the spec-kitty-events version the goldens were generated with. A version bump inside the allowed range that changes any output turns the Python golden test red until the goldens are regenerated, which forces the Java follow-up in the same change.
  • Behaviour on malformed input equals Python's, including where Python skips or refuses; the exact behaviour is recorded in the malformed-rows and torn-final-line classes of the golden set (FR-008), not assumed.
  • AC-5a: the two interleaved-event Missions reduce to the golden lanes and counts. The fixtures contain rows with equal at and different event_id, rows whose file order differs from (at, event_id) order, a duplicated event_id, and rows whose text order of at differs from their instant order (one with a +02:00 offset against a Z row, one pair differing only in the number of fractional-second digits), so that a parsed-instant sort, and a text comparison in lastActivityAt, both change a golden value. Fails if the sort key, the tie-break or the first-occurrence dedup is changed (the perturbation test of AC-8a performs exactly that change and must turn the suite red).
  • AC-5b: the domain module has no dependency on any framework, JSON, HTTP or filesystem package (also enforced by FR-012). Fails on any such import.
  • AC-5c: the Java reducer is exercised with no Spring context. Fails if the test needs one.
  • AC-5d: the goldens manifest field specKittyEventsVersion equals the installed spec-kitty-events version, checked by the Python golden test. Fails when the installed version differs from the recorded one, naming both.

FR-006. Coordination-topology path resolution. The filesystem adapter mirrors _read_path_resolver in src/specify_cli/missions/_read_path_resolver.py (STATUS_READ_PATH_NOT_FOUND_CODE, new topology under <repo>/.worktrees/<slug>-<mid8>-coord/kitty-specs/<slug>-<mid8>/, legacy under <repo>/kitty-specs/<slug>[-<mid8>]/). The rule is decided by the topology stored in the PRIMARY meta.json, not by what exists on disk: 1. A stored topology of single_branch or lanes resolves to the PRIMARY Mission directory regardless of any coordination worktree husk beside it (_resolve_existing_for_slug, the #2062 close). The husk is not consulted. 2. A stored topology of coord or lanes_with_coord (the coord-routing topologies, routes_through_coordination) reads the coordination Mission directory when it exists (probe_coord_state MATERIALIZED); when the coordination worktree root exists but its Mission directory does not (EMPTY), or the coordination worktree is absent (UNMATERIALIZED, or DELETED after a deleted branch), the read falls back to the Mission's own primary directory. The fallback outcome is the one the conformance oracle maps to "read the own directory": resolve_read_dir in tests/contract/_mission_status_payloads.py catches CoordinationBranchDeleted, CoordAuthorityUnavailable and MissionMetadataUnavailable, and also falls back when the resolved read directory is outside the checkout. 3. An absent topology field in a readable meta.json is classified once by classify_from_meta (topology_from_meta), which reads coordination_branch from the same meta and the lanes.json signal on disk; a corrupt or unreadable lanes.json degrades to "no lanes", it does not fail the read. A meta.json that is empty (or malformed) leaves topology unclassified, and the resolver then uses its historical probe-and-fail-closed path (StatusReadPathNotFound); the service maps that to the L7 row of the FR-004 table. A meta.json that cannot be read at the operating-system level, or is not valid UTF-8, is also L7 (Python's load_meta turns it into the same ValueError); a directory without a meta.json is not a Mission (not listed, no diagnostic, Edge Cases); a Mission directory that cannot be listed or stat-ed is L11. 4. The ledger is read from the resolved read directory; meta.json is read from the Mission's own primary directory only (FR-010). The coordination copy of meta.json is never read for the overview and no identity field depends on it.

  • Git state the service does not read. The Python resolvers consult git for some verdicts: probe_coord_state runs one git rev-parse of the declared coordination branch to tell DELETED from UNMATERIALIZED, MissionStatus classifies a read directory as coordination through is_registered_coord_worktree (the git worktree registry), and _coord_branch_exists can probe a remote. FR-016 and AC-10c forbid starting a process and FR-010 does not allow .git/worktrees, packed-refs or refs/ reads, so the service reproduces none of these verdicts. It decides by directory existence (stat of .worktrees/<slug>-<mid8>-coord/ and of its Mission directory), which gives the same read directory as Python in every case rules 1 to 3 describe, because every git-dependent verdict there ends in "own directory" or in the coordination directory that exists. This is recorded here, not only in Q-3: the divergence surface is a coordination directory that exists on disk but is not a registered worktree (a husk under a coord-routing topology). The curated goldens contain no such case; the goldens manifest records, per fixture, the read directory class (own or coord) that live Python chose, and the Java test asserts the same class. A case outside the curated set where Python's answer depends on a git verdict is a plan-time question (Q-3).
  • Degrade policy: what to do when the coordination branch/worktree is declared but absent, or its remote is unreachable, is open issue #5631 and is not decided by this mission. v1 mirrors the Python read path's current outcome as pinned by the coordination goldens, and the contract's GET /project text already states the one rule it fixes (a Mission whose declared coordination branch no longer exists, or whose remote cannot be reached, is read from its own directory and the answer is still 200). When #5631 resolves, the golden and Java follow in a change of their own. The service never runs a network probe.
  • Table-driven parity. The resolver cases are ONE committed data file, conformance/resolver-cases.json (one row per case: id, stored topology, coordination root present or absent, coordination Mission directory present or absent, lanes.json present, corrupt or absent, expected read-directory class own or coord), consumed by both languages. The Python golden test materialises each row in a temporary git repository (registering a coordination worktree where the row needs one), runs the real resolver through resolve_read_dir of tests/contract/_mission_status_payloads.py and asserts the row's class; the Java test materialises the same row as a static tree and asserts the same class. Each runner records the row ids it executed and asserts that set equals the ids in the file. A row whose Python answer depends on a git verdict the service does not read is not put in the table; it is listed under Q-3.
  • AC-6a: the coordination fixture has a current status log under .worktrees/...-coord/ and a deliberately stale copy in the primary checkout: the overview reflects the coordination copy. Fails if the stale primary copy is used.
  • AC-6b: a Mission with no coordination worktree and a coord-less stored topology is read from its primary directory. Fails if it is reported missing.
  • AC-6c: a Mission with a coord-routing topology and a declared but missing coordination worktree produces exactly what the golden (generated from live Python) records, which is the own-directory read. Fails on any difference, and the golden is regenerated only by the Python script.
  • AC-6d (stale husk): a single_branch Mission and a lanes Mission each have a stale coordination worktree husk beside them whose ledger differs from the primary ledger. The overview reflects the PRIMARY ledger. Fails if the husk ledger is read. The fixture belongs to the coord-husk class of the golden set (FR-008).
  • AC-6e (table): every row of conformance/resolver-cases.json yields the expected read-directory class in the Python run and in the Java run, and each run executes exactly the file's row ids. Fails on any class mismatch, on a row one runner did not execute, and on a row id present in a runner's executed set but not in the file (a case added to one language only).
  • AC-6f (absent topology): a Mission whose meta.json has no topology field is classified as live Python classifies it, once with a valid lanes.json, once with a corrupt lanes.json (no lanes, read not failed). Fails if the corrupt file fails the read.

FR-007. Cursor-keyed projection cache. The application caches the per-Mission projection. Cache key: the stream cursor (offset:invariant) together with the ledger's byte size and last-modified time (and, where the platform exposes it, its file key). An unchanged key never triggers re-reduction; a changed key invalidates. The stream cursor alone is not a content digest of the log: its invariant is the SHA-256 of the single most recently consumed line (TailCursor.content_invariant, computed by _content_invariant_for in tail_reader.py), so it detects a change to the last line and a change in length, and nothing else. The size and modified time cover a rewrite of an earlier line.

  • Known limit: a rewrite that keeps the byte length, keeps the final line and restores the modified time is not detected by the cache key, by the cursor digest, or by the stream (no log-truncated). Live Python has the same blind spot (its tail reader hashes only the last consumed line), so there is no Python-parity golden to differ from; AC-7d pins the limit so that a change to it is deliberate.
  • AC-7a: two consecutive /missions calls with no appends invoke the reducer zero times on the second. Fails if the counter moves.
  • AC-7b: after an append, the next call reduces that Mission only and returns the new counts. Fails if stale counts are served or other Missions are re-reduced.
  • AC-7c: two fixtures, each rewriting the log to a different content of EQUAL length after a first read. (i) Only the LAST line changes: the next call serves the new counts, the stream cursor's digest differs. (ii) Only a MIDDLE line changes, the last line stays, the modified time advances: the next call re-reduces and serves the new counts. Fails if either serves the stale cache entry.
  • AC-7d: the middle-line rewrite of AC-7c(ii) with the modified time restored to its previous value is served stale and emits no log-truncated; the test asserts exactly that and is named as the documented limit. Fails if the behaviour changes without this requirement being amended.

FR-008. Conformance gate. Per decision D-Q1: (i) a Python generator script writes the goldens from live Python output for the curated synthetic set under services/mission-status-api/src/test/resources/conformance/; (ii) a Python test in the contract-tools test job (tests (contract tools); the module lives in tests/contract/, carries the corpus marker and so runs in the contract_tools routing group, see tests/ci/test_contracts_routing.py; the operator decision D-Q1 says "router corpus job", and on this checkout the router's own corpus job runs only the three modules of ROUTER_CONTRACT_MODULES in tests/ci/test_contracts_workflows.py) asserts the committed goldens equal live Python output; (iii) the Java suite, against the inbound port, asserts Java equals the goldens; (iv) a nightly job, a schedule-triggered job of this mission's own workflow .github/workflows/mission-status-api.yml (not a second workflow file, because the count of workflow files is capped, Reflexivity item 2; the existing ci-nightly.yml is not edited), compares the full repository corpus both ways (job conditions: FR-015). All four fail on any difference.

  • The Python-side reference for the overview derivations currently lives in test helpers (tests/contract/_mission_status_payloads.py, expected data in tests/contract/fixtures/mission_status_expected.json) and no production Python overview builder or read port exists yet (#5532 is open). The generator reuses those helpers and materialize_snapshot; it is not a second oracle. Whether the generator moves to a production read port when #5532 lands is a plan item.
  • What the golden test proves, and what anchors the derivations. The goldens are generated by build_overview in the test helpers, which the module describes as a copy of the contract-description rules. The golden test (live helper output against golden made by the same helper) therefore gates a change to the reducer or to the helpers; it does not prove the helpers correct. Their correctness is anchored by tests/contract/fixtures/mission_status_expected.json through expected_mismatches/load_expected, and the new golden test module invokes that anchor check as well, so the two run in the same job.
  • Comparison scope. Every comparison (Python test, Java suite, nightly) is exact equality of the canonical serialisation of the ENTIRE response document, with an explicit ignore-list that is empty. The list is one named constant in each language and a test asserts it is empty. No timestamp, ordering or number is normalised.
  • Curated fixture classes. The four classes (five fixtures) of decision D-Q1 are retained: interleaved (two fixtures), coordination, lifecycle-only and log-truncated (one each). The classes below are added because a review of this specification found paths those four do not exercise. Each class has a tag and a machine-checked distinguishing property recorded in the goldens manifest (conformance/manifest.json, which also records specKittyEventsVersion, supportedSchemaMin and supportedSchemaMax (AC-1f), the id list of conformance/resolver-cases.json (FR-006) and, per fixture, the read-directory class):
Class tagFixturesDistinguishing property assertedParity
interleavedtwo Missionsnon-empty events; equal at with different event_id; file order differing from sort order; a duplicated event_id; rows whose text order of at differs from instant order (different offset, different fractional digits)Python golden
coordinationone coord Missiona current ledger under .worktrees/...-coord/ and a primary ledger whose lane counts differPython golden
coord-huska single_branch and a lanes Missiona husk ledger beside the Mission whose counts differ from the primary ledgerPython golden
absent-topologyMissions with no topology field, with valid and with corrupt lanes.jsonclassification differs between the two lanes.json cases only where Python's doesPython golden
lifecycle-onlyone Missiononly the seven lifecycle types; wpTotal 0; a lifecycle-derived statusPython golden
no-ledgertwo Missionsmeta.json only, no ledger file; one of them with discardedAt set (lifecycleStatus discarded, not draft)Python golden
log-truncatedone Mission, plus a stream scenarioa shrunken log and a same-length last-line rewritePython golden
malformed-rowsone Mission per variant: invalid JSON line, non-object line, missing required key, unknown lane, unknown kind, malformed meta.json, an empty meta.json, a non-UTF-8 meta.json, a directory without meta.json (not a Mission: not listed, no diagnostic), invalid UTF-8 bytes, a blank lineeach variant records live Python's outcome (an OS-unreadable meta.json, row L7, and an unlistable directory, row L11, have no Python golden and are Java-only fault-injection tests of AC-4a): the exception class actually raised (StoreError, DiaryError, UnicodeDecodeError for invalid UTF-8 in a ledger, which has no line number, or ValueError for the meta.json variants) and the 1-based line where there is one; the blank-line variant records both answers (read_events_raw skips it and the overview is served; poll_once raises, which the stream scenario asserts as row L10)Python golden (refusal or both-answers)
torn-final-linetwo Missions(a) an unterminated unparseable last line; (b) an unterminated last line that is valid JSON(a) Python golden (refusal); (b) Python golden of the mixed-prefix outcome, tagged knownDivergence, with a Java-only assertion of the L4b refusal
crlf-and-listinga CRLF ledger with a LF twin; Missions whose directory names sort differently by byte order and by case foldingCRLF counts equal LF counts, streamCursor differs; page order equals the contract orderPython golden
project-headerhealthy; .kittify/metadata.yaml absent; unknown schema version; detached HEAD; a branch name failing the pattern; a name over 255 characters; a host-path-shaped name; an unborn branch; a .git pointer file with a relative gitdir:the Project documentPython golden
projectionMissions whose free-text and strict fields exercise the projectora friendly name holding a host path and an email (served with [path] and [email]); a nullable strict field holding a path-shaped value (served null); a non-nullable strict field holding a token-shaped value (the oracle raises ProjectionError: the golden records the refusal and the Java answer is the 500 of rows L3a and L3b with the diagnostic projection_refused)Python golden
secret-canariesone projectcanary values planted in .kittify/config.yaml, .kittify/metadata.yaml, meta.json and event payload text (FR-009, AC-9g)Java assertion (no golden of the canaries)
symlinka symlinked ledger, a symlinked meta.json, a Mission directory symlink escaping the rooteach is refused (FR-010, AC-10d)Java only: live Python's read_events_raw does not refuse a symlinked ledger, so there is no Python golden; the manifest tags it javaOnly and the Python golden test skips it by that tag, asserting the tag is present

Fixtures are committed as static trees; the Python test materialises each into a temporary directory before running the oracle, with a git repository only for the classes that need one (coordination, coord-husk, and a stored-coord topology with an absent coordination root and a declared branch; a registered coordination worktree where the class needs one) and, for every project-header fixture, the .git/HEAD (or .git pointer file) written from the fixture's HEAD text with no repository at all; the Java suite copies the same static tree and runs the same HEAD step. Line endings of fixtures are protected from checkout conversion by a .gitattributes entry for the conformance directory.

  • AC-8a: the gate can go red, by two mechanisms and no other. (1) Comparator: for every member of MissionOverview and Project, a unit test copies a passing actual document, mutates that one member, and requires the comparator to report a difference naming the member; a comparator that ignores lastActivityAt or reorders silently fails here. (2) Reducer ordering (runs once, against the filesystem-backed source; it is red by construction and so is not in the dual-source subset of FR-013): the domain reducer takes its ordering as a constructor argument (it stays framework-free), and the test supplies a deliberately wrong ordering (sort by at only, no event_id tie-break) and requires the conformance suite to fail on the interleaved class. The Python side is perturbed by editing one golden value in a temporary copy and by monkeypatching one helper derivation; each turns the Python golden test red. Fails if any perturbation leaves its suite green.
  • AC-8b: the manifest lists exactly the classes in the table above (and resolver-cases.json, a data file, not a class); the Python test asserts the file set of the conformance tree (one golden per fixture, plus exactly manifest.json and resolver-cases.json as the accepted non-class files; the port-level id list is not in this tree, FR-013), the class tags and EACH class's distinguishing property (read from the golden content, not from the tag). A trivial golden carrying the right tag fails. Fails if a class is missing, extra, or lacks its property.
  • AC-8c: the nightly comparison is a script (its path is a plan choice; it keeps pytest out of the workflow, Reflexivity item 2) that exits non-zero on the first differing Mission and prints its slug and the differing field. For a whole-read 500, which cannot be attributed to a Mission over HTTP, it names the side, the route and the Problem code instead of a Mission; otherwise it names the Mission and the class. Two ways to see it fail before merge: a unit test of the script on a two-Mission input with one planted difference (asserts the non-zero exit, the slug and the field), and one workflow_dispatch run of the workflow on the PR branch, recorded in the PR (FR-015 runs the nightly job on workflow_dispatch for this reason; a schedule run happens only on the default branch and cannot be observed from the PR). Fails if the script only prints a summary and exits zero, or the dispatch run is not recorded.
  • AC-8d: the golden generator, run twice, produces byte-identical output. Fails on any diff (non-determinism would make the gate flaky).
  • AC-8f (registry): the new corpus-marked golden-test module is listed in _CORPUS_MARKED_MODULES of tests/architectural/test_ci_corpus_trigger_completeness.py and test_corpus_marked_modules_match_the_curated_registry passes. Fails if the module carries the marker and the registry does not name it, or if the registry names a module that does not carry it.
  • AC-8e (routing): a pull request that changes only a file under services/mission-status-api/src/test/resources/conformance/ selects the tests (contract tools) job and no src-backed code shard. Verified by a test beside tests/ci/test_contracts_routing.py that loads the router with scripts.ci.gate_selection.load_router and calls select_gates/select_modules, as that module does. Fails if the golden-only diff selects no Python job, or selects a code shard.

FR-009. Loopback, Host and Origin guard. The service binds to 127.0.0.1 only (and not 0.0.0.0, ::, [::1], a LAN address or a hostname). Accepted Host forms are exactly 127.0.0.1:<own port> and localhost:<own port>: a host from that set, a colon, and the service's own port. [::1] is NOT accepted, because the service never listens on IPv6 loopback, so no real client can send it and accepting it would only widen the accepted set beyond the reachable one; a client that resolves localhost to ::1 first meets a refused connection and falls back to 127.0.0.1 (the plan notes this in the service README); a Host without a port, with another port, with extra labels, or missing is refused. Origin: absent is allowed (non-browser clients); present, it must be http:// plus an accepted host form (own port); no allowed-origin configuration exists in v1, so only those two own-port loopback forms are accepted and there is no configuration key to widen them; the literal null and anything else is refused. The check is a servlet filter ordered ahead of MVC dispatch, so it covers every path including unknown paths, error paths and the contract document route. Contract document and springdoc: the service serves the committed contract document, as it stands and bundled at build time, at the fixed path /api/v1/openapi.yaml (operator decision E-11; AC-11d); springdoc is not part of the stack, so /v3/api-docs and /swagger-ui do not exist; the service answers only the three routes of C-004 and the contract document, and any other path is answered 404 only AFTER the Host and Origin filter has passed. No secret or token appears in any response or log line. The refusal status (403 or 421, Q-5) is one constant used for every refusal the filter makes; the container-rejected Host values of the closed list in AC-9b keep the container's 400.

  • When it cannot do its job: an address bind that fails (port in use) or that is refused (non-loopback) exits non-zero with a message naming the cause. Silent fallback to another port or another interface is forbidden.
  • AC-9a: configuring a non-loopback bind (each of 0.0.0.0, ::, a LAN address, a hostname) makes the process exit with a non-zero code within an initial, provisional 10 seconds (wall-clock note), writes a message naming the refused address to its log or stderr, and a TCP connect attempt to that address on the configured port is refused. Fails if the exit code is zero, the process is still running at the deadline, or any connect succeeds.
  • AC-9b (Host tables, operator decision E-1): the refusal is split into two sets, and both are refused before any read. Set 1, answered 403 request_refused (the refusal constant) by the filter: a parameterised real-socket test sends each Host value the filter can see: evil.example; evil.example:<port>; 127.0.0.1 (no port); 127.0.0.1:<port+1>; a request with no Host header (HTTP/1.0 over a raw socket); 127.0.0.1.evil.example:<port>; localhost.evil.example:<port>; [::1]:<port> (sent over IPv4 by a raw client). Set 2, answered 400 by the container (Tomcat) itself, before the filter: exactly these four, the first a malformed Host value and the other three decided by the container's request preparation (amendment of 2026-10-10, operator ruling after analyze, ANL-005): [email protected]:<port>; a request carrying a duplicate Host header; an HTTP/1.1 request without a Host header; an HTTP/1.1 request whose absolute-form request target names a foreign authority while the Host header is an accepted form. The list of set 2 is closed: no custom container valve re-renders it, every Host value not on it belongs to set 1, and adding a value to either set is an amendment of this specification. For every row of both sets the test requires its literal expected status (403 or 400, hard-coded in the test and never taken from the response), no Mission read (the instrumented filesystem port records zero reads), no stream opened, and no canary, path or HOME in the body (the AC-9g scan, which includes the container-generated 400 body). Each row also carries a literal reachesFilter flag, true for set 1 and false for set 2, asserted through a test-profile counter at filter entry. The same tables run against /api/v1/project, /api/v1/missions, /api/v1/events (no stream opens: the response is not text/event-stream and an append produces no data on the connection), the contract document route and an unknown path (refusal, not 404). Fails on any 200, any 404 for the unknown path, any status that differs from the row's literal, or any recorded read.
  • AC-9c (Origin table): a parameterised test sends each foreign Origin with an accepted Host and requires the refusal constant on the same five request targets: https://evil.example; null; http://127.0.0.1:<port+1>; http://127.0.0.1.evil.example:<port>; https://127.0.0.1:<port> (wrong scheme). Fails on 200 or on CORS response headers that allow the origin.
  • AC-9d: each refusal has an accepted control on the same fixture: Host: 127.0.0.1:<port> and localhost:<port> with no Origin, and with Origin: http://127.0.0.1:<port> and http://localhost:<port>, all return 200 on /api/v1/project. Fails if the guard refuses any of them.
  • AC-9e: with the port already in use, the service exits non-zero within an initial, provisional 10 seconds and its message names the port. Fails if it picks another port or the message omits the port.
  • AC-9f: withdrawn and replaced by AC-9g, whose canary scan fails on any match (the withdrawn text scanned for a "fixture token" that no requirement defined); the id is kept so references stay valid.
  • AC-9g (canaries): the secret-canaries fixture plants, in .kittify/config.yaml, .kittify/metadata.yaml, meta.json and the text fields of event payloads, a token-shaped value, an absolute-path-shaped value and an email-shaped value, each with a distinct recognisable marker. Every response body and header of the three routes (including the SSE frames of the events the fixture produces and every refusal), and every log line of the whole flow, is scanned for each canary and for the fixture root path and HOME. Fails if any canary, root path or HOME value appears. The free-text fields that the Python helper projects through its redaction classes (path, email; Projector.human in tests/contract/_mission_status_payloads.py) are served with the same redaction, and a strict field holding a non-representable value is nulled when nullable and otherwise refused, as Projector.strict does (a ProjectionError, answered 500 like row L3a or L3b by route, with one diagnostic line carrying the code projection_refused and the mid8 and no value); the goldens of the projection class (FR-008) record the redacted output, and the patterns have one source: contracts/tools/leak_patterns.py, written by the generator to src/main/resources/leak-patterns.json, whose SHA-256 the manifest records.
  • AC-9h (threads, native and JVM): a test-profile probe records Thread.currentThread().isVirtual() in the handler of /api/v1/missions and of the SSE handler. Fails if either records false, on the JVM test run and in the nativeTest run.

FR-010. Read allowlist. The "ledger and events only" claim of the issue is not literally true for this service's outputs: the overview also needs identity and dates from meta.json (including merged_at and baseline_merge_commit, written by record_baseline_merge_commit in src/specify_cli/consolidation/baseline.py), the project header needs .kittify/config.yaml, .kittify/metadata.yaml and the git HEAD file. The no-extra-reads test therefore asserts an EXPLICIT ALLOWLIST, per project root and per Mission, with the DIRECTORY each file is read from: 1. <read dir>/status.events.jsonl: the ledger, read from the RESOLVED read directory (the primary Mission directory, or the coordination Mission directory for a coord-routing topology, FR-006), and watched for the SSE stream. 2. <own primary mission dir>/meta.json: identity, dates, merged_at, baseline record, read from the Mission's OWN primary directory only. The coordination copy of meta.json is never read (it may be absent in a sparse checkout) and no identity field depends on it. 3. <own primary mission dir>/lanes.json: read only when meta.json has no stored topology and topology classification needs the lanes signal (FR-006 rule 3). A corrupt file means "no lanes". 4. .kittify/config.yaml (project.slug). 5. .kittify/metadata.yaml (spec_kitty.version, spec_kitty.schema_version). 6. .git/HEAD (current branch, no process started); when .git is a regular file, that pointer file and the one file HEAD in the git directory it names (a gitdir: line, as in a linked worktree or a submodule; the oracle helper head_file does the same). This is the single exception to root containment, because git places that directory outside the project root; no other file of it is read (not worktrees/, packed-refs or refs/), the pointer is read without following a link, and a pointer that names a missing or unreadable HEAD gives currentBranch: null. Plus the directory listings the resolver needs: kitty-specs/ entries and .worktrees/ entries (names only, and a stat of the coordination Mission directory). No git registry or ref file is read (.git/worktrees, packed-refs, refs/): see FR-006 for the verdicts this gives up. The plan finalises this list against the contract's x-source entries and may only narrow it or add a file by amending this requirement with a stated reason. It never includes tasks/*.md, spec.md, plan.md, artifacts, WP frontmatter or status.json. The Python oracle itself reads status.json once, inside materialize_snapshot, through _target_schema_version, only to pick the snapshot's schema generation; no overview field depends on that, so "never status.json" describes the service, not the oracle. Symlinks and containment: every read opens its target without following a final-component symbolic link (LinkOption.NOFOLLOW_LINKS plus a check of the file attributes; the equivalent of the O_NOFOLLOW of _read_text_without_following_symlinks in store.py, which Windows lacks), and the real path of every resolved directory, coordination worktrees under .worktrees included, must lie inside the real path of the project root (the containment _is_safe_slug and _is_contained in store.py apply to the slug of an event). A COORDINATION read directory whose real path is outside the root is treated as the oracle treats "read directory outside this checkout": the Mission's own primary directory is read instead (this fallback covers the resolved ledger directory only). A Mission's OWN primary directory, or its own meta.json, that is a symbolic link or resolves outside the root is not read at all and has no fallback: that one Mission is omitted with a diagnostic (row L9), and a symlinked ledger is row L2. Python's overview read path (read_events_raw) does not itself refuse a symlinked ledger, so this is deliberately stricter than the reference, which is why the symlink class is Java-only (FR-008).

  • AC-10a: the test runs the full fixture flow (all three routes) under an instrumented filesystem port and fails on any path outside the allowlist or any path read from the wrong directory (the coordination meta.json, the primary ledger of a coord Mission). Positive controls: it fails when a deliberate read of tasks/WP01.md is added, and when a deliberate read of the coordination meta.json is added.
  • AC-10b: the allowlist is stored in one test-visible constant and the service README's allowlist section (a fenced list the test parses) names the same files. Fails if the constant and the documented list differ in any entry.
  • AC-10c: no child process is started during any request. Two mechanisms, each named with the leg it runs on. (1) Static, all legs: an ArchUnit rule over the main source set that bans references to ProcessBuilder and Runtime.exec; it sees only the service's own classes, not framework or library code. (2) Runtime, JVM test run only: a Java Flight Recorder recording around the full request flow, asserted to hold no process-start event (jdk.ProcessStart; the plan confirms the event name on the pinned JDK, and if it does not exist the runtime half is dropped and this AC amended, not left as a phrase). Wrapping ProcessBuilder or a SecurityManager is NOT a mechanism (the class is final and the SecurityManager is permanently disabled from JDK 24), and neither a Java agent nor JFR exists in a native image. (3) Native legs: covered by rule (1) plus an operating-system observation by the test harness, not the service: the number of child processes of the service process (Linux: the children listed under /proc) sampled before and after the request flow and required equal; this is a sample, weaker than (2), and the AC says so. Fails if any mechanism sees a spawn; positive controls: a ProcessBuilder call added in a test branch must turn rule (1) red, and a probe that starts a process must appear in the JFR recording of (2).
  • AC-10d (symlinks): the symlink class fixtures (a symlinked ledger, a symlinked meta.json, a Mission directory symlink whose target is outside the root) are each refused: the ledger case is row L2, the other two are row L9 (that one Mission is omitted with the path_refused diagnostic; every other Mission is still served with 200, which the fixture asserts so one stray link is shown not to deny the service); the target's content is never read (instrumented port records no read of it) and never appears in any response. A positive control fixture, an ordinary regular file at the same path, is read normally. Fails if the symlink target is read or appears in a response, or if the control is refused. The fixture runs on Linux and macOS; on Windows the leg exercises the same refusal through a link it can create, and if the runner cannot create one the job summary says so explicitly (never a silent skip).
  • AC-10e (instrumentation is complete): the filesystem adapter is the only code that touches java.nio.file and java.io file APIs (ArchUnit, FR-012), so a read or write that bypasses the instrumented port is a build failure rather than an unseen access. Fails if any other package references those APIs.

FR-011. Contract conformance of responses. A contract test validates every real response of /project, /missions (including the 400 refusal) and the data of each SSE event against contracts/mission-status/openapi.yaml and its schemas using an OpenAPI 3.1-capable validator (unevaluatedProperties: false and type: [string, 'null'] must be honoured). The validator is chosen for 3.1 support; the contract's own Java parser is known to drop 3.1 constructs and is not that validator.

  • AC-11a: all fixture responses validate. Fails on any validation error.
  • AC-11b: a response with one added unknown property fails validation. Fails if it passes (the validator is not honouring unevaluatedProperties).
  • AC-11d (contract-first document, operator decision E-11): GET /api/v1/openapi.yaml with an accepted Host answers 200 with the committed contracts/mission-status document, bundled at build time, byte for byte equal to the bundle the contract tooling writes for the same commit; the build, not a runtime generator, produces it. Fails if the route answers 404, the bytes differ from the bundle (including a hand-copied stale copy), the document is generated from the controllers, or any springdoc or swagger-ui dependency is on any Gradle configuration; /v3/api-docs and /swagger-ui answer 404 after the filter.
  • AC-11c (generated types track the contract): a build-time test copies the contract root to a temporary directory, applies one deliberate mutation (renames one property of MissionOverview, and separately removes one required property of Project), runs the service build's generate-and-compile step against the copy and requires that step to fail; a positive control runs the same step on an unmutated copy and requires success. Fails if either mutation builds or the control fails. Fallback (C-007): if the WP02 spike chooses hand-written DTOs there are no generated interfaces, and this AC is replaced, by the WP02 decision record, with (i) AC-11a and AC-11b over every response and (ii) a drift test that fails when a property of Project, MissionOverview, MissionOverviewPage or the Problem schema in the contract has no DTO member (and the reverse), with the same mutation controls (a property added to the temporary contract copy turns it red). The test asserts which of the two modes the build is in, so an AC that checks nothing cannot pass by "no generated sources".

FR-012. Hexagonal architecture enforced. ArchUnit tests enforce: adapters depend on application, application on domain, never the reverse; the domain and application packages import no Spring, HTTP, JSON or java.nio.file/java.io filesystem packages; Spring stays in adapter.* and config; contract DTOs live only in adapter.in.http.

  • AC-12a: the ArchUnit suite passes on the tree. Fails (control) when a Spring import is added to a domain class in a test branch.
  • AC-12b: the rules are not empty: a sanity test asserts each rule's scanned class count is above zero. Fails on an empty scan.

FR-013. Conformance suite on the inbound port. The golden and behaviour suites are written against MissionStatusQuery (project header, mission overviews, changes since a cursor), through a test-fixture factory, so another implementation behind the port can run them. Which seam. MissionStatusQuery is the inbound port; the application service implements it, and the seam that a second implementation replaces in the test is the outbound port MissionEventSource (an in-memory source feeding the same application service). The outbound port supplies, per Mission, what the application cannot compute: the Mission's meta.json members, the decoded ledger lines of the resolved read directory (the adapter reads the bytes and decodes them; the application and domain never touch JSON, FR-012; amendment of 2026-10-10, ANL-006, matching plan E-13 and data-model.md), and the project-header inputs (configuration slug, metadata, branch name); path resolution, symlink and containment checks, listing and OS faults live in adapter.out.filesystem. The Clarification names the port by the ledger and event logs; the exact method set is a plan item and must keep this division. Two suites, not one. The port-level subset holds the tests whose whole input is ledger bytes plus meta.json members and whose observable is a typed value or the typed exception MissionStatusUnavailable carrying the row's diagnostic code; the HTTP mapping of that exception (status, Problem) is an adapter test. It is a committed id list, src/test/resources/portlevel-tests.txt (beside the Java test sources, not under conformance/, because only the Java side reads it), holding exactly: AC-2a restricted to the fixture classes interleaved, lifecycle-only, no-ledger, log-truncated and crlf-and-listing (the classes whose outcome is not decided by path resolution, config.yaml, HEAD or symbolic links); AC-2b; AC-5a; AC-5c; the port legs of AC-3g and AC-3i; and the port legs of AC-4a for rows L1, L3a, L3b, L4 and L4b. The adapter suites are everything else and run against the real adapters only: the other fixture classes of AC-2a (coordination, coord-husk, absent-topology, malformed-rows, project-header, symlink), the coordination resolver (AC-6), the size-and-mtime cache key (AC-7c, AC-7d), torn-tail bytes written by a concurrent writer (AC-4d), symlink and containment refusals, the instrumented-read allowlist and write-attempt tests (AC-10, AC-16), AC-8a, and every route, header, Host, Origin and SSE framing test (AC-2c, whose observable is the HTTP 400 invalid_page_cursor and names no typed port exception, so it is an adapter test; the stream legs of AC-3g and AC-3i, AC-3a to AC-3f, AC-3h, AC-3j to AC-3o, AC-9). The port mirrors the Python read port of #5532 one to one; since that port does not yet exist in src/ (see Assumptions), the mirror is against the contract operations and is re-checked when #5532 lands.

  • AC-13a: the port-level subset (the id list above) runs against the filesystem-backed MissionEventSource and against a second in-test in-memory MissionEventSource, each behind the one application service implementing MissionStatusQuery, through one fixture factory. Each run records the ids it executed and asserts the set equals the file's ids, and the file holds at least the ids named in FR-013 (a recorded floor, so an emptied list fails). Fails if the second run executes fewer ids than the first, or if any listed test is skipped in either run.
  • AC-13b (scope: the port-level subset only): the subset's test classes live in the package ..conformance.port..; the fixture factory lives in the test-support package ..testsupport.., which alone may import adapter.out.filesystem and returns only the MissionStatusQuery and MissionEventSource interfaces. An ArchUnit rule requires that no class in ..conformance.port.. depends on any class in ..adapter.., and the in-memory source is a ..testsupport.. class. The rule has a positive control that adds an adapter import to a subset class in a test branch and must turn red, and a sanity check that the scanned class set is non-empty and equals the classes named by the id list. Fails on an import, a missing control, or an empty scan. Adapter suites are exempt.

FR-014. Standalone Gradle build and supply chain. services/mission-status-api/ is its own Gradle (Kotlin DSL) project. It is not included in contracts/settings.gradle. The Gradle version is deliberately different from contracts/ (8.14.5 in contracts/tools/pins.json): that version cannot run JDK 25 (JDK 25 needs Gradle 9.1 or newer). "Match contracts/" (decision D-Q3) means adopting its supply-chain posture; it does not mean every control already exists there. Verified against this checkout:

ControlIn contracts/ todayIn this missionFailing check (new ones named here)
Verification metadatacontracts/gradle/verification-metadata.xml with verify-metadata true and verify-signatures falseINHERITED posture: the service has its own file with the same two settings and a checksum for every artifactAC-14a, AC-14c
Actions pinned by SHAverify_pins.py checks the uses: lines of contracts*.ymlINHERITED practice for the new workflow (FR-015)AC-15d
Gradle distribution pinned by hashGradle is a sha256-pinned archive in tools/pins.json, installed by install_tools.py; NO wrapper is committed (gradle_pin_check.py says so)INTRODUCED: a committed wrapper with distributionSha256SumAC-14f
Wrapper validationnone (there is no wrapper)INTRODUCED: the Gradle project's wrapper-validation action, pinned by SHAAC-14b
Version catalognone (contracts/build.gradle declares versions inline)INTRODUCED: gradle/libs.versions.toml, the only place a version appearsAC-14g, NFR-007
Tamper checkcontracts/tools/tamper_check.py, hard-wired to the contracts tree (it copies it and runs tools/bundle.py)INTRODUCED for the service; tamper_check.py is a model, not a reuseAC-14a
Generator plugin pinnedcontracts/tools/pins.json; gradle_pin_check.py compares contracts/build.gradle and contracts/gradle/ onlythe service plugin version equals the pins.json pin (7.25.0)AC-14e

contracts/tools/gradle_pin_check.py compares only a wrapper under contracts/gradle/wrapper/, which is untouched, and reads contracts*.yml workflows; the plan verifies that it still passes and does not read the service wrapper (AC-14d).

  • AC-14a: the service build resolves with dependency verification in strict mode and fails when one dependency checksum in the service's verification-metadata.xml is altered (a tamper test modelled on contracts/tools/tamper_check.py but written for the service tree). Fails if the tampered build passes.
  • AC-14b: CI fails if gradle-wrapper.jar does not match the wrapper-validation action. Observable: the validation step's exit status in a job run against a branch with a modified jar, recorded once in the PR; the workflow test (AC-15a) asserts the step is present, pinned by SHA and runs before any Gradle invocation. Fails if the modified jar passes or the step is absent or unpinned.
  • AC-14c: verification metadata covers every platform-specific artifact of the four-OS matrix. Observable: strict-mode resolution exits 0 on each of the four legs. Fails if a leg needs a checksum not present.
  • AC-14d: contracts/ builds and its tests are unchanged and green (non-regression), and tests/contract/test_gradle_pin_check.py passes. Fails if any contracts/ file or pin is edited.
  • AC-14e: a test beside tests/ci/ (for example test_mission_status_api_pins.py; gradle_pin_check.py is not extended because it parses Groovy build.gradle syntax) reads the generator plugin version from the service's version catalog and build file and the openapi-generator-gradle-plugin pin from contracts/tools/pins.json. Fails when they differ, naming both values.
  • AC-14f: a wrapper-properties test requires distributionSha256Sum to be present in services/mission-status-api/gradle/wrapper/gradle-wrapper.properties and to be 64 lowercase hex characters, and distributionUrl to name Gradle 9.1 or newer. Fails on a missing, short or non-hex value or an older Gradle.
  • AC-14g: a catalog-only test (a grep over every *.gradle.kts of the service) fails on any literal version string outside gradle/libs.versions.toml, and on any dynamic version (+, latest). Fails on the first occurrence, printing file and line.

FR-015. Path-filtered CI and native release matrix. A new workflow, .github/workflows/mission-status-api.yml, runs only on changes under services/mission-status-api/ or contracts/mission-status/ (and the workflow file itself) on push and pull request, builds a native binary on Linux x64, Linux arm64 (ubuntu-24.04-arm), macOS arm64 and Windows x64, and runs against each native binary the curated golden set over HTTP and a smoke test. The contract tests (response validation against the contract, AC-11) run on the JVM leg; on the native legs, byte equality of the response bodies with the committed goldens (AC-15b) stands in for them (amendment of 2026-10-10, ANL-009). The nightly comparison of FR-008 is a schedule-triggered job of that same file, not a second workflow file (the number of files in .github/workflows/ is capped, see Reflexivity item 2; the path filter applies to branch pushes and pull requests only, and a tag push carries none). *The file name does not match contracts.yml*: contracts/tools/gradle_pin_check.py and contracts/tools/verify_pins.py glob .github/workflows/contracts.yml (DEFAULT_WORKFLOW_GLOB) and compare every gradle-version input to 8.14.5, so a service workflow named contracts-.yml would be read by them and fail or confuse them. It follows tests/ci/test_fork_guard.py (a fork guard as the first conjunct of every root job and of every always() job of a push-, schedule- or workflow_run-triggered workflow; see the jobs bullet below), permissions: contents: read, pins every action by SHA, uses stock runners, and mirrors .github/workflows/contracts.yml. A CODEOWNERS entry is added for the new directory in .github/CODEOWNERS; the router edits this mission makes are listed in Reflexivity. Whether the workflow is a required check is a maintainer decision at merge time and is flagged in the PR (a required path-filtered check can stall unrelated PRs; ci-router.yml router-gate is unaffected by a services/*-only change).

  • Jobs and event conditions (the one authority). Triggers of the file: push (branch main with the path filter and, in the same push block, the tag pattern mission-status-api-v..*, to which GitHub applies no path filter) and pull_request (with the path filter), plus schedule and workflow_dispatch, which carry no path filter (GitHub applies paths per event and a scheduled run has none). Every job's if: opens with the canonical fork guard of tests/ci/test_fork_guard.py, a parenthesised || of github.repository == 'spec-kitty/spec-kitty', github.event_name == 'pull_request' and github.event_name == 'workflow_dispatch' atoms, as first top-level conjunct. That test requires it of root jobs and of jobs whose if: uses always(), so it covers the native legs, the aggregate job and the nightly job. After the guard: (a) the jvm job (./gradlew check on ubuntu-24.04, separate from the Linux x64 native leg) and each native leg add && github.event_name != 'schedule'; (b) the aggregate job needs the jvm job and the four legs, carries always() and the same added conjunct, so a scheduled run skips the jvm job, the legs and the aggregate together and the rule that a skipped job fails the gate (AC-15c) never meets a scheduled run, while on push, pull_request and workflow_dispatch it fails unless the jvm job and every leg are success; (c) the nightly comparison job adds && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'), so a push or pull request never runs the full-corpus comparison; (d) the release job needs the aggregate job and adds && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/mission-status-api-v'), so a manual run, a schedule run and a pull request never reach it. On a tag push the jvm job, the legs and the aggregate run (their conjunct is != 'schedule') and the nightly job is skipped. A workflow_dispatch run executes the legs and the nightly job, and is the pre-merge verification route of the nightly job (AC-8c).
  • Release job (operator decision E-4, 2026-10-10). The release step is a job of the same file that runs only for a tag push of the pattern above; the maintainer cuts the first tag, and this mission pushes no tag and assigns no version. Workflow-level permissions stay contents: read; only the release job adds contents: write, and no step before the release step holds a write token. The concurrency group is tag-aware, so a tag run is never cancelled by another run and a main push run is not cancelled by a pull request run: mission-status-api-${{ startsWith(github.ref, 'refs/tags/') && github.ref || (github.event_name == 'push' && github.sha || github.ref) }} with cancel-in-progress: ${{ github.event_name == 'pull_request' }}. The tag pattern is disjoint from the CLI release tags v.. (release.yml) and from contract--v.. (contracts-release.yml): a tag name matches exactly one of the three patterns. The job attaches the four binaries and their .sha256 files to a GitHub release. The release artefact is not part of the Python wheel (pyproject packages are src/) and nothing is published to PyPI.
  • AC-15a: a test (alongside tests/ci/) asserts, from the parsed workflow, (i) the push and pull_request path filters contain exactly the FR-015 paths, (ii) schedule and workflow_dispatch are triggers and carry no path filter, (iii) each job's if: has the form of the jobs bullet (guard first, then its event conjunct), the jvm job and the release job included, (iv) the tag pattern mission-status-api-v.. is in the same push block as the branch and path filters, (v) workflow-level permissions are contents: read and contents: write appears on the release job only, (vi) the concurrency group is the tag-aware expression of the release bullet with cancel-in-progress true only for pull requests, and (vii) the tag pattern is disjoint from v.. and contract--v..: sample tags of all three namespaces each match exactly one pattern. Fails if a path is missing or extra, a path filter is applied to schedule, a job's condition differs, the release job holds contents: write beside another job, or a sample tag matches two patterns.
  • AC-15b: each native job starts the binary and, over HTTP against the curated golden set (including the crlf-and-listing and secret-canaries fixtures), requires byte equality of every response body with its golden; sends one request per refusal code (invalid_page_cursor; the five stream refusal codes negative, misaligned, cursor_without_mission, out_of_range, content_mismatch; the unknown-Mission 404; the Host and Origin refusals; a row L3a and a row L3b 500; a row L4b 500) and requires the status and Problem code the contract fixes; and runs an append-while-open SSE round trip that receives one status-transition event with the expected id: within the initial 2-second bound. Fails on any byte difference, any wrong status or code, or a missing event on any OS; a missed bound is classified under the ADR method, not retried. This is what makes a missing reflection or resource hint in Jackson serialisation of null members, Problem bodies, the filesystem adapter or SSE delivery visible.
  • AC-15c: the native jobs run on a pull request that touches the service (so the matrix is proven before merge). The Windows x64 and macOS arm64 jobs cannot be run on a Linux workstation, so CI is the only proof; the PR states which runs were observed. A final aggregate job needs the jvm job and all four legs, runs with if: always() (full condition: the jobs bullet above), and exits non-zero unless the jvm job's and every leg's result is success. Fails if the jvm job or a matrix leg is skipped, cancelled or missing.
  • AC-15d: the existing workflow and registry tests pass with the new file: tests/ci/test_fork_guard.py, tests/ci/test_workflow_script_import_guard.py, tests/release/test_release_ci_ownership.py, tests/architectural/test_module_shard_registry.py, tests/architectural/test_workflow_coherence.py, tests/architectural/test_no_duplicate_suite_execution.py, tests/architectural/test_ci_corpus_trigger_completeness.py, plus every further test the plan's grep adds (Reflexivity item 2). Exception, operator ruling 2026-10-11 (supersedes plan E-3): tests/ci/test_fleet_verdict.py and tests/ci/test_fleet_main.py enumerate every pull_request workflow through scripts/ci/fleet_verdict.py's closed PR_WORKFLOWS set, so the new workflow is registered there and in ci-fleet-verdict.yml's workflow_run.workflows, and those two tests pass with that registration edit. Fails otherwise, and fails if the number of workflow files exceeds the registry ceiling.
  • AC-15e (cross-OS determinism, NFR-005): each leg writes a digest of the concatenated bodies of its curated request set (AC-15b, refusals and SSE frames included) as an artifact; the aggregate job fails when the four digests are not all equal. Fails on any difference, printing the four digests.
  • AC-15f: no file under .github/workflows/ added by this mission matches contracts*.yml, and tests/contract/test_gradle_pin_check.py and tests/contract/test_verify_pins.py still pass. Fails if a service workflow is swept into those globs.

FR-016. Read-only and drain-independent. The service opens no file for writing in the project, takes no lock, calls no writing materialize() and refreshes no derived view (ADR D-6). It returns the same answer with hosted drain on or off. The drain keys are the two the Python posture reads in src/specify_cli/core/hosted_posture.py (drain_posture): hosted.drain in the repository's .kittify/config.yaml and [hosted] drain in the personal config.toml. The service reads no drain key: the personal config.toml is not in the FR-010 allowlist, and .kittify/config.yaml is read for project.slug only. The toggle can therefore only matter if some code reads it; AC-16c is the check that none does.

  • AC-16a: the filesystem port records the open mode of every file operation. The test fails on any open that is not read-only (create, write, append, truncate, delete-on-close, rename or delete), including operations from classes other than the adapter (FR-010 AC-10e makes the adapter the only caller of file APIs). A positive control adds one write to a test branch and must turn the test red. A swallowed write failure is covered because the port records the attempt before the operating system answers.
  • AC-16b: a fingerprint of the fixture project before and after a full request flow is identical. The fingerprint is content hash, directory listing (names and counts, including .lock and temporary file names) and modification times of every file and directory. Fails on any difference, including a create-then-delete of a lock file.
  • AC-16c: with hosted.drain: true in the fixture's .kittify/config.yaml and [hosted] drain = true in the fixture's personal config.toml (pointed to by a test-only path), versus both false and both absent, every response of the curated request set is byte-identical. Fails on any difference, and fails if the instrumented port records a read of the personal config.toml.
  • AC-16d: the project directory is made read-only where the platform enforces it (a non-root Linux path or a read-only mount; not on Windows, and not when running as root, where the test records that it did not run on that leg and AC-16a carries the proof), and the three routes are served unchanged. Fails if any route needs a write.

Non-Functional Requirements

IDTitleRequirementCategoryPriorityStatus
NFR-001Overview latencyWith a fixture project of at least 500 Missions (the repository corpus has about 590) and a warm cache, GET /api/v1/missions p95 is under 400 ms and a cold first call completes in under 5 s on the CI Linux runner; a call after one appended event re-reduces one Mission only. Restates, for this service only, the open charter "100+ work packages without lag" line. The 5 s figure is INITIAL and PROVISIONAL; the warm figure was 200 ms and was amended to 400 ms by CI-runner measurement (hosted 4-cpu Linux runner at load 3.9: warm p95 212 ms, then 235 ms, with the same-run ratio 27.75 inside its limit of 40 both times); the figures are amended with a measurement (as NFR-004's are). The primary assertion follows the runner-relative method of ADR 2026-10-05-2: the warm p95 and the cold call are each a ratio to a floor sampled in the same run (the p95 of a no-op route of the same service over the same connection count), with the ratio limit set from the first measurement; the absolute figures are the ceiling beside it. A miss is classified under the ADR method, never retried to green. The test asserts the ratios, the ceilings and the reducer-invocation count, and fails when any is exceeded.PerformanceHighOpen
NFR-002Native start-upThe native binary accepts its first request within 2 s of process start on each of the four CI targets (decision text says tens of milliseconds; the budget leaves CI headroom). The 2 s is an INITIAL, PROVISIONAL ceiling amended by a measurement; the primary assertion is a ratio to a floor sampled in the same run (the start-up time of a trivial native binary built on the same leg, if the plan keeps that floor, otherwise the ceiling alone is recorded as unmeasured). A miss is classified under the ADR method, never retried. Fails if any target exceeds the ratio or ceiling.PerformanceMediumOpen
NFR-003Loopback and secretsNo response, header or log line contains a secret, token, absolute path or environment value; the service listens on loopback only. Failing checks: AC-9g (canaries), AC-9a (bind), AC-4c (diagnostics are path-free).SecurityHighOpen
NFR-004Memory and concurrency100 concurrent SSE connections held for 60 seconds over a 500-Mission fixture while 10 rows are appended: (a) the peak count of live platform threads rises by at most 16 over the count before the connections open (JVM leg: ThreadMXBean; native leg: the thread count of the process), not counting the virtual-thread carrier pool, which is one pool per process sized by the core count rather than by connections; the carrier count is asserted separately to be at most the number of available processors (amended 2026-10-12 after the WP13 measurement: a 24-core machine starts 24 carriers); (b) the used heap after a forced collection at the end exceeds the value before the connections by at most 64 MB (JVM), and the resident set of the native process grows by at most 128 MB; (c) the reducer-invocation counter after the 100 connections equals its value after one connection plus the invocations caused by the 10 appends (no per-connection rescan); (d) all 100 connections receive every forwarded append. The numbers are initial budgets, measured with the runner-relative method of ADR 2026-10-05-2, and change only by amending this row with the measurement. Each is a failing assertion. Virtual-thread execution itself is asserted by AC-9h.ReliabilityMediumOpen
NFR-005DeterminismThe same project state yields byte-identical responses (stable field order and ordering) across runs and across the four OS targets. Checked by AC-8d (generator), the golden equality of AC-15b on every leg and the cross-leg digest comparison of AC-15e; the fixtures include a CRLF ledger and directory names whose order differs between byte order and case folding, and the service sorts listings itself (never in file-system order).ReliabilityHighOpen
NFR-006Java test coverageA JaCoCo (or equivalent) line coverage threshold of at least 90% on the domain and application packages, mirroring the charter's 90% intent for new code; the threshold is a build gate, not a report: the build fails when the measured line coverage is below 90%. The Python charter rules (pytest, mypy strict) do not apply to the Java tree.QualityMediumOpen
NFR-007Dependency hygieneEvery runtime and build dependency is pinned in the version catalog and checksummed; no dynamic version (+, latest). Failing checks: AC-14a (checksums), AC-14g (catalog-only, no dynamic version).SecurityHighOpen
NFR-008TerminologyProse added to docs/ and the README of the service uses the Mission and Work Package vocabulary of the glossary; the legacy-terminology guard (tests/architectural/test_no_legacy_terminology.py) passes. Contextive glossary files are updated if a new term is introduced and the CI check passes.MaintainabilityMediumOpen
NFR-009Windows file namesAll new file names avoid characters rejected by the Windows-filename gate (test_no_invalid_windows_filenames.py): `: " ? *{ } $`.PortabilityMedium

Constraints

IDTitleConstraintCategoryPriorityStatus
C-001StackJava 25, Spring Boot 4, Spring MVC on virtual threads (not WebFlux), GraalVM native binary per OS via Spring AOT and nativeCompile. No springdoc: the OpenAPI document is the committed contract, served as it stands (operator decision E-11).TechnicalHighActive
C-002Build toolGradle with Kotlin DSL (not Maven, not Groovy), committed wrapper, version catalog; standalone build in services/mission-status-api/, not a composite with contracts/.TechnicalHighActive
C-003ArchitectureDDD and hexagonal; framework-free domain and application; Spring confined to adapters and config; ArchUnit enforces direction; inbound port mirrors the Python read port (#5532) one to one.TechnicalHighActive
C-004ScopeOnly /api/v1/project, /api/v1/missions, /api/v1/events, the contract document at /api/v1/openapi.yaml (E-11) and scaffolding (there is no springdoc; /v3/api-docs and /swagger-ui answer 404, FR-009). Detail routes (#957) and artifact, drift and ops routes are out of scope. The CLI --json verbs stay on the Python read port.BusinessHighActive
C-005Security postureLoopback-only bind, Host and Origin checks, no secrets in responses; read-only and drain-independent.SecurityHighActive
C-006ConformanceGoldens plus nightly (decision D-Q1); the gate fails on any difference.TechnicalHighActive
C-007Contract consumptionGenerate Spring interfaces and models for /project and /missions only, openapi-generator 7.25.0, hand-written SSE controller, 3.1-capable response validator; WP02 spikes the generated models and falls back to hand-written DTOs if unusable.TechnicalHighActive
C-008Supply chainWrapper with distributionSha256Sum, wrapper-validation action, gradle/verification-metadata.xml in the posture of contracts/ (checksums verified), version catalog. Inherited from contracts/: the verification-metadata posture and SHA-pinned actions. Introduced by this mission, with no existing check to copy: the committed wrapper with its hash, the wrapper-validation step, the version catalog and a service tamper check (tamper_check.py is a model, not a reuse). Each introduced control has its own failing check (FR-014 table). The Gradle distribution version differs from contracts/ (8.14.5) because JDK 25 needs Gradle 9.1 or newer.TechnicalHighActive
C-009Not in the wheelNothing under services/ enters the Python wheel or sdist; pyproject.toml packages stay src/*.TechnicalHighActive
C-010Public repo hygieneNo private or customer detail and no absolute home-directory paths in any committed file, fixture, golden or log message.BusinessHighActive
C-011No charter editThe charter is not edited by this mission (see Known drift).BusinessMediumActive
C-012Contract is the vocabularyThe contract is 1.0.0-SNAPSHOT, no contract-mission-status-v* tag exists; the service builds against the in-repo contract at the same commit. Lifecycle vocabulary, chips and x-provisional members are implemented as the contract has them, not redefined.TechnicalHighActive
C-013Installed artefactsNo step edits installed artefacts outside the checkout; spec-kitty is run from the checkout's own virtual environment.ProcessMediumActive

Key Entities

  • Mission: identity (missionId ULID, mid8, slug), type, target branch, topology, dates; owns a ledger.
  • Work Package (WP): a unit with a lane, derived only from the ledger (not from WP frontmatter).
  • Lane: one of the contract's StatusLane values; counts per lane are statusLaneCounts.
  • Status event: one row of status.events.jsonl, ordered by the reducer by at then event_id (Q-7); the Java status event carries no logical-clock field because the ported reduction never reads one.
  • Status reducer (the reduction materialize_snapshot runs, ordered by (at, event_id), Q-7; the ADR wording "Lamport" is stale): the pure function from events to a per-WP lane snapshot, equivalent to spec_kitty_events.status.reduce as materialize_snapshot calls it (FR-005; the ordering key is verified there, see Q-7).
  • Overview read model: MissionOverview, the derived value served per Mission.
  • Stream cursor: offset:invariant, the position in one Mission's physical log (equivalent to TailCursor); the invariant is the SHA-256 of the single most recently consumed line, not of the whole log (FR-007 states what that does and does not detect); the page cursor is a separate, opaque value.
  • Inbound port MissionStatusQuery and outbound port MissionEventSource: the two seams of the hexagon.

Success Criteria

Measurable Outcomes

  • SC-001: Over the curated set, Java and live Python give identical overviews on every Python-parity fixture class of the FR-008 table (the four classes, five fixtures, of decision D-Q1 plus the added ones), each class's distinguishing property holds in its golden, and a perturbation of either side (AC-8a) turns its suite red. — [build] · no-op passable: no
  • SC-002: The nightly full-corpus comparison runs against all Missions present in the repository and ends with zero differences, or exits non-zero naming the first. — [build] · no-op passable: no
  • SC-003: Every response of the three routes validates against the contract in the contract test; a response with one extra property fails. — [build] · no-op passable: no
  • SC-004: The native binary builds on Linux x64, Linux arm64, macOS arm64 and Windows x64 in CI, serves the curated golden set byte-identically on each (AC-15b), and the four leg digests are equal (AC-15e). — [build] · no-op passable: no
  • SC-005: Every foreign Host and Origin value of the AC-9b and AC-9c tables and every non-loopback bind of AC-9a is refused, each with an accepted-request control (AC-9d). — [build] · no-op passable: no
  • SC-006: A full request flow reads only allowlisted files and writes nothing; adding one extra read or one write fails the test. — [build] · no-op passable: no
  • SC-007: A services/-only pull request triggers the new workflow and no src-backed Python code shard; a change only to the conformance goldens additionally selects the tests (contract tools) job and nothing else Python (AC-8e); a src/ change does not trigger the native matrix. — [build] · no-op passable: no
  • SC-008: Mid-flight Missions are unaffected by the change landing (see Reflexivity). — [build] · no-op passable: no

Reflexivity: Missions in flight when this lands

The service is read-only and detached (FR-016, C-005). Landing it adds files under services/, one workflow, a CODEOWNERS line, a Python golden test and generator, and it edits the shared CI routing. It changes no src/ file, writes no ledger, takes no lock, and is not started by any CLI command. Missions in flight (including this one) therefore see no change to their event logs, lanes or commands. The shared-surface edits are listed, not summarised:

1. .github/workflows/ci-router.yml: the contract_tools filter group gains the path services/mission-status-api/src/test/resources/conformance/ (and the generator's path if it lives outside tests/contract/). This is the only router filter edit this mission plans, with one proviso: test_the_contract_tools_filter_group_names_every_src_file_the_mission_status_reader_its_tests_import_and_the_contract_cites derives required group entries from the imports of every tests/contract/mission_status.py file, so a golden test or generator whose file name contains mission_status and that imports a src file the group does not already name forces further entries; the plan keeps the new module's imports within files already in the group (it needs status/reducer.py, status/tail_reader.py, status/store.py and the existing helpers), or lists the extra entries here. It is needed because the goldens live under services/, in no router group today, so a golden-only change would otherwise skip the Python test that asserts the goldens equal live Python (FR-008 (ii)). 2. Pinning tests. Verified against this checkout by symbol:

3. The home of the new tests/contract/ module (the golden test; its registry entry is item 2): it carries the corpus marker and so runs in the tests (contract tools) job, not in the router's own corpus job (which runs only the three modules of ROUTER_CONTRACT_MODULES), and needs no edit to ci-router.yml for that reason, because tests (contract tools) runs every corpus-marked module of tests/contract except the three it ignores; test_contracts_workflows.py already pins that every tests/contract module has exactly one home, and the new module must satisfy it. 4. .github/CODEOWNERS: one line for services/mission-status-api/. 5. New workflow mission-status-api.yml, carrying the path-filtered build, the scheduled nightly job and the tag-triggered release job (FR-015); ci-nightly.yml is NOT edited. 6. A new pins test in tests/ci/ (AC-14e) and the wrapper/catalog tests (AC-14f, AC-14g), which add tests and no behaviour.

  • tests/ci/test_contracts_workflows.py, test_the_contract_tools_filter_group_names_the_paths_the_contracts_workflow_triggers_on_beyond_contracts: pins that the router group equals the trigger list of contracts.yml (EXPECTED_PATHS), so the new path would fail it. It is amended to allow the goldens path as a named, documented extra of the group; contracts.yml itself is not edited. The routing test of AC-8e is added beside tests/ci/test_contracts_routing.py.
  • tests/architectural/test_ci_corpus_trigger_completeness.py: the hand-maintained _CORPUS_MARKED_MODULES registry must gain the path of the new corpus-marked golden-test module (and of the generator, if it is a test_*.py module carrying the marker); test_corpus_marked_modules_match_the_curated_registry goes red on a newly marked module otherwise. AC-8f checks it.
  • tests/architectural/test_module_shard_registry.py, test_reusable_workflow_ceiling_respected: counts every *.yml in .github/workflows/ (19 on this checkout) against reusable_workflow_ceiling (20) in .github/ci-module-registry.yml, and itself caps that ceiling at GitHub's 20. A second new workflow file would make 21 and turn it red, so this mission adds exactly ONE workflow file (the nightly comparison is a schedule job in it, FR-015). That uses the last free slot; the PR says so.
  • Tests that enumerate workflow files and must pass with the new one unedited, run by AC-15d: tests/ci/test_fork_guard.py (a fork guard on every root job and every always() job of a push-, schedule- or workflow_run-triggered workflow, so on the scheduled job and the aggregate job too), tests/ci/test_workflow_script_import_guard.py, tests/release/test_release_ci_ownership.py (no workflow may carry the EXPERIMENTAL fence; INTRODUCED_WORKFLOWS is read from the convergence map, not the directory), tests/architectural/test_workflow_coherence.py and tests/architectural/test_no_duplicate_suite_execution.py (via discover_pytest_workflows and enumerate_workflows: if any job of the new workflow runs pytest, the workflow joins WORKFLOW_FILES in tests/architectural/_gate_coverage.py and may need an entry in NON_CHANGE_TRIGGERED_WORKFLOWS; the plan keeps pytest out of the new workflow's jobs, or lists that edit here).
  • Plan task. This list is the result of one grep; the plan repeats it after the workflow files exist (grep -rln over tests/ci, tests/architectural, tests/release and tests/contract for .github/workflows, _CORPUS_MARKED_MODULES and ROUTER_CONTRACT_MODULES), records any further test it finds in this section, and AC-15d is extended to run it. "No other pinning test changes" is therefore not claimed.

"No behaviour change" for Missions in flight is verified, not assumed: AC-16a/16b (read-only, unchanged fingerprint), AC-14d (contracts/ untouched), AC-8e (router selection), and a diff check in review that no src/ or kitty-specs/ path other than this mission's own is modified, that the ci-router.yml diff is the one filter-group addition above, and that the only edited pinning tests are the two named in item 2 (test_contracts_workflows.py, test_ci_corpus_trigger_completeness.py). The reference for the conformance tests is the live Python reducer, so a Python reducer change landing mid-mission turns the Python golden test red first (risk R-2).

Baseline and verification honesty

Main is not assumed green. The known-red baseline issue #3284 ("main full suite has 23 untracked failures and 2 errors after bootstrap prewarm") is recorded as CLOSED (2026-09-08) on GitHub, but the operator's note and the charter's NO_FULL_HEAVY_SUITES_IN_MISSION rule both mean the plan must capture the baseline red set before the first work package and compare against it, rather than claim "all green". Acceptance of this mission requires: each new gate red-then-green (the perturbation controls above), targeted test packages only (tests/contract/, tests/ci/, the Java build), and CI owning the full sweep. Failures outside the changed surface are reported against the baseline, not hidden and not attributed to this mission.

Risks

  • R-1 Coordination-path divergence. Java mirrors _read_path_resolver by stored topology (FR-006) but cannot reproduce its git verdicts; the degrade policy is #5631 (open). Mitigation: FR-006 goldens generated from live Python, the coord-husk and absent-topology classes, the table-driven resolver cases (AC-6e) and the per-fixture read-directory class; no invented resolution.
  • R-2 Concurrent reducer work. Pull requests #5902 ("refuse empty status from lifecycle-only journal") and #5326 (status/reducer.py and store.py changes) were reported in flight by the readiness probe; at the time of writing both show as closed without merge, so neither has changed the reducer on this checkout. They may be re-opened or re-landed under another number. Mitigation: the Python golden test goes red on any reducer change that alters output, forcing golden regeneration and a Java follow-up in the same change; the author of any reducer PR must be told of the gate (coordinate on #5528). Verify the live state of both before the plan is accepted.
  • R-3 Gradle and JDK 25. Gradle 8.14.5 cannot run JDK 25; the service wrapper pins Gradle 9.1 or newer. Spring Boot 4, Native Build Tools and GraalVM versions that support Java 25 are verified at plan time.
  • R-4 Generated models. Lossy OpenAPI 3.1 handling may make generated models unusable; WP02 spike with hand-written DTO fallback (C-007).
  • R-5 Native-image-only failures. Reflection hints for Jackson and SSE only show in native runs; Windows and macOS legs are provable only in CI. Fallback named in #5528: jlink/jpackage.
  • R-6 Corpus job budget. The reality check is at its 10-minute cap (#5838); the per-PR gate must not depend on it.
  • R-7 Supply-chain metadata churn. Strict verification of several hundred artifacts across four OSes; adding it late causes regeneration churn, so FR-014 lands in the first work package.
  • R-8 Read port not yet existing. #5532 is open; the "mirror one to one" requirement is checked against the contract today and re-checked when the Python port lands.
  • R-9 Contract expressiveness. The contract cannot say "this Mission's log is unreadable" in a 200 page (Q-1) and defines no stream-error observable (Q-2). Mitigation: the FR-004 decided rows, each with a diagnostic and a falsifying test, and no contract change (operator answers Q-1, Q-2). An unreadable ledger is visible only in the service log.

Assumptions

  • Line numbers are deliberately not cited in this specification: every code reference is by file and symbol, verified against this checkout on 2026-10-09, so a later edit to those files does not stale the text.
  • services/ does not exist on this checkout (verified); no Java or Kotlin files and no setup-java 25 exist yet; contracts.yml and contracts-release.yml use temurin 21 for the contract tools.
  • contracts/mission-status/openapi.yaml is mergeable-stable at 1.0.0-SNAPSHOT (issues #5558, #5625, #5776 closed); the service builds against it at the same commit.
  • ci-router.yml's unmatched fail-closed catch-all fires only for src/, so a services/-only change does not trip it (readiness evidence; re-verified in SC-007).
  • Cursor and page-cursor text forms, SSE framing and heartbeat are x-provisional in the contract and may change before release.
  • Whether the new workflow becomes a required status check is decided by the maintainer at merge.
  • The Python generator can import tests/contract helpers and specify_cli.status.reducer under the checkout's own .venv, runnable locally and in CI.

Out of Scope

  • Mission detail routes, work package detail, artifact, drift and ops routes (#957, #5533 and the contract's other paths).
  • The Python read port and re-pointing agent tasks status --json, orchestrator-api mission-state and list-ready (#5532).
  • Serving the dashboard bundle at / and embedding the UI release (a later mission; the ADR allows it).
  • A cut-over of the CLI to the service.
  • Lifecycle vocabulary and WP chip decisions (open on #5528); x-provisional members are served as contracted.
  • Resolving degrade policy #5631, and amending the charter (see Known drift).
  • Any change to src/specify_cli/** production code.
  • Cutting the first release tag, assigning a version, or publishing anywhere other than the GitHub release of the tag-triggered job (the maintainer cuts the first tag, operator decision E-4).
  • Correcting the Project.lastActivityAt description or any other file under contracts/ (left to the contract owners; Known drift, item 3).
  • Editing CLAUDE.md or the ADR to correct the "Lamport" reducer wording (left to their owners; Known drift, item 4).

Questions and their resolutions

Each question states what the contract already says, the behaviour the plan builds and tests, and who must decide. A resolved question (Q-1, Q-2, Q-6 and Q-7, resolved by the operator on 2026-10-10) states its decided behaviour. Q-3, Q-4 and Q-5 stay open; the one that states an "Interim behaviour" (Q-3) names what the plan builds until its owner decides, and that behaviour changes only by amending this specification. None of them lets an AC stay without an observable: the decided behaviour, or for Q-3 the stated interim behaviour, is the falsifiable one.

  • Q-1 (RESOLVED, operator 2026-10-10) Can the contract express a per-Mission failure inside a 200 MissionOverviewPage? What the contract says today: MissionHead.yaml (lifecycleStatus description, rule 2) reads "wpTotal is 0 (including an unreadable event log), so draft", and Project.yaml (lastActivityAt description) says an event log that cannot be decoded or parsed fails the whole read with a 500 Problem. There is no per-Mission failure member. This spec reads "unreadable" as the operating system refusing or failing the read, and "cannot be decoded or parsed" as bytes read but invalid; the operator kept that reading (Q-1). The contract is also silent on a per-Mission parse failure inside a /missions page: the 500 rule is stated for Project.lastActivityAt only, so row L3b (a whole-page 500 for one Mission's bad ledger, in tension with the omit-and-continue policy of row L9) is this spec's inference, decided by the operator below. Behaviour (FR-004 rows L2, L3b, L6, L7, L9 and L11): an unreadable ledger is a 200 item with zero counts and draft plus a ledger_unreadable diagnostic; a vanished Mission directory is omitted with a mission_vanished diagnostic; the undecodable case is a whole-read 500 (/project: row L3a, settled by the contract; /missions: row L3b, this spec's inference decided by the operator); a symbolic-link refusal of a Mission's meta.json or directory (L9) omits that Mission with a path_refused diagnostic; an unusable meta.json (L7) and an OS-level listing failure of kitty-specs/ or a Mission directory (L11) are whole-read 500s. The accepted limit of this behaviour: for L2 a response body alone cannot tell an unreadable log from a fresh Mission, and only the diagnostic can. Decision (operator, 2026-10-10): RESOLVED. Keep the behaviour above as the decided behaviour. There is no contract change for a per-Mission failure member. This mission makes no edit to contracts/. The Project.lastActivityAt wording error (it names status.json as an input, which the service and the Python helper build_overview, from the reduced snapshot, do not read) is only recorded here (Known drift, item 3) as a note for the contract owners; it is not a deliverable and is not widened (C-012, AC-14d).
  • Q-2 (RESOLVED, operator 2026-10-10) On a source log that becomes unreadable (or otherwise faults) under an open stream, the contract defines only three event names and no error observable. Behaviour: the stream table (FR-004, rows S1 to S6) is the one definition: a Mission-scoped stream closes the connection (after the 404 or 500 at connect time), and an UNSCOPED stream drops only the faulty Mission, writes the diagnostic and keeps the connection; a reconnect meets the connect-time rows. Decision (operator, 2026-10-10): RESOLVED. Keep the behaviour above as the decided behaviour: the Mission-scoped stream closes on a log fault; the unscoped stream drops only the faulty Mission and keeps the connection.
  • Q-3 Python's resolvers consult git for some verdicts (probe_coord_state DELETED, is_registered_coord_worktree, a remote probe in _coord_branch_exists); the service reads no git state and starts no process. Interim behaviour (FR-006): directory existence decides; the curated goldens contain no case in which Python's answer depends on a git verdict, and the manifest records the read-directory class per fixture. Blocked on #5631 for the degrade policy, and on the plan to classify any non-curated case where the two could differ.
  • Q-4 The state of the baseline red set (#3284 is closed, the operator refers to a known-red baseline on main): the plan must measure it on the day.
  • Q-5 Exact status code for a foreign Host or Origin (403 or 421) ; a plan decision, constrained to "refused before any read", one constant for every refusal the filter makes (the container-rejected Host values of the closed list in AC-9b keep the container's 400, operator decision E-1). The accepted Host and Origin forms are not open: they are fixed in FR-009, and v1 has no allowed-origin configuration. Whether a configuration should be added later is a follow-up for the maintainer, not a v1 requirement.
  • Q-6 (RESOLVED, operator 2026-10-10) Should a transient torn final line (a writer caught mid-append) be tolerated instead of refused? Live Python answers both ways for the same bytes (poll_once tolerates, materialize_snapshot refuses). The question covers the unterminated-but-valid-JSON tail too (row L4b), where Python's own overview mixes prefixes. Behaviour (FR-004 rows L4 and L4b): refuse both (500 Problem, ledger_torn_tail): the unparseable tail because that is what the overview oracle does and the contract says an unparseable log fails the read, the parseable one because only a refusal keeps the counts and streamCursor on one prefix; AC-4d proves no wrong overview is ever served. Decision (operator, 2026-10-10): RESOLVED. Refuse a torn tail (500 ledger_torn_tail), as specified in rows L4 and L4b. A tolerant reading would be a change in Python's materialize_snapshot first.
  • Q-7 (RESOLVED, operator 2026-10-10) The reduction FR-005 ports sorts by (at, event_id) at the locked spec-kitty-events 10.4.0, which is wall-clock, while the ADR and CLAUDE.md wording calls the reducer "Lamport". Decision (operator, 2026-10-10): port what Python runs. Java sorts by (at, event_id) exactly like materialize_snapshot (spec_kitty_events 10.4.0 diary.reduce_parsed), pinned by AC-5a, AC-5d and the goldens. A move to causal ordering is a Python-side change first (#4941); the Java port follows then. The ADR and CLAUDE.md "Lamport" wording is stale.