feat: serializer preserves unknown fields at every level (F5 SLICE-2, INV-15, #14)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 22:57:58 -07:00
parent da172a3117
commit a6b16f0d9c
2 changed files with 173 additions and 59 deletions
+105 -59
View File
@@ -121,6 +121,22 @@ export function newId(prefix: string): string {
return `${prefix}_${randomUUID()}`; 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<string, unknown>, src: unknown): Record<string, unknown> {
const rec = src as Record<string, unknown>;
for (const k of Object.keys(rec)
.filter((k) => !(k in known))
.sort()) {
known[k] = rec[k];
}
return known;
}
function serializeProvenance(p: Provenance): Record<string, unknown> { function serializeProvenance(p: Provenance): Record<string, unknown> {
const known: Record<string, unknown> = { const known: Record<string, unknown> = {
kind: p.kind, kind: p.kind,
@@ -128,21 +144,27 @@ function serializeProvenance(p: Provenance): Record<string, unknown> {
...(p.email !== undefined ? { email: p.email } : {}), ...(p.email !== undefined ? { email: p.email } : {}),
}; };
if (p.kind === "agent") { if (p.kind === "agent") {
known.agent = { known.agent = withUnknowns(
sdk: p.agent.sdk, {
model: p.agent.model, sdk: p.agent.sdk,
sessionId: p.agent.sessionId, model: p.agent.model,
...(p.agent.onBehalfOf !== undefined sessionId: p.agent.sessionId,
? { ...(p.agent.onBehalfOf !== undefined
onBehalfOf: { ? {
id: p.agent.onBehalfOf.id, onBehalfOf: withUnknowns(
...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}), {
}, 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<string, unknown> {
* ends with a trailing newline. * ends with a trailing newline.
*/ */
export function serializeArtifact(a: Artifact): string { export function serializeArtifact(a: Artifact): string {
const canonical = { const canonical = withUnknowns(
schemaVersion: a.schemaVersion, {
document: { path: a.document.path }, schemaVersion: a.schemaVersion,
anchors: Object.fromEntries( document: withUnknowns({ path: a.document.path }, a.document),
Object.keys(a.anchors) anchors: Object.fromEntries(
.sort() Object.keys(a.anchors)
.map((k) => [ .sort()
k, .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: { id: t.id,
text: a.anchors[k].fingerprint.text, anchorId: t.anchorId,
before: a.anchors[k].fingerprint.before, status: t.status,
after: a.anchors[k].fingerprint.after, messages: t.messages.map((m) =>
lineHint: a.anchors[k].fingerprint.lineHint, withUnknowns(
}, { id: m.id, author: serializeProvenance(m.author), body: m.body, createdAt: m.createdAt },
m,
),
),
}, },
]), t,
), ),
threads: a.threads.map((t) => ({ ),
id: t.id, attributions: a.attributions.map((at) =>
anchorId: t.anchorId, withUnknowns(
status: t.status, {
messages: t.messages.map((m) => ({ id: at.id,
id: m.id, anchorId: at.anchorId,
author: serializeProvenance(m.author), author: serializeProvenance(at.author),
body: m.body, createdAt: at.createdAt,
createdAt: m.createdAt, updatedAt: at.updatedAt,
})), ...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
})), },
attributions: a.attributions.map((at) => ({ at,
id: at.id, ),
anchorId: at.anchorId, ),
author: serializeProvenance(at.author), proposals: a.proposals.map((p) =>
createdAt: at.createdAt, withUnknowns(
updatedAt: at.updatedAt, {
...(at.turnId !== undefined ? { turnId: at.turnId } : {}), id: p.id,
})), anchorId: p.anchorId,
proposals: a.proposals.map((p) => ({ replacement: p.replacement,
id: p.id, author: serializeProvenance(p.author),
anchorId: p.anchorId, createdAt: p.createdAt,
replacement: p.replacement, ...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
author: serializeProvenance(p.author), ...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
createdAt: p.createdAt, },
...(p.turnId !== undefined ? { turnId: p.turnId } : {}), p,
...(p.instruction !== undefined ? { instruction: p.instruction } : {}), ),
})), ),
}; },
a,
);
return JSON.stringify(canonical, null, 2) + "\n"; return JSON.stringify(canonical, null, 2) + "\n";
} }
+68
View File
@@ -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)", () => { describe("attributions[] (F3 SLICE-1)", () => {
it("round-trips an attribution record through serialize → parse", () => { it("round-trips an attribution record through serialize → parse", () => {
const a = emptyArtifact("docs/x.md"); const a = emptyArtifact("docs/x.md");