From f400830738998db105107a2a4c69c7f2a2a6fd5d Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Sat, 18 Jul 2026 02:45:34 -0500 Subject: [PATCH] test(#830): bound promote lost-ack residual --- docs/architecture/lease-broker-security.md | 10 ++++ .../mutator-gate.acceptance.spec.ts | 60 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index d3dd19c3..183f94b2 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -13,4 +13,14 @@ - Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C. - Receipt observation, payload construction, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism. +## Named residual: promote-lease-lost-ACK (WI-3 D2-v5) + +A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered. + +**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists). + +**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant. + +**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations. + Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index dc4a0acc..a334c623 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -450,6 +450,66 @@ describe('whole mutator-class lease gate', () => { }); }); + test('promote-lease-lost-ACK orphaned VERIFIED lease is caught by observer revoke and by monotonic TTL expiry (D2-v5 backstop)', async () => { + const { socket } = await startBroker(); + const observerSessionId = await register(socket); + const observerPending = await beginVerification(socket, observerSessionId, 'claude'); + await promote(socket, observerSessionId, observerPending.promotion_token!); + + expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({ + ok: true, + decision: 'allow', + state: 'VERIFIED', + }); + const retriedPromotion = await promote( + socket, + observerSessionId, + observerPending.promotion_token!, + ); + expect(retriedPromotion.ok).toBe(false); + expect(['PROMOTION_TOKEN_MISMATCH', 'INVALID_LEASE_TRANSITION']).toContain( + retriedPromotion.code, + ); + + const revoked = spawnSync( + 'python3', + [revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'], + { + encoding: 'utf8', + env: { + ...process.env, + MOSAIC_LEASE_BROKER_SOCKET: socket, + MOSAIC_LEASE_SESSION_ID: observerSessionId, + MOSAIC_RUNTIME_GENERATION: '1', + }, + }, + ); + expect(revoked.status, revoked.stderr).toBe(0); + expect(await authorize(socket, observerSessionId, 'claude', 'Write')).toMatchObject({ + ok: false, + code: 'MUTATOR_UNVERIFIED', + decision: 'deny', + }); + + const { socket: expirySocket } = await startBroker(); + const expirySessionId = await register(expirySocket); + expect(expirySessionId).not.toBe(observerSessionId); + const expiryPending = await beginVerification(expirySocket, expirySessionId, 'claude', 1, 1); + await promote(expirySocket, expirySessionId, expiryPending.promotion_token!); + expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({ + ok: true, + decision: 'allow', + state: 'VERIFIED', + }); + + await new Promise((resolve) => setTimeout(resolve, 1_100)); + expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({ + ok: false, + code: 'LEASE_EXPIRED', + decision: 'deny', + }); + }); + test('a fired observer fences the old lease even while broker transport is unavailable', async () => { const { socket } = await startBroker(); const sessionId = await register(socket);