diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index fe852b0c..b9a17c8c 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -155,6 +155,28 @@ export type NativeComposeProbeFailure = | "decode"; const probeFailures = new WeakMap(); +/** The first refused ownership predicate only; never a claim about its external cause. */ +export type NativeComposeOwnershipRefusal = + | "resource-label" + | "generation" + | "state" + | "volume-birth" + | "bridge-policy" + | "topology" + | "endpoint" + | "cross-scan-drift" + | "unknown"; +const ownershipRefusals = new WeakMap(); + +/** Only captured-reply replay reads this diagnostic; callers cannot mint it from error properties. */ +export function nativeComposeOwnershipRefusal( + error: unknown +): NativeComposeOwnershipRefusal | undefined { + return typeof error === "object" && error !== null + ? ownershipRefusals.get(error) + : undefined; +} + /** Copies, prototypes and caller-created errors cannot acquire an observed probe classification. */ export function nativeComposeProbeFailure( error: unknown @@ -196,12 +218,20 @@ export class NativeComposeOwnershipError extends Error { this.code = code; } } -function refuse(code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP"): never { - throw new NativeComposeOwnershipError(code); +function refuse( + code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP", + reason: NativeComposeOwnershipRefusal = "unknown" +): never { + const error = new NativeComposeOwnershipError(code); + ownershipRefusals.set(error, reason); + throw error; } -function requireValue(value: unknown): asserts value { +function requireValue( + value: unknown, + reason: NativeComposeOwnershipRefusal = "unknown" +): asserts value { if (!value) { - refuse(); + refuse("E_NATIVE_COMPOSE_OWNERSHIP", reason); } } @@ -229,7 +259,7 @@ export function mergeNativeComposeNetworkPolicies(opts: { next.internal === previous.internal ) ) { - return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION"); + return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION", "bridge-policy"); } } } @@ -484,7 +514,8 @@ async function queryNativeComposeOwned( current.storage === volume.storage && (volume.createdAt === undefined || current.createdAt === volume.createdAt) - ) + ), + "volume-birth" ); } } @@ -522,21 +553,25 @@ async function queryNativeComposeOwned( first.name === second.name && first.generationId === second.generationId && first.service === second.service && - first.oneoff === second.oneoff + first.oneoff === second.oneoff, + "cross-scan-drift" ); } requireValue( JSON.stringify(topologySnapshot(observations, restarting)) === - JSON.stringify(topologySnapshot(rechecked, restarting)) + JSON.stringify(topologySnapshot(rechecked, restarting)), + "cross-scan-drift" ); requireValue( JSON.stringify(volumeSnapshot(observations)) === - JSON.stringify(volumeSnapshot(rechecked)) + JSON.stringify(volumeSnapshot(rechecked)), + "cross-scan-drift" ); for (const kind of ["container", "volume", "network"] as const) { requireValue( JSON.stringify(await inventory(kind)) === - JSON.stringify(selected.get(kind)) + JSON.stringify(selected.get(kind)), + "cross-scan-drift" ); } return { @@ -594,19 +629,22 @@ async function collectInspections(input: { remaining.delete(row.id); requireValue( row.name === - (kind === "container" ? `/${resource.name}` : resource.name) + (kind === "container" ? `/${resource.name}` : resource.name), + "resource-label" ); requireValue( - row.project === opts.composeProject && row.project === resource.project + row.project === opts.composeProject && row.project === resource.project, + "resource-label" ); requireValue( row.version === "1" && row.instance === opts.runtimeIdentity && - row.owner === opts.ownerToken + row.owner === opts.ownerToken, + "resource-label" ); if (kind === "container") { containers.push(containerObservation(row, opts)); - requireValue(isRecord(row.networks)); + requireValue(isRecord(row.networks), "endpoint"); observations.endpoints.set(resource.id, row.networks); } else if (kind === "volume") { requireValue( @@ -622,18 +660,25 @@ async function collectInspections(input: { ]) ); requireValue( - typeof row.storage === "string" && SERVICE.test(row.storage) + typeof row.storage === "string" && SERVICE.test(row.storage), + "resource-label" ); const expectedVolume = opts.expectedVolumes?.find( (volume) => volume.name === resource.name ); requireValue( - expectedVolume !== undefined && expectedVolume.storage === row.storage + expectedVolume !== undefined && + expectedVolume.storage === row.storage, + "resource-label" + ); + requireValue( + nativeComposeVolumeCreatedAt(row.createdAt), + "volume-birth" ); - requireValue(nativeComposeVolumeCreatedAt(row.createdAt)); requireValue( expectedVolume.createdAt === undefined || - expectedVolume.createdAt === row.createdAt + expectedVolume.createdAt === row.createdAt, + "volume-birth" ); volumes.push({ name: resource.name, @@ -660,11 +705,15 @@ async function collectInspections(input: { requireValue( expected !== undefined && row.driver === expected.driver && - row.internal === expected.internal + row.internal === expected.internal, + "bridge-policy" ); - requireValue(isRecord(row.containers)); + requireValue(isRecord(row.containers), "topology"); const members = Object.keys(row.containers); - requireValue(members.every((id) => ID.test(id))); + requireValue( + members.every((id) => ID.test(id)), + "topology" + ); observations.members.set(resource.name, members.sort()); networks.push({ id: resource.id, name: resource.name }); } @@ -695,17 +744,20 @@ function containerObservation( ); requireValue( typeof row.generation === "string" && - opts.generationIds.includes(row.generation) + opts.generationIds.includes(row.generation), + "generation" ); requireValue( typeof row.service === "string" && - opts.expectedServices.includes(row.service) + opts.expectedServices.includes(row.service), + "resource-label" ); requireValue( row.oneoff === "True" || row.oneoff === "False" || row.oneoff === "true" || - row.oneoff === "false" + row.oneoff === "false", + "resource-label" ); requireValue( row.state === "created" || @@ -714,18 +766,21 @@ function containerObservation( row.state === "removing" || row.state === "paused" || row.state === "exited" || - row.state === "dead" + row.state === "dead", + "state" ); requireValue( typeof row.exitCode === "number" && Number.isSafeInteger(row.exitCode) && - row.exitCode >= 0 + row.exitCode >= 0, + "state" ); requireValue( row.health === null || row.health === "starting" || row.health === "healthy" || - row.health === "unhealthy" + row.health === "unhealthy", + "state" ); requireValue(typeof row.id === "string" && ID.test(row.id)); requireValue(typeof row.name === "string"); @@ -745,7 +800,8 @@ function endpointAliases(value: unknown): readonly string[] { requireValue( Array.isArray(value) && value.every((alias) => typeof alias === "string" && NAME.test(alias)) && - new Set(value).size === value.length + new Set(value).size === value.length, + "endpoint" ); return [...new Set(value as string[])].sort(); } @@ -767,7 +823,8 @@ function validateEndpointIdentity(opts: { if (unrealizedCreatedEndpoint) { requireValue( id !== undefined && - (endpoint.NetworkID === undefined || endpoint.NetworkID === "") + (endpoint.NetworkID === undefined || endpoint.NetworkID === ""), + "endpoint" ); if ( endpoint.Aliases === undefined || @@ -777,16 +834,20 @@ function validateEndpointIdentity(opts: { return; } } else if (absentOwnedRecovery) { - requireValue(endpoint.NetworkID === undefined || endpoint.NetworkID === ""); + requireValue( + endpoint.NetworkID === undefined || endpoint.NetworkID === "", + "endpoint" + ); if (endpoint.Aliases === undefined || endpoint.Aliases === null) { return; } } else { - requireValue(id !== undefined && endpoint.NetworkID === id); + requireValue(id !== undefined && endpoint.NetworkID === id, "endpoint"); } requireValue( JSON.stringify(endpointAliases(endpoint.Aliases)) === - JSON.stringify(expectedAliases) + JSON.stringify(expectedAliases), + "endpoint" ); } @@ -869,7 +930,8 @@ function requireLiveMember(opts: { networkPolicies(selection).length === 1 && networkPolicies(selection)[0]?.internal === false && networkId !== undefined && - endpoint.NetworkID === networkId + endpoint.NetworkID === networkId, + "topology" ); return true; } @@ -894,7 +956,8 @@ function validateTopology( (id) => containers.has(id) && Object.hasOwn(observations.endpoints.get(id) ?? {}, name) - ) + ), + "topology" ); } for (const container of observations.containers) { @@ -903,18 +966,19 @@ function validateTopology( workload.generationId === container.generationId && workload.service === container.service ); - requireValue(policy !== undefined); + requireValue(policy !== undefined, "topology"); const endpoints = observations.endpoints.get(container.id); requireValue( endpoints !== undefined && hasKeys( endpoints, policy.networks.map((network) => network.name) - ) + ), + "topology" ); for (const attachment of policy.networks) { const endpoint = endpoints[attachment.name]; - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); const id = attachment.externalId ?? owned.get(attachment.name); const absentOwnedRecovery = opts.recovery === "down" && @@ -930,7 +994,8 @@ function validateTopology( }); if (unrealizedCreatedEndpoint) { requireValue( - !observations.members.get(attachment.name)?.includes(container.id) + !observations.members.get(attachment.name)?.includes(container.id), + "topology" ); } const expected = container.oneoff @@ -984,7 +1049,7 @@ function topologySnapshot( id, Object.entries(endpoints) .map(([name, endpoint]) => { - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); return [ name, endpoint.NetworkID, diff --git a/tests/e2e/native-process-policy-initial-replay.ts b/tests/e2e/native-process-policy-initial-replay.ts index 431a577b..3699a6c4 100644 --- a/tests/e2e/native-process-policy-initial-replay.ts +++ b/tests/e2e/native-process-policy-initial-replay.ts @@ -3,7 +3,10 @@ import { join } from "node:path"; import { isRecord } from "../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../src/lib/native-compose-generation.ts"; import { readNativeComposeNetworkTopology } from "../../src/lib/native-compose-network-topology.ts"; -import type { NativeComposeOwnershipOptions } from "../../src/lib/native-compose-ownership.ts"; +import type { + NativeComposeOwnershipOptions, + NativeComposeOwnershipRefusal, +} from "../../src/lib/native-compose-ownership.ts"; import { exec } from "../../src/lib/shell.ts"; import { type ProcessPolicyInitialTraceQuery, @@ -22,12 +25,28 @@ const CODES = [ type Replay = { readonly outcome: "owned" | "unready" | "refused"; readonly code: (typeof CODES)[number] | null; + /** First replayed ownership predicate only; absent for success or unclassified probe errors. */ + readonly reason: NativeComposeOwnershipRefusal | null; readonly consumed: number; readonly protocolMatched: boolean; }; function replayCode(value: unknown): value is Replay["code"] { return value === null || CODES.some((code) => code === value); } +function replayReason(value: unknown): value is Replay["reason"] { + return ( + value === null || + value === "resource-label" || + value === "generation" || + value === "state" || + value === "volume-birth" || + value === "bridge-policy" || + value === "topology" || + value === "endpoint" || + value === "cross-scan-drift" || + value === "unknown" + ); +} /** Run the real ownership policy against original recorded replies, with no engine access. */ export async function replayProcessPolicyInitialOwnership(opts: { @@ -65,13 +84,13 @@ await Bun.write(Bun.stdout,row.stdout);process.exit(row.exitCode); ); await chmod(docker, 0o700); const program = ` -import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; +import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError,nativeComposeOwnershipRefusal} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; const {selection}=await Bun.file(${JSON.stringify(inputs)}).json(); -let outcome="refused",code=null; +let outcome="refused",code=null,reason=null; try {const value=${opts.mode === "startup" ? 'await observeNativeComposeStartupOwned(selection,["retry"])' : "await assertNativeComposeOwned(selection)"};outcome=value===null?"unready":"owned";} -catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;} +catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=nativeComposeOwnershipRefusal(error)??null;} const consumed=(await Bun.file(${JSON.stringify(cursor)}).exists())?Number(await Bun.file(${JSON.stringify(cursor)}).text()):0; -process.stdout.write(JSON.stringify({outcome,code,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); +process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); `; const result = await exec( [process.execPath, "--no-env-file", "-e", program], @@ -93,6 +112,7 @@ process.stdout.write(JSON.stringify({outcome,code,consumed,protocolMatched:!(awa value.outcome === "unready" || value.outcome === "refused") && replayCode(value.code) && + replayReason(value.reason) && Number.isInteger(value.consumed) && typeof value.consumed === "number" && value.consumed >= 0 && @@ -105,6 +125,7 @@ process.stdout.write(JSON.stringify({outcome,code,consumed,protocolMatched:!(awa return { outcome: value.outcome, code: value.code, + reason: value.reason, consumed: value.consumed, protocolMatched: value.protocolMatched, }; @@ -260,3 +281,55 @@ export async function summarizeProcessPolicyInitialTrace(opts: { await store.close(); } } + +/** Persist only the first owner-issued refusal from matching after-Compose + * recorded replies. This cannot identify the original caller mode or timing. */ +export async function persistProcessPolicyFirstAfterComposeRefusal(opts: { + readonly directory: string; + readonly summary: Awaited< + ReturnType + >; +}) { + const row = opts.summary.observations.find( + (observation) => + observation.phase === "after-compose" && + observation.startup.protocolMatched && + observation.strict.protocolMatched && + [observation.startup, observation.strict].some( + (mode) => + mode.outcome === "refused" && + mode.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ) + ); + if (!row) { + return null; + } + const capsule = { + version: 1 as const, + kind: "native-process-policy-after-compose-replay-refusal" as const, + observationIndex: row.index, + replayUsesRecordedReplies: true as const, + replaysWallTiming: false as const, + originalCallerModeKnown: false as const, + startupReason: + row.startup.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.startup.reason + : null, + strictReason: + row.strict.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.strict.reason + : null, + }; + const handle = await open( + join(opts.directory, "first-after-compose-replay-refusal.json"), + "wx", + 0o600 + ); + try { + await handle.writeFile(`${JSON.stringify(capsule)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + return capsule; +} diff --git a/tests/e2e/scenarios/native-config-process-policy.ts b/tests/e2e/scenarios/native-config-process-policy.ts index 88a20ae7..6acc60ab 100644 --- a/tests/e2e/scenarios/native-config-process-policy.ts +++ b/tests/e2e/scenarios/native-config-process-policy.ts @@ -16,7 +16,10 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { summarizeProcessPolicyInitialTrace } from "../native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + summarizeProcessPolicyInitialTrace, +} from "../native-process-policy-initial-replay.ts"; import { prepareProcessPolicyInitialTrace } from "../native-process-policy-initial-trace.ts"; import { isKnownUncertainProcessPolicyStartup, @@ -733,6 +736,13 @@ export const nativeConfigProcessPolicyScenario: Scenario = { ctx.log( `fixed-field original-query replay: ${JSON.stringify(replay)}` ); + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: ctx.tempRoot, + summary: replay, + }); + ctx.log( + `fixed-field first after-compose replay refusal: ${JSON.stringify(capsule)}` + ); } catch { stage( "fixed-field original-query replay unavailable; no cause inferred" diff --git a/tests/native-compose-ownership.test.ts b/tests/native-compose-ownership.test.ts index f758b63a..5bbca5bc 100644 --- a/tests/native-compose-ownership.test.ts +++ b/tests/native-compose-ownership.test.ts @@ -7,6 +7,8 @@ import { mergeNativeComposeNetworkPolicies, NativeComposeOwnershipError, type NativeComposeOwnershipOptions, + type NativeComposeOwnershipRefusal, + nativeComposeOwnershipRefusal, observeNativeComposeStartupOwned, observeSavedNativeComposeOwned, } from "../src/lib/native-compose-ownership.ts"; @@ -172,7 +174,8 @@ async function commands(): Promise { } async function expectRefusal( opts = options, - code = "E_NATIVE_COMPOSE_OWNERSHIP" + code = "E_NATIVE_COMPOSE_OWNERSHIP", + reason?: NativeComposeOwnershipRefusal ) { try { await assertNativeComposeOwned(opts); @@ -180,12 +183,73 @@ async function expectRefusal( } catch (error: unknown) { expect(error).toBeInstanceOf(NativeComposeOwnershipError); expect(error).toMatchObject({ code }); + if (reason !== undefined) { + expect(nativeComposeOwnershipRefusal(error)).toBe(reason); + } expect(String(error)).not.toContain(CANARY); expect(JSON.stringify(error)).not.toContain(CANARY); } expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } +test("ownership refusal diagnostics cannot be forged, copied or obtained through getters", () => { + let issued: unknown; + try { + mergeNativeComposeNetworkPolicies({ + proposed: [], + retained: [ + [{ name: "fixture_default", driver: "bridge", internal: false }], + ], + }); + } catch (error: unknown) { + issued = error; + } + expect(issued).toBeInstanceOf(NativeComposeOwnershipError); + if (!(issued instanceof NativeComposeOwnershipError)) { + throw new Error("Missing owner-issued refusal"); + } + expect(nativeComposeOwnershipRefusal(issued)).toBe("bridge-policy"); + expect(issued).toMatchObject({ code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION" }); + expect(String(issued)).toBe( + "NativeComposeOwnershipError: Native Compose network topology changed. Run hack down for this instance before applying the change; values omitted." + ); + expect(JSON.parse(JSON.stringify(issued))).toEqual({ + code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION", + name: "NativeComposeOwnershipError", + }); + const clone = { ...issued, reason: "bridge-policy", secret: CANARY }; + let getters = 0; + const accessor = Object.defineProperty({}, "reason", { + get() { + getters += 1; + throw new Error(CANARY); + }, + }); + const proxy = new Proxy( + {}, + { + get() { + getters += 1; + throw new Error(CANARY); + }, + } + ); + for (const candidate of [ + new NativeComposeOwnershipError("E_NATIVE_COMPOSE_OWNERSHIP"), + clone, + Object.create(issued), + accessor, + proxy, + null, + undefined, + CANARY, + 1, + ]) { + expect(nativeComposeOwnershipRefusal(candidate)).toBeUndefined(); + } + expect(getters).toBe(0); +}); + test("selected on-failure restart is unready until two stable owned scans", async () => { const fixture = owned(); const container = fixture.container?.[0]; @@ -198,7 +262,7 @@ test("selected on-failure restart is unready until two stable owned scans", asyn await prepare(fixture); expect(await observeNativeComposeStartupOwned(options, ["web"])).toBeNull(); // The same observation must never authorize effect or finalization ownership. - await expectRefusal(options); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "topology"); fixture.mode = "restart-member-transition"; await prepare(fixture); await expectRefusal(options); @@ -453,7 +517,11 @@ test("old or missing owner tokens never adopt same-instance resources", async () } row.owner = owner; await prepare(fixture); - await expectRefusal(); + await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "resource-label" + ); } } }); @@ -465,7 +533,7 @@ test("unsolicited raw inspect fields are rejected without disclosing private val } row.Config = { Env: [CANARY], Image: CANARY }; await prepare(fixture); - await expectRefusal(); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "unknown"); }); test("every project container is checked, including unknown services and stale or absent generation", async () => { for (const fields of [ @@ -494,11 +562,23 @@ test("every project container is checked, including unknown services and stale o state: "running", exitCode: 0, health: null, + networks: { + [`${PROJECT}_default`]: { + NetworkID: NETWORK_ID, + Aliases: ["otherwise-unexpected-name", "web"], + }, + }, ...fields, }, ]; await prepare(fixture); - await expectRefusal(); + const reason = + "generation" in fields + ? "generation" + : "state" in fields || "health" in fields || "exitCode" in fields + ? "state" + : "resource-label"; + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", reason); } }); test("persistent storage must match exact generated name and logical storage ownership", async () => { @@ -533,7 +613,7 @@ test("retained volume policy requires exact presence and birth while cold invent { name: VOLUME, storage: "data", createdAt: CREATED }, ]); await prepare({}); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); // Legacy history can require presence without inventing a prior birth. await expectRefusal({ ...options, @@ -547,7 +627,7 @@ test("retained volume policy requires exact presence and birth while cold invent } volume.createdAt = REBORN; await prepare(replaced); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); }); test.each([ null, @@ -614,7 +694,8 @@ test("vanished resources and changed selected inventory refuse without retry or options, mode === "vanished" ? "E_NATIVE_COMPOSE_PROBE" - : "E_NATIVE_COMPOSE_OWNERSHIP" + : "E_NATIVE_COMPOSE_OWNERSHIP", + mode === "vanished" ? undefined : "cross-scan-drift" ); } }); @@ -833,7 +914,13 @@ test("custom bridge driver, internal flag, aliases, generation and unexpected en fixture.mode = change; } await prepare(fixture); - await expectRefusal(selection); + const reason = + change === "driver" || change === "internal" + ? "bridge-policy" + : change === "alias" || change === "endpoint-drift" + ? "endpoint" + : "topology"; + await expectRefusal(selection, "E_NATIVE_COMPOSE_OWNERSHIP", reason); expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } }); diff --git a/tests/native-process-policy-initial-trace.test.ts b/tests/native-process-policy-initial-trace.test.ts index 2a122fe1..600ff775 100644 --- a/tests/native-process-policy-initial-trace.test.ts +++ b/tests/native-process-policy-initial-trace.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test"; -import { chmod, mkdtemp, readdir, rm, symlink, unlink } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + unlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -7,7 +16,10 @@ import { type NativeComposeOwnershipOptions, } from "../src/lib/native-compose-ownership.ts"; import { exec } from "../src/lib/shell.ts"; -import { replayProcessPolicyInitialOwnership } from "./e2e/native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + replayProcessPolicyInitialOwnership, +} from "./e2e/native-process-policy-initial-replay.ts"; import { type ProcessPolicyInitialTraceQuery, prepareProcessPolicyInitialTrace, @@ -391,6 +403,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(startup).toEqual({ outcome: "unready", code: null, + reason: null, consumed: calls.length, protocolMatched: true, }); @@ -403,11 +416,69 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(strict).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", + reason: "topology", consumed: 6, protocolMatched: true, }); expect(JSON.stringify({ startup, strict })).not.toContain(owner); expect(JSON.stringify({ startup, strict })).not.toContain(id); + const summary: Parameters< + typeof persistProcessPolicyFirstAfterComposeRefusal + >[0]["summary"] = { + status: "captured", + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + queryCount: queries.length, + consumedQueries: queries.length, + complete: true, + observations: [ + { index: 0, phase: "before-compose", startup: strict, strict }, + { index: 1, phase: "after-compose", startup, strict }, + { index: 2, phase: "after-compose", startup: strict, strict }, + ], + }; + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + summary, + }); + expect(capsule).toEqual({ + version: 1, + kind: "native-process-policy-after-compose-replay-refusal", + observationIndex: 1, + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + startupReason: null, + strictReason: "topology", + }); + const capsulePath = join(root, "first-after-compose-replay-refusal.json"); + const capsuleBytes = await readFile(capsulePath, "utf8"); + expect(JSON.parse(capsuleBytes)).toEqual(capsule); + expect((await stat(capsulePath)).mode & 0o777).toBe(0o600); + expect(capsuleBytes).not.toContain(owner); + expect(capsuleBytes).not.toContain(id); + expect(capsuleBytes).not.toContain(CANARY); + await expect( + persistProcessPolicyFirstAfterComposeRefusal({ directory: root, summary }) + ).rejects.toThrow(); + expect(await readFile(capsulePath, "utf8")).toBe(capsuleBytes); + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: join(root, "unmatched"), + summary: { + ...summary, + observations: [ + { + index: 1, + phase: "after-compose", + startup, + strict: { ...strict, protocolMatched: false }, + }, + ], + }, + }) + ).toBeNull(); const missing = await replayProcessPolicyInitialOwnership({ directory: join(root, "missing"), queries: queries.slice(0, 1),