The stable-control runtime ADR (docs/architecture/mcp-stable-control-runtime-policy-adr.md)
established the policy but had no runtime enforcement: a daemon relaunched from a
feature worktree still holds production credentials and will happily mutate real
issues. This adds the enforcement layer (acceptance criteria 6-11).
stable_control_runtime.py:
- classify_runtime_mode(): stable-control | dev-test | unknown, inferred from the
process root and checkout branch, with an explicit GITEA_MCP_RUNTIME_MODE
declaration for packaged layouts that have no git checkout.
- build_runtime_report(): runtime mode, git SHA, branch, checkout path, process
root, active workspace, repo binding, profile, identity, dirty files,
alignment, and real_mutations_allowed.
- assess_runtime_mutation_gate(): fails closed on dev-test targeting production,
unknown runtime, dirty stable checkout, dev-worktree launch, and unsafe
process-root/workspace alignment.
- Post-transport-flap re-proving tracked per namespace, so proving the author
namespace never implies reviewer, merger, or reconciler (#584).
- assess_promotion_record(): promotion must record previous and promoted SHAs
plus health, identity, profile, workspace, capability, and rollback proof.
Server wiring:
- _profile_operation_gate() consults the runtime gate alongside the #420 parity
gate. gitea.read is never blocked, so an operator can still diagnose a sick
runtime.
- The gate reads a startup snapshot rather than shelling out per mutation, for
the same reason parity uses a startup baseline: the runtime a process serves
from is fixed when it loads its code. Enforcement is decided from how the
process was loaded, so per-test production simulation cannot switch it on.
- gitea_get_runtime_context() reports the live runtime under
stable_control_runtime and points at the promotion runbook when blocked.
Docs and tooling:
- docs/stable-runtime-promotion-runbook.md: operator promotion procedure,
required record fields, per-namespace re-proving, rollback.
- scripts/promote-stable-runtime: read-only helper that emits and validates a
promotion record; it never restarts anything.
- Five canonical [THREAD STATE LEDGER] examples: runtime healthy, transport flap
recovered, namespace not yet re-proven, promotion completed, rollback required.
- ADR section 5 follow-ups marked landed; runbooks cross-link the new runbook.
Validation: 47 new tests in tests/test_stable_control_runtime.py. Full suite
3827 passed / 6 skipped / 2 failed; both failures are pre-existing on master
059ee77 (verified in a clean baseline worktree: identical 2 failures,
3780 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
204 lines
12 KiB
Markdown
204 lines
12 KiB
Markdown
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||
|
||
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||
- **Date:** 2026-07-09
|
||
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||
- **Related:**
|
||
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||
|
||
## 1. Context
|
||
|
||
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||
|
||
- Mid-session identity/preflight resets
|
||
- Stale-runtime vs master parity failures
|
||
- IDE transport EOF / “tool not found” while code on disk has changed
|
||
- Accidental production mutations from experimental code
|
||
|
||
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||
|
||
## 2. Decision
|
||
|
||
### 2.1 Stable control runtime
|
||
|
||
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||
|
||
Characteristics:
|
||
|
||
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||
- Holds production profile credentials via sanctioned keychain/env paths only
|
||
|
||
### 2.2 Dev / test runtime
|
||
|
||
MCP **server code** development and testing:
|
||
|
||
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||
|
||
### 2.3 Forbidden actions (normal sessions)
|
||
|
||
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||
|
||
| Forbidden | Why |
|
||
|-----------|-----|
|
||
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||
|
||
**LLM-allowed vs operator-owned (authoritative split):**
|
||
|
||
| Actor | May do | Must not do |
|
||
|-------|--------|-------------|
|
||
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||
|
||
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||
|
||
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||
|
||
### 2.4 Promotion (operator / release-manager only)
|
||
|
||
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||
|
||
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||
|
||
| Field | Description |
|
||
|-------|-------------|
|
||
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||
| **promoted runtime SHA** | Commit after promotion |
|
||
| **source branch/PR** | Where the change was reviewed |
|
||
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||
|
||
Suggested durable marker:
|
||
|
||
```text
|
||
## MCP STABLE RUNTIME PROMOTION (#615)
|
||
|
||
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||
Previous-SHA: <full sha>
|
||
Promoted-SHA: <full sha>
|
||
Source-PR: <number>
|
||
Source-Branch: <name>
|
||
Reload-Method: <text>
|
||
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||
Identity-Proof: profile=<name> user=<name>
|
||
Workspace-Proof: root=<path>
|
||
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||
Operator: <username>
|
||
Timestamp: <ISO-8601>
|
||
```
|
||
|
||
### 2.5 Unhealthy stable runtime → stop work
|
||
|
||
If the stable MCP runtime is **unhealthy**, including any of:
|
||
|
||
- client-namespace probes fail
|
||
- wrong identity / wrong profile
|
||
- wrong workspace root
|
||
- missing mutation capability for the intended role
|
||
- persistent EOF after **client reconnect**
|
||
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||
|
||
then:
|
||
|
||
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||
4. Resume only after:
|
||
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||
|
||
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||
|
||
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||
|
||
**Sanctioned response (authoritative):**
|
||
|
||
| Step | Actor | Action |
|
||
|------|-------|--------|
|
||
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||
|
||
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||
|
||
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||
|
||
## 3. Relationship to other controls
|
||
|
||
| Doc / mechanism | Interaction |
|
||
|-----------------|-------------|
|
||
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||
| EOF recovery | Reconnect only; no process kill |
|
||
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||
|
||
## 4. Consequences
|
||
|
||
### Positive
|
||
|
||
- Predictable control plane for concurrent LLMs
|
||
- Clear operator-only promotion gate with rollback
|
||
- Aligns session behavior with health/EOF docs already landed
|
||
|
||
### Costs
|
||
|
||
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||
|
||
### Non-goals
|
||
|
||
- Does not ban operator-supervised restarts during incidents
|
||
- Does not replace CI or code review for MCP changes
|
||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||
|
||
## 5. Implementation follow-ups
|
||
|
||
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.”~~ **Landed.** `_runtime_mode_block()` refuses every mutating operation from a `dev-test`, dev-worktree-launched, dirty-stable, misaligned, or `unknown` runtime; `gitea.read` is never blocked, so an operator can still diagnose a sick runtime.
|
||
2. ~~Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.~~ **Landed** as `runtime_mode` (`stable-control` | `dev-test` | `unknown`), reported by `gitea_get_runtime_context` under `stable_control_runtime` together with the runtime git SHA, branch, checkout path, process root, active workspace, alignment, dirty files, and `real_mutations_allowed`. Operators running a packaged layout with no git checkout declare the mode explicitly with `GITEA_MCP_RUNTIME_MODE`.
|
||
3. ~~Promotion checklist script that emits the durable promotion marker fields.~~ **Landed** as `scripts/promote-stable-runtime` (read-only; emits and validates the record) plus [`../stable-runtime-promotion-runbook.md`](../stable-runtime-promotion-runbook.md).
|
||
|
||
Post-transport-flap proof is enforced per namespace: a flap invalidates every
|
||
`gitea-*` namespace at once, and author proof never transfers to reviewer,
|
||
merger, or reconciler (`namespace_not_reproven_after_flap`).
|
||
|
||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||
|
||
## 6. Acceptance for this ADR
|
||
|
||
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||
3. Issue #615 references this path.
|
||
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||
|
||
## 7. Document history
|
||
|
||
| Date | Change |
|
||
|------|--------|
|
||
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|