policy: guard adoption baselines, resolve-time validation, and inherited-delta narration
Review fixes (ds-review-bot on #623): - Persistence adoption compares the immutable policy baselines: onCreated's ownerless claim and adoptLivePrefix retain the STORED header, so a same-id live session with a conflicting baseline now rejects as a collision instead of appending under read-only and resuming under the stored danger-full-access. - resolve() resolves the session override BEFORE applying an explicit approved mode: the one-shot grant no longer bypasses the unconditional durable-header validation. - The approval narrator attributes positionally over the session's OWN events (past the seed boundary): a fork child whose baseline delta has no own override narrates 'inherited from the delegating session' instead of misattributing a stale seed-carried switch to the user or the operator. Red-first: baseline-conflict adoption in the shared coordinator contract (both backends), resolve-with-explicit-mode validation, and the fork-child narration attribution case.
This commit is contained in:
@@ -113,6 +113,22 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function assertSamePolicyBaselines(id: SessionId, stored: SessionHeader, live: SessionHeader): void {
|
||||
if (stored.sandboxMode !== live.sandboxMode || stored.approvalPolicy !== live.approvalPolicy) {
|
||||
throw new Error(
|
||||
`session "${id}" is already persisted with a different policy baseline `
|
||||
+ `(persisted: ${String(stored.sandboxMode)}/${String(stored.approvalPolicy)}, `
|
||||
+ `live: ${String(live.sandboxMode)}/${String(live.approvalPolicy)}) (id collision)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a live session seed reproduces a persisted prefix exactly. */
|
||||
function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
|
||||
return prefix.length <= seed.length
|
||||
@@ -534,6 +550,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
if (tracked.meta.cwd !== session.header.cwd) {
|
||||
throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
|
||||
}
|
||||
assertSamePolicyBaselines(id, tracked.meta, session.header)
|
||||
if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) {
|
||||
throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`)
|
||||
}
|
||||
@@ -587,6 +604,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
if (meta.cwd !== session.header.cwd) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
|
||||
}
|
||||
assertSamePolicyBaselines(session.header.id, meta, session.header)
|
||||
this.assertVersion(meta)
|
||||
assertSupportedEvents(events, session.header.id)
|
||||
if (!seedCoversPrefix(seed, events)) {
|
||||
|
||||
@@ -679,6 +679,27 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('a live session with a CONFLICTING policy baseline cannot adopt a stored prefix', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
// A stored artifact carrying a WIDE baseline. A same-id live session
|
||||
// claiming a NARROW baseline must be rejected: adoption retains the
|
||||
// stored header, so accepting the pair would let the session append
|
||||
// under read-only now but resume under danger-full-access later.
|
||||
await ctx.sessionPersistence.create({ ...meta('baseline-conflict', WORK), sandboxMode: 'danger-full-access' })
|
||||
await ctx.sessionPersistence.append(SessionId('baseline-conflict'), oneTurnLog())
|
||||
const live = ctx.sessions.create(SessionId('baseline-conflict'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, sandboxMode: 'read-only' },
|
||||
})
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/policy baseline|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)
|
||||
|
||||
Reference in New Issue
Block a user