#930 inventoried stdio coupling; nothing stated what the adversary is, what
each boundary protects, or why one process may hold credentials for several
services. This adds that document as child 2 of epic #929.
Adds docs/remote-mcp/threat-model.md covering 10 assets, the 5 adversaries
#956 names, 9 trust boundaries, the data flows between them, a 14-entry
per-boundary credential inventory, and an explicit decomposition ruling.
Findings established from live native evidence at this commit:
- Four prgs roles (reviewer, merger, reconciler, controller) resolve to one
Gitea account, so separation of duty between approving and landing is
enforced only by which process a call reaches. The mdcps tenant has no role
separation at all: author, reviewer, and merger share one account.
- Any one role process can resolve every other role's credential.
gitea_list_profiles reports "credentials present" for other roles because it
calls resolve_token on each one.
- The Gitea server reads Jenkins and GlitchTip secrets out of the keychain to
produce the "authenticated" word in gitea_audit_config's service summaries.
- Jenkins and GlitchTip were already decomposed into separate MCP servers; the
credential references were left behind in the Gitea configuration.
Ruling D1 forbids a single integration process from holding credentials for
unrelated services, with one time-boxed dual-run exception for the local fleet
that expires with #939. D2 requires separation of duty to be credential-backed,
D3 scopes credential resolution to the request principal, and D4 gives
coordination state its own authority. Every #929 child from 2 through 10 is
mapped to the boundary it implements.
Anchors are enforced rather than asserted. #930's inventory anchors into
gitea_mcp_server.py had already drifted between 7bf4f125 and aad5c8b4 with
nothing detecting it, so this change ships the guard that was missing:
docs/remote-mcp/threat-model-anchors.json declares all 58 anchors with the
substring each must contain, and tests/test_issue_956_threat_model.py fails if
any anchor does not resolve, if the document cites an anchor the fixture does
not cover, or if the structural obligations regress.
Documentation only. No server behavior changes.
Tests:
- tests/test_issue_956_threat_model.py: 17 passed.
- Four sabotage probes confirm the validator is not passing vacuously
(shifted anchor, undeclared citation, broken count tally, and a blanked
boundary owner). The last two probes exposed real weaknesses in the checks
themselves, which were fixed: the section slice now stops at the next
heading, and boundary ownership is read only from mapping table rows.
- Docs-sensitive sweep (17 modules referencing docs/): 559 passed.
- Full suite: 30 failed, 5799 passed, 6 skipped. All 30 reproduce on a clean
base worktree at aad5c8b4; branch failures are a strict subset of base
failures. No production file is modified by this change.
Closes#956
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Review 632 F4. Every prior reference to `gitea_recover_incomplete_bootstrap_lock`
and `gitea_inspect_issue_lock_contract` under `tests/` was a string literal — a
`tool=` argument, an assertion on returned prose, or a docs substring check —
and the suite reconstructed the recovery sequence by hand from
`assess_bootstrap_lock_recovery`, `build_canonical_issue_lock`,
`build_recovery_record`, and `bind_session_lock`. A hand-written sequence
validates the decision layer but cannot see a divergence between itself and
the tool body, which is exactly how F1 and F2 — both call-site defects —
survived 61 passing cases.
The new cases drive the registered functions against a real `git init`
repository, a real durable lock file, and config-backed profiles:
* `NamespaceMutationWallOnRecovery` — the gate is reached with this task and
`author_role_exclusive=True`; its return value aborts the tool rather than
being computed and discarded; the author namespace succeeds and produces a
canonical lock; a reviewer namespace is refused with `namespace_block` even
when the claimant data would otherwise match, and emits the standard BLOCKED
audit record; merger is refused; a mismatched claimant profile is refused by
the exact-owner layer with the namespace wall explicitly clear; a mismatched
head is refused; every refusal leaves the lock bytes, generation, branch,
worktree, and an unrelated lock untouched. A subtest matrix asserts the
role-kind wall admits `author` and refuses reviewer, merger, limited, and
mixed.
* `InspectionToolExecutes` — the registered read-only tool reports the contract
and the recovery preview while leaving lock bytes, mtime, HEAD, and porcelain
status unchanged, and reports an absent lock without creating one.
* `Ac7PostCompensationGuidance` — drives the real bootstrap to its AC7 refusal
with a forced partial lock and asserts the returned action against the state
the rollback actually left: complete cleanup directs to a bootstrap retry and
that retry is then executed and succeeds, leaving exactly one canonical lock
and one branch; partial cleanup with a surviving lock, and with a surviving
branch and worktree, each get their own executable action; a rollback that
never completed is distinguished from both; no recommendation names a deleted
artifact; unrelated locks are byte-identical afterwards. Two cases cover
`release_session_lock` directly — that the rollback now really removes the
lock, and that it refuses a lock owned by another session.
* `NativeEndToEndBootstrapToCreatePr` — bootstrap, inspect, heartbeat,
legitimate divergence (commit and push), pre-mutation ownership re-check, and
the unchanged #447 create-PR provenance guard, in one sequence against a real
origin. `gitea_lock_issue` is patched to fail the test if anything reaches for
it, so the bootstrap lock is proved to carry the whole cycle unrepaired.
* `DeadProvenanceConstantRemoved` — the removed constant stays removed and the
sanctioned source set stays unwidened.
`_NativeToolBase` clears `role_session_router` route state per test: the sticky
reviewer-stop marker is process-global and, now that this task is registered in
`AUTHOR_TASKS`, an earlier suite leaving it set would make the first gate refuse
before the namespace gate under test is reached.
Also documents both new tools in `docs/mcp-tool-inventory.md`, so this branch
adds no drift to `test_documented_inventory_equals_registered_tools`; the
failure reason there is now identical to the pinned base's.
Suite: 61 -> 92 cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Review 632 F2. The bootstrap AC7 read-back refusal calls
`run_compensating_recovery` and *then* reported
`author_lock_contract.recommended_action(contract)` — advice computed from the
malformed lock that provoked the rollback, not from the state the rollback
left. Compensation releases the lock, removes the worktree (always clean
there, no implementation bytes having been written), and deletes the branch,
so an author following that advice got `no_durable_lock` from
`gitea_recover_incomplete_bootstrap_lock` and, had the lock survived,
`worktree_invalid` instead; the `gitea_lock_issue` half of the same sentence
cannot bind a worktree that no longer exists. Two refusals in a row for a
state a plain bootstrap retry fixes — the unexecutable-guidance failure class
this issue exists to remove, reintroduced on the new fail-closed path.
Investigating that path surfaced why the "clean retry" state was in practice
unreachable: `run_compensating_recovery` has called
`issue_lock_store.release_session_lock` since #850, and that function has
never existed. The `AttributeError` landed in a bare `except Exception: pass`,
so every rollback removed the branch and worktree and silently left the lock
behind — precisely the uninspectable, unrecoverable state #953 is about
(`gitea_recover_incomplete_bootstrap_lock` refuses `worktree_invalid`,
`gitea_lock_issue` has no worktree to bind). Confirmed dead at the pinned base
`82d71b77`, not introduced by this branch.
`release_session_lock` is therefore implemented: it removes exactly one
durable lock whose recorded `owner_session` matches the caller's, keyed by
repository when known, refusing on zero or multiple matches so no caller can
delete a lock it does not own and an ambiguous directory is never guessed at.
Bootstrap phase journals and session pointers that share the directory are
excluded by shape. The flock sidecar is deliberately left alone. The caller no
longer swallows a release failure; it records `lock_release_failed:...`.
`assess_post_compensation_state` then classifies from directly observed
durable state — lock file, worktree directory, and branch ref — rather than
from the journal's `rolled_back` list, which records only what compensation
attempted. Three distinct states: `complete` (nothing remains),
`partial` (rollback ran, artifacts survive by design or because a step
errored), `failed` (rollback never completed, so nothing is proven removed).
`post_compensation_action` answers for exactly what survives:
complete -> re-run gitea_bootstrap_author_issue_worktree
lock + branch + worktree -> gitea_recover_incomplete_bootstrap_lock
branch + worktree, no lock -> gitea_lock_issue (still base-equivalent)
lock only, worktree gone -> gitea_inspect_issue_lock_contract
branch only -> gitea_inspect_issue_lock_contract, then retry
rollback did not complete -> gitea_inspect_issue_lock_contract
No branch names an artifact the classification says is gone, and a failed
rollback step is stated rather than presented as an intentional outcome. The
refusal payload carries `compensating_recovery` and `post_compensation_state`
alongside the derived `exact_next_action`, still with
`implementation_allowed: false`.
Also removes the dead `SOURCE_BOOTSTRAP_LOCK_RECOVERY` constant (review 632
F3), which had no readers and implied a second lock source; the deliberate
reuse of `SOURCE_LOCK_ISSUE` is now stated as a comment. The #447 guard and
`SANCTIONED_LOCK_SOURCES` remain untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Review 632 F1. `gitea_recover_incomplete_bootstrap_lock` writes the same
durable author issue lock as `gitea_recover_dirty_orphaned_issue_worktree`
but gated only on the reviewer-stop router check and the profile permission
block. Every other author state-creating mutation carries a third gate,
`_namespace_mutation_block`, and this tool was the outlier.
The surviving gates did not cover the gap: the task's required permission is
`gitea.issue.comment`, which every configured role holds, and
`_ensure_matching_profile` returns a profile name both call sites discard, so
it refuses nothing. A reviewer-bound session reached the exact-owner claimant
comparison inside `assess_bootstrap_lock_recovery` and was refused there —
one layer too late, with no namespace evaluation and no BLOCKED audit record
of the attempt.
Adding the call alone would have been inert. `check_author_mutation_namespace`
routes through `role_session_router.required_role_for_task`, which reads
`TASK_REQUIRED_ROLE` — not `task_capability_map` — and that table had no entry
for this task, so the check short-circuited to "allowed" for every caller.
The task is therefore registered in `TASK_REQUIRED_ROLE` and `AUTHOR_TASKS`,
matching the role the capability map already records.
The reviewer-namespace check alone is also insufficient for a task gated on
`gitea.issue.comment`, since merger, controller, and reconciler profiles hold
it too. `role_namespace_gate.check_author_role_kind` is added as an opt-in,
additive wall requiring the active profile's derived role kind to be exactly
`author`; `mixed` is refused rather than admitted. `_namespace_mutation_block`
gains a keyword-only `author_role_exclusive` flag, default off, so the six
pre-existing call sites are byte-for-byte unchanged.
Refusals produce the standard structured denial (`namespace_block: true`,
`mcp_namespace`) and the standard BLOCKED audit record, and no lock, lease,
generation, branch, worktree, issue, or PR is touched. Valid `prgs-author`
execution is unaffected. No reviewer permission is broadened and no existing
role wall is weakened.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
gitea_bootstrap_author_issue_worktree wrote a lock no downstream author
operation accepts, then directed the author straight to implementation. Once
the branch carried commits, heartbeat, re-lock, exact-owner renewal, and the
#447 create-PR guard all refused simultaneously and no sanctioned recovery
path remained eligible.
Each of those gates is individually correct. The defect was that two writers
disagreed about what a lock is.
- Add author_lock_contract as the single canonical definition: claimant,
work_lease, lock_provenance, generation, and an explicit expiration state.
Both gitea_lock_issue and bootstrap now build through it.
- Promote issue_lock_store.lock_claimant to the one shared claimant reader and
use it in the ownership check, so a claimant recorded at the lock top level
is read rather than refused. The values are still compared against
server-resolved identity and profile, so no legacy placement grants anything
the canonical placement would not.
- Represent missing expiration explicitly. An absent expires_at previously read
as "not yet expired", leaving a malformed lock permanently non-expiring and
permanently ineligible for #760 renewal.
- Bootstrap reads its lock back and verifies it structurally before reporting
success. A partial lock fails closed while the worktree is still
base-equivalent, names the missing fields, and never reports
implementation_allowed. Its exact_next_action now matches the state returned.
- Add gitea_recover_incomplete_bootstrap_lock for locks already written by the
old bootstrap, including those whose branches carry pushed commits. It never
moves, resets, or rewinds a branch, never requires base-equivalence, never
pushes or opens a PR, and touches only the target lock. It proves repository,
issue, claimant username and profile, branch, worktree, registration, and
head before writing, refuses healthy foreign-owned locks, and mints
provenance and authorization server-side.
- Add gitea_inspect_issue_lock_contract, a strictly read-only surface.
- Document the required ordering and the recovery path.
The #447 provenance guard is unchanged and the sanctioned source set was not
widened: bootstrap now satisfies the guard rather than the guard being relaxed
to admit bootstrap.
Tests: 61 new cases covering the canonical schema, immediate heartbeat,
renewal before and after commits, the create_pr guard, executable next actions,
partial and malformed and missing-expiration and expired and same-owner and
foreign-owner and legacy locks, recovery isolation, read-only inspection, and
the full bootstrap-implement-commit-push-create_pr regression against isolated
fixtures.
Full suite at head: 28 failed, 5753 passed, 6 skipped, 1042 subtests.
Full suite at base 82d71b77: 28 failed, 5692 passed, 6 skipped, 1042 subtests.
Failing test-ID sets are identical, so there are zero regressions; the +61
passes are this issue's new suite.
Issue #949 was preserved and not recovered: its branch remains at
92615f474b and its worktree, lock, and PR state
were not touched. The #949-shaped regression uses isolated fixtures only.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Addresses review 623 on PR #946 (B1 blocker, F2 medium, F3 minor).
B1 - the wiring this branch exists to install had no regression coverage.
The existing suite exercised owning_pr_renewal_from_lock,
_owning_pr_continuation_from_lock and the duplicate gate directly, but never
drove an enforcement path, so reverting any of the three call sites left the
whole repository green. Add tests/test_issue_945_enforcement_path_wiring.py,
which drives the real mcp_server._enforce_locked_issue_duplicate_recheck (the
shared recheck behind gitea_commit_files and gitea_create_pr),
mcp_server.gitea_assess_work_issue_duplicate and
mcp_server._prove_author_ownership_for_pr against a renewal-bearing lock, and
asserts each grants the exemption. Reverting the commit/create-PR recheck to
the recovery-only rebuild now fails 8 tests and 4 subtests; reverting the
assessor or the push prover fails 2 each. The suite also keeps the fail-closed
matrix on the real paths: an open PR alone, a second PR, a different PR,
branch, issue or head, identity and profile mismatch, ungranted and malformed
renewal blocks, and sequential-task non-inheritance are all still refused.
F2 - the claimant check compares lease_renewal.identity/profile against the
claimant recorded on the same lock file. Both sides are server-written fields
of one document, so it is an internal-consistency check, not verification of
the live authenticated caller. Correct the docstring and the inline comment to
say so, and document the binding that actually prevents cross-session reuse:
the enforcement paths load the lock through _load_existing_issue_lock() with no
issue coordinates, which resolves issue_lock_store.read_session_issue_lock() to
the session pointer at session-{os.getpid()}.json, so lock selection is scoped
to the operating-system process. Its limits are stated too - per-process rather
than per-authenticated-user, silent on locks reached by explicit coordinates,
and silent on two roles sharing one process. Live identity and profile stay
enforced by the mutation-authority and profile gates, not by this rebuild.
F3 - _owning_pr_continuation_from_lock previously fell through to renewal when
a dead_session_recovery block was present but failed to rebuild, so a recovery
record naming one PR could be bypassed by renewal evidence naming another.
Present-but-unusable recovery evidence is now ambiguous rather than absent and
fails closed. Because an expired lease whose recorded owner has also died
satisfies both dispositions in one gitea_lock_issue call, a sanctioned pair is
a reachable state; when both rebuild, they must agree on issue, PR, branch and
every head, or no continuation authority is returned. Recovery-only and
renewal-only locks keep their existing behaviour exactly.
Validation of the resulting token against live PR state remains untouched and
solely owned by issue_work_duplicate_gate._assess_owning_pr_exemption. No
public signature, MCP tool schema, refusal shape or reason code changes.
Tests: focused #945/#755/#760 suites 137 passed, 19 subtests. Full suite in a
branches/ worktree: 30F/5619P/6S/1013 subtests at this head vs 30F/5572P/6S/1002
at a clean checkout of 79334d48, with byte-identical failing test id sets - the
+47 passes and +11 subtests are exactly the new coverage.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q8RUznLXEA4JoK48sTZiSK
Add docs/remote-mcp/coupling-inventory.md, the blocking first child of epic
#929. It enumerates every place gitea_mcp_server.py and its supporting modules
depend on being a local, client-spawned, stdio-attached process on the
operator's machine.
62 entries across the seven required categories: transport bind, launch
provenance, role binding, credentials, runtime freshness, local filesystem,
and durable state. Each entry carries a file and line anchor resolving at
7bf4f12584, states what the code assumes today
and what it would observe on a remote host, is classified as portable as
written / needs a seam / needs a replacement / cannot be remote, and is
assigned to exactly one epic child. Every child from #931 through #939 is
named by at least one entry. Summary tables count entries per category, per
classification, per category-by-classification, and per child.
Documentation only. No server behavior changes.
Closes#930
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HvJz7bUz5CkZgUxq8twHMz
B1: classify_attention_event uses structured flags/category only — never
substring-match human-authored title/summary for escalation.
B2: make notification ids unique across probe_errors and collisions
(include loop index / kind).
B3: do not assign probe_errors to fetch_error (avoids false Fetch Warning
and double-reporting).
Regression tests cover all three blockers.
Refs #648
Keep notifications (#648) routes and nav alongside master requests (#643)
and other base updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Keep #644 recovery console actions and #643 initiate_workflow side by side
in console_authz and authz audit docs after merging latest master.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Reviewer REQUEST_CHANGES on PR #903 at head 1c88b87 raised five blockers, all
reproduced by executing that head. The shared shape: a write path that declared
itself gated, audited, and verified, but never armed the gate, mutated a copy of
the state it claimed to fix, and then verified against that same copy.
B1 - the apply path never asked the execution gate.
execute_recovery_playbook called console_authz.authorize with the default
for_execution=False, and the phase branch only fires when it is True. ACTIVE_PHASE
is 1 and every new action is phase 2, so an operator executed a phase-2 write
through POST /api/v1/system/recovery/apply while build_recovery_preview reported
execution_enabled false. The call now passes for_execution=True and surfaces the
phase_not_active refusal. Preview reports the same decision under
execution_authorization / execution_blocked_reason instead of a hardcoded False it
could not explain.
B2 - both env playbooks mutated a discarded copy and verified against it.
source_env = dict(os.environ) meant clear_stale_binding and rebind_session_worktree
never touched the running process, and verify_post_recovery(env=source_env)
re-diagnosed the same copy, confirming a change that had not happened. Mutations
now target the live mapping (apply_recovery's sanctioned env=None -> os.environ
path, #702 AC2) and verification re-reads state rather than the mutated input.
binding_before / binding_after / binding_changed are returned, and a playbook that
changed nothing reports performed: false. verify_post_recovery no longer reads an
unverified_inherited binding as clean, because unproven is not clean.
B3 - the reconcile playbook called a function that does not exist.
merged_cleanup_reconcile.reconcile_merged_cleanups is absent from that module and a
bare except turned the AttributeError into a generic failure, so the playbook could
never succeed. It now calls gitea_mcp_server.gitea_reconcile_merged_cleanups, the
real orchestrator, imported lazily; failures carry error_type. task_capability_map
declared gitea.pr.close for reconcile_cleanups while the entry point gates on
gitea.read; the two authority statements are reconciled to the one that is enforced.
B4 - the #630 contamination integration could not block.
assess_contamination_gate was fed marker=None, which short-circuits to block: False
on its first statement; the task passed was a console action id outside
CONTAMINATION_GATED_TASKS; and the result was read through a "contaminated" key the
gate never returns, making STATUS_BLOCKED_CONTAMINATION unreachable. The live marker
now comes from the #641 session inventory reader, the gated task key
console_recovery_apply is added to CONTAMINATION_GATED_TASKS, every read uses the
"block" key the gate actually returns, and the marker is forwarded to
sanctioned_restart.execute_restart so a restart cannot launder a contaminated
runtime. The reconciler cleanup playbook stays exempt as the designated remedy.
B5 - the parity baseline was captured from the head it was compared against.
capture_startup_parity(root, head=checkout_head) stores the head verbatim, so
in_parity was structurally incapable of being false, and live_remote_head was never
passed. The baseline is now the daemon start head that assess_stale_runtime already
returns, and the #610 live-remote dimension is restored.
Also: _recovery_card was the one renderer in system_health_views.py interpolating
without _esc(), and it is where a marker's operator-supplied command_summary lands
once B4 is wired; it now escapes, including the except branch. Docs no longer claim
apply enforces master parity or that verify asserts clean: true, and the absolute
file:///Users/... links are relative.
Tests: the two that asserted the defects as intended are inverted -
test_api_recovery_apply_with_dev_auth asserted the phase-gate bypass, and the rebind
test asserted the input echoed back. Added coverage per blocker, including a no-op
detection test that fails when a playbook reports success without changing anything,
the previously untested reconcile playbook, contamination block and remedy-exemption
tests, and parity baseline/live-remote tests. Both new guards were mutation-verified:
disarming for_execution fails 2 tests, restoring the env copy fails 2 tests.
Validation: WEBUI_TEST_OFFLINE=1 ../../venv/bin/python -m pytest tests/ -q from
branches/feat-issue-644 gives 27 failed / 5291 passed / 6 skipped / 953 subtests;
the same command from branches/baseline-master-76f293e at 76f293eb28 gives
28 failed / 5262 passed / 6 skipped / 926 subtests. Suites run one at a time.
comm of the sorted FAILED lines shows no new signature at the head. The single
absent signature, test_workspace_guard_alignment.py::
TestRuntimeContextGuardAlignment::test_declared_branches_worktree_passes_when_mcp_root_differs,
is suite-order dependent: that file passes 9/9 in isolation at both revisions.
Closes#644
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add recovery_playbook.py with the narrow-to-broad recovery ladder, symptom
routing, attempt-log helpers, and escalation metrics. Wire the attempt-log
gate into restart_coordinator so rolling/full/host restarts require prior
insufficient narrower attempts (or break-glass). Document the ladder and
update gitea_request_mcp_restart for prior_recovery_attempts_json.
Co-Authored-By: Grok 4.5 <[email protected]>
Records the two GET routes, what each panel consumes, and the three
properties the surface is held to: an unreadable source reports
unavailable rather than green, authorization is probed with
for_execution=True so a Phase 1 refusal is never shown as an allow, and
the control-plane database is opened mode=ro so reading status never
creates it.
Refs #655#642#658#661#662#663#633#652#653#664
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add a Phase 3 read-only console that resolves issue↔PR linkage with
evidence (closes keyword, branch marker, body mention), surfaces the
latest canonical handoff for a focused thread, and deep-links to Gitea
only under the admin reveal opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Addresses both blockers from the PR #902 review (review 589) for issue #643.
B1 — an allocator-created assignment could be orphaned and reported as no
mutation.
apply_request re-previews, then re-runs the allocator with apply=True. The CAS
fingerprint hashes only {kind, number} plus exclusions, so a competing lease
taken on the requested unit inside the window leaves the fingerprint identical:
the pin passes, the selection loop skips the now-claimed unit and commits an
assignment on the *next* one, and _selection_matches then fails on egress. The
old code returned mutation_performed False with that lease still committed and
owned by a synthetic session nothing heartbeats. The window contains a second
full load_queue_snapshot(), so it is seconds wide, and foreign sessions acting
on this repo concurrently are an observed condition.
The egress mismatch now releases the assignment the allocator created before
refusing. When the release succeeds the refusal reports mutation_performed
False and a compensation record; when it fails the response carries
mutation_performed True, an explicit orphaned_assignment, and a
gitea_release_workflow_lease reclaim action, because claiming nothing changed
while a lease is live is the defect rather than a report of it.
The same state was reachable through _run_allocator's bare except Exception:
allocate_next_work only catches InvalidWorkKindError, LeaseRequiredError and
ControlPlaneError, so anything raised after assign_and_lease committed arrived
as "no result" with a durable lease. A None result on the apply path now sweeps
and releases whatever this flow's session owns. That sweep is only possible
because of the B2 fix below — the session id is now stable across the flow, so
the lease is findable.
B2 — the "read-only" preview wrote to the control-plane DB.
allocate_next_work called db.upsert_session and db.expire_stale_leases
unconditionally, before the apply branch was consulted, and default_allocator
minted a fresh webui-request-<hex> per call. Every preview therefore appended a
never-reused session row and mutated global lease state while the payload said
dry_run True / mutation_performed False, driven by an operator refreshing a
form. One apply wrote two rows and bound the lease to the second, which is why
B1's orphan had no reclaimable owner.
allocate_next_work gains a keyword-only side_effect_free flag, default False so
every existing caller is byte-for-byte unchanged. Under the flag both writes are
suppressed and expired leases are instead filtered out of the claim map in
memory, which reaches the same selection the sweep would have produced without
persisting anything; a claim whose expiry cannot be parsed is kept, since an
unreadable expiry is not evidence that work is free. side_effect_free with
apply=True fails closed rather than silently reserving. request_service mints
one session id per request flow and threads it through both the dry-run and the
apply, and the dry-run now routes through the side-effect-free path.
Coverage.
test_allocator_drift_on_apply_is_not_read_as_an_assignment asserted the defect —
it built the orphan state and then required mutation_performed to be False, which
a leak satisfies. It now requires the compensating release. Added: release-failure
surfacing a reclaim action, an assignment with no lease id, the post-commit
exception route, and session-id identity across the flow. The two areas the review
named as having zero coverage now have it: default_allocator past its two
fail-closed early returns (side_effect_free routing, session-id pass-through and
minting, scope and fingerprint propagation) and default_claims_source (scoped
read, and an unreadable substrate denying rather than reading as "nothing
claimed"). allocator_service gains side-effect-free tests against a real
temp-file DB plus the expiry-filter unit tests.
Every new guard was mutation-tested by reverting it one at a time: B1
compensation removed → 3 failures; post-commit sweep removed → 1; session id
re-minted per call → 2; side_effect_free ignored → 3; in-memory expiry filter
removed → 1.
Verification: WEBUI_TEST_OFFLINE=1 python -m pytest tests/ -q from this
branches/ worktree gives 23 failed, 5263 passed, 6 skipped, 899 subtests. The
sorted FAILED set is identical to the reviewer's clean-master baseline at
2f4dec83 (23 failed, 5190 passed) — no new, changed, or disappeared failure —
and 21 tests were added over the reviewed head's 5242. Zero conflict markers;
py_compile passes; git diff --check clean.
Refs #643, PR #902
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01V6xFqovhbArPv61j9KCGkL
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
Operators had to paste a role prompt into a terminal to start work, and
nothing enforced that the allocator had been consulted first, so two sessions
could reach for the same issue and each believe it was theirs. This adds a
request surface: a desired role, an issue or PR, and a stated intent, answered
by an authorization decision and - on confirmation - an exclusive assignment
from the allocator.
Preview (POST /api/v1/requests/preview, and the /requests form) runs five
checks and reports authorize/deny with a reason for each: console
authorization, capability resolution for the desired role, lease availability,
whether the allocator would independently select this work unit, and head
pinning for PR work. It is read-only - it calls the allocator with apply=false
and writes only an audit line. An unauthorized principal never reaches the
allocator or the control-plane DB, so a denial cannot enumerate the queue.
Initiation (POST /api/v1/requests/apply) never assigns the requested item
directly. It runs a dry-run first and proceeds only when the allocator would
independently pick that exact work unit, carrying the dry-run's
candidate_set_fingerprint as a CAS pin; otherwise it returns wait or blocked
and mutates nothing. An active claim on the work unit rejects a duplicate
assign before one is attempted. A returned assignment carries a handoff block
naming the required profile, namespace, and the actions that stay forbidden.
Authorization reuses the #633 model rather than adding a second one. The new
initiate_workflow action is operator-class because its outcome is a claim, not
a Gitea verdict: requesting reviewer or merger work reserves that work but
grants no right to approve or merge. Execution is gated by a new per-action
execution_env_flag (WEBUI_REQUESTS_EXECUTION), deliberately in place of raising
ACTIVE_PHASE - a phase bump would enable execution for every phase-2 action at
once, including ones whose execution path is not implemented. Actions that
declare no flag are unchanged and still report execution_enabled false.
Every preview and apply emits a console audit record correlated to the
resulting assignment by correlation.request_id.
Fail-closed throughout: an unreadable control-plane DB, an incomplete queue
inventory (#758), an allocator that raises, an unpinned PR head, a moved PR
head, and an unconfirmed apply all deny without mutating.
Files:
- webui/request_service.py (new) - request model, preview, initiation
- webui/request_views.py (new) - form and preview rendering, escaped
- tests/test_webui_request_initiation.py (new) - 52 tests
- webui/console_authz.py - initiate_workflow action, execution_wired()
- webui/app.py - /requests, /api/v1/requests/preview, /api/v1/requests/apply
- webui/nav.py - Requests nav entry
- webui/traffic_loader.py - public candidates_from_queue_snapshot alias
- docs/webui-requests.md (new), docs/webui-authz-audit.md
Validation: full suite on this branch 5242 passed, 6 skipped, 899 subtests, 23
failed. Clean master baseline at 2f4dec83 in an equivalent branches/ worktree:
5190 passed, 6 skipped, 867 subtests, the same 23 tests failed. The branch adds
52 passing tests and introduces no new full-suite failure signature.
Closes#643
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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
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]>
Add pure post_restart_reconcile.reconcile_after_restart classifier with a
machine-readable completion proof covering service health, sessions, leases,
capabilities, worktrees, interrupted mutations (never auto-resumed),
duplicates, and queue state. Soft-depends on #660 checkpoints (skipped with
reason when the schema module is absent).
Wire read-only MCP tool gitea_reconcile_after_restart, boot-once hook via
gitea_assess_master_parity, log_only/enforce modes (mutation_hold), and docs.
Closes#662
Related: #655#652#653#660#661
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add a versioned, redacted, reconcile-on-boot session checkpoint store to
control_plane_db so a restart can recover session identity, stage, lease
ownership, and next valid action instead of forcing human reconstruction
(umbrella #655; #628 autonomous-handoff goal).
- Bump SCHEMA_VERSION 4->5. New `session_checkpoints` table + two indexes,
added via `CREATE TABLE IF NOT EXISTS` so table creation is itself the
additive, idempotent v4->v5 migration (dependency_edges precedent).
- Writer `write_session_checkpoint` upserts the current recoverable state
per (remote, org, repo, session_id, work_kind, work_number); stage
transitions audit to `events`. Readers `get_session_checkpoint` /
`list_session_checkpoints`.
- Every free-text and JSON field is passed through `gitea_audit.redact`
before storage — no tokens/credential URLs can land in a checkpoint (AC4).
- `reconcile_session_checkpoint` is pure and never restores: it diagnoses a
stored checkpoint against live head/lease state and flags staleness (AC3).
- Drain gate: `require_complete=True` fails closed (writes nothing) when a
checkpoint is missing a drain-required field, so drain cannot complete on
an unrecoverable record.
- `lease_id`/`assignment_id` are soft references (no enforced FK) so a
checkpoint survives deletion of the lease it names; `work_number=0` is the
NULL-safe session-level sentinel.
Tests: 13 new cases (schema/version, multi-role fixtures, upsert+audit,
JSON round-trip, secret redaction, reconcile stale-head/dead-lease/
reassigned-lease/clean/unknown, drain fail-closed, sentinel key). Existing
schema_version assertion updated 4->5. Full tests/test_control_plane_db.py
suite: 33/33 pass.
Links #652#653#655.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
resolve_canonical_repo_root fallback now uses commonpath only — no
string split and no os.path.isdir gate — so MCP project_root =
branches/<wt> still resolves to the repo root when the path is not yet
on disk (#274 / review #551 regression).
- Remove /branches/ string-split fallback in resolve_canonical_repo_root;
recover roots via commonpath ancestry only (review #531 F2).
- Refuse existing branches that do not contain live master; no weak
merge-base acceptance (F3).
- Verify caller-supplied assignment_id/lease_id against the control plane
or fail closed (F4).
- Compensating recovery releases bound workflow leases via lease_lifecycle (F5).
- Regression tests for each finding.
assess_same_issue_lease_conflict entered the reclaim branch only under is_lease_expired, so a heartbeat-lifecycle lease that is non-live but whose expires_at is still in the future (stale_absolute_cap under default policy; stale_missed_heartbeat under TTL>grace) fell through to the foreign active-lease block and never reached assess_expired_lock_reclaim, which already permits those bands. The gate now also enters reclaim/renewal when a heartbeat-lifecycle lease is not is_lease_live even with a future expires_at; legacy locks are excluded and keep their absolute expires_at clock (AC-N8). Adds tests/test_issue_794_conflict_gate_reclaim.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Address REQUEST_CHANGES on PR #795: stop claiming all 21 umbrella ACs
from a single unit-test module. Scope docs and cases to child issue #878
(CTH/format, ownership classify_skip, dependency edges). Does not close
umbrella #628.
No content conflicts. Master advanced with the #658 restart coordinator;
inventory API surfaces auto-merge cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>