fix(mosaic): de-flake mutator-class lease gate TTL-expiry test
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMpAGx3YQZT64dov5xCNJL
This commit is contained in:
Hermes Agent
2026-07-20 05:16:40 -05:00
parent acd7d380f6
commit 06d6828d73

View File

@@ -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, {