From 5e37a076e93f911bdd77ebdf46fe9427ff8d38fc Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 04:50:11 +0900 Subject: [PATCH 1/2] feat(sdk): validate task owner cleanup wire evidence Reconstruct strict internal owner dispositions from their compact broker DTO and reject missing or contradictory retirement flags without granting live filesystem authority. Keep public wire acceptance disabled until its complete owning effect consumer persists native outcomes safely. Lore-id: 183bc2d0 Constraint: pure DTO decoding never proves physical retirement or fresh scrub Confidence: high Scope-risk: narrow Reversibility: easy Tested: 6 pure codec boundary tests 41 assertions and targeted Biome Not-tested: exact-prefix package check pending independent qualification Not-tested: public SDK owning cleanup effect integration --- .../broker/task-artifact-owner-validation.ts | 259 ++++++++++ ...sdk-task-artifact-owner-validation.test.ts | 483 ++++++++++++++++++ 2 files changed, 742 insertions(+) create mode 100644 packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts create mode 100644 packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts diff --git a/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts b/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts new file mode 100644 index 00000000000..ab225193860 --- /dev/null +++ b/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts @@ -0,0 +1,259 @@ +import type { NativeExactUnlinkResult } from "@gajae-code/natives"; +import { + assertSessionRoot, + parseTaskArtifactOwnerDeletionEvidence, + parseTaskArtifactOwnerRetirementContinuation, + parseTaskArtifactOwnerRetirementOutcome, + type TaskArtifactOwnerDeletionEvidence, + type TaskArtifactOwnerRetirementContinuation, + type TaskArtifactOwnerRetirementOutcome, + type TaskArtifactOwnerStorageContext, +} from "../../session/task-artifact-owner-codec"; + +export type BrokerCleanupPhase = "artifacts" | "transcript" | "metadata" | "lifecycle"; + +/** Exact owner-disposition wire shape. Evidence and continuation are supplied separately by cleanup replay. */ +export type BrokerTaskArtifactOwnerRetirementDisposition = + | { readonly kind: "completed"; readonly nativeOutcome?: NativeExactUnlinkResult } + | { + readonly kind: "payload_retired"; + readonly namespace: "retained"; + readonly nativeOutcome: NativeExactUnlinkResult; + } + | { readonly kind: "cleanup_pending"; readonly nativeOutcome?: NativeExactUnlinkResult } + | { readonly kind: "uncertain"; readonly reason: string; readonly nativeOutcome?: NativeExactUnlinkResult }; + +/** Owner-only projection of durable cleanup state accepted by the pure validator. */ +export interface BrokerTaskArtifactOwnerCleanupFields { + readonly taskArtifactOwnerDeletionEvidence?: unknown; + readonly taskArtifactOwnerRetirementContinuation?: unknown; + readonly taskArtifactOwnerRetirementOutcome?: unknown; + readonly taskArtifactOwnerPayloadRetired?: unknown; + readonly taskArtifactOwnerNamespaceRetained?: unknown; + readonly taskArtifactOwnerRetired?: unknown; + readonly taskArtifactOwnerTranscriptDeleted?: unknown; + readonly taskArtifactOwnerCleanupError?: unknown; +} + +/** Fully decoded owner-only cleanup state for an authorized session and phase. */ +export interface BrokerTaskArtifactOwnerCleanupValidation { + readonly deletionEvidence?: TaskArtifactOwnerDeletionEvidence; + readonly retirementContinuation?: TaskArtifactOwnerRetirementContinuation; + readonly retirementOutcome?: TaskArtifactOwnerRetirementOutcome; + readonly payloadRetired?: true; + readonly namespaceRetained?: true; + readonly retired?: true; + readonly transcriptDeleted?: true; + readonly cleanupDiagnostic?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +} + +function invalidDisposition(): never { + throw new Error("task_artifact_owner_retirement_disposition_invalid"); +} + +/** + * Decode the owner-only wire disposition using its exact trusted evidence and continuation. + * A decoded `completed` value is historical DTO data, not proof of physical retirement or writer closure. + */ +export function parseBrokerTaskArtifactOwnerRetirementDisposition( + value: unknown, + context: TaskArtifactOwnerStorageContext, + evidence: TaskArtifactOwnerDeletionEvidence, + continuation: TaskArtifactOwnerRetirementContinuation | undefined, +): TaskArtifactOwnerRetirementOutcome { + assertSessionRoot(context); + if (!isRecord(value)) return invalidDisposition(); + const kind = value.kind; + let expectedKeys: readonly string[]; + switch (kind) { + case "completed": + case "cleanup_pending": + expectedKeys = Object.hasOwn(value, "nativeOutcome") ? ["kind", "nativeOutcome"] : ["kind"]; + break; + case "payload_retired": + expectedKeys = ["kind", "namespace", "nativeOutcome"]; + break; + case "uncertain": + expectedKeys = Object.hasOwn(value, "nativeOutcome") + ? ["kind", "reason", "nativeOutcome"] + : ["kind", "reason"]; + break; + default: + return invalidDisposition(); + } + if (!hasExactKeys(value, expectedKeys)) return invalidDisposition(); + const reconstructed = { + ...value, + evidence, + ...(continuation !== undefined ? { continuation } : {}), + }; + return parseTaskArtifactOwnerRetirementOutcome(context, evidence, reconstructed); +} + +function serializeNativeOutcome(outcome: NativeExactUnlinkResult): NativeExactUnlinkResult { + return Object.freeze({ + ok: outcome.ok, + ...(outcome.code !== undefined ? { code: outcome.code } : {}), + ...(outcome.detachedPath !== undefined ? { detachedPath: outcome.detachedPath } : {}), + ...(outcome.retainedSuccessorPath !== undefined ? { retainedSuccessorPath: outcome.retainedSuccessorPath } : {}), + ...(outcome.retainedPlaceholderPath !== undefined + ? { retainedPlaceholderPath: outcome.retainedPlaceholderPath } + : {}), + ...(outcome.retainedUnknownPath !== undefined ? { retainedUnknownPath: outcome.retainedUnknownPath } : {}), + ...(outcome.payloadDurable !== undefined ? { payloadDurable: outcome.payloadDurable } : {}), + ...(outcome.windowsErrorCode !== undefined ? { windowsErrorCode: outcome.windowsErrorCode } : {}), + }) as NativeExactUnlinkResult; +} + +/** Project an already validated internal outcome to the exact broker wire disposition. */ +export function serializeBrokerTaskArtifactOwnerRetirementDisposition( + outcome: TaskArtifactOwnerRetirementOutcome, +): BrokerTaskArtifactOwnerRetirementDisposition { + switch (outcome.kind) { + case "completed": + return Object.freeze({ + kind: outcome.kind, + ...(outcome.nativeOutcome ? { nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome) } : {}), + }); + case "payload_retired": + return Object.freeze({ + kind: outcome.kind, + namespace: outcome.namespace, + nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome), + }); + case "cleanup_pending": + return Object.freeze({ + kind: outcome.kind, + ...(outcome.nativeOutcome ? { nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome) } : {}), + }); + case "uncertain": + return Object.freeze({ + kind: outcome.kind, + reason: outcome.reason, + ...(outcome.nativeOutcome ? { nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome) } : {}), + }); + default: { + const exhaustive: never = outcome; + throw new Error(`task_artifact_owner_retirement_outcome_unknown:${String(exhaustive)}`); + } + } +} + +const OWNER_CLEANUP_KEYS = new Set([ + "taskArtifactOwnerDeletionEvidence", + "taskArtifactOwnerRetirementContinuation", + "taskArtifactOwnerRetirementOutcome", + "taskArtifactOwnerPayloadRetired", + "taskArtifactOwnerNamespaceRetained", + "taskArtifactOwnerRetired", + "taskArtifactOwnerTranscriptDeleted", + "taskArtifactOwnerCleanupError", +]); + +/** + * Strictly decode the owner-field projection of cleanup state against trusted session, scope context, phase, + * and artifact-removal state. Empty/legacy owner fields decode to an empty result. A decoded completion + * remains historical data: any later effect must separately verify current physical absence, fresh scrub + * proof, native success and current owner-parent/side-path authority before it mutates storage or transcripts. + */ +export function decodeBrokerTaskArtifactOwnerCleanupFields( + value: unknown, + context: TaskArtifactOwnerStorageContext, + expectedSessionId: string, + phase: BrokerCleanupPhase, + artifactsRemoved: boolean | undefined, +): BrokerTaskArtifactOwnerCleanupValidation { + assertSessionRoot(context); + if (!isRecord(value) || Object.keys(value).some(key => !OWNER_CLEANUP_KEYS.has(key))) + throw new Error("task_artifact_owner_cleanup_fields_invalid"); + if (expectedSessionId.length === 0 || (artifactsRemoved !== undefined && typeof artifactsRemoved !== "boolean")) + throw new Error("task_artifact_owner_cleanup_fields_invalid"); + + const has = (key: string) => Object.hasOwn(value, key); + const trueFlag = (key: string): true | undefined => { + if (!has(key)) return undefined; + if (value[key] !== true) throw new Error("task_artifact_owner_cleanup_flags_invalid"); + return true; + }; + let deletionEvidence: TaskArtifactOwnerDeletionEvidence | undefined; + if (has("taskArtifactOwnerDeletionEvidence")) { + deletionEvidence = parseTaskArtifactOwnerDeletionEvidence(value.taskArtifactOwnerDeletionEvidence); + if (deletionEvidence.sessionId !== expectedSessionId) + throw new Error("task_artifact_owner_cleanup_session_mismatch"); + } + let retirementContinuation: TaskArtifactOwnerRetirementContinuation | undefined; + if (has("taskArtifactOwnerRetirementContinuation")) { + if (!deletionEvidence) throw new Error("task_artifact_owner_cleanup_evidence_missing"); + retirementContinuation = parseTaskArtifactOwnerRetirementContinuation( + context, + deletionEvidence, + value.taskArtifactOwnerRetirementContinuation, + ); + } + let retirementOutcome: TaskArtifactOwnerRetirementOutcome | undefined; + if (has("taskArtifactOwnerRetirementOutcome")) { + if (!deletionEvidence) throw new Error("task_artifact_owner_cleanup_evidence_missing"); + retirementOutcome = parseBrokerTaskArtifactOwnerRetirementDisposition( + value.taskArtifactOwnerRetirementOutcome, + context, + deletionEvidence, + retirementContinuation, + ); + } + + const payloadRetired = trueFlag("taskArtifactOwnerPayloadRetired"); + const namespaceRetained = trueFlag("taskArtifactOwnerNamespaceRetained"); + const retired = trueFlag("taskArtifactOwnerRetired"); + const transcriptDeleted = trueFlag("taskArtifactOwnerTranscriptDeleted"); + let cleanupDiagnostic: string | undefined; + if (has("taskArtifactOwnerCleanupError")) { + const diagnostic = value.taskArtifactOwnerCleanupError; + if (typeof diagnostic !== "string" || diagnostic.length === 0 || diagnostic.length > 4096) + throw new Error("task_artifact_owner_cleanup_diagnostic_invalid"); + cleanupDiagnostic = diagnostic; + } + + if ( + (retired !== undefined && (payloadRetired !== undefined || namespaceRetained !== undefined)) || + (retired !== undefined && retirementOutcome?.kind !== "completed") || + (payloadRetired !== undefined) !== (namespaceRetained !== undefined) || + ((retired !== undefined || + payloadRetired !== undefined || + namespaceRetained !== undefined || + transcriptDeleted !== undefined) && + !deletionEvidence) || + (transcriptDeleted !== undefined && + (phase !== "artifacts" || artifactsRemoved !== true || retirementOutcome === undefined)) + ) + throw new Error("task_artifact_owner_cleanup_state_invalid"); + + if (retirementOutcome) { + if ( + (retired !== undefined && retirementOutcome.kind !== "completed") || + (retirementOutcome.kind === "completed" && retired === undefined) || + (payloadRetired !== undefined && + (retirementOutcome.kind !== "payload_retired" || retirementContinuation === undefined)) || + (retirementOutcome.kind === "payload_retired" && payloadRetired === undefined) + ) + throw new Error("task_artifact_owner_cleanup_disposition_mismatch"); + } + + return Object.freeze({ + deletionEvidence, + retirementContinuation, + retirementOutcome, + payloadRetired, + namespaceRetained, + retired, + transcriptDeleted, + cleanupDiagnostic, + }); +} diff --git a/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts b/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts new file mode 100644 index 00000000000..3bcc21f8dac --- /dev/null +++ b/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it } from "bun:test"; +import { + type BrokerTaskArtifactOwnerCleanupFields, + decodeBrokerTaskArtifactOwnerCleanupFields, + parseBrokerTaskArtifactOwnerRetirementDisposition, + serializeBrokerTaskArtifactOwnerRetirementDisposition, +} from "../src/sdk/broker/task-artifact-owner-validation"; +import type { TaskArtifactOwnerStorageContext } from "../src/session/task-artifact-owner-codec"; +import { + EMPTY_PAYLOAD_SHA256, + OWNER_DELETION_SCHEMA_VERSION, + OWNER_RETIREMENT_SCHEMA_VERSION, + OWNER_SCHEMA_VERSION, + ownerAbsolutePath, + ownerIdForSession, + parseTaskArtifactOwnerDeletionEvidence, + parseTaskArtifactOwnerRetirementContinuation, +} from "../src/session/task-artifact-owner-codec"; + +const sessionId = "broker-owner-validation-session"; +const sessionsRoot = "/tmp/broker-owner-validation"; +const locator = { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: ownerIdForSession(sessionId), + directoryDev: "100", + directoryIno: "101", +}; +const parentIdentity = { dev: "200", ino: "201" }; +const context: TaskArtifactOwnerStorageContext = { + rootAuthority: { canonicalPath: sessionsRoot, dev: 3n, ino: 4n }, + sessionsRoot, + securityPolicy: "default", + profileAgentDir: "/tmp/broker-owner-validation-profile", +}; +const ownerPath = ownerAbsolutePath(context, locator.ownerId); +const evidence = { + schemaVersion: OWNER_DELETION_SCHEMA_VERSION, + sessionId, + locator, + parentIdentity, + treeSnapshot: { + rootDev: locator.directoryDev, + rootIno: locator.directoryIno, + entries: [ + { + relativePath: "", + kind: "directory", + dev: locator.directoryDev, + ino: locator.directoryIno, + nlink: "1", + size: "0", + mtimeNs: "700", + ctimeNs: "701", + }, + { + relativePath: "data.bin", + kind: "file", + dev: "102", + ino: "103", + nlink: "1", + size: "6", + mtimeNs: "702", + ctimeNs: "703", + sha256: "c".repeat(64), + }, + ], + }, +}; +const parsedEvidence = parseTaskArtifactOwnerDeletionEvidence(evidence); +const sidePath = `${sessionsRoot}/.task-artifact-owners/.gjc-receipt-side`; + +function continuation( + extra: Record = {}, + retainedTree: typeof evidence.treeSnapshot = evidence.treeSnapshot, +): Record { + return { + schemaVersion: OWNER_RETIREMENT_SCHEMA_VERSION, + parentIdentity, + retainedRootPath: ownerPath, + retainedTreeSnapshot: retainedTree, + ...extra, + }; +} + +function cleanupFields(overrides: Record = {}): BrokerTaskArtifactOwnerCleanupFields { + return { + taskArtifactOwnerDeletionEvidence: evidence, + ...overrides, + }; +} + +describe("broker task artifact owner validation", () => { + it("round-trips strict owner dispositions and preserves native diagnostic roles", () => { + const diagnostics = { + ok: false, + code: "cleanup_pending", + detachedPath: `${ownerPath}.removing`, + retainedSuccessorPath: sidePath, + windowsErrorCode: "0x00000020", + }; + const retainedTree = evidence.treeSnapshot; + const exactContinuation = continuation( + { + detachedPaths: [`${ownerPath}.removing`], + nativeCodes: ["cleanup_pending"], + retainedSuccessorPaths: [sidePath], + windowsErrorCodes: ["0x00000020"], + }, + retainedTree, + ); + const disposition = { kind: "cleanup_pending", nativeOutcome: diagnostics }; + const outcome = parseBrokerTaskArtifactOwnerRetirementDisposition( + disposition, + context, + parsedEvidence, + parseContinuation(exactContinuation), + ); + const encoded = serializeBrokerTaskArtifactOwnerRetirementDisposition(outcome); + expect(encoded).toEqual(disposition); + expect(encoded).not.toHaveProperty("evidence"); + expect(encoded).not.toHaveProperty("continuation"); + + expect(() => + parseBrokerTaskArtifactOwnerRetirementDisposition( + { ...disposition, extra: true }, + context, + parsedEvidence, + parseContinuation(exactContinuation), + ), + ).toThrow(); + expect(() => + parseBrokerTaskArtifactOwnerRetirementDisposition( + { ...disposition, evidence }, + context, + parsedEvidence, + parseContinuation(exactContinuation), + ), + ).toThrow(); + expect(() => + parseBrokerTaskArtifactOwnerRetirementDisposition( + { ...disposition, nativeOutcome: { ...diagnostics, retainedPlaceholderPath: sidePath } }, + context, + parsedEvidence, + parseContinuation(exactContinuation), + ), + ).toThrow(); + }); + + it("decodes outcome variants with explicit evidence and continuation, without promoting completion to proof", () => { + const uncertain = { + kind: "uncertain", + reason: "retained namespace identity could not be refreshed", + }; + const decodedUncertain = parseBrokerTaskArtifactOwnerRetirementDisposition( + uncertain, + context, + parsedEvidence, + parseContinuation(continuation()), + ); + expect(serializeBrokerTaskArtifactOwnerRetirementDisposition(decodedUncertain)).toEqual(uncertain); + + const payloadRetiredTree = { + ...evidence.treeSnapshot, + entries: [ + evidence.treeSnapshot.entries[0], + { + ...evidence.treeSnapshot.entries[1], + size: "0", + sha256: EMPTY_PAYLOAD_SHA256, + }, + ], + }; + const payloadOutcome = { + ok: false, + code: "cleanup_pending", + payloadDurable: true, + }; + const payloadContinuation = continuation( + { nativeCodes: ["cleanup_pending"], payloadDurable: true, retainedRootPath: `${ownerPath}.removing` }, + payloadRetiredTree, + ); + const payloadDisposition = { + kind: "payload_retired", + namespace: "retained", + nativeOutcome: payloadOutcome, + }; + const decodedPayload = parseBrokerTaskArtifactOwnerRetirementDisposition( + payloadDisposition, + context, + parsedEvidence, + parseContinuation(payloadContinuation), + ); + expect(serializeBrokerTaskArtifactOwnerRetirementDisposition(decodedPayload)).toEqual( + payloadDisposition, + ); + + // A completed wire DTO without native-success fields is malformed; parsing a structural DTO below + // exercises only serialization shape and is never used as live retirement proof. + expect(() => + parseBrokerTaskArtifactOwnerRetirementDisposition({ kind: "completed" }, context, parsedEvidence, undefined), + ).toThrow(); + + // Structural codec acceptance only: this fixture does not establish that native retirement occurred. + const completedDisposition = { kind: "completed", nativeOutcome: { ok: true } }; + const decodedCompleted = parseBrokerTaskArtifactOwnerRetirementDisposition( + completedDisposition, + context, + parsedEvidence, + undefined, + ); + expect(decodedCompleted.kind).toBe("completed"); + expect(serializeBrokerTaskArtifactOwnerRetirementDisposition(decodedCompleted)).toEqual( + completedDisposition, + ); + }); + + it("accepts empty legacy owner state and returns named, frozen decoded fields", () => { + const empty = decodeBrokerTaskArtifactOwnerCleanupFields({}, context, sessionId, "artifacts", false); + expect(empty.deletionEvidence).toBeUndefined(); + expect(empty.retirementOutcome).toBeUndefined(); + expect(empty.cleanupDiagnostic).toBeUndefined(); + expect(Object.isFrozen(empty)).toBe(true); + + const decoded = decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerCleanupError: "task_artifact_owner_native_removal_uncertain" }), + context, + sessionId, + "artifacts", + false, + ); + expect(decoded.deletionEvidence).toEqual(parsedEvidence); + expect(decoded.cleanupDiagnostic).toBe("task_artifact_owner_native_removal_uncertain"); + }); + + it("rejects foreign sessions, foreign-root continuations, spliced parents, and expanding trees", () => { + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields(cleanupFields(), context, "other-session", "artifacts", false), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + parentIdentity: { dev: "-200", ino: "201" }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + locator: { ...locator, directoryIno: "018" }, + }), + ).toThrow(); + + const foreignContext: TaskArtifactOwnerStorageContext = { + ...context, + sessionsRoot: `${sessionsRoot}-other-profile`, + }; + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerRetirementContinuation: continuation() }), + foreignContext, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ + taskArtifactOwnerRetirementContinuation: continuation({ parentIdentity: { dev: "200", ino: "999" } }), + }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + const expanded = { + ...evidence.treeSnapshot, + entries: [ + ...evidence.treeSnapshot.entries, + { ...evidence.treeSnapshot.entries[1], relativePath: "new-authority.bin", ino: "104" }, + ], + }; + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerRetirementContinuation: continuation({}, expanded) }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + }); + + it("rejects contradictory owner flags, missing dispositions, and transcript deletion outside its authorized phase", () => { + const pendingDisposition = { + kind: "cleanup_pending", + nativeOutcome: { ok: false, code: "cleanup_pending" }, + }; + const pendingContinuation = continuation({ nativeCodes: ["cleanup_pending"] }); + const validPending = { + taskArtifactOwnerRetirementContinuation: pendingContinuation, + taskArtifactOwnerRetirementOutcome: pendingDisposition, + }; + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerRetired: true }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + const structurallyCompleted = decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ + taskArtifactOwnerRetirementOutcome: { + kind: "completed", + nativeOutcome: { ok: true }, + }, + taskArtifactOwnerRetired: true, + }), + context, + sessionId, + "artifacts", + false, + ); + // The completed wire DTO is parser data, not live physical-retirement evidence. + expect(structurallyCompleted.retirementOutcome?.kind).toBe("completed"); + expect(structurallyCompleted.retired).toBe(true); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerPayloadRetired: true }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ + ...validPending, + taskArtifactOwnerPayloadRetired: true, + taskArtifactOwnerNamespaceRetained: true, + }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerTranscriptDeleted: true, ...validPending }), + context, + sessionId, + "transcript", + true, + ), + ).toThrow(); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerTranscriptDeleted: true, ...validPending }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + const transcriptDone = decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerTranscriptDeleted: true, ...validPending }), + context, + sessionId, + "artifacts", + true, + ); + expect(transcriptDone.transcriptDeleted).toBe(true); + expect(transcriptDone.retirementOutcome?.kind).toBe("cleanup_pending"); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerTranscriptDeleted: true }), + context, + sessionId, + "artifacts", + true, + ), + ).toThrow(); + }); + + it("requires disposition flags to agree in both directions and rejects unknown owner keys", () => { + const completedWithoutAuthority = { + kind: "completed", + nativeOutcome: { ok: false, code: "cleanup_pending" }, + }; + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerRetirementOutcome: completedWithoutAuthority }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ + taskArtifactOwnerRetirementContinuation: continuation({ nativeCodes: ["cleanup_pending"] }), + taskArtifactOwnerRetirementOutcome: { + kind: "cleanup_pending", + nativeOutcome: { ok: false, code: "cleanup_pending" }, + }, + taskArtifactOwnerNamespaceRetained: true, + }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + const scrubbedTree = { + ...evidence.treeSnapshot, + entries: [ + evidence.treeSnapshot.entries[0], + { ...evidence.treeSnapshot.entries[1], size: "0", sha256: EMPTY_PAYLOAD_SHA256 }, + ], + }; + const retainedContinuation = continuation( + { retainedRootPath: `${ownerPath}.removing`, nativeCodes: ["cleanup_pending"], payloadDurable: true }, + scrubbedTree, + ); + const payloadState = { + taskArtifactOwnerRetirementContinuation: retainedContinuation, + taskArtifactOwnerRetirementOutcome: { + kind: "payload_retired", + namespace: "retained", + nativeOutcome: { ok: false, code: "cleanup_pending", payloadDurable: true }, + }, + taskArtifactOwnerPayloadRetired: true, + taskArtifactOwnerNamespaceRetained: true, + }; + const validPayloadState = decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields(payloadState), + context, + sessionId, + "artifacts", + false, + ); + expect(validPayloadState.payloadRetired).toBe(true); + expect(validPayloadState.namespaceRetained).toBe(true); + expect(validPayloadState.retirementOutcome?.kind).toBe("payload_retired"); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ ...payloadState, taskArtifactOwnerNamespaceRetained: undefined }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + { ...cleanupFields(), unrecognizedOwnerClaim: true }, + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields({ taskArtifactOwnerCleanupError: "" }), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow(); + }); +}); + +function parseContinuation(value: unknown) { + return parseTaskArtifactOwnerRetirementContinuation(context, parsedEvidence, value); +} From f369cdd59c0bc5910ffc3e794c4dd7122e5785c2 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 06:11:39 +0900 Subject: [PATCH 2/2] fix(sdk): require owner disposition for scrub claims Payload flags without a native-shaped disposition were accepted even though retired flags required one. Reject every cleanup claim without its evidence and outcome, and omit fields in regression fixtures so the tests reach state agreement checks rather than only the undefined-value parser guard. Lore-id: 710b64fc Constraint: decoded claims are data and never establish physical completion Confidence: high Scope-risk: narrow Reversibility: easy Tested: pure validator tests with omitted payload namespace and outcome keys Not-tested: fresh exact-prefix package and code-event CI pending --- .../broker/task-artifact-owner-validation.ts | 2 +- ...sdk-task-artifact-owner-validation.test.ts | 26 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts b/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts index ab225193860..d6a978ab0d9 100644 --- a/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts +++ b/packages/coding-agent/src/sdk/broker/task-artifact-owner-validation.ts @@ -229,7 +229,7 @@ export function decodeBrokerTaskArtifactOwnerCleanupFields( payloadRetired !== undefined || namespaceRetained !== undefined || transcriptDeleted !== undefined) && - !deletionEvidence) || + (!deletionEvidence || !retirementOutcome)) || (transcriptDeleted !== undefined && (phase !== "artifacts" || artifactsRemoved !== true || retirementOutcome === undefined)) ) diff --git a/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts b/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts index 3bcc21f8dac..c4191aadb44 100644 --- a/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts +++ b/packages/coding-agent/test/sdk-task-artifact-owner-validation.test.ts @@ -448,15 +448,23 @@ describe("broker task artifact owner validation", () => { expect(validPayloadState.payloadRetired).toBe(true); expect(validPayloadState.namespaceRetained).toBe(true); expect(validPayloadState.retirementOutcome?.kind).toBe("payload_retired"); - expect(() => - decodeBrokerTaskArtifactOwnerCleanupFields( - cleanupFields({ ...payloadState, taskArtifactOwnerNamespaceRetained: undefined }), - context, - sessionId, - "artifacts", - false, - ), - ).toThrow(); + for (const missing of [ + "taskArtifactOwnerNamespaceRetained", + "taskArtifactOwnerPayloadRetired", + "taskArtifactOwnerRetirementOutcome", + ]) { + const incomplete: Record = { ...payloadState }; + delete incomplete[missing]; + expect(() => + decodeBrokerTaskArtifactOwnerCleanupFields( + cleanupFields(incomplete), + context, + sessionId, + "artifacts", + false, + ), + ).toThrow("task_artifact_owner_cleanup_state_invalid"); + } expect(() => decodeBrokerTaskArtifactOwnerCleanupFields( { ...cleanupFields(), unrecognizedOwnerClaim: true },