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
@@ -171,13 +171,18 @@ then:
- Does not replace CI or code review for MCP changes
- Does not authorize editing stable checkout “because tests need a quick fix”
## 5. Implementation follow-ups (optional tooling)
## 5. Implementation follow-ups
These may land in later issues; the **policy binds sessions now**:
The **policy binds sessions now**. The enforcement layer landed with issue #615
acceptance criteria 611 in `stable_control_runtime.py`:
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
3. Promotion checklist script that emits the durable promotion marker fields.
1. ~~Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
Post-transport-flap proof is enforced per namespace: a flap invalidates every
`gitea-*` namespace at once, and author proof never transfers to reviewer,
merger, or reconciler (`namespace_not_reproven_after_flap`).
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
+1
View File
@@ -1241,6 +1241,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
## Related documents
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
- [`stable-runtime-promotion-runbook.md`](stable-runtime-promotion-runbook.md) — operator promotion procedure, required promotion-record fields, per-namespace post-flap re-proving, and rollback for the stable control runtime (#615).
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
+121
View File
@@ -0,0 +1,121 @@
# Stable control runtime — promotion runbook (#615)
Operator / release-manager procedure for promoting a revision into the **stable
control runtime**: the Gitea MCP server that performs real issue/PR mutations.
Policy source: [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md).
Enforcement: `stable_control_runtime.py` (runtime mode classification, mutation
gates, per-namespace post-flap re-proving, promotion-record validation).
**Promotion is operator-owned.** Normal author / reviewer / merger / reconciler
sessions must never kill, restart, or relaunch the MCP server, and must never
edit the stable runtime checkout. A session that needs newer server code stops
with `BLOCKED + DIAGNOSE` and hands off to the operator.
---
## 1. When a promotion is required
- A merged PR changes MCP server code the control plane must now enforce.
- `gitea_assess_master_parity` reports `stale: true` / `restart_required: true`.
- `gitea_get_runtime_context` reports a `runtime_mode` other than
`stable-control`, or `real_mutations_allowed: false`.
## 2. Pre-promotion checks
Run these **before** advancing the stable checkout:
1. The target revision is on remote `master` and was merged through
`gitea_merge_pr` (never a direct stable-branch push — see #671).
2. The stable control checkout is clean (`git status --porcelain` empty) and on
`master`. A dirty stable runtime is itself a mutation blocker.
3. The advance is strictly fast-forwardable: local `master` is an ancestor of
`prgs/master`.
4. No active workflow lease is mid-mutation (`gitea_list_workflow_leases`).
## 3. Promotion steps
1. Record the **previous** runtime SHA (`gitea_assess_master_parity`
`startup_head`).
2. `git fetch --prune prgs` in the stable control checkout.
3. `git merge --ff-only prgs/master` — never rebase, reset, or force.
4. Record the **promoted** runtime SHA (`git rev-parse HEAD`).
5. Reload the runtime using the sanctioned client path (IDE/client reconnect or
the operator's supervised service reload). Never `pkill` the daemon from a
workflow session.
6. Re-prove **each** namespace independently (see §5).
7. Record the promotion (see §4) and post it as a durable comment on the
tracking issue.
## 4. Promotion record (required fields)
Every promotion must record all of the following. `assess_promotion_record()`
validates them and fails closed on any missing field, or when
`previous_runtime_sha` equals `promoted_runtime_sha` (nothing was promoted).
| Field | Meaning |
|-------|---------|
| `previous_runtime_sha` | SHA the stable runtime was serving before promotion |
| `promoted_runtime_sha` | SHA the stable runtime serves after promotion |
| `source_branch` | Branch the promoted revision came from |
| `source_pr` | PR number that merged it |
| `restart_method` | Exact reload/restart mechanism the operator used |
| `health_check_proof` | `gitea_assess_mcp_namespace_health` result per namespace |
| `identity_proof` | `gitea_whoami` username + profile per namespace |
| `profile_proof` | `gitea_get_runtime_context` active profile per namespace |
| `workspace_proof` | Process root, canonical root, alignment, clean state |
| `mutation_capability_proof` | `gitea_resolve_task_capability` for the intended task |
| `rollback_instructions` | Exact steps to return to `previous_runtime_sha` |
Helper: `scripts/promote-stable-runtime` emits and validates the record. It
never restarts anything — it reads state and prints the record for the operator
to act on and archive.
## 5. Post-promotion namespace re-proving
A restart or transport flap drops every `gitea-*` namespace together. Author
proof is **not** global proof. For each of `author`, `reviewer`, `merger`,
`reconciler`, in that namespace:
1. `gitea_whoami`
2. `gitea_get_runtime_context`
3. `gitea_resolve_task_capability` immediately before the intended mutation
4. Mutate only when no reconnect / restart / stale-runtime gate is reported
Until a namespace passes all four, its mutations stay blocked with
`namespace_not_reproven_after_flap`.
## 6. Rollback
If the promoted runtime is unhealthy — namespace EOF that does not recover,
identity or profile mismatch, capability resolution failure, or an unexpected
`runtime_mode`:
1. **Stop all PR/review/merge work.** An unhealthy stable runtime fails closed;
do not route around it.
2. Fast-forward or check out `previous_runtime_sha` in the stable checkout.
3. Reload the runtime by the same sanctioned method.
4. Re-prove every namespace (§5).
5. Record the rollback as a promotion record whose `promoted_runtime_sha` is the
restored SHA, with the failure evidence in `health_check_proof`.
## 7. Runtime modes seen in reports
| Mode | Meaning | Real mutations |
|------|---------|----------------|
| `stable-control` | Promoted revision, stable branch, clean checkout | Allowed |
| `dev-test` | Launched from a `branches/` worktree or a feature branch | Blocked against production |
| `unknown` | Root unresolvable, not a git checkout, or detached HEAD with no declaration | Blocked |
A packaged deployment with no git checkout must declare itself explicitly with
`GITEA_MCP_RUNTIME_MODE=stable-control`; an unset or misspelled value falls back
to inference and, failing that, to `unknown`.
## 8. Related
- `architecture/mcp-stable-control-runtime-policy-adr.md` — the policy (#615)
- `mcp-namespace-health.md` — client-namespace health (#543)
- `mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery
- `mcp-daemon-import-guard.md` — sanctioned daemon only (#558)
- `bootstrap-review-path.md` — controller bootstrap when the live runtime cannot
review its own fix (#557)
+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"],
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
[--source-branch <branch>] [--source-pr <n>] \
[--restart-method <text>] [--rollback <text>] \
[--health-check-proof <text>] \
[--identity-proof <text>] [--profile-proof <text>] \
[--workspace-proof <text>] \
[--mutation-capability-proof <text>]
Emit and validate a stable-control-runtime promotion record (#615).
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
anything: promotion itself is an operator action documented in
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
state, assembles the required record, validates it with
stable_control_runtime.assess_promotion_record(), and prints it for the operator
to act on and archive.
Defaults:
--root the repository root containing this script
--promoted HEAD of that root
Exit status is non-zero when the assembled record is incomplete, so a promotion
cannot be recorded without its proof fields.
Example:
scripts/promote-stable-runtime \
--source-branch feat/issue-615-runtime-mode-enforcement \
--source-pr 770 \
--restart-method "IDE client reconnect (/mcp)" \
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
--identity-proof "gitea_whoami per namespace" \
--profile-proof "gitea_get_runtime_context per namespace" \
--workspace-proof "process root == canonical root; clean" \
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
EOF
}
ROOT=""
PROMOTED=""
SOURCE_BRANCH=""
SOURCE_PR=""
RESTART_METHOD=""
ROLLBACK=""
HEALTH_PROOF=""
IDENTITY_PROOF=""
PROFILE_PROOF=""
WORKSPACE_PROOF=""
CAPABILITY_PROOF=""
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="${2:-}"; shift 2 ;;
--promoted) PROMOTED="${2:-}"; shift 2 ;;
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
exit 1
fi
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
if [[ -z "$PREVIOUS" ]]; then
# The runtime the operator is replacing. Best-effort: the commit master
# pointed at before the fast-forward, recorded in the reflog.
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
fi
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "$WORKSPACE_PROOF" ]]; then
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
fi
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
fi
cat <<EOF
# Stable control runtime promotion record (#615)
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
promoted_runtime_sha: ${PROMOTED}
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
workspace_proof: ${WORKSPACE_PROOF}
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
EOF
if [[ "$DIRTY" != "0" ]]; then
{
echo
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
} >&2
fi
PYTHON_BIN="${PYTHON_BIN:-python3}"
RECORD_JSON="$(
ROOT="$ROOT" \
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
ROLLBACK="$ROLLBACK" \
"$PYTHON_BIN" -c '
import json
import os
print(json.dumps({
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
"source_pr": os.environ.get("SOURCE_PR", ""),
"restart_method": os.environ.get("RESTART_METHOD", ""),
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
"rollback_instructions": os.environ.get("ROLLBACK", ""),
}))
'
)"
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
import json
import os
import sys
sys.path.insert(0, os.environ["ROOT"])
import stable_control_runtime as scr
record = json.loads(os.environ["RECORD_JSON"])
assessment = scr.assess_promotion_record(record)
print()
print("validation:", json.dumps(assessment, indent=2))
print()
if not assessment["valid"]:
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
sys.exit(1)
print("Promotion record is complete. Archive it on the tracking issue.")
'
+628
View File
@@ -0,0 +1,628 @@
"""Stable-control vs dev/test runtime classification and mutation gates (#615).
``docs/architecture/mcp-stable-control-runtime-policy-adr.md`` states the policy:
real Gitea mutations may only be performed by the **stable control runtime**,
while MCP server development happens in isolated ``branches/`` worktrees and
optional dev/test runtimes. The ADR alone is not enforcement — a daemon
relaunched from a feature worktree still holds production credentials and will
happily mutate production issues.
This module supplies the runtime half of that policy:
* :func:`classify_runtime_mode` decides whether the running process is a
``stable-control``, ``dev-test``, or ``unknown`` runtime.
* :func:`build_runtime_report` collects the reporting fields the ADR requires
(mode, SHA, branch, checkout path, process root, workspace, binding, dirty
files, alignment, and whether real mutations are allowed).
* :func:`assess_runtime_mutation_gate` turns that report into a fail-closed
mutation gate.
* The post-transport-flap helpers keep namespace re-proving **per namespace**,
so proving the author namespace never implies the reviewer, merger, or
reconciler namespace is callable.
Every assessment is pure: callers inject the observed facts, so the logic is
unit-testable without a git checkout or a live daemon. Only the thin
:func:`observe_runtime` reader touches the filesystem.
"""
from __future__ import annotations
import os
import subprocess
# Operator declaration of the runtime this process is. An explicit, valid
# declaration wins over inference — an operator running a packaged release
# layout may have no git checkout to infer from.
ENV_RUNTIME_MODE = "GITEA_MCP_RUNTIME_MODE"
# Escape hatch mirroring the #420 parity gate: disables enforcement only.
ENV_DISABLE = "GITEA_MCP_DISABLE_RUNTIME_MODE_GATE"
RUNTIME_MODE_STABLE = "stable-control"
RUNTIME_MODE_DEV_TEST = "dev-test"
RUNTIME_MODE_UNKNOWN = "unknown"
VALID_RUNTIME_MODES = frozenset(
{RUNTIME_MODE_STABLE, RUNTIME_MODE_DEV_TEST, RUNTIME_MODE_UNKNOWN}
)
# Branches a stable control checkout is allowed to sit on. Anything else is a
# development checkout by definition (the global worktree rule keeps the
# control checkout on a stable branch).
STABLE_BRANCHES = frozenset({"master", "main", "dev"})
# Path segment that marks an isolated development worktree.
DEV_WORKTREE_SEGMENT = "branches"
BLOCKER_DEV_TEST_PRODUCTION = "dev_test_runtime_targets_production"
BLOCKER_UNKNOWN_RUNTIME = "unknown_runtime_mode"
BLOCKER_DIRTY_STABLE_RUNTIME = "dirty_stable_runtime_checkout"
BLOCKER_DEV_WORKTREE_LAUNCH = "runtime_launched_from_dev_worktree"
BLOCKER_UNSAFE_ALIGNMENT = "unsafe_process_root_workspace_alignment"
BLOCKER_NAMESPACE_NOT_REPROVEN = "namespace_not_reproven_after_flap"
# Namespaces that must each be re-proven independently after a transport flap.
WORKFLOW_NAMESPACES = ("author", "reviewer", "merger", "reconciler")
def gate_disabled() -> bool:
"""Whether the runtime-mode gate is disabled by env escape hatch."""
return bool((os.environ.get(ENV_DISABLE) or "").strip())
def declared_runtime_mode() -> str | None:
"""Return the operator-declared runtime mode, if a valid one is set.
An unset or unrecognised value returns ``None`` so classification falls
back to inference rather than trusting a typo.
"""
value = (os.environ.get(ENV_RUNTIME_MODE) or "").strip().lower()
if value in VALID_RUNTIME_MODES:
return value
return None
def _path_segments(path: str) -> list[str]:
return [seg for seg in os.path.normpath(path).split(os.sep) if seg]
def launched_from_dev_worktree(process_root: str | None) -> bool:
"""Whether *process_root* sits inside a ``branches/`` development worktree."""
if not process_root:
return False
return DEV_WORKTREE_SEGMENT in _path_segments(process_root)
def classify_runtime_mode(
*,
process_root: str | None,
checkout_branch: str | None,
is_git_checkout: bool = True,
declared_mode: str | None = None,
) -> dict:
"""Classify the runtime this process is serving from.
``declared_mode`` (normally :func:`declared_runtime_mode`) is authoritative
when supplied and valid. Otherwise the mode is inferred:
* no resolvable root, or a root that is not a git checkout → ``unknown``
(a packaged deployment must declare its mode explicitly);
* a root inside a ``branches/`` worktree → ``dev-test``;
* an unreadable branch → ``unknown``;
* a stable branch (``master``/``main``/``dev``) → ``stable-control``;
* any other branch → ``dev-test``.
Returns the mode plus the discriminating facts and human-readable reasons.
"""
reasons: list[str] = []
dev_worktree = launched_from_dev_worktree(process_root)
if declared_mode in VALID_RUNTIME_MODES:
reasons.append(
f"runtime mode declared by operator via {ENV_RUNTIME_MODE}="
f"{declared_mode}"
)
return _mode_result(declared_mode, dev_worktree, True, reasons)
if not process_root:
reasons.append(
"runtime process root could not be resolved; runtime mode is "
"indeterminate"
)
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
if not is_git_checkout:
reasons.append(
f"runtime process root '{process_root}' is not a git checkout and "
f"no {ENV_RUNTIME_MODE} declaration was supplied"
)
return _mode_result(RUNTIME_MODE_UNKNOWN, dev_worktree, False, reasons)
if dev_worktree:
reasons.append(
f"runtime was launched from development worktree '{process_root}' "
f"(inside '{DEV_WORKTREE_SEGMENT}/')"
)
return _mode_result(RUNTIME_MODE_DEV_TEST, True, False, reasons)
branch = (checkout_branch or "").strip()
if not branch:
reasons.append(
f"runtime checkout branch at '{process_root}' could not be read "
f"(detached HEAD or unreadable); runtime mode is indeterminate"
)
return _mode_result(RUNTIME_MODE_UNKNOWN, False, False, reasons)
if branch in STABLE_BRANCHES:
reasons.append(
f"runtime checkout '{process_root}' is on stable branch '{branch}'"
)
return _mode_result(RUNTIME_MODE_STABLE, False, False, reasons)
reasons.append(
f"runtime checkout '{process_root}' is on development branch "
f"'{branch}', not a stable branch "
f"({', '.join(sorted(STABLE_BRANCHES))})"
)
return _mode_result(RUNTIME_MODE_DEV_TEST, False, False, reasons)
def _mode_result(mode, dev_worktree, declared, reasons) -> dict:
return {
"runtime_mode": mode,
"dev_worktree_launched": bool(dev_worktree),
"declared": bool(declared),
"reasons": list(reasons),
}
def build_runtime_report(
*,
process_root: str | None,
checkout_branch: str | None,
runtime_head: str | None,
active_task_workspace: str | None = None,
canonical_repository_root: str | None = None,
repository_slug: str | None = None,
profile: str | None = None,
authenticated_identity: str | None = None,
dirty_files: list[str] | tuple[str, ...] | None = None,
workspace_roots_aligned: bool | None = None,
is_git_checkout: bool = True,
declared_mode: str | None = None,
) -> dict:
"""Build the ADR-required runtime report (#615 acceptance criterion 6).
``real_mutations_allowed`` is the summary bit: it is true only when the
corresponding mutation gate finds nothing to block on for a production
target.
"""
classification = classify_runtime_mode(
process_root=process_root,
checkout_branch=checkout_branch,
is_git_checkout=is_git_checkout,
declared_mode=declared_mode,
)
report = {
"runtime_mode": classification["runtime_mode"],
"runtime_mode_declared": classification["declared"],
"runtime_mode_reasons": classification["reasons"],
"dev_worktree_launched": classification["dev_worktree_launched"],
"runtime_git_sha": runtime_head,
"runtime_branch": checkout_branch,
"runtime_checkout_path": process_root,
"mcp_process_root": process_root,
"active_task_workspace": active_task_workspace,
"canonical_repository_root": canonical_repository_root,
"repository_slug": repository_slug,
"profile": profile,
"authenticated_identity": authenticated_identity,
"dirty_files": sorted(dirty_files or []),
"workspace_roots_aligned": workspace_roots_aligned,
"gate_enforced": not gate_disabled(),
}
gate = assess_runtime_mutation_gate(report)
report["real_mutations_allowed"] = not gate["block"]
report["mutation_block_reasons"] = gate["reasons"]
return report
def assess_runtime_mutation_gate(
report: dict,
*,
target_is_production: bool = True,
namespace: str | None = None,
namespace_reproof: dict | None = None,
) -> dict:
"""Fail-closed mutation gate for the runtime a mutation would execute in.
Blocks when (acceptance criterion 7):
* the runtime is ``dev-test`` and the mutation targets the production
repository;
* the runtime mode is ``unknown``;
* the stable runtime checkout is dirty;
* the runtime was launched from a development worktree;
* process-root / workspace alignment is unsafe;
* (criterion 8) the namespace has not been re-proven since a transport flap.
The disabled escape hatch never blocks; the caller decides read-vs-mutate
before calling.
"""
reasons: list[str] = []
blockers: list[str] = []
if gate_disabled():
return _gate_result(False, blockers, reasons, disabled=True)
mode = (report or {}).get("runtime_mode")
if mode == RUNTIME_MODE_UNKNOWN:
blockers.append(BLOCKER_UNKNOWN_RUNTIME)
reasons.append(
"runtime mode is 'unknown'; a runtime that cannot prove it is the "
f"stable control runtime must not mutate production (declare "
f"{ENV_RUNTIME_MODE} or run from a stable checkout)"
)
if mode == RUNTIME_MODE_DEV_TEST and target_is_production:
blockers.append(BLOCKER_DEV_TEST_PRODUCTION)
reasons.append(
"runtime mode is 'dev-test' and the mutation targets the "
"production repository; dev/test runtimes must not mutate real "
"issues or PRs (ADR: stable control runtime vs dev runtime)"
)
if report.get("dev_worktree_launched") and target_is_production:
blockers.append(BLOCKER_DEV_WORKTREE_LAUNCH)
reasons.append(
f"runtime was launched from a '{DEV_WORKTREE_SEGMENT}/' development "
f"worktree ('{report.get('mcp_process_root')}'); production "
f"mutations require the promoted stable control runtime"
)
dirty = list(report.get("dirty_files") or [])
if mode == RUNTIME_MODE_STABLE and dirty:
blockers.append(BLOCKER_DIRTY_STABLE_RUNTIME)
reasons.append(
"stable control runtime checkout is dirty "
f"({len(dirty)} file(s): {', '.join(dirty[:5])}"
f"{'...' if len(dirty) > 5 else ''}); the control plane must run "
"promoted, unmodified code"
)
if report.get("workspace_roots_aligned") is False:
blockers.append(BLOCKER_UNSAFE_ALIGNMENT)
reasons.append(
"process-root / active-workspace alignment is unsafe; the runtime "
"and the task workspace disagree about which checkout is being "
"mutated"
)
if namespace:
reproof = assess_namespace_reproof(namespace_reproof, namespace)
if reproof["reproof_required"] and not reproof["proven"]:
blockers.append(BLOCKER_NAMESPACE_NOT_REPROVEN)
reasons.extend(reproof["reasons"])
return _gate_result(bool(blockers), blockers, reasons)
def _gate_result(block, blockers, reasons, *, disabled=False) -> dict:
return {
"block": bool(block),
"blocker_kinds": list(blockers),
"blocker_kind": blockers[0] if blockers else None,
"reasons": list(reasons),
"gate_disabled": bool(disabled),
}
def runtime_block_reasons(
report: dict,
*,
target_is_production: bool = True,
namespace: str | None = None,
namespace_reproof: dict | None = None,
) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed)."""
gate = assess_runtime_mutation_gate(
report,
target_is_production=target_is_production,
namespace=namespace,
namespace_reproof=namespace_reproof,
)
return gate["reasons"]
def runtime_report_payload(report: dict, gate: dict | None = None) -> dict:
"""Structured recovery payload for permission-block responses."""
gate = gate or assess_runtime_mutation_gate(report)
return {
"kind": "runtime_mode_block",
"runtime_mode": report.get("runtime_mode"),
"runtime_git_sha": report.get("runtime_git_sha"),
"runtime_branch": report.get("runtime_branch"),
"runtime_checkout_path": report.get("runtime_checkout_path"),
"blocker_kind": gate.get("blocker_kind"),
"blocker_kinds": list(gate.get("blocker_kinds") or []),
"reasons": list(gate.get("reasons") or []),
"recovery": [
"Real workflow mutations run only on the promoted stable control "
"runtime (see docs/architecture/"
"mcp-stable-control-runtime-policy-adr.md).",
"Operator action: promote the intended revision into the stable "
"runtime and reload it — see "
"docs/stable-runtime-promotion-runbook.md.",
"Normal author/reviewer/merger/reconciler sessions must not kill, "
"restart, or relaunch the MCP server themselves.",
],
}
def format_runtime_mode(report: dict) -> str:
"""One-line human summary for logs / runtime context."""
mode = report.get("runtime_mode") or RUNTIME_MODE_UNKNOWN
sha = report.get("runtime_git_sha")
branch = report.get("runtime_branch") or "unknown-branch"
short = sha[:12] if sha else "unknown-sha"
suffix = (
"" if report.get("real_mutations_allowed", True) else " (mutations blocked)"
)
return f"{mode} at {short} on {branch}{suffix}"
# ---------------------------------------------------------------------------
# Post-transport-flap namespace re-proving (#615 acceptance criterion 8)
#
# A transport flap (#584) drops every gitea-* namespace at once. Proving the
# author namespace afterwards says nothing about the reviewer, merger, or
# reconciler namespace, so proof is tracked per namespace and a flap
# invalidates all of them.
# ---------------------------------------------------------------------------
REQUIRED_NAMESPACE_PROOF_STEPS = (
"whoami",
"runtime_context",
"capability_resolved",
)
def new_reproof_state() -> dict:
"""Return an empty post-flap re-proving state."""
return {"flap_at": None, "namespaces": {}}
def record_transport_flap(state: dict | None, *, at: str) -> dict:
"""Record a transport flap: every namespace must be re-proven after *at*.
Existing per-namespace proofs are kept for audit but no longer satisfy the
gate, because they were recorded before the flap.
"""
result = dict(state or new_reproof_state())
result["flap_at"] = at
result["namespaces"] = dict(result.get("namespaces") or {})
return result
def record_namespace_proof(
state: dict | None,
namespace: str,
*,
at: str,
whoami: bool = False,
runtime_context: bool = False,
capability_resolved: bool = False,
stale_runtime_reported: bool = False,
) -> dict:
"""Record proof steps completed for exactly one namespace.
A namespace whose proof reported a reconnect/restart/stale-runtime gate is
never counted as proven, regardless of which steps ran.
"""
result = dict(state or new_reproof_state())
namespaces = dict(result.get("namespaces") or {})
namespaces[(namespace or "").strip()] = {
"at": at,
"whoami": bool(whoami),
"runtime_context": bool(runtime_context),
"capability_resolved": bool(capability_resolved),
"stale_runtime_reported": bool(stale_runtime_reported),
}
result["namespaces"] = namespaces
return result
def assess_namespace_reproof(state: dict | None, namespace: str) -> dict:
"""Whether *namespace* is re-proven after the most recent transport flap.
``reproof_required`` is false when no flap has been recorded — this gate
only speaks to post-flap proof and never invents a requirement.
"""
ns = (namespace or "").strip()
store = state or {}
flap_at = store.get("flap_at")
reasons: list[str] = []
if not flap_at:
return {
"namespace": ns,
"reproof_required": False,
"proven": True,
"flap_at": None,
"proof_at": None,
"missing_steps": [],
"reasons": reasons,
}
entry = (store.get("namespaces") or {}).get(ns)
if not entry:
reasons.append(
f"MCP namespace '{ns}' has not been re-proven since the transport "
f"flap at {flap_at}; run whoami, runtime context, and capability "
f"resolve for '{ns}' itself (proof of another namespace does not "
f"transfer)"
)
return {
"namespace": ns,
"reproof_required": True,
"proven": False,
"flap_at": flap_at,
"proof_at": None,
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
"reasons": reasons,
}
proof_at = entry.get("at")
if proof_at is not None and str(proof_at) < str(flap_at):
reasons.append(
f"MCP namespace '{ns}' proof at {proof_at} predates the transport "
f"flap at {flap_at}; re-prove the namespace before mutating"
)
return {
"namespace": ns,
"reproof_required": True,
"proven": False,
"flap_at": flap_at,
"proof_at": proof_at,
"missing_steps": list(REQUIRED_NAMESPACE_PROOF_STEPS),
"reasons": reasons,
}
missing = [step for step in REQUIRED_NAMESPACE_PROOF_STEPS if not entry.get(step)]
if missing:
reasons.append(
f"MCP namespace '{ns}' post-flap proof is incomplete; missing: "
f"{', '.join(missing)}"
)
if entry.get("stale_runtime_reported"):
missing = missing or ["stale_runtime_clear"]
reasons.append(
f"MCP namespace '{ns}' reported a reconnect/restart/stale-runtime "
f"gate during re-proving; mutation stays blocked until the "
f"namespace reconnects cleanly"
)
return {
"namespace": ns,
"reproof_required": True,
"proven": not missing,
"flap_at": flap_at,
"proof_at": proof_at,
"missing_steps": list(missing),
"reasons": reasons,
}
def unproven_namespaces(
state: dict | None, namespaces=WORKFLOW_NAMESPACES
) -> list[str]:
"""Return the namespaces still requiring post-flap re-proving."""
out = []
for ns in namespaces:
assessment = assess_namespace_reproof(state, ns)
if assessment["reproof_required"] and not assessment["proven"]:
out.append(ns)
return out
# ---------------------------------------------------------------------------
# Promotion records (#615 acceptance criterion 4 / 10)
# ---------------------------------------------------------------------------
PROMOTION_REQUIRED_FIELDS = (
"previous_runtime_sha",
"promoted_runtime_sha",
"source_branch",
"source_pr",
"restart_method",
"health_check_proof",
"identity_proof",
"profile_proof",
"workspace_proof",
"mutation_capability_proof",
"rollback_instructions",
)
def assess_promotion_record(record: dict | None) -> dict:
"""Validate an operator promotion record against the ADR checklist.
A promotion that does not record both the previous and the promoted SHA is
not a promotion — it is an undocumented restart.
"""
data = record or {}
missing = [
field
for field in PROMOTION_REQUIRED_FIELDS
if not str(data.get(field) or "").strip()
]
reasons = []
if missing:
reasons.append(
"promotion record is incomplete; missing: " + ", ".join(missing)
)
previous = str(data.get("previous_runtime_sha") or "").strip()
promoted = str(data.get("promoted_runtime_sha") or "").strip()
if previous and promoted and previous == promoted:
reasons.append(
"promotion record lists the same previous and promoted SHA "
f"({previous[:12]}); nothing was promoted"
)
return {
"valid": not reasons,
"missing_fields": missing,
"reasons": reasons,
}
# ---------------------------------------------------------------------------
# Filesystem observation (the only impure helper)
# ---------------------------------------------------------------------------
def _git_capture(root: str, *args: str) -> str | None:
if not root:
return None
try:
res = subprocess.run(
["git", "-C", root, *args],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
if res.returncode != 0:
return None
return (res.stdout or "").strip() or None
def observe_runtime(process_root: str | None) -> dict:
"""Read the runtime facts classification needs from *process_root*.
Returns ``checkout_branch``, ``runtime_head``, ``is_git_checkout``, and
``dirty_files``. Every read failure degrades to ``None``/empty rather than
raising, so a runtime that cannot be inspected classifies as ``unknown``
instead of crashing the caller.
"""
empty = {
"checkout_branch": None,
"runtime_head": None,
"is_git_checkout": False,
"dirty_files": [],
}
if not process_root:
return empty
if not _git_capture(process_root, "rev-parse", "--show-toplevel"):
return empty
branch = _git_capture(process_root, "rev-parse", "--abbrev-ref", "HEAD")
if branch == "HEAD": # detached HEAD has no branch name
branch = None
porcelain = _git_capture(process_root, "status", "--porcelain") or ""
dirty = [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
return {
"checkout_branch": branch,
"runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
"is_git_checkout": True,
"dirty_files": dirty,
}
+472
View File
@@ -0,0 +1,472 @@
"""Tests for the stable-control runtime mode gates (#615).
Covers acceptance criteria 6-11: runtime mode + SHA reporting, the fail-closed
mutation gates (dev-test targeting production, unknown runtime, dirty stable
checkout, dev-worktree launch, unsafe alignment), per-namespace post-flap
re-proving, promotion-record completeness, and the policy statements that keep
normal sessions from restarting the stable MCP runtime.
"""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
import stable_control_runtime as scr # noqa: E402
SHA_A = "a" * 40
SHA_B = "b" * 40
STABLE_ROOT = "/Users/dev/Development/Gitea-Tools"
DEV_WORKTREE_ROOT = "/Users/dev/Development/Gitea-Tools/branches/issue-615-work"
def stable_report(**overrides):
"""A healthy stable-control runtime report, overridable per test."""
base = dict(
process_root=STABLE_ROOT,
checkout_branch="master",
runtime_head=SHA_A,
active_task_workspace=STABLE_ROOT,
canonical_repository_root=STABLE_ROOT,
repository_slug="Scaled-Tech-Consulting/Gitea-Tools",
profile="prgs-author",
authenticated_identity="jcwalker3",
dirty_files=[],
workspace_roots_aligned=True,
)
base.update(overrides)
return scr.build_runtime_report(**base)
class TestClassifyRuntimeMode(unittest.TestCase):
def test_stable_branch_checkout_is_stable_control(self):
res = scr.classify_runtime_mode(
process_root=STABLE_ROOT, checkout_branch="master")
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
self.assertFalse(res["dev_worktree_launched"])
def test_main_and_dev_are_also_stable(self):
for branch in ("main", "dev"):
res = scr.classify_runtime_mode(
process_root=STABLE_ROOT, checkout_branch=branch)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE, branch)
def test_branches_worktree_launch_is_dev_test(self):
res = scr.classify_runtime_mode(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
self.assertTrue(res["dev_worktree_launched"])
def test_feature_branch_outside_branches_is_still_dev_test(self):
res = scr.classify_runtime_mode(
process_root="/Users/dev/Development/scratch-clone",
checkout_branch="feat/experiment",
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
self.assertFalse(res["dev_worktree_launched"])
def test_unresolvable_root_is_unknown(self):
res = scr.classify_runtime_mode(process_root=None, checkout_branch=None)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
def test_non_git_root_is_unknown(self):
res = scr.classify_runtime_mode(
process_root="/opt/gitea-tools-release",
checkout_branch=None,
is_git_checkout=False,
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
def test_detached_head_is_unknown(self):
res = scr.classify_runtime_mode(
process_root=STABLE_ROOT, checkout_branch=None)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
def test_operator_declaration_wins_over_inference(self):
res = scr.classify_runtime_mode(
process_root="/opt/gitea-tools-release",
checkout_branch=None,
is_git_checkout=False,
declared_mode=scr.RUNTIME_MODE_STABLE,
)
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
self.assertTrue(res["declared"])
def test_invalid_declaration_is_ignored(self):
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "production-ish"}):
self.assertIsNone(scr.declared_runtime_mode())
def test_valid_declaration_is_read_from_env(self):
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "dev-test"}):
self.assertEqual(scr.declared_runtime_mode(), scr.RUNTIME_MODE_DEV_TEST)
class TestRuntimeReport(unittest.TestCase):
"""Acceptance criterion 6: runtime mode and SHA reporting."""
def test_report_carries_every_required_field(self):
report = stable_report()
for field in (
"runtime_mode",
"runtime_git_sha",
"runtime_branch",
"runtime_checkout_path",
"mcp_process_root",
"active_task_workspace",
"repository_slug",
"profile",
"authenticated_identity",
"dirty_files",
"workspace_roots_aligned",
"real_mutations_allowed",
):
self.assertIn(field, report, field)
def test_report_records_the_runtime_sha(self):
self.assertEqual(stable_report()["runtime_git_sha"], SHA_A)
def test_format_summarises_mode_sha_and_branch(self):
summary = scr.format_runtime_mode(stable_report())
self.assertIn(scr.RUNTIME_MODE_STABLE, summary)
self.assertIn(SHA_A[:12], summary)
self.assertIn("master", summary)
class TestMutationGate(unittest.TestCase):
"""Acceptance criterion 7: fail-closed mutation gates."""
def test_stable_healthy_runtime_allows_real_mutations(self):
report = stable_report()
gate = scr.assess_runtime_mutation_gate(report)
self.assertFalse(gate["block"])
self.assertEqual(gate["reasons"], [])
self.assertTrue(report["real_mutations_allowed"])
def test_dev_test_runtime_blocks_real_production_mutations(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
active_task_workspace=DEV_WORKTREE_ROOT,
)
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_DEV_TEST_PRODUCTION, gate["blocker_kinds"])
self.assertFalse(report["real_mutations_allowed"])
def test_dev_test_runtime_may_mutate_a_non_production_target(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
gate = scr.assess_runtime_mutation_gate(
report, target_is_production=False)
self.assertFalse(gate["block"])
def test_unknown_runtime_blocks_mutations(self):
report = stable_report(checkout_branch=None)
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_UNKNOWN_RUNTIME, gate["blocker_kinds"])
def test_unknown_runtime_blocks_even_a_non_production_target(self):
report = stable_report(checkout_branch=None)
gate = scr.assess_runtime_mutation_gate(
report, target_is_production=False)
self.assertTrue(gate["block"])
def test_dirty_stable_runtime_blocks_mutations(self):
report = stable_report(dirty_files=["gitea_mcp_server.py"])
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_DIRTY_STABLE_RUNTIME, gate["blocker_kinds"])
self.assertTrue(
any("dirty" in reason for reason in gate["reasons"]))
def test_dev_worktree_launch_is_reported_as_its_own_blocker(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
gate = scr.assess_runtime_mutation_gate(report)
self.assertIn(scr.BLOCKER_DEV_WORKTREE_LAUNCH, gate["blocker_kinds"])
def test_unsafe_workspace_alignment_blocks_mutations(self):
report = stable_report(workspace_roots_aligned=False)
gate = scr.assess_runtime_mutation_gate(report)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_UNSAFE_ALIGNMENT, gate["blocker_kinds"])
def test_unknown_alignment_does_not_block(self):
report = stable_report(workspace_roots_aligned=None)
self.assertFalse(scr.assess_runtime_mutation_gate(report)["block"])
def test_env_escape_hatch_disables_the_gate(self):
report = stable_report(checkout_branch=None)
with patch.dict(os.environ, {scr.ENV_DISABLE: "1"}):
gate = scr.assess_runtime_mutation_gate(report)
self.assertFalse(gate["block"])
self.assertTrue(gate["gate_disabled"])
def test_block_reasons_helper_matches_the_gate(self):
report = stable_report(checkout_branch=None)
self.assertEqual(
scr.runtime_block_reasons(report),
scr.assess_runtime_mutation_gate(report)["reasons"],
)
def test_block_payload_names_the_operator_recovery_path(self):
report = stable_report(checkout_branch=None)
payload = scr.runtime_report_payload(report)
self.assertEqual(payload["kind"], "runtime_mode_block")
self.assertEqual(payload["blocker_kind"], scr.BLOCKER_UNKNOWN_RUNTIME)
self.assertTrue(
any("promotion-runbook" in line for line in payload["recovery"]))
class TestPostFlapReproving(unittest.TestCase):
"""Acceptance criterion 8: per-namespace post-flap re-proving."""
def test_no_flap_means_no_reproof_required(self):
state = scr.new_reproof_state()
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertFalse(res["reproof_required"])
self.assertTrue(res["proven"])
def test_transport_recovery_requires_namespace_specific_reproving(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertTrue(res["reproof_required"])
self.assertFalse(res["proven"])
self.assertEqual(
res["missing_steps"], list(scr.REQUIRED_NAMESPACE_PROOF_STEPS))
def test_author_proof_does_not_imply_other_namespaces(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state,
"author",
at="2026-07-20T14:05:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
)
self.assertTrue(scr.assess_namespace_reproof(state, "author")["proven"])
for other in ("reviewer", "merger", "reconciler"):
assessment = scr.assess_namespace_reproof(state, other)
self.assertFalse(assessment["proven"], other)
self.assertTrue(
any("does not transfer" in reason
for reason in assessment["reasons"]),
other,
)
self.assertEqual(
scr.unproven_namespaces(state),
["reviewer", "merger", "reconciler"],
)
def test_proof_recorded_before_the_flap_does_not_count(self):
state = scr.record_namespace_proof(
scr.new_reproof_state(),
"merger",
at="2026-07-20T13:00:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
)
state = scr.record_transport_flap(state, at="2026-07-20T14:00:00Z")
res = scr.assess_namespace_reproof(state, "merger")
self.assertFalse(res["proven"])
self.assertTrue(any("predates" in reason for reason in res["reasons"]))
def test_incomplete_proof_lists_the_missing_steps(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state, "reviewer", at="2026-07-20T14:05:00Z", whoami=True)
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertFalse(res["proven"])
self.assertEqual(
res["missing_steps"], ["runtime_context", "capability_resolved"])
def test_stale_runtime_report_keeps_the_namespace_unproven(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state,
"reviewer",
at="2026-07-20T14:05:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
stale_runtime_reported=True,
)
res = scr.assess_namespace_reproof(state, "reviewer")
self.assertFalse(res["proven"])
self.assertTrue(
any("stale-runtime" in reason for reason in res["reasons"]))
def test_unproven_namespace_blocks_the_mutation_gate(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
gate = scr.assess_runtime_mutation_gate(
stable_report(), namespace="reviewer", namespace_reproof=state)
self.assertTrue(gate["block"])
self.assertIn(scr.BLOCKER_NAMESPACE_NOT_REPROVEN, gate["blocker_kinds"])
def test_reproven_namespace_clears_the_mutation_gate(self):
state = scr.record_transport_flap(
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
state = scr.record_namespace_proof(
state,
"reviewer",
at="2026-07-20T14:05:00Z",
whoami=True,
runtime_context=True,
capability_resolved=True,
)
gate = scr.assess_runtime_mutation_gate(
stable_report(), namespace="reviewer", namespace_reproof=state)
self.assertFalse(gate["block"])
class TestPromotionRecord(unittest.TestCase):
"""Acceptance criteria 4 / 10: promotion records previous and promoted SHAs."""
def complete_record(self, **overrides):
record = {
"previous_runtime_sha": SHA_A,
"promoted_runtime_sha": SHA_B,
"source_branch": "feat/issue-615-runtime-mode-enforcement",
"source_pr": "770",
"restart_method": "operator reload of the stable control runtime",
"health_check_proof": "gitea_assess_mcp_namespace_health: healthy",
"identity_proof": "gitea_whoami: sysadmin / prgs-reviewer",
"profile_proof": "runtime context: prgs-reviewer",
"workspace_proof": "process root == canonical root, clean",
"mutation_capability_proof": "resolve review_pr: allowed",
"rollback_instructions": "re-promote " + SHA_A,
}
record.update(overrides)
return record
def test_complete_record_is_valid(self):
res = scr.assess_promotion_record(self.complete_record())
self.assertTrue(res["valid"])
self.assertEqual(res["missing_fields"], [])
def test_promotion_records_previous_and_promoted_shas(self):
res = scr.assess_promotion_record(
self.complete_record(previous_runtime_sha="", promoted_runtime_sha=""))
self.assertFalse(res["valid"])
self.assertIn("previous_runtime_sha", res["missing_fields"])
self.assertIn("promoted_runtime_sha", res["missing_fields"])
def test_identical_shas_are_not_a_promotion(self):
res = scr.assess_promotion_record(
self.complete_record(promoted_runtime_sha=SHA_A))
self.assertFalse(res["valid"])
self.assertTrue(
any("nothing was promoted" in reason for reason in res["reasons"]))
def test_missing_rollback_instructions_fail_closed(self):
res = scr.assess_promotion_record(
self.complete_record(rollback_instructions=""))
self.assertFalse(res["valid"])
self.assertIn("rollback_instructions", res["missing_fields"])
def test_empty_record_is_invalid(self):
self.assertFalse(scr.assess_promotion_record(None)["valid"])
class TestNormalSessionsCannotRestartStableRuntime(unittest.TestCase):
"""Acceptance criterion 3: normal sessions do not restart the stable MCP."""
def test_adr_forbids_kill_restart_and_relaunch(self):
adr = (
REPO_ROOT
/ "docs"
/ "architecture"
/ "mcp-stable-control-runtime-policy-adr.md"
).read_text()
for phrase in ("Kill the running MCP server process",
"Restart / relaunch the MCP server process",
"Relaunch MCP from a development worktree"):
self.assertIn(phrase, adr, phrase)
def test_promotion_runbook_exists_and_lists_every_record_field(self):
runbook = (
REPO_ROOT / "docs" / "stable-runtime-promotion-runbook.md"
).read_text()
for field in scr.PROMOTION_REQUIRED_FIELDS:
self.assertIn(field, runbook, field)
def test_no_mcp_tool_offers_a_runtime_restart(self):
server = (REPO_ROOT / "gitea_mcp_server.py").read_text()
for forbidden in ("def gitea_restart_", "def gitea_kill_"):
self.assertNotIn(forbidden, server, forbidden)
class TestServerWiring(unittest.TestCase):
"""The gate is wired into the server's mutation permission path."""
def setUp(self):
import gitea_mcp_server as srv # imported lazily: heavy module
self.srv = srv
def test_reads_are_never_blocked_by_runtime_mode(self):
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}):
self.assertEqual(self.srv._runtime_mode_block("gitea.read"), [])
def test_gate_is_skipped_under_pure_unit_test_isolation(self):
# The suite itself runs from a branches/ worktree (dev-test by design);
# without forced production guards the gate must not fire.
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
def test_dev_worktree_runtime_blocks_mutations_when_guards_forced(self):
report = stable_report(
process_root=DEV_WORKTREE_ROOT,
checkout_branch="feat/issue-615-runtime-mode-enforcement",
)
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
patch.object(
self.srv, "_current_runtime_mode_report", return_value=report):
reasons = self.srv._runtime_mode_block("gitea.pr.create")
self.assertTrue(reasons)
self.assertTrue(any("dev-test" in reason for reason in reasons))
def test_stable_runtime_allows_mutations_when_guards_forced(self):
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
patch.object(
self.srv,
"_current_runtime_mode_report",
return_value=stable_report()):
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
def test_unassessable_runtime_fails_closed(self):
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
patch.object(
self.srv,
"_current_runtime_mode_report",
side_effect=RuntimeError("boom")):
reasons = self.srv._runtime_mode_block("gitea.pr.create")
self.assertTrue(reasons)
self.assertTrue(any("fail closed" in reason for reason in reasons))
def test_live_report_describes_this_checkout(self):
report = self.srv._current_runtime_mode_report()
self.assertIn(report["runtime_mode"], scr.VALID_RUNTIME_MODES)
self.assertEqual(report["mcp_process_root"], self.srv.PROJECT_ROOT)
if __name__ == "__main__":
unittest.main()
+187
View File
@@ -304,6 +304,193 @@ Who/what acts next:
)
)
# ── Stable control runtime states (#615) ─────────────────────────────────────
EXAMPLES.append(
_example(
"runtime_healthy",
"""
[CONTROLLER HANDOFF] Runtime check — stable control runtime healthy
Server-side mutation ledger:
- none — no server-side state changed
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] Runtime — stable control runtime healthy
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: runtime reported real_mutations_allowed=true
- Latest known validation: gitea_get_runtime_context read in this session
What changed:
- nothing; this is a read-only runtime observation
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: proceed with the allocated workflow phase
- Do not do: restart or relaunch the stable runtime
- Resume from: gitea_workflow_dashboard
""",
)
)
EXAMPLES.append(
_example(
"transport_flap_recovered",
"""
[CONTROLLER HANDOFF] Runtime check — transport flap recovered
Server-side mutation ledger:
- none — no server-side state changed
Blockers:
- environment/tooling blocker: MCP transport dropped mid-session and recovered
""",
f"""
[THREAD STATE LEDGER] Runtime — transport flap recovered, namespaces re-proven
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: all four namespaces re-proven after the flap
- Latest known validation: whoami + runtime context + capability resolve per namespace
What changed:
- author, reviewer, merger, and reconciler namespaces each re-proven independently
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: resume the interrupted workflow phase from its last durable state
- Do not do: treat author proof as proof of the other namespaces
- Resume from: the phase handoff that preceded the flap
""",
)
)
EXAMPLES.append(
_example(
"namespace_not_yet_reproven",
"""
[CONTROLLER HANDOFF] Runtime check — reviewer namespace not re-proven
Server-side mutation ledger:
- none — no server-side state changed
Blockers:
- environment/tooling blocker: reviewer namespace not re-proven since the transport flap
""",
f"""
[THREAD STATE LEDGER] Runtime — reviewer namespace not re-proven after flap
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: reviewer namespace unproven; mutation gate fails closed
- Latest known validation: author namespace re-proven; reviewer not attempted
What changed:
- reviewer mutations blocked with namespace_not_reproven_after_flap
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: reviewer
- Required action: run whoami, runtime context, and capability resolve in the reviewer namespace
- Do not do: substitute author proof for reviewer proof
- Resume from: docs/stable-runtime-promotion-runbook.md section 5
""",
)
)
EXAMPLES.append(
_example(
"promotion_completed",
"""
[CONTROLLER HANDOFF] Runtime promotion — completed
Server-side mutation ledger:
- gitea_create_issue_comment on #615 with the promotion record
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] Runtime — promotion completed and re-proven
What is true now:
- Runtime mode: stable-control
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: server-side state changed
- Local verdict/state: promotion record carries every required field
- Latest known validation: assess_promotion_record valid=true; all namespaces re-proven
What changed:
- stable control runtime advanced to the promoted SHA and reloaded by the operator
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: resume normal workflow phases on the promoted runtime
- Do not do: promote again without a fresh record
- Resume from: docs/stable-runtime-promotion-runbook.md section 4
""",
)
)
EXAMPLES.append(
_example(
"rollback_required",
"""
[CONTROLLER HANDOFF] Runtime promotion — rollback required
Server-side mutation ledger:
- gitea_create_issue_comment on #615 with the rollback evidence
Blockers:
- environment/tooling blocker: promoted runtime unhealthy, rollback required
""",
f"""
[THREAD STATE LEDGER] Runtime — promoted runtime unhealthy, rollback required
What is true now:
- Runtime mode: unknown
- Runtime git SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed after the promotion record
- Local verdict/state: promoted runtime failed namespace health; mutations blocked
- Latest known validation: namespace health probe reported EOF after reload
What changed:
- all PR/review/merge work stopped pending rollback to the previous runtime SHA
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: controller
- Required action: operator rolls back to the previous runtime SHA and re-proves every namespace
- Do not do: route around the unhealthy runtime or mutate from a dev/test runtime
- Resume from: docs/stable-runtime-promotion-runbook.md section 6
""",
)
)
EXAMPLES.append(
_example(
"duplicate_canonicalization_blocker",