@tpsdev-ai/flair 0.20.1 → 0.22.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 (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
@@ -1,31 +1,26 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { getEmbedding, getMode } from "./embeddings-provider.js";
4
- import { patchRecord, withDetachedTxn } from "./table-helpers.js";
4
+ import { patchRecord } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
- import { wrapUntrusted } from "./content-safety.js";
7
6
  import { resolveReadScope } from "./memory-read-scope.js";
8
- import { cosineSimilarity } from "./dedup.js";
9
7
  import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
10
- // Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
11
- // relevance floor) lives in ./scoring.tsa Harper-free module so it can be
12
- // unit-tested directly (see test/unit/temporal-scoring.test.ts).
13
- import { compositeScore } from "./scoring.js";
14
- // BM25 + union-RRF hybrid retrieval (ops-i39b / FLAIR-BM25-HYBRID-RETRIEVAL).
15
- // Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
16
- // SECURITY conditions-filter are unit-tested against the shipped code.
17
- import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
18
- import { isAllowedBm25Candidate } from "./bm25-filter.js";
19
- // Convert HNSW cosine distance (1 - similarity) to similarity score
20
- function distanceToSimilarity(distance) {
21
- return 1 - distance;
22
- }
8
+ // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
9
+ // (imported from ./bm25 — Harper-free so it's unit-testable). Default is ON as
10
+ // of 2026-07-08 (see ./bm25.ts's hybridEnabled() doc); set
11
+ // FLAIR_HYBRID_RETRIEVAL=false to revert to the legacy HNSW + +0.05
12
+ // keyword-bump path, byte-identical to the original pre-hybrid behavior.
13
+ import { hybridEnabled } from "./bm25.js";
14
+ // The actual HNSW/BM25 retrieval + post-retrieval filtering (temporal/
15
+ // supersede/isAllowed) now lives in the pure, side-effect-free
16
+ // retrieveCandidates() core (flair-bootstrap-scale-fix, Kern-approved
17
+ // extraction) MemoryBootstrap.ts calls the SAME core bare, without
18
+ // tripping this file's rate-limit/reranker/hit-tracking side effects. See
19
+ // resources/semantic-retrieval-core.ts's module doc for the full boundary.
20
+ import { retrieveCandidates } from "./semantic-retrieval-core.js";
23
21
  // Candidate multiplier: fetch more candidates than needed from the HNSW index
24
22
  // so composite re-ranking has enough headroom to reorder results.
25
23
  const CANDIDATE_MULTIPLIER = 5;
26
- // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
27
- // (imported from ./bm25 — Harper-free so it's unit-testable). Flag OFF (default)
28
- // → byte-identical to the pre-hybrid HNSW + +0.05 keyword-bump behavior.
29
24
  export class SemanticSearch extends Resource {
30
25
  // Self-authorize via the Ed25519 agent verify instead of relying on the auth
31
26
  // gate's admin super_user elevation (removed in the auth reshape). Any
@@ -38,7 +33,34 @@ export class SemanticSearch extends Resource {
38
33
  return allowVerified(this.getContext?.());
39
34
  }
40
35
  async post(data) {
41
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
36
+ // Default scoring is "raw", not "composite" (flair#623 follow-up, measured
37
+ // 2026-07-08). recall-eval on the live corpus with BM25 hybrid retrieval
38
+ // active (default since eb26890) showed composite was net-HARMFUL at the
39
+ // time: Δp@3 (composite − raw) = -0.38 to -0.50 across repeated runs, MRR
40
+ // 0.44→0.06-0.44. Root cause: compositeScore's durability-weight ×
41
+ // recency-decay multiplier applied UNCONDITIONALLY (no relevance gate,
42
+ // unlike retrievalBoost's RBOOST_RELEVANCE_FLOOR), so a `permanent`
43
+ // -durability or freshly-created LOW-relevance record could outrank the
44
+ // objectively best-matching `persistent`/older record. Now that BM25+RRF
45
+ // fusion normalizes rawScore into a tight [0,1] band, an unbounded
46
+ // durability/recency multiplier is often bigger than the actual relevance
47
+ // gap between candidates.
48
+ //
49
+ // FIXED (flair#623 follow-up, 2026-07-08, see ./scoring.ts's
50
+ // COMPOSITE_DISCOUNT_FLOOR / COMPOSITE_RELEVANCE_FLOOR): compositeScore's
51
+ // durability/recency multiplier is now bounded to a small (~2%) nudge and
52
+ // relevance-gated, the same way RBOOST_CAP/RBOOST_RELEVANCE_FLOOR already
53
+ // bound the retrieval-popularity boost — `scoring: "composite"` no longer
54
+ // reproduces the magnet/inversion bug (recall-harness: p@3 and MRR both
55
+ // now match raw exactly on its 87-record corpus). The default REMAINS
56
+ // "raw" anyway: on that same corpus, a relevance-gated composite only
57
+ // MATCHES raw's precision, it doesn't beat it, so there is no measured
58
+ // upside to switching the default, only the (now-closed) downside risk
59
+ // for anyone who explicitly opts into "composite". Re-run
60
+ // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
61
+ // before reconsidering this default if the compositeScore formula or
62
+ // corpus changes.
63
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf } = data || {};
42
64
  // Authenticated identity lives on the Harper Resource context (getContext().request).
43
65
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
44
66
  // silently returned undefined and the defense-in-depth scope check below
@@ -81,10 +103,10 @@ export class SemanticSearch extends Resource {
81
103
  ? authenticatedAgent
82
104
  : bodyAgentId;
83
105
  // Read-scope: own (any visibility) + granted owners' SHARED memories only
84
- // (ops-2dm3 Layer 1). Centralized in resolveReadScope() — this used to be
106
+ // (Layer 1). Centralized in resolveReadScope() — this used to be
85
107
  // an inline grant-resolution loop here PLUS a `visibility === "office"`
86
108
  // global OR-clause below that leaked ANY authenticated agent's read of
87
- // ANY other agent's office-visible memories (ops-nzxa). Both are gone;
109
+ // ANY other agent's office-visible memories. Both are gone;
88
110
  // this is the ONE scoping resolution for this endpoint now.
89
111
  const scope = agentId ? await resolveReadScope(agentId) : null;
90
112
  // Generate query embedding
@@ -92,8 +114,9 @@ export class SemanticSearch extends Resource {
92
114
  if (!qEmb && q) {
93
115
  // Always attempt embedding generation — getEmbedding() handles init internally.
94
116
  // Don't gate on getMode() which may return "none" before init completes in worker threads.
117
+ // flair#504 Phase 2: 'query' — this is a search query, not stored content.
95
118
  try {
96
- qEmb = await getEmbedding(String(q).slice(0, 8000));
119
+ qEmb = await getEmbedding(String(q).slice(0, 8000), "query");
97
120
  }
98
121
  catch { }
99
122
  }
@@ -155,322 +178,67 @@ export class SemanticSearch extends Resource {
155
178
  });
156
179
  }
157
180
  }
158
- const results = [];
159
181
  const hybrid = hybridEnabled();
160
182
  // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
161
183
  // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
162
184
  // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
163
185
  // headroom. Scoped to the legacy (non-hybrid) vector path below — the
164
186
  // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
165
- // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union (ops-i39b); the
187
+ // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
166
188
  // reranker still applies to its output further down regardless of which
167
189
  // path produced `filteredResults`.
168
190
  const rerankOn = isRerankEnabled();
169
191
  const rerankTopN = getRerankTopN();
170
- if (hybrid) {
171
- // ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
172
- // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
173
- // over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
174
- // by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
175
- // is fetched with those conditions, AND re-checked in-process as
176
- // defense-in-depth) so no other agent's memory is ever scored or fused.
177
- // 4. Candidate-union RRF → normalize → feed as rawScore to compositeScore.
178
- const ctx = this.getContext?.();
179
- // ── (a) Semantic candidate records (best-first) ──────────────────────
180
- // Same HNSW query as the legacy path; we keep the raw records so the BM25
181
- // pass + fusion can re-derive rawScore. No embedding → empty semantic list
182
- // (RRF degrades naturally to BM25-only).
183
- const semRecords = [];
184
- const semIds = [];
185
- if (qEmb) {
186
- const candidateLimit = limit * CANDIDATE_MULTIPLIER;
187
- const semQuery = {
188
- sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
189
- select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
190
- "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
191
- "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
192
- "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
193
- "validFrom", "validTo", "_safetyFlags", "$distance"],
194
- limit: candidateLimit,
195
- };
196
- if (conditions.length > 0)
197
- semQuery.conditions = conditions;
198
- const semResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(semQuery));
199
- for await (const record of semResults) {
200
- // Same per-record temporal gate as the legacy HNSW loop.
201
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
202
- continue;
203
- if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
204
- continue;
205
- if (asOf && record.validFrom && record.validFrom > asOf)
206
- continue;
207
- if (asOf && record.validTo && record.validTo <= asOf)
208
- continue;
209
- // ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
210
- // loop below for the full rationale) — applies regardless of asOf.
211
- if (record.validTo && Date.parse(record.validTo) < Date.now())
212
- continue;
213
- semRecords.push(record);
214
- semIds.push(record.id);
215
- }
216
- }
217
- // ── (b) BM25 candidate records over the SCOPED corpus ────────────────
218
- // SECURITY: fetch the corpus WITH the same conditions[] so Harper applies
219
- // the agent boundary, then re-apply the identical predicate + temporal
220
- // filters in-process (isAllowedBm25Candidate) BEFORE building/scoring the
221
- // index. The BM25 index therefore only ever contains the caller's allowed
222
- // memories — no other agent's content/term-frequency enters scoring or
223
- // fusion. This is the conditions-filter-before-fusion gate.
224
- // Explicit select (same fields as the HNSW path, no embedding / $distance)
225
- // so the large embedding vector is never fetched into the BM25 corpus and
226
- // never spread into a result payload.
227
- const corpusSelect = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
228
- "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
229
- "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
230
- "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
231
- "validFrom", "validTo", "_safetyFlags"];
232
- const corpusQuery = conditions.length > 0
233
- ? { conditions, select: corpusSelect }
234
- : { select: corpusSelect };
235
- const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
236
- const allowedById = new Map();
237
- const bm25Docs = [];
238
- for await (const record of corpusResults) {
239
- // Defense-in-depth: re-check the SAME conditions[] + temporal filters
240
- // in-process. Even if a Harper query change ever let an out-of-scope
241
- // record through, it is dropped here BEFORE it can be BM25-scored/fused.
242
- if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
243
- continue;
244
- allowedById.set(record.id, record);
245
- bm25Docs.push({ id: record.id, content: record.content });
246
- }
247
- // Carry semantic candidates that survived their temporal gate into the
248
- // allowed map too (so a fused id always resolves to a record). Semantic
249
- // records were fetched with the SAME conditions[], so they're in-scope.
250
- for (const r of semRecords) {
251
- if (!allowedById.has(r.id)) {
252
- const { $distance, ...rest } = r;
253
- allowedById.set(r.id, rest);
254
- }
255
- }
256
- // ── (c) BM25 lexical ranking → top SEM_LIMIT (only when q present) ────
257
- let bm25Ids = [];
258
- if (q) {
259
- const bm25 = buildBM25(bm25Docs);
260
- const ranked = bm25.rank(String(q));
261
- // Drop zero-score docs (no query-term overlap → contribute nothing) and
262
- // cap at SEM_LIMIT — the production BM25 candidate window.
263
- bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
264
- }
265
- // ── (d) Candidate-union RRF → normalized [0,1] rawScore ──────────────
266
- // Union dedupes semantic ∪ bm25 ids; absent-from-a-list = 0 contribution.
267
- const fused = fuseRrfNormalized(semIds, bm25Ids);
268
- for (const [id, rrfRaw] of fused) {
269
- const record = allowedById.get(id);
270
- if (!record)
271
- continue; // should not happen — union ⊆ allowed
272
- const rawScore = rrfRaw; // already normalized to [0,1]
273
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
274
- if (temporalBoost > 1.0)
275
- finalScore *= temporalBoost;
276
- const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
277
- const source = record.agentId !== agentId ? record.agentId : undefined;
278
- results.push({
279
- ...record,
280
- content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
281
- _score: Math.round(finalScore * 1000) / 1000,
282
- _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
283
- _source: source,
284
- });
285
- }
286
- }
287
- else if (qEmb) {
288
- // ─── HNSW vector search path (legacy, hybrid flag OFF) ────────────────
289
- const candidateLimit = rerankOn
290
- ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN)
291
- : limit * CANDIDATE_MULTIPLIER;
292
- const query = {
293
- sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
294
- select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
295
- "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
296
- "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
297
- "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
298
- "validFrom", "validTo", "_safetyFlags", "$distance"],
299
- limit: candidateLimit,
300
- };
301
- if (conditions.length > 0) {
302
- query.conditions = conditions;
303
- }
304
- // MemoryGrant.search above left a closed transaction in ctx's chain —
305
- // detach it so Harper builds a fresh transaction for this Memory read.
306
- const ctx = this.getContext?.();
307
- const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
308
- for await (const record of memoryResults) {
309
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
310
- continue;
311
- if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
312
- continue;
313
- // Temporal validity: if asOf is specified, only include memories valid at that point
314
- if (asOf && record.validFrom && record.validFrom > asOf)
315
- continue;
316
- if (asOf && record.validTo && record.validTo <= asOf)
317
- continue;
318
- // ops-9rc6: a past validTo ALWAYS means the record has been closed out
319
- // (server supersede path — Memory.ts closeSupersededRecord — sets
320
- // validTo without necessarily setting `archived`). Unconditional, not
321
- // gated on `asOf`, so a server-superseded record can't resurface in
322
- // the DEFAULT recall path just because its successor isn't
323
- // co-present in this result set (the supersededIds filter further
324
- // down only catches co-presence). A record with no validTo, or a
325
- // future validTo, is unaffected.
326
- if (record.validTo && Date.parse(record.validTo) < Date.now())
327
- continue;
328
- let semanticScore;
329
- if (record.$distance !== undefined) {
330
- semanticScore = distanceToSimilarity(record.$distance);
331
- }
332
- else {
333
- // ─── ops-syzm: Harper's cosine-sort query omits $distance for a
334
- // SINGLETON post-filter result set — the SAME quirk root-caused and
335
- // fixed for the dedup path in ops-ume4 (resources/Memory.ts
336
- // findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
337
- // Sort ORDER is still correct; only the numeric `$distance`
338
- // annotation is missing on that one record, regardless of the
339
- // query's own `limit` (reproduced here with candidateLimit=50, not
340
- // just limit=1 — the trigger is the post-filter MATCH COUNT, not the
341
- // requested limit).
342
- //
343
- // ops-2dm3 Layer 1 made this common: the no-grants agent scope used
344
- // to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
345
- // {visibility=="office"}]}` condition; resolveReadScope() now emits
346
- // a PLAIN single `{agentId==X}` condition for the common (no-grants)
347
- // case, so a scoped search against an agent with exactly one
348
- // matching memory hits this Harper quirk directly. Before, the OR
349
- // wrap incidentally avoided the singleton shape. The old `?? 1`
350
- // fallback silently collapsed this to similarity 0 — read by
351
- // callers (including the clean-VM CI gate's single-memory init
352
- // probe, #533) as "embeddings not loaded", which is WRONG:
353
- // embeddings ARE loaded, only the score was computed incorrectly.
354
- //
355
- // Fix: point-lookup the record by id (a plain get(), unaffected by
356
- // the sort-query quirk — selecting "embedding" directly on the SAME
357
- // sort-by-embedding query comes back as a bare scalar per ops-ume4's
358
- // findings) and compute cosine similarity ourselves in JS from its
359
- // real stored `embedding` vector against this query's `qEmb`, via
360
- // the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
361
- // Only done on this (rare) undefined-$distance branch — never adds
362
- // a point-lookup to the normal per-record path. If the stored
363
- // embedding is missing/empty (e.g. a legacy pre-embedding record),
364
- // cosineSimilarity returns 0 — the same safe "no match" the old
365
- // `?? 1` fallback produced, never a false-high score.
366
- const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
367
- const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
368
- semanticScore = cosineSimilarity(qEmb, storedEmbedding);
369
- }
370
- let keywordHit = false;
371
- if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
372
- keywordHit = true;
373
- }
374
- const rawScore = semanticScore + (keywordHit ? 0.05 : 0);
375
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
376
- if (temporalBoost > 1.0)
377
- finalScore *= temporalBoost;
378
- const { $distance, ...rest } = record;
379
- const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
380
- const source = record.agentId !== agentId ? record.agentId : undefined;
381
- results.push({
382
- ...rest,
383
- content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
384
- _score: Math.round(finalScore * 1000) / 1000,
385
- _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
386
- _source: source,
387
- });
388
- }
389
- }
390
- else {
391
- // ─── No embedding available — keyword-only fallback ──────────────────
392
- // Full scan is only used when there's no query embedding (e.g. tag-only
393
- // or subject-only searches, or when the embedding engine is unavailable).
394
- const query = conditions.length > 0 ? { conditions } : {};
395
- // MemoryGrant.search above left a closed transaction in ctx's chain —
396
- // detach it so Harper builds a fresh transaction for this Memory read.
397
- const ctx = this.getContext?.();
398
- const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
399
- for await (const record of memoryResults) {
400
- if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
401
- continue;
402
- if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
403
- continue;
404
- if (asOf && record.validFrom && record.validFrom > asOf)
405
- continue;
406
- if (asOf && record.validTo && record.validTo <= asOf)
407
- continue;
408
- // ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
409
- // loop above for the full rationale) — applies regardless of asOf.
410
- if (record.validTo && Date.parse(record.validTo) < Date.now())
411
- continue;
412
- let keywordHit = false;
413
- if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
414
- keywordHit = true;
415
- }
416
- const rawScore = keywordHit ? 0.05 : 0;
417
- if (q && rawScore === 0)
418
- continue;
419
- const { embedding, ...rest } = record;
420
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, rest);
421
- if (temporalBoost > 1.0)
422
- finalScore *= temporalBoost;
423
- const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
424
- const source = record.agentId !== agentId ? record.agentId : undefined;
425
- results.push({
426
- ...rest,
427
- content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
428
- _score: Math.round(finalScore * 1000) / 1000,
429
- _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
430
- _source: source,
431
- });
432
- }
433
- }
434
- // Build superseded set and filter (unless caller opts in to see full history)
435
- let filteredResults = results;
436
- if (!includeSuperseded) {
437
- const supersededIds = new Set();
438
- for (const r of results) {
439
- if (r.supersedes)
440
- supersededIds.add(r.supersedes);
441
- }
442
- filteredResults = results.filter((r) => !supersededIds.has(r.id));
443
- }
444
- // Apply minimum score filter
445
- if (minScore > 0) {
446
- filteredResults = filteredResults.filter((r) => r._score >= minScore);
447
- }
192
+ // The overfetch policy (how many raw candidates to pull from the
193
+ // HNSW/BM25 legs relative to what the caller ultimately wants) is THIS
194
+ // wrapper's decision retrieveCandidates() never multiplies its `limit`
195
+ // param internally (see resources/semantic-retrieval-core.ts's doc), so
196
+ // every caller (this one, and MemoryBootstrap's own K formula) computes
197
+ // its own fetch depth. Hybrid's semantic leg is never rerank-widened
198
+ // (matches the pre-extraction behavior: only the legacy path widened for
199
+ // rerank).
200
+ const candidateLimit = hybrid
201
+ ? limit * CANDIDATE_MULTIPLIER
202
+ : (rerankOn ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN) : limit * CANDIDATE_MULTIPLIER);
203
+ const ctx = this.getContext?.();
204
+ let filteredResults = await retrieveCandidates({
205
+ queryEmbedding: qEmb,
206
+ q,
207
+ conditions,
208
+ limit: candidateLimit,
209
+ includeSuperseded,
210
+ scoring,
211
+ temporalBoost,
212
+ sinceDate,
213
+ asOf,
214
+ minScore,
215
+ agentId,
216
+ isAllowed: scope?.isAllowed,
217
+ hybrid,
218
+ ctx,
219
+ });
448
220
  // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
449
221
  // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
450
222
  // can't do) and reorders before the final slice. Reorders whatever
451
- // `filteredResults` the retrieval stage above produced — legacy HNSW-only
452
- // OR the BM25+union-RRF hybrid path (ops-i39b) — since both converge into
453
- // the same `results`/`filteredResults` shape before this point; hybrid
454
- // retrieves+fuses, the reranker only reorders the fused set. Still gated
455
- // on `qEmb` (an embedding was actually generated); the pure keyword-only
456
- // fallback (no qEmb at all) is untouched either way. The reranker
457
- // overwrites `_score` with the rerank score (so margin measurement reads it)
458
- // and preserves the semantic score as `_semScore`; `_rawScore` is left as-is
459
- // so recall-bench's scoring:"raw" path stays reproducible. On init failure,
460
- // timeout, or any throw, rerankCandidates returns the input UNCHANGED and we
461
- // fall through to the existing vector-order sort — recall is never blocked.
223
+ // `filteredResults` retrieveCandidates() produced — legacy HNSW-only OR
224
+ // the BM25+union-RRF hybrid path — since both converge into the same
225
+ // shape (retrieveCandidates never exposes which leg produced a result).
226
+ // Still gated on `qEmb` (an embedding was actually generated); the pure
227
+ // keyword-only fallback (no qEmb at all) is untouched either way. The
228
+ // reranker overwrites `_score` with the rerank score (so margin
229
+ // measurement reads it) and preserves the semantic score as `_semScore`;
230
+ // `_rawScore` is left as-is so recall-bench's scoring:"raw" path stays
231
+ // reproducible. On init failure, timeout, or any throw, rerankCandidates
232
+ // returns the input UNCHANGED and we fall through to retrieveCandidates'
233
+ // own vector-order sort — recall is never blocked. retrieveCandidates
234
+ // already returns its output sorted best-first, so the non-rerank branch
235
+ // needs no additional sort here.
462
236
  if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
463
- // Pre-sort by vector order so the topN fed to the reranker is the most
464
- // semantically-promising slice (filteredResults isn't sorted yet here).
465
- filteredResults.sort((a, b) => b._score - a._score);
466
237
  filteredResults = await rerankCandidates(String(q), filteredResults, {
467
238
  topN: rerankTopN,
468
239
  budgetMs: getRerankBudgetMs(),
469
240
  });
470
241
  }
471
- else {
472
- filteredResults.sort((a, b) => b._score - a._score);
473
- }
474
242
  const topResults = filteredResults.slice(0, limit);
475
243
  // Async hit tracking — don't block the response
476
244
  const now = new Date().toISOString();
@@ -1,4 +1,5 @@
1
1
  import { Resource } from "@harperfast/harper";
2
+ import { allowVerified } from "./agent-auth.js";
2
3
  import { scanSkillContent } from "./scan/skill-scanner.js";
3
4
  /**
4
5
  * POST /SkillScan/
@@ -30,6 +31,20 @@ import { scanSkillContent } from "./scan/skill-scanner.js";
30
31
  * weakening detection on actual shell content.
31
32
  */
32
33
  export class SkillScan extends Resource {
34
+ /**
35
+ * allowCreate()=allowVerified (authorizeLocal-escalation-class follow-up to
36
+ * #601/#604/#609/#612 — flair#614's backstop found this resource had NO
37
+ * allow* at all). The docstring above already says "Auth: any authenticated
38
+ * agent" — this was never actually enforced; Harper's own default
39
+ * (`user?.role.permission.super_user`, satisfiable only by a genuine admin
40
+ * OR authorizeLocal's forged loopback super_user) silently stood in
41
+ * instead. allowVerified matches the documented intent: any verified agent
42
+ * (not admin-only — this is a stateless text scanner, no agent/memory data
43
+ * touched), anonymous denied.
44
+ */
45
+ async allowCreate() {
46
+ return allowVerified(this.getContext?.());
47
+ }
33
48
  async post(data, _context) {
34
49
  const { content } = data || {};
35
50
  if (!content || typeof content !== "string") {
@@ -1,5 +1,6 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
+ import { localInstanceId } from "./instance-identity.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
  /**
@@ -37,6 +38,14 @@ export class Soul extends databases.flair.Soul {
37
38
  content.durability ||= "permanent";
38
39
  content.createdAt = new Date().toISOString();
39
40
  content.updatedAt = content.createdAt;
41
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
42
+ // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
43
+ // full contract. No-op if already set (never fires for a genuine local
44
+ // write; a federation-synced record never reaches this method — the
45
+ // merge path writes via the raw table object, bypassing this class).
46
+ if (content.originatorInstanceId == null) {
47
+ content.originatorInstanceId = await localInstanceId();
48
+ }
40
49
  return super.post(content, context);
41
50
  }
42
51
  async put(content, context) {
@@ -44,6 +53,11 @@ export class Soul extends databases.flair.Soul {
44
53
  if (denied)
45
54
  return denied;
46
55
  content.updatedAt = new Date().toISOString();
56
+ // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
57
+ // stampOriginatorInstanceId doc. No-op if already set.
58
+ if (content.originatorInstanceId == null) {
59
+ content.originatorInstanceId = await localInstanceId();
60
+ }
47
61
  return super.put(content, context);
48
62
  }
49
63
  }
@@ -16,7 +16,7 @@ export class WorkspaceLatest extends Resource {
16
16
  async get(pathInfo) {
17
17
  // Harper v5 does not populate this.context / this.request on Resource
18
18
  // subclasses — getContext() is the only reliable path to the gate's
19
- // tpsAgent/tpsAgentIsAdmin annotations (ops-sal4: the previous
19
+ // tpsAgent/tpsAgentIsAdmin annotations (the previous
20
20
  // `(this as any).context?.request ?? (this as any).request` read was always
21
21
  // undefined, so the ownership check below never ran — fail-open cross-agent
22
22
  // read).
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { databases } from "@harperfast/harper";
11
11
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
12
+ import { invalidEntitiesResponse } from "./entity-vocab.js";
12
13
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
13
14
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
14
15
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
@@ -20,7 +21,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
20
21
  }
21
22
  /**
22
23
  * Self-authorize now that the global gate is non-rejecting (memory-soul-
23
- * read-gate family fix, ops-oox7 — applying the Memory.ts/Soul.ts pattern
24
+ * read-gate family fix — applying the Memory.ts/Soul.ts pattern
24
25
  * to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
25
26
  * P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
26
27
  * collection describe (`GET /WorkspaceState`) to a path outside search(),
@@ -114,6 +115,12 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
114
115
  }
115
116
  content.createdAt = new Date().toISOString();
116
117
  content.timestamp ||= content.createdAt;
118
+ // attention-plane vocabulary gate (flair#675): `entities`, if present,
119
+ // must be well-formed vocabulary strings — see resources/entity-vocab.ts.
120
+ // Field is additive/optional; absent entities is not an error.
121
+ const entitiesError = invalidEntitiesResponse(content.entities);
122
+ if (entitiesError)
123
+ return entitiesError;
117
124
  return super.post(content);
118
125
  }
119
126
  async put(content) {
@@ -123,6 +130,10 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
123
130
  if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
124
131
  return FORBIDDEN("forbidden: cannot write workspace state for another agent");
125
132
  }
133
+ // attention-plane vocabulary gate (flair#675) — same as post() above.
134
+ const entitiesError = invalidEntitiesResponse(content.entities);
135
+ if (entitiesError)
136
+ return entitiesError;
126
137
  return super.put(content);
127
138
  }
128
139
  async delete(id) {