@tpsdev-ai/flair 0.51.1 → 0.52.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 (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  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/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +13 -8
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -54,6 +54,7 @@
54
54
  */
55
55
  import { Resource, databases } from "harper";
56
56
  import { mkdirSync, writeFileSync } from "node:fs";
57
+ import { setImmediate as yieldToRequests } from "node:timers/promises";
57
58
  import { dirname } from "node:path";
58
59
  import { allowAdmin } from "./agent-auth.js";
59
60
  import { retrieveCandidates } from "./semantic-retrieval-core.js";
@@ -82,11 +83,19 @@ async function computeAndPersist(ctx, opts) {
82
83
  // exactly what this stat is for). Bounded to the most-recently-created
83
84
  // maxMemories when the instance exceeds the safety cap (see
84
85
  // dedup-cluster.ts's doc on why this is a defensive bound, not sampling).
86
+ let yieldAt = performance.now() + 10;
85
87
  const all = [];
86
88
  for await (const record of databases.flair.Memory.search({
87
- conditions: NOT_ARCHIVED,
88
- select: ["id", "embedding", "createdAt"],
89
+ // Harper 5.2.8 starts a standalone not_equal index scan at true,
90
+ // skipping false/missing archive keys. Filter the primary scan instead.
91
+ select: ["id", "embedding", "createdAt", "archived"],
89
92
  })) {
93
+ if (performance.now() >= yieldAt) {
94
+ await yieldToRequests();
95
+ yieldAt = performance.now() + 10;
96
+ }
97
+ if (record.archived === true)
98
+ continue;
90
99
  if (!record?.id || !Array.isArray(record.embedding) || record.embedding.length === 0)
91
100
  continue;
92
101
  all.push({ id: record.id, embedding: record.embedding, createdAt: record.createdAt });
@@ -110,6 +119,12 @@ async function computeAndPersist(ctx, opts) {
110
119
  // at the documented annK, rather than silently examining annK-1.
111
120
  const edges = [];
112
121
  for (const memory of sweepSet) {
122
+ // Awaiting cached table reads can keep draining microtasks indefinitely.
123
+ // Give the HTTP event loop a turn between bounded chunks of ANN work.
124
+ if (performance.now() >= yieldAt) {
125
+ await yieldToRequests();
126
+ yieldAt = performance.now() + 10;
127
+ }
113
128
  let neighbors;
114
129
  try {
115
130
  neighbors = await retrieveCandidates({
@@ -2,8 +2,10 @@ import { Resource, databases } from "harper";
2
2
  import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
3
3
  import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
4
4
  import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
5
+ import { guardAuthorityFields, stripAuthorityFields } from "./authority-field-guard.js";
5
6
  import { assertValidVisibility, assertVisibilityAllowedForDurability, PRIVATE_VISIBILITY } from "./memory-visibility.js";
6
7
  import { assertValidDurability } from "./memory-durability.js";
8
+ import { enforceSkillDurability, skillScanGate } from "./skill-write.js";
7
9
  import { noteMemoryUpsert } from "./bm25-index-service.js";
8
10
  export class FeedMemories extends Resource {
9
11
  // Self-authorize via the Ed25519 agent verify (the auth reshape removes the
@@ -56,6 +58,20 @@ export class FeedMemories extends Resource {
56
58
  headers: { "Content-Type": "application/json" },
57
59
  });
58
60
  }
61
+ // ── flair#1542: skill-tagged writes are gated (SkillScan + forced durability) ──
62
+ // This endpoint writes via the RAW table object below — NOT Memory.post()/
63
+ // put() — so a caller could spread tags:["skill"] + trigger into the raw
64
+ // put and land an unscanned, 30-day-reapable (durability=standard) skill.
65
+ // Run the SAME gate Memory.post() runs, BEFORE the durability default is
66
+ // computed so a forced "persistent" flows into the tier rule below.
67
+ {
68
+ const skillScanDenial = skillScanGate(content);
69
+ if (skillScanDenial)
70
+ return skillScanDenial;
71
+ const skillDurabilityDenial = enforceSkillDurability(content);
72
+ if (skillDurabilityDenial)
73
+ return skillDurabilityDenial;
74
+ }
59
75
  // ── Write-side durability/visibility validation (#1009/#1238/#1257) ─────
60
76
  // This endpoint writes via the RAW table object below — NOT the exported
61
77
  // Memory resource — so it inherits NONE of Memory.post()/put()'s write
@@ -96,6 +112,19 @@ export class FeedMemories extends Resource {
96
112
  });
97
113
  }
98
114
  }
115
+ // ── Authority-field guard (#1524 leftover) ────────────────────────────
116
+ // Same raw-table bypass as the durability/visibility block above:
117
+ // guardAuthorityFields sits on Memory.put/patch/post, not the raw
118
+ // handle. A verified agent could POST {promotionStatus:"approved"}
119
+ // here and land a forged verdict. Refuse a body that sets or changes
120
+ // a stamp, then unconditionally strip before the raw put so even
121
+ // stamps the guard would restore onto an omitted-field update cannot
122
+ // ride a feed write (feed ingest is not a promotion-stamp path).
123
+ {
124
+ const authorityDenial = await guardAuthorityFields(() => content?.id ? databases.flair.Memory.get(content.id) : undefined, content, "Memory");
125
+ if (authorityDenial)
126
+ return authorityDenial;
127
+ }
99
128
  const now = new Date().toISOString();
100
129
  const contentHash = computeContentHash(agentId, body);
101
130
  const existing = await findExistingMemoryByContentHash(databases.flair.Memory.search(), agentId, contentHash);
@@ -125,6 +154,7 @@ export class FeedMemories extends Resource {
125
154
  if (record.durability === "ephemeral" && (record.visibility === undefined || record.visibility === null)) {
126
155
  record.visibility = PRIVATE_VISIBILITY;
127
156
  }
157
+ stripAuthorityFields(record, "Memory");
128
158
  await databases.flair.Memory.put(record);
129
159
  // flair#1357 — raw-table write: hook it explicitly (see bm25-index-service).
130
160
  noteMemoryUpsert(record);
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
3
4
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
5
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
6
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
@@ -93,7 +94,20 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
93
94
  content.createdAt ||= new Date().toISOString();
94
95
  return super.post(content, context);
95
96
  }
97
+ // PATCH routes past put(), so ownerId immutability is enforced on both verbs
98
+ // via the one shared delegate. Only the owner may modify a grant, and not even
99
+ // the owner may re-point ownerId at another principal (a grantee never could —
100
+ // the middleware ownership guard refuses a non-owner mutation first).
101
+ async patch(content, query) {
102
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "ownerId");
103
+ if (denial)
104
+ return denial;
105
+ return super.patch(content, query);
106
+ }
96
107
  async put(content, context) {
108
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "ownerId");
109
+ if (denial)
110
+ return denial;
97
111
  const denied = await this._enforceOwnerWrite(content);
98
112
  if (denied)
99
113
  return denied;
@@ -11,7 +11,8 @@
11
11
  * agentId string — which agent to reflect on
12
12
  * scope string — "recent" | "tagged" | "all" (default: "recent")
13
13
  * since string? — ISO timestamp lower bound (default: 24h ago)
14
- * maxMemories number? — cap (default: 50)
14
+ * maxMemories number? — cap (default: 50, env FLAIR_REM_MAX_MEMORIES,
15
+ * hard ceiling 200). Oldest-unreflected first.
15
16
  * focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" | "continuity"
16
17
  * (default: "lessons_learned"; a continuity-tag run — scope="tagged" with an
17
18
  * adk:continuity:* tag — always uses "continuity", flair#1257 slice 3)
@@ -42,9 +43,19 @@
42
43
  */
43
44
  import { Resource, databases, models, logger } from "harper";
44
45
  import { randomBytes } from "node:crypto";
46
+ import { existsSync } from "node:fs";
47
+ import { resolve } from "node:path";
48
+ import { homedir } from "node:os";
49
+ import { setImmediate as yieldToRequests } from "node:timers/promises";
45
50
  import { isAdmin, allowVerified } from "./agent-auth.js";
46
51
  import { patchRecordSilent } from "./table-helpers.js";
47
- import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, isContinuityScopeTag, filterStaleSessionIntentCandidates, resolveCandidateVisibilityRuling, DEFAULT_STALE_INTENT_HORIZON_MS, } from "./memory-reflect-lib.js";
52
+ import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, isContinuityScopeTag, filterStaleSessionIntentCandidates, resolveCandidateVisibilityRuling, considerForOldestUnreflectedCap, isRemAbortRequested, resolveMaxMemoriesPerRun, shouldStampLastReflected, DEFAULT_STALE_INTENT_HORIZON_MS, REM_GATHER_YIELD_BUDGET_MS, } from "./memory-reflect-lib.js";
53
+ /** Same path `flair rem pause` writes. Duplicated across the src/ boundary. */
54
+ const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
55
+ const GATHER_SELECT = [
56
+ "id", "agentId", "archived", "durability", "expiresAt", "tags",
57
+ "createdAt", "lastReflected", "content",
58
+ ];
48
59
  export class ReflectMemories extends Resource {
49
60
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
50
61
  // admin elevation). Any verified agent may reflect; the isAdmin checks in post()
@@ -53,7 +64,7 @@ export class ReflectMemories extends Resource {
53
64
  return allowVerified(this.getContext?.());
54
65
  }
55
66
  async post(data) {
56
- const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, execute = false, } = data || {};
67
+ const { agentId: bodyAgentId, scope = "recent", since, maxMemories, focus = "lessons_learned", tag, execute = false, } = data || {};
57
68
  // Authenticated identity comes from getContext().request, not this.request
58
69
  // (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
59
70
  // check was silently bypassed — bob could reflect on alice's memories and
@@ -72,8 +83,26 @@ export class ReflectMemories extends Resource {
72
83
  const agentId = actorResolution.agentId;
73
84
  const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
74
85
  const gatherNow = new Date();
86
+ const maxN = resolveMaxMemoriesPerRun(typeof maxMemories === "number" ? maxMemories : undefined);
87
+ // #1515: do not take the first N search hits. Scan eligible rows (yielding
88
+ // so /Health keeps serving), keep oldest-unreflected first up to maxN
89
+ // (already-reflected fill leftover slots), and abort if the operator
90
+ // paused mid-run. Embeddings are excluded from the select — loading 3k
91
+ // vectors just to strip them pegs the main thread.
75
92
  const memories = [];
76
- for await (const record of databases.flair.Memory.search()) {
93
+ let unreflectedSeen = 0;
94
+ let yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
95
+ for await (const record of databases.flair.Memory.search({ select: GATHER_SELECT })) {
96
+ if (performance.now() >= yieldAt) {
97
+ if (isRemAbortRequested(process.env, existsSync, REM_PAUSE_FLAG)) {
98
+ return new Response(JSON.stringify({
99
+ error: "rem_aborted",
100
+ detail: "REM distillation aborted (pause sentinel or FLAIR_REM_PAUSE=1). Resume with: flair rem resume",
101
+ }), { status: 503 });
102
+ }
103
+ await yieldToRequests();
104
+ yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
105
+ }
77
106
  if (record.agentId !== agentId)
78
107
  continue;
79
108
  if (record.archived)
@@ -94,26 +123,29 @@ export class ReflectMemories extends Resource {
94
123
  // never cite another user's memory.
95
124
  if (!memoryMatchesReflectScope(record, { scope, tag, sinceDate }))
96
125
  continue;
97
- const { embedding, ...rest } = record;
98
- memories.push(rest);
99
- if (memories.length >= maxMemories)
100
- break;
126
+ if (record.lastReflected == null || record.lastReflected === "")
127
+ unreflectedSeen++;
128
+ const { embedding: _embedding, ...rest } = record;
129
+ considerForOldestUnreflectedCap(memories, rest, maxN);
130
+ }
131
+ if (isRemAbortRequested(process.env, existsSync, REM_PAUSE_FLAG)) {
132
+ return new Response(JSON.stringify({
133
+ error: "rem_aborted",
134
+ detail: "REM distillation aborted (pause sentinel or FLAIR_REM_PAUSE=1). Resume with: flair rem resume",
135
+ }), { status: 503 });
101
136
  }
102
- memories.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
103
137
  // Collect tags present in source memories
104
138
  const tagSet = new Set();
105
139
  for (const m of memories) {
106
140
  for (const t of m.tags ?? [])
107
141
  tagSet.add(t);
108
142
  }
109
- // Update lastReflected on source memories (read-modify-write to preserve
110
- // embeddings). Unconditional for both modes — calling /ReflectMemories at
111
- // all means these memories were considered, regardless of what happens next.
112
- const nowISO = new Date().toISOString();
113
- for (const m of memories) {
114
- patchRecordSilent(databases.flair.Memory, m.id, { lastReflected: nowISO });
115
- }
116
- const promptInputs = memories.map((m) => ({ id: m.id, createdAt: m.createdAt, content: m.content }));
143
+ const promptInputs = memories.map((m) => ({
144
+ id: m.id,
145
+ createdAt: m.createdAt,
146
+ content: typeof m.content === "string" ? m.content : "",
147
+ }));
148
+ const gatherMeta = { gathered: memories.length, unreflected: unreflectedSeen, maxMemories: maxN };
117
149
  if (!execute) {
118
150
  const prompt = buildReflectionPrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
119
151
  return {
@@ -121,6 +153,17 @@ export class ReflectMemories extends Resource {
121
153
  prompt,
122
154
  suggestedTags: [...tagSet].slice(0, 20),
123
155
  count: memories.length,
156
+ ...gatherMeta,
157
+ };
158
+ }
159
+ // #1515: empty gather → no generate() call. A fully-reflected (or empty)
160
+ // set must not spend a model turn or hold the main thread.
161
+ if (promptInputs.length === 0) {
162
+ return {
163
+ candidates: [],
164
+ count: 0,
165
+ model: process.env.FLAIR_REM_MODEL || "default",
166
+ ...gatherMeta,
124
167
  };
125
168
  }
126
169
  // ── execute mode (spec §3A) ─────────────────────────────────────────────
@@ -184,7 +227,12 @@ export class ReflectMemories extends Resource {
184
227
  }
185
228
  // Dedup against this agent's existing pending candidates (spec §3A item 4).
186
229
  const existingPendingClaims = [];
230
+ yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
187
231
  for await (const c of databases.flair.MemoryCandidate.search({})) {
232
+ if (performance.now() >= yieldAt) {
233
+ await yieldToRequests();
234
+ yieldAt = performance.now() + REM_GATHER_YIELD_BUDGET_MS;
235
+ }
188
236
  if (c.agentId !== agentId)
189
237
  continue;
190
238
  if (c.status !== "pending")
@@ -225,6 +273,15 @@ export class ReflectMemories extends Resource {
225
273
  await databases.flair.MemoryCandidate.put(row);
226
274
  staged.push(row);
227
275
  }
276
+ // Stamp after a successful generate on execute runs only. Prompt-only
277
+ // and 502/503/abort leave lastReflected unset so the next night retries
278
+ // the same sources instead of permanently skipping them (#1515 Bugbot).
279
+ if (shouldStampLastReflected({ execute, generateSucceeded: true })) {
280
+ const now = new Date().toISOString();
281
+ for (const memory of memories) {
282
+ patchRecordSilent(databases.flair.Memory, memory.id, { lastReflected: now });
283
+ }
284
+ }
228
285
  // Response omits rationalePrompt (spec §3A item 5: "no prompt field") —
229
286
  // it's identical across every row in this batch and already persisted
230
287
  // for audit on the MemoryCandidate row itself; echoing it back per
@@ -235,6 +292,7 @@ export class ReflectMemories extends Resource {
235
292
  candidates: responseCandidates,
236
293
  count: responseCandidates.length,
237
294
  model: resolvedModel,
295
+ ...gatherMeta,
238
296
  // flair#1257 slice 3: continuity-run observability — how many candidates
239
297
  // the stale-intent post-filter dropped (0 for non-continuity runs).
240
298
  ...(isContinuityRun ? { droppedStaleIntent: staleIntentResult.droppedStaleIntent.length } : {}),
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Message.ts — Flair Relay S1 resource surface (point-to-point, single host).
3
+ *
4
+ * A signed, principal-addressed, durably-queued message with an explicit ack
5
+ * and a VISIBLE dead-letter — a DIFFERENT primitive from memory and OrgEvent
6
+ * (whose expiresAt is a silent dead-letter). See relay-ops.ts for the
7
+ * orchestration and relay-lib.ts for the pure primitives; this file is the thin
8
+ * Harper adapter that resolves auth and injects the real table accessors.
9
+ *
10
+ * Auth (self-enforced, mirroring OrgEvent/MemoryGrant now that the global gate
11
+ * is non-rejecting): reads gated to verified agents; every write path resolves
12
+ * the three-way verdict and fails closed on anonymous. Direct REST mutation of
13
+ * the table (PUT/DELETE) is admin/internal only — agents SEND via post() and
14
+ * ACK via MessageAck, never by writing state fields directly. relay-ops' own
15
+ * writes use the static table accessor and bypass these instance gates (the
16
+ * same raw-put seam Federation.ts relies on).
17
+ */
18
+ import { Resource, databases } from "harper";
19
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
20
+ import { makeAuthGate, makeScopedSearch, FORBIDDEN, UNAUTH, NOT_FOUND } from "./record-type-kit.js";
21
+ import { localInstanceId } from "./instance-identity.js";
22
+ import { relaySend, relayInbox, relayConsume, relayDeadLetters, relaySweepDeadlines, } from "./relay-ops.js";
23
+ function relayDeps() {
24
+ return {
25
+ messages: databases.flair.Message,
26
+ agents: databases.flair.Agent,
27
+ resolveOrg: localInstanceId,
28
+ };
29
+ }
30
+ function ctxOf(self) {
31
+ return self.getContext?.();
32
+ }
33
+ function pathId(pathInfo) {
34
+ const id = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : undefined) ??
35
+ (typeof pathInfo === "string" ? pathInfo : undefined);
36
+ return id ? String(id) : undefined;
37
+ }
38
+ // ─── Message table resource — post() sends a message ────────────────────────
39
+ const messageAuthGate = makeAuthGate();
40
+ // P0-1 collection read-scope: a non-admin agent's collection read is limited to
41
+ // the messages it is a PARTY to — `from === self` OR `to === self`. Composed as
42
+ // the OUTERMOST `and` block (makeScopedSearch) so a caller-supplied
43
+ // `operator: "or"` cannot boolean-inject past the scope — the exact
44
+ // injection-safe shape Memory.search() uses. Without this, super.get() on a
45
+ // collection returns EVERY row in the table to any verified agent (the
46
+ // cross-principal read leak Kern P0-1 flagged). Admin/internal are unfiltered.
47
+ const messageScopedSearch = makeScopedSearch(async (agentId) => ({
48
+ condition: {
49
+ operator: "or",
50
+ conditions: [
51
+ { attribute: "from", comparator: "equals", value: agentId },
52
+ { attribute: "to", comparator: "equals", value: agentId },
53
+ ],
54
+ },
55
+ isAllowed: (r) => !!r && (r.from === agentId || r.to === agentId),
56
+ }));
57
+ export class Message extends databases.flair.Message {
58
+ allowRead() {
59
+ return messageAuthGate.call(this);
60
+ }
61
+ /** Send (POST) self-authorizes for any verified agent: Harper authorizes
62
+ * BEFORE post() runs, so without this a de-elevated flair_agent 403s on the
63
+ * create before relaySend is ever reached (Kern P0-2). Per-send ownership
64
+ * (no-forge `from`, signature) is enforced inside relaySend. */
65
+ async allowCreate() {
66
+ return allowVerified(ctxOf(this));
67
+ }
68
+ async post(content) {
69
+ const auth = await resolveAgentAuth(ctxOf(this));
70
+ return relaySend(relayDeps(), auth, content ?? {});
71
+ }
72
+ /** Scope a non-admin agent's collection read to the messages it is a party
73
+ * to (from/to === self); admin/internal unfiltered; anonymous denied. Reached
74
+ * via get()'s collection branch, the same way Memory.get delegates collection
75
+ * reads to Memory.search. */
76
+ async search(query) {
77
+ const auth = await resolveAgentAuth(ctxOf(this));
78
+ if (auth.kind === "anonymous")
79
+ return UNAUTH();
80
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
81
+ return super.search(query);
82
+ return messageScopedSearch(auth.agentId, query, (q) => super.search(q));
83
+ }
84
+ /** By-id read is scoped to the two parties (from/to); anyone else gets 404,
85
+ * so ids can't be enumerated. Collection reads route to the party-scoped
86
+ * search() above (P0-1) — NOT super.get, which returns the whole table. */
87
+ async get(target) {
88
+ if (!target || (typeof target === "object" && target.isCollection)) {
89
+ return this.search(target);
90
+ }
91
+ const auth = await resolveAgentAuth(ctxOf(this));
92
+ if (auth.kind === "anonymous")
93
+ return NOT_FOUND();
94
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
95
+ return super.get(target);
96
+ const record = await super.get(target);
97
+ if (!record)
98
+ return NOT_FOUND();
99
+ if (record.from !== auth.agentId && record.to !== auth.agentId)
100
+ return NOT_FOUND();
101
+ return record;
102
+ }
103
+ /** Direct table writes are admin/internal only — see the file header. */
104
+ async put(content, context) {
105
+ const auth = await resolveAgentAuth(ctxOf(this));
106
+ if (auth.kind === "anonymous")
107
+ return UNAUTH();
108
+ if (auth.kind === "agent" && !auth.isAdmin) {
109
+ return FORBIDDEN("forbidden: send via POST and ack via MessageAck; direct writes are not permitted");
110
+ }
111
+ return super.put(content, context);
112
+ }
113
+ /** PATCH is a distinct Harper verb (Resource.patch → type:update → TableResource.patch
114
+ * runs update()+save(), NEVER Message.put()), so without this override a de-elevated
115
+ * agent with an `update` grant could mutate ANY message — set state, rewrite
116
+ * body/from/to on a row it is not a party to — bypassing put()'s guard and
117
+ * relayConsume's recipient-only check (Kern P0). The `update:false` grant now closes
118
+ * this at the platform gate; this mirrors put()'s guard as defense-in-depth for the
119
+ * verb surface (admin/internal only). Agents ack via MessageAck, never a direct PATCH. */
120
+ async patch(content, context) {
121
+ const auth = await resolveAgentAuth(ctxOf(this));
122
+ if (auth.kind === "anonymous")
123
+ return UNAUTH();
124
+ if (auth.kind === "agent" && !auth.isAdmin) {
125
+ return FORBIDDEN("forbidden: ack via MessageAck; direct writes are not permitted");
126
+ }
127
+ return super.patch(content, context);
128
+ }
129
+ async delete(id, context) {
130
+ const auth = await resolveAgentAuth(ctxOf(this));
131
+ if (auth.kind === "anonymous")
132
+ return UNAUTH();
133
+ if (auth.kind === "agent" && !auth.isAdmin) {
134
+ return FORBIDDEN("forbidden: direct deletes are not permitted");
135
+ }
136
+ return super.delete(id, context);
137
+ }
138
+ }
139
+ // ─── /MessageInbox/{to}? — the caller's unconsumed inbox, sorted by seq ──────
140
+ export class MessageInbox extends Resource {
141
+ async allowRead() {
142
+ const auth = await resolveAgentAuth(ctxOf(this));
143
+ return auth.kind !== "anonymous";
144
+ }
145
+ async get(pathInfo) {
146
+ const auth = await resolveAgentAuth(ctxOf(this));
147
+ return relayInbox(relayDeps(), auth, pathId(pathInfo));
148
+ }
149
+ }
150
+ // ─── /MessageAck (POST {id}) — recipient acks a message ────────────────────
151
+ export class MessageAck extends Resource {
152
+ async allowRead() {
153
+ const auth = await resolveAgentAuth(ctxOf(this));
154
+ return auth.kind !== "anonymous";
155
+ }
156
+ /** Ack (POST) self-authorizes for any verified agent: a bare Resource
157
+ * subclass defaults allowCreate to super_user, which 403s every de-elevated
158
+ * flair_agent — the ack, the core primitive, would be admin-only as shipped
159
+ * (Kern P0-2). Recipient-only ownership is enforced inside relayConsume. */
160
+ async allowCreate() {
161
+ return allowVerified(ctxOf(this));
162
+ }
163
+ async post(content) {
164
+ const auth = await resolveAgentAuth(ctxOf(this));
165
+ const id = content && typeof content === "object" ? String(content.id ?? "") : "";
166
+ return relayConsume(relayDeps(), auth, id);
167
+ }
168
+ }
169
+ // ─── /MessageDeadLetter/{from}? — the sender's visible failures ──────────────
170
+ export class MessageDeadLetter extends Resource {
171
+ async allowRead() {
172
+ const auth = await resolveAgentAuth(ctxOf(this));
173
+ return auth.kind !== "anonymous";
174
+ }
175
+ async get(pathInfo) {
176
+ const auth = await resolveAgentAuth(ctxOf(this));
177
+ return relayDeadLetters(relayDeps(), auth, pathId(pathInfo));
178
+ }
179
+ }
180
+ // ─── /MessageSweep (POST) — admin/internal deadline sweep ───────────────────
181
+ export class MessageSweep extends Resource {
182
+ async allowRead() {
183
+ const auth = await resolveAgentAuth(ctxOf(this));
184
+ return auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin);
185
+ }
186
+ async post() {
187
+ const auth = await resolveAgentAuth(ctxOf(this));
188
+ return relaySweepDeadlines(relayDeps(), auth);
189
+ }
190
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { databases } from "harper";
14
14
  import { resolveAgentAuth } from "./agent-auth.js";
15
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
15
16
  import { invalidEntitiesResponse } from "./entity-vocab.js";
16
17
  import { makeAuthGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
17
18
  import { RECORD_TYPES } from "./record-types.js";
@@ -55,7 +56,18 @@ export class OrgEvent extends databases.flair.OrgEvent {
55
56
  // Harper 5: table resources use put() for create/upsert (post() removed).
56
57
  return databases.flair.OrgEvent.put(content);
57
58
  }
59
+ // PATCH routes past put(), so authorId immutability is enforced on both verbs
60
+ // via the one shared delegate.
61
+ async patch(content, query) {
62
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "authorId");
63
+ if (denial)
64
+ return denial;
65
+ return super.patch(content, query);
66
+ }
58
67
  async put(content) {
68
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "authorId");
69
+ if (__ownerDenial)
70
+ return __ownerDenial;
59
71
  const auth = await this._auth();
60
72
  if (auth.kind === "anonymous")
61
73
  return UNAUTH();
@@ -0,0 +1,76 @@
1
+ import { Resource, databases } from "harper";
2
+ import { randomUUID } from "node:crypto";
3
+ import { resolveAgentAuth } from "./agent-auth.js";
4
+ import { Memory } from "./Memory.js";
5
+ import { MemoryCandidate } from "./MemoryCandidate.js";
6
+ import { stampMemoryPromotionIsolated } from "./promotion-stamp.js";
7
+ import { derivePromotedTags, derivePromotedVisibility, validateHumanReviewerId } from "../src/rem/promote-policy.js";
8
+ const error = (status, message) => new Response(JSON.stringify({ error: message }), {
9
+ status, headers: { "content-type": "application/json" },
10
+ });
11
+ /** Human/agent review of a pending candidate. The request names the decision;
12
+ * content, owner, lineage and the resulting verdict come from stored state. */
13
+ export class PromoteMemoryCandidate extends Resource {
14
+ async allowCreate() {
15
+ return (await resolveAgentAuth(this.getContext?.())).kind !== "anonymous";
16
+ }
17
+ async post(data) {
18
+ const ctx = this.getContext?.();
19
+ const auth = await resolveAgentAuth(ctx);
20
+ if (auth.kind === "anonymous")
21
+ return error(401, "authentication required");
22
+ if (typeof data?.candidateId !== "string" || !data.candidateId)
23
+ return error(400, "candidateId required");
24
+ if (typeof data.rationale !== "string" || !data.rationale.trim())
25
+ return error(400, "rationale required");
26
+ const candidate = await MemoryCandidate.get(data.candidateId, ctx);
27
+ if (!candidate || candidate instanceof Response)
28
+ return error(404, "candidate not found");
29
+ if (auth.kind === "agent" && !auth.isAdmin && candidate.agentId !== auth.agentId)
30
+ return error(403, "cannot promote another agent's candidate");
31
+ if (candidate.status !== "pending")
32
+ return error(409, "candidate is not pending");
33
+ const actorId = auth.kind === "agent" ? auth.agentId : "admin";
34
+ const reviewerId = data.reviewerId ?? actorId;
35
+ if (typeof reviewerId !== "string" || !reviewerId.trim())
36
+ return error(400, "reviewerId required");
37
+ if (auth.kind === "agent" && !auth.isAdmin && reviewerId !== actorId)
38
+ return error(403, "cannot impersonate another reviewer");
39
+ const reviewerError = validateHumanReviewerId(reviewerId);
40
+ if (reviewerError)
41
+ return error(400, reviewerError);
42
+ const sourceFetches = [];
43
+ const scopeTag = typeof candidate.scopeTag === "string" && candidate.scopeTag ? candidate.scopeTag : undefined;
44
+ if (!scopeTag) {
45
+ for (const id of Array.isArray(candidate.sourceMemoryIds) ? candidate.sourceMemoryIds : []) {
46
+ try {
47
+ const memory = await Memory.get(String(id), ctx);
48
+ sourceFetches.push(memory && !(memory instanceof Response)
49
+ ? { ok: true, tags: Array.isArray(memory.tags) ? memory.tags : [] } : { ok: false });
50
+ }
51
+ catch {
52
+ sourceFetches.push({ ok: false });
53
+ }
54
+ }
55
+ }
56
+ const tags = derivePromotedTags(candidate.id, sourceFetches, scopeTag);
57
+ if (!tags.ok)
58
+ return error(400, tags.reason);
59
+ const visibility = derivePromotedVisibility(candidate);
60
+ const decidedAt = new Date().toISOString();
61
+ const memoryId = `${candidate.agentId}-promoted-${randomUUID()}`;
62
+ const written = await Memory.put({
63
+ id: memoryId, agentId: candidate.agentId, content: candidate.claim, durability: "persistent",
64
+ ...(visibility ? { visibility } : {}), tags: tags.tags,
65
+ derivedFrom: candidate.sourceMemoryIds ?? [], createdAt: decidedAt,
66
+ }, ctx);
67
+ if (written instanceof Response && !written.ok)
68
+ return written;
69
+ await stampMemoryPromotionIsolated(memoryId, reviewerId, decidedAt);
70
+ await databases.flair.MemoryCandidate.put({
71
+ ...candidate, status: "promoted", target: "memory", reviewerId,
72
+ reviewRationale: data.rationale, decidedAt,
73
+ });
74
+ return { memoryId, candidateId: candidate.id, reviewerId, decidedAt };
75
+ }
76
+ }
@@ -4,7 +4,7 @@
4
4
  * A generic "record that a memory was actually used" surface: an agent that
5
5
  * grounded an answer or decision on a recalled memory reports it here.
6
6
  * Distinct from — and NEVER wired to — retrieval: `Memory.retrievalCount`
7
- * (bumped on every SemanticSearch hit, resources/SemanticSearch.ts:~551) is
7
+ * (bumped on every SemanticSearch hit, resources/hit-tracking.ts) is
8
8
  * the WEAK, self-reinforcing signal root-caused in flair#623 ("a search hit
9
9
  * counted as usage"); `Memory.usageCount` (this endpoint's only writer) is
10
10
  * the STRONG signal driving `usageBoost` in resources/scoring.ts, which
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
2
  import { resolveAgentAuth } from "./agent-auth.js";
3
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
3
4
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
5
  import { localInstanceId } from "./instance-identity.js";
5
6
  import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
@@ -120,7 +121,18 @@ export class Relationship extends databases.flair.Relationship {
120
121
  * HTTP caller and a true internal call, so an internal caller would have
121
122
  * been wrongly 401'd too. resolveAgentAuth distinguishes the two.
122
123
  */
124
+ // PATCH routes past put(), so agentId immutability is enforced on both verbs
125
+ // via the one shared delegate.
126
+ async patch(content, query) {
127
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
128
+ if (denial)
129
+ return denial;
130
+ return super.patch(content, query);
131
+ }
123
132
  async put(content) {
133
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
134
+ if (__ownerDenial)
135
+ return __ownerDenial;
124
136
  const ctx = this.getContext?.();
125
137
  const auth = await resolveAgentAuth(ctx);
126
138
  if (auth.kind === "anonymous") {