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:
+105
-59
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user