@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
@@ -0,0 +1,88 @@
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ import { mcpIssuer } from "./mcp-oauth-flag.js";
3
+ import { agentPublicKeyToJwk, buildCimdDocument } from "./mcp-client-metadata-fields.js";
4
+ /**
5
+ * MCPClientMetadata — serves a Client ID Metadata Document (CIMD) for a
6
+ * Flair agent at `GET /MCPClientMetadata/{agentId}`.
7
+ *
8
+ * This is the "publish" half of RFC 7523 private_key_jwt / client_credentials
9
+ * agent-auth (see docs/notes/mcp-agent-auth-consumer.md and
10
+ * HarperFast/oauth#159, whose full chain is now shipped in the published
11
+ * @harperfast/oauth@2.2.0: assertion verification #160/PR #165, CIMD-first
12
+ * client resolution #161/#167, token-endpoint grant + issuance rate
13
+ * limiting #162/#163 via PRs #170/#171). An authorization server that
14
+ * treats an agent's `client_id` as this URL fetches this document to learn
15
+ * the agent's JWKS (its EXISTING Ed25519 identity key) instead of doing a
16
+ * DCR registration — no registration state to replicate across Fabric
17
+ * nodes, matching Flair's stateless posture.
18
+ *
19
+ * Public, unauthenticated — mirrors AgentCard.ts. CIMD documents are meant
20
+ * to be fetched by an AS with no prior trust relationship (same posture as
21
+ * an A2A agent card / OIDC client metadata); this document exposes ONLY the
22
+ * agent's already-public identity key (the same value a verified caller
23
+ * already reads via `GET /Agent/{id}`), never anything secret.
24
+ *
25
+ * The document served here is proven against the REAL published plugin:
26
+ * test/unit/mcp-client-credentials-live-package.test.ts feeds it through
27
+ * 2.2.0's actual `resolveCimdClient` fetch+validate pipeline, and
28
+ * test/integration/mcp-client-credentials-e2e.test.ts serves it from a live
29
+ * spawned Harper. Note the plugin's CIMD fetch enforces an UNCONDITIONAL
30
+ * SSRF gate (https only, no private/loopback/link-local DNS answers, no
31
+ * override knob) — so an AS can only ever consume this document when it is
32
+ * served from a genuinely public HTTPS host. See the doc note's
33
+ * "SSRF/loopback boundary" section.
34
+ *
35
+ * Deployment coordination: the AS's `clientIdMetadataDocuments.allowedHosts`
36
+ * config MUST include this route's host (derived from `FLAIR_MCP_ISSUER` /
37
+ * `FLAIR_PUBLIC_URL`, same env vars as `mcpIssuer()` below) — merely being
38
+ * reachable is not enough; the allowlist gate is AS-side config and
39
+ * fail-closed. See docs/notes/mcp-agent-auth-consumer.md.
40
+ */
41
+ export class MCPClientMetadata extends Resource {
42
+ allowRead() {
43
+ return true;
44
+ }
45
+ async get(pathInfo) {
46
+ const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
47
+ this.getId?.() ??
48
+ null;
49
+ if (!agentId) {
50
+ return new Response(JSON.stringify({ error: "agentId required in path: GET /MCPClientMetadata/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
51
+ }
52
+ const issuer = mcpIssuer();
53
+ if (!issuer) {
54
+ // Fail closed rather than guess a client_id URL — CIMD requires the
55
+ // document's client_id to equal the URL it was fetched from, byte for
56
+ // byte, so a floating/unpinned origin would produce a document that
57
+ // never validates.
58
+ return new Response(JSON.stringify({
59
+ error: "mcp_issuer_not_configured",
60
+ message: "FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL) must be set to publish a stable client_id URL.",
61
+ }), { status: 501, headers: { "Content-Type": "application/json" } });
62
+ }
63
+ const agent = await databases.flair.Agent.get(agentId).catch(() => null);
64
+ if (!agent?.publicKey) {
65
+ return new Response(JSON.stringify({ error: "agent_not_found_or_no_key", agentId }), {
66
+ status: 404,
67
+ headers: { "Content-Type": "application/json" },
68
+ });
69
+ }
70
+ let jwk;
71
+ try {
72
+ jwk = agentPublicKeyToJwk(String(agent.publicKey), agentId);
73
+ }
74
+ catch (err) {
75
+ return new Response(JSON.stringify({ error: "invalid_agent_key", message: err?.message ?? String(err) }), { status: 500, headers: { "Content-Type": "application/json" } });
76
+ }
77
+ const clientId = `${issuer.replace(/\/+$/, "")}/MCPClientMetadata/${agentId}`;
78
+ const doc = buildCimdDocument({
79
+ clientId,
80
+ clientName: String(agent.name ?? agent.displayName ?? agentId),
81
+ jwk,
82
+ });
83
+ return new Response(JSON.stringify(doc), {
84
+ status: 200,
85
+ headers: { "Content-Type": "application/json" },
86
+ });
87
+ }
88
+ }
@@ -1,11 +1,14 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
3
3
  import { isAdmin, resolveAgentAuth, allowVerified } from "./agent-auth.js";
4
+ import { localInstanceId } from "./instance-identity.js";
4
5
  import { getEmbedding, getModelId } from "./embeddings-provider.js";
5
6
  import { scanFields, isStrictMode } from "./content-safety.js";
7
+ import { invalidEntitiesResponse } from "./entity-vocab.js";
6
8
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
7
9
  import { resolveReadScope } from "./memory-read-scope.js";
8
10
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
11
+ import { buildProvenance } from "./provenance.js";
9
12
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
10
13
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
11
14
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
@@ -15,7 +18,7 @@ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { s
15
18
  * live in ./memory-read-scope.ts — the ONE centralized helper every
16
19
  * cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
17
20
  * MemoryBootstrap.ts, auth-middleware.ts's by-id guard) resolves its scope
18
- * through, so the scoping rule cannot drift per-path again (ops-nzxa: a
21
+ * through, so the scoping rule cannot drift per-path again (a
19
22
  * SemanticSearch inline `visibility === "office"` OR-clause leaked office
20
23
  * memories to any authenticated agent because the rule had scattered). See
21
24
  * that module's doc for the migration invariant (no-visibility-field reads
@@ -86,7 +89,7 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
86
89
  }
87
90
  if (!top)
88
91
  return null;
89
- // ─── ops-ume4: Harper's cosine-sort query omits $distance for a SINGLETON
92
+ // ─── Harper's cosine-sort query omits $distance for a SINGLETON
90
93
  // result set ─────────────────────────────────────────────────────────────
91
94
  // Initial working theory was a per-agentId HNSW "cold-start" (first-ever
92
95
  // query cold, second query warm) and the initially-recommended fix was a
@@ -133,7 +136,7 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
133
136
  cosine = 1 - top.$distance;
134
137
  }
135
138
  else {
136
- console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result (ops-ume4) — " +
139
+ console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result — " +
137
140
  "falling back to a manual cosine computation from the candidate's stored embedding", { agentId, candidateId: top.id });
138
141
  const fullCandidate = await withDetachedTxn(ctx, () => databases.flair.Memory.get(top.id));
139
142
  const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
@@ -170,10 +173,29 @@ async function runDedupGate(ctx, content) {
170
173
  if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
171
174
  return null;
172
175
  }
176
+ // flair#504 Phase 2: 'document' — this embedding IS the stored vector (the
177
+ // "generate embedding if missing" step in post()/put() below reuses
178
+ // whatever this computes), so it MUST use the same inputType as every
179
+ // other document-write site or dedup's cosine compare would cross prefixed
180
+ // and unprefixed spaces. All three Memory doc sites (here, post(), put())
181
+ // move together in one commit for exactly that reason — see
182
+ // embeddings-provider.ts's file header for why the VALUE must be the
183
+ // literal 'document', never the prefix string.
184
+ //
185
+ // Dedup-during-transition transient (documented per Kern's review, not a
186
+ // bug to fix here): mid stage-2 re-embed, a NEW write embeds 'document'
187
+ // (prefixed) but may compare against an OLDER stored vector that hasn't
188
+ // been re-embedded yet (unprefixed) — cross-space cosine, so dedup can
189
+ // miss a near-duplicate during that window. Bounded (the re-embed pass is
190
+ // batched and finishes in minutes), self-healing once the pass completes,
191
+ // and a missed dedup is a duplicate row, not data loss — quality, not
192
+ // correctness. Stage 1 (this PR) doesn't trigger this at all: no re-embed
193
+ // runs, so there's no mixed-space window until stage 2's separate,
194
+ // deliberate ops step.
173
195
  let embedding = Array.isArray(content.embedding) ? content.embedding : null;
174
196
  if (!embedding) {
175
197
  try {
176
- embedding = await getEmbedding(content.content);
198
+ embedding = await getEmbedding(content.content, "document");
177
199
  }
178
200
  catch {
179
201
  embedding = null;
@@ -209,7 +231,7 @@ function buildWriteResponse(content, result, dedupMatch) {
209
231
  * detachment discipline as findConservativeDedupMatch (each discrete Harper
210
232
  * call individually wrapped — see withDetachedTxn's doc for why a single
211
233
  * wrap around a multi-await async function would not protect the later
212
- * call). Does NOT swallow failures (ops-a4t5 fix) — throws so the caller can
234
+ * call). Does NOT swallow failures — throws so the caller can
213
235
  * log it. Never called before the new record is already written.
214
236
  */
215
237
  async function closeSupersededRecord(ctx, oldId, patch) {
@@ -273,7 +295,7 @@ async function validateAndAuthorizeSupersedes(content, auth) {
273
295
  }
274
296
  /**
275
297
  * Close the superseded record — called AFTER the new record has already been
276
- * written (write-new-BEFORE-close-old, ops-a4t5 fix). Safe failure state is
298
+ * written (write-new-BEFORE-close-old). Safe failure state is
277
299
  * two active records (recoverable), never a tombstoned-old-with-lost-new.
278
300
  * Failure is logged (observable), never silently swallowed. No-op if
279
301
  * `content.supersedes` is not set.
@@ -293,11 +315,11 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
293
315
  // `err` arg) would let an id containing %s/%o consume/hide the real error
294
316
  // (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
295
317
  console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
296
- "(ops-a4t5 — observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
318
+ "(observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
297
319
  }
298
320
  }
299
321
  /**
300
- * ─── Durability-keyed default visibility (ops-2dm3 Layer 1, part A) ─────────
322
+ * ─── Durability-keyed default visibility (Layer 1, part A) ─────────────────
301
323
  *
302
324
  * Writer intent: an explicit `visibility` on the write ALWAYS overrides this
303
325
  * (callers check `content.visibility == null` before calling this). When
@@ -310,6 +332,51 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
310
332
  function defaultVisibilityForDurability(durability) {
311
333
  return durability === "permanent" || durability === "persistent" ? "shared" : "private";
312
334
  }
335
+ /**
336
+ * ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
337
+ *
338
+ * `buildProvenance` itself now lives in ./provenance.ts (imported above) —
339
+ * extracted so resources/Relationship.ts's write path can reuse the EXACT
340
+ * same `{v, verified, claimed?}` shape (the relationship-write-path spec's
341
+ * "reuse buildProvenance as-is" contract) instead of a hand-copied format
342
+ * that could drift. See that module for the full field-by-field rationale
343
+ * (verified.agentId from the auth verdict never the body, verified.timestamp
344
+ * = the server-computed createdAt, optional unverified claimed.model
345
+ * passthrough). Deliberately NOT implemented in this slice: a
346
+ * context-fingerprint field — bootstrap doesn't return the IDs a fingerprint
347
+ * would need, so it requires client cooperation that's out of scope here.
348
+ */
349
+ /**
350
+ * ─── Write-time originatorInstanceId stamp (federation-edge-hardening slice 1) ──
351
+ *
352
+ * Stamps this instance's own federation identity (resources/instance-
353
+ * identity.ts's localInstanceId(), cached — never a DB read per write) onto
354
+ * every LOCAL write. Deliberately a no-op when `content.originatorInstanceId`
355
+ * already carries a non-null value: this is the anti-clobber rule that keeps
356
+ * a federation-synced record's true origin intact.
357
+ *
358
+ * Why this can never clobber a synced record: FederationSync.post()
359
+ * (resources/Federation.ts) merges incoming records via the RAW table object
360
+ * (`(databases as any).flair.Memory.put(mergedData)`) — Harper's static
361
+ * table-level put, not this Resource subclass's instance put() below. The
362
+ * merge path never runs this function at all, so a record arriving from
363
+ * instance B keeps whatever `originatorInstanceId` it already carried in
364
+ * `mergedData` (that instance's own write-time stamp, carried through in the
365
+ * synced row) with no risk of this instance overwriting it with its own id.
366
+ * The `content.originatorInstanceId == null` guard below is still applied —
367
+ * defense-in-depth for any future path that might route a synced payload
368
+ * through this class's post()/put() — so the invariant holds even if that
369
+ * assumption ever changes.
370
+ *
371
+ * `localInstanceId()` resolves to null on an instance that has never been
372
+ * federation-bootstrapped (no Instance row yet) — the field is nullable by
373
+ * design, so this stamps null rather than inventing an id.
374
+ */
375
+ async function stampOriginatorInstanceId(content) {
376
+ if (content.originatorInstanceId == null) {
377
+ content.originatorInstanceId = await localInstanceId();
378
+ }
379
+ }
313
380
  export class Memory extends databases.flair.Memory {
314
381
  /**
315
382
  * Self-authorize now that the global gate is non-rejecting. Closes the P0
@@ -360,8 +427,8 @@ export class Memory extends databases.flair.Memory {
360
427
  return super.get(target);
361
428
  }
362
429
  // Non-admin agent: only its own memories (any visibility), or a granted
363
- // owner's SHARED memories — never that owner's private ones (ops-2dm3
364
- // Layer 1 private-exclusion). Centralized in resolveReadScope() so this
430
+ // owner's SHARED memories — never that owner's private ones (Layer 1
431
+ // private-exclusion). Centralized in resolveReadScope() so this
365
432
  // and search() below cannot drift.
366
433
  const record = await super.get(target);
367
434
  if (!record)
@@ -397,7 +464,7 @@ export class Memory extends databases.flair.Memory {
397
464
  return super.search(query);
398
465
  }
399
466
  // Non-admin agent: scope to own (any visibility) + granted owners' SHARED
400
- // memories only (ops-2dm3 Layer 1 private-exclusion). Centralized in
467
+ // memories only (Layer 1 private-exclusion). Centralized in
401
468
  // resolveReadScope() so get() above and search() here cannot drift.
402
469
  const authAgent = auth.agentId;
403
470
  const scope = await resolveReadScope(authAgent);
@@ -450,7 +517,7 @@ export class Memory extends databases.flair.Memory {
450
517
  content.createdAt = new Date().toISOString();
451
518
  content.updatedAt = content.createdAt;
452
519
  content.archived = content.archived ?? false;
453
- // ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
520
+ // ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
454
521
  // post() only ever creates a NEW record — patchRecord/supersede-close/
455
522
  // retrievalCount bumps all route through put() instead (see put()'s
456
523
  // pre-existing-record guard below), so there is no "don't overwrite an
@@ -483,12 +550,19 @@ export class Memory extends databases.flair.Memory {
483
550
  if (!content.validFrom) {
484
551
  content.validFrom = content.createdAt;
485
552
  }
553
+ // attention-plane vocabulary gate (flair#675): `entities`, if present,
554
+ // must be well-formed vocabulary strings — see resources/entity-vocab.ts.
555
+ // Field is additive/optional (v1 schema-only; no auto-derivation here —
556
+ // that producer is a follow-up); absent entities is not an error.
557
+ const entitiesError = invalidEntitiesResponse(content.entities);
558
+ if (entitiesError)
559
+ return entitiesError;
486
560
  if (content.durability === "ephemeral" && !content.expiresAt) {
487
561
  const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
488
562
  content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
489
563
  }
490
564
  // Content safety scan — covers content + summary (defense-in-depth for
491
- // agent-set summaries, ops-i2jb).
565
+ // agent-set summaries).
492
566
  if (content.content || content.summary) {
493
567
  const safety = scanFields(content, ["content", "summary"]);
494
568
  if (!safety.safe) {
@@ -517,17 +591,27 @@ export class Memory extends databases.flair.Memory {
517
591
  delete content.lexicalThreshold;
518
592
  }
519
593
  // Generate embedding from content text (no-op if the dedup gate above
520
- // already computed one for this content).
594
+ // already computed one for this content). flair#504 Phase 2: 'document'
595
+ // — see runDedupGate's comment above for why all three Memory doc sites
596
+ // must move together.
521
597
  if (content.content && !content.embedding) {
522
- const vec = await getEmbedding(content.content);
598
+ const vec = await getEmbedding(content.content, "document");
523
599
  if (vec) {
524
600
  content.embedding = vec;
525
601
  content.embeddingModel = getModelId();
526
602
  }
527
603
  }
604
+ // Write-time provenance stamp (memory-provenance slice 1) — see
605
+ // buildProvenance's doc above. Stamped last, right before persist, so it
606
+ // reflects the final resolved `content.createdAt`.
607
+ content.provenance = buildProvenance(auth, content.createdAt, content);
608
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
609
+ // 1) — see stampOriginatorInstanceId's doc above. No-op if already set
610
+ // (never fires for a genuine local write — no client sets this field).
611
+ await stampOriginatorInstanceId(content);
528
612
  // ── Write the new record FIRST ──────────────────────────────────────────
529
613
  const result = await super.post(content);
530
- // ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
614
+ // ── THEN close the superseded record ────────────────────────────────────
531
615
  // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
532
616
  // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
533
617
  // old record and then lose the new one if the write failed afterward.
@@ -582,7 +666,7 @@ export class Memory extends databases.flair.Memory {
582
666
  // visibility stamped) or an update/patch (dedup-bypassed, visibility left
583
667
  // untouched). See the dedup-gate block further down for why an existing
584
668
  // id skips the gate; the SAME "does a record already exist" check gates
585
- // the visibility default (ops-2dm3 Layer 1 part A): patchRecord/supersede-
669
+ // the visibility default (Layer 1 part A): patchRecord/supersede-
586
670
  // close/retrievalCount bumps all route through put() with a MERGED
587
671
  // `{...existing, ...patch}` payload, and must never have their stored
588
672
  // visibility overwritten by a default recomputed from that merged content
@@ -590,7 +674,7 @@ export class Memory extends databases.flair.Memory {
590
674
  const preExisting = content.id
591
675
  ? await databases.flair.Memory.get(content.id).catch(() => null)
592
676
  : null;
593
- // ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
677
+ // ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
594
678
  // Explicit visibility on the write ALWAYS overrides; only stamp the
595
679
  // default when the caller left it unset AND this is a fresh record.
596
680
  // permanent|persistent → shared; standard|ephemeral|absent → private.
@@ -607,7 +691,11 @@ export class Memory extends databases.flair.Memory {
607
691
  if (content.supersedes && !content.validFrom) {
608
692
  content.validFrom = content.createdAt;
609
693
  }
610
- // Content safety scan on updated content + summary (ops-i2jb).
694
+ // attention-plane vocabulary gate (flair#675) see post()'s comment above.
695
+ const entitiesError = invalidEntitiesResponse(content.entities);
696
+ if (entitiesError)
697
+ return entitiesError;
698
+ // Content safety scan on updated content + summary.
611
699
  if (content.content || content.summary) {
612
700
  const safety = scanFields(content, ["content", "summary"]);
613
701
  if (!safety.safe) {
@@ -653,9 +741,12 @@ export class Memory extends databases.flair.Memory {
653
741
  dedupMatch = await runDedupGate(ctx, content);
654
742
  }
655
743
  // Re-generate embedding if content changed (no-op if the dedup gate above
656
- // already computed one for this content).
744
+ // already computed one for this content). flair#504 Phase 2: 'document'
745
+ // — this is also the regen branch `flair reembed` triggers (clears
746
+ // embedding/embeddingModel then hits this put()), so it's what actually
747
+ // re-embeds a stale row WITH the prefix once stage 2 runs.
657
748
  if (content.content && !content.embedding) {
658
- const vec = await getEmbedding(content.content);
749
+ const vec = await getEmbedding(content.content, "document");
659
750
  if (vec) {
660
751
  content.embedding = vec;
661
752
  content.embeddingModel = getModelId();
@@ -674,9 +765,23 @@ export class Memory extends databases.flair.Memory {
674
765
  if (content.promotionStatus === "approved") {
675
766
  content.durability = "permanent";
676
767
  }
768
+ // Write-time provenance stamp (memory-provenance slice 1) — see
769
+ // buildProvenance's doc above post(). Applies to every put() (fresh
770
+ // create AND update/patch) — never gated on preExisting, so an update
771
+ // always gets a freshly-stamped provenance reflecting the CURRENT
772
+ // authenticated actor performing this write.
773
+ content.provenance = buildProvenance(auth, content.createdAt, content);
774
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
775
+ // 1) — see stampOriginatorInstanceId's doc above post(). No-op if
776
+ // already set: an update/patch of an existing local record carries its
777
+ // own already-stamped originatorInstanceId forward unchanged (the
778
+ // `{...existing, ...patch}` merge pattern every put() caller uses), and a
779
+ // federation-synced record never reaches this method at all (see that
780
+ // function's doc for why the merge path can't clobber it here either).
781
+ await stampOriginatorInstanceId(content);
677
782
  // ── Write the new/updated record FIRST ──────────────────────────────────
678
783
  const result = await super.put(content);
679
- // ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
784
+ // ── THEN close the superseded record (see post()) ───────────────────────
680
785
  await closeSupersededIfNeeded(ctx, content, "put");
681
786
  return buildWriteResponse(content, result, dedupMatch);
682
787
  }