Merge pull request 'feat: enforce stable-control vs dev runtime modes for Gitea MCP (Closes #615)' (#770) from feat/issue-615-runtime-mode-enforcement into master
This commit was merged in pull request #770.
This commit is contained in:
@@ -171,13 +171,18 @@ then:
|
|||||||
- Does not replace CI or code review for MCP changes
|
- Does not replace CI or code review for MCP changes
|
||||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
- 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.”
|
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.
|
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.
|
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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -1241,6 +1241,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
## Related documents
|
## 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).
|
- [`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).
|
- [`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).
|
- [`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.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -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)
|
||||||
+139
-1
@@ -2002,12 +2002,30 @@ import native_mcp_preference # noqa: E402
|
|||||||
import branch_cleanup_guard # noqa: E402
|
import branch_cleanup_guard # noqa: E402
|
||||||
import thread_state_ledger_validator # noqa: E402
|
import thread_state_ledger_validator # noqa: E402
|
||||||
import master_parity_gate # 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.
|
# 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
|
# 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.
|
# master has advanced past the running code and fail closed until restart.
|
||||||
# Read-only operations are never blocked by staleness.
|
# Read-only operations are never blocked by staleness.
|
||||||
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
||||||
|
|
||||||
|
# Stable-control runtime facts (#615): which runtime this process serves from.
|
||||||
|
# These are the *immutable* facts -- process root, branch, head, checkout-ness --
|
||||||
|
# and they are captured at import exactly like the #420 parity baseline, because
|
||||||
|
# the runtime a process serves from is fixed when it loads its code.
|
||||||
|
#
|
||||||
|
# Dirty state and the session's workspace binding are deliberately NOT captured
|
||||||
|
# here. Both change during a process lifetime, so freezing them would retire the
|
||||||
|
# acceptance-criterion-7 dirty-runtime blocker after the first snapshot and let
|
||||||
|
# whichever session happened to call first decide alignment for every session
|
||||||
|
# after it. _current_runtime_mode_report() re-evaluates them 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_FACTS = stable_control_runtime.observe_runtime(PROJECT_ROOT)
|
||||||
|
_RUNTIME_MODE_GATE_UNDER_TEST = "pytest" in sys.modules or "unittest" in sys.modules
|
||||||
import worktree_cleanup_audit # noqa: E402
|
import worktree_cleanup_audit # noqa: E402
|
||||||
import canonical_comment_validator as ccv # noqa: E402
|
import canonical_comment_validator as ccv # noqa: E402
|
||||||
|
|
||||||
@@ -11356,6 +11374,98 @@ def _current_master_parity() -> dict:
|
|||||||
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
|
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 immutable facts -- process root, branch, head, checkout-ness -- come from
|
||||||
|
the import-time snapshot, 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.
|
||||||
|
|
||||||
|
Everything mutation-sensitive is recomputed on **every** call: the dirty-file
|
||||||
|
list, the session's workspace binding, and the alignment derived from it.
|
||||||
|
Caching those would mean a checkout that goes dirty after the first call is
|
||||||
|
never caught again, and that one session's binding decides alignment for the
|
||||||
|
whole process. ``refresh`` is retained for the read-only reporting path; it
|
||||||
|
no longer selects between a cache and live state, so a read-only call can
|
||||||
|
neither seed nor weaken a later mutation decision.
|
||||||
|
"""
|
||||||
|
facts = _STARTUP_RUNTIME_FACTS
|
||||||
|
dirty_files = stable_control_runtime.observe_dirty_files(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")
|
||||||
|
# Alignment keeps its established repository-level meaning (#615 F1):
|
||||||
|
# does this namespace target the repository the process is installed in,
|
||||||
|
# i.e. canonical_repo_root == process_project_root. It is deliberately
|
||||||
|
# NOT path equality between the task workspace and the process root --
|
||||||
|
# the global worktree rule requires task work to live in a branches/
|
||||||
|
# worktree, so that comparison would classify every correctly bound
|
||||||
|
# author, reviewer, and merger session as unsafe.
|
||||||
|
aligned = ctx.get("roots_aligned")
|
||||||
|
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
|
||||||
|
return stable_control_runtime.build_runtime_report(
|
||||||
|
process_root=PROJECT_ROOT,
|
||||||
|
checkout_branch=facts["checkout_branch"],
|
||||||
|
runtime_head=facts["runtime_head"],
|
||||||
|
is_git_checkout=facts["is_git_checkout"],
|
||||||
|
dirty_files=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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
def _master_parity_block(op: str) -> list[str]:
|
||||||
"""Fail-closed staleness reasons for a mutation *op* (#420).
|
"""Fail-closed staleness reasons for a mutation *op* (#420).
|
||||||
|
|
||||||
@@ -11380,11 +11490,16 @@ def _profile_operation_gate(op: str) -> list[str]:
|
|||||||
|
|
||||||
A mutating operation is additionally refused when the server code is stale
|
A mutating operation is additionally refused when the server code is stale
|
||||||
relative to master (#420), so a long-running process cannot bypass a
|
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)
|
stale_reasons = _master_parity_block(op)
|
||||||
if stale_reasons:
|
if stale_reasons:
|
||||||
return stale_reasons
|
return stale_reasons
|
||||||
|
runtime_reasons = _runtime_mode_block(op)
|
||||||
|
if runtime_reasons:
|
||||||
|
return runtime_reasons
|
||||||
try:
|
try:
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -15027,6 +15142,29 @@ def gitea_get_runtime_context(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
parity = _current_master_parity()
|
||||||
result["master_parity"] = {
|
result["master_parity"] = {
|
||||||
"in_parity": parity["in_parity"],
|
"in_parity": parity["in_parity"],
|
||||||
|
|||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
|
||||||
|
[--source-branch <branch>] [--source-pr <n>] \
|
||||||
|
[--restart-method <text>] [--rollback <text>] \
|
||||||
|
[--health-check-proof <text>] \
|
||||||
|
[--identity-proof <text>] [--profile-proof <text>] \
|
||||||
|
[--workspace-proof <text>] \
|
||||||
|
[--mutation-capability-proof <text>]
|
||||||
|
|
||||||
|
Emit and validate a stable-control-runtime promotion record (#615).
|
||||||
|
|
||||||
|
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
|
||||||
|
anything: promotion itself is an operator action documented in
|
||||||
|
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
|
||||||
|
state, assembles the required record, validates it with
|
||||||
|
stable_control_runtime.assess_promotion_record(), and prints it for the operator
|
||||||
|
to act on and archive.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
--root the repository root containing this script
|
||||||
|
--promoted HEAD of that root
|
||||||
|
|
||||||
|
Exit status is non-zero when the assembled record is incomplete, so a promotion
|
||||||
|
cannot be recorded without its proof fields.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
scripts/promote-stable-runtime \
|
||||||
|
--source-branch feat/issue-615-runtime-mode-enforcement \
|
||||||
|
--source-pr 770 \
|
||||||
|
--restart-method "IDE client reconnect (/mcp)" \
|
||||||
|
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
|
||||||
|
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
|
||||||
|
--identity-proof "gitea_whoami per namespace" \
|
||||||
|
--profile-proof "gitea_get_runtime_context per namespace" \
|
||||||
|
--workspace-proof "process root == canonical root; clean" \
|
||||||
|
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT=""
|
||||||
|
PROMOTED=""
|
||||||
|
SOURCE_BRANCH=""
|
||||||
|
SOURCE_PR=""
|
||||||
|
RESTART_METHOD=""
|
||||||
|
ROLLBACK=""
|
||||||
|
HEALTH_PROOF=""
|
||||||
|
IDENTITY_PROOF=""
|
||||||
|
PROFILE_PROOF=""
|
||||||
|
WORKSPACE_PROOF=""
|
||||||
|
CAPABILITY_PROOF=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--root) ROOT="${2:-}"; shift 2 ;;
|
||||||
|
--promoted) PROMOTED="${2:-}"; shift 2 ;;
|
||||||
|
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
|
||||||
|
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
|
||||||
|
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
|
||||||
|
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
|
||||||
|
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||||
|
|
||||||
|
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
|
||||||
|
if [[ -z "$PREVIOUS" ]]; then
|
||||||
|
# The runtime the operator is replacing. Best-effort: the commit master
|
||||||
|
# pointed at before the fast-forward, recorded in the reflog.
|
||||||
|
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
|
||||||
|
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||||
|
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
|
||||||
|
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
|
if [[ -z "$WORKSPACE_PROOF" ]]; then
|
||||||
|
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
|
||||||
|
fi
|
||||||
|
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
|
||||||
|
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
# Stable control runtime promotion record (#615)
|
||||||
|
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
|
||||||
|
|
||||||
|
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
|
||||||
|
promoted_runtime_sha: ${PROMOTED}
|
||||||
|
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
|
||||||
|
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
|
||||||
|
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
|
||||||
|
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
|
||||||
|
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
|
||||||
|
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
|
||||||
|
workspace_proof: ${WORKSPACE_PROOF}
|
||||||
|
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
|
||||||
|
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "$DIRTY" != "0" ]]; then
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
|
||||||
|
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
|
||||||
|
} >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||||
|
RECORD_JSON="$(
|
||||||
|
ROOT="$ROOT" \
|
||||||
|
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
|
||||||
|
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
|
||||||
|
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
|
||||||
|
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
|
||||||
|
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
|
||||||
|
ROLLBACK="$ROLLBACK" \
|
||||||
|
"$PYTHON_BIN" -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
|
||||||
|
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
|
||||||
|
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||||
|
"source_pr": os.environ.get("SOURCE_PR", ""),
|
||||||
|
"restart_method": os.environ.get("RESTART_METHOD", ""),
|
||||||
|
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
|
||||||
|
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
|
||||||
|
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
|
||||||
|
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
|
||||||
|
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
|
||||||
|
"rollback_instructions": os.environ.get("ROLLBACK", ""),
|
||||||
|
}))
|
||||||
|
'
|
||||||
|
)"
|
||||||
|
|
||||||
|
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.environ["ROOT"])
|
||||||
|
import stable_control_runtime as scr
|
||||||
|
|
||||||
|
record = json.loads(os.environ["RECORD_JSON"])
|
||||||
|
assessment = scr.assess_promotion_record(record)
|
||||||
|
print()
|
||||||
|
print("validation:", json.dumps(assessment, indent=2))
|
||||||
|
print()
|
||||||
|
if not assessment["valid"]:
|
||||||
|
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("Promotion record is complete. Archive it on the tracking issue.")
|
||||||
|
'
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"""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_dirty_files(process_root: str | None) -> list[str]:
|
||||||
|
"""Read the live dirty-file list at *process_root*.
|
||||||
|
|
||||||
|
Split out from :func:`observe_runtime` because dirtiness is the one runtime
|
||||||
|
fact that legitimately changes during a process lifetime. The mutation gate
|
||||||
|
must re-read it per call rather than trust a startup snapshot, or a checkout
|
||||||
|
that goes dirty after the snapshot is never blocked again (#615).
|
||||||
|
"""
|
||||||
|
if not process_root:
|
||||||
|
return []
|
||||||
|
porcelain = _git_capture(process_root, "status", "--porcelain") or ""
|
||||||
|
return [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
dirty = observe_dirty_files(process_root)
|
||||||
|
return {
|
||||||
|
"checkout_branch": branch,
|
||||||
|
"runtime_head": _git_capture(process_root, "rev-parse", "HEAD"),
|
||||||
|
"is_git_checkout": True,
|
||||||
|
"dirty_files": dirty,
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerWiringRealDerivation(unittest.TestCase):
|
||||||
|
"""Drive the *real* report derivation, not a pre-built fixture (#615 F3).
|
||||||
|
|
||||||
|
Every other server-wiring test patches ``_current_runtime_mode_report`` with
|
||||||
|
a fixture, so the derivation the daemon actually runs was never executed by
|
||||||
|
the suite. These tests patch only its *inputs* -- the import-time facts, the
|
||||||
|
dirty-file read, and the resolved namespace binding -- and let the real
|
||||||
|
function build the report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TASK_WORKTREE = STABLE_ROOT + "/branches/issue-615-runtime-mode-enforcement"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import gitea_mcp_server as srv # imported lazily: heavy module
|
||||||
|
|
||||||
|
self.srv = srv
|
||||||
|
|
||||||
|
def _stable_facts(self):
|
||||||
|
"""Immutable facts of a promoted stable-control runtime."""
|
||||||
|
return {
|
||||||
|
"checkout_branch": "master",
|
||||||
|
"runtime_head": SHA_A,
|
||||||
|
"is_git_checkout": True,
|
||||||
|
"dirty_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _binding(self, *, roots_aligned=True, workspace=None):
|
||||||
|
"""A resolved namespace binding, as the server's resolver returns it."""
|
||||||
|
return {
|
||||||
|
"workspace_path": workspace or self.TASK_WORKTREE,
|
||||||
|
"canonical_repo_root": STABLE_ROOT,
|
||||||
|
"process_project_root": STABLE_ROOT,
|
||||||
|
"roots_aligned": roots_aligned,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _real_derivation(self, *, dirty=None, roots_aligned=True, workspace=None):
|
||||||
|
"""Context managers that patch only the inputs, never the derivation."""
|
||||||
|
return (
|
||||||
|
patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}),
|
||||||
|
patch.object(self.srv, "PROJECT_ROOT", STABLE_ROOT),
|
||||||
|
patch.object(self.srv, "_STARTUP_RUNTIME_FACTS", self._stable_facts()),
|
||||||
|
patch.object(
|
||||||
|
self.srv,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
return_value=self._binding(
|
||||||
|
roots_aligned=roots_aligned, workspace=workspace),
|
||||||
|
),
|
||||||
|
patch.object(scr, "observe_dirty_files", return_value=list(dirty or [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clean_stable_checkout_with_bound_task_worktree_permits_mutation(self):
|
||||||
|
# The sanctioned configuration: a clean control checkout on master plus a
|
||||||
|
# correctly bound branches/ worktree. Before the F1 fix this failed, because
|
||||||
|
# alignment was path equality between the task workspace and the process
|
||||||
|
# root, which a branches/ worktree can never satisfy.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation()
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertEqual(report["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
||||||
|
self.assertEqual(report["active_task_workspace"], self.TASK_WORKTREE)
|
||||||
|
self.assertTrue(report["workspace_roots_aligned"])
|
||||||
|
self.assertTrue(
|
||||||
|
report["real_mutations_allowed"], report["mutation_block_reasons"])
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
def test_misaligned_process_and_canonical_roots_fail_closed(self):
|
||||||
|
# Alignment keeps its repository-level meaning: the namespace targeting a
|
||||||
|
# different repository than the process is installed in is the unsafe case.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(roots_aligned=False)
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertFalse(report["workspace_roots_aligned"])
|
||||||
|
self.assertFalse(report["real_mutations_allowed"])
|
||||||
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertTrue(any("alignment" in reason for reason in reasons))
|
||||||
|
|
||||||
|
def test_newly_dirty_task_state_is_detected_after_an_earlier_clean_read(self):
|
||||||
|
# A clean read must not license every later mutation: the acceptance
|
||||||
|
# criterion 7 dirty blocker has to keep applying for the process lifetime.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
self.assertTrue(
|
||||||
|
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
|
||||||
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(
|
||||||
|
dirty=["gitea_mcp_server.py"])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertEqual(report["dirty_files"], ["gitea_mcp_server.py"])
|
||||||
|
self.assertFalse(report["real_mutations_allowed"])
|
||||||
|
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
|
||||||
|
|
||||||
|
def test_read_only_refresh_cannot_freeze_a_permissive_mutation_result(self):
|
||||||
|
# gitea_get_runtime_context() calls with refresh=True. That read-only call
|
||||||
|
# must not seed a cache that a later mutation gate would then trust.
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
self.assertTrue(
|
||||||
|
self.srv._current_runtime_mode_report(refresh=True)[
|
||||||
|
"real_mutations_allowed"]
|
||||||
|
)
|
||||||
|
|
||||||
|
env, root, facts, ctx, dirty = self._real_derivation(
|
||||||
|
dirty=["stable_control_runtime.py"])
|
||||||
|
with env, root, facts, ctx, dirty:
|
||||||
|
self.assertFalse(
|
||||||
|
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
|
||||||
|
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
|
||||||
|
|
||||||
|
def test_unresolvable_binding_reports_unknown_alignment_never_alignment_proof(self):
|
||||||
|
# An unresolvable binding must report alignment as unknown (None), never
|
||||||
|
# as True. Only *definite* misalignment blocks: a session with no task
|
||||||
|
# binding resolved is the ordinary case, and failing it closed would
|
||||||
|
# reintroduce exactly the F1 breakage this change removes.
|
||||||
|
env, root, facts, _, dirty = self._real_derivation()
|
||||||
|
broken = patch.object(
|
||||||
|
self.srv,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
side_effect=RuntimeError("no binding"),
|
||||||
|
)
|
||||||
|
with env, root, facts, broken, dirty:
|
||||||
|
report = self.srv._current_runtime_mode_report()
|
||||||
|
self.assertIsNone(report["workspace_roots_aligned"])
|
||||||
|
self.assertNotIn(
|
||||||
|
scr.BLOCKER_UNSAFE_ALIGNMENT,
|
||||||
|
scr.assess_runtime_mutation_gate(report)["blocker_kinds"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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(
|
EXAMPLES.append(
|
||||||
_example(
|
_example(
|
||||||
"duplicate_canonicalization_blocker",
|
"duplicate_canonicalization_blocker",
|
||||||
|
|||||||
Reference in New Issue
Block a user