@tpsdev-ai/flair 0.22.0 → 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
  }
@@ -70,7 +70,6 @@ function resolveDeps(deps) {
70
70
  ledgerDeps: deps.ledgerDeps ?? {},
71
71
  batchDelayMs: deps.batchDelayMs ?? resolveTestBatchDelayMs() ?? 100,
72
72
  initiator: deps.initiator ?? "auto",
73
- headroomFloor: deps.headroomFloor,
74
73
  };
75
74
  }
76
75
  // ─── space/snapshot sizing heuristics (overridable via RunnerDeps in future if ever needed) ──
@@ -256,11 +255,11 @@ async function runOneMigration(migration, envelope, deps, lock) {
256
255
  }
257
256
  const estSnapshot = estimateSnapshotBytes(posture.snapshotScope, initialRemaining);
258
257
  const estWorkingSet = estimateWorkingSetBytes(migration.riskClass, initialRemaining);
259
- let space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet, headroomFloor: deps.headroomFloor }, deps.spaceProbe);
258
+ let space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
260
259
  if (!space.ok) {
261
260
  // ── Ladder step 2: prune snapshots, then retry ──
262
261
  pruneMigrationSnapshots(deps.snapshotRoot);
263
- space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet, headroomFloor: deps.headroomFloor }, deps.spaceProbe);
262
+ space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
264
263
  }
265
264
  if (!space.ok) {
266
265
  // ── Ladder step 5: no safe path → halt-don't-brick ──
@@ -13,9 +13,60 @@
13
13
  * `estimateWorkingSetBytes` below is the caller's job (the runner computes
14
14
  * it per risk class); this module just evaluates the estimate against real
15
15
  * (or test-overridden) disk free/total space.
16
+ *
17
+ * flair#720 rewrote the rule this module enforces. The original shape
18
+ * (`projectedUsedFraction <= 0.9`, measured against TOTAL disk size) was
19
+ * designed for a flair-dedicated volume, but on a general-purpose machine
20
+ * — a personal Mac especially, where APFS purgeable space makes
21
+ * `statfs.bavail` understate real availability — the system volume
22
+ * routinely sits above 90% used already, so the fraction test failed
23
+ * before a single byte was written, regardless of how trivially the
24
+ * migration's own footprint fit (flair#720: 220 KB needed, 18.6 GB free,
25
+ * halted anyway). The fix keeps invariant III's "never fill past a
26
+ * cushion" intent but measures the migration's OWN impact plus an
27
+ * absolute reserve, instead of the disk's pre-existing fullness:
28
+ * `ok = neededBytes <= freeBytes AND (freeBytes - neededBytes) >= reserve`.
29
+ * See `RESERVE_MIN_BYTES` / `RESERVE_MAX_BYTES` / `RESERVE_FRACTION` below
30
+ * for how `reserve` is derived.
16
31
  */
17
32
  import { statfsSync } from "node:fs";
18
- export const DEFAULT_HEADROOM_FLOOR = 0.9; // never project past 90% used
33
+ /**
34
+ * Reserve floor: on any disk, no migration may leave less than this much
35
+ * free afterward, even on a tiny volume where 5% of total would be less.
36
+ * 256 MiB is comfortably more than any single migration's own working set
37
+ * in this codebase (the largest estimate is embedding/HNSW rebuild
38
+ * overhead, a few KB per row) — it exists to leave room for Harper's own
39
+ * ordinary operation (WAL, temp files, log growth), not the migration
40
+ * itself.
41
+ */
42
+ export const RESERVE_MIN_BYTES = 256 * 1024 * 1024; // 256 MiB
43
+ /**
44
+ * Reserve cap: on a large disk, 5% of total would be an unreasonably large
45
+ * cushion to demand (e.g. 50 GB on a 1 TB drive) for what is, worst case,
46
+ * a few GB of migration working set. 2 GiB caps the reserve so the rule
47
+ * stays proportionate to what migrations actually need, not the disk's
48
+ * raw size.
49
+ */
50
+ export const RESERVE_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
51
+ /**
52
+ * Between the floor and the cap, the reserve scales with disk size: 5% of
53
+ * total. This is the "proportionate cushion" middle ground — big enough to
54
+ * matter on a mid-size disk, small enough not to dominate on either
55
+ * extreme (RESERVE_MIN_BYTES / RESERVE_MAX_BYTES clamp the two ends).
56
+ */
57
+ export const RESERVE_FRACTION = 0.05;
58
+ /**
59
+ * Production escape hatch for constrained deployments (flair#720) — e.g. a
60
+ * small dedicated volume where even the 256 MiB floor is more cushion than
61
+ * the operator can spare. When set to a finite, non-negative number, this
62
+ * overrides the computed reserve entirely (including the floor/cap clamp
63
+ * — set it to 0 to disable the reserve check outright, keeping only the
64
+ * `neededBytes <= freeBytes` fit test). Invalid values (non-numeric,
65
+ * negative, NaN, Infinity) are ignored and the computed reserve is used
66
+ * instead — mirrors TEST_FREE_BYTES_ENV's validation below. Unset (the
67
+ * default) in every normal deployment.
68
+ */
69
+ export const RESERVE_BYTES_ENV = "FLAIR_MIGRATION_RESERVE_BYTES";
19
70
  /**
20
71
  * Test-only escape hatch: when set, overrides the real statfs-reported free
21
72
  * byte count. Used by the halt-on-blocked-space integration test (real
@@ -45,29 +96,72 @@ export const defaultSpaceProbe = {
45
96
  getTotalBytes: realGetTotalBytes,
46
97
  };
47
98
  /**
48
- * Evaluates the pre-flight space check. Fails (ok:false) if the needed
49
- * bytes don't fit in free space OR if consuming them would project disk
50
- * usage past the headroom floor — matching invariant III's "never fill past
51
- * ~90%" rule exactly (a technically-fitting migration that would still push
52
- * the disk to 95% full is refused, same as one that doesn't fit at all).
99
+ * Resolves the absolute reserve a migration must leave free afterward:
100
+ * `RESERVE_BYTES_ENV` if set to a valid (finite, >= 0) value, else
101
+ * `clamp(RESERVE_FRACTION * totalBytes, RESERVE_MIN_BYTES, RESERVE_MAX_BYTES)`.
102
+ * Exported so tests can exercise the clamp/override logic directly without
103
+ * going through a full `checkSpace` call.
104
+ */
105
+ export function resolveReserveBytes(totalBytes, env = process.env) {
106
+ const override = env[RESERVE_BYTES_ENV];
107
+ if (override !== undefined) {
108
+ const n = Number(override);
109
+ if (Number.isFinite(n) && n >= 0)
110
+ return n;
111
+ }
112
+ const fractional = totalBytes * RESERVE_FRACTION;
113
+ return Math.min(RESERVE_MAX_BYTES, Math.max(RESERVE_MIN_BYTES, fractional));
114
+ }
115
+ /**
116
+ * Bytes → human readable (binary-based thresholds, decimal labels — same
117
+ * convention as src/render.ts's `humanBytes`, which this module can't
118
+ * import: resources/**\/*.ts and src/**\/*.ts are separate TypeScript
119
+ * project boundaries (tsconfig.json vs tsconfig.cli.json/tsconfig.check.src.json)
120
+ * — resources/ is Harper-loaded component code and never imports from the
121
+ * CLI's src/ tree. Small enough to duplicate locally rather than restructure
122
+ * the module boundary for one formatter.
123
+ */
124
+ export function humanBytes(n) {
125
+ if (!Number.isFinite(n) || n < 0)
126
+ return "—";
127
+ if (n < 1024)
128
+ return `${n} B`;
129
+ if (n < 1024 * 1024)
130
+ return `${(n / 1024).toFixed(1)} KB`;
131
+ if (n < 1024 * 1024 * 1024)
132
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
133
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
134
+ }
135
+ /**
136
+ * Evaluates the pre-flight space check (flair#720's rule): fails (ok:false)
137
+ * if the needed bytes don't fit in free space outright, OR if spending them
138
+ * would leave less than `reserveBytes` free afterward. Unlike the rule this
139
+ * replaced, fullness that already exists on the disk before the migration
140
+ * runs is irrelevant — only the migration's OWN impact on free space is
141
+ * judged, against an absolute (not fraction-of-total) cushion.
53
142
  */
54
143
  export function checkSpace(input, probe = defaultSpaceProbe) {
55
- const floor = input.headroomFloor ?? DEFAULT_HEADROOM_FLOOR;
56
144
  const freeBytes = probe.getFreeBytes(input.dataDir);
57
145
  const totalBytes = probe.getTotalBytes(input.dataDir);
58
146
  const neededBytes = input.estimatedSnapshotBytes + input.estimatedWorkingSetBytes;
59
- const usedBytesNow = Math.max(0, totalBytes - freeBytes);
60
- const projectedUsedBytes = usedBytesNow + neededBytes;
61
- const projectedUsedFraction = totalBytes > 0 ? projectedUsedBytes / totalBytes : 1;
147
+ const reserveBytes = resolveReserveBytes(totalBytes);
62
148
  const fits = neededBytes <= freeBytes;
63
- const withinFloor = projectedUsedFraction <= floor;
64
- const ok = fits && withinFloor;
149
+ const remainingAfter = freeBytes - neededBytes;
150
+ const withinReserve = fits && remainingAfter >= reserveBytes;
151
+ const ok = fits && withinReserve;
65
152
  let reason;
66
- if (!ok) {
153
+ if (!fits) {
154
+ reason =
155
+ `need ${humanBytes(neededBytes)} free (snapshot + migration working set), have ${humanBytes(freeBytes)} — ` +
156
+ `short by ${humanBytes(neededBytes - freeBytes)}. Set ${RESERVE_BYTES_ENV} to lower the required post-migration ` +
157
+ `reserve on constrained deployments (this won't help here — the migration doesn't fit even before any reserve)`;
158
+ }
159
+ else if (!withinReserve) {
67
160
  reason =
68
- `need ${neededBytes} bytes free (snapshot + migration working set), have ${freeBytes}; ` +
69
- `proceeding would exceed the ${Math.round(floor * 100)}% disk headroom floor — ` +
70
- `prune old snapshots (flair keeps last-3/30-day) or set FLAIR_SNAPSHOT_DIR to a volume with more room`;
161
+ `need ${humanBytes(neededBytes)} free (snapshot + migration working set); have ${humanBytes(freeBytes)}, ` +
162
+ `which covers it but would leave only ${humanBytes(remainingAfter)} free afterwardshort of the ` +
163
+ `${humanBytes(reserveBytes)} minimum reserve required post-migration. Set ${RESERVE_BYTES_ENV} to a lower ` +
164
+ `byte count to shrink the required reserve on constrained deployments`;
71
165
  }
72
- return { ok, freeBytes, totalBytes, neededBytes, projectedUsedFraction, reason };
166
+ return { ok, freeBytes, totalBytes, neededBytes, reserveBytes, reason };
73
167
  }
@@ -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.