@tpsdev-ai/flair 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,13 +123,20 @@ async function memoryStore(agent, args) {
123
123
  // agentId is the RESOLVED agent — Memory.post also re-checks ownership via
124
124
  // resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
125
125
  // to the verified id so the write is correctly owned.
126
- return unwrap(await h.post({
126
+ const body = {
127
127
  agentId: agent.agentId,
128
128
  content: args?.content,
129
129
  type: args?.type ?? "session",
130
130
  durability: args?.durability ?? "standard",
131
131
  tags: args?.tags,
132
- }));
132
+ };
133
+ // flair#718 authorship-provenance: forward the resolved OAuth client_id
134
+ // (never a tool argument — no forging) as claimedClient; Memory.post()
135
+ // folds it into provenance.claimed.client and strips it from the row.
136
+ // Omitted entirely when the token carried no client_id.
137
+ if (agent.clientId)
138
+ body.claimedClient = agent.clientId;
139
+ return unwrap(await h.post(body));
133
140
  }
134
141
  /**
135
142
  * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
@@ -175,12 +182,20 @@ async function memoryUpdate(agent, args) {
175
182
  delete record.validFrom;
176
183
  delete record.validTo;
177
184
  delete record.archivedAt;
185
+ // flair#718 authorship-provenance — see memoryStore's comment: forward
186
+ // the resolved OAuth client_id (never forgeable via args) so the NEW
187
+ // version's provenance records which client authored this update.
188
+ if (agent.clientId)
189
+ record.claimedClient = agent.clientId;
178
190
  h.isCollection = true;
179
191
  return unwrap(await h.post(record));
180
192
  }
181
193
  const merged = { ...existing, content, updatedAt: new Date().toISOString() };
182
194
  delete merged.embedding;
183
195
  delete merged.embeddingModel;
196
+ // flair#718 authorship-provenance — see memoryStore's comment above.
197
+ if (agent.clientId)
198
+ merged.claimedClient = agent.clientId;
184
199
  return unwrap(await h.put(merged));
185
200
  }
186
201
  async function memoryGet(agent, args) {
@@ -287,6 +302,38 @@ async function recordUsage(agent, args) {
287
302
  : typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
288
303
  return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
289
304
  }
305
+ /**
306
+ * Verb→tool-name overrides — the three naming quirks where the shipped tool
307
+ * name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
308
+ * that module's `mcp` header doc, and RECORD_TYPES.<Table>.mcp itself,
309
+ * record-types slice 3, flair#520). Keyed by table name + verb; consumed by
310
+ * `mcpToolName()` below and by test/unit/mcp-surface-tripwire.test.ts to
311
+ * compute the expected tool name for every declared registry verb.
312
+ *
313
+ * Placement here (not in record-types.ts) is deliberate — per Kern/
314
+ * Sherlock's unanimous slice-3 verdict, the registry declares WHAT is
315
+ * exposed (capability: toolPrefix + verbs); this map declares HOW that
316
+ * capability's tool is actually named (presentation). Coupling names into
317
+ * the registry would mix the policy layer with the presentation layer,
318
+ * exactly what the registry's separation is meant to avoid.
319
+ */
320
+ export const TOOL_NAME_OVERRIDES = {
321
+ Soul: { store: "soul_set" },
322
+ WorkspaceState: { store: "flair_workspace_set" },
323
+ OrgEvent: { store: "flair_orgevent" },
324
+ };
325
+ /**
326
+ * Resolve the actual `TOOLS` key for a declared (table, verb) pair: the
327
+ * `TOOL_NAME_OVERRIDES` entry if one exists, else the default
328
+ * `${toolPrefix}_${verb}` shape. `toolPrefix` is passed in (rather than
329
+ * looked up from RECORD_TYPES here) so this stays a pure naming function —
330
+ * both this module and the tripwire test call it with the registry's own
331
+ * `toolPrefix` value, keeping the naming rule in exactly one place.
332
+ */
333
+ export function mcpToolName(table, toolPrefix, verb) {
334
+ const override = TOOL_NAME_OVERRIDES[table]?.[verb];
335
+ return override ?? `${toolPrefix}_${verb}`;
336
+ }
290
337
  export const TOOLS = {
291
338
  memory_search: {
292
339
  def: {
@@ -489,7 +536,7 @@ export const TOOLS = {
489
536
  impl: recordUsage,
490
537
  },
491
538
  };
492
- /** The tool definitions for a tools/list response (exactly the 9 curated tools). */
539
+ /** The tool definitions for a tools/list response (exactly the 12 curated tools). */
493
540
  export function listToolDefs() {
494
541
  return Object.values(TOOLS).map((t) => t.def);
495
542
  }
@@ -1,5 +1,36 @@
1
1
  /**
2
- * ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
2
+ * Sanitize an optional, unverified `claimed.*` passthrough value (memory-
3
+ * provenance slice 1 + flair#718 authorship-provenance). Shared by BOTH
4
+ * `claimed.model` and `claimed.client` — same authority level, same
5
+ * discipline, one implementation so they can't drift:
6
+ *
7
+ * 1. Must be a `string` — anything else (number, object, array) is dropped.
8
+ * 2. Control characters (C0 + DEL, `\x00`-`\x1F`,`\x7F`) are stripped —
9
+ * this is caller-supplied, unverified data landing in a stored JSON
10
+ * blob; no newlines/nulls smuggled into logs or downstream renders.
11
+ * 3. Trimmed.
12
+ * 4. Length-capped at 200 chars (truncated, not rejected — a label this
13
+ * long is almost certainly malformed, but the write must never fail
14
+ * because of it).
15
+ * 5. Dropped (returns `undefined`) if empty after the above — an
16
+ * all-control-chars or all-whitespace input is treated as absent, not
17
+ * stamped as `""`.
18
+ *
19
+ * Sherlock flair#718 review: `claimed.model` previously had only a
20
+ * truthiness check (no cap, no sanitize) — folded into this same function
21
+ * "while touching the same code" per that review's non-blocking recommendation.
22
+ */
23
+ function sanitizeClaim(value) {
24
+ if (typeof value !== "string")
25
+ return undefined;
26
+ const cleaned = value.replace(/[\x00-\x1F\x7F]/g, "").trim();
27
+ if (cleaned.length === 0)
28
+ return undefined;
29
+ return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned;
30
+ }
31
+ /**
32
+ * ─── Write-time provenance stamp (memory-provenance slice 1; claimed.client
33
+ * added by flair#718 authorship-provenance) ──────────────────────────────────
3
34
  *
4
35
  * Foundational capture for an emergent-trust model: every write gets a
5
36
  * structured, versioned `provenance` JSON blob recording what the server can
@@ -8,7 +39,7 @@
8
39
  *
9
40
  * { v: 1,
10
41
  * verified: { agentId: <string|null>, timestamp: <ISO string> },
11
- * claimed?: { model: <string> } }
42
+ * claimed?: { model?: <string>, client?: <string> } }
12
43
  *
13
44
  * - `verified.agentId` comes from the ALREADY-RESOLVED auth verdict
14
45
  * (resolveAgentAuth) — never from anything the caller can forge on the
@@ -23,10 +54,25 @@
23
54
  * `embeddingModel = getModelId()` stamp in resources/Memory.ts.
24
55
  * - `claimed.model` is an OPTIONAL, UNVERIFIED passthrough: included only
25
56
  * when the incoming write payload itself already carries a non-empty
26
- * string `model` field. No client/CLI sets one today — this just means the
27
- * server won't discard it if/when a future write path does. Never
28
- * invented, never defaulted, and the `claimed` key is omitted entirely
29
- * (not stamped as `{}`) when absent.
57
+ * string `model` field (sanitized via sanitizeClaim above). Never
58
+ * invented, never defaulted.
59
+ * - `claimed.client` (flair#718) is the SAME kind of OPTIONAL, UNVERIFIED
60
+ * passthrough, sourced from `content.claimedClient` (a deliberately
61
+ * distinct body-field name from the output key — see the write paths in
62
+ * resources/Memory.ts / resources/Relationship.ts, which strip this field
63
+ * from the row after calling buildProvenance so it is NEVER persisted
64
+ * outside this provenance blob). Records WHICH CLIENT authored a write
65
+ * under one shared principal (the personal deployment shape — see
66
+ * docs/auth.md "Deployment shapes"). `claimed` — never `verified` —
67
+ * because this is self-reported by an authenticated principal, not
68
+ * independently corroborated: it MUST grant zero authority anywhere
69
+ * (never read for access control, attribution weighting, or dedup
70
+ * decisions — Sherlock flair#718 binding refinement). On the native /mcp
71
+ * OAuth path, the caller is required to source this from the verified
72
+ * `client_id` token claim, never the user-controlled `client_name` — see
73
+ * resources/mcp-handler.ts's handleToolCall for that stamp site.
74
+ * - The `claimed` key is omitted entirely (not stamped as `{}`) when both
75
+ * `model` and `client` are absent.
30
76
  *
31
77
  * Originally introduced in resources/Memory.ts (Memory.post()/Memory.put());
32
78
  * extracted here so Relationship.ts (and any future write path) can reuse the
@@ -43,8 +89,14 @@ export function buildProvenance(auth, createdAt, content) {
43
89
  timestamp: createdAt,
44
90
  },
45
91
  };
46
- if (typeof content?.model === "string" && content.model.length > 0) {
47
- provenance.claimed = { model: content.model };
92
+ const model = sanitizeClaim(content?.model);
93
+ const client = sanitizeClaim(content?.claimedClient);
94
+ if (model !== undefined || client !== undefined) {
95
+ provenance.claimed = {};
96
+ if (model !== undefined)
97
+ provenance.claimed.model = model;
98
+ if (client !== undefined)
99
+ provenance.claimed.client = client;
48
100
  }
49
101
  return JSON.stringify(provenance);
50
102
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * record-type-kit.ts — shared, parameterized building blocks for a flair
3
+ * table's agent-identity / read-scope / no-forge-attribution machinery
4
+ * (record-types slice 1: kit extraction, flair#520).
5
+ *
6
+ * ─── What this is ────────────────────────────────────────────────────────
7
+ * Five flair-owned tables — Memory, Relationship, WorkspaceState, OrgEvent,
8
+ * Soul — each independently hand-copied ~150-250 lines of near-identical
9
+ * auth/scoping code: call resolveAgentAuth(getContext()), branch on the
10
+ * three-way verdict (internal / agent / anonymous), 404 a non-owner by-id
11
+ * read (never 403, so ids can't be enumerated), stamp agentId/authorId from
12
+ * the verified identity rather than the request body ("no-forge
13
+ * attribution"). Every allowRead() docstring in those five files literally
14
+ * says "same pattern as X.ts" — this module makes that pattern real code
15
+ * instead of a comment convention that can drift.
16
+ *
17
+ * ─── What this is NOT (scope discipline for slice 1) ─────────────────────
18
+ * This is a PURE REFACTOR extracting what is IDENTICAL across the five
19
+ * existing tables today. It is deliberately conservative, not the full
20
+ * `applyRecordTypeKit(policy)` composition sketched in the record-types
21
+ * design doc's §5 (that shape is for slice 2/3's registry, generating a
22
+ * COMPLETE {allowRead, get, search, post, put, delete} bundle for a
23
+ * brand-new consumer type with no bespoke logic). The five EXISTING tables
24
+ * genuinely diverge in real, load-bearing ways that must be preserved
25
+ * byte-for-byte, not papered over by a one-size bundle:
26
+ * - search()'s query-merge shape differs per table (Memory detaches the
27
+ * transaction and falls through differently on a bare query object than
28
+ * WorkspaceState's in-place mutation than Relationship's nested-wrap);
29
+ * - the no-forge attribution idiom differs not just per table but per
30
+ * METHOD within a table (WorkspaceState.post() unconditionally stamps
31
+ * with no mismatch check at all, while WorkspaceState.put() rejects a
32
+ * mismatch and never stamps — see stampAttribution's mode doc below);
33
+ * - delete() diverges the most: Memory.delete() doesn't even use
34
+ * resolveAgentAuth (raw tpsAgent + isAdmin(), gated on the `permanent`
35
+ * durability flag, no ownership check at all for non-permanent rows);
36
+ * Soul has no delete() override; the other three each fetch the
37
+ * pre-existing record with a different super.get() call signature.
38
+ * - OrgEvent and Soul have NO get()/search() overrides at all (org events
39
+ * and souls are readable by any verified agent, unscoped by owner) —
40
+ * this module does not force them into a scope they never had.
41
+ * Each of those divergences stays inline in its resource file as visible,
42
+ * type-specific business logic. What THIS module extracts is only the
43
+ * genuinely-identical primitives underneath: the auth-gate three-way
44
+ * dispatch, the two read-scope condition/predicate shapes, the no-forge
45
+ * attribution idioms (named and parameterized, not merged into one), and
46
+ * the canonical error-response builders. `buildProvenance` itself
47
+ * (./provenance.ts) is already table-agnostic and is reused as-is — this
48
+ * module does not wrap it, it re-exports it for a single import surface.
49
+ *
50
+ * Design lineage: flair#520 design draft (issue comment, 2026-07-13),
51
+ * Kern's DESIGN REVIEW ("the makeAuthGate/makeReadScope/buildProvenance
52
+ * as-is extraction is the right factoring... each class keeps its
53
+ * type-specific business logic visibly, loses only the copied
54
+ * boilerplate"). `stampEmbedding` (the design's 4th cut-line) is OUT of
55
+ * scope here — no table this module composes writes an embedding today
56
+ * (only Memory does, and Memory's embedding logic is dedup-gate-entangled,
57
+ * not part of the five-table auth/scope/provenance duplication this slice
58
+ * targets); it belongs to the registry/new-capability slice.
59
+ */
60
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
61
+ import { resolveReadScope } from "./memory-read-scope.js";
62
+ import { buildProvenance } from "./provenance.js";
63
+ export { buildProvenance };
64
+ // ─── Canonical error responses ─────────────────────────────────────────────
65
+ // Byte-for-byte the same bodies every one of the five files hand-rolled
66
+ // (Memory/Relationship/WorkspaceState/OrgEvent each defined their own
67
+ // FORBIDDEN/UNAUTH/NOT_FOUND consts identically; Soul only needed
68
+ // FORBIDDEN/UNAUTH). Header-key casing is normalized here to "Content-Type"
69
+ // — HTTP header names are case-insensitive (and Harper/undici's Headers
70
+ // object normalizes on read), so this is not an observable behavior change
71
+ // from the two files (Memory.ts, Relationship.ts) whose inline copies used
72
+ // a lowercase "content-type" key.
73
+ export const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
74
+ export const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
75
+ export const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
76
+ // ─── (a) Identity gating — makeAuthGate ────────────────────────────────────
77
+ /**
78
+ * Produces the `allowRead()` override every one of the five tables hand-
79
+ * wrote identically:
80
+ * `allowRead() { return allowVerified((this as any).getContext?.()); }`
81
+ * Self-authorizes now that the global gate is non-rejecting (the
82
+ * memory-soul-read-gate family fix): Harper routes `GET /<Table>/<id>` to
83
+ * get() and the collection describe (`GET /<Table>`) to a path outside
84
+ * search(), so neither was gated before that fix landed — closes the same
85
+ * P0 leak (an anonymous caller getting a 200 with full record content) for
86
+ * every table that composes this. Per-record/collection scoping is still
87
+ * each table's own get()/search() responsibility below this gate.
88
+ *
89
+ * `allowVerified()` itself (permit verified agents, admins/super_user, and
90
+ * trusted internal calls; deny anonymous HTTP) already lives in
91
+ * ./agent-auth.ts and is reused unmodified — this factory exists only to
92
+ * remove the identical one-line method body duplicated five times, not to
93
+ * change what it does.
94
+ *
95
+ * MUST be composed as a genuine prototype method, never a class-field
96
+ * assignment (`allowRead = makeAuthGate();`). Harper's Table.js has a code
97
+ * path (`relatedTable.prototype.allowRead.call(null, user, property,
98
+ * context)`, used when a query's `select` traverses a GraphQL relationship
99
+ * into one of these tables under Harper's native attribute-level RBAC) that
100
+ * reads `allowRead` off the CLASS PROTOTYPE directly, not off a resource
101
+ * instance. A class field is an own-instance property — invisible to that
102
+ * lookup — so a class-field assignment would silently fall back to Harper's
103
+ * default RBAC-based `allowRead` for that one relationship-traversal path
104
+ * while every direct/primary call (`this.allowRead(...)`, always
105
+ * instance-invoked) kept working, making the gap easy to miss. Every
106
+ * caller of this factory MUST wire it as `allowRead() { return
107
+ * gate.call(this); }` (see Memory.ts/Relationship.ts/WorkspaceState.ts/
108
+ * OrgEvent.ts/Soul.ts for the exact pattern), not `allowRead =
109
+ * makeAuthGate();`.
110
+ */
111
+ export function makeAuthGate() {
112
+ return function allowRead() {
113
+ return allowVerified(this.getContext?.());
114
+ };
115
+ }
116
+ export async function resolveAuthGate(ctx, denyResponse) {
117
+ const auth = await resolveAgentAuth(ctx);
118
+ if (auth.kind === "anonymous")
119
+ return { kind: "denied", response: denyResponse };
120
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
121
+ return { kind: "unfiltered" };
122
+ return { kind: "scoped", agentId: auth.agentId };
123
+ }
124
+ /** owner-only: `agentId === authAgentId`, full stop — the model
125
+ * Relationship.ts and WorkspaceState.ts hand-rolled identically for their
126
+ * get()/search() overrides (`{attribute: ownerField, comparator: "equals",
127
+ * value: authAgentId}` / `record[ownerField] !== authAgentId`). No
128
+ * visibility field, no org-open exception — a real, legitimate read-scope
129
+ * shape in its own right (per Kern/Sherlock's review), not a lesser
130
+ * version of Memory's model. */
131
+ function ownerOnlyReadScope(ownerField) {
132
+ return async (authAgentId) => ({
133
+ condition: { attribute: ownerField, comparator: "equals", value: authAgentId },
134
+ isAllowed: (record) => !!record && record[ownerField] === authAgentId,
135
+ });
136
+ }
137
+ /**
138
+ * Resolve the read-scope resolver for a table, by mode:
139
+ * - "owner-only": own records only, scoped on `ownerField` (defaults to
140
+ * "agentId" — every current owner-only caller uses that field; the
141
+ * parameter exists so a future owner-only type using a different field,
142
+ * the way OrgEvent's WRITE path uses "authorId", isn't hardcoded out).
143
+ * - "open-within-org": delegates to the EXACT existing
144
+ * resolveReadScope()/PRIVATE_VISIBILITY semantics in
145
+ * ./memory-read-scope.ts — Memory's own module, untouched, not
146
+ * reimplemented here. `ownerField` is ignored for this mode:
147
+ * resolveReadScope() is hardcoded to "agentId" (the only table that
148
+ * uses this mode today), matching the task's instruction to delegate to
149
+ * that module "as the exact existing" implementation rather than
150
+ * generalizing it in this slice.
151
+ *
152
+ * The returned resolver is tagged with its own `.mode`/`.ownerField` (record-
153
+ * types slice 2, flair#520) — harmless own-properties on the function object,
154
+ * never read by any caller of the resolver itself in production. Pinned by
155
+ * test/unit/record-type-kit.test.ts directly (mock-free — this primitive
156
+ * touches no `databases` mock). The registry's own drift tripwire
157
+ * (test/unit/record-types-registry.test.ts) verifies the five resource files
158
+ * draw their kit parameters from RECORD_TYPES via a source-text scan instead
159
+ * of importing them (see that test file's header for why — importing
160
+ * multiple of Memory.ts/Relationship.ts/WorkspaceState.ts together risks a
161
+ * cross-file Harper-mock module-cache collision); this tagging exists so a
162
+ * FUTURE single-resource-file behavior test (the pattern every existing
163
+ * table's own read-gate test already uses in isolation) can also assert
164
+ * `<table>ReadScope.mode === RECORD_TYPES.<Table>.readScope` directly against
165
+ * a live, exported resolver without re-deriving it.
166
+ */
167
+ export function makeReadScope(mode, ownerField = "agentId") {
168
+ const resolve = mode === "open-within-org"
169
+ ? (authAgentId) => resolveReadScope(authAgentId)
170
+ : ownerOnlyReadScope(ownerField);
171
+ return Object.assign(resolve, { mode, ownerField });
172
+ }
173
+ /**
174
+ * By-id read-gate factory for get() overrides that scope a single-record
175
+ * read the same way search() scopes a collection read (Memory.ts,
176
+ * Relationship.ts, WorkspaceState.ts — NOT OrgEvent/Soul, which have no
177
+ * get() override at all and are intentionally left alone, see file header).
178
+ * Never distinguishes "doesn't exist" from "exists but not yours" — both
179
+ * return 404, never 403, so a denied caller can't use get() to enumerate
180
+ * other agents' record ids.
181
+ *
182
+ * `superGet` is a caller-supplied closure so the class's own `super.get()`
183
+ * (which cannot be referenced from outside the class body) stays exactly
184
+ * where it was.
185
+ */
186
+ export function makeByIdReadGate(readScope) {
187
+ return async function byIdReadGate(target, superGet) {
188
+ // Collection / query reads arrive as a RequestTarget with
189
+ // `isCollection === true`, and are governed by search() (same owner
190
+ // scoping). Only a genuine by-id get is ownership-checked below.
191
+ if (!target || (typeof target === "object" && target.isCollection)) {
192
+ return this.search(target);
193
+ }
194
+ const ctx = this.getContext?.();
195
+ const auth = await resolveAgentAuth(ctx);
196
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
197
+ // this is defense-in-depth if get() is ever reached directly.
198
+ if (auth.kind === "anonymous")
199
+ return NOT_FOUND();
200
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
201
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
202
+ return superGet(target);
203
+ }
204
+ // Non-admin agent: scoped per the table's own read-scope model.
205
+ const record = await superGet(target);
206
+ if (!record)
207
+ return NOT_FOUND();
208
+ const scope = await readScope(auth.agentId);
209
+ if (!scope.isAllowed(record))
210
+ return NOT_FOUND();
211
+ return record;
212
+ };
213
+ }
214
+ export function stampAttribution(auth, content, field, mode, forbiddenMessage) {
215
+ if (auth.kind !== "agent")
216
+ return {}; // internal → always passthrough
217
+ if (auth.isAdmin) {
218
+ if (mode === "stamp-default")
219
+ content[field] ||= auth.agentId;
220
+ // every other mode: admin passthrough, untouched
221
+ return {};
222
+ }
223
+ // non-admin agent
224
+ switch (mode) {
225
+ case "validate-truthy":
226
+ if (content?.[field] && content[field] !== auth.agentId) {
227
+ return { denied: FORBIDDEN(forbiddenMessage) };
228
+ }
229
+ return {};
230
+ case "validate-strict":
231
+ if (content[field] !== auth.agentId) {
232
+ return { denied: FORBIDDEN(forbiddenMessage) };
233
+ }
234
+ return {};
235
+ case "stamp-default":
236
+ content[field] = auth.agentId;
237
+ return {};
238
+ case "stamp-strict":
239
+ if (content?.[field] && content[field] !== auth.agentId) {
240
+ return { denied: FORBIDDEN(forbiddenMessage) };
241
+ }
242
+ content[field] = auth.agentId;
243
+ return {};
244
+ }
245
+ }
246
+ // ─── (d) Provenance wiring ──────────────────────────────────────────────────
247
+ // buildProvenance itself is already table-agnostic (./provenance.ts) and is
248
+ // re-exported above, unmodified, for a single kit import surface. Per
249
+ // Kern/Sherlock's DESIGN REVIEW verdict (Q4): stamp the identical shape
250
+ // everywhere it's wired, never a table-specific format — and per this
251
+ // slice's explicit behavior-preservation bar, do NOT wire it onto
252
+ // WorkspaceState/OrgEvent, which don't stamp it today (only Memory and
253
+ // Relationship do). See Memory.ts/Relationship.ts for the call sites.