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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user