From 9bf3acfef681f9766ae1d93680b03996a93f98a1 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Mon, 20 Jul 2026 10:48:47 -0400 Subject: [PATCH] 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) --- .../mcp-stable-control-runtime-policy-adr.md | 15 +- docs/llm-workflow-runbooks.md | 1 + docs/stable-runtime-promotion-runbook.md | 121 ++++ gitea_mcp_server.py | 125 +++- scripts/promote-stable-runtime | 171 +++++ stable_control_runtime.py | 628 ++++++++++++++++++ tests/test_stable_control_runtime.py | 472 +++++++++++++ thread_state_ledger_examples.py | 187 ++++++ 8 files changed, 1714 insertions(+), 6 deletions(-) create mode 100644 docs/stable-runtime-promotion-runbook.md create mode 100755 scripts/promote-stable-runtime create mode 100644 stable_control_runtime.py create mode 100644 tests/test_stable_control_runtime.py diff --git a/docs/architecture/mcp-stable-control-runtime-policy-adr.md b/docs/architecture/mcp-stable-control-runtime-policy-adr.md index 00608eb..b6bccd9 100644 --- a/docs/architecture/mcp-stable-control-runtime-policy-adr.md +++ b/docs/architecture/mcp-stable-control-runtime-policy-adr.md @@ -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 6–11 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. diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index ffe85aa..436831f 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -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. diff --git a/docs/stable-runtime-promotion-runbook.md b/docs/stable-runtime-promotion-runbook.md new file mode 100644 index 0000000..15c6a88 --- /dev/null +++ b/docs/stable-runtime-promotion-runbook.md @@ -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) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index a127171..c80e403 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -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"], diff --git a/scripts/promote-stable-runtime b/scripts/promote-stable-runtime new file mode 100755 index 0000000..c28374d --- /dev/null +++ b/scripts/promote-stable-runtime @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +usage: scripts/promote-stable-runtime [--root ] [--promoted ] \ + [--source-branch ] [--source-pr ] \ + [--restart-method ] [--rollback ] \ + [--health-check-proof ] \ + [--identity-proof ] [--profile-proof ] \ + [--workspace-proof ] \ + [--mutation-capability-proof ] + +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 merge --ff-only ; 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 <} +promoted_runtime_sha: ${PROMOTED} +source_branch: ${SOURCE_BRANCH:-} +source_pr: ${SOURCE_PR:-} +restart_method: ${RESTART_METHOD:-} +health_check_proof: ${HEALTH_PROOF:-} +identity_proof: ${IDENTITY_PROOF:-} +profile_proof: ${PROFILE_PROOF:-} +workspace_proof: ${WORKSPACE_PROOF} +mutation_capability_proof: ${CAPABILITY_PROOF:-} +rollback_instructions: ${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.") +' diff --git a/stable_control_runtime.py b/stable_control_runtime.py new file mode 100644 index 0000000..51591a6 --- /dev/null +++ b/stable_control_runtime.py @@ -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, + } diff --git a/tests/test_stable_control_runtime.py b/tests/test_stable_control_runtime.py new file mode 100644 index 0000000..83ca0ea --- /dev/null +++ b/tests/test_stable_control_runtime.py @@ -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() diff --git a/thread_state_ledger_examples.py b/thread_state_ledger_examples.py index 9c17442..f0aeae9 100644 --- a/thread_state_ledger_examples.py +++ b/thread_state_ledger_examples.py @@ -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",