diff --git a/docs/reference/native-compose-phase-trace.md b/docs/reference/native-compose-phase-trace.md index 7f1a68bdc..30a2850bf 100644 --- a/docs/reference/native-compose-phase-trace.md +++ b/docs/reference/native-compose-phase-trace.md @@ -33,7 +33,7 @@ environment values, subprocess output or exception text. The canonical call sites cover `oneoff.post-remove-owned`, `oneoff.post-remove-fresh`, `oneoff.post-remove-guard`, `storage.enroll`, -`guard.fresh-before`, `guard.ownership`, `guard.storage`, `guard.fresh-after`, +`storage.proof`, `guard.fresh-before`, `guard.ownership`, `guard.storage`, `guard.fresh-after`, `finish.pending`, `finish.witness-work`, and the finalization phases `finalize.fresh`, `finalize.generation`, `finalize.projection`, `finalize.owned`, `finalize.remember-storage`, `finalize.witnesses`, @@ -41,7 +41,10 @@ phases `finalize.fresh`, `finalize.generation`, `finalize.projection`, recheck repeats the phase with a new span. Conditional phases appear only when the existing command takes that path. `guard.storage` measures the ordinary storage verification await inside the workload ownership guard; `storage.enroll` measures -its preceding enrollment phase. Nested spans describe nested awaits, so their +its preceding enrollment phase. `storage.proof` measures one performed +directory-xattr verification kernel proof (three helper carriers) and nests inside +whichever phase requested it; a verification that reuses an adjacent proof under +the same lease emits no `storage.proof` span. Nested spans describe nested awaits, so their durations must not be added as independent CPU time. Output stops after 256 records or 64 KiB, or if the clock or sink fails. Diagnostic diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 4476dc8c3..8146831af 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -103,15 +103,44 @@ projection, recovery or newly declared volume. A speculative ownership read can preview that merge but cannot publish it. A birth addition repeats the original fully fenced path. Each qualifying phase keeps a fresh closing kernel proof and exact source, generation and receipt-incarnation checks. No proof is reused across -pending publication or effects. Opening preparation, enrollment, pre-spawn and -both final receipt proofs remain separate. The one-volume offline counter is +pending publication or effects. The opening preparation proof and the +post-workload receipt proof remain separate. The one-volume offline counter is three full proofs before effect entry rather than five; it establishes call ordering and count, not CPU savings or atomic observation across the daemon and private filesystem. +Within one command and one mutation lease, a verification whose code since the +previous proof performed only reads may reuse that completed kernel proof. It +still runs every host check (anchors, expectation, completion and binding). It +skips the three helper carriers only when the engine, generation and reference +are the same and its opening material binding (receipt identity and hash, lease +token, checkout anchor and generation) is byte-identical to the binding that +opened and closed the recorded proof. A receipt publication in between, such as +pending intent, a storage birth or completion, changes that binding and forces a +full proof. A refused or failed verification clears the record. Nothing is +persisted: every later command or lease, including the first after an engine +restart, opens with a full proof. Callers claim adjacency from code structure, +never from timing: + +- the warm `up`/`restart` enrollment tail (no newly declared volume) reuses the + effect-entry proof of the same generation; a new volume seeds and proves in + full; +- the first pre-spawn guard of an `up`/`restart` without build, routing or file + material reuses the enrollment-tail record; +- saved `exec` reuses its opening proof before the process. Its closing proof + after the process stays fresh. + +Final receipt verification proves once after the workload when no route, after +hook, or new or saved file material needs a completion step. Otherwise it proves +again after that step. A warm image-only, unrouted `up` without hooks therefore +proves four times (opening, prepublication, effect-entry and post-workload) +rather than six. With `HACK_NATIVE_COMPOSE_PHASE_TRACE=1`, each performed +verification proof emits one `storage.proof` span; a reused proof emits none. +These counts establish call ordering and helper invocations, not wall time. + The one-off `run` path separately omits the enrollment-tail read that would immediately repeat its mandatory pre-spawn witness read. `up` and `restart` -keep that tail read. Cold enrollment still seeds once and verifies before +keep that tail read; when warm it is the adjacent reuse above. Cold enrollment still seeds once and verifies before publishing its enrolled reference. The one-volume cold-run control reaches the actual pre-spawn boundary after eleven helper calls rather than fourteen; it intercepts the workload there and does not qualify Docker execution or timing. diff --git a/src/lib/native-compose-command-storage.ts b/src/lib/native-compose-command-storage.ts index c9384e64c..ddcd7abc1 100644 --- a/src/lib/native-compose-command-storage.ts +++ b/src/lib/native-compose-command-storage.ts @@ -314,7 +314,16 @@ export async function prepareNativeComposeCommandStorage(opts: { // guard. Up and restart can arm other effects before that guard. if (operation !== "run") { stage = "storage-verify"; - await verify(generation); + // Warm enrollment (no new volume) performs only the read-only + // selection compare and cold-absence check above, after the + // effect-entry proof of this same generation under this lease. It + // may reuse that proof; a receipt write since then changes the + // material binding and forces a full proof. A new volume seeds and + // always proves in full. + await verify( + generation, + newVolumes.length === 0 ? { adjacentProof: true } : {} + ); } } catch (error) { retainNativeComposeEffectRefusal(error, { diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 758d25dc9..02e674912 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -56,6 +56,7 @@ import { assertNativeComposeFileRunSupported, assertNativeComposeSavedFileEngines, type NativeComposeCommandFiles, + nativeComposeSavedFilesPresent, prepareNativeComposeCommandFiles, prepareNativeComposeSavedFileStop, retireNativeComposeSavedFiles, @@ -1653,21 +1654,30 @@ async function executePreparedGeneration(opts: { signal, }); }, - retireFiles: async () => { - await files?.assertReady(selection, generation); - await retireNativeComposeSavedFiles({ - mutation, - store, - saved: previous, - selection, - signal, - }); - }, + // Without new or saved file material the retirement only reads, so + // leaving it out lets finalization take its single proof pass when no + // route or after hook needs a completion step either. + ...(files !== null || nativeComposeSavedFilesPresent(previous) + ? { + retireFiles: async () => { + await files?.assertReady(selection, generation); + await retireNativeComposeSavedFiles({ + mutation, + store, + saved: previous, + selection, + signal, + }); + }, + } + : {}), }); - // Up/restart enrollment ends with a storage proof. With no build, routing or - // file material, the first pre-spawn guard follows it with only read-only - // source and ownership checks (arming is a no-op), so that guard may reuse the - // adjacent proof. Every later guard and finalization proof stays fresh. + // Up/restart enrollment ends with a storage proof (a warm enrollment reuses + // the effect-entry proof; a new volume proves in full). With no build, + // routing or file material, the first pre-spawn guard follows it with only + // read-only source and ownership checks (arming is a no-op), so that guard + // may reuse the adjacent proof. Every later guard and the post-workload + // finalization proof stay fresh. let adjacentGuardProof = (operation === "up" || operation === "restart") && builds.length === 0 && diff --git a/src/lib/native-compose-file-command.ts b/src/lib/native-compose-file-command.ts index 7368665a4..59e3c1b0d 100644 --- a/src/lib/native-compose-file-command.ts +++ b/src/lib/native-compose-file-command.ts @@ -463,6 +463,17 @@ export async function prepareNativeComposeCommandFiles(opts: { } } +/** Whether any saved generation carries file material that + * `retireNativeComposeSavedFiles` would retire. With none, that retirement + * performs only read-only document loads and no file or engine effect. */ +export function nativeComposeSavedFilesPresent( + saved: readonly Saved[] +): boolean { + return saved.some((selected) => + Object.hasOwn(selected.document, NATIVE_COMPOSE_FILES_EXTENSION) + ); +} + /** Saved cleanup acquires neither authored inputs nor credentials and never infers child reaping. */ export async function retireNativeComposeSavedFiles(opts: { readonly mutation: NativeComposeMutation; diff --git a/src/lib/native-compose-phase-trace.ts b/src/lib/native-compose-phase-trace.ts index 540f833b0..0bcebca28 100644 --- a/src/lib/native-compose-phase-trace.ts +++ b/src/lib/native-compose-phase-trace.ts @@ -7,6 +7,7 @@ const PHASES = [ "oneoff.post-remove-fresh", "oneoff.post-remove-guard", "storage.enroll", + "storage.proof", "guard.fresh-before", "guard.ownership", "guard.storage", diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index 7586eb73b..726307b07 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -18,6 +18,7 @@ import { publishNativeComposeStorageWitnessEnrollment, runNativeComposeMaterialAction, } from "./native-compose-generation.ts"; +import { measureNativeComposePhase } from "./native-compose-phase-trace.ts"; import { type HeldDirectory, holdDirectory, @@ -1274,10 +1275,11 @@ function takeLeaseProof(opts: { const recorded = proofs.get(key); proofs.delete(key); return { - /** Run the kernel proof unless an adjacent caller opens with the recorded binding. */ + /** Run the kernel proof unless an adjacent caller opens with the recorded + * binding. Only a performed proof emits the passive `storage.proof` span. */ async prove(opening: string, run: () => Promise): Promise { if (!(opts.adjacent && recorded === opening)) { - await run(); + await measureNativeComposePhase("storage.proof", run); } }, /** Record a completed proof only when its binding never moved. */ diff --git a/tests/e2e/scenarios/native-config-compose.ts b/tests/e2e/scenarios/native-config-compose.ts index e77445915..5c784551a 100644 --- a/tests/e2e/scenarios/native-config-compose.ts +++ b/tests/e2e/scenarios/native-config-compose.ts @@ -146,6 +146,36 @@ function object(text: string): Record { return value; } +/** + * Storage verification proof counts from one CLI's phase trace. Each performed + * kernel proof (three helper carriers) emits one `storage.proof` span; a proof + * reused within the command's lease emits none. + */ +function storageProofTrace(stderr: string): { + readonly proofs: number; + readonly unfinished: number; + readonly proofMs: number; + readonly finalizations: number; +} { + const rows = stderr + .split("\n") + .filter((line) => line.startsWith('{"diagnostic":"native-compose-phase",')) + .map((line) => object(line)); + const spans = (phase: string, boundary: string) => + rows.filter((row) => row.phase === phase && row.boundary === boundary); + const ended = spans("storage.proof", "end"); + return { + proofs: spans("storage.proof", "begin").length, + unfinished: spans("storage.proof", "begin").length - ended.length, + proofMs: ended.reduce( + (total, row) => + total + (typeof row.durationMs === "number" ? row.durationMs : 0), + 0 + ), + finalizations: spans("finalize.witnesses", "begin").length, + }; +} + function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -1280,6 +1310,38 @@ export const nativeConfigComposeScenario: Scenario = { await checkSql(marker); await Bun.write(projectPath, projectText); await cli(["up", "--detach", "--json"]); + // HACK-1123: two consecutive unchanged warm ups each prove storage four + // times (opening, prepublication, effect-entry, post-workload); the + // enrollment tail and first guard reuse the effect-entry proof, and with + // no route, hook or file material finalization proves once. + const warmUps: (ReturnType & { + readonly totalMs: number; + })[] = []; + for (const attempt of [1, 2]) { + const warm = await raw(["up", "--detach", "--json"], { + HACK_NATIVE_COMPOSE_PHASE_TRACE: "1", + }); + expectExit({ + result: warm, + codes: [0], + message: `Traced unchanged warm up ${attempt} must succeed`, + }); + warmUps.push({ + ...storageProofTrace(warm.stderr), + totalMs: Math.round(warm.durationMs), + }); + } + ctx.log(`warm up storage proofs: ${JSON.stringify(warmUps)}`); + expect({ + that: warmUps.every( + (warm) => + warm.proofs === 4 && + warm.unfinished === 0 && + warm.finalizations === 1 + ), + message: + "Unchanged warm up must perform exactly four storage proofs and one finalization witness check", + }); const beforeRestart = await snapshot(); const beforeRestartBoot = await probe("exec", marker); stage("owned workload removal/rename and retained SQL data verified"); diff --git a/tests/helpers/native-compose-command.ts b/tests/helpers/native-compose-command.ts index b928ec72e..2d5c39329 100644 --- a/tests/helpers/native-compose-command.ts +++ b/tests/helpers/native-compose-command.ts @@ -55,6 +55,8 @@ export async function fixture( cleanupGuards.set(root, opts.cleanupAllowed); } await mkdir(join(root, ".hack")); + // Private HOME for child CLIs; the contributor's home is never selected. + await mkdir(join(root, "user-home"), { mode: 0o700 }); await Bun.write( join(root, ".hack/hack.project.json"), JSON.stringify({ @@ -246,7 +248,7 @@ export async function invoke( { cwd: root, env: { - HOME: process.env.HOME, + HOME: join(root, "user-home"), LANG: "C", PATH: `${root}:/usr/bin:/bin`, HACK_HOME: join(root, "home"), diff --git a/tests/helpers/native-compose-files.ts b/tests/helpers/native-compose-files.ts index 76f015624..957b07662 100644 --- a/tests/helpers/native-compose-files.ts +++ b/tests/helpers/native-compose-files.ts @@ -70,6 +70,8 @@ export async function fileCommandFixture(source: unknown = FILE_SOURCE) { const root = join(parent, "checkout"); await mkdir(join(root, ".hack"), { recursive: true }); await mkdir(join(parent, "home"), { mode: 0o700 }); + // Private HOME for child CLIs; the contributor's home is never selected. + await mkdir(join(parent, "user-home"), { mode: 0o700 }); await Bun.write( join(root, ".hack/hack.project.json"), JSON.stringify(source) @@ -152,7 +154,9 @@ await fail(); } export function spawnFiles( fixture: { readonly parent: string; readonly root: string }, - args = ["up", "--detach", "--json"] + args = ["up", "--detach", "--json"], + /** Diagnostics only, such as the passive phase trace. */ + extraEnv: Readonly> = {} ) { const child = Bun.spawn( [ @@ -165,7 +169,7 @@ export function spawnFiles( { cwd: fixture.root, env: { - HOME: process.env.HOME, + HOME: join(fixture.parent, "user-home"), LANG: "C", PATH: `${fixture.parent}:/usr/bin:/bin`, HACK_HOME: join(fixture.parent, "home"), @@ -176,6 +180,7 @@ export function spawnFiles( HACK_COMPOSE_STARTUP_TIMEOUT_MS: "3000", CI: "1", HACK_EXECUTION_MODE: "non_interactive", + ...extraEnv, }, stdin: "ignore", stdout: "pipe", @@ -187,9 +192,10 @@ export function spawnFiles( } export async function invokeFiles( fixture: { readonly parent: string; readonly root: string }, - args = ["up", "--detach", "--json"] + args = ["up", "--detach", "--json"], + extraEnv: Readonly> = {} ) { - const child = spawnFiles(fixture, args); + const child = spawnFiles(fixture, args, extraEnv); const timer = setTimeout(() => child.kill("SIGKILL"), 45_000); try { const [code, stdout, stderr] = await Promise.all([ diff --git a/tests/native-compose-file-command.test.ts b/tests/native-compose-file-command.test.ts index 87057b2e5..9196a2356 100644 --- a/tests/native-compose-file-command.test.ts +++ b/tests/native-compose-file-command.test.ts @@ -166,6 +166,59 @@ test("source CLI delivers actual acquired binary, managed and empty files with e expect(await fileJournal(ready.document)).toContain('"phase":"retired"'); await assertFileTransport(fixture); }, 120_000); +// Finalization may take its single proof pass only when the file retirement +// step would merely read. A first up has new file material and no saved file +// material: its completion step must still run, so the post-completion +// ownership pass re-observes the exact file mounts (files.assertReady) and the +// second finalize witness pass follows it. +test("first file up without saved file material keeps the completion step, post-completion file check and second finalize pass", async () => { + const fixture = await fileCommandFixture(); + const up = await invokeFiles(fixture, undefined, { + HACK_NATIVE_COMPOSE_PHASE_TRACE: "1", + }); + expect(up.code).toBe(0); + const phases = up.stderr + .split("\n") + .filter((line) => line.startsWith('{"diagnostic":"native-compose-phase",')) + .map((line) => JSON.parse(line) as { phase: string; boundary: string }) + .filter((row) => row.boundary === "begin") + .map((row) => row.phase); + const pass = [ + "finalize.fresh", + "finalize.generation", + "finalize.owned", + "finalize.remember-storage", + "finalize.witnesses", + "finalize.pending", + "finalize.pending-check", + ]; + expect(phases.slice(phases.indexOf("finalize.fresh"))).toEqual([ + ...pass, + "finalize.before-complete", + ...pass, + "finalize.save", + ]); + // Exact file-mount observations after the engine start: workload readiness, + // the first finalize pass's unbound-root check, the completion step's + // readiness check and the post-completion ownership pass's readiness check. + // Without the completion step only the first two remain. + const requests = await assertFileTransport(fixture); + const started = requests.findIndex((args) => args[0] === "compose"); + expect(started).toBeGreaterThanOrEqual(0); + expect( + requests + .slice(started) + .filter( + (args) => + args[0] === "container" && + args[1] === "inspect" && + String(args[3]).includes('"mounts":{{json .Mounts}}') + ) + ).toHaveLength(4); + const ready = await fileCommandState(fixture.root); + expect(ready.pending).toBeNull(); + expect(ready.current.stopped).toBe(false); +}, 120_000); test("unchanged authored file up acquires new bytes and retires its prior snapshot instead of reusing a warm generation", async () => { const fixture = await fileCommandFixture(); expect((await invokeFiles(fixture)).code).toBe(0); diff --git a/tests/native-compose-phase-trace.test.ts b/tests/native-compose-phase-trace.test.ts index a34785627..85dd53b73 100644 --- a/tests/native-compose-phase-trace.test.ts +++ b/tests/native-compose-phase-trace.test.ts @@ -339,14 +339,8 @@ test( "finalize.witnesses", "finalize.pending", "finalize.pending-check", - "finalize.before-complete", - "finalize.fresh", - "finalize.generation", - "finalize.owned", - "finalize.remember-storage", - "finalize.witnesses", - "finalize.pending", - "finalize.pending-check", + // No route, after hook or file material: no completion step, so no + // second finalization pass. "finalize.save", ]); expect(rows.every((row, index) => row.sequence === index + 1)).toBe(true); diff --git a/tests/native-compose-storage-activation-command.test.ts b/tests/native-compose-storage-activation-command.test.ts index e7c5fae6d..e44bc003c 100644 --- a/tests/native-compose-storage-activation-command.test.ts +++ b/tests/native-compose-storage-activation-command.test.ts @@ -71,7 +71,8 @@ async function ownedColdCase( ], cwd: root, env: { - HOME: process.env.HOME ?? root, + // A private HOME under the fixture, never the contributor's. + HOME: join(root, "user-home"), LANG: "C", PATH: `${root}:/usr/bin:/bin`, HACK_HOME: join(root, "home"), @@ -382,11 +383,13 @@ test("public down recover completes only removed readonly original work and next }, 500_000); // One proof is root, verify and root-after through three fresh helper carriers. -// Warm up/restart keep the opening, both effect-phase, enrollment and both -// finalization proofs; only the first pre-spawn guard reuses the adjacent -// enrollment proof (no build, routing or file material). Saved exec keeps its -// opening proof, reuses it for the adjacent pre-spawn check and proves again -// after the process. A cold up seeds once and keeps every later proof. +// Warm up/restart keep the opening and both effect-phase proofs. The warm +// enrollment tail (no new volume) and then the first pre-spawn guard (no build, +// routing or file material) reuse the adjacent effect-entry proof. With no +// route, after hook or file material, finalization keeps one fresh +// post-workload proof. Saved exec keeps its opening proof, reuses it for the +// adjacent pre-spawn check and proves again after the process. A cold up seeds +// once and proves its new volume in full. Consecutive warm ups cost the same. test( "source CLI helper invocations per command keep every non-adjacent proof", async () => @@ -414,12 +417,17 @@ test( "seed", ...proofs(3), "workload", - ...proofs(2), + ...proofs(1), + ]); + expect(await counted(["up", "--detach", "--json"])).toEqual([ + ...proofs(3), + "workload", + ...proofs(1), ]); expect(await counted(["up", "--detach", "--json"])).toEqual([ - ...proofs(4), + ...proofs(3), "workload", - ...proofs(2), + ...proofs(1), ]); expect(await counted(["exec", "web", "--", "true"])).toEqual([ ...proofs(1), @@ -427,9 +435,9 @@ test( ...proofs(1), ]); expect(await counted(["restart", "--json"])).toEqual([ - ...proofs(4), + ...proofs(3), "workload", - ...proofs(2), + ...proofs(1), ]); expect( (await operations(root)).filter((op) => op === "seed") @@ -438,3 +446,57 @@ test( }), 390_000 ); + +// Negative control for the single finalization pass: an after hook is a host +// effect between readiness and the ready receipt, so its effect phases keep +// their proofs and finalization keeps its second fresh proof after the +// completion step. The eight post-workload proofs equal the count before +// proof reuse; only the warm enrollment tail drops (six to five before the +// workload). +test( + "source CLI warm up with an after hook keeps both finalization proofs", + async () => + await ownedColdCase(async (owner) => { + const root = await fixture("", false, { + storage, + hooks: { + up: { + after: [ + { + name: "after", + command: { exec: [process.execPath, "-e", "0"] }, + }, + ], + }, + }, + cleanupAllowed: owner.cleanupAllowed, + }); + const eventsFile = join(root, "storage-events"); + const events = async () => + (await Bun.file(eventsFile).exists()) + ? (await Bun.file(eventsFile).text()).trim().split("\n") + : []; + const counted = async (args: readonly string[]) => { + const before = (await events()).length; + const result = await owner.invoke(root, args); + expect(result.code, await disposition(root, result.code)).toBe(0); + return (await events()).slice(before); + }; + const proofs = (count: number) => + Array.from({ length: count }, () => ["root", "verify", "root"]).flat(); + expect(await counted(["up", "--detach", "--json"])).toEqual([ + "root", + "seed", + ...proofs(3), + "workload", + ...proofs(8), + ]); + expect(await counted(["up", "--detach", "--json"])).toEqual([ + ...proofs(5), + "workload", + ...proofs(8), + ]); + expect(await Bun.file(join(root, "carriers")).json()).toEqual([]); + }), + 390_000 +); diff --git a/tests/native-compose-storage-fence-cost.test.ts b/tests/native-compose-storage-fence-cost.test.ts index c0322ce87..7d641d7d4 100644 --- a/tests/native-compose-storage-fence-cost.test.ts +++ b/tests/native-compose-storage-fence-cost.test.ts @@ -152,11 +152,14 @@ test("storage fence count per command: every proof kept, exact duplicates gone, direct: 0, observations: 2 * PROOF_OBSERVATIONS, }); + // Warm up proves four times: opening, prepublication, effect-entry (reused + // by the enrollment tail and first guard) and one post-workload finalization. + // A reused proof makes no engine fence or target observation. expect(await counted(root, ["up", "--detach", "--json"])).toEqual({ code: 0, - cli: FILE_ENGINE_FENCES + 6 * PROOF_FENCES, + cli: FILE_ENGINE_FENCES + 4 * PROOF_FENCES, direct: 0, - observations: 6 * PROOF_OBSERVATIONS, + observations: 4 * PROOF_OBSERVATIONS, }); // Admitted Unix selection: the same fences, one fresh read each. Only the // first storage fence is a CLI read, checked against one extra direct read. @@ -170,8 +173,8 @@ test("storage fence count per command: every proof kept, exact duplicates gone, expect(await counted(root, ["up", "--detach", "--json"], selected)).toEqual({ code: 0, cli: FILE_ENGINE_FENCES + 1, - direct: 6 * PROOF_FENCES, - observations: 6 * PROOF_OBSERVATIONS, + direct: 4 * PROOF_FENCES, + observations: 4 * PROOF_OBSERVATIONS, }); // Admitted context selection (DOCKER_HOST unset; the client config's current // context names the socket): the same counts as an explicit Unix host. diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts index 2cb45c0bc..8c1dd21f3 100644 --- a/tests/native-compose-storage-witness-xattr-owner.test.ts +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -663,7 +663,7 @@ test("completion publication refusal retains its first boundary after complete h test.each([ "up", "restart", -] as const)("warm no-op %s keeps opening, two closing and all later witness proofs", async (operation) => { +] as const)("warm no-op %s reuses the effect-entry proof for enrollment and keeps every other proof", async (operation) => { const { store, transport } = await rememberedActive(); const command = commandPorts(transport); let finalizers = 0; @@ -697,7 +697,7 @@ test.each([ finalizers++; expect( transport.calls.filter((call) => call === "verify") - ).toHaveLength(6); + ).toHaveLength(5); }, effect: async () => { expect(owned).toBe(2); @@ -710,8 +710,17 @@ test.each([ expect((await store.loadCurrent()).pending?.generationId).toBe( generation.generationId ); + // Warm enrollment (no new volume) reuses the effect-entry proof of + // this generation: only read-only checks ran since it. await storage.enroll(generation, document); + expect( + transport.calls.filter((call) => call === "verify") + ).toHaveLength(3); + // An ordinary (non-adjacent) verification always proves in full. await storage.verify(generation); + expect( + transport.calls.filter((call) => call === "verify") + ).toHaveLength(4); transport.calls.push("workload"); return { value: 0, outcome: "complete" }; }, @@ -719,12 +728,140 @@ test.each([ expect(result).toEqual({ value: 0, outcome: "complete" }); }); expect(finalizers).toBe(1); - expect(transport.calls.filter((call) => call === "verify")).toHaveLength(7); + expect(transport.calls.filter((call) => call === "verify")).toHaveLength(6); expect(transport.calls).not.toContain("seed"); expect(transport.calls).not.toContain("provision"); expect((await store.loadCurrent()).pending).toBeNull(); }, 30_000); +test("warm enrollment proves in full when the material binding moved since the effect-entry proof", async () => { + const { store, transport } = await rememberedActive(); + const command = commandPorts(transport); + const original = generationOwner.assertNativeComposeMaterialAuthority; + let moved = false; + // Stand-in for a receipt publication (for example a storage birth) between + // the effect-entry proof and the enrollment tail: the next opening binding + // carries a different receipt hash than the one the lease recorded. + const authority = spyOn( + generationOwner, + "assertNativeComposeMaterialAuthority" + ).mockImplementation(async (input) => { + const binding = await original(input); + if (moved) { + moved = false; + return { + ...binding, + receipt: { ...binding.receipt, hash: "0".repeat(64) }, + }; + } + return binding; + }); + try { + await store.withMutation(async (mutation) => { + const storage = await prepareNativeComposeCommandStorage({ + store, + mutation, + operation: "up", + selected: [selection], + signal: transport.controller.signal, + ports: command.ports, + }); + if (!storage) { + throw new Error("Expected retained storage"); + } + const generation = await publish(mutation); + transport.state.generation = generation; + const document = await store.readGenerationDocument(generation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => [volume], + storageWitnesses: storage.effectWitnesses, + effect: async () => { + expect(helperOperations(transport)).toHaveLength(9); + moved = true; + await storage.enroll(generation, document); + expect(moved).toBe(false); + expect(helperOperations(transport)).toHaveLength(12); + transport.calls.push("workload"); + return { value: 0, outcome: "complete" }; + }, + }); + }); + } finally { + authority.mockRestore(); + } + expect(transport.calls).toContain("workload"); + expect(transport.calls).not.toContain("seed"); +}, 30_000); + +test("an adjacent verification never reuses a proof across a receipt publication or a later lease", async () => { + const { store, transport, generation, reference } = await rememberedActive(); + const command = commandPorts(transport); + const proofs = () => + transport.calls.filter((call) => call === "verify").length; + await store.withMutation(async (mutation) => { + const storage = await prepareNativeComposeCommandStorage({ + store, + mutation, + operation: "up", + selected: [selection], + signal: transport.controller.signal, + ports: command.ports, + }); + if (!storage) { + throw new Error("Expected retained storage"); + } + expect(proofs()).toBe(1); + // Positive control: only reads since the opening proof. + await storage.verify(generation, { adjacentProof: true }); + expect(proofs()).toBe(1); + // The effect publishes pending and then the completed receipt. Its last + // recorded proof precedes that completion publication. + const result = await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => [volume], + storageWitnesses: storage.effectWitnesses, + effect: async () => ({ value: 0, outcome: "complete" }), + }); + expect(result.outcome).toBe("complete"); + const published = proofs(); + await storage.verify(generation, { adjacentProof: true }); + expect(proofs()).toBe(published + 1); + }); + // A later command holds a new lease (as after an engine restart): nothing + // recorded in the previous one is visible, so an adjacent claim still proves. + await store.withMutation(async (mutation) => { + const before = proofs(); + await verifyNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + reference, + carrier: transport.carrier, + adjacentProof: true, + }); + expect(proofs()).toBe(before + 1); + // A different engine never matches the recorded proof or the binding. + await expect( + verifyNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId: "e".repeat(64), + reference, + carrier: transport.carrier, + adjacentProof: true, + }) + ).rejects.toThrow(witnessRefusal); + expect(proofs()).toBe(before + 1); + }); +}, 30_000); + test.each( (["prepublication", "effect-entry"] as const).flatMap((phase) => (["ownership", "source-await"] as const).map((boundary) => ({