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:
kingwl
2026-07-26 23:56:41 +08:00
parent 9aaa4a871f
commit 290e1acc45
9 changed files with 105 additions and 15 deletions

View File

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

View File

@@ -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)