@tpsdev-ai/flair 0.20.0 → 0.21.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 (49) hide show
  1. package/README.md +29 -4
  2. package/SECURITY.md +28 -18
  3. package/dist/cli.js +482 -32
  4. package/dist/deploy.js +280 -18
  5. package/dist/doctor-client.js +312 -0
  6. package/dist/install/clients.js +18 -0
  7. package/dist/rem/restore.js +1 -1
  8. package/dist/resources/Admin.js +1 -1
  9. package/dist/resources/AdminConnectors.js +1 -1
  10. package/dist/resources/AdminDashboard.js +1 -1
  11. package/dist/resources/AdminIdp.js +1 -1
  12. package/dist/resources/AdminInstance.js +1 -1
  13. package/dist/resources/AdminMemory.js +2 -2
  14. package/dist/resources/AdminPrincipals.js +1 -1
  15. package/dist/resources/Agent.js +14 -0
  16. package/dist/resources/AgentCard.js +1 -1
  17. package/dist/resources/AgentSeed.js +1 -1
  18. package/dist/resources/Federation.js +98 -2
  19. package/dist/resources/IngestEvents.js +1 -1
  20. package/dist/resources/Integration.js +1 -1
  21. package/dist/resources/Memory.js +123 -17
  22. package/dist/resources/MemoryBootstrap.js +46 -36
  23. package/dist/resources/MemoryGrant.js +1 -1
  24. package/dist/resources/OAuth.js +61 -4
  25. package/dist/resources/OrgEventCatchup.js +1 -1
  26. package/dist/resources/Presence.js +55 -3
  27. package/dist/resources/Relationship.js +10 -1
  28. package/dist/resources/SemanticSearch.js +14 -14
  29. package/dist/resources/Soul.js +14 -0
  30. package/dist/resources/WorkspaceLatest.js +1 -1
  31. package/dist/resources/WorkspaceState.js +1 -1
  32. package/dist/resources/agent-auth.js +1 -1
  33. package/dist/resources/agentcard-fields.js +2 -2
  34. package/dist/resources/auth-middleware.js +24 -5
  35. package/dist/resources/bm25-filter.js +1 -1
  36. package/dist/resources/bm25.js +1 -1
  37. package/dist/resources/dedup.js +2 -2
  38. package/dist/resources/ed25519-auth.js +2 -2
  39. package/dist/resources/embeddings-provider.js +1 -1
  40. package/dist/resources/federation-nonce-store.js +195 -0
  41. package/dist/resources/instance-identity.js +53 -0
  42. package/dist/resources/memory-bootstrap-lib.js +1 -1
  43. package/dist/resources/memory-read-scope.js +58 -71
  44. package/dist/resources/memory-visibility.js +37 -0
  45. package/dist/version-check.js +167 -0
  46. package/package.json +2 -2
  47. package/schemas/agent.graphql +7 -0
  48. package/schemas/federation.graphql +12 -0
  49. package/schemas/memory.graphql +16 -0
@@ -213,7 +213,7 @@ export async function applySnapshot(opts) {
213
213
  // Catches silent failures: Harper schema coercion, 4xx responses the
214
214
  // apiCall layer masked as ok, partial-DELETE leftovers. Per-ID diff,
215
215
  // not just counts — count parity can hide simultaneous PUT+DELETE
216
- // failures that wash out numerically. See ops-90dq.
216
+ // failures that wash out numerically.
217
217
  if (opts.verifyPostRestore !== false) {
218
218
  try {
219
219
  const verifyMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
@@ -7,7 +7,7 @@ import { allowAdmin } from "./agent-auth.js";
7
7
  * they hit a 404 and assume the admin UI is broken. The dashboard is the
8
8
  * canonical landing surface; redirect there.
9
9
  *
10
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): the /Admin* pathname
10
+ * allowRead()=allowAdmin (defense-in-depth): the /Admin* pathname
11
11
  * gate in auth-middleware.ts only 401s when there's NO Authorization header
12
12
  * at all (or Basic creds don't resolve to a real user) — a validly-verified
13
13
  * TPS-Ed25519 agent that is NOT an admin, or a valid-but-non-super_user Basic
@@ -4,7 +4,7 @@ import { allowAdmin } from "./agent-auth.js";
4
4
  /**
5
5
  * GET /AdminConnectors — OAuth clients and active sessions.
6
6
  *
7
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
7
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
8
8
  */
9
9
  export class AdminConnectors extends Resource {
10
10
  async allowRead() {
@@ -4,7 +4,7 @@ import { allowAdmin } from "./agent-auth.js";
4
4
  /**
5
5
  * GET /AdminDashboard — admin home page with system overview.
6
6
  *
7
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts for why
7
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts for why
8
8
  * this closes a real gap, not just belt-and-suspenders — a verified
9
9
  * non-admin agent could otherwise read full instance stats (principal/agent/
10
10
  * memory/relationship counts) with no admin check at all.
@@ -4,7 +4,7 @@ import { allowAdmin } from "./agent-auth.js";
4
4
  /**
5
5
  * GET /AdminIdp — enterprise IdP configuration management.
6
6
  *
7
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
7
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
8
8
  */
9
9
  export class AdminIdp extends Resource {
10
10
  async allowRead() {
@@ -85,7 +85,7 @@ function resolveVersion() {
85
85
  /**
86
86
  * GET /AdminInstance — instance info, public key, version.
87
87
  *
88
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
88
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
89
89
  */
90
90
  export class AdminInstance extends Resource {
91
91
  async allowRead() {
@@ -21,7 +21,7 @@ import { allowAdmin } from "./agent-auth.js";
21
21
  * makes that legible: every memory shows where it came from, what it derived
22
22
  * from, what it superseded, and which peer it synced from.
23
23
  *
24
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts — this
24
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts — this
25
25
  * page surfaces full memory content across ALL agents, so the gap it closes
26
26
  * matters more here than on the other Admin* pages.
27
27
  */
@@ -31,7 +31,7 @@ export class AdminMemory extends Resource {
31
31
  }
32
32
  async get() {
33
33
  // Harper v5 does not populate this.request on Resource subclasses —
34
- // getContext() is the only reliable path (ops-sal4: the previous
34
+ // getContext() is the only reliable path (the previous
35
35
  // `(this as any).request` read was always undefined, so query params
36
36
  // (?id=, ?q=, ?subject=, ?limit=) were always ignored).
37
37
  const ctx = this.getContext?.();
@@ -4,7 +4,7 @@ import { allowAdmin } from "./agent-auth.js";
4
4
  /**
5
5
  * GET /AdminPrincipals — list all principals with kind, trust, status.
6
6
  *
7
- * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
7
+ * allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
8
8
  */
9
9
  export class AdminPrincipals extends Resource {
10
10
  async allowRead() {
@@ -1,5 +1,6 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
3
+ import { localInstanceId } from "./instance-identity.js";
3
4
  /**
4
5
  * Agent resource — serves as the Principal table in 1.0.
5
6
  *
@@ -40,6 +41,14 @@ export class Agent extends databases.flair.Agent {
40
41
  }
41
42
  content.createdAt = now;
42
43
  content.updatedAt = now;
44
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
45
+ // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
46
+ // full contract. No-op if already set (never fires for a genuine local
47
+ // write; a federation-synced record never reaches this method — the
48
+ // merge path writes via the raw table object, bypassing this class).
49
+ if (content.originatorInstanceId == null) {
50
+ content.originatorInstanceId = await localInstanceId();
51
+ }
43
52
  return super.post(content, context);
44
53
  }
45
54
  async put(content) {
@@ -64,6 +73,11 @@ export class Agent extends databases.flair.Agent {
64
73
  // Protect immutable fields
65
74
  delete content.createdAt;
66
75
  delete content.publicKey; // key rotation goes through dedicated endpoint
76
+ // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
77
+ // stampOriginatorInstanceId doc. No-op if already set.
78
+ if (content.originatorInstanceId == null) {
79
+ content.originatorInstanceId = await localInstanceId();
80
+ }
67
81
  return super.put(content);
68
82
  }
69
83
  }
@@ -29,7 +29,7 @@ export class AgentCard extends Resource {
29
29
  return {
30
30
  name: String(agent.name ?? agent.id ?? agentId),
31
31
  // Only an explicit kind="description" soul publishes — no private-soul
32
- // fallback (ops-vz6j). See agentcard-fields.ts for the security rationale.
32
+ // fallback. See agentcard-fields.ts for the security rationale.
33
33
  description: selectPublicDescription(souls),
34
34
  url: String(agent.url ?? ""),
35
35
  version: String(agent.version ?? "1.0.0"),
@@ -40,7 +40,7 @@ export class AgentSeed extends Resource {
40
40
  }
41
41
  async post(data) {
42
42
  // Harper v5 does not populate this.request on Resource subclasses —
43
- // getContext() is the only reliable path (ops-sal4: the previous
43
+ // getContext() is the only reliable path (the previous
44
44
  // `(this as any).request` read was always undefined, so actorId was always
45
45
  // undefined and this belt-and-suspenders check fail-closed every request,
46
46
  // even from a real admin already verified by allowCreate()).
@@ -1,16 +1,41 @@
1
1
  import { Resource, databases, server } from "@harperfast/harper";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
- import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
4
+ import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
5
5
  import { initFederationCleanup } from "./federation-cleanup.js";
6
+ import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
6
7
  import { classifyRecord } from "./federation-classify.js";
7
8
  export { classifyRecord } from "./federation-classify.js";
8
9
  // Module-level nonce store for federation anti-replay.
9
10
  // Shared across FederationPair + FederationSync — nonces are globally unique
10
11
  // (generated by signBodyFresh per request with 128-bit random nonces).
11
- const federationNonceStore = createNonceStore();
12
+ //
13
+ // Backed by the `Nonce` table (federation-edge-hardening slice 4) so a
14
+ // process restart doesn't wipe recently-seen nonces within the ±30s
15
+ // freshness window — see federation-nonce-store.ts for the full design.
16
+ // The NonceStore interface (has/set/evict) stays synchronous, so this swap
17
+ // requires NO change to verifyBodySignatureFresh or its 2 call sites below.
18
+ const federationNonceStore = createPersistentNonceStore();
12
19
  // Re-export for consumers that import from Federation.ts
13
20
  export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce };
21
+ // ─── Per-record signature enforcement mode (federation-edge-hardening slice 3b) ──
22
+ /**
23
+ * Whether FederationSync.post requires EVERY record to carry a valid
24
+ * per-record signature (§3a), skipping unsigned ones (`missing_signature`),
25
+ * vs. the default "verify-if-present" mode where an unsigned record still
26
+ * merges (relying on the batch-level signature already verified above it).
27
+ *
28
+ * Default OFF (verify-if-present) — pre-3a spokes don't sign individual
29
+ * records yet, and their batch is already authenticated. Turning this ON
30
+ * is an OPERATOR decision, never auto-flipped by this code: flip it only
31
+ * once every paired peer has been confirmed sending per-record signatures
32
+ * (e.g. by checking SyncLog for unsigned records / peer versions). Flipping
33
+ * it before every peer has upgraded silently starts dropping that peer's
34
+ * records instead of merging them.
35
+ */
36
+ function requireRecordSignatures() {
37
+ return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES ?? "").toLowerCase() === "true";
38
+ }
14
39
  // ─── Conflict resolution ─────────────────────────────────────────────────────
15
40
  /**
16
41
  * Field-level Last-Write-Wins merge.
@@ -293,6 +318,61 @@ export class FederationSync extends Resource {
293
318
  recordSkip(decision.reason);
294
319
  continue;
295
320
  }
321
+ // ── Per-record signature verification (federation-edge-hardening slice 3b) ──
322
+ // classifyRecord's "merge" verdict above only proves the record is
323
+ // STRUCTURALLY eligible (known table, self-originated OR the sender
324
+ // is a hub, not stale, not a no-op) — that hub bypass trusts the
325
+ // BATCH-level signature (verifyBodySignatureFresh above, verified
326
+ // against the SENDER's pinned key). It does NOT prove the record's
327
+ // *claimed* originator actually produced it — a hub relaying on
328
+ // behalf of many spokes could otherwise forge a record under any
329
+ // originatorInstanceId it likes, and the receiver had no way to
330
+ // tell. This gate closes that hole: verify the record's own
331
+ // signature (§3a, set at push-time by the ORIGINATOR, never by
332
+ // whoever relayed it) against the ORIGINATOR's pinned instance key
333
+ // — never the sender's key.
334
+ //
335
+ // A bad/unverifiable signature skips ONLY this record, never the
336
+ // batch — rejecting the whole batch on one bad record would be a
337
+ // DoS vector (one forged/garbled record could blackhole every other
338
+ // legitimate record riding along in the same POST).
339
+ const originator = decision.originator;
340
+ if (record.signature) {
341
+ const originatorPublicKey = originator === instanceId
342
+ ? peer.publicKey
343
+ : (await databases.flair.Peer.get(originator))?.publicKey;
344
+ if (!originatorPublicKey) {
345
+ recordSkip("unknown_originator_key");
346
+ continue;
347
+ }
348
+ // CONTRACT — must match src/cli.ts runFederationSyncOnce's signing
349
+ // payload byte-for-byte: keys { v, table, id, data, updatedAt,
350
+ // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
351
+ // doesn't matter, but the field SET and values do. `v: 1` versions
352
+ // the canonical form itself — bump it on BOTH sides together if the
353
+ // signed field set ever changes, so an old signature fails closed
354
+ // instead of silently mis-verifying under a new form.
355
+ const signatureValid = verifyBodySignature({
356
+ v: 1,
357
+ table: record.table,
358
+ id: record.id,
359
+ data: record.data,
360
+ updatedAt: record.updatedAt,
361
+ originatorInstanceId: originator,
362
+ signature: record.signature,
363
+ }, originatorPublicKey);
364
+ if (!signatureValid) {
365
+ recordSkip("invalid_signature");
366
+ continue;
367
+ }
368
+ }
369
+ else if (requireRecordSignatures()) {
370
+ // require-mode: unsigned records are no longer trusted on
371
+ // batch-level auth alone. Default mode (verify-if-present) falls
372
+ // through here and merges — see requireRecordSignatures() above.
373
+ recordSkip("missing_signature");
374
+ continue;
375
+ }
296
376
  const mergedData = mergeRecord(local, record);
297
377
  mergedData._originatorInstanceId = decision.originator;
298
378
  mergedData._syncedFrom = instanceId;
@@ -405,5 +485,21 @@ if (typeof setTimeout !== "undefined") {
405
485
  // Swallow — in test/resource-env the Harper databases may not be bound.
406
486
  // In production, initFederationCleanup handles its own error paths.
407
487
  });
488
+ // Nonce store hydration (slice 4) — load nonces persisted by a previous
489
+ // process (if any) before this instance starts guarding live traffic.
490
+ // Runs on the same deferred tick as the cleanup sweep above; there is a
491
+ // narrow window between process start and hydration completing during
492
+ // which a pre-restart nonce isn't yet visible — an accepted tradeoff
493
+ // consistent with this file's existing deferred-init pattern (see the
494
+ // cleanup sweep comment above).
495
+ federationNonceStore.hydrate().catch((err) => {
496
+ // Swallow — in test/resource-env the Harper databases may not be bound.
497
+ });
498
+ // Nonce table eviction sweep — runs on every instance (both hub and
499
+ // spoke can be the receiving/verifying side of a signed federation
500
+ // request), unlike PairingToken cleanup which is hub-only.
501
+ initNonceStoreCleanup().catch((err) => {
502
+ // Swallow — in test/resource-env the Harper databases may not be bound.
503
+ });
408
504
  }, 0);
409
505
  }
@@ -64,7 +64,7 @@ export class IngestEvents extends Resource {
64
64
  }
65
65
  async post(body, context) {
66
66
  // Harper v5 does not populate this.request on Resource subclasses —
67
- // getContext() is the only reliable path (ops-sal4: the previous
67
+ // getContext() is the only reliable path (the previous
68
68
  // `(this as any).request` read was always undefined, so authHeader was
69
69
  // always undefined and every request 401'd before reaching the office
70
70
  // Ed25519 signature check below).
@@ -15,7 +15,7 @@ export class Integration extends databases.flair.Integration {
15
15
  }
16
16
  /**
17
17
  * Self-authorize now that the global gate is non-rejecting (memory-soul-
18
- * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
18
+ * read-gate family fix — same pattern as Memory.ts/Soul.ts/
19
19
  * WorkspaceState.ts/Relationship.ts). Closes the same P0 leak: Harper
20
20
  * routes `GET /Integration/<id>` to get() and the collection describe
21
21
  * (`GET /Integration`) outside search(), so neither was gated before this
@@ -1,6 +1,7 @@
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";
6
7
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
@@ -15,7 +16,7 @@ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { s
15
16
  * live in ./memory-read-scope.ts — the ONE centralized helper every
16
17
  * cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
17
18
  * 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
19
+ * through, so the scoping rule cannot drift per-path again (a
19
20
  * SemanticSearch inline `visibility === "office"` OR-clause leaked office
20
21
  * memories to any authenticated agent because the rule had scattered). See
21
22
  * that module's doc for the migration invariant (no-visibility-field reads
@@ -86,7 +87,7 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
86
87
  }
87
88
  if (!top)
88
89
  return null;
89
- // ─── ops-ume4: Harper's cosine-sort query omits $distance for a SINGLETON
90
+ // ─── Harper's cosine-sort query omits $distance for a SINGLETON
90
91
  // result set ─────────────────────────────────────────────────────────────
91
92
  // Initial working theory was a per-agentId HNSW "cold-start" (first-ever
92
93
  // query cold, second query warm) and the initially-recommended fix was a
@@ -133,7 +134,7 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
133
134
  cosine = 1 - top.$distance;
134
135
  }
135
136
  else {
136
- console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result (ops-ume4) — " +
137
+ console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result — " +
137
138
  "falling back to a manual cosine computation from the candidate's stored embedding", { agentId, candidateId: top.id });
138
139
  const fullCandidate = await withDetachedTxn(ctx, () => databases.flair.Memory.get(top.id));
139
140
  const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
@@ -209,7 +210,7 @@ function buildWriteResponse(content, result, dedupMatch) {
209
210
  * detachment discipline as findConservativeDedupMatch (each discrete Harper
210
211
  * call individually wrapped — see withDetachedTxn's doc for why a single
211
212
  * 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
213
+ * call). Does NOT swallow failures — throws so the caller can
213
214
  * log it. Never called before the new record is already written.
214
215
  */
215
216
  async function closeSupersededRecord(ctx, oldId, patch) {
@@ -273,7 +274,7 @@ async function validateAndAuthorizeSupersedes(content, auth) {
273
274
  }
274
275
  /**
275
276
  * 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
277
+ * written (write-new-BEFORE-close-old). Safe failure state is
277
278
  * two active records (recoverable), never a tombstoned-old-with-lost-new.
278
279
  * Failure is logged (observable), never silently swallowed. No-op if
279
280
  * `content.supersedes` is not set.
@@ -293,11 +294,11 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
293
294
  // `err` arg) would let an id containing %s/%o consume/hide the real error
294
295
  // (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
295
296
  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 });
297
+ "(observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
297
298
  }
298
299
  }
299
300
  /**
300
- * ─── Durability-keyed default visibility (ops-2dm3 Layer 1, part A) ─────────
301
+ * ─── Durability-keyed default visibility (Layer 1, part A) ─────────────────
301
302
  *
302
303
  * Writer intent: an explicit `visibility` on the write ALWAYS overrides this
303
304
  * (callers check `content.visibility == null` before calling this). When
@@ -310,6 +311,89 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
310
311
  function defaultVisibilityForDurability(durability) {
311
312
  return durability === "permanent" || durability === "persistent" ? "shared" : "private";
312
313
  }
314
+ /**
315
+ * ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
316
+ *
317
+ * Foundational capture for an emergent-trust model: every Memory write gets a
318
+ * structured, versioned `provenance` JSON blob recording what the server can
319
+ * actually VERIFY about the write, plus (optionally) what the caller merely
320
+ * CLAIMS. Deliberately minimal — verified fields only:
321
+ *
322
+ * { v: 1,
323
+ * verified: { agentId: <string|null>, timestamp: <ISO string> },
324
+ * claimed?: { model: <string> } }
325
+ *
326
+ * - `verified.agentId` comes from the ALREADY-RESOLVED auth verdict
327
+ * (resolveAgentAuth) — never from anything the caller can forge on the
328
+ * request body. `kind: "agent"` → the Ed25519-verified agentId. Any other
329
+ * verdict (in practice only `kind: "internal"` — a trusted in-process call
330
+ * with no per-agent identity to attribute) stamps `null` rather than
331
+ * throwing; `kind: "anonymous"` never reaches here — both post()/put()
332
+ * already 401 it before this point.
333
+ * - `verified.timestamp` reuses the server-clock `createdAt` the caller has
334
+ * already computed by this point (never client-suppliable) — the same
335
+ * "stamp a dynamic attribute the server controls" mechanism as the existing
336
+ * `embeddingModel = getModelId()` stamp elsewhere in this file. NOTE: the
337
+ * *signed* Ed25519 request timestamp is also available (it's verified in
338
+ * auth-middleware) but currently discarded there rather than threaded
339
+ * through to resource methods — a future slice can carry it alongside (or
340
+ * instead of) this server-clock timestamp without changing this shape's
341
+ * `v: 1` contract. Not done here to keep auth-middleware untouched.
342
+ * - `claimed.model` is an OPTIONAL, UNVERIFIED passthrough: included only
343
+ * when the incoming write payload itself already carries a non-empty
344
+ * string `model` field. No client/CLI sets one today — this just means the
345
+ * server won't discard it if/when a future write path does. Never
346
+ * invented, never defaulted, and the `claimed` key is omitted entirely
347
+ * (not stamped as `{}`) when absent.
348
+ *
349
+ * Deliberately NOT implemented in this slice: a context-fingerprint field —
350
+ * bootstrap doesn't return the IDs a fingerprint would need, so it requires
351
+ * client cooperation that's out of scope here.
352
+ */
353
+ function buildProvenance(auth, createdAt, content) {
354
+ const provenance = {
355
+ v: 1,
356
+ verified: {
357
+ agentId: auth.kind === "agent" ? auth.agentId : null,
358
+ timestamp: createdAt,
359
+ },
360
+ };
361
+ if (typeof content?.model === "string" && content.model.length > 0) {
362
+ provenance.claimed = { model: content.model };
363
+ }
364
+ return JSON.stringify(provenance);
365
+ }
366
+ /**
367
+ * ─── Write-time originatorInstanceId stamp (federation-edge-hardening slice 1) ──
368
+ *
369
+ * Stamps this instance's own federation identity (resources/instance-
370
+ * identity.ts's localInstanceId(), cached — never a DB read per write) onto
371
+ * every LOCAL write. Deliberately a no-op when `content.originatorInstanceId`
372
+ * already carries a non-null value: this is the anti-clobber rule that keeps
373
+ * a federation-synced record's true origin intact.
374
+ *
375
+ * Why this can never clobber a synced record: FederationSync.post()
376
+ * (resources/Federation.ts) merges incoming records via the RAW table object
377
+ * (`(databases as any).flair.Memory.put(mergedData)`) — Harper's static
378
+ * table-level put, not this Resource subclass's instance put() below. The
379
+ * merge path never runs this function at all, so a record arriving from
380
+ * instance B keeps whatever `originatorInstanceId` it already carried in
381
+ * `mergedData` (that instance's own write-time stamp, carried through in the
382
+ * synced row) with no risk of this instance overwriting it with its own id.
383
+ * The `content.originatorInstanceId == null` guard below is still applied —
384
+ * defense-in-depth for any future path that might route a synced payload
385
+ * through this class's post()/put() — so the invariant holds even if that
386
+ * assumption ever changes.
387
+ *
388
+ * `localInstanceId()` resolves to null on an instance that has never been
389
+ * federation-bootstrapped (no Instance row yet) — the field is nullable by
390
+ * design, so this stamps null rather than inventing an id.
391
+ */
392
+ async function stampOriginatorInstanceId(content) {
393
+ if (content.originatorInstanceId == null) {
394
+ content.originatorInstanceId = await localInstanceId();
395
+ }
396
+ }
313
397
  export class Memory extends databases.flair.Memory {
314
398
  /**
315
399
  * Self-authorize now that the global gate is non-rejecting. Closes the P0
@@ -360,8 +444,8 @@ export class Memory extends databases.flair.Memory {
360
444
  return super.get(target);
361
445
  }
362
446
  // 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
447
+ // owner's SHARED memories — never that owner's private ones (Layer 1
448
+ // private-exclusion). Centralized in resolveReadScope() so this
365
449
  // and search() below cannot drift.
366
450
  const record = await super.get(target);
367
451
  if (!record)
@@ -397,7 +481,7 @@ export class Memory extends databases.flair.Memory {
397
481
  return super.search(query);
398
482
  }
399
483
  // Non-admin agent: scope to own (any visibility) + granted owners' SHARED
400
- // memories only (ops-2dm3 Layer 1 private-exclusion). Centralized in
484
+ // memories only (Layer 1 private-exclusion). Centralized in
401
485
  // resolveReadScope() so get() above and search() here cannot drift.
402
486
  const authAgent = auth.agentId;
403
487
  const scope = await resolveReadScope(authAgent);
@@ -450,7 +534,7 @@ export class Memory extends databases.flair.Memory {
450
534
  content.createdAt = new Date().toISOString();
451
535
  content.updatedAt = content.createdAt;
452
536
  content.archived = content.archived ?? false;
453
- // ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
537
+ // ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
454
538
  // post() only ever creates a NEW record — patchRecord/supersede-close/
455
539
  // retrievalCount bumps all route through put() instead (see put()'s
456
540
  // pre-existing-record guard below), so there is no "don't overwrite an
@@ -488,7 +572,7 @@ export class Memory extends databases.flair.Memory {
488
572
  content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
489
573
  }
490
574
  // Content safety scan — covers content + summary (defense-in-depth for
491
- // agent-set summaries, ops-i2jb).
575
+ // agent-set summaries).
492
576
  if (content.content || content.summary) {
493
577
  const safety = scanFields(content, ["content", "summary"]);
494
578
  if (!safety.safe) {
@@ -525,9 +609,17 @@ export class Memory extends databases.flair.Memory {
525
609
  content.embeddingModel = getModelId();
526
610
  }
527
611
  }
612
+ // Write-time provenance stamp (memory-provenance slice 1) — see
613
+ // buildProvenance's doc above. Stamped last, right before persist, so it
614
+ // reflects the final resolved `content.createdAt`.
615
+ content.provenance = buildProvenance(auth, content.createdAt, content);
616
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
617
+ // 1) — see stampOriginatorInstanceId's doc above. No-op if already set
618
+ // (never fires for a genuine local write — no client sets this field).
619
+ await stampOriginatorInstanceId(content);
528
620
  // ── Write the new record FIRST ──────────────────────────────────────────
529
621
  const result = await super.post(content);
530
- // ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
622
+ // ── THEN close the superseded record ────────────────────────────────────
531
623
  // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
532
624
  // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
533
625
  // old record and then lose the new one if the write failed afterward.
@@ -582,7 +674,7 @@ export class Memory extends databases.flair.Memory {
582
674
  // visibility stamped) or an update/patch (dedup-bypassed, visibility left
583
675
  // untouched). See the dedup-gate block further down for why an existing
584
676
  // 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-
677
+ // the visibility default (Layer 1 part A): patchRecord/supersede-
586
678
  // close/retrievalCount bumps all route through put() with a MERGED
587
679
  // `{...existing, ...patch}` payload, and must never have their stored
588
680
  // visibility overwritten by a default recomputed from that merged content
@@ -590,7 +682,7 @@ export class Memory extends databases.flair.Memory {
590
682
  const preExisting = content.id
591
683
  ? await databases.flair.Memory.get(content.id).catch(() => null)
592
684
  : null;
593
- // ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
685
+ // ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
594
686
  // Explicit visibility on the write ALWAYS overrides; only stamp the
595
687
  // default when the caller left it unset AND this is a fresh record.
596
688
  // permanent|persistent → shared; standard|ephemeral|absent → private.
@@ -607,7 +699,7 @@ export class Memory extends databases.flair.Memory {
607
699
  if (content.supersedes && !content.validFrom) {
608
700
  content.validFrom = content.createdAt;
609
701
  }
610
- // Content safety scan on updated content + summary (ops-i2jb).
702
+ // Content safety scan on updated content + summary.
611
703
  if (content.content || content.summary) {
612
704
  const safety = scanFields(content, ["content", "summary"]);
613
705
  if (!safety.safe) {
@@ -674,9 +766,23 @@ export class Memory extends databases.flair.Memory {
674
766
  if (content.promotionStatus === "approved") {
675
767
  content.durability = "permanent";
676
768
  }
769
+ // Write-time provenance stamp (memory-provenance slice 1) — see
770
+ // buildProvenance's doc above post(). Applies to every put() (fresh
771
+ // create AND update/patch) — never gated on preExisting, so an update
772
+ // always gets a freshly-stamped provenance reflecting the CURRENT
773
+ // authenticated actor performing this write.
774
+ content.provenance = buildProvenance(auth, content.createdAt, content);
775
+ // Write-time originatorInstanceId stamp (federation-edge-hardening slice
776
+ // 1) — see stampOriginatorInstanceId's doc above post(). No-op if
777
+ // already set: an update/patch of an existing local record carries its
778
+ // own already-stamped originatorInstanceId forward unchanged (the
779
+ // `{...existing, ...patch}` merge pattern every put() caller uses), and a
780
+ // federation-synced record never reaches this method at all (see that
781
+ // function's doc for why the merge path can't clobber it here either).
782
+ await stampOriginatorInstanceId(content);
677
783
  // ── Write the new/updated record FIRST ──────────────────────────────────
678
784
  const result = await super.put(content);
679
- // ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
785
+ // ── THEN close the superseded record (see post()) ───────────────────────
680
786
  await closeSupersededIfNeeded(ctx, content, "put");
681
787
  return buildWriteResponse(content, result, dedupMatch);
682
788
  }