Implementation Plan: Mission Status Read API, overview (Java service)
Branch: issue-5529-mission-status-read-api-overview | Date: 2026-10-10 | Spec: spec.md Input: Mission specification kitty-specs/mission-status-read-api-overview-01M4EMSA/spec.md (binding, including the operator decisions of 2026-10-08 and 2026-10-10).
Branch contract. Current branch at plan start: issue-5529-mission-status-read-api-overview. Planning/base branch: the same. Final merge target of the mission PR: main (via a pull request from this issue branch; the operator merges). branch_matches_target is true.
Companion artifacts: research.md (decisions, version verification, supply-chain checklist), data-model.md (domain types, ports, stream and diagnostics model), contracts/service-contract-notes.md (how the in-repo contract is consumed, refusal table, conformance file formats, Host/Origin rule), quickstart.md (local commands), and the three tracer files traces/tooling-friction.md, traces/approach.md, traces/design-decisions.md (procedure mission-tracer-files). Section 14 lists the spec amendments and escalations this plan cannot decide.
Numbering note: in this Mission's work-package numbering the spike discussed here is WP02 and the scaffold is WP03 (the plan and this file say "WP01" for the whole of phase 1 in places); see tasks-overview.md section 1, "Numbering note".
Summary
A new top-level, standalone Gradle (Kotlin DSL) project services/mission-status-api/ builds a Java 25, Spring Boot 4.1 MVC (virtual threads) service, shipped as a GraalVM native binary on four targets. Domain and application code are framework-free and enforced by ArchUnit; the inbound port MissionStatusQuery and the outbound port MissionEventSource are the only seams. The service answers GET /api/v1/project, GET /api/v1/missions and the SSE GET /api/v1/events from status.events.jsonl through a Java port of the reduction materialize_snapshot runs (sorted by (at, event_id), Q-7). The OpenAPI document is contract-first (operator decision E-11): the service serves the committed contracts/mission-status document as it stands, bundled at build time, at the fixed path /api/v1/openapi.yaml; there is no springdoc and no runtime document generation. Python-side the mission adds a golden generator and a golden-honesty test under tests/contract/, and the CI helper scripts of section 1. One new workflow file (the last free slot) carries the path-filtered JVM and native build, the nightly full-corpus comparison, which fails on any Python/Java difference with no exemption (E-10), and a tag-triggered release job (operator decision E-4, section 12) that the maintainer exercises by cutting the first tag. No src/, contracts/, kernel, doctrine or CLI file changes.
Technical Context
Language/Version: Java 25 (JDK 25) for the service; Python 3.11+ only for the golden generator/test (the checkout's own .venv) Primary Dependencies: Spring Boot 4.1.1 (MVC, virtual threads; its BOM pins Tomcat 11.0.24 and Jackson 3.1.5 as tools.jackson, with a Jackson 2.21.5 BOM beside it, read from the published spring-boot-dependencies-4.1.1.pom), no springdoc (operator decision E-11: the OpenAPI document is the committed contract, bundled at build time and served as-is; section 13), openapi-generator-gradle-plugin 7.25.0 (the contracts/tools/pins.json pin), Gradle 9.7.1 wrapper, GraalVM Native Build Tools via the Spring Boot plugin, ArchUnit 1.4.x, JaCoCo 0.8.15, networknt json-schema-validator 3.0.8 (OpenAPI 3.1 / JSON Schema 2020-12). Exact versions are fixed at WP01 in gradle/libs.versions.toml; verification status is in research.md R-4 and the Unverified list. Storage: none written. Reads files only: the FR-010 allowlist. Testing: JUnit 5 + ArchUnit + contract validation + golden suite on the JVM leg (check); on each native leg nativeTest runs ONLY the tagged native subset (section 11, "Native test partition") plus an HTTP smoke against the binary; Python pytest for the golden-honesty test and the CI pinning tests. Target Platform: Linux x64, Linux arm64, macOS arm64, Windows x64 native binaries; loopback only. Project Type: single standalone service (a new top-level tree beside src/, contracts/). Performance Goals: NFR-001 (warm p95 under 400 ms over 500 Missions; amended from 200 ms by CI-runner measurement), NFR-002 (first request within 2 s of start), NFR-004 (100 SSE connections). All provisional per ADR 2026-10-05-2, asserted as ratios to a same-run floor. Constraints: loopback-only, read-only, no child process, no secret/path in any response or log, byte-identical output on all four OSes (NFR-005), nothing in the Python wheel/sdist (C-009), workflow-file ceiling (one new file only). Scale/Scope: 588 Missions in this repository's corpus (tracked meta.json count on this checkout); three routes.
Charter Check
Charter read (.kittify/charter/charter.md, 1.5.0, 2026-10-06), then AGENTS.md/CLAUDE.md (identical files, 771 lines each) and CONTRIBUTING.md.
| Charter rule | Verdict |
|---|---|
| Single canonical authority; reconcile, do not duplicate | Pass. Reducer logic exists twice by ADR decision (Python reducer + Java port), made safe by the conformance gate (FR-008). Supported schema range has one authority per language, coupled by the manifest (AC-1f). The Python oracle is the existing test helper set, not a third oracle. |
| Architectural alignment / DDD + tiered rigour | Pass. Hexagonal layout, ArchUnit-enforced; JaCoCo threshold stricter on the domain (see section 5). |
| ATDD-first | Pass. Every WP starts with a committed failing acceptance test; test strategy lists the revert-fails test for each behaviour. |
| Terminology (Mission, not Feature) | Pass. New prose uses Mission / Work Package. tests/architectural/test_no_legacy_terminology.py scans src, tests, docs. |
| Standing order 2, campsite | Pass. One distinct behaviour-preserving first commit, with the Sonar survey and the declined surfaces recorded (section 9). |
| Standing order 3, tracer files | Pass. Three files seeded and committed with this plan at traces/{tooling-friction,approach,design-decisions}.md in the dated-entry format of procedure mission-tracer-files (section 10). |
| Standing order 1, adversarial squad | Not run by this author. The operator rule (squad after every plan phase) belongs to the orchestrator; findings dispositions are recorded in research.md section "Squad dispositions" (plan review round 1 is recorded there). |
| Pre-existing Failure Reporting Rule | Pass. Procedure in section 6. |
| Tracker Ticket Assignment Rule | Orchestrator concern (assign #5529 to the HiC); not a plan artifact. |
| Quality gates "mypy --strict", "pytest 90%" | Apply to Python only (see Known drift 1 in spec.md). Python files added: generator + tests; they carry type annotations. mypy is not run in CI (section 5). |
| Do not edit the charter | Honoured. Drift recorded by the spec (languages; dashboard scale; contract wording; "Lamport" wording); this plan adds one more observation: the charter Testing Requirements say "pytest 90%+" and CLAUDE.md lists Python 3.11+; neither names Java. Charter wins; no conflict found beyond that silence. |
| Public repo hygiene (C-010) | Pass by construction: no /home/<user> paths in any artifact here (placeholders <repo>, <project root>). |
Post-design re-check: no new violation. One observation the charter owner may want: charter "Programme PR Workflow" says nothing on GitHub enforces review or required checks, which is consistent with what section 5 finds (required checks live in repository settings, unverifiable from the checkout).
Readiness facts (verified against this checkout)
| Fact | Evidence |
|---|---|
services/ does not exist; no Java/Kotlin file; no setup-java 25 | ls of repo root; grep setup-java .github/workflows finds only contracts.yml (lines 124 and the negative-tests job) and contracts-release.yml:86, all temurin '21' at SHA de7274f081f381c8f8158605e0321c36c376e2e6 (v6.0.1). |
contracts/ pins Gradle 8.14.5 and no wrapper | contracts/tools/pins.json (gradle 8.14.5, sha256 6f74b6...); contracts/settings.gradle header comment ("there is no committed wrapper jar"). Gradle runs JDK 25 only from 9.1.0 (Gradle compatibility page, fetched for 9.7.1). So the service needs its own, newer wrapper: 9.7.1 is chosen (released 2026-08-19 per the services.gradle.org feed, over seven weeks old; 9.8.1 is only days old; date and checksum are re-fetched at WP01, Unverified 18). |
| Workflow ceiling | tests/architectural/test_module_shard_registry.py::test_reusable_workflow_ceiling_respected counts *.yml in .github/workflows/ (19 now) against reusable_workflow_ceiling: 20 in .github/ci-module-registry.yml and caps the ceiling at GitHub's 20. Adding exactly ONE workflow file makes 20 and uses the last slot; nothing else in this mission, and no later mission, may add a workflow file without first raising an architectural limit that GitHub itself imposes (so in practice: any further workflow must be a job of an existing file). |
| Corpus reality check at budget | issue #5838 (open): "tests (corpus-blocking) ... outran the 10-minute job budget". The new workflow does not needs or call it (section 5). |
services/**-only diff selection | Computed with scripts.ci.gate_selection.select_gates / select_modules on this checkout: no filter group matches, unmatched_src is false (the catch-all only fires for src/**), selected jobs = the 13 always-on jobs only, no module shard. A golden-only diff (services/mission-status-api/src/test/resources/conformance/manifest.json) also selects nothing today, which is why the router edit of Reflexivity item 1 is required. |
make ci-parity | Its entry point (scripts/ci/local_gate_parity.py) diffs the working tree against the merge-base, so it cannot preview a not-yet-existing directory; the same authority (select_gates) was called directly with synthetic path sets. Results are in section 5. |
| #3284 | CLOSED 2026-09-08 (gh issue view). #5631 OPEN, #5532 OPEN, #4941 OPEN, #5838 OPEN. PRs #5902 and #5326 CLOSED unmerged (plan risk PR-2 re-verified on 2026-10-10). |
spec-kitty regen --check | Ran read-only on this checkout: "All 3 generated fixtures are fresh". |
| Packs advisory corpus lane | .github/workflows/packs.yml runs pytest -m "corpus and not windows_ci" with a hand-written list of 35 --deselect tests/contract/<module>.py entries (every tests/contract tool module that has a blocking home elsewhere); no test pins that list. A new corpus-marked tests/contract module that is not on it runs twice. |
test_no_invalid_windows_filenames.py | NOT on the architectural-fast roster (fast_gate.roster in .github/ci-module-registry.yml does not name it). It runs in the path-filtered architectural-heavy shards, so a bare services/ diff does not run it; this mission's PR does (workflow, tests/ and scripts/ci/ edits select that job). |
| Corpus size | 588 tracked kitty-specs/*/meta.json on this checkout (git ls-files); the first plan said "about 590". |
| Boot 4.1.1 BOM | The published spring-boot-dependencies-4.1.1.pom pins tomcat.version 11.0.24, jackson-bom.version 3.1.5 (Jackson 3, tools.jackson) and jackson-2-bom.version 2.21.5. |
| Tomcat Host parsing | Read from the public apache/tomcat 11.0.x source on 2026-10-10 (Http11Processor.prepareRequest, AbstractProcessor.parseHost, Host.parse, HttpParser.readHostDomainName), not from a run; the WP02 spike confirms against the 11.0.24 jar. Summary in section 8. |
| Sonar | scripts/ci/sonarcloud_branch_review.sh issues --file <path> reached SonarCloud on 2026-10-10 and reported total: 0 open issues for tests/ci/test_contracts_workflows.py and tests/architectural/test_ci_corpus_trigger_completeness.py. |
1. Seam: where the change lands
- New top-level tree
services/mission-status-api/(standalone Gradle build; not part ofcontracts/settings.gradle, not inpyproject.tomlpackages=src/*, not in the sdistincludelist; C-009 is held by a new packaging test, the AC-14/C-009 row of the test strategy in section 11). - Python additions (the only ones), all test-side or CI helpers:
tests/contract/_mission_status_conformance.py: the golden generator (importable helper +main()), reusing_mission_status_payloads.py(build_overview,resolve_read_dir,Projector,tree_fingerprint...) andspecify_cli.status.reducer.materialize_snapshot. The leading underscore keeps it out of pytest collection like its siblings. It also writesservices/mission-status-api/src/main/resources/leak-patterns.jsonfromcontracts/tools/leak_patterns.py(the single source of the path, email and strict/human patterns that the oracle'sProjectorapplies; section 13, Projection), and the manifest records that file by SHA-256.- Git provisioning of the oracle run (required, not optional).
build_overviewobtains every read directory throughMissionStatus.load(src/specify_cli/status/aggregate.py), which callsMissionStatus._is_coord_dirand sois_registered_coord_worktree(src/specify_cli/coordination/surface_resolver.py). That predicate runsgit worktree list --porcelainthroughread_worktree_registrywhenever the resolved read directory has a.worktreesancestor (classify_worktree_topology), and raisesWorktreeRegistryUnavailablewhen git cannot be read;resolve_read_dirintests/contract/_mission_status_payloads.pycatches onlyCoordinationBranchDeleted,CoordAuthorityUnavailableandMissionMetadataUnavailable, so that error would abort the run. Coordination resolution also runsgit rev-parsethroughprobe_coord_state. A committed static tree has no.git, so the generator and the golden-honesty test never run on the committed trees in place: each materialises every fixture into a temporary git repository at run time. Which fixture classes need git. Git is consulted only when the resolved read directory has a.worktreesancestor (classify_worktree_topology) and, throughprobe_coord_state, when a storedcoordtopology has an absent coordination root and a declared coordination branch. So a repository is provisioned for thecoordclass, thecoord-huskclass and any stored-coord topology with an absent coordination root and a declared branch; every other fixture (anown-class read directory with no.worktreesancestor, which includes all ofproject-header) is materialised WITHOUT a repository. The recipe for a fixture that needs one: a fresh temp directory per fixture (the repository root);git initand one commit of the fixture's primary tree; for a fixture whose manifestreadDirectoryClassiscoord, a coordination branch named bycoord_branch_name(src/specify_cli/lanes/branch_naming.py) carrying the fixture's coordination-side files, and a worktree registered withgit worktree addat the coordination worktree ROOT,CoordinationWorkspace.worktree_path(repo_root, slug, mid8)(src/specify_cli/coordination/workspace.py, the.worktrees/<slug>-<mid8>-coorddirectory), with the Mission directory below it atcoord_feature_dir(src/specify_cli/missions/_read_path_resolver.py, which returns<worktree root>/kitty-specs/<slug-mid8>and is NOT the path to register); for a husk, a plain directory at the husk's worktree-root path that is deliberately never registered. It is deterministic and offline:GIT_CONFIG_GLOBALpointed at an empty file andGIT_CONFIG_NOSYSTEM=1, a fixed author and committer identity, fixedGIT_AUTHOR_DATEandGIT_COMMITTER_DATE,commit.gpgsign=false, no remote and no network. Neither the temp path, a commit SHA nor a git date enters a golden byte (the overview documents carry none). Tests in 2b: (1) the deterministic-provisioning test generates twice into differently named temp roots under different fixed dates and requires byte-identical goldens (AC-8d); (2) a registration check that compares thegit worktree list --porcelainentries withCoordinationWorkspace.worktree_path(the worktree root, not the Mission directory) for everycoord-class fixture, asserts thatclassify_worktree_topology(src/specify_cli/coordination/surface_resolver.py) returnsWorktreeTopology.COORD_WORKTREEfor the fixture's coordination read directory andUNREGISTEREDfor every husk, and finds no registry entry for anown-class fixture; two planted controls must turn it red: one skips the registration, and one registers the Mission directory (coord_feature_dir) instead of the worktree root (a path whose parent iskitty-specs, so the enclosing.worktrees/<name>directory is not in the registry and the classification isUNREGISTERED). Project-header materialisation (one step, run by the Python generator, the golden-honesty test and the Java fixture factory). A committed fixture tree carries no.git, and a realgit initplus one commit cannot produce a detached HEAD, an unborn branch or a pointer file. A project-header fixture therefore holds aHEADfile at the tree root (the exact text to install, for exampleref: refs/heads/main, a bare commit id for a detached HEAD, or aref:line naming a branch that has no commit) or, for the pointer case, aGITDIR_POINTERfile (the exact text of the.gitpointer file, a relativegitdir: gitdir-target) plus agitdir-target/directory holding its ownHEAD(and no rootHEAD). The step runs after the tree is copied into the temporary root and before any oracle or service call: without a pointer it creates.git/and writes theHEADtext to.git/HEAD; with a pointer it writesGITDIR_POINTERas the regular file.gitand leavesgitdir-target/HEADas copied. Nogit initand no commit run for these fixtures, so the unborn case is reproducible andinit.defaultBranchcannot change a branch. The generator's--checkmode and the honesty test assert that the branch the oracle read (read_head,tests/contract/_mission_status_project.py) equals the branch parsed from the fixture's HEAD text, and the manifest recordsheadTextper project-header fixture. The Java fixture factory copies the committed tree into a temporary directory and runs the same step (no git binary involved), so the service reads the very.git/HEADthe oracle read. The Java service reads no git (FR-006); the git dependence is on the oracle side only. tests/contract/test_mission_status_conformance.py: the golden-honesty test,pytest.mark.corpus, run blocking bytests (contract tools)(needs.changes.outputs.corpus == 'true' || ...contract_tools == 'true',.github/workflows/ci-router.yml) and DESELECTED in the advisory Packs corpus command (below), so it has exactly one home.tests/contract/is the existing home (ROUTER_CONTRACT_MODULESintests/ci/test_contracts_workflows.pylists the three modules the router's own corpus job runs; every other corpus module oftests/contract/runs in the tool-test job, so the new module has exactly one home once the Packs deselect below is in place). Its imports stay insidesrcfiles already named in thecontract_toolsfilter group (status/reducer.py,status/store.py,status/tail_reader.py,status/progress.py,missions/_read_path_resolver.py,migration/schema_version.py,mission_metadata.pyare all listed); the testtest_the_contract_tools_filter_group_names_every_src_file_...derives its demand fromtests/contract/mission_status.pyimports, so any newsrcimport must be added to the group in the same commit.scripts/ci/mission_status_corpus_compare.py: the nightly full-corpus comparison script (AC-8c; its non-vacuity floor, failure paths and Q-3 reporting are in section 7, "Nightly comparison contract"). It lives inscripts/ci/(router groupci/ci_config; module rowcirunstests/ci). Run by path,scripts/ciand not the repository root is first onsys.path, so the script bootstraps the repository root ontosys.pathfor bothscripts.andtests.imports (tests.contract...), in the formtests/ci/test_workflow_script_import_guard.pyaccepts. The oracle'sProjectorneeds aContractToolsvalue, whichload_contract_tools(mp, repo_root)(tests/contract/_mission_status_payloads.py) builds from apytest.MonkeyPatch; the script creates it insidepytest.MonkeyPatch.context(), holds it for the whole comparison and does not fork a second copy of the oracle. Its unit test covers both (a subprocess import of the script by path from another working directory, and the MonkeyPatch path). It imports the Python oracle fromtests/contract/_mission_status_payloads.py, whose module level importspytest,yaml,jsonschemaand tensrcpackages, so it needs the fully synced test environment (the nightly job recipe, section 7); it is "pytest-free" only in that no job step invokespytest(which is whattests/architectural/test_no_duplicate_suite_execution.pyinspects), not in its dependencies. Its unit test:tests/ci/test_mission_status_corpus_compare.py.scripts/ci/mission_status_gate_checks.py: the executable home of the two non-Gradle guards, with subcommandsdigests(the AC-15e comparison: exactly fourdigest-*files with the expected os/arch names,requestCountequal and at least the floor, allsha256equal; prints the four digests on failure) andnative-tag-floor(counts the executed tests in thenativeTestJUnit XML and fails belownativeTagFloor). The gate job and each native leg invoke it from a workflow step; it holds no other logic. Its tests,tests/ci/test_mission_status_gate_checks.py, run each subcommand on planted files (four digest files, one altered, one missing, wrong names, a lowrequestCount; an XML with fewer tests than the floor, an empty directory). Phase 1 creates it withnative-tag-floor; phase 5 addsdigests. The JaCoCo scope guard is not here: it is the Gradle taskjacocoScopeCheckof phase 1 (section 5).tests/ci/test_mission_status_api_build.py(pins AC-14e/f/g, wrapper properties, catalog-only, toolchain policy, JaCoCo minimums andcheckwiring, generated-package names, no springdoc or swagger-ui dependency in any Gradle configuration (E-11), the contract-document bundling task and its resource path, packaging exclusion C-009, workflow structure AC-15a/f, CODEOWNERS line, path filters, the NFR-009 tracked-path check) andtests/ci/test_mission_status_routing.py(AC-8e; besidetest_contracts_routing.py; also pins the Packs--deselectof the new module with its discovered-count guard).- Two existing pinning tests are edited, no others:
tests/ci/test_contracts_workflows.py(named extras for thecontract_toolsgroup) andtests/architectural/test_ci_corpus_trigger_completeness.py(_CORPUS_MARKED_MODULESgains the new module). - Router/CI edits:
.github/workflows/ci-router.yml(contract_toolsgroup gainsservices/mission-status-api/src/test/resources/conformance/),.github/workflows/packs.yml(one added--deselect tests/contract/test_mission_status_conformance.pyon the corpus command), new.github/workflows/mission-status-api.yml,.github/CODEOWNERS(one line),.gitattributes(one scoped lineservices/mission-status-api/src/test/resources/conformance/ -text, never a global rule; it keeps the CRLF fixture bytes through checkout).ci-nightly.yml,contracts*.yml,ci-fleet-verdict.ymluntouched. - Shared-surface edits the spec's Reflexivity list is silent on.
spec.mdReflexivity items 1-6 do not namepacks.yml,.gitattributes,docs/development/reference/ci-gate-mechanics.mdordocs/changelog/CHANGELOG.md. This plan extends the list with them (no spec edit needed); the review diff check of section 4 names every one. Recorded in section 14, E-6. - Not touched:
src/(kernel, charter/doctrine, specify_cli, status, mission_runtime),packs/,contracts/**,pyproject.toml,uv.lock. No CLI command is added or changed; nothing reaches into kernel internals; nosync/Zeitgeist coupling (the service never readsconfig.tomlor any hosted key, AC-16c). - Docs:
docs/development/reference/ci-gate-mechanics.mdsection "Contracts gates" gains a short "Mission Status API workflow" subsection;docs/changelog/CHANGELOG.md[Unreleased]gets one Added line (docs impact below). Both are shared-surface edits listed in the extension bullet above.
2. Generated artifacts and their regeneration commands
Hand-patching any of these is a review finding; each has a freshness check.
| Artifact | Committed? | Regenerate | Freshness check |
|---|---|---|---|
Spring interfaces/models for /project and /missions (openapi-generator 7.25.0, from the split contract root) | No (build output under build/, generated on every build) | cd services/mission-status-api && ./gradlew openApiGenerate (task name fixed at WP01) | AC-11c mutation test; the build fails if the contract changes shape |
gradle/verification-metadata.xml | Yes (generated) | ONE run on a Linux x64 workstation that resolves every configuration, not just the plugin classpath: ./gradlew --write-verification-metadata sha256 check nativeCompile nativeTest nativeSmoke -PnativeBinary=<built binary> jacocoTestReport openApiGenerate --refresh-dependencies (a help-only run resolves only the plugin and buildscript classpaths and would leave the first real build failing on missing entries). Never hand-edited. | Strict resolution on all four legs (AC-14c); tamper test (AC-14a). The other three legs run a verify-only step (strict, no write); an entry missing for that OS (an OS-classified artifact) fails the leg and the step prints the exact command that adds it, to be run on that OS or in a workflow_dispatch job that uploads the generated diff as an artifact (CI never commits). WP01 records in research.md R-4 whether any resolved artifact carries an OS or arch classifier. |
| Gradle wrapper jar + properties | Yes | ./gradlew wrapper --gradle-version 9.7.1 --gradle-distribution-sha256-sum <sha256 from the official .sha256 file> | wrapper-validation action + AC-14f test |
Conformance goldens, manifest.json | Yes | .venv/bin/python -m tests.contract._mission_status_conformance --write (from the repository root; sets all outputs under services/mission-status-api/src/test/resources/conformance/). resolver-cases.json and portlevel-tests.txt are hand-authored data, not generated. Goldens are written in the canonical form of section 13 (sorted keys, compact separators, UTF-8 without ASCII escaping). | Python golden test (AC-8b/8d); --check mode compares without writing |
Python bytecode/build/, .gradle/ | No | .gitignore in the service dir | n/a |
| Docs / glossary (Contextive) | Terms live in docs/context/<slug>.md; src/specify_cli/.contextive/*.yml and the scope .contextive.yml files are GENERATED from them through .kittify/traceability/contextive-map.yaml and are never hand-edited. | .venv/bin/python scripts/generate_contextive_glossaries.py generate | .venv/bin/python scripts/generate_contextive_glossaries.py check (exits 1 when a generated file is missing or stale) |
Terms decision (NFR-008). The service README and the added docs/ prose use only terms that exist in the glossary: Mission, Work Package and Lane (verified with .venv/bin/spec-kitty glossary list), and Tail cursor and log_truncated (defined in docs/context/system-events.md; Tail cursor has status candidate). "Reducer" and "status event" have NO glossary entry (grep -rn reducer docs/context/*.md finds nothing; "status event" appears only inside a Moment definition in team-kitty.md), so the earlier claim that they are canonical is withdrawn. They, and Diagnostic, "overview read model" and "conformance golden", are treated as code identifiers: the README defines each in its own short terms list the first time it is used and does not present them as glossary terms. No docs/context file and no contextive-map.yaml scope changes (the map scopes are src/ paths; services/ is not one), so no generated file changes and NFR-008 is satisfied by this stated decision plus the check run in section 5. If a reviewer asks for a real term, the work is: add it to the matching docs/context/<slug>.md, run generate, run check, commit both.
spec-kitty regen --check is not affected: it verifies "3 generated fixtures" (ran fresh on this checkout) and none is an input of this mission (no CLI surface, template, pack or agent command changes). It stays in the always-on set as a regression check.
3. Contracts: what moves, what is preserved
| Contract | Move | Preserved |
|---|---|---|
contracts/mission-status/** (1.0.0-SNAPSHOT) | none (AC-14d, C-012); consumed read-only at the same commit. Project.lastActivityAt wording drift is recorded in the spec, not edited. | Every path, schema, status code, provisional member as written. |
contracts/tools/pins.json / gradle_pin_check.py / verify_pins.py | none. The new workflow file is not named contracts.yml, so their .github/workflows/contracts.yml glob (DEFAULT_WORKFLOW_GLOB) never reads it (AC-15f). | Gradle 8.14.5 pin for contracts/. |
Vendored/consumed spec-kitty-events (spec_kitty_events 10.4.0, uv.lock) | none. The reducer is ported, the package is only imported by the Python oracle through materialize_snapshot. | specKittyEventsVersion is recorded in the manifest (AC-5d). |
orchestrator-api contract | none | untouched |
| Doctrine schemas / packs / agent surfaces | none | untouched |
| New committed data formats (internal to this mission) | conformance/manifest.json, conformance/resolver-cases.json, portlevel-tests.txt | defined in contracts/service-contract-notes.md |
4. Upgrade/migration chain
Not touched. No migration, no .kittify metadata change, no template or agent-surface change, no CLI schema version bump, so spec-kitty upgrade has nothing to deploy. Reflexivity (spec): the service is read-only and detached, is not started by any CLI command, writes no ledger and takes no lock; Missions in flight see no change to event logs, lanes or commands. The shared-surface edits are the ones listed in section 1 and verified by AC-16a/16b, AC-14d, AC-8e and the review diff check: no src/ or other-Mission kitty-specs/ path modified; the ci-router.yml diff is one filter-group line; the packs.yml diff is one --deselect token; the .gitattributes diff is one line scoped to the conformance tree; the docs/ diff is the ci-gate-mechanics.md subsection and one changelog line; exactly two existing pinning tests are edited (test_contracts_workflows.py, test_ci_corpus_trigger_completeness.py) and tests/ci/test_mission_status_routing.py is new. The spec's own diff-check wording lists only the router, the two tests and CODEOWNERS; this plan extends it with the packs.yml, .gitattributes, docs and changelog edits (section 14, E-6).
5. Gate set (re-verified against this checkout)
Sources read: ci-router.yml (jobs changes, prose-scan, ruff, commit-msg, markdownlint, uv-lock, import-linter, regen-check, terminology, docs-lint, layer-rules, archive-freeze, architectural-fast, architectural-heavy, tests-docs, tests-e2e, tests-corpus-blocking, tests-contract-tools, mission-state-audit, router-gate), ci-modules.yml/module-tests.yml, ci-aggregate.yml, ci-quality.yml, packs.yml, contracts.yml, docs-pages.yml, protect-main.yml, release-readiness.yml, .github/ci-module-registry.yml.
Enforced gates, and whether a services/-only diff triggers each
| Gate (job) | Triggered by services-only diff? | Reason |
|---|---|---|
ruff (ruff check . AND ruff format --check ., router) and Lint in ci-quality.yml (same two commands) | Yes (always-on) | No filter group; runs repo-wide. Passes vacuously for Java; bites the new Python files. Note [tool.ruff.format].exclude is honoured only in directory discovery. |
uv-lock (uv lock --check) | Yes (always-on) | We add no dependency to pyproject.toml/uv.lock; stays green. |
import-linter (ruff check --select TID251 .) | Yes (always-on) | Python imports of the new files only. |
regen-check (spec-kitty regen --check) | Yes (always-on) | No input changes. |
terminology (test_no_legacy_terminology.py) | Yes (always-on) | It scans src, tests, docs only; services/ is outside its scan, but tests/ and docs/ additions are in it. |
layer-rules (test_layer_rules.py, test_pyproject_shape.py) | Yes (always-on) | No src module added; wheel packages unchanged. |
archive-freeze (test_archive_root_byte_identical.py) | Yes (always-on) | Our kitty-specs/<SLUG>/ files are not on main, so nothing frozen is edited. Never edit another Mission's files. |
architectural-fast (ratchet roster) | Yes (always-on) | Runs the fast_gate.roster of .github/ci-module-registry.yml (--battery-part fast). test_no_invalid_windows_filenames.py is NOT on that roster (verified: absent from the roster; it runs in the architectural-heavy shards, which a bare services/ diff does not select). So NFR-009 is not enforced by architectural-fast; the plan makes it enforced by tests/ci/test_mission_status_api_build.py, which applies the guard's own character sets (WINDOWS_ILLEGAL_CHARS, SHELL_EXPANSION_TELLTALE_CHARS, imported from tests/architectural/test_no_invalid_windows_filenames.py so there is one authority; importability checked at WP01) to every tracked path under services/mission-status-api/, with a discovered-count guard (the scan must see at least the recorded number of tracked service paths) and a planted-path control. The Java fixtures must avoid `< > : " \ \ |
docs-lint (scripts.docs.check_spelling, check_changelog_style) | Yes (always-on) | Spelling scope is docs/, packs/built-in/, root README.md (TYPO_ROOTS, TYPO_FILES); services/*/.md is outside it. Our changelog line and the ci-gate-mechanics.md edit are inside. |
router-gate (terminal) | Yes (always-on) | Waits on all router jobs. |
prose-scan (feeds architectural-heavy/tests-docs) | Yes (always-on, informational) | n/a |
architectural-heavy (path-filtered: ci, ci_config, architectural groups plus src groups) | No for services/ alone; YES for this mission's PR as a whole | The workflow file, scripts/ci/ and tests/architectural/ edits select it. Targeted gate files only during mission work (charter NO_FULL_HEAVY_SUITES_IN_MISSION); CI runs the whole battery. |
tests (contract tools) (-m "corpus and not windows_ci" over tests/contract/) | Not for a bare Java edit; YES for a golden-only diff after the router edit, and for any tests/contract/** edit | The edit adds services/mission-status-api/src/test/resources/conformance/** to contract_tools; AC-8e pins it. The new module must NOT also run in the Packs advisory lane: see the packs-gate row. |
tests (corpus-blocking) (router, at budget #5838) | Not by services/; YES by this mission's kitty-specs//{spec,plan}.md and tasks/ | The service gate must not depend on it (no needs, no shared artifact, no workflow_run). |
mission-state audit (mission_state: kitty-specs/**) | Not by services/; yes by this Mission's own files | n/a |
tests (docs) | Only if docs/** changes (yes: ci-gate-mechanics.md, changelog) | docs page inventory/freshness: the edited pages keep frontmatter and refresh updated:; no new page is added in v1 (no inventory lockfile or toc.yml change). |
CI Modules shards (ci-modules.yml, module-tests.yml) | No module shard for services/; the ci module (tests/ci) YES because .github/workflows/ and scripts/ci/ change | select_modules result computed above. |
diff-cover (>= 90% of changed critical-path lines, ci-aggregate.yml) | Runs after CI Modules but scores nothing for this mission | CRITICAL_PATHS in scripts/ci/aggregate_source.py are src/kernel/, src/charter/, src/specify_cli/status/, lanes/branch_naming.py, consolidation/, runtime/next/, mission_runtime/ and only .py files are scored. diff-cover scores Python lines only and no Java line. No src/ file changes, so it is vacuous here. |
build-wheel + clean-install-verification (ci-quality.yml) | Yes (they run on every PR) | Wheel packages = src/*; services/ cannot enter. |
packs-gate (packs.yml) | The gate itself: no. The advisory corpus job: YES on this PR (its corpus selection is evaluated on the PR as a whole and this Mission's kitty-specs files and the new corpus-marked module can select it) | That job runs -m "corpus and not windows_ci" with an explicit per-module --deselect list and promises nothing runs twice. The plan therefore edits packs.yml: add --deselect tests/contract/test_mission_status_conformance.py to the corpus command. tests/ci/test_mission_status_routing.py pins it (section 11). Advisory, but a silent double run is the defect class being closed. |
Contracts workflow (contracts.yml) | No | Its path list does not include services/ and we edit no contracts/; contracts-gate stays untouched. |
docs-pages.yml | Only on push to main for docs//kitty-specs/ (a post-merge deploy, not a PR gate) | n/a |
release-readiness.yml | Its kitty-specs/** path triggers cutover-guard on this Mission's PR | pre-existing behaviour. |
protect-main.yml | On push to main (post-merge commit inspection) | n/a |
New mission-status-api.yml | Yes for services/mission-status-api/, contracts/mission-status/ and the workflow file (push to main and PR, path-filtered); a tag push of the release pattern, schedule and workflow_dispatch unfiltered | See below. |
Not enforced (do not promise them): commit-message lint (commit-msg job runs git log ... || true, informational), markdownlint (npx markdownlint-cli2 ... || true), Bandit, pip-audit, mypy (no CI job runs it; charter text says mypy --strict but no workflow enforces it), the charter's named coverage floors (kernel 90%, mission-loader 90%), SonarCloud (sonar-pr is continue-on-error and excluded from aggregate-gate; sonar.yml is nightly/informational). Required status checks are repository settings, not files; nothing in the checkout names them (protect-main.yml documents only clean-install-verification), so whether any check is "required" is unverified.
Java coverage (diff-cover cannot see it). A JaCoCo verification rule fails the build: LINE coverage >= 90% on the domain and application packages (NFR-006) and BRANCH coverage >= 80% on domain only (tiered rigour: the reducer and derivations are the core logic whose divergence is the product risk). The branch floor is a plan addition: NFR-006 asks for line coverage only, so a build failing on branch coverage is recorded as such in section 14, E-6. Generated sources, config, and adapter.* are excluded from the threshold (adapters are held by the adapter suites, contract validation, goldens and the native smoke, which JaCoCo cannot measure in native mode); the adapter report is published unthresholded. Rationale: 90% mirrors the charter intent, branch coverage on the domain catches an untested tie-break that line coverage hides. Diff-cover scores Python only and cannot back-stop this, so the wiring is specified and pinned here:
- Task and binding. The rule is the Gradle task
jacocoTestCoverageVerification;checkdependsOnit, and the CI JVM step runs./gradlew check(nevertestalone, which would skip the rule). The JVM leg uploads the HTML and XML report as an artifact on every run. - Scope patterns. JaCoCo class-name patterns (
crosses package separators):org.speckitty.missionstatus.domain.for both rules andorg.speckitty.missionstatus.application.*for the line rule. These are the same two packages the ArchUnit rules name; the exact pattern text is verified at WP01 against the generated class files. - Non-vacuity. A pattern that matches zero classes passes vacuously, and a JUnit test inside the same Gradle build cannot read the XML report that
jacocoTestReportwrites aftertest. So the guard is a Gradle verification task,jacocoScopeCheck, in phase 1'sbuild.gradle.kts(the only owner of the build script), wired so thatcheckdepends on it and it runs afterjacocoTestReport. It parses the XML report and fails unless each include pattern matched at least the number of classes recorded insrc/test/resources/jacoco-class-floor.properties(a floor recorded at WP01 and only raised). The floor file is phase 2a's: phase 1 ships the task tolerant of an absent file only while the report holds no class under either package, and the task fails on an absent file or a zero floor once a class exists, so 2a cannot forget the file. Its revert-fails evidence is a scratch-copy run with a pattern emptied, which turnscheckred (section 11, NFR-006 row); the build test pins the task, its wiring and its reading of the floor file. - Pinning.
tests/ci/test_mission_status_api_build.pypins (a) the three minimums (0.90 line, 0.90 line, 0.80 branch) by parsingbuild.gradle.kts, (b) thatcheckdepends onjacocoTestCoverageVerificationand onjacocoScopeCheck, and (c) that the workflow's JVM step invokescheck. Revert-fails rows are in section 11 (NFR-006).
The new workflow itself. Path-filtered on push (branch main; a tag push carries the tag pattern of section 12, to which GitHub applies no path filter) and pull_request to services/mission-status-api/, contracts/mission-status/, .github/workflows/mission-status-api.yml (AC-15a pins exactly this list); schedule and workflow_dispatch carry no filter. The tag trigger and the release job are part of the spec's trigger list since operator decision E-4 (FR-015, AC-15a) and are specified in section 12. It has ONE terminal job, mission-status-api-gate, which is FR-015's "aggregate job" (the plan has no second aggregate job; the gate also downloads the four leg digests and compares them, AC-15e). It needs the jvm job and the four native legs (the jvm job carries the same fork guard and != 'schedule' conjunct as a native leg, so a scheduled run skips it too, and AC-15a(iii) pins its if: like every other job) and carries if: (github.repository == 'spec-kitty/spec-kitty' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && github.event_name != 'schedule' && always() (canonical fork guard first, the schedule conjunct of FR-015 (b) second, always() last); it fails unless every leg is success (AC-15c), so a skipped leg cannot read as green, while a scheduled run skips the legs and the gate together and never meets the rule. AC-15a(iii) pins this exact expression for the gate like every other job. Wiring comparison with contracts.yml: that workflow is NOT listed in the router's needs (the router only runs its tool tests as tests (contract tools)), its terminal contracts-gate is a standalone check, and ci-fleet-verdict.yml lists "Contracts" under workflow_run.workflows. Decision: the new workflow follows the same standalone-terminal-job shape and is not added to the router needs (a path-filtered workflow cannot be a router dependency: a skipped workflow posts no check). Whether Mission Status API gate becomes a required check is a maintainer decision at merge (a required path-filtered check stalls unrelated PRs). The workflow IS added to ci-fleet-verdict.yml's workflow_run.workflows list and to scripts/ci/fleet_verdict.py's PR_WORKFLOWS (operator ruling 2026-10-11, superseding E-3: the closed PR_WORKFLOWS inventory rejects any unregistered pull_request workflow), so a red Mission Status API gate feeds the fleet verdict (PR [ci] comment, main-red issue).
Exact local commands per gate (all from the repository root checkout; .venv/bin/..., never bare uv run):
.venv/bin/ruff check . && .venv/bin/ruff format --check .
.venv/bin/ruff check --select TID251 .
.venv/bin/spec-kitty regen --check
.venv/bin/python -m pytest tests/architectural/test_no_legacy_terminology.py -q
.venv/bin/python -m pytest tests/architectural/test_layer_rules.py tests/architectural/test_pyproject_shape.py -q
.venv/bin/python -m pytest tests/architectural/test_archive_root_byte_identical.py -q
.venv/bin/python -m scripts.docs.check_spelling && .venv/bin/python -m scripts.docs.check_changelog_style
# Contextive: generated files must stay in sync with docs/context (no change expected; see the terms decision in section 2)
.venv/bin/python scripts/generate_contextive_glossaries.py check
# baseline for ordinary changes (CLAUDE.md "Test policy"); the heavy suites stay excluded
make test-fast
# specific architectural gates the workflow addition implicates (never the bare directory)
.venv/bin/python -m pytest 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 tests/architectural/test_no_invalid_windows_filenames.py -q
# tool tests that own the golden test and the routing
PWHEADLESS=1 .venv/bin/python -m pytest tests/contract/test_mission_status_conformance.py tests/contract/test_gradle_pin_check.py tests/contract/test_verify_pins.py -m "corpus and not windows_ci" -q
.venv/bin/python -m pytest tests/ci/test_contracts_routing.py tests/ci/test_contracts_workflows.py tests/ci/test_fork_guard.py tests/ci/test_workflow_script_import_guard.py tests/ci/test_mission_status_api_build.py tests/ci/test_mission_status_routing.py tests/ci/test_mission_status_corpus_compare.py tests/ci/test_mission_status_gate_checks.py tests/ci/test_corpus_select.py tests/ci/test_corpus_blocking_home.py tests/ci/test_skip_if_green_wiring.py tests/release/test_release_ci_ownership.py -q
# what a diff selects
.venv/bin/python scripts/ci/local_gate_parity.py --base-ref origin/main
# Java (inside services/mission-status-api; JDK 25 on PATH)
./gradlew check # unit, ArchUnit, adapters, contract validation, goldens, JaCoCo rule, tamper test (build.VerificationTamperTest, a standing test)
./gradlew nativeCompile nativeTest # nativeTest runs ONLY the tagged native subset (section 11)
./gradlew nativeSmoke -PnativeBinary=<path>
6. Baseline: pre-existing red versus introduced red (Q-4)
#3284 is CLOSED (2026-09-08), so no committed list of known failures is assumed. Procedure, executed by the orchestrator before WP01 claims and again before the PR:
1. In a scratch worktree of main at the merge-base (never switch HEAD of a working checkout), run make test-fast (the repository baseline, CLAUDE.md "Test policy") plus the targeted surfaces this mission touches: tests/contract/, tests/ci/ and the arch gate files named in section 5. The directive NO_FULL_HEAVY_SUITES_IN_MISSION excludes make test-full, a bare tests/architectural/, tests/e2e/ and the performance, stress and timing suites; it lists make test-fast inside the targeted surface, so the earlier "not run" reading was wrong. Record the make test-fast pass and fail counts in baseline-red.md on both the merge-base and the branch, and in the PR's "Tests run" section. 2. Record the command lines, date, merge-base SHA and the failing test ids in kitty-specs/<SLUG>/research/baseline-red.md (a new file, written once, appended to, never rewritten). 3. A failure is "introduced" only if it is red on the branch and green on the base (CLAUDE.md "Test-run baseline-red gotcha"); categories 2-4 (CI-environment, stale install, stale venv) are re-checked with uv sync --frozen --all-extras before being called pre-existing. 4. Per the charter Pre-existing Failure Reporting Rule, every genuinely pre-existing failure gets a GitHub issue (command, failure summary, why it is pre-existing) BEFORE it is treated as baseline; the issue number is written next to its row in baseline-red.md. Failures outside the changed surface are reported against that file, not hidden, not attributed to this mission. 5. For the Java side the baseline is trivially empty (new tree). A red Mission Status API gate is always introduced. 6. CI-level reds on the PR. This Mission's own kitty-specs/** edits select the at-budget tests (corpus-blocking) job (issue #5838, 20-minute budget in ci-router.yml), so a red or cancelled check is likely before any assertion fails. For every red PR check: (a) read the real conclusion with gh run view/gh pr checks (a cancelled or timed_out job is not a failure of the change; the zero-second "failure" is usually a cancelled job); (b) remember that PR CI tests the head merged with the current main and that one red shard cancels the rest, so compare the same job on the latest main run; (c) cite the existing issue (#5838 for the corpus-blocking budget) rather than opening a duplicate, and open one under step 4 only when none exists; (d) allow one diagnosed rerun, never retry-to-green; (e) record the run ids and conclusions in baseline-red.md.
I did not run any suite while authoring this plan.
7. Q-3 (Python git verdicts)
Interim resolution (stated plan-level): the service decides the read directory by directory existence and the stored topology only (FR-006). conformance/resolver-cases.json is the one data file both languages consume (columns: id, stored topology, coordination root present/absent, coordination Mission directory present/absent, lanes.json present/corrupt/absent, expected class own/coord). The service reads no git, so the curated goldens contain no case whose Python answer depends on a git verdict that directory existence cannot reproduce (probe_coord_state DELETED for a declared-but-absent coordination branch, the remote probe). This does NOT mean the Python oracle runs without git: it consults the worktree registry and git rev-parse for the curated coordination fixtures (section 1, "Git provisioning of the oracle run"), which is why the generator builds a temporary git repository per fixture; the git verdicts that still occur on the Python side are is_registered_coord_worktree (registry read) and the probe_coord_state rev-parse, both answering "registered / exists" for the curated coord fixtures, and the registry read answering nothing for an own-class read directory (no .worktrees ancestor). The manifest records the read-directory class per fixture. Classifying a non-curated divergent case: a case where Python's answer needs a git verdict is NOT added to resolver-cases.json and not to goldens; it is classified in research.md R-9 ("Q-3 classification"; documentation only, no script reads it, and it never turns a difference green) with (a) the Python verdict used, (b) what the service reads, (c) class same (directory existence reproduces it), differs (only a case the oracle run actually shows) or unknown. The coordination husk (a coordination directory that exists but is not a registered worktree, under a coord-routing topology) starts as same by code reading, not differs: _resolve_existing_for_slug and probe_coord_state (src/specify_cli/missions/_read_path_resolver.py) decide MATERIALIZED or EMPTY by Path.exists() and describe themselves as pure-path, and is_registered_coord_worktree (src/specify_cli/status/aggregate.py) only sets the topology label of MissionStatus (legacy versus coordination), which build_overview does not use (it derives topology from meta). The AC-6d husk golden decides it: the class is unknown until the first oracle run while that golden is not yet generated, and differs only if the run shows a difference. The nightly full-corpus compare (AC-8c) is what finds real-corpus instances. Per D-Q1 and AC-8c the nightly fails on ANY Python/Java difference, so an instance found there keeps the nightly red, reported with its Mission id and difference class, until the port is fixed or a spec or operator ruling resolves it. No exemption mechanism exists (operator decision E-10, 2026-10-10: none). Nightly comparison contract (scripts/ci/mission_status_corpus_compare.py; AC-8c fixes "exits non-zero on the first differing Mission and prints its slug and the differing field"; the items below only add checks that keep the gate non-vacuous, none of them weakens it). The script compares the Python overview of every Mission with the service's, and:
- Non-vacuity. It prints and asserts the number of Missions compared. It fails when that number is below the recorded floor (500; 588 on this checkout), or differs from an independent count it takes itself (
git ls-files 'kitty-specs/*/meta.json'), or is zero. The workflow checkout must not be shallow in a way that dropskitty-specs/(fetch-depth: 0or a full checkout). - Failed start. It exits non-zero when the service does not start or is unreachable, and when the service refuses every Mission.
- One-sided Mission. A Mission present in only one side's listing fails.
- Refusal. Over HTTP a bad ledger refuses the whole
/api/v1/missionsand/api/v1/projectread (rows L3a, L3b, L4, L4b), and the Problem body is path-free with the Mission'smid8only in the service log, which the script is not given. The workflow therefore cannot attribute a refusal to a Mission or compare a per-Mission refusal class, and does not try: any 500 from the service fails the job and names the side (service, with the route and the Problemcode); an oracle refusal (a Mission for which the Python oracle raises) fails the job naming the side (python), the Mission slug, itsmission_idand the exception class; and when both sides refuse, the job still fails ("both failed, so equal" is not accepted). A per-Mission refusal-class comparison exists only as a library function, covered by unit cases against a fake. - No exemption. Any difference fails the job: a differing field, a one-sided Mission, a service 500 or an oracle refusal. The failure line names the Mission slug, its
mission_id, the difference class (field,one_sided, or, for a refusal,refusalwith the side as above) and the differing field; for a whole-read 500 the Mission is not observable over HTTP, so the line names the side, the route and the Problemcodeinstead. The script also writes the same rows as a machine-readable report (nightly-report.json, a workflow artifact, never committed, never read as input). The report records outcomes; it cannot turn a difference green, and the exit code is non-zero while any row is notequal. There is no committed list of known differences. A non-curated Q-3 divergence therefore keeps the nightly red until the port is fixed or the spec or operator rules; there is no exemption (E-10, resolved: none). - Unit cases in
tests/ci/test_mission_status_corpus_compare.py: empty corpus; below-floor count; unreachable service; one-sided Mission; the refusal library function (same class, different class, against a fake; the workflow never exercises it); a service 500 and an oracle refusal each exiting non-zero naming their side; a difference of the Q-3 shape (a coordination husk case) still exits non-zero; a planted all-equalreport beside one differing Mission still exits non-zero (the report cannot flip the exit); plus the AC-8c two-Mission planted difference.
- Nightly job environment (
nightly-corpus, the complete recipe).runs-on: ubuntu-24.04,timeout-minutes: 45,if:per FR-015 (c). Steps, each action pinned by SHA exactly ascontracts.ymlandci-router.ymlpin them: (1)actions/checkout(08c6903cd8c0fde910a37f88322edcfb5dd907a8, v5.0.0) withfetch-depth: 0andpersist-credentials: false(a real.gitis required: the oracle reads the worktree registry and the corpus isgit ls-files); (2)astral-sh/setup-uv(20cfd1bf945f4377ade1205e4dbc17946fc9a30d, v10.0.1) withpython-version: '3.12', thenuv sync --frozen --all-extras(the formci-router.ymluses for its test jobs; the oracle module imports pytest, jsonschema and PyYAML, so the test extras are needed); (3)gradle/actions/wrapper-validationthengraalvm/setup-graalvm(the same SHA pins as the legs; JDK 25 andJAVA_HOME); (4) build the JVM artifact, not the native binary:./gradlew --no-daemon bootJarinservices/mission-status-apiproducesbuild/libs/mission-status-api.jar(the byte equality of the native binaries is the legs' job, AC-15b/15e; this job compares the service logic with the oracle over the whole corpus); (5) start it in the background against this checkout:java -jar build/libs/mission-status-api.jar --mission-status.project-root="$GITHUB_WORKSPACE" --mission-status.port=8787 > "$RUNNER_TEMP/service.log" 2>&1 &and record the PID; (6)uv run --frozen --no-sync python scripts/ci/mission_status_corpus_compare.py --base-url http://127.0.0.1:8787 --repo-root "$GITHUB_WORKSPACE" --report "$RUNNER_TEMP/nightly-report.json"; the script pollsGET /api/v1/projectfor readiness every 2 s for at most 60 s and exits non-zero when the service never answers (the "failed start" case); (7) anif: always()step uploadsnightly-report.jsonandservice.logand stops the process by the recorded PID. The project-root key ismission-status.project-root(section 13, Bind and configuration). The script's HTTP client sends the acceptedHostform127.0.0.1:8787. The job runs nopytestcommand, sotest_no_duplicate_suite_executionclassifies it as non-suite (AC-15d runs that test on the first commit of the workflow to confirm), but it does need pytest installed, because the imported oracle module imports it;workflow_dispatchon the PR branch is the pre-merge exercise of exactly this recipe (AC-8c).
Remains blocked on #5631 (open): the degrade policy for a declared-but-absent coordination branch/worktree and an unreachable remote. v1 mirrors the own-directory outcome pinned by the coordination goldens; when #5631 resolves, golden and Java change together in their own change.
8. Q-5 decision: foreign Host or Origin is refused with 403
Decision: HTTP 403 Forbidden, one constant (HostOriginRefusal.STATUS = 403), Problem code: request_refused, title: Request refused, no detail naming which check failed, issued by a servlet filter at the highest precedence before MVC dispatch and before any MissionEventSource call.
Rationale: (1) contracts/_shared/responses/Problem.yaml is the default response of every operation and the Problem schema allows any status 400-599; the contract names neither 403 nor 421 anywhere (grep of contracts/mission-status), so both fit and the plan is free; (2) 403 is the standard answer for a refused Origin (Spring's own CORS rejection is 403 "Invalid CORS request"; browser same-origin tooling expects it) and a single constant must serve both Host and Origin; 421 Misdirected Request is defined for connection-reuse routing and is wrong for an Origin; (3) developer tooling that implements the same rebinding defence (Vite allowedHosts, Jupyter) answers 403 for a foreign Host.
Which Host the filter evaluates.
- The RAW
Hostrequest header (request.getHeader("Host")), nevergetServerName()orgetServerPort(): Tomcat derives those from the Host header (or, for HTTP/1.0 absolute-form, from the request target), so reading them can disagree with the header the client sent. An absent or empty header is refused. - Comparison is exact and case-sensitive against the two forms of FR-009,
127.0.0.1:<own port>andlocalhost:<own port>. Upper-case (LOCALHOST:<port>) and trailing-dot (localhost.:<port>) spellings are therefore refused; widening to case-insensitive matching is a spec decision (section 14, E-7), not a plan decision. - The own port comes from the bound server (
WebServerInitializedEvent,getWebServer().getPort()), held inconfig.OwnPortHolder, which implements the small interfaceadapter.in.http.OwnPortSourcethat the filter takes in its constructor (the filter never depends onconfig; ArchUnit rules 3 and 6 stay without an exception), never from configuration, so a configured port 0 (ephemeral, used by every real-socket test) works.BindGuardchecks only the address (section 13, Bind). - Forwarded headers:
server.forward-headers-strategyis pinned tonone, noForwardedHeaderFilterbean and noserver.tomcat.remoteip.*property exist (a config test asserts all three), and the filter refuses (403) any request carryingForwardedorX-Forwarded-Host, so a proxy-style header can never substitute the evaluated value. - Absolute-form request targets. For HTTP/1.1 Tomcat itself compares the target's authority with the Host header (case-insensitively) and answers 400
inconsistentHostson a mismatch, so a foreign target with an accepted Host never reaches the filter. For HTTP/1.0 it generates the Host header from the target's authority, so the raw header the filter reads is the target's authority and a foreign target is refused 403.
Tomcat reconciliation, read from source (Boot 4.1.1 pins Tomcat 11.0.24; source read on the 11.0.x branch, to be confirmed against the 11.0.24 jar by the WP02 spike). Http11Processor.prepareRequest answers 400 itself, before any servlet filter, for: more than one Host header (multipleHosts), HTTP/1.1 without Host (noHostHeader), and an HTTP/1.1 absolute-form target whose authority differs from Host (inconsistentHosts). AbstractProcessor.parseHost then validates the value with Host.parse; any character outside host syntax (including @), or a non-numeric port, makes it set status 400 and close. Everything that parses reaches the filter: a domain name that merely looks foreign, an IPv4 literal, a bracketed IPv6 literal ([::1]:<port> parses), a trailing dot, an empty value, and HTTP/1.0 without Host (no Host header is created for a relative target).
AC-9b: two sets, FIXED here (operator decision E-1, 2026-10-10). 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. Both are refused before any read. There is no custom container valve: no ErrorReportValve and no other Tomcat customisation re-renders the container's 400. The values come from the Tomcat source above, not from an observation. The WP02 spike runs the table against the pinned jar and CONFIRMS it; it never generates it. The spike harness is THROWAWAY and UNCOMMITTED: a scratch Gradle project outside the repository tree (in the session scratchpad) that boots the pinned Tomcat 11.0.24 with a counting servlet filter whose only job is to record "filter entered" per row; none of it enters the branch. Its observations are written as text into the research.md spike record.
| # | Host value (AC-9b) | Expected status | Decided by |
|---|---|---|---|
| 1 | evil.example | 403 | filter |
| 2 | evil.example:<port> | 403 | filter |
| 3 | 127.0.0.1 (no port) | 403 | filter |
| 4 | 127.0.0.1:<port+1> | 403 | filter |
| 5 | no Host header (HTTP/1.0, raw socket) | 403 | filter (raw header absent) |
| 6 | 127.0.0.1.evil.example:<port> | 403 | filter |
| 7 | localhost.evil.example:<port> | 403 | filter |
| 8 | [email protected]:<port> | 400 | container (Host.parse rejects @; never reaches the filter) |
| 9 | [::1]:<port> (sent over IPv4) | 403 | filter |
The 400 set is closed. The container-rejected values are exactly the ones the spec's AC-9b lists in set 2: [email protected]:<port> (row 8) and the three container-decided rows of the E-7 table below (duplicate Host, HTTP/1.1 without Host, HTTP/1.1 absolute-form foreign target with an accepted Host, added to AC-9b by the post-analyze amendment); every other value of the table is in the 403 set, and an addition to either set is a spec amendment. Each row hard-codes its literal expected status and its literal reachesFilter flag (false for row 8 and for the three container rows below, true for the others). Under both sets the assertions are the same: no Mission read (zero recorded reads through the instrumented port), no event stream opened, and no canary, path or HOME in the body (AC-9g scan, applied to the container-generated 400 body too). Phase 4b hard-codes these literals; E-1 no longer blocks it (section 14).
Oracle rule (no circularity). The expected status of every row is a literal in the test source. The test never derives it from the response it just received. Each row also carries a literal reachesFilter flag, asserted through a test-profile counter incremented at filter entry, so "HTTP/1.0 without Host reaches the filter" is proved, not assumed. A Tomcat upgrade or a Boot property that flips a row fails the test; it does not rewrite the oracle.
Further rows (AC-9b now totals twelve rows: the nine-row table above plus the three container rows below, i.e. eight 403 rows in set 1 and four 400 rows in set 2; the other rows below go beyond AC-9b) (section 14, E-7; additive, they replace nothing and keep AC-9b's rows intact). The three rows decided by the container (duplicate Host, HTTP/1.1 without Host, HTTP/1.1 absolute-form foreign target with an accepted Host) are NOT provisional: the operator amended the specification after analyze (2026-10-10, reviews/analyze.ruling.md, ANL-005) so that AC-9b's closed 400 list (set 2) contains them; they land with phase 4b like every AC-9b row. The remaining rows, decided by the filter, stay provisional: behaviour and test land together with phase 4b, and an E-7 ruling that declines one removes its behaviour and its test together. Each row is run on the same five request targets:
| Case | Expected status | Decided by |
|---|---|---|
duplicate Host header | 400 | container |
HTTP/1.1 without Host | 400 | container |
HTTP/1.1 absolute-form foreign target with accepted Host | 400 | container (inconsistentHosts) |
| HTTP/1.0 absolute-form foreign target | 403 | filter (header generated from the target) |
empty Host: value | 403 | filter |
accepted Host plus X-Forwarded-Host: evil.example | 403 | filter |
accepted Host plus Forwarded: host=evil.example | 403 | filter |
LOCALHOST:<port> | 403 (interim: exact match; flips to 200 only if E-7 rules case-insensitive) | filter |
localhost.:<port> | 403 | filter |
| configured port 0, accepted forms with the bound port | 200 | control (own port from the bound server) |
Status constants. One constant, HostOriginRefusal.STATUS (403), covers every Host and Origin policy refusal the filter itself makes. It does not govern the container's own 400 for a malformed Host (the 400 set above) or the other refusals: those keep the statuses the contract fixes (400 invalid_page_cursor; the stream 400, 409 and 404 rows; 500 for the whole-read rows), listed in contracts/service-contract-notes.md. Any lint or ArchUnit rule about the constant is scoped to the filter class.
9. Campsite-clean scope
A distinct, behaviour-preserving FIRST commit, folding only domain-matched debt in surfaces this mission must edit:
1. tests/ci/test_contracts_workflows.py::test_the_contract_tools_filter_group_names_the_paths_the_contracts_workflow_triggers_on_beyond_contracts hard-codes "group == contracts.yml trigger list (+ src import spellings)". Introduce a named, documented, currently-empty constant ROUTER_ONLY_EXTRA_GLOBS (with a one-line rationale per entry required by the test itself) that the assertion adds to its expectation, plus the non-vacuity check that it is a tuple of strings. Behaviour is identical (empty tuple); the functional commit later adds the goldens glob as its first entry. This is tidy-first for a surface the mission must edit anyway (Reflexivity item 2).
Survey (evidence of looking, 2026-10-10). scripts/ci/sonarcloud_branch_review.sh issues --file <path> (token-free, read-only) reached SonarCloud and reported total: 0 open issues for both existing Python files this mission edits, tests/ci/test_contracts_workflows.py and tests/architectural/test_ci_corpus_trigger_completeness.py. The new Python files have no findings by construction and are held to ruff check and ruff format --check. WP01 re-runs the helper on the two files before the campsite commit and records the output in the commit message; if the service is unreachable then, that is recorded instead.
Candidates examined and NOT folded, each with its reason:
tests/ci/test_contracts_workflows.py(1,500 lines, about 70 tests): declined. The mission edits one test function and adds one constant in it; the size is not touched by the change, no Sonar finding exists, and a split would not be domain-matched debt (it would grow the file set, which the change-scope reconciliation order forbids).tests/architectural/test_ci_corpus_trigger_completeness.py(466 lines): declined. One registry entry is added; no finding, no function near the complexity ceiling in the edited area..github/workflows/ci-router.yml(1,157 lines): declined. One filter-group line; a YAML file has no Sonar finding class here and decomposing a router is out of scope.contracts/tools/gradle_pin_check.py(untouched),tests/architectural/test_module_shard_registry.pyceiling test (correct as is),ci-gate-mechanics.md(edited functionally, not cleaned).
The first commit stays behaviour-preserving with a green targeted run (tests/ci/test_contracts_workflows.py) before the functional commit; the empty-constant rationale goes in its commit message. Not a grab-bag: exactly one item.
10. Tracer files
Seeded with real entries and committed with this plan at the location and in the format the procedure mission-tracer-files (packs/built-in/procedures/mission-tracer-files.procedure.yaml) prescribes: kitty-specs/<SLUG>/traces/tooling-friction.md, traces/approach.md, traces/design-decisions.md, each entry - [YYYY-MM-DD][phase] Short symptom. 1-3 sentences; disposition. The kitty-specs/*/traces/.md merge=spec-kitty-traces rule in .gitattributes covers exactly this path. The first plan's root-level tracer-*.md files (an older convention) were removed in review round 1. Appended during implementation; assessed at close (retrospective input, unresolved friction filed in the tracker, files kept in the compacted history).
11. Phasing, work-package seams, traceability, tests
Phasing (risk front-loaded; every phase leaves the branch green)
| Phase | Content | Write scope (disjointness) | Exit gate |
|---|---|---|---|
| 0 | Campsite commit (section 9) | tests/ci/test_contracts_workflows.py | targeted tests/ci green |
| 1 (WP02 spike + scaffold, decision record) | Gradle 9.7.1 wrapper + hash, gradle.properties (toolchain policy), libs.versions.toml, strict verification-metadata.xml (Linux write, three verify-only legs), JDK 25 / GraalVM build, the COMPLETE build.gradle.kts (generated /project + /missions models, the bundleContract task that bundles the committed contract document into the main resources at build time (section 13), bootJar pinned to mission-status-api.jar, the JaCoCo verification rule and the jacocoScopeCheck task with check depending on both, the JUnit/ArchUnit/networknt test dependencies, the dedicated native-tagged test source set with three skeleton @Tag("native") tests so that nativeTagFloor is 3 from the first native leg, the nativeSmoke task registration pointing at a skeleton nativesmoke.NativeSmokeTest), the packaged src/main/resources/application.properties carrying every default of section 13 (server.address=127.0.0.1, server.port=${mission-status.port:8787}, server.forward-headers-strategy=none, mission-status.stream.queue-capacity) and META-INF/native-image/** hints, a skeleton application class that reads and validates mission-status.project-root through adapter.out.filesystem.ProjectRootCheck (created here, so the directory check is not a file API call in config), one hello controller and a skeleton contract-document route (package adapter.in.http.skeleton, deleted by 4a) with its test in the skeleton test package that the served bytes equal the bundled document (deleted together with the skeleton route by 4a, which replaces it with the real AC-11d test against ContractDocumentController under adapter/in/http), scripts/ci/mission_status_gate_checks.py with its native-tag-floor subcommand and tests/ci/test_mission_status_gate_checks.py, nativeCompile of that skeleton on the 4 targets in the NEW workflow (created here: it is the single slot), the spike items of research.md R-2/R-7 (Jackson major, required-nullable and member-order emission, canonical bytes, Tomcat table CONFIRMATION in a throwaway, uncommitted harness (see section 8), jdk.ProcessStart JFR event, SseEmitter under native, JaCoCo on Java 25, networknt unevaluatedProperties, plugin 7.25.0 on Gradle 9.7.1, the contract-bundling mechanism: candidate the committed contract tooling, contracts/tools/bundle.py --bundle-only, accepted only if the served bytes equal that bundler's output for the same commit and it runs on all four legs), CODEOWNERS, pins/wrapper/catalog/toolchain/JaCoCo-wiring tests | services/mission-status-api/{build.gradle.kts,settings.gradle.kts,gradle.properties,gradle/*,gradlew,.gitignore}, services/mission-status-api/src/main/resources/, services/mission-status-api/src/main/java//config/MissionStatusApplication, services/mission-status-api/src/main/java//adapter/in/http/skeleton/, services/mission-status-api/src/main/java//adapter/out/filesystem/ProjectRootCheck, services/mission-status-api/src/test/java//{skeleton,nativesmoke}/ (the nativesmoke skeleton only), the native test source set directory (pinned at WP01) with its three skeleton tests, scripts/ci/mission_status_gate_checks.py and tests/ci/test_mission_status_gate_checks.py (serialised: phase 5 extends both), .github/workflows/mission-status-api.yml, .github/CODEOWNERS, tests/ci/test_mission_status_api_build.py (serialised chokepoint, below) | decision record in research.md: generated-models vs hand DTOs (C-007) with the go/no-go rule of R-2, versions pinned, the quickstart command run once on the skeleton jar (research.md spike record); all four native legs green on a hello route, with nativeTagFloor (3) met by the skeleton native-tagged tests |
| 2a (Java core) | domain + application: reducer, derivations, cursors, paging, MissionStatusQuery (including subscribe), MissionEventSource, MissionStatusService, ChangeFeed; adapter.out.filesystem.codec.LedgerLineDecoder (a sub-package of the one outbound adapter; JSON-capable, no file API) with its tests (duplicate keys, number forms, non-UTF-8, L3/L4 line classification); ArchUnit rules; the in-memory source (a testsupport class constructed from RAW ledger lines, section 13) over hand-built fixtures; portlevel-tests.txt (the complete FR-013 id list is written here); the JaCoCo class-count floor file jacoco-class-floor.properties that phase 1's jacocoScopeCheck reads (the rule and the task are phase 1's); the projection component domain.projection (strict and human field rules, ProjectionError, the leak-class value built from patterns handed to its constructor; section 13, Projection) with unit tests on inline patterns; domain unit tests | services/.../src/main/java//{domain,application,adapter/out/filesystem/codec}/; services/.../src/test/java//{domain,application,adapter/out/filesystem/codec,conformance/port,testsupport,architecture}/; services/.../src/test/resources/portlevel-tests.txt; services/.../src/test/resources/jacoco-class-floor.properties | JVM check green with the NON-GOLDEN members of portlevel-tests.txt (AC-2b, AC-5a, AC-5c, the port legs of AC-3g and AC-3i, the port legs of AC-4a for L1, L3a, L3b, L4, L4b) running against the in-memory source over hand-built fixtures. The AC-2a members (five fixture classes, golden equality) need 2b's fixtures and goldens and are NOT run at this gate; a named constant INTERIM_GOLDEN_DEPENDENT_IDS in testsupport lists exactly them, the interim id-set check is "executed ids = file ids minus that constant", a guard test asserts that every member of the constant is an AC-2a id of portlevel-tests.txt (so a wrongly added id cannot go silently unexecuted; revert: add a non-AC-2a id to the constant and the guard is red), and the FR-013 floor and the full AC-13a equality are NOT claimed here; they are first met at the phase 3 exit gate, where phase 3 deletes the constant |
| 2b (Python oracle) | generator (with the per-fixture temporary git provisioning and the project-header HEAD materialisation of section 1), src/main/resources/leak-patterns.json (the one main-resource file this phase writes), goldens including the projection class, manifest.json, resolver-cases.json, golden-honesty test, the deterministic-provisioning and registered-worktree tests, _CORPUS_MARKED_MODULES entry, router contract_tools glob, the functional edit of ROUTER_ONLY_EXTRA_GLOBS (phase 0 only introduced the empty constant), Packs deselect, routing and Packs-pin test, one scoped .gitattributes line for the conformance tree | tests/contract/_mission_status_conformance.py, tests/contract/test_mission_status_conformance.py, services/.../src/test/resources/conformance/**, services/.../src/main/resources/leak-patterns.json, .github/workflows/ci-router.yml, .github/workflows/packs.yml, tests/ci/test_mission_status_routing.py, tests/ci/test_contracts_workflows.py, tests/architectural/test_ci_corpus_trigger_completeness.py, .gitattributes | Python golden test green; AC-8a/8b/8d/8e/8f; the Packs deselect pin and the contract-tools group test green; 2b reads no Java output and no portlevel-tests.txt |
| 3 | adapter.out.filesystem (resolver, symlink/containment, allowlist, instrumented port, listing, fault injection; everything except the 2a codec/ sub-package), adapter suites; the Java tests that read 2b's data: golden suite, SupportedSchemaRange-versus-manifest equality, resolver-cases.json consumption (AC-6e), the project-header materialisation step in the Java fixture factory (section 1), the README stub that carries the allowlist fenced list so that AC-10b (the allowlist constant equals the README fence) runs here (README.md is created in this phase and extended by phase 5), and the port-level subset's second leg: testsupport.FilesystemFixtureFactory (added here; it is the only new class that imports the real adapter) plus the launcher test conformance.golden.PortSubsetSecondLegTest, which runs the conformance.port classes through that factory (so rule 11 stays true: nothing in ..conformance.port.. imports an adapter and the launcher imports only testsupport), and the full AC-13a check (executed ids equal the file's ids, FR-013 floor) with INTERIM_GOLDEN_DEPENDENT_IDS deleted and a reflection test asserting that the constant no longer exists | .../src/main/java//adapter/out/filesystem/ except codec/* (ProjectRootCheck was created in phase 1), services/mission-status-api/README.md (stub with the allowlist fenced list, created here), .../src/test/java//{adapter/out/filesystem,conformance/golden}/, .../src/test/java//testsupport/ (extends the 2a package; serialised after 2a, which is already merged by then) | needs both 2a and 2b merged; goldens pass against the real adapter; the AC-2a members now run on both sources; the full portlevel-tests.txt id set is executed on both sources and equals the file; the FR-013 floor is met |
| 4a (routes) | adapter.in.http without the filter (generated-interface controllers, SSE controller, ContractDocumentController, Problem mapping; the phase 1 skeleton package and its tests are deleted), config without OwnPortHolder (ServiceWiring, the one class that constructs FilesystemMissionEventSource, ProjectionCache and MissionStatusService and reads leak-patterns.json from the classpath into the leak-class value; bind guard, mission-status.project-root and mission-status.port properties, virtual threads, logging diagnostics, feed executor), contract validation (AC-11), bind guard tests (AC-9a, AC-9e), virtual-thread probe (AC-9h), the three @Tag("native") tests that replace the phase 1 skeletons (AC-9h probe, Jackson and SSE round trip, resource check), the contract-document tests (AC-11d: /api/v1/openapi.yaml serves the committed contract and the served bytes equal the bundled document), which live under src/test/java//adapter/in/http/, the accepted-Host 404 assertions for /v3/api-docs and /swagger-ui (C-004, no springdoc), canary scan (AC-9g), the stream suites (AC-3, AC-4 HTTP legs, overflow); edits to the phase 1 application.properties and native hints (4a is the only later writer, serialised after phase 1) | .../src/main/java//adapter/in/http/ except HostOriginFilter and OwnPortSource, .../src/main/java//config/ except OwnPortHolder, .../src/main/resources/ (serialised after phase 1), .../src/test/java//{adapter/in/http,config}/ except the filter tests, .../src/test/java//skeleton/* (deletion only) and the native test source set, .../src/test/resources/application-test.properties, tests/ci/test_mission_status_api_build.py (serialised chokepoint) | JVM check green incl. AC-3, AC-9a, AC-9e, AC-9g, AC-9h, AC-11 and the accepted-Host 404 rows. The real-socket, canary and wiring tests, and so this gate, follow phase 3 (see Parallelism). Tests run with no filter registered and send an accepted-form Host (127.0.0.1:<bound port>), so they stay valid unchanged when 4b lands; AC-9b/9c/9d are NOT in this gate |
| 4b (Host/Origin filter) | HostOriginFilter, OwnPortSource, config.OwnPortHolder, AC-9b/9c/9d with the two sets of E-1 (403 for the filter-visible values, the container's 400 for the closed 400 list) and the E-7 rows (the three container 400 rows are part of AC-9b set 2; the filter-decided rows stay provisional), and the foreign-Host 403 variants of the /v3/api-docs, /swagger-ui and /api/v1/openapi.yaml requests (the accepted-Host assertions are 4a's) | .../adapter/in/http/HostOriginFilter, .../adapter/in/http/OwnPortSource, .../config/OwnPortHolder* and their tests | JVM check green incl. AC-9b/9c/9d |
| 5 | native matrix complete (smoke replay, per-leg digest file, digest comparison in the gate job), nightly job + mission_status_corpus_compare.py, README (allowlist fenced list, terms list), docs + changelog. The tag-triggered release job (section 12, operator decision E-4) and the digests subcommand of scripts/ci/mission_status_gate_checks.py. Dependencies. Phase 5 follows phases 3 and 4a (the smoke binary serves golden-backed requests from a fixture tree, which needs the real adapter and config.ServiceWiring) and needs 4b only for stage hostOrigin. The smoke request set (src/test/resources/nativesmoke/requests.json, each request with its literal expected status and Problem code, plus per-stage requestCountFloor values) is staged: stage base (golden-backed requests, the pageCursor and five stream-cursor refusals, the unknown-Mission 404, the L3a, L3b and L4b 500s, the SSE round trip) needs 4a (and so phase 3) only; stage hostOrigin (the Host and Origin refusals of AC-15b) is added when 4b lands. Phase 5 may therefore start after phases 3 and 4a with stage base; AC-15b and AC-15e are NOT met for the mission until stage hostOrigin is added, and the mission is not accepted on them without it | workflow file (serialised after phase 1), tests/ci/test_mission_status_api_build.py (serialised chokepoint: nightly, gate-if: and digest pins), .../src/test/java//nativesmoke/, .../src/test/resources/nativesmoke/**, scripts/ci/mission_status_corpus_compare.py, tests/ci/test_mission_status_corpus_compare.py, scripts/ci/mission_status_gate_checks.py and tests/ci/test_mission_status_gate_checks.py (extending phase 1), services/mission-status-api/README.md (extending the phase 3 stub), docs/development/reference/ci-gate-mechanics.md, docs/changelog/CHANGELOG.md | all four legs + gate green on the PR with stage base, the release job's structure pinned (this mission pushes no tag); one workflow_dispatch recorded for the nightly job (AC-8c); after 4b, stage hostOrigin added and the four legs and gate green again |
Write-scope completeness. Every path of the section 13 layout tree is owned by exactly one phase above (or is deleted by a named phase: the skeleton main and test packages are created by phase 1 and deleted by 4a), or is a named chokepoint: build.gradle.kts, gradle.properties, settings.gradle.kts, .gitignore, the wrapper and gradle/ are phase 1 alone (no later phase edits the build script; a later need is a reviewed phase 1 amendment, not a parallel edit); src/main/resources/ is phase 1 then 4a (serialised); README.md is created by phase 3 (stub with the allowlist fenced list) and extended by phase 5 (serialised); portlevel-tests.txt and jacoco-class-floor.properties are 2a; conformance/** is 2b, and so is the single main-resource file leak-patterns.json (the one file of src/main/resources that is neither phase 1 nor 4a); adapter.out.filesystem.ProjectRootCheck is created by phase 1 and then belongs to phase 3; config.ServiceWiring is 4a; testsupport is 2a then 3 (serialised); the adapter.out.filesystem tree is 3 except codec/, which is 2a. The tasks stage assigns every path of that tree to one WP; a path no WP owns is a tasks-analysis finding.
Parallelism and the honest dependency of 2a on 2b. 2a and 2b are write-disjoint (2a owns the Java domain, application, codec, test-support and architecture trees, portlevel-tests.txt and the class-count floor file; 2b owns the Python, CI and conformance-resource files) and run in parallel after phase 1, but their exit gates are independent ONLY for the non-golden members of the port-level subset. The AC-2a members of portlevel-tests.txt compare against goldens and fixture trees that 2b writes, so they are exercised once 2b has merged, in phase 3; FR-013's id floor and AC-13a's executed-ids-equal-file check are therefore first satisfied at the phase 3 exit gate and are explicitly not claimed at 2a's (the interim check and its constant are described in the 2a row). Phase 3 needs both merged. Phase 4a's code may start after 2a against the in-memory source, but 4a's real-socket, canary and wiring tests, and so its exit gate, follow phase 3: they need FilesystemMissionEventSource, and config.ServiceWiring (owned by 4a) is the class that wires it into MissionStatusService; the canary scan in particular means nothing against the in-memory source. Phase 5 follows phases 3 and 4a and needs 4b only for stage hostOrigin of its request set. Chokepoints (serialise): mission-status-api.yml (created in phase 1, appended by phase 5; every WP touching it rebases), tests/ci/test_mission_status_api_build.py (phases 1, 4a and 5; each adds its own pins and rebases), scripts/ci/mission_status_gate_checks.py with its test (phases 1 and 5), services/mission-status-api/README.md (phases 3 and 5), src/main/resources/application.properties (phases 1, 4a), gradle/libs.versions.toml and verification-metadata.xml (regeneration churn: dependency additions land in phase 1, later additions regenerate and are reviewed by command, not by line), ci-router.yml (one line, phase 2b), packs.yml (one token, phase 2b), the single workflow-count slot (used once, phase 1). The Host/Origin filter work package (phase 4b) is no longer blocked: the operator ruled E-1 on 2026-10-10 (section 14) and 4b hard-codes the two sets of section 8.
One PR, readability. Default stands (one PR per mission, ordered commit series by phase). Honest size: hand-written Java will likely exceed what is readable in one sitting once tests are counted; generated/lock files (wrapper jar, verification-metadata.xml, goldens) must be reviewed by regeneration command, not by line. Recommendation to the orchestrator at the tasks stage: keep one PR, but pre-agree a split seam after phase 4 (native matrix + nightly + publish + docs as a second stacked PR) if the hand-written diff passes about 4,000 lines; each half is independently green (phase 1-4 has JVM tests and a native hello-route leg).
Implementation concerns (not work packages)
- IC-01 Spike and scaffold (phase 1): FR-014, FR-015 (skeleton), C-001, C-002, C-007, C-008, research.md R-3, R-4, R-5, R-7.
- IC-02 Java domain and application core (2a): FR-005, FR-007, FR-012, FR-013, NFR-006, and the AC-9g projection (
domain.projection). - IC-03 Python oracle and conformance data (2b): FR-008, FR-006 table, AC-1f, AC-5d.
- IC-04 Filesystem adapter (3): FR-004, FR-006, FR-010, FR-016.
- IC-05 HTTP adapter and SSE (4a): FR-001, FR-002, FR-003, FR-009 (bind guard), FR-011; Host/Origin filter (4b): FR-009; the contract document route (AC-11d).
- IC-06 Native matrix, nightly, release job (E-4), docs (5): FR-015, FR-008 (iv), NFR-002, NFR-004, NFR-005.
Requirements-to-plan traceability
| Spec element | Covered by (plan element) |
|---|---|
| FR-001, AC-1a-f | IC-05 ProjectController + IC-04 project-header inputs; SupportedSchemaRange in IC-02; goldens class project-header; manifest coupling IC-03 |
| FR-002, AC-2a-c | IC-02 paging/PageCursor (domain), IC-05 MissionsController; AC-2c adapter suite |
| FR-003, AC-3a-o | IC-02 MissionStatusQuery.subscribe, package-private ChangeFeed and cursor, IC-05 SSE controller; stream table S1-S6 is in spec.md (after FR-004), and data-model.md "Stream state" maps its connect and open rows |
| FR-004, AC-4a-d | IC-02 typed MissionStatusUnavailable + diagnostics; IC-04 faults; IC-05 Problem mapping and diagnostics logging |
| FR-005, AC-5a-d | IC-02 StatusReducer (ordering injected); IC-03 manifest specKittyEventsVersion |
| FR-006, AC-6a-f | IC-04 ReadDirResolver; resolver-cases.json (IC-03) consumed by both languages |
| FR-007, AC-7a-d | IC-02 ProjectionCache (key = the opaque LedgerStamp: size + mtime + file key + the tail digest that yields the stream cursor; section 13) |
| FR-008, AC-8a-f | IC-03 generator/goldens/test; Java golden suite (IC-02/IC-04); mission_status_corpus_compare.py + nightly job (IC-06); router glob + registry entry |
| FR-009, AC-9a-h | IC-05 bind guard in config and AC-9a/9e/9g/9h (4a); HostOriginFilter and AC-9b/9c/9d (4b); IC-02 domain.projection for the AC-9g redaction; Q-5 section 8; virtual-thread probe |
| FR-010, AC-10a-e | IC-04 instrumented read port, allowlist constant + README fenced list; ArchUnit file-API rule |
| FR-011, AC-11a-c | IC-05 contract validator test; generated-vs-DTO mode assertion (IC-01 decision); contract document route and its served-bytes test (AC-11d) |
| FR-012, AC-12a-b | IC-02 ArchUnit rule set of section 13, one positive control per rule, sanity counts |
| FR-013, AC-13a-b | ..conformance.port.. + ..testsupport.. packages, portlevel-tests.txt |
| FR-014, AC-14a-g | IC-01 wrapper, catalog, verification metadata, tests/ci/test_mission_status_api_build.py, tamper test (a committed JUnit class build.VerificationTamperTest in the service tree, WP03, run by ./gradlew check on the jvm job; operator ruling after analyze, ANL-002) |
| FR-015, AC-15a-f | IC-01/IC-06 workflow, native matrix, smoke, digest, aggregate; tests/ci/test_mission_status_api_build.py |
| FR-016, AC-16a-d | IC-04 recording read port, fingerprint test |
| NFR-001, NFR-002, NFR-004 | IC-06 ratio tests (floor = no-op route / trivial native binary) |
| NFR-003 | AC-9g canary scan, AC-9a bind, AC-4c path-free diagnostics |
| NFR-005 | AC-8d generator determinism, AC-15b/15e digests, listing sorted in code |
| NFR-006 | JaCoCo rule, task wiring and pins, section 5; revert-fails row in section 11. The branch floor (>= 80% on domain) is a plan addition beyond NFR-006's line coverage (E-6) |
| NFR-007 | catalog test AC-14g + strict metadata |
| NFR-008 | README/docs prose, terminology guard |
| NFR-009 | fixtures and Java files avoid Windows-illegal characters; test_no_invalid_windows_filenames.py |
| C-001..C-013 | C-001/C-002/C-007/C-008 IC-01; C-003 IC-02 ArchUnit; C-004 routes + the contract document route, no springdoc (IC-05, E-11); C-005 FR-009/FR-016; C-006 IC-03; C-009 packaging test (tests/ci/test_mission_status_api_build.py); C-010 leak scan of new files (existing username-leak policy) and placeholders; C-011 charter untouched; C-012 no contracts edit (AC-14d); C-013 .venv/bin/spec-kitty only |
| SC-001..SC-008 | SC-001 IC-03 + Java suite; SC-002 nightly; SC-003 AC-11; SC-004 AC-15b/e; SC-005 AC-9; SC-006 AC-10/16; SC-007 AC-8e + AC-15a; SC-008 Reflexivity checks |
| PR-1..PR-16 (plan risks; not research.md R-n) | risks table below |
Test strategy (layers, red-first, revert-fails)
Java layers: domain unit (reducer, derivations, cursor, paging; no Spring, AC-5c), ArchUnit (AC-12, AC-13b, AC-10c/10e rules, with non-empty scan checks), adapter (filesystem faults, HTTP framing, Host/Origin, SSE), contract validation (networknt, AC-11a/b/c), goldens (port-level subset against both sources, adapter suite against the real adapter), native smoke (per leg, byte equality plus refusal codes plus an SSE round trip, AC-15b/e).
| AC group | Red-first / revert-fails test |
|---|---|
| AC-1, AC-2 | change the lifecycleStatus rule or the (createdAt desc, missionId asc) comparator: golden/paging test turns red |
| AC-3 | drop the id: line, forward annotation rows, or change heartbeat text: AC-3a/3b/3f go red; remove the shrink check: AC-3c red. Idle stream: hold one stream open with no appends past 35 s (provisional, ADR 2026-10-05-2) and require it still open and a heartbeat received; revert: build the emitter with the container default timeout and the test goes red. Disconnect: close the client socket and require the subscriber to be unsubscribed from the feed after the next heartbeat write (a count of feed subscribers through the test seam). Overflow: with mission-status.stream.queue-capacity 2 and a subscriber that never calls next, three appends close that subscriber with Closed(slow_consumer) (no diagnostic, no other subscriber affected, no signal dropped or reordered before the close); revert: make the queue unbounded or drop the oldest signal and the test goes red |
| AC-4 | return 200 for L3/L4/L4b: AC-4a/4b red; drop a diagnostic: AC-4c red; remove the whole-prefix read: AC-4d free-running part red. Connect time (S1/S2): subscribe throws StreamRefused (S1) or MissionStatusUnavailable (S2) and exactly one diagnostic line is written, by MissionStatusService, not again by ChangeFeed; revert: also emit from the feed and the line count is 2, red. AC-4c with N subscribers: open 100 unscoped streams, remove one Mission, require exactly ONE mission_vanished line (and one per S6 transition into failure); revert: log from the per-subscriber path and the line count becomes 100, red |
| AC-5 | the AC-8a perturbation (below) is the revert-fails test for the ordering; derivation perturbations are unit tests (lifecycleStatus, progress, lastActivityAt tie rule) |
| AC-6 | read the husk ledger: AC-6d red; ignore stored topology: AC-6e red. AC-6e both languages: remove one row of resolver-cases.json from ONE language's input in a temp copy and the sibling suite goes red (the case-id set must equal manifest.json resolverCaseIds on both sides) |
| AC-7 | disable invalidation: AC-7b/7c red; reducer counter proves hits (AC-7a). Warm request: two /missions calls with no appends perform ZERO readLedger calls and one stampLedger per Mission on the second, asserted through the instrumented port; revert: derive the key inside readLedger and the warm call reads every ledger, red |
| AC-8 | (a) the AC-8a perturbation (ordering by at only; mutate one member in the comparator test) is the revert-fails test for the Java side and the comparator; the Python side by editing one golden value in a temp copy and by monkeypatching one derivation. (b) AC-8b non-vacuity: replace one golden with a trivial document carrying the right class tag and the honesty test goes red because the class's distinguishing property is read from content. (c) AC-8c: the script on a planted difference exits non-zero printing slug and field; plus the nightly-contract cases of section 7 (empty corpus, below floor, unreachable service, one-sided Mission, a service 500 and an oracle refusal each naming their side, a difference of the Q-3 shape still fails, the report cannot flip the exit). (d) AC-8d: run the generator twice with a planted unordered dict (an unsorted-keys fixture) and the byte-identity test goes red. (e) AC-8e: delete the conformance glob from a temp copy of ci-router.yml and tests/ci/test_mission_status_routing.py goes red. (f) AC-8f: drop the _CORPUS_MARKED_MODULES entry and the registry test goes red. (g) Packs deselect (the "AC-8 (g)" row; the spec defines AC-8a to AC-8f only): remove the --deselect token from a temp copy of packs.yml and the routing test goes red. (h) Oracle git provisioning (section 1): generate twice into differently named temp roots under different fixed dates and require byte-identical goldens; planted controls: skip the git worktree add for one coord fixture, or register the Mission directory instead of the worktree root, and the registration check goes red; change one project-header fixture's HEAD text in a scratch copy so it disagrees with the branch the oracle read and the honesty assertion goes red |
| AC-9 | AC-9a (phase 4a, not blocked): a table of bind values, each exiting non-zero within the bound and naming the address: configured 0.0.0.0, ::, a LAN address, a hostname, a blank server.address, and the property ABSENT (the packaged default is removed through the test override so the guard sees nothing; Boot would otherwise bind every interface); with the shipped defaults the bound address read back from the started Tomcat connector equals 127.0.0.1 and a connect to a non-loopback local address is refused; revert: make the guard treat an unset value as a pass and the unset row is red. AC-9b/9c/9d as written in the spec (eight Host rows answered 403 (set 1), four rows answered 400 by the container (set 2: row 8 plus the three container rows), five Origin rows, controls), phase 4b; per-row literal expected status and reachesFilter flag (section 8); the E-7 provisional rows (forwarded headers refused, exact case, trailing dot, empty Host, port 0) land with 4b and flip or go with the E-7 ruling; widen the allowed Host set or move the filter behind MVC: AC-9b/9c red; accept [::1]: AC-9b red. No springdoc (E-11): /v3/api-docs, /v3/api-docs.yaml, /swagger-ui.html and /swagger-ui/index.html answer 404 after the filter with an accepted Host (phase 4a) and 403 with a foreign Host (phase 4b); revert: register a handler that answers 200 on /v3/api-docs in a test branch and the 404 row goes red |
| AC-9g (projection) | goldens of the projection class: a friendly name carrying a host path and an email (human field, [path] and [email] in the golden), a nullable strict field holding a path-shaped value (nulled), and a non-nullable strict field holding a token-shaped value (golden of the refusal; the Java answer is the 500 projection_refused Problem of rows L3a/L3b); revert: serve friendlyName unprojected and the class is red; remove one pattern from a scratch copy of leak-patterns.json and the manifest SHA-256 check and the class are red. Python versus Java regex: the manifest records, per pattern, probe strings with the Python verdicts, and one Java test per pattern requires the same verdicts from java.util.regex (a pattern that behaves differently is red) |
| AC-10, AC-16 | add a read of tasks/WP01.md, a coordination meta.json read, a write, a ProcessBuilder call: each positive control red |
| AC-11 | add an unknown property / mutate the temp contract copy: AC-11b/11c red. Contract document (AC-11d): the bytes served at /api/v1/openapi.yaml equal the bundle the contract tooling writes for the same commit; revert: hand-copy the document into resources (a stale copy) or change one byte, and the test is red; a 404 on the route, or a document generated from the controllers, is red. Jackson: a required-nullable member is emitted as null (not omitted) and member order equals the golden; revert: switch the mapper to omit nulls and the test is red |
| AC-12, AC-13 | the full rule set of section 13, ONE positive-control mutation per rule (a test-branch import that turns exactly that rule red); delete a rule and the rule-count guard (rules defined equals rules run) is red; import Spring in domain: AC-12a red; adapter import in conformance.port: AC-13b red |
| AC-14 | alter one checksum, one wrapper byte, a literal version: AC-14a/14b/14g red. Toolchain: add a foojay plugin or set auto-download=true and the toolchain pin test is red. springdoc: add any org.springdoc or swagger-ui dependency to any configuration of a temp copy of the build script and the dependency-absence test is red. C-009: add services/ to the sdist include list or packages in a temp copy of pyproject.toml and the packaging test is red |
| AC-15 | remove a path from the filter, drop the fork guard, add a pytest step: AC-15a, test_fork_guard, test_no_duplicate_suite_execution red. Gate if: (R-6): drop the != 'schedule' conjunct and AC-15a(iii) is red. AC-15b (native smoke): perturb one body byte in a copy of one golden, one expected status, and one expected Problem code in requests.json: each turns nativeSmoke red; planted-empty control: an empty or glob-less requests.json (zero requests) turns nativeSmoke red on its request-count floor. AC-15e (digests): run scripts/ci/mission_status_gate_checks.py digests (tested in tests/ci/test_mission_status_gate_checks.py) on four planted digest files with one sha256 altered (red, printing the four digests), with one file missing (red on "exactly four"), with a wrong os/arch name set (red), and with one requestCount below the recorded floor (red). Native-tagged tests: a tag filter that selects zero tests turns the leg red on the nativeTagFloor check (the native-tag-floor subcommand on a planted XML with fewer tests than the floor). Release job (E-4): the tag-pattern disjointness test (sample tags of the CLI v.., contract--v.. and mission-status-api-v.. namespaces each match exactly one pattern) and the job's if: / permissions / concurrency pins go red when altered |
| NFR-001/002/004 | ratio assertions; a miss is classified under ADR 2026-10-05-2, never retried |
| NFR-006 | raise the JaCoCo minimum above the measured value in a temp copy, or delete one domain test, and jacocoTestCoverageVerification (hence check) fails; the Gradle task jacocoScopeCheck (each include pattern matches at least the recorded class count) goes red when a pattern is emptied (scratch-copy run); the build pin test goes red when check stops depending on the task |
| NFR-005 | canonical-form unit test: one non-ASCII string and one fractional progress value serialised by the production mapper equal the Python-written golden bytes; revert: enable ASCII escaping or change double rendering and it is red |
| NFR-009 | plant a path containing : under a temp copy of the service tree and the build test's tracked-path check goes red |
AC-9b after E-1 (operator decision, 2026-10-10), and E-7. The spec's AC-9b is two tables: the filter-visible Host values answer 403 request_refused, and the closed list of container-rejected values ([email protected]:<port> and the three container-decided E-7 rows, added to AC-9b after analyze) answers the container's 400; both are refused before any read. Phase 4b hard-codes the literals of section 8 and schedules the E-7 rows with the filter (the three container 400 rows are fixed AC-9b rows; the filter-decided rows are provisional): behaviour and test land together, and a declined E-7 row is removed with its behaviour.
Discovered-count guards (defect class: iteration over a glob, fixture directory or list that silently matches nothing). Every test that iterates a discovered set asserts a named floor so that zero matches fails: the conformance fixture directories and goldens (one per fixture, per-class floor from manifest.json), the workflow files enumerated by the pinning tests, the tracked service paths in the NFR-009 check, the --deselect tests/contract/*.py entries in the Packs command (the 35 present today plus the new one; the check also requires every named module to exist), the ArchUnit rules and the classes each scans, the JaCoCo include patterns, the Missions the nightly script compares, and the ids of portlevel-tests.txt (FR-013 floor, enforced from the phase 3 exit gate, 2a's interim check being the one described in its row), the requests nativeSmoke replays (per-stage requestCountFloor in requests.json; zero replayed requests or zero compared bodies fails), the digest files the gate downloads (exactly four, with the expected os/arch names, each with a requestCount at least the recorded floor and all four equal), and the @Tag("native") tests nativeTest ran (at least the recorded nativeTagFloor, counted from the JUnit XML; WP01 records it, initially the three classes below). Each guard has a revert row above or a planted-empty control (an emptied directory or glob turns it red).
Native test partition (what nativeTest runs). nativeTest compiles a second native image of its test source set, and ArchUnit (class-file scanning), networknt (schema loading by reflection) and JaCoCo (JVM agent) cannot run in it. So the tests that must run in native mode form a small tagged subset, JUnit 5 @Tag("native"), held in a dedicated source set that the Native Build Tools test binary is configured to use (the exact wiring, or a tag filter on the standard test source set as fallback, is a WP01 decision recorded in research.md): the AC-9h virtual-thread probe, a Jackson and SSE round trip, and a resource-loading check (so nativeTagFloor starts at 3 and only rises; phase 1 writes the three as skeleton tests so the floor holds from the first native leg, and phase 4a replaces them with the real ones). The leg counts the executed tests from the nativeTest JUnit XML and fails when fewer than nativeTagFloor ran, so a tag filter or source set that selects nothing cannot pass with zero tests. ArchUnit, contract validation, goldens and JaCoCo stay on the JVM leg (check). The leg timeout budget states the doubled native compile (one for the binary, one for the native test image).
Revert-fails discipline. Each row above names a behaviour whose removal turns a test red; the rows for AC-8b to AC-8f, the AC-8 (g) and (h) rows, AC-12, AC-15b and AC-15e, NFR-005, NFR-006 and NFR-009 are run empirically in a scratch copy of the tree before the PR (never by editing the working tree under review) and the results go in the PR's "Tests run" section.
12. Native release matrix
| Target | Runner | Notes |
|---|---|---|
| Linux x64 | ubuntu-24.04 | native leg; the separate jvm job (also ubuntu-24.04, ./gradlew check: JaCoCo, tamper, contract validation, JFR) is not this leg (research.md R-6); both are needs of the gate |
| Linux arm64 | ubuntu-24.04-arm (named in the spec; runner label not verifiable offline) | |
| macOS arm64 | macos-15 (arm64 image; label an assumption) | ci-windows.yml uses windows-latest; this repo has no macOS job, so this is a first |
| Windows x64 | windows-2025 (assumption; windows-latest is the repo's existing label) | symlink fixtures are created at test time (never committed) and the job summary states if the runner cannot create one (AC-10d) |
Per native leg: setup GraalVM Community for JDK 25 (graalvm/setup-graalvm, SHA-pinned; it sets JAVA_HOME, which is the only JDK source on EVERY leg including the JVM leg, so JaCoCo, JFR and native share one JDK distribution), ./gradlew nativeCompile nativeTest (the tagged native subset only; section 11), then nativeSmoke (a Gradle task running the JUnit class nativesmoke.NativeSmokeTest, which starts the binary as <binary> --mission-status.project-root=<copy of the curated fixture tree> --mission-status.port=<free port the test picks>, replays nativesmoke/requests.json through the shared CanonicalJson comparison of section 13, and writes digest-<os>-<arch>.json = {os, arch, requestCount, bodyCount, sha256}, where the digest covers the concatenated bodies only, never the os or arch). It fails when it replays fewer requests than the recorded floor of the stages present, or compares zero bodies. The leg timeout budget states the doubled native compile. Artifacts: mission-status-api-<os>-<arch>[.exe] plus .sha256, uploaded as workflow artifacts (30 days) on every run, not published. The terminal gate job (mission-status-api-gate, FR-015's aggregate job, section 5) downloads the digest-* artifacts and fails unless there are exactly four, named for exactly linux-x64, linux-arm64, macos-arm64 and windows-x64, each with a requestCount equal to the others and at least the recorded floor, and all four sha256 values are equal (AC-15e; it prints the four digests on failure).
Release job (operator decision E-4, 2026-10-10: shipped in this mission; the maintainer cuts the first tag). FR-015 and AC-15a now carry the tag trigger, the job if:, the permissions, the concurrency and the disjointness requirement stated below, as the plan specified them:
- Trigger.
on.push.tags: ['mission-status-api-v..']in the SAMEpushblock asbranches: [main]and thepathsfilter. GitHub does not applypathsto a tag push (to be confirmed by the first tag-shaped dry run; Unverified). The block stays a per-change push fortests/architectural/_workflow_jobs.push_is_per_changebecause it also carries branch and path filters, so no workflow classification changes. The pattern is disjoint from the CLIv..(release.yml) and fromcontract--v..(contracts-release.yml): a tag name beginningmission-status-api-vmatches neither, and neither of those names can match this one. A test asserts disjointness over sample tags of all three namespaces. if:form. The canonical fork guard first (AC-15a(iii) shape), then the event conjunct:(github.repository == 'spec-kitty/spec-kitty' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/mission-status-api-v'). A fork's tag push skips; a manual run, a schedule run and a pull request never reach it, so a manual run never creates a release. The other jobs keep theif:forms of the jobs bullet of FR-015 unchanged; on a tag push the legs and the gate run (their conjunct is!= 'schedule') and the nightly job is skipped.- Needs and the "gate passed" check.
needs: [mission-status-api-gate]. The gate of the SAME tag-push run, which needs all four legs and issuccessonly when each is, proves the tagged commit built and smoke-tested on all four targets; this replaces a lookup of an earlier run and so needs noactions: read. A first step, withfetch-depth: 0, fails closed unlessgit merge-base --is-ancestor "$GITHUB_SHA" origin/main; a later step re-resolves the tag withgit ls-remoteand fails unless it still points at the built commit (ascontracts-release.ymldoes). - Permissions. Workflow level stays
contents: read(FR-015). Only the release job addscontents: write. No other job and no step before the release step holds a write token;persist-credentials: falseon checkout. - Concurrency. A workflow-level group that cannot cancel a release or a
mainpush 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 per-SHA shape ofcontracts.ymlalone does not distinguish tag runs, hence the tag branch of the expression. - Action.
gh release createwith the four binaries and their.sha256files,--verify-tag --target "$GITHUB_SHA", mirroringcontracts-release.yml; the version comes from the tag only, the build version stays0.0.0-dev.
The fork guard's pull_request and workflow_dispatch disjuncts are dead given the following event_name == 'push' conjunct; they are kept verbatim so the expression keeps the guard-first shape that AC-15a(iii) and tests/ci/test_fork_guard.py pin.
What this mission does and does not do: every run uploads the four binaries and checksums as workflow artifacts (30 days), which is the FR-015 matrix output; the release job runs only on a tag push of the release pattern, so it is exercised for the first time when the maintainer cuts the first tag, and its structure is pinned by tests meanwhile (section 11, AC-15 row). The mission pushes no tag and assigns no version (charter: no version numbers in scope). Explicitly NOT done: no PyPI publication, nothing in the wheel or sdist, no release.yml or contracts-release.yml edit (release.yml fires on v..* tags and workflow_dispatch with an existing CLI tag; neither overlaps), no Homebrew/winget/Docker, no signing/notarisation (macOS Gatekeeper and Windows SmartScreen will warn; recorded as a limitation), no auto-update, no port to the CLI.
Toolchain policy (no implicit JDK download). org.gradle.java.installations.auto-download=false in gradle.properties; no foojay resolver plugin in settings.gradle.kts; java { toolchain { languageVersion = JavaLanguageVersion.of(25) } } with the vendor left unconstrained; graalvmNative.toolchainDetection off so the native image comes from the JAVA_HOME that graalvm/setup-graalvm sets. This repository's other workflows use Temurin 21 (contracts.yml, contracts-release.yml); the service needs 25 and does not share that setup. tests/ci/test_mission_status_api_build.py pins the property, the absence of a resolver plugin, the toolchain version and that every job sets up the same GraalVM action.
Supply chain (DIRECTIVE_051 checklist, advisory, recorded in research.md R-4/R-5): wrapper distributionSha256Sum + gradle/actions/wrapper-validation (SHA-pinned) before any Gradle call; strict verification-metadata.xml (verify-metadata true, verify-signatures false as in contracts/, the same stated limit); version catalog only, no dynamic versions; actions pinned by SHA; GraalVM distribution pinned by version and checksum recorded from the vendor's release; --no-daemon, no build cache sharing across PRs; read-only token (permissions: contents: read), persist-credentials: false.
Versions pinned and how verified (2026-10-10, from public registries): Spring Boot 4.1.1 (latest stable of the 4.x line in Maven Central metadata; 4.2.0-M2 is a milestone and excluded); no springdoc (E-11); Gradle 9.7.1 (services.gradle.org version feed, release date 2026-08-19, checksum acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a read from the official .sha256 file, to be re-fetched at WP01, Unverified 18; runs Java 17-26, Java 25 from 9.1.0); openapi-generator-gradle-plugin 7.25.0 exists on the Gradle plugin portal (latest there is 7.26.0; the pin stays equal to contracts/tools/pins.json, AC-14e); GraalVM Community JDK 25.0.2 (graalvm-ce-builds release jdk-25.0.2, assets for all four targets present); Native Build Tools Gradle plugin 1.1.14 is the newest on Central but the Spring Boot plugin manages its own NBT version, so the Boot-managed one wins and is read at WP01; JaCoCo 0.8.15; networknt json-schema-validator 3.0.8; ArchUnit 1.4.2 (1.5.1 exists; 1.4.2 chosen pending a Java 25 class-file check at WP01). The Gradle release date and checksum were taken from a web read at planning time and are re-fetched at WP01 (Unverified 18). Verification record (round 3, 2026-10-10). Checked against registry or API metadata in this review round: the spring-boot-dependencies-4.1.1.pom on Maven Central pins tomcat.version 11.0.24, jackson-bom.version 3.1.5 and jackson-2-bom.version 2.21.5; the POMs of JaCoCo 0.8.15, networknt json-schema-validator 3.0.8, ArchUnit 1.4.2 and the Native Build Tools Gradle plugin 1.1.14 are served by Maven Central; the openapi-generator-gradle-plugin 7.25.0 jar is served by the plugin portal (latest there 7.26.0); the services.gradle.org version feed lists Gradle 9.7.1 with build time 2026-08-19 and its .sha256 file equals the recorded acd53f1e...; the GitHub API gives graalvm/setup-graalvm tag v1.6.7 at commit 27b360db..., gradle/actions tag v6.4.0 as an annotated tag object that dereferences to commit 3f5f9ada..., and graalvm-ce-builds release jdk-25.0.2 with assets for linux-x64, linux-aarch64, macos-aarch64 and windows-x64 (the per-asset checksums are still recorded at WP01). Checked only by a web summary and not re-fetched: the runner labels (Unverified 7). WP01 still re-fetches the Gradle checksum before generating the wrapper and the action SHAs with gh api before pinning. Anything not verifiable offline is in the Unverified list.
13. Hexagonal layout, ports, SSE, filter, bind, diagnostics
Package root org.speckitty.missionstatus (a package namespace only; nothing is published to Maven).
services/mission-status-api/
settings.gradle.kts build.gradle.kts gradle.properties gradlew gradlew.bat
gradle/{libs.versions.toml,verification-metadata.xml,wrapper/}
README.md .gitignore
src/main/java/org/speckitty/missionstatus/
domain/{model,reducer,overview,projection,cursor,paging} # no framework, no JSON, no java.io/nio.file; java.security (SHA-256) allowed
application/{MissionStatusService (public; constructor takes MissionEventSource, Clock, Consumer<Diagnostic>, ScheduledExecutorService, StreamSettings),ProjectionCache,ChangeFeed (package-private),
port/in/MissionStatusQuery,
port/out/MissionEventSource}
adapter/in/http/{api (generated ProjectApi*, MissionsApi*),model (generated models),EventsController,ContractDocumentController,HostOriginFilter,OwnPortSource (interface),ProblemMapper,dto/** (hand mode only)}
adapter/out/filesystem/{FilesystemMissionEventSource,ProjectRootCheck,ReadDirResolver,SafeFiles,Allowlist,Listing,codec/LedgerLineDecoder} # codec/ is a sub-package of the one outbound adapter: JSON-capable, no file API, the one place ledger lines are decoded
config/{MissionStatusApplication,ServiceWiring (constructs FilesystemMissionEventSource, the leak-class value and MissionStatusService, which takes the leak-class value and builds its own ProjectionCache (WP06, DD-91); owner phase 4a),BindGuard,Properties,VirtualThreads,NativeHints,FeedExecutorConfig,LoggingDiagnostics,OwnPortHolder (implements adapter.in.http.OwnPortSource)}
src/main/resources/{application.properties,leak-patterns.json (generated by the Python oracle; phase 2b),contract/openapi.yaml (build output of bundleContract, not committed),META-INF/native-image/**}
src/test/java/.../{domain,application,conformance/{port,golden},testsupport,architecture,adapter/**,nativesmoke,skeleton}
src/test/resources/{portlevel-tests.txt,jacoco-class-floor.properties,application-test.properties,nativesmoke/requests.json,conformance/**}
- Ports (plan level; full types in data-model.md). Exactly the two seams the operator's Architecture decision names (spec.md, 2026-10-08), and no other named port. Inbound
MissionStatusQuery:ProjectHeader project(),MissionPage missions(PageRequest),ChangeBatch changesSince(StreamScope, Optional<StreamCursor>), which mirror the read port of #5532 one to one (C-003), plusSubscription subscribe(StreamScope, Optional<StreamCursor>), the continuous form of the same "changes since a cursor" capability (Python has no push stream, so it has no #5532 counterpart; it is an operation of the one inbound port, not a second port).subscribedecides every connect-time outcome BEFORE a subscription exists: it throwsStreamRefused(code, kind)(S1 unknown or refused Mission, and the cursor refusals of the contract) orMissionStatusUnavailable(code, diagnostic)(the S2 connect rows, includingmission_dir_unreadablefor an unlistablekitty-specs/), andMissionStatusService.subscribe, the component that performs the connect-time resolution, emits that occurrence's diagnostic once. The returnedSubscriptionhands out a sealedStreamSignal(Forwarded(ChangeBatch),Truncated,Dropped,Closed(closure),Idle) through a blockingnext(Duration)andclose(). The HTTP adapter depends ONLY onMissionStatusQuery;ChangeFeedis package-private behind it. OutboundMissionEventSource:ProjectInputs projectInputs(),List<MissionRef> listMissions(),MissionMeta readMeta(MissionRef),LedgerStamp stampLedger(MissionRef),LedgerRead readLedger(MissionRef, LedgerStamp),LedgerWindow readLedgerFrom(MissionRef, long offset). Not ports: the diagnostics consumer is ajava.util.function.Consumer<Diagnostic>and the poll scheduling uses an injectedjava.util.concurrent.ScheduledExecutorServiceplusClock, all handed to theMissionStatusServiceconstructor byconfig(LoggingDiagnostics,FeedExecutorConfig); they are application-internal collaborators, not named in the hexagon. Fixture factory (AC-13b).testsupport.FixtureFactorybuilds the realMissionStatusServiceover a givenMissionEventSourcewith a fixedClock, a recordingConsumer<Diagnostic>and a deterministic scheduled executor it owns, and returns onlyMissionStatusQuery(and the source it was given); the recording consumer and the executor are reachable through a separatetestsupport.FixtureHandlesobject, not through the returned interfaces. The conformance suite uses the first three operations ofMissionStatusQuery;subscribeis covered by the stream suites. Faults are typed exceptions orUnreadableresults, neverIOException. - Who decodes ledger lines (decision). The application and domain may not touch JSON (FR-012), and the filesystem adapter must not scatter Python-parity decoding rules. So decoding lives in
adapter.out.filesystem.codec.LedgerLineDecoder: a sub-package of the one outbound adapter (the spec's Architecture bullet names two adapters,adapter.in.httpandadapter.out.filesystem; the plan adds a package inside the second, not a third adapter), JSON-capable and with no file API (ArchUnit rule 12 enforces both). It turns physical lines into typedLedgerLinerecords: 1-based physical line number, SHA-256 of the raw line (the cursor invariant), a kind (Status,Lifecycle,Ignoredfor other well-formed objects,Blank,Malformed(reason)), the decodedStatusEventorLifecycleEvent, and the unterminated-tail classification.FilesystemMissionEventSourcecalls it on the lines it reads; the in-test in-memoryMissionEventSource(atestsupportclass, andtestsupportalone may importadapter.out.filesystem, codec sub-package included, AC-13b) is constructed from RAW ledger lines (byte arrays or text, neverLedgerLine) and decodes them through the same decoder. The codec is a phase-2a deliverable (section 11), so the port-level subset exercises the production decode path: duplicate keys, number forms, non-UTF-8 and the L3/L4 line classification are tested once at the codec and again through both sources. What FR-013's "ledger bytes" means here. FR-013 says the outbound port supplies "the ledger bytes of the resolved read directory". In this plan the bytes are what the conformance HARNESS feeds (raw lines into the in-memory source) and what the filesystem adapter reads from disk, while the port itself carries the decoded lines, because decoding inside the application would put JSON there against FR-012. That is a reading of FR-013, not its letter; it is escalated as E-13 with a testable interim. Data types in data-model.md. - Cache probe versus read (decision).
stampLedgeris cheap and adapter-defined: it stats the ledger and reads only the final physical line, returning an opaqueLedgerStampwith value equality over (size, mtime, file key, terminated length, SHA-256 of the last terminated line). The application never interprets the stamp fields; the stream cursoroffset:invariantis derived from it, so the cache key is FR-007's (cursor, size, mtime, file key) while the application learns it without reading the whole ledger.readLedger(ref, stamp)runs only on a cache miss. A warm request therefore does zero whole-ledger reads (one stat and one bounded tail read per Mission); the AC-7 row asserts this through the instrumented port. What "unchanged" means (inode reuse, Windows file ids, CRLF) stays inside the adapter. NFR-001 over 588 Missions is measured against this probe cost at WP01. - Diagnostics (decision). One occurrence, one line. A diagnostic is emitted exactly once, at the point of detection, through the
Consumer<Diagnostic>the service was constructed with: connect-time occurrences (S1 and S2, and an unscoped connect's dropped Missions) byMissionStatusService.subscribebefore the feed is involved; request-scoped failures byMissionStatusServiceper request; post-connect stream faults by the sharedChangeFeedper occurrence (a Mission that vanishes under 100 unscoped subscribers produces onemission_vanishedline, not 100). The S6 "none again until a listing has succeeded" state lives in the feed, not in the subscriber. Per-subscriber signals (Dropped,Closed) carry only the stream consequence and no diagnostic.config.LoggingDiagnosticswrites the line (path-free, stable code,mid8). This replaces the earlier "diagnostics travel inside results, no third port" decision (tracer DD-2) and the round-1 namedDiagnosticsSinkport (DD-13). - SSE. Hand-written
EventsController. Each connection owns one virtual thread that callsSubscription.next(30 s);Idleafter 30 s becomes the: heartbeatcomment (interval is configuration so AC-3f can shorten it). The emitter isnew SseEmitter(0L)(no timeout) withspring.mvc.async.request-timeout=-1, because the container default (about 30 s) would end an idle stream at the instant the first heartbeat is due;onCompletion,onErrorandonTimeoutcallbacks callSubscription.close(). A client disconnect is noticed on the next write, at most one heartbeat later; the spec's 2-second bounds are for server-side closure on a fault (rows S3-S5, delivered by theClosedsignal, not by a timeout) and are not weakened by this. One sharedChangeFeedpolls (500 ms tick; stat size/mtime first, read only appended bytes;kitty-specs/re-listed once per second for the unscoped discovery rule) and fans out through bounded per-subscriber queues, so 100 connections cause no per-connection rescan (NFR-004c). Overflow policy. Each queue holdsmission-status.stream.queue-capacitysignals (default 1024). When a signal cannot be queued the subscriber is closed withClosed(slow_consumer): nothing is dropped silently or reordered before the close, no diagnostic is written (no Mission fault occurred), other subscribers are unaffected, a scoped client reconnects with its lastid:and an unscoped client reconnects and sees later rows only (stated in the README). No row of the spec's stream table covers a slow consumer, so this is escalated as E-12. The poller's executor is created inconfig(FeedExecutorConfig) and passed toMissionStatusServiceas a constructor argument; the application creates no thread (rule 2) and holds no clock beyond the injectedClock. Tests drive the feed with a deterministic executor. Cursor =streamCursor(offset:invariant, invariant = SHA-256 of the last consumed line);streamCursorwins overLast-Event-ID; the rules ofcontracts/mission-status/paths/events.yamland the S1-S6 table are implemented as written. - Host/Origin filter.
OncePerRequestFilter,Ordered.HIGHEST_PRECEDENCE, the exact rules of section 8 (raw Host header, own port from the bound server, forwarded headers refused); Origin absent orhttp://+ accepted form; 403request_refused(section 8). The filter takesOwnPortSource(anadapter.in.httpinterface) in its constructor. There is no springdoc (contract document bullet below); the 404-after-filter behaviour of/v3/api-docsand/swagger-ui, and the 200 of/api/v1/openapi.yamlwith an accepted Host, are asserted (section 11, AC-9 and AC-11 rows). A Host the container rejects before the filter (the closed 400 list of AC-9b) is answered by Tomcat's own 400; nothing in the service re-renders it. - Contract document (operator decision E-11: contract-first, no springdoc). The service serves the committed
contracts/mission-statusOpenAPI document as it stands. The Gradle taskbundleContract(phase 1,build.gradle.kts) bundles the split rootcontracts/mission-status/openapi.yamlinto one file at build time and places it in the main resources ascontract/openapi.yaml(a build output: never committed, never edited, never regenerated from the controllers), andadapter.in.http.ContractDocumentControlleranswersGET /api/v1/openapi.yamlfrom that classpath resource with the bytes unchanged. The bundling mechanism is a WP01 decision (candidate: the contract tooling's own bundler,contracts/tools/bundle.py --bundle-only; accepted only if the served bytes equal its output for the same commit and it runs on every leg; research.md R-2 and the spike record). The document is a classpath resource, not a read of the project root, so the FR-010 allowlist is unchanged. The route is a fourth GET beside the three contract operations; it is not an operation of the contract, and the Host/Origin filter covers it like any other path. springdoc has no remaining role: not at runtime and not at build time, because it would generate a second document from the controllers, which can drift from the contract that the generated interfaces and the response validator already follow, and the contract is the authority; and not in test scope either, because the document test it served is replaced by the served-bytes test (AC-11d). So there is nospringdoc-openapidependency in any Gradle configuration and nospringdoc.property, and the springdoc reflection hints of spec R-5 do not apply. A dependency-absence test pins this (section 11, AC-14 row), and/v3/api-docsand/swagger-ui*answer 404 after the filter (AC-9 row). - Projection (AC-9g; the oracle's
Projector).domain.projectionportsProjectoroftests/contract/_mission_status_payloads.py. The fieldsbuild_overviewroutes through it (missionId,slug,friendlyName,targetBranch,missionType; the strict or human class of each is the oracle's and is recorded in the manifest) follow two rules. A strict field is served as is when it is clean, nulled when it is nullable and not clean, and refused when it is non-nullable and not representable. A human field has each host path replaced by the oracle's[path]token and each email by[email], and a leak left after redaction is refused. A refusal is a domainProjectionErrorthat carries the field name and never the value;MissionStatusServiceturns it intoMissionStatusUnavailablewith the diagnostic codeprojection_refusedand the same 500 Problem shape as rows L3a and L3b (path-free, no value in the body). One source of truth for the patterns:contracts/tools/leak_patterns.py(the STRICT and HUMAN host-path patterns, the email pattern, the secret patterns and the two tokens). The Python generator writes them tosrc/main/resources/leak-patterns.json(pattern text, flags, class, token); the manifest records that file's SHA-256 and, per pattern, probe strings with Python's verdicts;config.ServiceWiringreads the resource from the classpath, compiles the patterns withjava.util.regexand hands the leak-class value to the domain by constructor (the domain does no I/O, rule 1). Pythonreandjava.util.regexdiffer in places, so the pattern file is limited to their common subset and each pattern has a Java test that must reproduce Python's verdicts on its probe strings (section 11, AC-9g row); whatever the probes miss is for the nightly to find, where any difference is red. - Bind. The packaged defaults set
server.address=127.0.0.1.BindGuardruns before the web server starts and reads the EFFECTIVEserver.address: any value other than the literal127.0.0.1exits non-zero naming the address, and an absent or blank value exits non-zero naming "unset" (Spring Boot would otherwise bind every interface). After the server starts, aWebServerInitializedEventlistener also checks the address the Tomcat connector actually bound (the accessor is confirmed at WP01, Unverified 19) and exits non-zero if it is not loopback. Port in use exits non-zero naming the port (Boot'sPortInUseExceptionmessage). The default port is a plan choice (configuration keymission-status.port, default 8787), not a spec requirement. Configuration keys (the complete set that the quickstart,nativeSmokeand the nightly job use).mission-status.project-root(required: the project root whosekitty-specs/is read; absent or blank exits non-zero naming "project root unset", and a value that is not a directory exits non-zero naming the key, never an OS message; the directory check isadapter.out.filesystem.ProjectRootCheck, whichconfigcalls, soconfigmakes no file API call and rule 7 holds from the phase 1 skeleton on);mission-status.port(default 8787; the packagedapplication.propertiesmaps it withserver.port=${mission-status.port:8787}; 0 binds an ephemeral port, used by every real-socket test);mission-status.stream.queue-capacity(default 1024); the heartbeat interval key (name fixed in 4a). Spring Boot reads command-line properties as--name=value, so the run command isjava -jar build/libs/mission-status-api.jar --mission-status.project-root=<project root> --mission-status.port=8787. The jar name is pinned:bootJar { archiveFileName = "mission-status-api.jar" }inbuild.gradle.kts(the build version stays0.0.0-devand is not part of the file name), pinned by the build test. The skeleton application of phase 1 already reads and validatesmission-status.project-root, and WP01 runs this exact command once on the skeleton jar and records the output in the research.md spike record; the native binary takes the same two properties. Tests: section 11, AC-9 row (unset, blank, wildcard, LAN, hostname, default). - Diagnostics model.
Diagnostic(code, mid8, line?); codes:ledger_unreadable,ledger_unparseable,ledger_torn_tail,meta_unparseable,mission_vanished,path_refused,mission_dir_unreadable,projection_refused. - Serialisation and Jackson (decision). Boot 4.1.1 defaults to Jackson 3 (
tools.jackson, BOM 3.1.5) and also manages a Jackson 2 BOM (2.21.5); the generator'sspringmodels are expected to use Jackson 2 style annotations andjackson-databind-nullable(unverified). The contract usestype: [string, 'null']members that must appear as an explicitnull, and NFR-005/AC-15b require byte-exact bodies. So: (1) the production mapper is configured explicitly, not left at Boot defaults: no pretty printing, null members written, map keys and properties emitted in the canonical order, non-ASCII written as UTF-8 (no\uXXXXescaping),progressrendered by one pinned function; (2) the canonical form, fixed by the golden writer, is sorted keys, compact separators (,and:), UTF-8 without ASCII escaping (the formjson.dumps(..., sort_keys=True, ensure_ascii=False, separators=(",", ":"))produces); (3) ONE test-support class,CanonicalJson, is the only comparison code (raw-byte equality; see below), used by the JVM golden suite and bynativeSmoke, so the two cannot disagree and a native-only formatting difference is not misread as a reflection problem; (4) a unit test pins one non-ASCII string and one fractionalprogressvalue against the Python-written golden. Comparison is on RAW bytes: the response body as received is compared with the golden's bytes and the per-leg digests hash the raw bodies;CanonicalJsonparses and re-serialises only to build a diff message after a byte mismatch. No weakening is applied. If the production mapper cannot emit the canonical bytes, bodies would have to be canonicalised before comparison, which would weaken AC-15b from raw to canonical byte equality; that contingency is a spec decision (section 14, E-8) and is not taken. The Jackson major and the generated models' behaviour are WP02 spike criteria with a go/no-go rule (research.md R-2).
ArchUnit rule set (FR-012, AC-12, AC-13b). Production rules import main classes only (ImportOption.DoNotIncludeTests), including generated classes; rule 11 imports test classes. Generated packages are pinned (apiPackage org.speckitty.missionstatus.adapter.in.http.api, modelPackage org.speckitty.missionstatus.adapter.in.http.model, read from build.gradle.kts by the build test), so "contract DTOs live only in adapter.in.http" is checkable before the generated-versus-hand decision.
| # | Rule | Positive control (a test-branch edit that must turn exactly this rule red) |
|---|---|---|
| 1 | domain: (a) references none of the FR-012 prohibited packages: Spring (org.springframework..), HTTP (jakarta.servlet.., org.apache.catalina.., org.apache.coyote.., java.net..), any JSON library (the packages of rule 8), java.nio.file.. and java.io..; (b) depends, over all packages, only on org.speckitty.missionstatus.domain.. and the JDK packages java.lang.., java.util.. EXCEPT java.util.concurrent.., java.time.., java.security.. (SHA-256) and java.nio.charset.. (StandardCharsets for PageCursor and the digest; those overloads throw no IOException). In ArchUnit identifiers .. includes sub-packages, so java.util.. covers java.util.function and java.util.stream | import Spring in a domain class (AC-12a); second control: reference java.util.concurrent.ConcurrentHashMap in a domain class |
| 2 | application: (a) references none of Spring, HTTP, JSON, java.io.., java.nio.file.., java.net..; (b) depends only on org.speckitty.missionstatus.application.., org.speckitty.missionstatus.domain.. and the JDK packages java.lang.., java.util.. (this includes java.util.concurrent, .atomic, .locks, .function, .stream), java.time.., java.security.. and java.nio.charset..; no adapter, no config; and no thread creation: no call of Executors.*, Thread.ofVirtual, Thread.ofPlatform, no construction of Thread, ThreadPoolExecutor or ForkJoinPool (the executor is injected) | import an adapter class in an application class; second control: call Executors.newFixedThreadPool in ChangeFeed |
| 3 | Scoped to project packages (org.speckitty.missionstatus..; dependencies on the JDK, Spring, Jackson, generated and other third-party types are NOT constrained by this rule, rules 7 to 9 constrain them): adapter.in.http depends, among project packages, only on application.port.in (and its exception types and the domain value types in those signatures) and on itself; not on application internals, adapter.out, or config (the filter reads the bound port through adapter.in.http.OwnPortSource, which config.OwnPortHolder implements) | import ChangeFeed or adapter.out.filesystem in the controller; second control: import config.OwnPortHolder in the filter |
| 4 | Scoped to project packages as in rule 3: adapter.out.filesystem.. (its codec sub-package included) depends, among project packages, only on application.port.out, domain.. and itself | import adapter.in.http in the filesystem adapter |
| 5 | no other adapter-to-adapter dependency | import across adapter.in and adapter.out |
| 6 | nothing depends on config; config may depend on any package | import config in application |
| 7 | file APIs (java.io file classes, java.nio.file) only in adapter.out.filesystem.. EXCLUDING its codec sub-package (rule 12), over the whole main source set | use Files.readString in config |
| 8 | JSON libraries and Spring only in adapter.* and config | use a Jackson type in application |
| 9 | generated and hand contract DTO packages only in adapter.in.http | reference a generated model from application |
| 10 | no reference to ProcessBuilder or Runtime.exec (AC-10c) | add a ProcessBuilder call |
| 11 | test classes in ..conformance.port.. depend on no ..adapter.. class; only ..testsupport.. may import adapter.out.filesystem.. (codec sub-package included; AC-13b); the phase 3 launcher conformance.golden.PortSubsetSecondLegTest imports testsupport only | add an adapter import to a subset class |
| 12 | adapter.out.filesystem.codec.. depends, among project packages, only on domain.. and itself (not on SafeFiles, ReadDirResolver or any other filesystem class), uses no file API, and is used only by adapter.out.filesystem and testsupport | reference SafeFiles in the decoder; second control: use Files.readString in the decoder |
Non-vacuity has three parts: each rule scans more than zero classes (AC-12b), each rule has its positive control above (so a missing or inverted direction rule cannot pass unnoticed), and a rule-count guard fails when the number of rules run differs from the number defined.
Docs impact
docs/ changes: (1) docs/development/reference/ci-gate-mechanics.md section "Contracts gates" gains a "Mission Status API workflow" subsection (what triggers it, that it is not a router dependency, the one-slot workflow ceiling, how to run it locally) and its updated: date is refreshed; (2) docs/changelog/CHANGELOG.md [Unreleased] gets one ### Added line under the enforced headings (Breaking, Upgrade Notes, Added, Changed, Fixed, Internal; guard: scripts.docs.check_changelog_style), impact-first, for example that a read-only local Mission Status API service is built from services/mission-status-api/ and is not part of the PyPI package. The root CHANGELOG.md is a symlink to that file. No new docs page, so no toc.yml, page inventory lockfile, Divio frontmatter or index changes are triggered; the docs freshness gate (updated:) bites the one edited reference page. The service README lives under services/, outside the docs spelling scope, and carries the allowlist fenced list AC-10b parses.
Risks and mitigations
| Risk (plan ids PR-n; research.md sections keep R-n) | Mitigation |
|---|---|
| PR-1 coordination divergence | Section 7 classification record, resolver-cases.json, per-fixture class, nightly failure report (any difference is red) |
| PR-2 concurrent reducer work (#5902/#5326 closed unmerged) | Golden test red-first; re-verify PR states before WP01 claims |
| PR-3 Gradle/JDK 25 | Gradle 9.7.1 (JDK 25 from 9.1.0); WP01 proves a green JVM and native build |
| PR-4 generated models unusable | WP02 spike, fallback hand DTOs + drift test (AC-11c replacement) |
| PR-5 native-only failures | nativeTest + HTTP smoke on all four legs; jlink/jpackage is the named fallback |
| PR-6 corpus budget | Service gate independent of tests-corpus-blocking; no job step runs pytest (the nightly script still imports the pytest-importing oracle module, so its job syncs the test environment, section 7) |
| PR-7 metadata churn | All dependencies land in phase 1; later additions regenerate by command |
| PR-8 read port absent (#5532) | mirror against the contract; re-check when it lands |
| PR-9 contract expressiveness | decided L/S rows; diagnostics are the only observable of L2 |
| PR-10 last workflow slot | one file only; every future CI need is a job of an existing file; stated in the PR |
| PR-11 Tomcat answers 400 before the filter for a Host it cannot parse (row 8 of AC-9b) | per-row status fixed in section 8 from the Tomcat source, confirmed (not generated) at WP01; operator decision E-1 (2026-10-10): the spec lists the closed 400 set and there is no custom container valve |
| PR-12 unsigned macOS/Windows binaries | recorded limitation; not a gate |
PR-13 windows-2025/macos-15/ubuntu-24.04-arm labels and GraalVM setup action SHA not verifiable offline | verified by the first workflow run in WP01; SHAs re-checked with gh api before pinning |
| PR-14 Jackson 3 default mapper versus Jackson-2-style generated models; byte-exact output | WP02 spike criteria and go/no-go rule (research.md R-2); explicit mapper; one shared CanonicalJson; contingency E-8 |
PR-15 nativeTest compiling the whole test source set | tagged native subset in a dedicated source set (section 11); ArchUnit, networknt, goldens and JaCoCo stay on the JVM leg |
| PR-16 Packs advisory lane runs the new corpus module a second time | --deselect added to packs.yml and pinned (section 5, section 11) |
Unverified (could not be confirmed while planning)
1. Whether any check is a required status check on main (repository settings; not in the checkout). 2. Embedded Tomcat's behaviour for each AC-9b Host row: the expected statuses in section 8 were read from the public Tomcat 11.0.x source, not from a run against the 11.0.24 jar that the Boot 4.1.1 BOM pins (no JDK locally). WP01 CONFIRMS them in a throwaway, uncommitted harness (section 8). Also unconfirmed: the body of a container-generated 400 (HTML page or empty), which the AC-9g scan applies to; no valve re-renders it (E-1). 3. Native-image behaviour of SseEmitter(0L) with spring.mvc.async.request-timeout=-1, of the classpath contract-document resource under native, and of Jackson null members; how to point the Native Build Tools test binary at the dedicated native-tagged source set (fallback: a tag filter on test); WP02 spike. 4. The jdk.ProcessStart JFR event name on the pinned JDK, and that it is emitted under the GraalVM CE JDK 25 in JVM mode (AC-10c says confirm; if absent the runtime half is dropped by amendment, E-2). 5. Spring Boot 4.1.1 compatibility with Gradle 9.7.1 and with the Native Build Tools plugin version it manages; ArchUnit 1.4.2 reading Java 25 class files. 6. Closed in round 3 for graalvm/setup-graalvm (v1.6.7 is commit 27b360db...) and gradle/actions (v6.4.0 is an annotated tag that dereferences to commit 3f5f9ada...), both read from the GitHub API on 2026-10-10 (section 12, verification record). WP01 still re-verifies with gh api immediately before pinning. 7. Runner labels macos-15, windows-2025, ubuntu-24.04-arm. 8. Whether tests/architectural/test_dual_mode_contract.py, test_gate_os_tier.py, test_ci_integrity_oracle/_gate_coverage and tests/ci/test_ci_module_wiring.py accept a workflow with windows/macos runners and no mode input; the grep list of workflow-enumerating tests is in traces/tooling-friction.md and AC-15d must run all of them on the first workflow commit. 9. ci-fleet-verdict.yml coverage of the new workflow (E-3, superseded 2026-10-11: registered by WP04). 10. Jackson 3 (Boot default) versus the generator's output: that a required-nullable member serialises as null, that member order equals the golden, and number formatting (progress); the annotation style openapi-generator 7.25.0 emits for Boot 4. WP02 spike criterion with a go/no-go rule (research.md R-2). 11. JaCoCo 0.8.15 reading Java 25 class files and Spring AOT generated classes, and the exact class-name pattern syntax of the verification rule. WP01 acceptance check: a JaCoCo report on a Java 25 test run. 12. networknt json-schema-validator 3.0.8 honouring unevaluatedProperties under OpenAPI 3.1 schemas extracted from the bundle. WP01 acceptance check: a negative case that must fail. 13. The org.openapi.generator plugin 7.25.0 running on Gradle 9.7.1 (the repository pin was validated on Gradle 8.14.5, contracts/tools/pins.json). WP01 acceptance check: openApiGenerate green. Decided up front: the plugin version stays equal to pins.json; if the Gradle 9 run needs a patch it is documented, not worked around by changing the pin. 14. Whether --write-verification-metadata merges additively when re-run on another OS, and whether any resolved artifact has an OS or arch classifier (research.md R-4 records the answer at WP01). 15. That GitHub does not apply a paths filter to a tag push (needed for the release job form in section 12; confirmed by a tag-shaped dry run, or at the maintainer's first tag). 16. The cost of the per-Mission stampLedger (one stat and one bounded tail read) over 588 Missions against NFR-001's ratio; measured at WP01. 17. The Windows-filename guard constants (WINDOWS_ILLEGAL_CHARS, SHELL_EXPANSION_TELLTALE_CHARS) being importable from the architectural test module in the tests/ci run; fallback is a one-line re-export in a shared helper, decided at WP01. 18. Partly closed in round 3: the services.gradle.org version feed (Gradle 9.7.1, build time 2026-08-19) and the .sha256 file (acd53f1e...) were fetched on 2026-10-10 and agree with the recorded values. WP01 still re-fetches https://services.gradle.org/distributions/gradle-9.7.1-bin.zip.sha256, compares it with the recorded value and records the outcome in research.md R-4 before generating the wrapper. 19. Which Tomcat accessor reports the address a connector actually bound (used by the bind guard's post-start check, section 13, Bind); WP02 spike.
14. Spec amendments and escalations
Rows marked RESOLVED record operator decisions of 2026-10-10 that are applied to spec.md (Clarifications / Operator decisions) and to this plan. Nothing else in this table is applied to spec.md, the charter or contracts/. Each row is a decision the plan cannot take. "Blocking" means the named work cannot be tasked or started until the owner rules; everything not named keeps going. The interim behaviour is what the plan does in the meantime.
| ID | Item | Why it cannot be decided in the plan | Owner | Blocking? | Interim behaviour |
|---|---|---|---|---|---|
| E-1 | AC-9b, container-rejected Host rows | RESOLVED (operator, 2026-10-10). Tomcat answers 400 itself for a Host it cannot parse (row 8, [email protected]:<port>), before any filter | operator | No longer blocking: phase 4b proceeds | Decision applied to spec.md (AC-9b, FR-009, Q-5) and to section 8, section 11 and the AC-9 test row: a Host value the filter can see is refused with 403 request_refused; a malformed Host value the container rejects itself keeps the container's 400; the spec lists the closed set of 400 values; both are refused before any read; no custom container valve (option (b) is dropped) |
| E-2 | AC-10c JFR fallback | If jdk.ProcessStart does not exist on the pinned JDK the spec drops the runtime half "and amends this AC" | operator | No | Keep the runtime assertion; WP01 confirms the event name early; only an operator-approved amendment may drop it |
| E-3 | ci-fleet-verdict.yml coverage of the new workflow | Adding "Mission Status API" to its workflow_run.workflows list edits a workflow the spec says stays untouched and adds a pinning-test edit; not adding it means a red gate posts no PR [ci] comment and files no main-red issue | maintainer | No | Superseded 2026-10-11 by operator ruling (reviews/analyze.ruling.md): fleet_verdict.PR_WORKFLOWS rejects any unregistered pull_request workflow, so the workflow is registered in scripts/ci/fleet_verdict.py and listed in ci-fleet-verdict.yml (WP04) |
| E-4 | Release job: trigger, if:, permissions, concurrency; AC-15a | RESOLVED (operator, 2026-10-10). The release job ships in this mission | operator, then maintainer for the first tag | No | Decision applied to spec.md (FR-015, AC-15a) and to section 5, section 11 (phase 5, AC-15 row) and section 12: tag pattern mission-status-api-v.. in the same push block, the job if:, contents: write on the release job only, tag-aware concurrency, and a tag pattern disjoint from v.. and contract--v... The maintainer cuts the first tag; this mission pushes none |
| E-5 | Workflow-ceiling slot: acknowledge that the single new mission-status-api.yml uses the last of the 20 allowed workflow files (test_reusable_workflow_ceiling_respected) | The spec says "the PR says so"; a future workflow needs an architectural limit that GitHub itself imposes to be raised, so every later CI need must be a job here | operator | No | Exactly one workflow file; the PR description states it |
| E-6 | Reflexivity list extension, and one build-failing plan addition | spec.md Reflexivity items 1-6 and its review diff check do not name packs.yml, .gitattributes, ci-gate-mechanics.md or CHANGELOG.md; the plan edits all four (one token, one scoped line, one subsection, one line). Separately, the plan's JaCoCo rule fails the build on BRANCH coverage below 80% on domain (section 5); NFR-006 requires line coverage of 90% on domain and application only | operator (awareness; no spec edit needed) | No | Section 1 and section 4 list them; the review diff check of section 4 names each |
| E-7 | Additional AC-9b rows and exact-versus-case-insensitive Host | The plan adds: duplicate Host, HTTP/1.1 without Host, absolute-form foreign target (HTTP/1.1 and 1.0), empty Host, X-Forwarded-Host, Forwarded, trailing dot, upper case, configured port 0. Spec FR-009 says accepted forms are "exactly" two, so case-insensitive acceptance is a spec widening | operator | No (rows are additive and are scheduled with phase 4b) | Exact, case-sensitive match; upper case, trailing dot and forwarded headers refused; the three container-decided 400 rows are part of AC-9b set 2 (spec amended after analyze, ANL-005) and no longer provisional; the filter-decided rows of section 8 stay provisional and land with 4b, behaviour and test together; a declined row is removed with its behaviour |
| E-8 | AC-15b byte equality contingency | If the production mapper cannot emit the canonical bytes, comparison must canonicalise first, weakening AC-15b from raw to canonical byte equality | operator | No (only if the WP02 spike fails the criterion) | The mapper is configured to emit canonical bytes; the spike confirms; no weakening applied |
| E-9 | Whether Mission Status API gate becomes a required status check | A required path-filtered check stalls unrelated PRs; repository settings are not in the checkout | maintainer, at merge (the spec already assigns it) | No | Not required; stated in the PR |
| E-10 | Standing exemption for a known Python/Java difference in the nightly | RESOLVED (operator, 2026-10-10): no exemptions. The nightly fails on any Java/Python difference and names the Mission and the class (for a whole-read refusal, which is not attributable to a Mission over HTTP, the side, route and Problem code; section 7); D-Q1 and AC-8c are unchanged | operator | No | No exemption mechanism exists; resolved by fixing the port or by a ruling |
| E-11 | The OpenAPI document and springdoc (FR-009, C-001, C-004) | RESOLVED (operator, 2026-10-10): contract-first. The running service serves the committed contracts/mission-status document as it stands, bundled at build time, at /api/v1/openapi.yaml; springdoc generates and serves nothing at runtime | operator | No | Decision applied to spec.md (FR-009, FR-011 AC-11d, C-001, C-004, R-5) and to section 13 (Contract document): springdoc has no remaining role (no dependency in any configuration, runtime or test scope) because a generated second document can drift from the contract. The issue text gets a one-line correction, which the orchestrator makes with the operator's consent |
| E-12 | Slow stream consumer has no row in the stream table | S1 to S6 cover Mission faults only; a bounded per-subscriber queue needs a defined overflow behaviour | operator (awareness) | No | The overflowing subscriber is closed with Closed(slow_consumer), no diagnostic, no drop or reorder before the close (section 13, SSE) |
| E-13 | Ledger decoding and the outbound port's return type (FR-013 wording, Architecture bullet) | FR-012 bars JSON from domain and application, so ledger lines cannot be decoded inside the application; FR-013 says the outbound port supplies "the ledger bytes of the resolved read directory". The plan places the decoder in adapter.out.filesystem.codec (a package of the one outbound adapter; the two named adapters stand, no third adapter) and lets the port carry decoded LedgerLine values. Rejected: a byte-returning port with the decoder in the application (violates FR-012), and a decoder as a third adapter package (departs from the two named adapters) | operator (awareness, or amend FR-013 to "the ledger lines"); RULED after analyze (ANL-006): FR-013 amended, see spec | No | Port carries decoded lines. The harness feeds RAW bytes: the in-memory MissionEventSource takes only raw lines, and a test asserts by reflection that no public constructor or factory of it accepts a LedgerLine, so the FR-013 division (bytes in, typed results out) is preserved at the conformance seam; ArchUnit rule 12 keeps the codec JSON-capable but file-API-free |