From 06d6828d739f0e1203dc512c6d3f89756cbb9e94 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 20 Jul 2026 05:16:40 -0500 Subject: [PATCH] fix(mosaic): de-flake mutator-class lease gate TTL-expiry test The "observer revocation and monotonic TTL expiry deny the next mutator" acceptance test reused a 1-second-TTL lease both to prove authorization works immediately after promotion and, later, to demonstrate real monotonic-clock expiry. Under a contended push-CI host (concurrent build-gateway/appservice/web matrix), scheduling delay alone between promote() and the immediate "still valid" authorize() check could consume the entire 1-second margin, flipping that setup assertion to a spurious deny (Woodpecker push CI#1945: `expected { ok: false } to match { ok: true, decision: 'allow' }`). Reproduced deterministically by inserting a 995ms delay between promote() and the check, which reliably reproduced the exact CI#1945 failure signature. Fix is test-determinism only: the initial "lease authorizes" check now uses a normal-TTL lease with no real-time race, and a dedicated, isolated short-TTL lease drives only the deliberate wait-then-expire assertion. No production lease-broker/mutator-gate code changed, and the deny-the-next-mutator security assertions (LEASE_EXPIRED after TTL wait, MUTATOR_UNVERIFIED after revoke) are unchanged and unweakened. Closes #860 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BMpAGx3YQZT64dov5xCNJL --- .../mutator-gate.acceptance.spec.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 ca809b18..4fb90ea6 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => { test('observer revocation and monotonic TTL expiry deny the next mutator', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); - const pending = await beginVerification(socket, sessionId, 'claude', 1, 1); - await promote(socket, sessionId, pending.receipt_challenge!); + // Establish the lease with a normal (non-racing) TTL first and prove it + // authorizes. This "still valid" check is setup, not a TTL-expiry + // assertion, so it must not share a lease with a 1-second TTL: on a + // contended push-CI host, scheduling delay alone between promote() and + // this authorize() call can consume that entire 1-second margin and + // spuriously deny it (CI#1945). Using a generous TTL here removes that + // real-time race without touching lease-gate security semantics. + const pending = await beginVerification(socket, sessionId, 'claude'); + await promote(socket, sessionId, pending.receipt_challenge!); expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ ok: true, decision: 'allow', }); + + // A dedicated, isolated short-TTL lease drives the deliberate monotonic + // expiry demonstration below. It is never used for anything but the + // wait-then-expire assertion, so there is no setup work racing its + // 1-second window. + const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2); + await promote(socket, sessionId, shortLived.receipt_challenge!); await new Promise((resolve) => setTimeout(resolve, 1_100)); expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ ok: false, @@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => { decision: 'deny', }); - const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2); + const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3); await promote(socket, sessionId, refreshed.receipt_challenge!); expect( await request(socket, {