"""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, }