diff --git a/src/model.ts b/src/model.ts index b59d3b3..5499382 100644 --- a/src/model.ts +++ b/src/model.ts @@ -121,6 +121,22 @@ export function newId(prefix: string): string { return `${prefix}_${randomUUID()}`; } +/** + * INV-15 (round-trip preservation): after rebuilding the canonical known keys, + * append any fields of `src` this writer does not recognize, sorted + * lexicographically — no rung's writer may destroy another rung's data, and + * placement stays deterministic (INV-2). Contract §2 rule 5 / §4 rule 3. + */ +function withUnknowns(known: Record, src: unknown): Record { + const rec = src as Record; + for (const k of Object.keys(rec) + .filter((k) => !(k in known)) + .sort()) { + known[k] = rec[k]; + } + return known; +} + function serializeProvenance(p: Provenance): Record { const known: Record = { kind: p.kind, @@ -128,21 +144,27 @@ function serializeProvenance(p: Provenance): Record { ...(p.email !== undefined ? { email: p.email } : {}), }; if (p.kind === "agent") { - known.agent = { - sdk: p.agent.sdk, - model: p.agent.model, - sessionId: p.agent.sessionId, - ...(p.agent.onBehalfOf !== undefined - ? { - onBehalfOf: { - id: p.agent.onBehalfOf.id, - ...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}), - }, - } - : {}), - }; + known.agent = withUnknowns( + { + sdk: p.agent.sdk, + model: p.agent.model, + sessionId: p.agent.sessionId, + ...(p.agent.onBehalfOf !== undefined + ? { + onBehalfOf: withUnknowns( + { + id: p.agent.onBehalfOf.id, + ...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}), + }, + p.agent.onBehalfOf, + ), + } + : {}), + }, + p.agent, + ); } - return known; + return withUnknowns(known, p); } /** @@ -151,52 +173,76 @@ function serializeProvenance(p: Provenance): Record { * ends with a trailing newline. */ export function serializeArtifact(a: Artifact): string { - const canonical = { - schemaVersion: a.schemaVersion, - document: { path: a.document.path }, - anchors: Object.fromEntries( - Object.keys(a.anchors) - .sort() - .map((k) => [ - k, + const canonical = withUnknowns( + { + schemaVersion: a.schemaVersion, + document: withUnknowns({ path: a.document.path }, a.document), + anchors: Object.fromEntries( + Object.keys(a.anchors) + .sort() + .map((k) => [ + k, + withUnknowns( + { + fingerprint: withUnknowns( + { + text: a.anchors[k].fingerprint.text, + before: a.anchors[k].fingerprint.before, + after: a.anchors[k].fingerprint.after, + lineHint: a.anchors[k].fingerprint.lineHint, + }, + a.anchors[k].fingerprint, + ), + }, + a.anchors[k], + ), + ]), + ), + threads: a.threads.map((t) => + withUnknowns( { - fingerprint: { - text: a.anchors[k].fingerprint.text, - before: a.anchors[k].fingerprint.before, - after: a.anchors[k].fingerprint.after, - lineHint: a.anchors[k].fingerprint.lineHint, - }, + id: t.id, + anchorId: t.anchorId, + status: t.status, + messages: t.messages.map((m) => + withUnknowns( + { id: m.id, author: serializeProvenance(m.author), body: m.body, createdAt: m.createdAt }, + m, + ), + ), }, - ]), - ), - threads: a.threads.map((t) => ({ - id: t.id, - anchorId: t.anchorId, - status: t.status, - messages: t.messages.map((m) => ({ - id: m.id, - author: serializeProvenance(m.author), - body: m.body, - createdAt: m.createdAt, - })), - })), - attributions: a.attributions.map((at) => ({ - id: at.id, - anchorId: at.anchorId, - author: serializeProvenance(at.author), - createdAt: at.createdAt, - updatedAt: at.updatedAt, - ...(at.turnId !== undefined ? { turnId: at.turnId } : {}), - })), - proposals: a.proposals.map((p) => ({ - id: p.id, - anchorId: p.anchorId, - replacement: p.replacement, - author: serializeProvenance(p.author), - createdAt: p.createdAt, - ...(p.turnId !== undefined ? { turnId: p.turnId } : {}), - ...(p.instruction !== undefined ? { instruction: p.instruction } : {}), - })), - }; + t, + ), + ), + attributions: a.attributions.map((at) => + withUnknowns( + { + id: at.id, + anchorId: at.anchorId, + author: serializeProvenance(at.author), + createdAt: at.createdAt, + updatedAt: at.updatedAt, + ...(at.turnId !== undefined ? { turnId: at.turnId } : {}), + }, + at, + ), + ), + proposals: a.proposals.map((p) => + withUnknowns( + { + id: p.id, + anchorId: p.anchorId, + replacement: p.replacement, + author: serializeProvenance(p.author), + createdAt: p.createdAt, + ...(p.turnId !== undefined ? { turnId: p.turnId } : {}), + ...(p.instruction !== undefined ? { instruction: p.instruction } : {}), + }, + p, + ), + ), + }, + a, + ); return JSON.stringify(canonical, null, 2) + "\n"; } diff --git a/test/model.test.ts b/test/model.test.ts index a536af9..3815baa 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -103,6 +103,74 @@ describe("cross-rung identity (F5 SLICE-2)", () => { }); }); +describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => { + it("a rewrite preserves unknown fields at every level, after known keys, sorted", () => { + const foreign = { + schemaVersion: 1, + document: { path: "docs/x.md", zFuture: "keep-me" }, + anchors: { + a_1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0, confidence: 0.9 }, label: "intro" }, + }, + threads: [ + { + id: "t_1", + anchorId: "a_1", + status: "open", + messages: [ + { + id: "m_1", + author: { kind: "human", id: "ben", forgeLogin: "bstull" }, + body: "hi", + createdAt: "2026-06-10T00:00:00.000Z", + reactions: ["+1"], + }, + ], + locked: false, + }, + ], + attributions: [ + { + id: "at_1", + anchorId: "a_1", + author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + reviewState: "approved", + }, + ], + proposals: [ + { + id: "p_1", + anchorId: "a_1", + replacement: "ALPHA", + author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T00:00:00.000Z", + priority: 2, + }, + ], + futureSection: [{ id: "f_1" }], + aaaEarly: true, + }; + const out = serializeArtifact(foreign as unknown as Artifact); + const parsed = JSON.parse(out); + expect(parsed.futureSection).toEqual([{ id: "f_1" }]); + expect(parsed.aaaEarly).toBe(true); + expect(parsed.document.zFuture).toBe("keep-me"); + expect(parsed.anchors.a_1.label).toBe("intro"); + expect(parsed.anchors.a_1.fingerprint.confidence).toBe(0.9); + expect(parsed.threads[0].locked).toBe(false); + expect(parsed.threads[0].messages[0].reactions).toEqual(["+1"]); + expect(parsed.threads[0].messages[0].author.forgeLogin).toBe("bstull"); + expect(parsed.attributions[0].reviewState).toBe("approved"); + expect(parsed.proposals[0].priority).toBe(2); + // unknown ROOT keys serialize AFTER known keys, sorted: aaaEarly then futureSection, both after proposals + expect(out.indexOf('"proposals"')).toBeLessThan(out.indexOf('"aaaEarly"')); + expect(out.indexOf('"aaaEarly"')).toBeLessThan(out.indexOf('"futureSection"')); + // byte-stable on re-serialize (INV-2 holds with unknowns present) + expect(serializeArtifact(parsed as Artifact)).toBe(out); + }); +}); + describe("attributions[] (F3 SLICE-1)", () => { it("round-trips an attribution record through serialize → parse", () => { const a = emptyArtifact("docs/x.md");