persistence: include the seed boundary in the adoption policy-identity check

Review fix (ds-review-bot on #623): matching baselines with differing
seedLength still resolve different policies — overrideOf folds own switches
past the boundary, so a stored seedLength: 1 marks event 0 as subsumed seed
history while a live seedLength: 0 lets the same event tighten the session;
adoption retained the stored header and a restart silently restored the
wide baseline. When either side carries a baseline, the seed boundary is
part of the policy identity and a mismatch rejects as an id collision.
Red-first in the shared coordinator contract (both backends).
This commit is contained in:
kingwl
2026-07-27 00:16:54 +08:00
parent 290e1acc45
commit 49a4ebd4bc
2 changed files with 39 additions and 1 deletions

View File

@@ -117,7 +117,10 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
* Reject a stored/live pair whose immutable policy baselines differ.
* Adoption and ownerless claims retain the STORED header, so accepting a
* conflicting pair would let a session run under its live baseline now but
* resume under the stored one later — a silent policy swap.
* resume under the stored one later — a silent policy swap. When either side
* carries a baseline, the seed boundary is part of the policy identity too:
* `overrideOf()` folds own switches past `seedLength`, so differing
* boundaries make the same log resolve different policies across a restart.
*/
function assertSamePolicyBaselines(id: SessionId, stored: SessionHeader, live: SessionHeader): void {
if (stored.sandboxMode !== live.sandboxMode || stored.approvalPolicy !== live.approvalPolicy) {
@@ -127,6 +130,13 @@ function assertSamePolicyBaselines(id: SessionId, stored: SessionHeader, live: S
+ `live: ${String(live.sandboxMode)}/${String(live.approvalPolicy)}) (id collision)`,
)
}
const hasBaseline = stored.sandboxMode !== undefined || stored.approvalPolicy !== undefined
if (hasBaseline && (stored.seedLength ?? 0) !== (live.seedLength ?? 0)) {
throw new Error(
`session "${id}" is already persisted with a different policy seed boundary `
+ `(persisted seedLength: ${String(stored.seedLength)}, live: ${String(live.seedLength)}) (id collision)`,
)
}
}
/** Whether a live session seed reproduces a persisted prefix exactly. */

View File

@@ -700,6 +700,34 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('a live session with a DIFFERENT seed boundary cannot adopt a stored prefix when a baseline exists', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
// Same wide baseline both sides, but the stored header says event 0 is
// seed-carried (seedLength 1) while the live header says it is the
// session's OWN (seedLength 0). overrideOf() resolves policy through
// that boundary: a read-only switch at event 0 tightens the live
// session, yet a restart resumes under the stored header and the wide
// baseline silently returns. The boundary is part of the policy
// identity whenever a baseline exists.
await ctx.sessionPersistence.create({
...meta('seed-boundary-conflict', WORK),
sandboxMode: 'danger-full-access',
seedLength: 1,
})
await ctx.sessionPersistence.append(SessionId('seed-boundary-conflict'), oneTurnLog())
const live = ctx.sessions.create(SessionId('seed-boundary-conflict'), {
seed: oneTurnLog(),
meta: { cwd: WORK, sandboxMode: 'danger-full-access' },
})
await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)