diff --git a/src/model.ts b/src/model.ts index 4ac79f1..b59d3b3 100644 --- a/src/model.ts +++ b/src/model.ts @@ -25,10 +25,25 @@ export interface Anchor { fingerprint: Fingerprint; } -/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */ +/** + * The reusable author field (contract §3.3). `agent` only present when + * kind=agent. F5 (spec §6.3, fork c): `email` is the cross-rung join key — + * git's own identity model (forges map email → account); `onBehalfOf` records + * the operator a machine acted for (rfc-app#46 §6.5 made data). + */ export type Provenance = - | { kind: "human"; id: string } - | { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } }; + | { kind: "human"; id: string; email?: string } + | { + kind: "agent"; + id: string; + email?: string; + agent: { + sdk: string; + model: string; + sessionId: string; + onBehalfOf?: { id: string; email?: string }; + }; + }; export interface Message { id: string; @@ -107,9 +122,27 @@ export function newId(prefix: string): string { } function serializeProvenance(p: Provenance): Record { - return p.kind === "agent" - ? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } } - : { kind: p.kind, id: p.id }; + const known: Record = { + kind: p.kind, + id: p.id, + ...(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 } : {}), + }, + } + : {}), + }; + } + return known; } /** diff --git a/test/model.test.ts b/test/model.test.ts index c0bea25..a536af9 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -57,6 +57,52 @@ describe("serializeArtifact", () => { }); }); +describe("cross-rung identity (F5 SLICE-2)", () => { + it("round-trips email on human provenance, omitted when absent", () => { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } }; + a.threads.push({ + id: "t_1", + anchorId: "a_1", + status: "open", + messages: [ + { id: "m_1", author: { kind: "human", id: "ben", email: "ben@wiggleverse.org" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }, + { id: "m_2", author: { kind: "human", id: "anon" }, body: "yo", createdAt: "2026-06-10T00:00:01.000Z" }, + ], + }); + const out = serializeArtifact(a); + const parsed = JSON.parse(out) as Artifact; + expect(parsed.threads[0].messages[0].author).toEqual({ kind: "human", id: "ben", email: "ben@wiggleverse.org" }); + expect("email" in parsed.threads[0].messages[1].author).toBe(false); + expect(serializeArtifact(parsed)).toBe(out); + }); + + it("round-trips agent.onBehalfOf and omits it when absent", () => { + const a = emptyArtifact("docs/x.md"); + a.attributions.push({ + id: "at_1", + anchorId: "a_1", + author: { + kind: "agent", + id: "claude", + agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1", onBehalfOf: { id: "benstull", email: "ben@wiggleverse.org" } }, + }, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + }); + const out = serializeArtifact(a); + const parsed = JSON.parse(out) as Artifact; + const author = parsed.attributions[0].author; + expect(author.kind).toBe("agent"); + if (author.kind === "agent") { + expect(author.agent.onBehalfOf).toEqual({ id: "benstull", email: "ben@wiggleverse.org" }); + } + expect(out.includes("onBehalfOf")).toBe(true); + const a2 = emptyArtifact("docs/x.md"); + expect(serializeArtifact(a2).includes("onBehalfOf")).toBe(false); + }); +}); + describe("attributions[] (F3 SLICE-1)", () => { it("round-trips an attribution record through serialize → parse", () => { const a = emptyArtifact("docs/x.md");