Cut-over guard: fail-closed pre-merge gate for runtime-state cut-over

Status: stable

Authority:

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-from file, missing project root). The guard never passes on uncertainty (NFR-003) — any verify error, ambiguous corpus, missing mission_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.json has no status_phase, or a well-formed one below 1;
  • it has no terminal evidence: no accepted_at, merged_at, accept_commit, merged_commit, acceptance_history or mission_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 absent mission_id);
  • an accepted_at or merged_at that is present but not a non-empty string;
  • a top-level key repeated in meta.json (the last value would win);
  • a tasks/, tasks.md or 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 main without 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's changes job). A diff that touches only kitty-specs/** does not set the src-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 is if:-skipped reports neutral and does not block merge" footgun).
  • release-readiness.yml is a small, pull_request-scoped workflow with no such gate on its own trigger. Its on.pull_request.paths filter has been extended to include kitty-specs/** specifically so that a corpus-only PR triggers this workflow (and therefore the cutover-guard job) at all. If kitty-specs/** is ever removed from that paths list, the guard stops firing on corpus-only PRs with no other symptom — treat that path entry as load-bearing.
  • The cutover-guard job itself carries no changes/dorny step gate. It has exactly one conditional: the standard repo-wide pr:deferred / pr:skip-ci label skip (the same explicit, operator-visible opt-out also used in ci-windows.yml and check-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:

  1. Open the repository's branch protection settings for the target branch (main; the workflow's pull_request.branches list also still names the legacy 2.x branch).
  2. Under "Require status checks to pass before merging", add cutover-guard (the job name from release-readiness.yml) to the required-checks list.
  3. 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 0 when 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.