feat: Provenance gains email + agent.onBehalfOf — cross-rung identity (F5 SLICE-2, #14)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 22:56:45 -07:00
parent 7c4d3ed79f
commit da172a3117
2 changed files with 85 additions and 6 deletions
+39 -6
View File
@@ -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<string, unknown> {
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<string, unknown> = {
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;
}
/**
+46
View File
@@ -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");