diff --git a/src/model.ts b/src/model.ts index c1113de..968578b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -47,6 +47,20 @@ export interface Thread { messages: Message[]; } +/** F3 (spec §6.3): an anchored, author-attributed span — state, not history. */ +export interface AttributionRecord { + id: string; + /** shared anchors map — same Fingerprint primitive (INV-4). */ + anchorId: string; + author: Provenance; + /** ISO-8601. */ + createdAt: string; + /** ISO-8601; bumped when the span's extent/fingerprint changes. */ + updatedAt: string; + /** groups all spans applied by one live turn. */ + turnId?: string; +} + export interface Artifact { schemaVersion: number; /** repo-relative path; the sidecar key. */ @@ -54,8 +68,8 @@ export interface Artifact { /** SHARED primitive (INV-4). */ anchors: Record; threads: Thread[]; - /** F3 extension point — reuses anchors[]; not implemented in F2. */ - attributions: unknown[]; + /** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */ + attributions: AttributionRecord[]; /** F4 extension point — reuses anchors[] + provenance; not in F2. */ proposals: unknown[]; } @@ -117,7 +131,14 @@ export function serializeArtifact(a: Artifact): string { createdAt: m.createdAt, })), })), - attributions: a.attributions, + 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, }; return JSON.stringify(canonical, null, 2) + "\n"; diff --git a/test/model.test.ts b/test/model.test.ts index 53d5805..c0bea25 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -56,3 +56,35 @@ describe("serializeArtifact", () => { expect(e.proposals).toEqual([]); }); }); + +describe("attributions[] (F3 SLICE-1)", () => { + it("round-trips an attribution record through serialize → parse", () => { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", lineHint: 0 } }; + a.attributions.push({ + id: "at_1", + anchorId: "a_1", + author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } }, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + turnId: "turn-1", + }); + const parsed = JSON.parse(serializeArtifact(a)) as Artifact; + expect(parsed.attributions).toEqual(a.attributions); + }); + + it("omits turnId when undefined and serializes stably", () => { + const a = emptyArtifact("docs/x.md"); + a.attributions.push({ + id: "at_2", + anchorId: "a_2", + author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + }); + const s1 = serializeArtifact(a); + expect(s1.includes("turnId")).toBe(false); + const s2 = serializeArtifact(JSON.parse(s1) as Artifact); + expect(s2).toBe(s1); + }); +});