diff --git a/src/node/services/historyMessageEvidence.test.ts b/src/node/services/historyMessageEvidence.test.ts new file mode 100644 index 0000000000..b2273d220c --- /dev/null +++ b/src/node/services/historyMessageEvidence.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createHash } from "node:crypto"; +import { isReadableHistoryMessage } from "./historyScanner"; +import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy"; +import { scanHistoryRows } from "./historyRowScanner"; +import { createHistoryMessageEvidence } from "./historyMessageEvidence"; + +const date = "2026-09-11T12:00:00Z"; +const run = () => ({ + id: "wfr_test", + workspaceId: "workspace", + source: "source", + sourceHash: "hash", + workflow: { name: "test-flow", description: "Description", scope: "project", executable: true }, + args: { input: [null, true, "value", 1] }, + status: "running", + createdAt: date, + updatedAt: date, + events: [{ sequence: 1, type: "status", at: date, status: "running" }], + steps: [], +}); +const message = (parts: unknown = [{ type: "text", text: "hello" }]) => ({ + id: "message", + role: "user", + parts, + metadata: { historySequence: 3, compactionReplacementNonce: "nonce" }, +}); +const tool = (record: unknown = run()) => ({ + type: "dynamic-tool", + toolCallId: "call", + toolName: "workflow_run", + state: "output-available", + input: {}, + output: {}, + workflowRun: { runId: "wfr_test", timestamp: 1, run: record }, +}); +function native(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} +function readable(value: unknown) { + try { + return isReadableHistoryMessage(value); + } catch { + return false; + } +} + +describe("streamed history message evidence", () => { + let directory: string; + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "history-message-evidence-")); + }); + afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); + }); + async function inspect( + rows: Array, + targets = { id: "message", nonce: "nonce" } + ) { + const file = path.join(directory, "chat.jsonl"); + const content = Buffer.concat(rows.flatMap((row) => [Buffer.from(row), Buffer.from("\n")])); + await fs.writeFile(file, content); + const results: Array["finish"]>> = + []; + await scanHistoryRows( + file, + () => { + const evidence = createHistoryMessageEvidence(content.length, targets); + return { + token: evidence.token.bind(evidence), + finish(row) { + results.push(evidence.finish(row)); + }, + }; + }, + { decoding: "replacement" } + ); + return results; + } + async function differential(rows: string[]) { + const results = await inspect(rows); + expect(results).toHaveLength(rows.length); + for (const [index, text] of rows.entries()) { + const parsed = native(text); + const result = results[index]; + expect(result.readable, `row ${index}: ${text}`).toBe(readable(parsed)); + if (readable(parsed) && isReadableHistoryMessage(parsed)) { + expect(result.id?.length).toBe(parsed.id.length); + expect(result.id?.sha256).toBe( + createHash("sha256").update(Buffer.from(parsed.id, "utf16le")).digest("hex") + ); + expect(result.id?.matchesExpected).toBe(parsed.id === "message"); + expect(result.sequence).toEqual(parsed.metadata?.historySequence); + expect(result.matchesNonce).toBe( + !!parsed.metadata && + "compactionReplacementNonce" in parsed.metadata && + parsed.metadata.compactionReplacementNonce === "nonce" + ); + expect(result.systemRole).toBe(String(parsed.role) === "system"); + expect(result.normalizationChanged, text).toBe( + JSON.stringify(normalizeLegacyMuxMetadata(parsed)) !== JSON.stringify(parsed) + ); + } + } + } + + it("matches native basic parts, legacy roles, metadata permissiveness, and malformed rows", async () => { + const values: unknown[] = [ + message(), + message([]), + message([{ type: "reasoning", text: "why", signature: "kept" }]), + message([{ type: "file", url: "data:test", mediaType: "x", filename: "name" }]), + message([tool()]), + message([{ type: "text" }]), + message([null]), + message({}), + ...[ + "user", + "assistant", + "system", + ["user"], + [["system"]], + [null], + ["", "user"], + {}, + null, + 1, + ].map((role) => ({ ...message(), role })), + ...[ + {}, + [], + null, + false, + { createdAt: "bad", compactionPublicationGeneration: {} }, + { cmuxMetadata: {} }, + { compacted: true, idleCompacted: true }, + { compacted: false, idleCompacted: true }, + ].map((metadata) => ({ ...message(), metadata })), + { ...message(), createdAt: { arbitrary: true } }, + ]; + await differential([ + ...values.map((value) => JSON.stringify(value)), + '{"id":"m",', + JSON.stringify(message()) + "oops", + "{}", + "[]", + "null", + ]); + }); + + it("retains JSON.parse last-wins behavior without accepting invalid parts or duplicate event sequences", async () => { + const valid = JSON.stringify(message()); + const workflow = JSON.stringify(message([tool()])); + await differential([ + valid.replace('"parts":', '"parts":[{"type":"bogus"}],"parts":'), + valid.replace('"parts":', '"parts":[{"type":"text","text":1}],"parts":'), + valid.replace('"id":"message"', '"id":null,"id":"message"'), + valid.replace('"role":"user"', '"role":false,"role":"user"'), + valid.replace('"metadata":', '"metadata":null,"metadata":'), + valid.replace('"text":"hello"', '"text":false,"text":"hello"'), + valid.replace('"text":"hello"', '"text":"hello","text":false'), + workflow.replace('"args":', '"args":1e999,"args":'), + workflow.replace('"args":{"input":[null,true,"value",1]}', '"args":{"key":1e999,"key":1}'), + workflow.replace('"args":{"input":[null,true,"value",1]}', '"args":{"key":1,"key":1e999}'), + JSON.stringify(message([tool({ ...run(), events: [run().events[0], run().events[0]] })])), + workflow.replace('"events":', '"events":[{"bad":true}],"events":'), + ]); + }); + + it("validates actual workflow variants, strict fields, and unbounded JSON values", async () => { + const events = [ + { type: "phase", name: "phase", details: [1] }, + { type: "log", message: "log", data: {} }, + { type: "agent-step", stepId: "s", inputHash: "h", status: "reserved" }, + { type: "task", stepId: "s", taskId: "t", status: "running" }, + { type: "timeout", stepId: "s", taskId: "t", phase: "soft" }, + { type: "workflow", stepId: "s", runId: "wfr_child", name: "child", status: "started" }, + { type: "patch", stepId: "s", sourceTaskId: "t", status: "applied" }, + { type: "action", stepId: "s", name: "action", status: "cached", effect: "read" }, + { type: "validation", stepId: "s", success: true }, + { type: "result", result: { reportMarkdown: "result", structuredOutput: { a: [1, null] } } }, + { type: "error", message: "failed" }, + ].map((event, index) => ({ ...event, sequence: index + 1, at: date })); + const rich = { + ...run(), + events, + parentWorkflow: { runId: "wfr_parent", stepId: "s", inputHash: "h", depth: 1 }, + workflow: { + ...run().workflow, + scope: "scratch", + phaseManifest: { provenance: "declared", phases: [{ name: "phase", parallel: true }] }, + }, + steps: [ + { + stepId: "s", + inputHash: "h", + status: "completed", + startedAt: date, + completedAt: date, + timeout: { softDeadlineAt: date, finalizationToken: "token" }, + result: { reportMarkdown: "done", title: null, structuredOutput: [1] }, + }, + ], + }; + const good = JSON.stringify(message([tool(rich)])); + await differential([ + good, + good.replace('"depth":1', '"depth":-1'), + good.replace('"depth":1', '"depth":1,"extra":true'), + good.replace('"depth":1', '"depth":1,"__proto__":true'), + good.replace('"parallel":true', '"parallel":true,"extra":null'), + good.replace('"provenance":"declared"', '"provenance":"declared","extra":true'), + good.replace('"finalizationToken":"token"', '"finalizationToken":"token","extra":1'), + good.replace('"args":{"input":[null,true,"value",1]}', '"args":[{"value":1e999}]'), + good.replace('"input":{}', '"input":{"value":1e999}'), + good.replace('"output":{}', '"output":[1e999]'), + good.replace('"args":{"input":[null,true,"value",1]}', '"args":{"__proto__":1e999}'), + good.replace('"executable":true', '"executable":false'), + good.replace('"executable":true', '"executable":false,"blockedReason":"blocked"'), + good.replace('"sequence":2', '"sequence":1'), + good.replace('"at":"' + date + '"', '"at":"' + date + '","data":1e999'), + ]); + }); + + it("preserves full date validity across arbitrary fractional precision", async () => { + const dates = [ + date, + "2024-02-29T23:59:59+02:30", + "2025-02-29T00:00:00Z", + "2026-09-11T12:00Z", + "2026-09-11T12:00:00." + "1".repeat(10000) + "Z", + "2026-09-11T12:00:00." + "0".repeat(10000) + "+01:00", + "2026-09-11T12:00:00." + "0".repeat(5000) + "x" + "0".repeat(5000) + "Z", + "2026-09-11T12:00:00.Z", + "2026-09-11T12:00:00Z" + "x".repeat(2000), + ]; + await differential( + dates.map((createdAt) => JSON.stringify(message([tool({ ...run(), createdAt })]))) + ); + }); + + it("keeps giant ordinary strings, tool payloads, keys and identifiers readable with bounded prefixes", async () => { + const huge = "x".repeat(2 * 1024 * 1024); + const row = JSON.stringify({ + ...message([ + { type: "text", text: huge }, + { ...tool(), input: { [huge]: huge } }, + ]), + id: huge, + metadata: { historySequence: 9007199254740991, compactionReplacementNonce: huge }, + }); + const [result] = await inspect([row], { id: huge, nonce: huge }); + expect(result.readable).toBe(true); + expect(result.id?.matchesExpected).toBe(true); + expect(result.matchesNonce).toBe(true); + expect(result.id?.length).toBe(huge.length); + expect(result.id!.prefix.length).toBeLessThan(2048); + await differential([ + JSON.stringify( + message([tool({ ...run(), workflow: { ...run().workflow, description: huge } })]) + ), + ]); + }); + + it("matches schema field mutations without retaining whole workflow records", async () => { + const rich = { + ...run(), + parentWorkflow: { runId: "wfr_parent", stepId: "s", inputHash: "h", depth: 1 }, + workflow: { + ...run().workflow, + phaseManifest: { provenance: "declared", phases: [{ name: "phase" }] }, + }, + steps: [ + { + stepId: "s", + inputHash: "h", + status: "completed", + startedAt: date, + result: { reportMarkdown: "done" }, + timeout: { softDeadlineAt: date }, + }, + ], + }; + const base = message([tool(rich)]); + const paths: Array> = [ + [], + ["parts", 0], + ["parts", 0, "workflowRun"], + ["parts", 0, "workflowRun", "run"], + ["parts", 0, "workflowRun", "run", "workflow"], + ["parts", 0, "workflowRun", "run", "workflow", "phaseManifest"], + ["parts", 0, "workflowRun", "run", "workflow", "phaseManifest", "phases", 0], + ["parts", 0, "workflowRun", "run", "parentWorkflow"], + ["parts", 0, "workflowRun", "run", "steps", 0], + ["parts", 0, "workflowRun", "run", "steps", 0, "timeout"], + ["parts", 0, "workflowRun", "run", "steps", 0, "result"], + ["parts", 0, "workflowRun", "run", "events", 0], + ]; + function at(value: unknown, keys: Array): Record { + for (const key of keys) value = (value as Record)[key]; + return value as Record; + } + const rows: string[] = []; + for (const keys of paths) { + for (const field of [...Object.keys(at(base, keys)), "unknownField"]) { + for (const replacement of [ + undefined, + null, + true, + false, + 0, + -1, + 1.5, + "", + "x", + [], + {}, + [null], + "x".repeat(1025), + ]) { + const candidate: unknown = structuredClone(base); + at(candidate, keys)[field] = replacement; + rows.push(JSON.stringify(candidate)); + } + } + } + await differential(rows); + }); + + it("validates every reduced array element and bounded workflow phase counts", async () => { + const parts = Array.from({ length: 20000 }, () => ({ type: "text", text: "part" })); + const phases = Array.from({ length: 64 }, () => ({ name: "phase" })); + const records = [0, 1, 64, 65].map((count) => ({ + ...run(), + workflow: { + ...run().workflow, + phaseManifest: { + provenance: "declared", + phases: [...phases, { name: "extra" }].slice(0, count), + }, + }, + })); + await differential([ + JSON.stringify(message(parts)), + JSON.stringify(message([...parts, { type: "text", text: false }])), + ...records.map((record) => JSON.stringify(message([tool(record)]))), + JSON.stringify( + message([ + { + ...tool(), + nestedCalls: [{ toolCallId: "n", toolName: "n", state: "input-available" }], + }, + ]) + ), + JSON.stringify( + message([ + { + ...tool(), + nestedCalls: [ + { + toolCallId: "n", + toolName: "n", + state: "input-available", + workflowRun: { runId: "bad", timestamp: 0 }, + }, + ], + }, + ]) + ), + ]); + }); + + it("keeps native scalar rounding, escaped identities and complete syntax separate", async () => { + const row = JSON.stringify(message()); + await differential([ + row.replace('"historySequence":3', '"historySequence":3.0000000000000001'), + row.replace('"historySequence":3', '"historySequence":-0'), + row.replace('"historySequence":3', '"historySequence":1e999'), + row.replace( + '"historySequence":3', + '"historySequence":10000000000000000000000000000000001e-34' + ), + row.replace('"id":"message"', '"id":"m\\u0065ssage"'), + row.replace('"id":"message"', '"id":"\\ud800"'), + row.replace('"id":"message"', '"id":"\\ud801"'), + row.replace('"text":"hello"', '"text":"\\ud83d\\ude00"'), + row.replace('"role":"user"', '"role":[{"toString":null}]'), + row.replace('"role":"user"', '"role":[[["user"]]]'), + ]); + const [exact, mismatch] = await inspect([row, row], { id: "message!", nonce: "nonce!" }); + expect(exact.id?.matchesExpected).toBe(false); + expect(mismatch.matchesNonce).toBe(false); + }); + + it("retains replacement-decoded collision identity while malformed syntax remains unreadable", async () => { + const raw = Buffer.from(JSON.stringify(message())); + const invalid = Buffer.concat([raw.subarray(0, 7), Buffer.from([0xff]), raw.subarray(7)]); + const [result] = await inspect([invalid]); + expect(result.readable).toBe(readable(native(invalid.toString("utf8")))); + expect(result.id?.matchesExpected).toBe(false); + }); +}); diff --git a/src/node/services/historyMessageEvidence.ts b/src/node/services/historyMessageEvidence.ts new file mode 100644 index 0000000000..1e8ade2418 --- /dev/null +++ b/src/node/services/historyMessageEvidence.ts @@ -0,0 +1,310 @@ +import { MuxMessageSchema, NestedToolCallSchema } from "@/common/orpc/schemas/message"; +import { + WorkflowRunEventSchema, + WorkflowStepRecordSchema, + WorkflowDeclaredPhaseSchema, + WorkflowScriptDescriptorSchema, + WORKFLOW_DECLARED_PHASES_MAX, +} from "@/common/orpc/schemas/workflow"; +import { isReadableHistoryMessage } from "./historyScanner"; +import type { HistoryRowDescriptor, HistoryRowToken } from "./historyRowScanner"; +import { createHistoryStringEvidence, createHistoryNumberEvidence } from "./historyScalarEvidence"; + +const INVALID = Symbol("invalid history projection"); +const STRING_PREFIX = WorkflowScriptDescriptorSchema.shape.description.maxLength! + 1; +type StringFacts = ReturnType["finish"]>; +interface Value { + value: unknown; + string?: StringFacts; + fields?: Map; +} +type Context = + | "message" + | "metadata" + | "part" + | "call" + | "attachment" + | "run" + | "workflow" + | "parent" + | "manifest" + | "phase" + | "step" + | "timeout" + | "event" + | "result" + | "json" + | "ignore" + | "date" + | "role" + | "parts" + | "calls" + | "events" + | "steps" + | "phases"; +function grammar( + scalars: string, + children: Record = {} +): Readonly> { + return { + ...Object.fromEntries(scalars.split(" ").map((key) => [key, "ignore"])), + ...children, + }; +} +// Explicit history grammar, not a Zod interpreter. Validation remains with the actual schemas. +const fields = { + message: grammar("id", { role: "role", parts: "parts", metadata: "metadata" }), + metadata: grammar( + "historySequence compactionReplacementNonce cmuxMetadata compacted idleCompacted" + ), + part: grammar( + "type text timestamp toolCallId toolName input output state failed executionStartedAt url mediaType filename", + { nestedCalls: "calls", workflowRun: "attachment" } + ), + call: grammar("toolCallId toolName input output state failed timestamp", { + workflowRun: "attachment", + }), + attachment: grammar("runId timestamp", { run: "run" }), + run: grammar( + "id workspaceId source sourceHash agentOutputSchemaRequired agentTypeAliasAllowed attentionPolicy status", + { + workflow: "workflow", + args: "json", + parentWorkflow: "parent", + createdAt: "date", + updatedAt: "date", + events: "events", + steps: "steps", + } + ), + workflow: grammar( + "name description scope sourcePath requestedScriptPath canonicalScriptPath sourceKind sourceHash executable blockedReason", + { phaseManifest: "manifest" } + ), + parent: grammar("runId stepId inputHash depth"), + manifest: grammar("provenance", { phases: "phases" }), + phase: grammar("name label description parallel"), + step: grammar("stepId inputHash status taskId error", { + startedAt: "date", + completedAt: "date", + result: "result", + timeout: "timeout", + }), + timeout: grammar("finalizationToken", { + executionStartedAt: "date", + softDeadlineAt: "date", + hardDeadlineAt: "date", + softTimedOutAt: "date", + finalizationPromptSentAt: "date", + hardTimedOutAt: "date", + }), + event: grammar( + "sequence type status name message stepId inputHash title taskId phase runId sourceTaskId effect sourcePath sourceHash success", + { at: "date", details: "json", data: "json", result: "result" } + ), + result: grammar("reportMarkdown title planFilePath taskId", { structuredOutput: "json" }), +}; +const arrays = { + parts: { context: "part", schema: MuxMessageSchema.shape.parts.element }, + calls: { context: "call", schema: NestedToolCallSchema }, + events: { context: "event", schema: WorkflowRunEventSchema }, + steps: { context: "step", schema: WorkflowStepRecordSchema }, + phases: { context: "phase", schema: WorkflowDeclaredPhaseSchema }, +} as const; +const strict = new Set(["parent", "manifest", "phase", "timeout"]); +interface Frame { + context: Context; + array: boolean; + fields: Map; + key?: StringFacts; + valid: boolean; + count: number; + sequence: number; + sample?: unknown; + roleText?: string; +} +/** + * Stream only the finite message/workflow shape needed by the existing reader. Large strings and + * array elements are reduced after their schema checks; JSON objects retain last-key validity. + * Memory is bounded per scalar and grows only with nesting and distinct JSON-object keys. + * Returned identity is provisional collision evidence, never durable replacement authority. + */ +export function createHistoryMessageEvidence( + sourceBytes: number, + targets: { id?: string; nonce?: string } = {} +) { + targets = { ...targets }; + const frames: Frame[] = []; + let root: Value | undefined; + let string: ReturnType | undefined; + let number: ReturnType | undefined; + let isKey = false; + let date: string | undefined; + let datePosition = 0; + let fraction = false; + let suffix = false; + const nextContext = (): Context => { + const frame = frames.at(-1); + if (!frame) return "message"; + if (frame.context === "json" || frame.context === "role") return frame.context; + if (frame.array) return arrays[frame.context as keyof typeof arrays]?.context ?? "ignore"; + const shape = fields[frame.context as keyof typeof fields] as + | Readonly> + | undefined; + return shape?.[frame.key?.prefix ?? ""] ?? "ignore"; + }; + const deliver = (item: Value) => { + const frame = frames.at(-1); + if (!frame) { + root = item; + return; + } + if (frame.array) { + frame.count++; + if (frame.context === "role") + frame.roleText = ( + (frame.roleText ?? "") + + (frame.count > 1 ? "," : "") + + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- Match the legacy reader's intentional String(role) coercion. + (item.value == null ? "" : String(item.value)) + ).slice(0, STRING_PREFIX); + if (frame.context === "json") frame.valid &&= item.value !== INVALID; + const schema = arrays[frame.context as keyof typeof arrays]?.schema; + if (schema) frame.valid &&= schema.safeParse(item.value).success; + if (frame.context === "events") { + const sequence = item.fields?.get("sequence")?.value; + frame.valid &&= typeof sequence === "number" && sequence > frame.sequence; + if (typeof sequence === "number") frame.sequence = sequence; + } + if (frame.context === "phases" && frame.count === 1) frame.sample = item.value; + } else if (frame.context === "json") { + // JSON.parse keeps the last occurrence, including an invalid value overwritten later. + // z.record deliberately ignores this key, including its value's validation. + if (frame.key!.prefix !== "__proto__") + frame.fields.set(frame.key!.sha256, { value: item.value !== INVALID }); + } else { + const shape = fields[frame.context as keyof typeof fields]; + const key = frame.key!.prefix; + if (shape && Object.hasOwn(shape, key) && key.length === frame.key!.length) + frame.fields.set(key, item); + else if (strict.has(frame.context) && key !== "__proto__") + frame.fields.set("", { value: null }); + } + }; + return { + token(token: HistoryRowToken) { + switch (token.name) { + case "startObject": + case "startArray": + frames.push({ + context: nextContext(), + array: token.name === "startArray", + fields: new Map(), + valid: true, + count: 0, + sequence: 0, + }); + break; + case "endObject": + case "endArray": { + const frame = frames.pop()!; + let value: unknown; + if (frame.array) { + if (frame.context === "phases") + frame.valid &&= frame.count <= WORKFLOW_DECLARED_PHASES_MAX; + value = frame.valid + ? frame.context === "phases" && frame.count + ? [frame.sample] + : [] + : INVALID; + } else if (frame.context === "json") { + value = [...frame.fields.values()].every((item) => item.value) ? {} : INVALID; + } else + value = Object.fromEntries([...frame.fields].map(([key, item]) => [key, item.value])); + // The legacy reader uses String(role); preserve its array-coercion compatibility. + if (frame.array && frame.context === "role") value = [frame.roleText ?? ""]; + deliver({ value, fields: frame.fields }); + break; + } + case "startKey": + case "startString": { + isKey = token.name === "startKey"; + const parent = frames.at(-1); + const expected = + !isKey && parent?.context === "message" && parent.key?.prefix === "id" + ? targets.id + : !isKey && + parent?.context === "metadata" && + parent.key?.prefix === "compactionReplacementNonce" + ? targets.nonce + : undefined; + string = createHistoryStringEvidence(STRING_PREFIX, expected); + date = !isKey && nextContext() === "date" ? "" : undefined; + datePosition = 0; + fraction = false; + suffix = false; + break; + } + case "stringChunk": + string!.push(token.value); + if (date !== undefined) + for (const character of token.value) { + // The schema permits arbitrary fractional precision, but no other unbounded date run. + if (datePosition++ < 20) { + date += character; + fraction = datePosition === 20 && character === "."; + } else if (fraction && !suffix && /^[0-9]$/.test(character)) { + if (date.length === 20) date += "0"; + } else { + suffix = true; + if (date.length < STRING_PREFIX) date += character; + } + } + break; + case "endKey": + case "endString": { + const facts = string!.finish(); + if (isKey) frames.at(-1)!.key = facts; + else deliver({ value: date ?? facts.prefix, string: facts }); + string = undefined; + break; + } + case "startNumber": + number = createHistoryNumberEvidence(sourceBytes); + break; + case "numberChunk": + number!.push(token.value); + break; + case "endNumber": { + const value = number!.finish().value; + deliver({ value: nextContext() === "json" && !Number.isFinite(value) ? INVALID : value }); + number = undefined; + break; + } + case "trueValue": + case "falseValue": + case "nullValue": + deliver({ value: token.value }); + break; + case "whitespace": + break; + default: + throw new Error("Replacement projection requires unpacked tokens"); + } + }, + finish(row: HistoryRowDescriptor) { + const metadata = root?.fields?.get("metadata")?.fields; + const readable = row.decodedJsonComplete && isReadableHistoryMessage(root?.value); + return { + readable, + id: root?.fields?.get("id")?.string, + sequence: metadata?.get("historySequence")?.value, + matchesNonce: metadata?.get("compactionReplacementNonce")?.string?.matchesExpected ?? false, + // Match the readable-history role coercion; malformed objects must not be coerced. + systemRole: readable && String(root?.fields?.get("role")?.value) === "system", + normalizationChanged: + !!metadata?.has("cmuxMetadata") || metadata?.get("idleCompacted")?.value === true, + }; + }, + }; +}