Cut-over guard: fail-closed pre-merge gate for runtime-state cut-over
Status: stable
Authority:
kitty-specs/runtime-state-birth-cutover-all-paths-01KYH654/contracts/pre-merge-guard.md(IC-03 / IC-04; FR-002, FR-003, FR-008, FR-009, NFR-002, NFR-003)- Mission
runtime-state-birth-cutover-all-paths-01KYH654, WP03 (guard CLI) and WP04 (CI wiring, this document)
What the guard does
spec-kitty cutover-guard is a diff-scoped, fail-closed check that blocks a
pull request unless every kitty-specs/<mission>/ corpus touched by that PR's
diff has been cut over to event-log-authoritative runtime state, or is a
pre-accept Mission, which is exempt from the stamp check.
"Cut over" is decided using the same event-log-evidence authority the
dogfood corpus lock uses
(specify_cli.status.cutover_eligibility.is_cut_over) — a mission carries a
runtime-state event log
(_mission_carries_event_log_runtime) AND satisfies the non-empty-snapshot
birth invariant (_assert_birth_invariant_holds). verify_backfill.ok is
checked but is not sufficient on its own — it is vacuous for
natively-born missions (they never ran a backfill and can still report
ok), so the guard never keys solely on it.
The guard is a second, independent caller of that same authority; it is not a fork of the decision logic.
Exit behavior
- Exit 0 — every mission touched by the diff is cut over or pre-accept
(including the
vacuous case where the diff touches no
kitty-specs/mission at all). - Exit 1 — one or more touched missions are un-cut-over, OR the diff
itself could not be determined (unknown base ref, unreadable
--paths-fromfile, missing project root). The guard never passes on uncertainty (NFR-003) — any verify error, ambiguous corpus, missingmission_id, or missing mission directory is treated as a failure, not a skip.
On failure the guard prints the un-cut-over mission slug(s) and a remedy specific to the reason (see Remedies). The default remedy is:
spec-kitty migrate backfill-runtime-state --mission <slug>
Pre-accept Missions
A Mission driven only by the canonical commands carries event-log runtime
evidence from its first claim, but the status_phase stamp is written only at
accept/consolidate, by design (#2917). Before this exemption, every in-flight
Mission failed the guard on its first push (#5835, #5300).
A Mission is exempt, and passes with the note
pre-accept: status_phase stamp deferred to accept/consolidate, when all of
these hold:
- it has event-log evidence of runtime state;
meta.jsonhas nostatus_phase, or a well-formed one below1;- it has no terminal evidence: no
accepted_at,merged_at,accept_commit,merged_commit,acceptance_historyormission_number; - no work package carries legacy claim runtime in its WP frontmatter;
- it has a
mission_id.
What counts as legacy claim runtime. Only a non-empty shell_pid,
shell_pid_created_at or assignee, or a completed review override, in a WP
file. agent, agent_profile, role, model and tracker_refs do not count:
the canonical tasks-packages step writes them at planning time, before any claim
(operator ruling of 2026-10-07). Checked tasks.md subtask rows do not count
either. spec-kitty migrate backfill-runtime-state still seeds all of them.
The guard lists exempt Missions in a separate section of the report and in the
exempt key of --json. It does not call them cut over. Each exempt item is
{"slug": "<mission slug>", "reasons": ["<pre-accept note>"]}. The key is
additive: consumers that ignore unknown keys are unaffected.
The exemption fails closed. Each of these makes the Mission fail, never pass:
- a malformed or negative
status_phase; - a missing or unparsable
meta.json(reported as an absentmission_id); - an
accepted_atormerged_atthat is present but not a non-empty string; - a top-level key repeated in
meta.json(the last value would win); - a
tasks/,tasks.mdor WP file the guard cannot read to rule out legacy runtime. The failure reason names the file and the error.
Remedies
| Reason | Remedy |
|---|---|
| Accepted or merged, but no stamp; or legacy WP frontmatter | spec-kitty migrate backfill-runtime-state --mission <slug> |
status_phase malformed |
remove the malformed status_phase from kitty-specs/<slug>/meta.json (accept and consolidate stamp it), or, for an accepted Mission, run spec-kitty migrate backfill-runtime-state --mission <slug> |
accepted_at or merged_at is not a non-empty string |
set it to an ISO-8601 string (or remove the key) in kitty-specs/<slug>/meta.json, then rerun |
A top-level key repeats in meta.json |
remove the repeated key from kitty-specs/<slug>/meta.json, then rerun |
A file unreadable (tasks.md, a WP file, or tasks/ itself) |
repair the file the reason names, then rerun |
Absent mission_id (also a missing or unparsable meta.json) |
repair kitty-specs/<slug>/meta.json if it is missing or not valid JSON; otherwise spec-kitty migrate backfill-identity --mission <slug> |
Known limits
- A Mission merged to
mainwithout accept, and with no legacy runtime state, stays exempt: nothing marks it terminal (#5850). - A coordination-topology Mission's event log may be absent from a PR head. This is pre-existing; a follow-up tracks it.
How it is invoked
# CI usage — diff against the PR's base ref
spec-kitty cutover-guard --base-ref origin/main
# CI usage — host already has the changed-file list (e.g. a PR file listing)
spec-kitty cutover-guard --paths-from changed-files.txt
# Either form, machine-readable
spec-kitty cutover-guard --base-ref origin/main --json
Exactly one of --base-ref / --paths-from is required.
How it triggers in CI (the corpus-only-PR requirement)
The guard runs as the cutover-guard job in
.github/workflows/release-readiness.yml, not in ci-quality.yml or the
modular CI. This placement is deliberate:
- The test workflows decide which jobs run from a paths filter over the
source areas a diff touches (today
ci-router.yml'schangesjob). A diff that touches onlykitty-specs/**does not set thesrc-family outputs, so a guard job hosted there and gated the same way would never execute on a corpus-only PR — exactly the leak this WP exists to close (risk R1, and the general "a required check that isif:-skipped reports neutral and does not block merge" footgun). release-readiness.ymlis a small,pull_request-scoped workflow with no such gate on its own trigger. Itson.pull_request.pathsfilter has been extended to includekitty-specs/**specifically so that a corpus-only PR triggers this workflow (and therefore thecutover-guardjob) at all. Ifkitty-specs/**is ever removed from thatpathslist, the guard stops firing on corpus-only PRs with no other symptom — treat that path entry as load-bearing.- The
cutover-guardjob itself carries nochanges/dornystep gate. It has exactly one conditional: the standard repo-widepr:deferred/pr:skip-cilabel skip (the same explicit, operator-visible opt-out also used inci-windows.ymlandcheck-spec-kitty-events-alignment.yml). Beyond that label check, the job runs unconditionally whenever the workflow runs — it does not sit behind any src-changes-shaped filter that a corpus-only diff could fail to satisfy.
Registering it as a required check (operator action)
Wiring the job into the workflow is necessary but not sufficient. GitHub branch protection only blocks a merge on checks that are explicitly registered as required. The operator must:
- Open the repository's branch protection settings for the target branch
(
main; the workflow'spull_request.brancheslist also still names the legacy2.xbranch). - Under "Require status checks to pass before merging", add
cutover-guard(the job name fromrelease-readiness.yml) to the required-checks list. - Confirm the setting was saved — GitHub's UI only offers checks that have run at least once on that branch, so the job must have executed on some PR before it appears in the picker.
The skipped-required-check footgun
A required check whose job is if:-skipped for a given run reports a
neutral/"skipped" conclusion in GitHub's UI, not a failure — and GitHub
treats a skipped required check as satisfied, letting the PR merge. This is
exactly why the job above avoids any conditional beyond the label skip: a
required check that can legitimately be skipped for reasons unrelated to
the label opt-out is a required check that can silently fail open.
If this job is ever refactored, re-verify — with a live scratch PR touching
only kitty-specs/** — that it still executes (not skips) on a corpus-only
diff. Do not trust a reading of the paths:/if: YAML alone; the mission
that produced this guard (runtime-state-birth-cutover-all-paths-01KYH654)
was itself created to close a gap that a path-filter reading alone had
missed (see #2968).
Local verification (CI-equivalent simulation)
Because a real GitHub Actions run only happens once a PR is opened, the
job's behavior can be simulated locally with --paths-from, which accepts
exactly the changed-path list the CI job would otherwise obtain from a real
diff:
# 1. A synthetic corpus-only changed-path list (as if only this file changed)
echo "kitty-specs/<mission-slug>/status.events.jsonl" > /tmp/changed-paths.txt
# 2. Run the guard exactly as CI would
spec-kitty cutover-guard --paths-from /tmp/changed-paths.txt
This proves, without needing a live GitHub run:
- the guard triggers (evaluates the mission) on a changed-path set that
touches only
kitty-specs/**; - it exits non-zero and names the mission plus the remedy command when that mission is un-cut-over;
- it exits
0when the named mission is cut over.
The true end-to-end verification — that GitHub itself schedules and runs
the cutover-guard job for a real corpus-only PR — is recorded as a
separate, mandatory piece of evidence (subtask T019) gathered against a real
scratch PR, since it cannot be produced from a local git checkout.