feat: enforce stable-control vs dev runtime modes for Gitea MCP (Closes #615)

The stable-control runtime ADR (docs/architecture/mcp-stable-control-runtime-policy-adr.md)
established the policy but had no runtime enforcement: a daemon relaunched from a
feature worktree still holds production credentials and will happily mutate real
issues. This adds the enforcement layer (acceptance criteria 6-11).

stable_control_runtime.py:
- classify_runtime_mode(): stable-control | dev-test | unknown, inferred from the
  process root and checkout branch, with an explicit GITEA_MCP_RUNTIME_MODE
  declaration for packaged layouts that have no git checkout.
- build_runtime_report(): runtime mode, git SHA, branch, checkout path, process
  root, active workspace, repo binding, profile, identity, dirty files,
  alignment, and real_mutations_allowed.
- assess_runtime_mutation_gate(): fails closed on dev-test targeting production,
  unknown runtime, dirty stable checkout, dev-worktree launch, and unsafe
  process-root/workspace alignment.
- Post-transport-flap re-proving tracked per namespace, so proving the author
  namespace never implies reviewer, merger, or reconciler (#584).
- assess_promotion_record(): promotion must record previous and promoted SHAs
  plus health, identity, profile, workspace, capability, and rollback proof.

Server wiring:
- _profile_operation_gate() consults the runtime gate alongside the #420 parity
  gate. gitea.read is never blocked, so an operator can still diagnose a sick
  runtime.
- The gate reads a startup snapshot rather than shelling out per mutation, for
  the same reason parity uses a startup baseline: the runtime a process serves
  from is fixed when it loads its code. Enforcement is decided from how the
  process was loaded, so per-test production simulation cannot switch it on.
- gitea_get_runtime_context() reports the live runtime under
  stable_control_runtime and points at the promotion runbook when blocked.

Docs and tooling:
- docs/stable-runtime-promotion-runbook.md: operator promotion procedure,
  required record fields, per-namespace re-proving, rollback.
- scripts/promote-stable-runtime: read-only helper that emits and validates a
  promotion record; it never restarts anything.
- Five canonical [THREAD STATE LEDGER] examples: runtime healthy, transport flap
  recovered, namespace not yet re-proven, promotion completed, rollback required.
- ADR section 5 follow-ups marked landed; runbooks cross-link the new runbook.

Validation: 47 new tests in tests/test_stable_control_runtime.py. Full suite
3827 passed / 6 skipped / 2 failed; both failures are pre-existing on master
059ee77 (verified in a clean baseline worktree: identical 2 failures,
3780 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-20 10:48:47 -04:00
co-authored by Claude Opus 4.8
parent 059ee77c1f
commit 9bf3acfef6
8 changed files with 1714 additions and 6 deletions
+124 -1
View File
@@ -2002,12 +2002,21 @@ import native_mcp_preference # noqa: E402
import branch_cleanup_guard # noqa: E402
import thread_state_ledger_validator # noqa: E402
import master_parity_gate # noqa: E402
import stable_control_runtime # noqa: E402
# Master-parity baseline (#420): the commit this server process started at.
# Captured once so that, at mutation time, we can detect when the on-disk
# master has advanced past the running code and fail closed until restart.
# Read-only operations are never blocked by staleness.
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
# Stable-control runtime snapshot (#615): which runtime this process serves
# from. Captured lazily on first use and then reused, so the mutation gate never
# shells out per call. Whether the gate enforces at all is decided from how the
# process was loaded -- a suite running from a branches/ worktree is dev-test by
# design, and per-test production simulation must not switch this gate on.
_STARTUP_RUNTIME_MODE: dict | None = None
_RUNTIME_MODE_GATE_UNDER_TEST = "pytest" in sys.modules or "unittest" in sys.modules
import worktree_cleanup_audit # noqa: E402
import canonical_comment_validator as ccv # noqa: E402
@@ -11356,6 +11365,92 @@ def _current_master_parity() -> dict:
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
def _current_runtime_mode_report(refresh: bool = False) -> dict:
"""Describe the runtime this process is serving from (#615).
Combines the observed checkout facts at ``PROJECT_ROOT`` with the workspace
binding this session resolved, so the report answers both "what code is the
control plane running" and "does it agree with the task workspace".
The mutation gate uses the **startup snapshot** (``refresh=False``), for the
same reason the #420 parity gate compares against a startup baseline: the
runtime a process serves from is fixed when it loads its code, and editing
files afterwards does not change what is already in memory. That also keeps
the mutation path free of per-call subprocess reads. ``refresh=True`` re-reads
live state for the read-only reporting path.
"""
global _STARTUP_RUNTIME_MODE
if not refresh and _STARTUP_RUNTIME_MODE is not None:
return _STARTUP_RUNTIME_MODE
observed = stable_control_runtime.observe_runtime(PROJECT_ROOT)
workspace_root = None
aligned = None
canonical_root = None
try:
ctx = _resolve_namespace_mutation_context(None)
workspace_root = ctx.get("workspace_path")
canonical_root = ctx.get("canonical_repo_root")
aligned = os.path.realpath(workspace_root or "") == (
ctx.get("process_project_root") or ""
)
except Exception:
# An unresolvable binding is reported as unknown alignment, never as
# proof of alignment.
aligned = None
try:
profile_name = get_profile()["profile_name"]
except Exception:
profile_name = None
report = stable_control_runtime.build_runtime_report(
process_root=PROJECT_ROOT,
checkout_branch=observed["checkout_branch"],
runtime_head=observed["runtime_head"],
is_git_checkout=observed["is_git_checkout"],
dirty_files=observed["dirty_files"],
active_task_workspace=workspace_root,
canonical_repository_root=canonical_root,
workspace_roots_aligned=aligned,
profile=profile_name,
declared_mode=stable_control_runtime.declared_runtime_mode(),
)
if _STARTUP_RUNTIME_MODE is None:
_STARTUP_RUNTIME_MODE = report
return report
def _runtime_mode_block(op: str) -> list[str]:
"""Fail-closed runtime-mode reasons for a mutation *op* (#615).
Real Gitea mutations may only run on the promoted stable control runtime.
Read-only operations are never blocked: a dev/test or unknown runtime can
still be inspected, which is exactly how an operator diagnoses it.
Under unit-test isolation the gate is skipped unless the explicit #683
force-on flag requests production behaviour: the suite legitimately runs
from ``branches/`` worktrees, which classify as ``dev-test`` by design. That
decision is taken from how the *process* was loaded, not from a per-call
check, so a test that simulates production for some other guard does not
silently switch this one on. The legacy dirty/porcelain force flags are
deliberately not honoured here -- they request dirtiness paths, not a claim
that the runtime is promoted.
"""
if op == "gitea.read":
return []
if _RUNTIME_MODE_GATE_UNDER_TEST and not (
workflow_scope_guard.production_guards_forced()
):
return []
try:
report = _current_runtime_mode_report()
except Exception as exc:
return [
"runtime mode could not be assessed (fail closed): "
f"{_redact(str(exc))}"
]
return stable_control_runtime.runtime_block_reasons(report)
def _master_parity_block(op: str) -> list[str]:
"""Fail-closed staleness reasons for a mutation *op* (#420).
@@ -11380,11 +11475,16 @@ def _profile_operation_gate(op: str) -> list[str]:
A mutating operation is additionally refused when the server code is stale
relative to master (#420), so a long-running process cannot bypass a
capability gate that has since been merged.
capability gate that has since been merged, and when the runtime itself is
not the promoted stable control runtime (#615) -- a dev/test or unknown
runtime holds production credentials but has not been promoted.
"""
stale_reasons = _master_parity_block(op)
if stale_reasons:
return stale_reasons
runtime_reasons = _runtime_mode_block(op)
if runtime_reasons:
return runtime_reasons
try:
profile = get_profile()
except Exception as exc:
@@ -15027,6 +15127,29 @@ def gitea_get_runtime_context(
except Exception:
pass
# #615: runtime mode + SHA reporting, so a session can tell whether it is
# talking to the promoted stable control runtime before it mutates.
try:
runtime_mode_report = _current_runtime_mode_report(refresh=True)
result["stable_control_runtime"] = runtime_mode_report
result["stable_control_runtime"]["summary"] = (
stable_control_runtime.format_runtime_mode(runtime_mode_report)
)
# The advisory only replaces safe_next_action when the gate is live for
# this process; under test isolation the report stays informational.
gate_live = not _RUNTIME_MODE_GATE_UNDER_TEST or (
workflow_scope_guard.production_guards_forced()
)
if gate_live and not runtime_mode_report["real_mutations_allowed"]:
result["safe_next_action"] = (
"Runtime is not the promoted stable control runtime "
f"({stable_control_runtime.format_runtime_mode(runtime_mode_report)}); "
"real mutations are blocked. Operator promotion is required — "
"see docs/stable-runtime-promotion-runbook.md."
)
except Exception:
pass
parity = _current_master_parity()
result["master_parity"] = {
"in_parity": parity["in_parity"],