Research: Mission Status contract v1
Mission mission-status-contract-v1-01M3WC5X (#5558). Phase 0 output of the plan. Each entry gives the decision, the rationale, the alternatives considered, and the evidence. Everything marked verified was read or run on this checkout during planning; everything marked to verify is deliberately left to the named work package because it cannot be known offline (tool versions, generator behaviour).
R-1 Contract resolution in the reality check (OQ-1)
- Decision. A Python reference resolver,
contracts/tools/contract_resolver.py, is the only code that reads the split contract in Python. The reality check, the parity script and everycontracts/tools/check import it.contracts/tools/resolver_parity.pychecks in the contracts workflow that the resolver's fully dereferenced tree equals the CI bundle's after a capped list of named normalisations. That is a consistency check, not an independent proof (plan D-P2): a defect both sides share is invisible to it, which is why golden-tree unit tests and an independentjsonschemaplusreferencingdereference check sit beside it. - Rationale. The corpus-marker jobs (
tests-corpusinci-router.ymlandbuilt-in-corpus-suiteinpacks.yml) have no JVM and must not be edited (C-003, FR-017, SC-008). The reality check still has to give a real verdict in them and may never skip (FR-019). The resolver implements only what the contract uses, which keeps it small enough to prove equal. - Alternatives. (a) Add a JDK and Gradle step to the two corpus jobs: edits two existing jobs, excluded by the spec. (b) Commit the bundle and read it: the bundle is a build product that is never committed (CL-5, FR-001). (c) Have the test download the CI artefact: makes a per-change test depend on another job, the network and artefact access from forks, and gives a local run no verdict. (d) A second resolver with no equality proof: excluded by the OQ-1 constraint ("no second unproven resolution authority").
- Resolver scope (what it must support, and refuse; a floor, not the final set). Support: relative file
$ref(including intocontracts/_shared/), in-file JSON-pointer fragments, sibling keywords beside$ref(legal in 3.1), and the composition keywordsallOf,oneOf,anyOf. Refuse loudly, with a stable message code: a URL$ref, an absolute-path$ref, a~1pointer, a cycle, an unresolved target, a non-mapping document. Output: one dereferenced tree plus the three counts (path items, schemas, resolved references) that the parity script prints. Change control: a contract keyword outside this list ($defs,$anchor,discriminator, brace-named files) is added to the resolver and its parity fixtures in the same commit that first uses it. - Evidence. Locked dependencies available:
jsonschema4.26.0 (withreferencing), and PyYAML 6.0.3 are in the locked base set.jsonpointerandrfc3339-validatorare not in the base set (the resolver implements JSON-pointer fragments itself, OQ-1):uv.lockreaches both only through theformat-nongplextra ofjsonschema, requested bycyclonedx-python-libforcyclonedx-bom, whichpyproject.tomllists in thelintextra. It is importable in a hand-built.venvand intests-corpus(uv sync --frozen --all-extras) but neither is available after the contracts prelude, so the plan does not rely on them (plan D-P14:FormatChecker(formats=())with one stdlibdate-timecheck incontracts/tools/schema_formats.py).openapi-spec-validatorandopenapi-schema-validatorare not installed, so OpenAPI-document validation is the JVM tool's job and payload validation isjsonschemawith the Draft 2020-12 validator, which is the 3.1 schema dialect.
R-2 Gradle provisioning without a wrapper jar
- Decision.
contracts/tools/install_tools.pydownloads the pinned Gradle distribution archive over HTTPS, verifies its sha256 againstcontracts/tools/pins.json, and only then unpacks it. Nogradle-wrapper.jaris committed. - Rationale. R-10 of the spec prefers a pinned distribution over a committed binary. FR-018's parenthetical (
distributionSha256Sum) is a wrapper-properties field and needs the wrapper, so the manifest check replaces it with the same guarantee: a missing or mismatching checksum fails before execution. - Alternatives. Commit the wrapper (jar plus properties) and validate the jar with a validation action: adds a committed binary and a third-party action. A setup action that downloads Gradle by version: its checksum source is the same host as the download.
- Dependency verification.
gradle --write-verification-metadata sha256 <task>(Java toolguide,packs/built-in/toolguides/java-supply-chain.toolguide.yaml) generatescontracts/gradle/verification-metadata.xml; the build runs with strict verification. The Gradle plugin portal and Maven Central are the only repositories the build may use. The tamper test is a copy of the metadata with one checksum altered at run time. - To verify at IC-07a (JDK, Gradle, plugin) and IC-07b (vacuum, oasdiff). The exact Gradle, plugin, vacuum and oasdiff versions, their publication dates and checksums (none are invented in the plan).
R-3 Bundler fidelity spike (the exit criterion of IC-07a)
- Why. The contract depends on constructs a Java OpenAPI toolchain may not round-trip cleanly: object-valued extension keywords on nested properties (
x-source, structuredx-derived,x-provisional),unevaluatedProperties, type arrays such as["string","null"],const, sibling keywords beside$ref,format: date-time,pattern, atext/event-streamresponse with a schema, andexamples. If theopenapi-yamlgenerator drops or rewrites any of these, parity fails for reasons that are not the resolver's. - Plan. A minimal contracts workflow skeleton (the first deliverable of IC-07a, which depends only on IC-01) runs the spike on PR 1 (a draft): build one minimal module under
contracts/tools/fixtures/spike/that uses every construct above, bundle it, and diff the bundle with the resolver's tree. The result is a table of named normalisations (a finite list capped at eight, each naming the construct and the tool behaviour and carrying a planted test in the parity script's unit-test module); anything not on the list fails parity, and an entry that drops information is an E-1 decision. Record the table here when known. The spike also decides, and records here, the canonical$refspelling for brace-named path files (percent-encoded%7B/%7Dis the default candidate) after testing it against the Python resolver, the Java parser and a generated TypeScript client, and runs the generate-from-split client smoke: an openapi-generator TypeScript client generated from the split root (Java, so Node-free; generation only, the output is not compiled because that needs a Node toolchain). The spike also records, from a pushed run, the plant that makes the pinned generator exit non-zero (or the warningclient_smoke.pyfails on if it only warns), because the negative test depends on it. Its recorded result gates IC-05 and IC-07b: no content check is built on a resolver tree the bundler later contradicts. A negative result escalates as E-1 (bundler constructs) or E-4 (brace spelling). - Escalation E-1. If a construct the contract needs does not survive, the plan does not pick a replacement tool. Options in preference order: a longer documented normalisation list; a different Node-free bundler for the same validate step. The second changes CL-6 and is the maintainers' decision.
- Status. To verify. No JVM exists on the planning workstation, so this cannot be run before PR 1 is opened.
R-4 Breaking-change baseline source (PQ-3)
- Decision. Rebuild the baseline from the latest tag matching
contract-<module>-v<semver>:git archiveof the module and_shared/at that tag into a temporary directory, built with the same pinned Gradle build, then compared with oasdiff against the candidate bundle. - Rationale. Same build path for baseline and candidate; no dependency on a release host; works offline once tools are installed. FR-016 allows "rebuilt or downloaded as a verified release asset".
- Alternative. Download the release asset and verify its sha256: needs network access to the release host and trusts that the asset equals what the tag builds.
- First release. No tag exists.
breaking_check.pyprints a loud "no baseline" notice, writes it to the job summary, and allows it only wheninfo.versionis the module's initial version andCHANGELOG.mdcarries the initial entry. It fails in a shallow clone (git rev-parse --is-shallow-repository) or on any tag-listing error (D-12, FR-016).
R-5 Router selection, simulated (verified)
scripts.ci.gate_selection.select_gates and select_modules were called with explicit path sets on a scratch copy of ci-router.yml with one added line, - 'contracts/**', in the corpus filter group (no workflow file in the tree was modified).
| Changed paths | Today (no glob) | With the glob |
|---|---|---|
contracts/mission-status/openapi.yaml | no test job selected; always-on lint jobs only | group corpus, job tests-corpus, no code shard, no module shard |
contracts/tools/<script>.py plus docs/x.md | no test job | groups corpus and docs, tests-corpus selected, no code shard |
.github/CODEOWNERS | no test job | nothing selected by the router (the contracts workflow's own path filter covers it) |
tests/contract/test_mission_status_reality.py alone | no test job | no test job (accepted residual P-10 in the plan: a change confined to the reality check selects nothing) |
scripts/ci/fleet_verdict.py, tests/ci/test_fleet_verdict.py | module ci | module ci |
the same plus tests/release/pinning_rule_inventory.json or tests/release/ci_retirement_scrub.json | module ci | modules ci and release (the release shard runs all of tests/release, 15 test modules) |
docs/development/reference/ci-gate-mechanics.md, docs/changelog/CHANGELOG.md | group docs: tests-docs and the always-on docs-lint | same |
tests/architectural/test_ci_corpus_trigger_completeness.py | group architectural, job architectural-heavy | same |
Consequence for tests: a tests/contract/ module with only fast or contract markers is collected by no per-change job; every new module there carries corpus on one line and a registry row. A new test that must run on an edit confined to .github/workflows/** goes in tests/ci/, whose module row selects it.
R-6 Hidden and easily missed gates (verified)
| Gate | What it pins | Effect on this Mission |
|---|---|---|
tests/release/test_pinning_inventory_fresh.py | tests/release/pinning_rule_inventory.json, re-derived by scripts/ci/derive_pinning_inventory.py and compared byte for byte, including recorded line numbers in scripts/ci/fleet_verdict.py, tests/ci/test_fleet_verdict.py, tests/ci/test_fleet_main.py | Regenerate with the script after editing those files; new text must not reference its three subjects. Baseline green. It runs per pull request only when tests/release/** is in the diff (the release module shard, selected through the test mirror tests/release), so a diff touching only scripts/ci/ and tests/ci/ never runs it and CI will not catch a stale inventory there. |
tests/ci/test_workflow_script_import_guard.py | Workflow-invoked bare scripts must not import scripts.* at module level | contracts/tools/ scripts import only stdlib, locked dependencies and their siblings. |
tests/release/test_release_ci_ownership.py | A frozen set of seven net-new workflows under the introduced disposition; a text scan of every workflow file for the retired private-repo fence | No row is added to the convergence map; the two new workflow files contain neither retired string. The on-disk workflow set is not asserted equal to the map, so two new files do not trip it (read, not assumed; run as part of the named set at implement start). |
tests/architectural/test_dual_mode_contract.py | Dual-mode and dispatch contract over a discovered, listed set of reinstated workflows | The new workflows are not in its candidate list; run at implement start to confirm. |
tests/architectural/test_ci_router_transcription_guards.py | Router filter groups equal the scrub JSON groups rows | corpus is not a scrub group row, so the added glob does not trip it. |
test_reusable_workflow_ceiling_respected | At most 20 workflow files | 17 today (counted), 19 after. |
R-7 Corpus measurements (verified, own-directory read, read-only)
A throwaway read-only script called materialize_snapshot for every kitty-specs/*/ directory that has a meta.json; the working tree was unchanged afterwards (git status identical).
- 539 Missions with
meta.json(this Mission's scaffold included), 2936 snapshot work packages, 0 exceptions, about 0.5 s total. - These match the spec's figures (539 and 2936), so the floors of 500 Missions and 2800 snapshot work packages sit below the measured values with headroom.
- 3125
tasks/WP*.mdfiles and the 52-Mission disagreement list are quoted from the spec and re-measured at implement start (IC-01), not copied. - Write-path audit:
materialize_snapshotreads throughread_event_streamandread_events_raw(a read-onlyopen), then reduces in memory; onlymaterializewritesstatus.json. The reality check calls the first and never the second.
R-8 Baseline (verified)
One invocation of the named gate files on the planning base: 265 passed, 1 skipped, 0 failed, 68 s: corpus-trigger completeness, no-duplicate-suite, workflow-coherence, module-shard registry, fork guard, fleet verdict, fleet main, pinning-inventory freshness. The tree was identical to main for every file under test at that moment. That is eight files only. The rest of the baseline (corpus-marked tests/contract modules, the router derivation guards, test_prose_only, test_ci_module_wiring, terminology, the whole tests/release directory, the named tests/docs files, ruff check and format, docs-lint, the read-only cutover-guard record, and CI job logs for tests-corpus and built-in-corpus-suite timing, plus floors) is taken at implement start; see the plan's Baseline section. ruff check . and ruff format --check . were measured clean at planning on 2922 files. No pre-existing red was found, so no tracker issue is owed under the Pre-existing Failure Reporting Rule at this point.
R-9 Supply-chain evidence (DIRECTIVE_051, supply-chain-install-safety)
The plan adds dependencies in two ecosystems (JVM through Gradle; Go binaries). Python gains none. Results per threat-class control; none is silently skipped. "Result" is the design decision now; the measured outcome is recorded in the PR bodies that carry IC-07a (PR 1) and IC-07b (PR 4) and any adverse result needs explicit maintainer acknowledgement.
| Control | Design for this Mission | Result now | Evidence captured at IC-07a and IC-07b |
|---|---|---|---|
| 1 Registry authenticity | JVM artefacts only from the Gradle plugin portal and Maven Central, strict verification; Go binaries and the Gradle distribution only from their vendors' official HTTPS release locations; no mirror | Designed | The repositories block of the Gradle build; the download URLs in pins.json |
| 2 Package freshness | pins.json records each artefact's publication date; a version published less than 14 days before the pin date is adverse and needs maintainer acknowledgement | Designed | Dates in pins.json; the adverse list (empty expected) |
| 3 Deny-by-default lifecycle scripts | Gradle plugin resolution runs no install scripts; the Go binaries are downloaded and checksum-verified, never piped to a shell (no download-and-run one-liners); no package-manager install in the workflow | Designed | The workflow text, asserted by the SHA-pin and no-unpinned-install guard tests |
| 4 Lockfile-driven installs | verification-metadata.xml (sha256 for every resolved artefact, strict mode) and pins.json are the lock; Python uses the existing uv.lock through a frozen sync | Designed | The two files; the tamper fixtures |
| 5 Incident list and IoC posture | Before each pin, the vendor's advisory feed for the artefact is read and the result recorded; no incident package name is hard-coded in the repository | To do at IC-07a and IC-07b | The cited advisory feeds consulted and the result |
Adversarial challenge pass: requested after the plan point-cut and before implementation, per the adversarial-squad-deployment procedure; the lenses that matter most are supply chain, gate non-vacuity and the bundler-fidelity risk. No contested finding is left undispositioned in this file.
R-10 Event stream cursor naming (verified)
The spec's stream cursor {offset, invariant} maps to TailCursor in specify_cli/status/tail_reader.py, whose fields are offset and content_invariant; the empty-digest sentinel is the module constant EMPTY_DIGEST ("nothing consumed yet"). The contract field is invariant, so the citation names the class and the attribute content_invariant, which is a bare annotated class attribute. The citation check therefore must treat an annotated class-level target as a defined name (decision PQ-10); the spec's own example would otherwise not resolve. ResumeRefused in the same module carries the four refusal reasons the contract maps to Problem responses.
R-11 Dashboard evidence is recoverable but not citable (verified)
The dashboard sources removed by #5545 and #5530 remain readable at the recorded base commit with git show d78aa2345:src/specify_cli/dashboard/scanner.py (an ancestor of this branch; _derive_mission_status, _derive_next_action, get_workflow_status, build_mission_registry and scan_all_features are present there). That is how the pinned expected-output fixture is produced once (IC-09) without reintroducing the code. No contract citation may point at a path that git ls-files does not list on the branch tip; the citation check enforces it.
R-12 What is not known yet (honest list)
1. Bundler fidelity (R-3): needs CI. 2. Exact tool versions, dates and checksums (R-2, R-9): chosen at IC-07a (JDK, Gradle, plugin) and IC-07b (vacuum, oasdiff). 3. How diff-cover behaves on a pull request with no changed critical-path lines (the scope is the CRITICAL_PATHS tuple in scripts/ci/aggregate_source.py): read at implement start from an existing pull request with the same module selection; baseline step 6. 4. Whole-job duration of tests-corpus and of the push-to-main built-in-corpus-suite (20 minute budget, coverage tracer attached) after this Mission (NFR-001): baselined from existing logs, then measured from the first draft-PR run and a full-mode packs.yml dispatch. 5. Whether the two new workflow files trip test_dual_mode_contract.py or the release-ownership guard: read as no; run as named files at implement start to confirm. 6. The canonical $ref spelling for brace-named files, and whether openapi-generator's TypeScript generator accepts the split layout at all: decided by the IC-07a spike (E-4). 7. #5557 may merge before this Mission does and change the router, the registry and the gate files; the plan's edits are additive and re-verified after each rebase.