Add restart_audit with mcp.restart.* event schema, redacted emission via
gitea_audit, correlation ids, and incident materialization for failed drain
and break-glass. Wire gitea_request_mcp_restart to always audit impact
previews and fail closed on privileged apply when the audit sink is enabled
but write fails.
Closes#665
Addresses the two blockers raised in the PR #886 review (comment 16559) for
issue #663.
B1 — apply_authorized ignored restart-class authorization.
The #663 restart-class matrix and the #661 drain-proof hard gate are
independent authorizations that first coexisted when PR #882 landed on
master and this branch merged it. The union preserved both, but the apply
decision consulted only the drain gate:
payload["apply_authorized"] = gate.allow
so a clean drain proof — or an authorized break-glass, which needs no proof
at all — reported apply_authorized: True for a class the least-privilege
matrix had just denied, in the same payload carrying allow_restart: False
and "role 'author' may not request full_mcp_restart". One environment
variable therefore collapsed the whole nine-class matrix for the apply
decision, including host_restart.
The apply decision is now the conjunction of both authorizations, and
apply_gate carries drain_gate_allow and restart_class_authorized so a denial
is attributable to the authorization that produced it. Break-glass keeps its
purpose — bypassing the drain proof — and never bypasses the class matrix.
No existing fail-closed behaviour is weakened: allow_restart, drain-proof
verification, fingerprint binding, and requester authorization are untouched.
B2 — docs/mcp-restart-coordinator.md described pre-#661 behaviour.
The document still called the drain proof "a separate child" and omitted
drain_proof_json and request_break_glass from the published signature, so a
safety document asserted there was no gate where a gate now exists. It now
documents both parameters, states that the gate executes inside this tool,
and records dry-run versus apply behaviour, authorization ordering, the
break-glass scope, and fail-closed conditions as implemented.
Regression coverage.
tests/test_issue_886_apply_authorization_conjunction.py exercises the MCP
tool itself, which previously had no test at all — that absence is why the
defect shipped. It pins both conjunction directions, proves a clean proof
cannot override a role, approval, unknown-class, or missing-target denial,
proves break-glass does not collapse the matrix for any worker role or
restricted class, and proves the existing scoped and unscoped paths and the
#661 denials still hold. Against the pre-fix tree 24 of these fail; against
this commit all 19 pass with 45 subtests.
tests/test_mcp_restart_governance_docs.py now binds the published signature
to inspect.signature() of the real tool and forbids the stale pre-#661
phrasing, so the drift that produced B2 cannot return unnoticed.
Verification: targeted restart/drain/governance/webui suites 194 passed,
113 subtests. Full suite 23 failed, 5230 passed, 6 skipped, 912 subtests —
the failure set is identical to the reviewed baseline at 9bc021e
(23 failed, 5201 passed), with +29 passing from the added tests and no new
or changed failure. Zero conflict markers; py_compile passes; the #882
union remains intact in both directions.
Refs #663, PR #886
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01V6xFqovhbArPv61j9KCGkL
Stale-runtime and runtime-mode mutation refusals previously shared the
permission-denial channel, so permission_report claimed a missing op the
active profile already held and recommended gitea_activate_profile.
Typed blocker_kind payloads report reconnect-only recovery for staleness,
omit permission_report for non-permission gates, and fail closed when a
permission_report would invent a missing permission the profile holds.
Addresses the two blockers from the PR #898 review at a81db754.
B1 - degraded ownership inventory was rendered as affirmative absence.
_build_session_rows read the sessions/leases/locks sections without
consulting their status, so a session row emitted lease_ids=() and
worktree_paths=() whether the session genuinely held nothing or the
lease store simply could not be read. The renderer printed both as
"none" and "unbound", contradicting the ownership_authority_complete
invariant documented on InventorySnapshot.
SessionRow now carries lease_authority and worktree_authority. A
worktree binding is correlated through lease work numbers, so it is
unproven when either the leases or the locks section fails to read --
this covers the narrow variant where locks hold real worktree paths but
a degraded leases section leaves work_numbers empty. The renderer emits
"unknown (inventory <status>)" with an authority-unproven badge instead
of none/unbound, the card names the unreadable sections, and an empty
session list from an unreadable sessions section no longer reads as
"no sessions recorded". snapshot_to_dict exports
ownership_authority_complete, ownership_section_status, and per-row
lease_authority / worktree_authority so /api/sessions consumers can
distinguish the two cases.
B2 - contamination payload strings bypassed redaction.
_inspect_contamination copied command_summary, session_id, role and
reason_class out of the marker payload with only str(), while every
inventory-sourced field on the same page arrives through
webui.inventory.scrub(). The write-time redactor
stable_branch_push_guard.redact_command is a narrow denylist that leaves
absolute $HOME paths, -H 'X-Api-Key: <value>', --password <value>, and
PRIVATE_KEY=<value> intact, and this is the first web surface to render
command_summary at all.
Adds webui.inventory.scrub_text(), which collapses $HOME and redacts
credential-shaped tokens and URL userinfo anywhere inside a string rather
than only at its start, and routes the marker payload through it. scrub()
and every existing caller are untouched. The command_summary field is
kept: it is the #630 evidence naming which daemon was killed. The module
docstring claiming absolute paths were already collapsed is corrected.
Also: removes the locks_by_session_hint dead loop and its discard (N1),
adds the missing trailing newline to webui/runtime_views.py (N4), drops
an unused dataclasses.field import, and documents both honesty rules in
docs/webui-local-dev.md.
Tests: tests/test_webui_sessions_view.py grows from 12 to 26 cases,
covering degraded and unavailable ownership sections in both the HTML and
JSON paths, the locks-readable/leases-degraded variant, a guard against
over-correcting clean inventory into "unknown", the previously untested
expired-lease flag, HTML escaping of hostile values in clean and degraded
renders, and each secret class the write-time denylist misses. The
STATUS_UNAVAILABLE import that was present but unused is now exercised.
Validation, from the issue worktree with venv/bin/python (Python 3.14.5,
pytest 9.1.1):
pytest tests/test_webui_sessions_view.py tests/test_webui_*.py -q
-> 512 passed, 376 subtests (was 498 / 372; +14 new tests)
pytest tests/test_issue_854_semantic_container_exclusion.py -q
-> 13 passed, 8 subtests
13-file runtime/health/inventory/restart set
-> 1 failed, 223 passed; the single failure is
test_runtime_clarity.py::TestRuntimeClarity::
test_activate_profile_succeeds_when_enabled, the identical test and
assertion the reviewer recorded on master at 7af40fb5, so it is
baseline-equivalent and not introduced here.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Brings PR #886 up to date with master @ 2f4dec8323
(8 commits behind), resolving the single conflicted file.
Conflict: gitea_mcp_server.py, both hunks inside gitea_request_mcp_restart.
Both sides were purely additive to the same tool, so both are kept in full:
- Branch side (#663, restart classes): parameters restart_class,
target_session_id, target_role, target_connector; payload keys
controller_approval_authorized, requester_role, requester_permissions.
- Master side (#661 via PR #882, drain-proof hard gate): parameters
drain_proof_json, request_break_glass; the explanatory comment describing
the apply-path hard gate and break-glass authorization.
No behaviour from either side was dropped, reordered, or reimplemented. Every
parameter from both sides is already consumed by the auto-merged function body
(restart_class and the three target_* arguments flow into the coordinator call;
drain_proof_json and request_break_glass drive the dry_run=False hard gate), so
the union is the only resolution that keeps the merged function coherent.
Validation on the merged tree:
python -m pytest tests/test_drain_proof.py tests/test_restart_classes.py \
tests/test_restart_coordinator.py tests/test_mcp_restart_paths.py \
tests/test_mcp_restart_governance_docs.py tests/test_webui_sanctioned_restart.py \
tests/test_issue_662_post_restart_reconcile.py -q
# 165 passed, 68 subtests passed
py_compile on gitea_mcp_server.py passes and no conflict markers remain.
Closes#663
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Review 582 (REQUEST_CHANGES at 95178349) found a residual fail-open of the
same class the PR set out to close. Acknowledgement coverage was decided by
comparing a count against a count:
covers_live_sessions = live_count_known and acked_count >= sessions_live_other
Nothing bound an acknowledgement to the identity of a session that actually
owed one, so acknowledgements supplied for the requesting session and for a
session that does not exist satisfied the obligations of two live sessions
that never answered - minting a clean, correctly signed proof and an allow
verdict from the restart gate.
Coverage is now derived from authoritative impact-report evidence:
- New `_required_ack_sessions()` derives the required session ids from the
report itself, via `ack_state` keys and/or `affected_sessions` filtered on
`live and not is_requester`. The requester is excluded only on explicit
`is_requester` evidence, never inferred.
- When both views are present they must name the same set, and the result is
reconciled against `counts.sessions_live_other`. Missing, malformed,
duplicated, contradictory, or unreconcilable identity evidence fails closed
and outranks every permitting path, including the timeout policy.
- Coverage requires every required id to carry an explicit acknowledgement
token keyed by that id. Acknowledgements for the requester, for unknown
ids, or for fabricated ids never increase coverage.
- Caller-supplied acknowledgement cardinality is no longer proof of anything.
Failure propagates unchanged through `acks_or_timeout` -> `proof.clean` ->
`failed_checks` -> `gate_apply_restart` verdict `deny` / `allow=False`.
The earlier missing-acknowledgement remediation is preserved in full: absent,
None, non-mapping, empty, partial, stale, and unparseable acks still fail
closed, `ack_timeout_policy_applied` stays strict `value is True`, and the
legitimate zero-live-sessions and explicit-timeout paths still pass.
Reviewer's reproduction, before and after this commit:
sessions_live_other = 2
report ack_state = {'other-0': 'pending', 'other-1': 'pending'}
supplied acks = {'req': 'ack', 'totally-bogus-session': 'ack'}
before: acks_or_timeout = True | proof.clean = True | gate allow
after: acks_or_timeout = False | proof.clean = False | gate deny
Tests: 22 new cases in `AcknowledgementIdentityBindingTests` covering the
reviewer's exact exploit, wrong-ids-with-sufficient-count, partial identity
match, requester-only acks, fabricated ids, unproven per-session states,
missing/malformed/contradictory identity evidence, count mismatch, and the
preserved success paths.
Verification:
- `pytest tests/test_drain_proof.py` -> 61 passed, 56 subtests
(baseline at 95178349: 39 passed, 26 subtests)
- Restart surface (6 modules) -> 154 passed, 68 subtests, exit 0
(baseline at 95178349: 132 passed, 38 subtests)
- Full `pytest tests/` -> 5177 passed vs baseline 5155 passed; the 23
failures are identical in both runs and pre-exist at 95178349.
Scope: drain_proof.py, tests/test_drain_proof.py.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRUZAf3Fr5n3kqhhiayN6C
(cherry picked from commit 4193b63f415b066ee292386c2c89bc3d2651a0cc)
Compose runtime health with inventory sessions/namespaces/worktrees into a
live /sessions page and JSON API. Surface stale PID/lease flags and durable
contamination markers when detectable. Recovery links name sanctioned
reconnect/restart paths only — no kill controls.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The acks_or_timeout check treated an absent `acks` key as proof that no
session needed to acknowledge: `drain_state.get("acks") or {}` collapsed
absent, None, and empty into the same value, and the resulting empty mapping
satisfied `no_sessions_to_ack`. The impact report's counts.sessions_live_other
was never consulted, so absence of evidence was read as evidence of absence.
Reproduced at head 1cbbde0089: with
sessions_live_other = 3 and the acknowledgement key absent, acks_or_timeout
passed with detail "no other live sessions required to acknowledge", the proof
minted clean, and gate_apply_restart returned verdict allow — a restart
authorized against three live sessions with zero acknowledgement evidence, and
the resulting artifact carried a valid signature.
Whether acknowledgement is required is now derived from the impact report,
never from the shape of the drain state:
- _live_session_count() reads counts.sessions_live_other and returns None for a
missing, malformed, negative, or bool value, so an unreadable report fails
closed instead of reading as "nobody was live".
- Absent, None, non-mapping, empty, partially-covering, and unparseable or
stale acknowledgement data all fail closed while live sessions require
acknowledgement.
- _is_acknowledged() no longer coerces with str(); only an explicit
"ack"/"acked"/"acknowledged" string counts, so None, timestamps, and
"pending"/"stale" markers are never read as an acknowledgement.
- Present-but-unacknowledged entries fail closed even when the report claims
zero live sessions: that contradiction is not safe to resolve in favour of
the restart.
- ack_timeout_policy_applied stays strict (`value is True`), so an absent, null,
or non-boolean value cannot open the gate on its own.
The genuine no-other-live-sessions case still passes, now justified by the
report proving sessions_live_other == 0 rather than by the absence of data.
Adds AcknowledgementFailClosedTests: 14 cases / 26 subtests covering missing,
null, empty, malformed, stale, partial-coverage, and unproven-count inputs,
the valid-acknowledgement and zero-live-session paths, timeout-policy
strictness, and that a failed check blocks proof.clean, verification, and the
restart gate.
Restart-surface suite: 132 passed, 38 subtests (branch baseline 118 passed,
12 subtests; +14 new tests, no regressions).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VEaP3TohHLFWkp3Z2mmuZw
Address PR #885 REQUEST_CHANGES: full head_sha pins from queue signals,
reviewer leases keyed by pr_number only, claim inventory via entries,
live-path fixture tests, and traffic state vocabulary docs.
Closes#640 (re-review at new head)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Starlette 1.3.x prefers httpx2 for starlette.testclient.TestClient; plain
httpx still works but emits StarletteDeprecationWarning.
- Pin httpx2==2.9.1 (keep httpx for MCP/runtime)
- Centralize Web UI TestClient import via tests/webui_testclient.py
- Point all test_webui_* modules at the helper
- Add regression tests that the deprecation warning is gone
Closes#682
Add `drain_proof.py`: a machine-verifiable DrainProof artifact plus a
fail-closed verifier and the hard gate the sanctioned restart-apply path
must consult, so a restart can never proceed on a stale or false "ready"
claim (#655 umbrella, child of #658 coordinator / #659 drain / #660
checkpoints).
- DrainProof: HMAC-SHA256 keyed proof-id over a canonical body using a
per-process secret -> non-forgeable within the process; a proof minted in
a prior daemon process will not verify after restart. Short TTL (120s).
- build_drain_proof(): mints the proof from the #658 impact report + the
drain-mode outcomes. Checklist: no in-flight mutations, assignments
stopped, checkpoints complete, handoffs ok, leases handled, acks-or-
timeout. Every check fails closed on missing/ambiguous evidence; the
no-in-flight-mutations and leases-handled checks are derived from the
authoritative impact report, not self-reported.
- verify_drain_proof(): fail-closed — rejects missing, malformed, expired,
signature-mismatched (forged/tampered/prior-process), unclean, or
stale-fingerprint proofs; recomputes cleanliness from the checks rather
than trusting the flag.
- gate_apply_restart(): allow only on a valid clean proof; deny -> durable
incident descriptor; break-glass is the only bypass and is never silent.
- Checkpoint completeness is a supplied input, not a hard dependency on the
(still-unmerged #660) checkpoint schema.
Wire the gate into gitea_request_mcp_restart: dry_run=False now enforces the
hard gate (drain_proof_json required; break-glass via request_break_glass +
GITEA_BREAKGLASS_RESTART_AUTHORIZATION env). The tool still performs no
actual restart — execution remains a further child.
Tests: tests/test_drain_proof.py — 25 cases covering AC#1-4 (apply without
proof denied, successful drain verifiable, open unsafe mutation fails,
pass/fail/expired), forgery/tamper/wrong-secret/stale-fingerprint rejection,
break-glass bypass, and secret hygiene. 25/25 pass (coordinator suite
unaffected: 40/40 together).
Links #652#653#655#658#659#660.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01E7Fv9Bp2XWgvaWa4M1kdR7
(cherry picked from commit e7bcc952bb3e820fda95acbecefeaebfa5f8fcff)
#844 only caught epic-shaped child-only records. Live allocation still
selected product vision (#652), phased roadmap (#653), and umbrella (#655)
as implement targets. Extend pre-rank semantic classification with body
markers and container labels for those coordination records, keep title-
only and incidental mentions eligible, and add a live-equivalent canary.
Closes#854
Adds the session_checkpoints section to the control-plane DB substrate
architecture doc: schema version and row identity, the column groups, the
public API surface, and the hard rules (redaction at write, reconcile
rather than blind restore, unknown live state is not a mismatch, drain
fails closed on an incomplete checkpoint, no transcript storage).
Satisfies acceptance criterion 1 of #660 ("Schema documented and
versioned") for the implementation added in 7e18dcc.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Base-sync of the #660 durable session checkpoint schema branch onto current
master. The only conflicting file was control_plane_db.py, where master added
the #651 usage_events table, migration, and indexes while this branch added the
Resolution keeps both sides in full. Verified against both merge parents: the
resolved file contains every line of master's control_plane_db.py with zero
removals, plus exactly the #660 additions (session_checkpoints table, its two
indexes, _session_checkpoint_row, write_session_checkpoint,
get_session_checkpoint, list_session_checkpoints, reconcile_session_checkpoint,
and checkpoint_completeness).
Conflicts:
control_plane_db.py
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>