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 withcontracts/. - 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
MissionStatusQueryoffers the project header, mission overviews and changes since a cursor. The outbound portMissionEventSourcereads the ledger and event logs. Adapters areadapter.in.http(REST and SSE) andadapter.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--jsonverbs 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
/projectand/missionsfrom the split contract root at build time (openapi-generator 7.25.0, the same pin ascontracts/). 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 withdistributionSha256Sum, the Gradle wrapper-validation action, a strictgradle/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 likematerialize_snapshot(spec_kitty_events10.4.0diary.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/: theProject.lastActivityAtwording error (it wrongly namesstatus.jsonas 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: writeon that job only, tag-aware concurrency, and a tag pattern disjoint fromv..andcontract--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-statusOpenAPI 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
wpTotal0 andlifecycleStatusdraft(MissionHead.yaml,lifecycleStatusdescription, rule 2: "including an unreadable event log"). The decided behaviour is that contract rule (draft, ordiscardedwhendiscardedAtis 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: forGET /api/v1/projectthe contract itself fails the whole read with a 500 Problem (Project.yaml,lastActivityAtdescription; FR-004 table, row L3a); forGET /api/v1/missionsthe 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(throughread_events_raw) raisesStoreErroron an unterminated, unparseable last line and counts an unterminated line that is valid JSON;poll_onceleaves 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,streamCursorwithout 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 yieldslog-truncated(FR-003). - Malformed event (invalid JSON, non-object line, missing required key, unknown lane, unknown
kind): verified against live Python,read_events_rawandread_event_streamraiseStoreError(parse_diaryraisesDiaryErrorfor an unknownkind), so the whole read is refused, not skipped. Invalid UTF-8 is the exception to theStoreErrorrule: neither reader catches the decoding error, so Python raisesUnicodeDecodeError; 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 byread_events_rawbut raises inpoll_once(a terminated blank chunk is passed tojson.loads); the golden set records both answers (FR-008). Rows that carry a top-levelevent_typeor aretrospective.*name are skipped in place byparse_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:healthreports the contract value for drift andschemaVersionis the recorded integer, never a defaulted one (FR-001). - Absent or unusable
meta.json, unlistable directory: a directory underkitty-specs/without ameta.jsonis not a Mission (Python'senumerate_missionslists only directories holding one): it is not listed, not counted and writes no diagnostic. Ameta.jsonthat 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'sload_metaconverts every one of those into one path-namedValueError, and the service answers one 500 Problem. Akitty-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
missionCountequal 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 issingle_branchorlanesmust NOT be read (FR-006, the #2062 close). - Symbolic link or escaping path where a ledger,
meta.jsonor Mission directory is expected: refused, never read, never followed outside the project root (FR-010, AC-10d). A refused ledger is row L2; a refusedmeta.jsonor 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;
streamCursordiffers because the cursor is a position in the physical bytes (verified:poll_oncecounts the\r). The CRLF fixture has its own golden (FR-008). - Two Missions with the same
createdAt: ordered bymissionIdascending. - 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,lastEventIdandstreamCursorall 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
| ID | Title | User Story | Priority | Status | Delivery | No-op passable? |
|---|---|---|---|---|---|---|
| FR-001 | Project header route | As a dashboard, I want GET /api/v1/project so that I can head the Mission list. | High | Open | [build] | no, paired with the missing-file fixture |
| FR-002 | Mission overview route | As a dashboard, I want paged GET /api/v1/missions so that I can list every Mission without rescanning. | High | Open | [build] | no, paired with the golden of a populated Mission |
| FR-003 | Change stream route | As a client, I want GET /api/v1/events (SSE) so that I refetch only on change. | High | Open | [build] | no, paired with an append-while-open fixture |
| FR-004 | Fail-loud reads | As an operator, I want every unreadable, truncated, missing or malformed input reported so that a wrong overview is never served as healthy. | High | Open | [build] | no, each failure has its own positive control |
| FR-005 | Status 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. | High | Open | [build] | no, interleaved fixtures differ if tie-break is wrong |
| FR-006 | Coordination-topology path resolution | As a user of a coordination-topology Mission, I want its status read from the coordination worktree so that I see live state. | High | Open | [build] | no, the coord fixture has a stale primary copy |
| FR-007 | Cursor-keyed projection cache | As a user with many clients, I want repeated reads to be cheap so that many clients never cause many rescans. | Medium | Open | [build] | no, cache-hit and cache-invalidation are both asserted |
| FR-008 | Conformance gate | As a maintainer, I want Java and Python overviews compared as a failing gate so that drift cannot ship. | High | Open | [build] | no, a perturbation test proves the gate can go red |
| FR-009 | Loopback, Host and Origin guard | As a user, I want the service unreachable except from my machine's own origin so that the #4767 to #4769 class cannot recur. | High | Open | [build] | no, each refusal has an accepted-request control |
| FR-010 | Read allowlist | As a reviewer, I want proof that the service reads only an explicit allowlist of files so that "ledger and events only" is checkable. | High | Open | [build] | no, a deliberate extra read fails the test |
| FR-011 | Contract conformance of responses | As a UI developer, I want every real response valid against the contract so that generated clients work. | High | Open | [build] | no, a deliberately invalid response fails the test |
| FR-012 | Hexagonal architecture enforced | As 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. | High | Open | [build] | no, a violating import fails the test |
| FR-013 | Conformance suite runs on the inbound port | As a maintainer, I want the port-level suite written against MissionStatusQuery so that it runs unchanged against another implementation behind the port. | Medium | Open | [build] | no, a second in-test MissionEventSource runs the same port-level subset |
| FR-014 | Standalone Gradle build and supply chain | As a security reviewer, I want pinned, hash-verified build inputs so that the public repo's pin doctrine holds. | High | Open | [build] | no, tamper test fails the build |
| FR-015 | Path-filtered CI and native release matrix | As a maintainer, I want the service built and smoke-tested natively on four targets so that missing reflection hints are caught. | High | Open | [build] | no, a path-filter test and a native smoke test |
| FR-016 | Read-only and drain-independent | As a user mid-flight on a Mission, I want the service to never write or lock so that it cannot disturb running work. | High | Open | [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.yamlyieldsspecKittyVersion: null,schemaVersion: nulland ahealththat is nothealthy, exactly as the contract defines;currentBranchis null, per the contract, for a detached, missing, unreadable or unparseableHEAD, 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) recordssupportedSchemaMinandsupportedSchemaMax, the Python golden test asserts they equalMIN_SUPPORTED_SCHEMAandMAX_SUPPORTED_SCHEMAinsrc/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
Projectandhealthis healthy. Fails if any field differs from the golden project header. - AC-1b: with
.kittify/metadata.yamldeleted, the body hasschemaVersion: nullandhealthother than healthy. Fails if health is healthy or the status is not 200. - AC-1c:
missionCountequals the count of/missionsitems 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.yamlabsent, unknown schema version, detachedHEAD, a branch name failing the pattern, a branch name over 255 characters, a host-path-shaped branch name, an unborn branch, a.gitpointer file with a relativegitdir:) equals its golden. Fails on any field difference. - AC-1f (schema range, coupled): the manifest's
supportedSchemaMinandsupportedSchemaMaxequal the live Python constants (Python golden test) and the JavaSupportedSchemaRange(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-provisionalmembers 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 codeinvalid_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
pageCursorvalues returns 400invalid_page_cursor: the empty string;abc(not thev1.form);v2.plus a valid body;v1.plus text that is not base64;v1.plus valid base64 of bytes with no LF; a well-formedv1.cursor naming amissionIdthat is not listed; a well-formedv1.cursor naming a listedmissionIdwith a differentcreatedAt; a 600-character value. A control cursor taken from a realnextPageCursorreturns 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
typeis 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 unknownkindis dropped by the stream while the same bytes make/missionsanswer 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 unlistablekitty-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-listskitty-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 ameta.json(a directory without one is not a Mission, as inenumerate_missions); its ledger is read from offset 0 whenever the ledger exists, so the creation order (meta.jsonfirst 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
\nis left unconsumed, aspoll_onceleaves 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'sid: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-transitionevent withid: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
typeis outside the seven lifecycle types (for example a decision-point type), and (iii) a well-formed object with an unknownkind: 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_unparseableand the Mission'smid8is 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 oneledger_unparseablediagnostic 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-truncatedevent without anid:line arrives; reconnect with the old id returns 409out_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 409content_mismatch. Fails if no event, anid: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
MissionStatusQuerychanges-since-a-cursor call returns the rows below, each with its cursor; stream leg: the same rows arrive as SSE events withid:equal to that cursor): the contract defines a cursor as the positionspec-kitty events tailresumes from, so a valid cursor delivers the forwarded rows after it. With rows 1..N in the log, connect withstreamCursorequal to the cursor after row k (k < N): exactly the forwarded rows k+1..N arrive first, in log order, each withid: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=C1andLast-Event-ID: C2with 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-transitionevent arrives, withid:equal to the cursor after the full row (scoped), or noid:(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.jsonfirst 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 andmeta.jsonsecond: exactly one event arrives within the bound; (iii) in a third directory write a ledger with a row but never ameta.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 anid:appears or history is replayed. - AC-3f: with the heartbeat interval shortened by test configuration, a comment line
: heartbeatarrives 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
missionIdanswers 404 with no diagnostic; a Mission whosemeta.jsonis a symbolic link (row L9) answers 404 and writespath_refused; a Mission whose ledger is unreadable (fault injection) answers 500 Problem withledger_unreadableand no event; a Mission with a malformedmeta.jsonanswers 500 Problem withmeta_unparseable; a Mission with no ledger (row L1) opens normally (control). Unscoped, the same faults in one of two Missions: the connection opens astext/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; withkitty-specs/unlistable the connect answers 500 Problemmission_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_vanisheddiagnostic. Unscoped, removing one Mission's directory keeps the connection, writes onemission_vanisheddiagnostic, and an append to the other Mission is delivered; makingkitty-specs/unlistable (fault injection) keeps the connection and every watched Mission, writes onemission_dir_unreadablediagnostic, 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.
| Row | Condition | Live Python reference (verified) | Service observable | Status |
|---|---|---|---|---|
| L1 | Ledger file absent (a fresh Mission) | read_events_raw returns an empty list | 200; 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 |
| L2 | Ledger unreadable: permission denied, I/O error, symbolic link refused (FR-010) | an OSError propagates; there is no Python golden for it | 200 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 S3 | Decided (operator, Q-1) |
| L3a | Ledger 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/project | read_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 number | GET /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) |
| L3b | The same condition observed through GET /api/v1/missions | as L3a | the 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 policy | Decided (operator, Q-1); alternative considered: omit that Mission with a diagnostic, as L9 |
| L4 | Torn final line: an unterminated last line that does not parse | materialize_snapshot raises StoreError (invalid JSON on that line); poll_once leaves the tail unconsumed and says nothing | same 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 it | Mirrors the snapshot path; refusal decided (operator, Q-6) |
| L4b | Unterminated 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 refusal | Golden-pinned divergence; refusal decided (operator, Q-6) |
| L5 | Log shrinks or changes under an open stream | the tail reader's truncation signal | a log-truncated event without id: (FR-003) | Settled by the contract |
| L6 | Mission directory vanishes between enumeration and read; or an unknown missionId on GET /api/v1/events | no 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 S5 | Decided (operator, Q-1 and Q-2) for /missions and S5; settled for the /events 404 |
| L7 | meta.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 class | 500 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 S2 | Mirrors 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) |
| L8 | Unknown schema version in .kittify/metadata.yaml | n/a (project header) | 200; schemaVersion is the recorded integer, health is the contract's drift value (FR-001) | Settled by the contract |
| L9 | A 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 root | no 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 L2 | Decided (operator, Q-1); Java only |
| L10 | A 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) |
| L11 | OS-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 golden | 500 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 S6 | Decided (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).
| Row | Condition | Mission-scoped stream | Unscoped stream |
|---|---|---|---|
| S1 | Unknown 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_refused | not 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) |
| S2 | At 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 event | that 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 |
| S3 | While open, the ledger becomes unreadable (L2) | the connection closes, no further heartbeat or event, one ledger_unreadable diagnostic | that Mission is dropped, one ledger_unreadable diagnostic; connection, heartbeat and other Missions kept |
| S4 | A 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 open | the connection closes after delivering the rows before it, one ledger_unparseable diagnostic | that Mission is dropped, one ledger_unparseable diagnostic; connection kept |
| S5 | While open, the Mission directory vanishes (L6) | the connection closes, one mission_vanished diagnostic | that Mission is dropped, one mission_vanished diagnostic; connection kept |
| S6 | While 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
MissionStatusUnavailablecarrying the row's diagnostic code (for L3a and L3b theproject()and themissions()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'sdraftitem. L1 and L2 are each also run withdiscardedAtset inmeta.jsonand requirediscarded, notdraft; fails ondraft. - 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
StoreErrorand the line number. The unterminated-but-parseable one records live Python's actual outcome (the mixed-prefix overview) taggedknownDivergence, 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
mid8field and no absolute path (scanned for the fixture root,HOMEand every canary of FR-009 AC-9g). Themid8field is the first 8 characters ofmission_idfrom the Mission'smeta.jsonwhen that file was read; where nometa.jsonwas read (rows L7, L9, L11, and any stream row on a Mission or listing whosemeta.jsonwas not read) it is the fixed tokennone, and the test asserts that token there and the real value everywhere else. The one exception is the 404 for a plainly unknownmissionIdonGET /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 themid8field, amid8that 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/missionscalls. In both parts every 200 item must match some prefix of complete lines of the final log: itseventCount,statusLaneCounts,lastEventIdandstreamCursorare 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.pyholds only the wrapper (materialize_snapshot, which callsreduce_shared_state, imported at the top of the module asfrom spec_kitty_events.status import reduce as reduce_shared_state) and the cancellation and implementer projections. The reduction itself is in the external packagespec-kitty-events, locked at version 10.4.0 inuv.lock(pyproject.tomlallows>=10.4.0,<11).spec_kitty_events.status.reduceis a re-export ofspec_kitty_events.diary.reduce, which isparse_diaryfollowed byreduce_parsed. At the locked versionreduce_parsedapplies: (1) deduplicate transitions byevent_id, keeping the first occurrence in input order; (2) sort by the key(at, event_id)ascending, whereatandevent_idare compared as TEXT, the way a Pythonstrcompares, 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_diaryskips in place a row with a top-levelevent_type, aretrospective.*event_nameand the three retrospective lifecycletypevalues, and raisesDiaryErrorfor a non-object row, a malformed lane or annotation row, or an unknownkind. - Two comparisons of
at, kept apart. The reducer's sort comparesatas text (above). The overview'slastActivityAtis the latestlast_transition_atcompared as an INSTANT (_latest_instantintests/contract/_mission_status_payloads.pytakes 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'smaxdoes. A port that sorts by parsed instant, or compareslastActivityAtas text, differs from Python for rows with a non-UTC offset or a different fractional-second length, so theinterleavedclass 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
atthenevent_id; the in-package functionreduce_status_events, which sorts by(lamport_clock, timestamp, event_id), is a different function thatmaterialize_snapshotdoes not call. This mission ports whatmaterialize_snapshotruns, 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-eventsversion 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
atand differentevent_id, rows whose file order differs from(at, event_id)order, a duplicatedevent_id, and rows whose text order ofatdiffers from their instant order (one with a+02:00offset against aZrow, one pair differing only in the number of fractional-second digits), so that a parsed-instant sort, and a text comparison inlastActivityAt, 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
specKittyEventsVersionequals the installedspec-kitty-eventsversion, 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_stateruns onegit rev-parseof the declared coordination branch to tellDELETEDfromUNMATERIALIZED,MissionStatusclassifies a read directory as coordination throughis_registered_coord_worktree(thegit worktreeregistry), and_coord_branch_existscan probe a remote. FR-016 and AC-10c forbid starting a process and FR-010 does not allow.git/worktrees,packed-refsorrefs/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 (ownorcoord) 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 /projecttext 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.jsonpresent, corrupt or absent, expected read-directory classownorcoord), 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 throughresolve_read_diroftests/contract/_mission_status_payloads.pyand 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_branchMission and alanesMission 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.jsonyields 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.jsonhas notopologyfield is classified as live Python classifies it, once with a validlanes.json, once with a corruptlanes.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
/missionscalls 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 intests/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 andmaterialize_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_overviewin 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 bytests/contract/fixtures/mission_status_expected.jsonthroughexpected_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-onlyandlog-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 recordsspecKittyEventsVersion,supportedSchemaMinandsupportedSchemaMax(AC-1f), the id list ofconformance/resolver-cases.json(FR-006) and, per fixture, the read-directory class):
| Class tag | Fixtures | Distinguishing property asserted | Parity |
|---|---|---|---|
interleaved | two Missions | non-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 |
coordination | one coord Mission | a current ledger under .worktrees/...-coord/ and a primary ledger whose lane counts differ | Python golden |
coord-husk | a single_branch and a lanes Mission | a husk ledger beside the Mission whose counts differ from the primary ledger | Python golden |
absent-topology | Missions with no topology field, with valid and with corrupt lanes.json | classification differs between the two lanes.json cases only where Python's does | Python golden |
lifecycle-only | one Mission | only the seven lifecycle types; wpTotal 0; a lifecycle-derived status | Python golden |
no-ledger | two Missions | meta.json only, no ledger file; one of them with discardedAt set (lifecycleStatus discarded, not draft) | Python golden |
log-truncated | one Mission, plus a stream scenario | a shrunken log and a same-length last-line rewrite | Python golden |
malformed-rows | one 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 line | each 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-line | two 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-listing | a CRLF ledger with a LF twin; Missions whose directory names sort differently by byte order and by case folding | CRLF counts equal LF counts, streamCursor differs; page order equals the contract order | Python golden |
project-header | healthy; .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 document | Python golden |
projection | Missions whose free-text and strict fields exercise the projector | a 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-canaries | one project | canary 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) |
symlink | a symlinked ledger, a symlinked meta.json, a Mission directory symlink escaping the root | each 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
MissionOverviewandProject, 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 ignoreslastActivityAtor 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 byatonly, noevent_idtie-break) and requires the conformance suite to fail on theinterleavedclass. 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 exactlymanifest.jsonandresolver-cases.jsonas 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
codeinstead 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 oneworkflow_dispatchrun of the workflow on the PR branch, recorded in the PR (FR-015 runs the nightly job onworkflow_dispatchfor this reason; aschedulerun 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_MODULESoftests/architectural/test_ci_corpus_trigger_completeness.pyandtest_corpus_marked_modules_match_the_curated_registrypasses. 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 thetests (contract tools)job and nosrc-backed code shard. Verified by a test besidetests/ci/test_contracts_routing.pythat loads the router withscripts.ci.gate_selection.load_routerand callsselect_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 duplicateHostheader; an HTTP/1.1 request without aHostheader; an HTTP/1.1 request whose absolute-form request target names a foreign authority while theHostheader 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 orHOMEin the body (the AC-9g scan, which includes the container-generated 400 body). Each row also carries a literalreachesFilterflag, 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 nottext/event-streamand 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>andlocalhost:<port>with no Origin, and withOrigin: http://127.0.0.1:<port>andhttp://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-canariesfixture plants, in.kittify/config.yaml,.kittify/metadata.yaml,meta.jsonand 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 andHOME. Fails if any canary, root path orHOMEvalue appears. The free-text fields that the Python helper projects through its redaction classes (path, email;Projector.humanintests/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, asProjector.strictdoes (aProjectionError, answered 500 like row L3a or L3b by route, with one diagnostic line carrying the codeprojection_refusedand themid8and no value); the goldens of theprojectionclass (FR-008) record the redacted output, and the patterns have one source:contracts/tools/leak_patterns.py, written by the generator tosrc/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/missionsand of the SSE handler. Fails if either records false, on the JVM test run and in thenativeTestrun.
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 oftasks/WP01.mdis added, and when a deliberate read of the coordinationmeta.jsonis 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
ProcessBuilderandRuntime.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). WrappingProcessBuilderor aSecurityManageris NOT a mechanism (the class is final and theSecurityManageris 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: aProcessBuildercall 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
symlinkclass fixtures (a symlinked ledger, a symlinkedmeta.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 thepath_refuseddiagnostic; 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.fileandjava.iofile 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.yamlwith an accepted Host answers 200 with the committedcontracts/mission-statusdocument, 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-docsand/swagger-uianswer 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 ofProject), 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 ofProject,MissionOverview,MissionOverviewPageor 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
MissionEventSourceand against a second in-test in-memoryMissionEventSource, each behind the one application service implementingMissionStatusQuery, 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 importadapter.out.filesystemand returns only theMissionStatusQueryandMissionEventSourceinterfaces. 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:
| Control | In contracts/ today | In this mission | Failing check (new ones named here) |
|---|---|---|---|
| Verification metadata | contracts/gradle/verification-metadata.xml with verify-metadata true and verify-signatures false | INHERITED posture: the service has its own file with the same two settings and a checksum for every artifact | AC-14a, AC-14c |
| Actions pinned by SHA | verify_pins.py checks the uses: lines of contracts*.yml | INHERITED practice for the new workflow (FR-015) | AC-15d |
| Gradle distribution pinned by hash | Gradle 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 distributionSha256Sum | AC-14f |
| Wrapper validation | none (there is no wrapper) | INTRODUCED: the Gradle project's wrapper-validation action, pinned by SHA | AC-14b |
| Version catalog | none (contracts/build.gradle declares versions inline) | INTRODUCED: gradle/libs.versions.toml, the only place a version appears | AC-14g, NFR-007 |
| Tamper check | contracts/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 reuse | AC-14a |
| Generator plugin pinned | contracts/tools/pins.json; gradle_pin_check.py compares contracts/build.gradle and contracts/gradle/ only | the 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.xmlis altered (a tamper test modelled oncontracts/tools/tamper_check.pybut written for the service tree). Fails if the tampered build passes. - AC-14b: CI fails if
gradle-wrapper.jardoes 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), andtests/contract/test_gradle_pin_check.pypasses. Fails if anycontracts/file or pin is edited. - AC-14e: a test beside
tests/ci/(for exampletest_mission_status_api_pins.py;gradle_pin_check.pyis not extended because it parses Groovybuild.gradlesyntax) reads the generator plugin version from the service's version catalog and build file and theopenapi-generator-gradle-pluginpin fromcontracts/tools/pins.json. Fails when they differ, naming both values. - AC-14f: a wrapper-properties test requires
distributionSha256Sumto be present inservices/mission-status-api/gradle/wrapper/gradle-wrapper.propertiesand to be 64 lowercase hex characters, anddistributionUrlto 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.ktsof the service) fails on any literal version string outsidegradle/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(branchmainwith the path filter and, in the samepushblock, the tag patternmission-status-api-v..*, to which GitHub applies no path filter) andpull_request(with the path filter), plusscheduleandworkflow_dispatch, which carry no path filter (GitHub appliespathsper event and a scheduled run has none). Every job'sif:opens with the canonical fork guard oftests/ci/test_fork_guard.py, a parenthesised||ofgithub.repository == 'spec-kitty/spec-kitty',github.event_name == 'pull_request'andgithub.event_name == 'workflow_dispatch'atoms, as first top-level conjunct. That test requires it of root jobs and of jobs whoseif:usesalways(), so it covers the native legs, the aggregate job and the nightly job. After the guard: (a) thejvmjob (./gradlew checkonubuntu-24.04, separate from the Linux x64 native leg) and each native leg add&& github.event_name != 'schedule'; (b) the aggregate jobneedsthejvmjob and the four legs, carriesalways()and the same added conjunct, so a scheduled run skips thejvmjob, 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 thejvmjob and every leg aresuccess; (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 jobneedsthe 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 thejvmjob, the legs and the aggregate run (their conjunct is!= 'schedule') and the nightly job is skipped. Aworkflow_dispatchrun 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
permissionsstaycontents: read; only the release job addscontents: 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 amainpush 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) }}withcancel-in-progress: ${{ github.event_name == 'pull_request' }}. The tag pattern is disjoint from the CLI release tagsv..(release.yml) and fromcontract--v..(contracts-release.yml): a tag name matches exactly one of the three patterns. The job attaches the four binaries and their.sha256files to a GitHub release. The release artefact is not part of the Python wheel (pyprojectpackages aresrc/) and nothing is published to PyPI. - AC-15a: a test (alongside
tests/ci/) asserts, from the parsed workflow, (i) thepushandpull_requestpath filters contain exactly the FR-015 paths, (ii)scheduleandworkflow_dispatchare triggers and carry no path filter, (iii) each job'sif:has the form of the jobs bullet (guard first, then its event conjunct), thejvmjob and the release job included, (iv) the tag patternmission-status-api-v..is in the samepushblock as the branch and path filters, (v) workflow-levelpermissionsarecontents: readandcontents: writeappears on the release job only, (vi) the concurrency group is the tag-aware expression of the release bullet withcancel-in-progresstrue only for pull requests, and (vii) the tag pattern is disjoint fromv..andcontract--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 toschedule, a job's condition differs, the release job holdscontents: writebeside 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-listingandsecret-canariesfixtures), requires byte equality of every response body with its golden; sends one request per refusal code (invalid_page_cursor; the five stream refusal codesnegative,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 Problemcodethe contract fixes; and runs an append-while-open SSE round trip that receives onestatus-transitionevent with the expectedid: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 ofnullmembers, 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
needsthejvmjob and all four legs, runs withif: always()(full condition: the jobs bullet above), and exits non-zero unless thejvmjob's and every leg's result issuccess. Fails if thejvmjob 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.pyandtests/ci/test_fleet_main.pyenumerate everypull_requestworkflow throughscripts/ci/fleet_verdict.py's closedPR_WORKFLOWSset, so the new workflow is registered there and inci-fleet-verdict.yml'sworkflow_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 matchescontracts*.yml, andtests/contract/test_gradle_pin_check.pyandtests/contract/test_verify_pins.pystill 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
.lockand 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: truein the fixture's.kittify/config.yamland[hosted] drain = truein the fixture's personalconfig.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 personalconfig.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
| ID | Title | Requirement | Category | Priority | Status |
|---|---|---|---|---|---|
| NFR-001 | Overview latency | With 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. | Performance | High | Open |
| NFR-002 | Native start-up | The 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. | Performance | Medium | Open |
| NFR-003 | Loopback and secrets | No 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). | Security | High | Open |
| NFR-004 | Memory and concurrency | 100 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. | Reliability | Medium | Open |
| NFR-005 | Determinism | The 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). | Reliability | High | Open |
| NFR-006 | Java test coverage | A 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. | Quality | Medium | Open |
| NFR-007 | Dependency hygiene | Every 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). | Security | High | Open |
| NFR-008 | Terminology | Prose 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. | Maintainability | Medium | Open |
| NFR-009 | Windows file names | All new file names avoid characters rejected by the Windows-filename gate (test_no_invalid_windows_filenames.py): `: " ? * | { } $`. | Portability | Medium |
Constraints
| ID | Title | Constraint | Category | Priority | Status |
|---|---|---|---|---|---|
| C-001 | Stack | Java 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). | Technical | High | Active |
| C-002 | Build tool | Gradle with Kotlin DSL (not Maven, not Groovy), committed wrapper, version catalog; standalone build in services/mission-status-api/, not a composite with contracts/. | Technical | High | Active |
| C-003 | Architecture | DDD 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. | Technical | High | Active |
| C-004 | Scope | Only /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. | Business | High | Active |
| C-005 | Security posture | Loopback-only bind, Host and Origin checks, no secrets in responses; read-only and drain-independent. | Security | High | Active |
| C-006 | Conformance | Goldens plus nightly (decision D-Q1); the gate fails on any difference. | Technical | High | Active |
| C-007 | Contract consumption | Generate 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. | Technical | High | Active |
| C-008 | Supply chain | Wrapper 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. | Technical | High | Active |
| C-009 | Not in the wheel | Nothing under services/ enters the Python wheel or sdist; pyproject.toml packages stay src/*. | Technical | High | Active |
| C-010 | Public repo hygiene | No private or customer detail and no absolute home-directory paths in any committed file, fixture, golden or log message. | Business | High | Active |
| C-011 | No charter edit | The charter is not edited by this mission (see Known drift). | Business | Medium | Active |
| C-012 | Contract is the vocabulary | The 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. | Technical | High | Active |
| C-013 | Installed artefacts | No step edits installed artefacts outside the checkout; spec-kitty is run from the checkout's own virtual environment. | Process | Medium | Active |
Key Entities
- Mission: identity (
missionIdULID,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
StatusLanevalues; counts per lane arestatusLaneCounts. - Status event: one row of
status.events.jsonl, ordered by the reducer byatthenevent_id(Q-7); the Java status event carries no logical-clock field because the ported reduction never reads one. - Status reducer (the reduction
materialize_snapshotruns, 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 tospec_kitty_events.status.reduceasmaterialize_snapshotcalls 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 toTailCursor); 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
MissionStatusQueryand outbound portMissionEventSource: 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 nosrc-backed Python code shard; a change only to the conformance goldens additionally selects thetests (contract tools)job and nothing else Python (AC-8e); asrc/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 ofcontracts.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.ymlitself is not edited. The routing test of AC-8e is added besidetests/ci/test_contracts_routing.py.tests/architectural/test_ci_corpus_trigger_completeness.py: the hand-maintained_CORPUS_MARKED_MODULESregistry must gain the path of the new corpus-marked golden-test module (and of the generator, if it is atest_*.pymodule carrying the marker);test_corpus_marked_modules_match_the_curated_registrygoes 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*.ymlin.github/workflows/(19 on this checkout) againstreusable_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 aschedulejob 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 everyalways()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_WORKFLOWSis read from the convergence map, not the directory),tests/architectural/test_workflow_coherence.pyandtests/architectural/test_no_duplicate_suite_execution.py(viadiscover_pytest_workflowsandenumerate_workflows: if any job of the new workflow runspytest, the workflow joinsWORKFLOW_FILESintests/architectural/_gate_coverage.pyand may need an entry inNON_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 -rlnovertests/ci,tests/architectural,tests/releaseandtests/contractfor.github/workflows,_CORPUS_MARKED_MODULESandROUTER_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_resolverby stored topology (FR-006) but cannot reproduce its git verdicts; the degrade policy is #5631 (open). Mitigation: FR-006 goldens generated from live Python, thecoord-huskandabsent-topologyclasses, 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 nosetup-java25 exist yet;contracts.ymlandcontracts-release.ymluse temurin 21 for the contract tools.contracts/mission-status/openapi.yamlis mergeable-stable at1.0.0-SNAPSHOT(issues #5558, #5625, #5776 closed); the service builds against it at the same commit.ci-router.yml'sunmatchedfail-closed catch-all fires only forsrc/, so aservices/-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-provisionalin 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/contracthelpers andspecify_cli.status.reducerunder 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-stateandlist-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-provisionalmembers 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.lastActivityAtdescription or any other file undercontracts/(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(lifecycleStatusdescription, rule 2) reads "wpTotal is 0 (including an unreadable event log), so draft", andProject.yaml(lastActivityAtdescription) 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/missionspage: the 500 rule is stated forProject.lastActivityAtonly, 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 anddraftplus aledger_unreadablediagnostic; a vanished Mission directory is omitted with amission_vanisheddiagnostic; 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'smeta.jsonor directory (L9) omits that Mission with apath_refuseddiagnostic; an unusablemeta.json(L7) and an OS-level listing failure ofkitty-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 tocontracts/. TheProject.lastActivityAtwording error (it namesstatus.jsonas an input, which the service and the Python helperbuild_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_stateDELETED,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_oncetolerates,materialize_snapshotrefuses). 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 andstreamCursoron one prefix; AC-4d proves no wrong overview is ever served. Decision (operator, 2026-10-10): RESOLVED. Refuse a torn tail (500ledger_torn_tail), as specified in rows L4 and L4b. A tolerant reading would be a change in Python'smaterialize_snapshotfirst. - Q-7 (RESOLVED, operator 2026-10-10) The reduction FR-005 ports sorts by
(at, event_id)at the lockedspec-kitty-events10.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 likematerialize_snapshot(spec_kitty_events10.4.0diary.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.