@tpsdev-ai/flair 0.51.2 → 0.53.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.
Files changed (74) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1037 -566
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/launchd-repair.js +198 -0
  10. package/dist/lib/stabilize-mqtt-network.js +123 -0
  11. package/dist/lib/upgrade-exec-path.js +257 -0
  12. package/dist/lib/upgrade-plain-tree.js +558 -0
  13. package/dist/rem/promote-policy.js +204 -0
  14. package/dist/rem/restore.js +55 -15
  15. package/dist/rem/runner.js +203 -20
  16. package/dist/resources/AdminMemory.js +2 -1
  17. package/dist/resources/AgentSeed.js +26 -10
  18. package/dist/resources/Asset.js +203 -0
  19. package/dist/resources/AutoPromoteCandidates.js +2 -4
  20. package/dist/resources/Credential.js +14 -0
  21. package/dist/resources/Federation.js +80 -0
  22. package/dist/resources/Integration.js +12 -0
  23. package/dist/resources/Memory.js +158 -60
  24. package/dist/resources/MemoryBootstrap.js +63 -20
  25. package/dist/resources/MemoryCandidate.js +12 -0
  26. package/dist/resources/MemoryConsolidate.js +2 -1
  27. package/dist/resources/MemoryDedupStats.js +17 -2
  28. package/dist/resources/MemoryFeed.js +30 -0
  29. package/dist/resources/MemoryGrant.js +14 -0
  30. package/dist/resources/MemoryReflect.js +75 -17
  31. package/dist/resources/Message.js +190 -0
  32. package/dist/resources/OrgEvent.js +12 -0
  33. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  34. package/dist/resources/RecordUsage.js +1 -1
  35. package/dist/resources/Relationship.js +12 -0
  36. package/dist/resources/SemanticSearch.js +45 -13
  37. package/dist/resources/Soul.js +54 -18
  38. package/dist/resources/WorkspaceState.js +12 -0
  39. package/dist/resources/auth-middleware.js +17 -44
  40. package/dist/resources/authority-field-guard.js +37 -0
  41. package/dist/resources/bm25-index-service.js +1 -1
  42. package/dist/resources/bm25-index.js +50 -11
  43. package/dist/resources/embedding-space-guard.js +238 -0
  44. package/dist/resources/embeddings-provider.js +32 -5
  45. package/dist/resources/federation-classify.js +23 -1
  46. package/dist/resources/health.js +11 -2
  47. package/dist/resources/hit-tracking.js +244 -0
  48. package/dist/resources/mcp-tools.js +272 -7
  49. package/dist/resources/memory-reflect-lib.js +111 -0
  50. package/dist/resources/migrations/embedding-stamp.js +22 -4
  51. package/dist/resources/owner-field-guard.js +62 -0
  52. package/dist/resources/promotion-stamp.js +29 -0
  53. package/dist/resources/record-owner-guard.js +71 -5
  54. package/dist/resources/record-types.js +30 -7
  55. package/dist/resources/relay-lib.js +205 -0
  56. package/dist/resources/relay-ops.js +294 -0
  57. package/dist/resources/skill-write.js +120 -0
  58. package/dist/resources/soul-adk-guard.js +68 -0
  59. package/dist/resources/soul-write-policy.js +63 -0
  60. package/dist/resources/table-helpers.js +2 -0
  61. package/dist/resources/usage-recording.js +3 -3
  62. package/dist/src/rem/promote-policy.js +204 -0
  63. package/docs/api-reference.md +374 -0
  64. package/docs/auth.md +52 -0
  65. package/docs/federation.md +4 -0
  66. package/docs/integrations.md +6 -6
  67. package/docs/mcp-clients.md +16 -1
  68. package/docs/releasing.md +11 -8
  69. package/docs/rem.md +20 -2
  70. package/docs/upgrade.md +47 -2
  71. package/package.json +6 -5
  72. package/schemas/memory.graphql +51 -2
  73. package/schemas/message.graphql +74 -0
  74. package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * relay-ops.ts — Flair Relay S1 orchestration (send / inbox / consume / sweep /
3
+ * dead-letter). Pure of Harper: every external dependency (the Message table,
4
+ * the Agent table, org resolution) is INJECTED, so the full behavioural surface
5
+ * is unit-testable with plain mocks and real ed25519 keys — no Harper instance,
6
+ * and none of the bun-single-process superclass-capture hazard that mocking the
7
+ * `harper` module invites. resources/Message.ts wires the real accessors in.
8
+ *
9
+ * All authorization decisions are made here against an already-resolved auth
10
+ * verdict; the resource layer only resolves the verdict and forwards the call.
11
+ */
12
+ import { capDecision, computeContentHash, isUnconsumed, LOCAL_ORG_SENTINEL, reconcileState, sweepDecision, verifyMessageSignature, } from "./relay-lib.js";
13
+ // ─── HTTP responses (Response is a web global, not a Harper import) ──────────
14
+ const json = (status, obj) => new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
15
+ const UNAUTH = () => json(401, { error: "authentication required" });
16
+ const FORBIDDEN = (error) => json(403, { error });
17
+ const NOT_FOUND = () => json(404, { error: "not found" });
18
+ const BAD_REQUEST = (error) => json(400, { error });
19
+ /** A per-(from, threadId) sequence conflict — the seq is not strictly ahead of
20
+ * the thread's last message (Sherlock P1 seq monotonicity). */
21
+ const CONFLICT = (error) => json(409, { error, reason: "seq_conflict" });
22
+ /** Over-cap: a synchronous 4xx that names the reason — backpressure that fails loud. */
23
+ const INBOX_FULL = (scope) => json(429, { error: "inbox full", reason: "inbox_full", scope });
24
+ // ─── Helpers ────────────────────────────────────────────────────────────────
25
+ /**
26
+ * Materialize a table.search() result (async iterable | iterable | promise) to
27
+ * an array. `conditions` are passed to search() so a real Harper backend uses
28
+ * the @indexed columns (`to`/`from`/`threadId`/`state` in schemas/message.graphql)
29
+ * instead of a full-table scan — the Sherlock P0 fix: the cap-counter is the
30
+ * DoS-preventer, so it must not itself be O(table). The in-code filters at each
31
+ * call site remain the correctness guarantee (the unit-test table double ignores
32
+ * the query and returns every row); the conditions are the production index hint.
33
+ */
34
+ async function queryRows(table, conditions = []) {
35
+ const query = conditions.length > 0
36
+ ? { operator: "and", conditions: conditions.map((c) => ({ attribute: c.attribute, comparator: "equals", value: c.value })) }
37
+ : undefined;
38
+ const res = table.search(query);
39
+ const iter = res && typeof res.then === "function" ? await res : res;
40
+ const out = [];
41
+ for await (const row of iter)
42
+ out.push(row);
43
+ return out;
44
+ }
45
+ // `from` is REQUIRED: it is inside the SIGNED body, so it must be present BEFORE
46
+ // signature verification and can never be server-mutated first (Sherlock P1 —
47
+ // a mutated `from` would be signed-without / verified-with). A non-admin agent's
48
+ // `from` is enforced-equal to its authenticated id (no overwrite), not defaulted.
49
+ const REQUIRED_SEND_FIELDS = ["id", "from", "to", "threadId", "kind", "body", "createdAt", "signature"];
50
+ /** The stable "accepted" projection — identical whether or not `to` exists. */
51
+ function acceptedView(row) {
52
+ return {
53
+ id: row.id,
54
+ orgScope: row.orgScope,
55
+ from: row.from,
56
+ to: row.to,
57
+ threadId: row.threadId,
58
+ seq: row.seq,
59
+ kind: row.kind,
60
+ createdAt: row.createdAt,
61
+ contentHash: row.contentHash,
62
+ state: row.state,
63
+ deliveredAt: row.deliveredAt,
64
+ };
65
+ }
66
+ // ─── send ───────────────────────────────────────────────────────────────────
67
+ /**
68
+ * post(msg). Verifies the sender signature, resolves orgScope server-side,
69
+ * enforces the no-forge `from`, dedups retries, enforces the inbox cap + the
70
+ * per-sender sub-cap, and delivers. Returns the accepted envelope — the SAME
71
+ * shape regardless of whether `to` exists (no existence oracle): the only
72
+ * principal ever looked up is the sender (needed for its public key), which is
73
+ * the authenticated caller and therefore always present.
74
+ */
75
+ export async function relaySend(deps, auth, content) {
76
+ if (auth.kind === "anonymous")
77
+ return UNAUTH();
78
+ for (const f of REQUIRED_SEND_FIELDS) {
79
+ if (content[f] === undefined || content[f] === null || content[f] === "") {
80
+ return BAD_REQUEST(`missing required field: ${f}`);
81
+ }
82
+ }
83
+ if (typeof content.seq !== "number" || !Number.isInteger(content.seq) || content.seq < 0) {
84
+ return BAD_REQUEST("seq must be a non-negative integer");
85
+ }
86
+ // Validate `deadline` as a parseable ISO-8601 timestamp at send time. Left
87
+ // unvalidated, a garbage string yields NaN in sweepDecision, `NaN > now` is
88
+ // false, and the row sweeps to failed/deadline IMMEDIATELY (Kern nit). Absent
89
+ // is fine (no deadline); present-but-unparseable is a 400.
90
+ if (content.deadline !== undefined && content.deadline !== null) {
91
+ if (typeof content.deadline !== "string" || Number.isNaN(new Date(content.deadline).getTime())) {
92
+ return BAD_REQUEST("deadline must be a valid ISO-8601 timestamp");
93
+ }
94
+ }
95
+ // No-forge `from`: a non-admin agent can only send AS itself. `from` is a
96
+ // REQUIRED, SIGNED field — we ENFORCE equality (never overwrite, which would
97
+ // invalidate the signature) and NEVER mutate it before verification (a
98
+ // server-set `from` would be signed-without / verified-with — Sherlock P1).
99
+ if (auth.kind === "agent" && !auth.isAdmin && content.from !== auth.agentId) {
100
+ return FORBIDDEN("forbidden: `from` must match the authenticated agent");
101
+ }
102
+ // orgScope is server-resolved from the auth/pairing context and NEVER trusted
103
+ // from the body. It is inside the signed body, so the caller must have signed
104
+ // the correct org; any other value is a forged/foreign scope and is rejected.
105
+ const resolvedOrg = (await deps.resolveOrg()) ?? LOCAL_ORG_SENTINEL;
106
+ if (content.orgScope !== resolvedOrg) {
107
+ return FORBIDDEN("forbidden: orgScope is server-resolved, not settable from the body");
108
+ }
109
+ // Verify the sender's signature against the sender's pinned public key.
110
+ // `from` is guaranteed present by REQUIRED_SEND_FIELDS above (String() only
111
+ // narrows the optional type — it can never be undefined here).
112
+ const sender = await Promise.resolve(deps.agents.get(String(content.from))).catch(() => null);
113
+ if (!sender?.publicKey)
114
+ return FORBIDDEN("forbidden: unknown sender principal");
115
+ const verdict = verifyMessageSignature(content, String(sender.publicKey));
116
+ if (!verdict.ok)
117
+ return BAD_REQUEST(`signature: ${verdict.reason}`);
118
+ // Store the server-recomputed canonical hash (verified equal above).
119
+ content.contentHash = computeContentHash(content);
120
+ // Retry-dedup: an identical resend — same primary id, or the same content
121
+ // hash from this sender to this recipient — returns the already-accepted
122
+ // envelope. No second row, and (critically) no second charge against the cap.
123
+ const existingById = await Promise.resolve(deps.messages.get(String(content.id))).catch(() => null);
124
+ if (existingById)
125
+ return acceptedView(existingById);
126
+ // The recipient's inbox — queried by the @indexed `to`, never the whole table
127
+ // (Sherlock P0). Serves both dedup (same content from this sender) and the cap.
128
+ const recipientRows = await queryRows(deps.messages, [{ attribute: "to", value: content.to }]);
129
+ const dup = recipientRows.find((r) => r.from === content.from && r.to === content.to && r.contentHash === content.contentHash);
130
+ if (dup)
131
+ return acceptedView(dup);
132
+ // Seq monotonicity per (from, threadId): a distinct new message must be
133
+ // STRICTLY ahead of the thread's last seq (Sherlock P1). Checked AFTER dedup so
134
+ // a legitimate retry (same content, caught above) is never rejected. Queried by
135
+ // the @indexed `threadId`, then filtered to this sender in-code.
136
+ const threadRows = await queryRows(deps.messages, [{ attribute: "threadId", value: content.threadId }]);
137
+ let prevMaxSeq = -1;
138
+ for (const r of threadRows) {
139
+ if (r.from !== content.from || r.threadId !== content.threadId)
140
+ continue;
141
+ if (typeof r.seq === "number" && r.seq > prevMaxSeq)
142
+ prevMaxSeq = r.seq;
143
+ }
144
+ if (content.seq <= prevMaxSeq) {
145
+ return CONFLICT(`seq ${content.seq} is not ahead of the last seq (${prevMaxSeq}) for this (from, threadId)`);
146
+ }
147
+ // Inbox cap + per-sender sub-cap — AFTER dedup, so a retry is never rejected.
148
+ const counts = { recipientUnconsumed: 0, senderUnconsumed: 0 };
149
+ for (const r of recipientRows) {
150
+ if (r.to !== content.to || !isUnconsumed(r.state))
151
+ continue;
152
+ counts.recipientUnconsumed++;
153
+ if (r.from === content.from)
154
+ counts.senderUnconsumed++;
155
+ }
156
+ const cap = capDecision(counts);
157
+ if (!cap.ok)
158
+ return INBOX_FULL(cap.scope);
159
+ // Strip client-supplied lifecycle fields from the stored row: they are NOT
160
+ // under the signature (SIGNED_BODY_FIELDS excludes them), so a caller could
161
+ // pre-set `state`/`consumedAt`/`failureReason` and have them persist. `state`
162
+ // and `deliveredAt` are overwritten below regardless; consumedAt/failureReason
163
+ // are dropped here (Kern nit — hygiene, no injection today but no drift later).
164
+ const { state: _s, consumedAt: _c, failureReason: _f, deliveredAt: _d, ...clean } = content;
165
+ const now = (deps.now ?? (() => new Date()))().toISOString();
166
+ const row = {
167
+ ...clean,
168
+ kind: content.kind ?? "message",
169
+ state: "delivered",
170
+ deliveredAt: now,
171
+ };
172
+ await deps.messages.put(row);
173
+ return acceptedView(row);
174
+ }
175
+ // ─── inbox ──────────────────────────────────────────────────────────────────
176
+ /**
177
+ * inbox(to). Unconsumed messages addressed to a principal, sorted by `seq`
178
+ * (per §12 P1-5 — createdAt collides at ms). A non-admin agent may only read
179
+ * its OWN inbox; admin/internal may read any.
180
+ */
181
+ export async function relayInbox(deps, auth, requestedTo) {
182
+ if (auth.kind === "anonymous")
183
+ return UNAUTH();
184
+ const to = resolveSelfScope(auth, requestedTo);
185
+ if (to instanceof Response)
186
+ return to;
187
+ // Queried by the @indexed `to` — bounded by this inbox, not the table (Sherlock P0).
188
+ const rows = await queryRows(deps.messages, [{ attribute: "to", value: to }]);
189
+ return rows
190
+ .filter((r) => r.to === to && isUnconsumed(r.state))
191
+ .sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
192
+ }
193
+ // ─── consume / ack ────────────────────────────────────────────────────────
194
+ /**
195
+ * consume(id). Marks a message consumed. Only the RECIPIENT may consume its own
196
+ * message; a missing id or another principal's message is an indistinguishable
197
+ * 404 (no enumeration). `consumed` is absorbing — consuming an already-consumed
198
+ * message is an idempotent no-op that returns the row, never a regression.
199
+ */
200
+ export async function relayConsume(deps, auth, id) {
201
+ if (auth.kind === "anonymous")
202
+ return UNAUTH();
203
+ if (!id)
204
+ return BAD_REQUEST("missing message id");
205
+ const row = await Promise.resolve(deps.messages.get(id)).catch(() => null);
206
+ if (!row)
207
+ return NOT_FOUND();
208
+ const isRecipient = auth.kind === "agent" && !auth.isAdmin ? row.to === auth.agentId : true;
209
+ if (!isRecipient)
210
+ return NOT_FOUND();
211
+ if (row.state === "consumed")
212
+ return row; // absorbing — idempotent
213
+ const now = (deps.now ?? (() => new Date()))().toISOString();
214
+ const updated = { ...row, state: reconcileState(row.state, "consumed"), consumedAt: now };
215
+ await deps.messages.put(updated);
216
+ return updated;
217
+ }
218
+ // ─── dead-letter ────────────────────────────────────────────────────────────
219
+ /**
220
+ * The visible dead-letter: `failed` messages queryable by the SENDER (from ===
221
+ * caller), each carrying its failureReason. A non-admin sees only its own
222
+ * failures; admin/internal may query any sender.
223
+ */
224
+ export async function relayDeadLetters(deps, auth, requestedFrom) {
225
+ if (auth.kind === "anonymous")
226
+ return UNAUTH();
227
+ const from = resolveSelfScope(auth, requestedFrom);
228
+ if (from instanceof Response)
229
+ return from;
230
+ // Queried by the @indexed `from` + `state` — the sender's failed rows only,
231
+ // never a full-table scan (Sherlock P0).
232
+ const rows = await queryRows(deps.messages, [
233
+ { attribute: "from", value: from },
234
+ { attribute: "state", value: "failed" },
235
+ ]);
236
+ return rows
237
+ .filter((r) => r.from === from && r.state === "failed")
238
+ .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
239
+ }
240
+ // ─── deadline sweep ─────────────────────────────────────────────────────────
241
+ /**
242
+ * Deadline sweep. Unconsumed past-deadline messages transition to a VISIBLE
243
+ * `failed`/`deadline` state (the inverse of OrgEvent's silent expiry). Consumed
244
+ * rows are NEVER regressed (absorbing). Admin/internal only — it is a
245
+ * maintenance operation over the whole table.
246
+ */
247
+ export async function relaySweepDeadlines(deps, auth) {
248
+ if (auth.kind === "anonymous")
249
+ return UNAUTH();
250
+ if (auth.kind === "agent" && !auth.isAdmin) {
251
+ return FORBIDDEN("forbidden: deadline sweep is an administrative operation");
252
+ }
253
+ const now = (deps.now ?? (() => new Date()))();
254
+ // Only UNCONSUMED rows can sweep to failed — query the two unconsumed states
255
+ // by the @indexed `state` and union by id, instead of scanning the whole table
256
+ // (Sherlock P0). The id-dedup also makes this correct if a backend (or the
257
+ // test double) returns overlapping rows across the two state queries.
258
+ const byId = new Map();
259
+ for (const st of ["submitted", "delivered"]) {
260
+ for (const r of await queryRows(deps.messages, [{ attribute: "state", value: st }])) {
261
+ if (r?.id != null)
262
+ byId.set(String(r.id), r);
263
+ }
264
+ }
265
+ const rows = [...byId.values()];
266
+ let failed = 0;
267
+ for (const row of rows) {
268
+ const outcome = sweepDecision(row, now);
269
+ if (!outcome.fail)
270
+ continue;
271
+ // reconcileState guards the absorbing rule a second time: even if `row`
272
+ // were consumed, "failed" could never win — belt and suspenders.
273
+ const next = reconcileState(row.state, "failed");
274
+ if (next !== "failed")
275
+ continue;
276
+ await deps.messages.put({ ...row, state: "failed", failureReason: outcome.failureReason });
277
+ failed++;
278
+ }
279
+ return { failed };
280
+ }
281
+ // ─── shared scoping ─────────────────────────────────────────────────────────
282
+ /** A non-admin is pinned to its own id; admin/internal may target any id. */
283
+ function resolveSelfScope(auth, requested) {
284
+ if (auth.kind === "agent" && !auth.isAdmin) {
285
+ if (requested && requested !== auth.agentId) {
286
+ return FORBIDDEN("forbidden: can only act on your own messages");
287
+ }
288
+ return auth.agentId;
289
+ }
290
+ // admin or internal
291
+ if (!requested)
292
+ return FORBIDDEN("forbidden: a target principal id is required");
293
+ return requested;
294
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * skill-write.ts — the WRITE-side skill policy (flair#1542 components 1-3).
3
+ *
4
+ * A skill is a Memory tagged "skill" (reuse the substrate — no new table).
5
+ * This module centralizes the three write-side rules that make a skill-tagged
6
+ * Memory behave like a skill:
7
+ *
8
+ * 1. Embedding source: a skill-tagged row embeds from `trigger` (the
9
+ * "when to use" text), NOT from `content` (the full procedure). The
10
+ * recall signal is "when does this skill apply", so the vector must
11
+ * represent the trigger. Non-skill rows are untouched — `skillEmbedText`
12
+ * returns `content` for them, byte-identical to the pre-slice behavior.
13
+ *
14
+ * 2. SkillScan gate: every skill-tagged write is statically scanned
15
+ * (resources/scan/skill-scanner.ts) BEFORE the embedding is computed.
16
+ * Fail-closed on high/critical risk (a rejected write pays no embed);
17
+ * allow-with-flag on medium (the findings are recorded on the row's
18
+ * `_safetyFlags` so the write is auditable, but it is not blocked).
19
+ *
20
+ * 3. Forced durability: a skill MUST be durability=persistent. The 30-day
21
+ * reaper would archive a default-written ("standard") skill, and an
22
+ * ephemeral/session skill is a contradiction in terms — ephemeral/session
23
+ * are rejected outright, and every other value is forced to "persistent".
24
+ *
25
+ * Deliberately ZERO Harper imports — pure functions + constants, so the
26
+ * coverage-gate test and unit tests can import this module without the
27
+ * runtime (same load-bearing reason as memory-durability.ts /
28
+ * memory-visibility.ts).
29
+ */
30
+ import { scanSkillContent } from "./scan/skill-scanner.js";
31
+ /** The tag that marks a Memory as a skill. */
32
+ export const SKILL_TAG = "skill";
33
+ /** Is this write a skill-tagged Memory? */
34
+ export function isSkillWrite(content) {
35
+ return Array.isArray(content?.tags) && content.tags.includes(SKILL_TAG);
36
+ }
37
+ /**
38
+ * The text a row embeds from. Skill-tagged rows embed from `trigger` (the
39
+ * recall signal); every other row embeds from `content`. Returns `undefined`
40
+ * only when there is no usable text at all.
41
+ */
42
+ export function skillEmbedText(content) {
43
+ if (isSkillWrite(content) && typeof content.trigger === "string" && content.trigger.length > 0) {
44
+ return content.trigger;
45
+ }
46
+ return content.content;
47
+ }
48
+ /**
49
+ * Force a skill-tagged write to durability=persistent. Rejects an explicit
50
+ * ephemeral/session durability (a skill that expires is a contradiction);
51
+ * every other value — including the "standard" default and "permanent" — is
52
+ * forced to "persistent" so the 30-day reaper never archives a skill.
53
+ *
54
+ * Returns a 400 Response to short-circuit the write, or null to proceed.
55
+ * `content.durability` is mutated to "persistent" on the proceed path.
56
+ */
57
+ export function enforceSkillDurability(content) {
58
+ if (!isSkillWrite(content))
59
+ return null;
60
+ const d = content.durability;
61
+ if (d === "ephemeral" || d === "session") {
62
+ return new Response(JSON.stringify({
63
+ error: "skill_durability",
64
+ message: "skill memories must be durability=persistent; ephemeral/session rejected",
65
+ }), { status: 400, headers: { "content-type": "application/json" } });
66
+ }
67
+ content.durability = "persistent";
68
+ return null;
69
+ }
70
+ /**
71
+ * Reject a skill-tagged write on a path that does NOT run the SkillScan gate
72
+ * or forced durability (patch, seed, etc.). Skills are written ONLY via
73
+ * skill_store (→ Memory.post) or Memory.put — every other verb rejects a
74
+ * skill-tagged write rather than land it unscanned (the #1537 raw-writer
75
+ * lesson: gate EVERY verb, not just post/put).
76
+ *
77
+ * Returns a 400 Response to short-circuit the write, or null to proceed.
78
+ */
79
+ export function rejectSkillWritePath(content) {
80
+ if (!isSkillWrite(content))
81
+ return null;
82
+ return new Response(JSON.stringify({
83
+ error: "skill_write_path",
84
+ message: "skill memories must be written via skill_store (or Memory post/put); this path does not gate skill writes",
85
+ }), { status: 400, headers: { "content-type": "application/json" } });
86
+ }
87
+ /**
88
+ * SkillScan gate — run BEFORE the embedding is computed. Scans the combined
89
+ * `trigger` + `content` text (a dangerous shell/network payload in EITHER is
90
+ * a rejection). Fail-closed on high/critical risk; allow-with-flag on medium
91
+ * (findings appended to `_safetyFlags`); a clean scan is a no-op.
92
+ *
93
+ * Returns a 400 Response to short-circuit the write, or null to proceed.
94
+ */
95
+ export function skillScanGate(content) {
96
+ if (!isSkillWrite(content))
97
+ return null;
98
+ const parts = [content.trigger, content.content].filter((s) => typeof s === "string" && s.length > 0);
99
+ if (parts.length === 0)
100
+ return null;
101
+ const result = scanSkillContent(parts.join("\n\n"));
102
+ if (result.riskLevel === "high" || result.riskLevel === "critical") {
103
+ return new Response(JSON.stringify({
104
+ error: "skill_scan_rejected",
105
+ riskLevel: result.riskLevel,
106
+ violations: result.violations,
107
+ message: "skill content failed SkillScan (fail-closed on high/critical risk)",
108
+ }), { status: 400, headers: { "content-type": "application/json" } });
109
+ }
110
+ if (result.riskLevel === "medium") {
111
+ // allow-with-flag: record the findings so the write is auditable, but do
112
+ // not block it. `_safetyFlags` is the existing content-safety flag column;
113
+ // skill findings are namespaced `skill:<type>` so they never collide with
114
+ // the content-safety flags that may already be present.
115
+ const flags = result.violations.map((v) => `skill:${v.type}`);
116
+ const existing = Array.isArray(content._safetyFlags) ? content._safetyFlags : [];
117
+ content._safetyFlags = [...existing, ...flags];
118
+ }
119
+ return null;
120
+ }
@@ -0,0 +1,68 @@
1
+ import { ADK_SCOPE_TAG_PREFIX } from "../src/rem/promote-policy.js";
2
+ /** Soul is agentId-scoped and cannot carry a per-user `adk:` tag. Writing an
3
+ * ADK-sourced claim there leaks that user's distilled text to every other
4
+ * user of the shared agentId. CLI refusal is not enough — a scripted
5
+ * PUT /Soul must hit the same door.
6
+ *
7
+ * This vendor-string match is a dated rollout bridge only. Soul authorization
8
+ * is the deny-by-default operator/internal allowlist in soul-write-policy.ts;
9
+ * runtime denial does not depend on `adk:`. Remove this module after
10
+ * ADK_SOUL_REFUSE_KILL_DATE once classified writers are deployed (#1540). */
11
+ export const ADK_SOUL_REFUSAL = "adk_sourced_claim_cannot_be_written_to_soul";
12
+ /** Calendar date (UTC) after which this `adk:` bridge must be removed. */
13
+ export const ADK_SOUL_REFUSE_KILL_DATE = "2026-10-31";
14
+ export function tagLooksAdk(tag) {
15
+ return typeof tag === "string" && tag.toLowerCase().startsWith(ADK_SCOPE_TAG_PREFIX);
16
+ }
17
+ /** Request body carries an ADK scope tag (promotion leftover or forged). */
18
+ export function bodyCarriesAdkScope(content) {
19
+ if (tagLooksAdk(content?.scopeTag))
20
+ return true;
21
+ const tags = content?.tags;
22
+ return Array.isArray(tags) && tags.some(tagLooksAdk);
23
+ }
24
+ export function rowLooksAdkSourced(row) {
25
+ if (!row)
26
+ return false;
27
+ if (tagLooksAdk(row.scopeTag))
28
+ return true;
29
+ return Array.isArray(row.tags) && row.tags.some(tagLooksAdk);
30
+ }
31
+ function adkForbidden() {
32
+ return new Response(JSON.stringify({ error: ADK_SOUL_REFUSAL }), {
33
+ status: 403,
34
+ headers: { "Content-Type": "application/json" },
35
+ });
36
+ }
37
+ async function* searchByAgentId(tableName, agentId) {
38
+ const { databases } = await import("harper");
39
+ const table = databases.flair?.[tableName];
40
+ if (!table?.search)
41
+ return;
42
+ const query = { conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }] };
43
+ yield* table.search(query);
44
+ }
45
+ /** Refuse a Soul write whose value is an ADK-sourced claim. Body tags are
46
+ * sufficient; otherwise match stored MemoryCandidate.claim / Memory.content
47
+ * that already carries an `adk:` scope tag. */
48
+ export async function refuseAdkSourcedSoulWrite(content, lookup = {}) {
49
+ if (bodyCarriesAdkScope(content))
50
+ return adkForbidden();
51
+ const value = typeof content?.value === "string" ? content.value : "";
52
+ const agentId = typeof content?.agentId === "string" ? content.agentId : "";
53
+ if (!value || !agentId)
54
+ return null;
55
+ const candidates = lookup.searchCandidates
56
+ ?? ((id) => searchByAgentId("MemoryCandidate", id));
57
+ for await (const row of candidates(agentId)) {
58
+ if (row?.claim === value && rowLooksAdkSourced(row))
59
+ return adkForbidden();
60
+ }
61
+ const memories = lookup.searchMemories
62
+ ?? ((id) => searchByAgentId("Memory", id));
63
+ for await (const row of memories(agentId)) {
64
+ if (row?.content === value && rowLooksAdkSourced(row))
65
+ return adkForbidden();
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1,63 @@
1
+ import { resolveAgentAuth } from "./agent-auth.js";
2
+ import { FORBIDDEN, UNAUTH } from "./record-type-kit.js";
3
+ import { buildProvenance } from "./provenance.js";
4
+ import { databases } from "harper";
5
+ import { refuseAdkSourcedSoulWrite } from "./soul-adk-guard.js";
6
+ // Role is not source: an admin agent key or delegated OAuth identity is still
7
+ // a runtime credential. Only verified Basic admin auth enters the operator path.
8
+ // Callers cannot choose this class via body fields or connector-supplied labels.
9
+ export function soulWriteSource(context, auth) {
10
+ const request = context?.request ?? context;
11
+ // Internal path needs a deliberate __flairInternal marker; a contextless call is refused.
12
+ if (auth.kind === "internal" && context?.__flairInternal === true && !request?.headers)
13
+ return "internal";
14
+ const header = request?.headers?.get?.("authorization") ?? request?.headers?.asObject?.authorization ?? "";
15
+ if (auth.kind === "agent" && auth.isAdmin && /^Basic\s/i.test(header))
16
+ return "operator";
17
+ return null;
18
+ }
19
+ export async function authorizeSoulWrite(context) {
20
+ const auth = await resolveAgentAuth(context);
21
+ const source = soulWriteSource(context, auth);
22
+ const denied = auth.kind === "anonymous" ? UNAUTH()
23
+ : source ? null : FORBIDDEN("soul_write_requires_operator: use authenticated operator credentials");
24
+ return { auth, source, denied };
25
+ }
26
+ export function soulProvenance(auth, source, now) {
27
+ const provenance = JSON.parse(buildProvenance(auth, now, {}));
28
+ provenance.verified.sourceClass = source;
29
+ return JSON.stringify(provenance);
30
+ }
31
+ export async function refuseLearnedSoulWrite(content) {
32
+ // Generic content-provenance backstop. Stored artifacts need no vendor tag —
33
+ // an exact owner-scoped Memory / MemoryCandidate match is enough.
34
+ if (typeof content?.value !== "string" || !content.value)
35
+ return null;
36
+ if (typeof content.agentId !== "string" || !content.agentId)
37
+ return FORBIDDEN("soul_owner_required");
38
+ for (const [name, field] of [["MemoryCandidate", "claim"], ["Memory", "content"]]) {
39
+ const table = databases.flair[name];
40
+ // Missing tables or failed reads throw before any write, rather than
41
+ // treating unavailable provenance as proof of operator-authored content.
42
+ for await (const row of table.search({
43
+ conditions: [
44
+ { attribute: "agentId", comparator: "equals", value: content.agentId },
45
+ { attribute: field, comparator: "equals", value: content.value },
46
+ ],
47
+ select: ["agentId", field],
48
+ limit: 1,
49
+ })) {
50
+ if (row.agentId === content.agentId && row[field] === content.value)
51
+ return FORBIDDEN("soul_value_is_learned_content");
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ /** Content guards after source authorization: dated vendor-tag bridge, then the
57
+ * generic learned-content backstop. New connectors need no Soul-side branch. */
58
+ export async function refuseSoulWriteContent(content) {
59
+ const adk = await refuseAdkSourcedSoulWrite(content);
60
+ if (adk)
61
+ return adk;
62
+ return refuseLearnedSoulWrite(content);
63
+ }
@@ -4,6 +4,8 @@
4
4
  * Harper's `put()` is FULL RECORD REPLACEMENT. If you pass a partial
5
5
  * object, all missing fields (including embeddings!) are permanently
6
6
  * deleted. This helper ensures you always read the full record first.
7
+ * It does not promise atomic counters — search hit-tracking uses
8
+ * `resources/hit-tracking.ts` (MemoryHitStat) instead of this helper.
7
9
  *
8
10
  * Usage:
9
11
  * import { patchRecord } from "./table-helpers.js";
@@ -75,10 +75,10 @@ export const MAX_USAGE_IDS_PER_CALL = 20;
75
75
  * patchRecord() helper, which would combine both into one un-safe wrap.
76
76
  *
77
77
  * The final get-then-put for the increment is a best-effort (non-atomic)
78
- * read-modify-write, same class of race already accepted elsewhere in
79
- * this codebase for count fields (e.g. retrievalCount's bump in
80
- * SemanticSearch.ts) — a concurrent contribution from a DIFFERENT agent
78
+ * read-modify-write a concurrent contribution from a DIFFERENT agent
81
79
  * landing between this call's read and write could lose one increment.
80
+ * Search hit-tracking is no longer this class of race (flair#1528;
81
+ * resources/hit-tracking.ts coalesces MemoryHitStat increments).
82
82
  * Re-fetching immediately before the write (rather than reusing the
83
83
  * earlier existence-check read) narrows, without eliminating, that
84
84
  * window. Not solved here: bounded, low-severity (an undercount, never an