@tpsdev-ai/flair 0.20.1 → 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.
- package/README.md +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +465 -32
- package/dist/deploy.js +93 -11
- package/dist/doctor-client.js +312 -0
- package/dist/install/clients.js +18 -0
- package/dist/rem/restore.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/Federation.js +98 -2
- package/dist/resources/IngestEvents.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/Memory.js +123 -17
- package/dist/resources/MemoryBootstrap.js +46 -36
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/OAuth.js +61 -4
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +55 -3
- package/dist/resources/Relationship.js +10 -1
- package/dist/resources/SemanticSearch.js +14 -14
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +24 -5
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +1 -1
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-provider.js +1 -1
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/version-check.js +167 -0
- package/package.json +2 -2
- package/schemas/agent.graphql +7 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +16 -0
|
@@ -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,
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
package/dist/resources/Memory.js
CHANGED
|
@@ -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 (
|
|
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
|
-
// ───
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"(
|
|
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 (
|
|
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 (
|
|
364
|
-
//
|
|
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 (
|
|
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) —
|
|
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
|
|
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
|
|
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 (
|
|
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) —
|
|
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
|
|
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 (
|
|
785
|
+
// ── THEN close the superseded record (see post()) ───────────────────────
|
|
680
786
|
await closeSupersededIfNeeded(ctx, content, "put");
|
|
681
787
|
return buildWriteResponse(content, result, dedupMatch);
|
|
682
788
|
}
|
|
@@ -14,17 +14,19 @@ import { resolveReadScope } from "./memory-read-scope.js";
|
|
|
14
14
|
* 3. Recent memories (adaptive window)
|
|
15
15
|
* 4. Task-relevant memories (semantic search if currentTask provided)
|
|
16
16
|
* 4b. Teammate findings relevant to your task (flair#550 — the SAME scored
|
|
17
|
-
* task-relevant set as #4, split by origin:
|
|
18
|
-
*
|
|
19
|
-
* #4, attributed via "[via <agentId>]"
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* task-relevant set as #4, split by origin: any other in-org agent's
|
|
18
|
+
* NON-PRIVATE memory that scores against currentTask lands here instead
|
|
19
|
+
* of #4, attributed via "[via <agentId>]" — no MemoryGrant required
|
|
20
|
+
* (open-within-org read, per #578). Presentation only — what's readable
|
|
21
|
+
* is entirely resolveReadScope()'s job; this only changes how an
|
|
22
|
+
* already-read cross-agent record is formatted/sectioned)
|
|
22
23
|
* 5. Relationship context (active relationships for mentioned entities)
|
|
23
24
|
* 6. Predicted context (based on channel/surface/subject hints)
|
|
24
25
|
* 7. Team roster (other active agents in this office + a search-first nudge —
|
|
25
|
-
* bootstrap loads the caller's own memories plus
|
|
26
|
-
*
|
|
27
|
-
* section nudges toward memory_search for
|
|
26
|
+
* bootstrap loads the caller's own memories plus every other in-org
|
|
27
|
+
* agent's non-private memories (open-within-org read, never anyone's
|
|
28
|
+
* private ones), so this section nudges toward memory_search for
|
|
29
|
+
* anything beyond that window)
|
|
28
30
|
*
|
|
29
31
|
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
30
32
|
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
@@ -199,9 +201,10 @@ export class BootstrapMemories extends Resource {
|
|
|
199
201
|
}
|
|
200
202
|
// --- 1c. Team roster + cross-agent search nudge ---
|
|
201
203
|
// Soul is still caller-own-only (unaffected here). Memory loading below
|
|
202
|
-
// (step 2) now also includes
|
|
203
|
-
//
|
|
204
|
-
//
|
|
204
|
+
// (step 2) now also includes every other in-org agent's non-private
|
|
205
|
+
// memories (open-within-org read, no MemoryGrant needed — #578) — but
|
|
206
|
+
// this section stays: memory_search/SemanticSearch remains the
|
|
207
|
+
// deliberate, query-driven way to find a teammate's finding, vs.
|
|
205
208
|
// bootstrap's fixed recent/permanent window. This section is fixed-cost
|
|
206
209
|
// (no query text to format per agent) so it's cheap enough to always
|
|
207
210
|
// include, not budgeted.
|
|
@@ -225,14 +228,18 @@ export class BootstrapMemories extends Resource {
|
|
|
225
228
|
// Agent table may not exist in older / standalone deployments
|
|
226
229
|
}
|
|
227
230
|
// --- 2. Permanent memories (always included, highest priority) ---
|
|
228
|
-
// Read-scope: own (any visibility) +
|
|
229
|
-
//
|
|
230
|
-
// in resolveReadScope(): the condition is
|
|
231
|
-
// (so the table itself never returns an
|
|
232
|
-
// `scope.isAllowed` re-checks in-process as
|
|
233
|
-
// belt-and-suspenders discipline as
|
|
234
|
-
// filter) — this is the #550 foundation:
|
|
235
|
-
// beyond own-only without a parallel
|
|
231
|
+
// Read-scope: own (any visibility) + every OTHER in-org agent's
|
|
232
|
+
// non-private memory — open-within-org read (#578), no MemoryGrant
|
|
233
|
+
// consulted at all. Centralized in resolveReadScope(): the condition is
|
|
234
|
+
// pushed into the Harper query (so the table itself never returns an
|
|
235
|
+
// out-of-scope row), and `scope.isAllowed` re-checks in-process as
|
|
236
|
+
// defense-in-depth (same belt-and-suspenders discipline as
|
|
237
|
+
// SemanticSearch's BM25 pre-fusion filter) — this is the #550 foundation:
|
|
238
|
+
// bootstrap can now safely expand beyond own-only without a parallel
|
|
239
|
+
// scoping rule, and that rule tracks resolveReadScope()'s model
|
|
240
|
+
// automatically (grant-gated when #568 first built this, open-within-org
|
|
241
|
+
// now that #578 has landed — this file never re-implements the rule, so
|
|
242
|
+
// it never has to change when the rule does).
|
|
236
243
|
const scope = await resolveReadScope(agentId);
|
|
237
244
|
const allMemories = [];
|
|
238
245
|
for await (const record of databases.flair.Memory.search({ conditions: [scope.condition] })) {
|
|
@@ -240,18 +247,19 @@ export class BootstrapMemories extends Resource {
|
|
|
240
247
|
continue;
|
|
241
248
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
242
249
|
continue;
|
|
243
|
-
//
|
|
250
|
+
// A past validTo ALWAYS means the record has been closed out
|
|
244
251
|
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
245
252
|
// validTo without necessarily setting `archived`), same root cause and
|
|
246
|
-
// fix as
|
|
253
|
+
// fix as SemanticSearch.ts's unconditional past-validTo/bm25-filter
|
|
254
|
+
// exclusion. Unconditional
|
|
247
255
|
// so a server-superseded record can't resurface in bootstrap just
|
|
248
256
|
// because its successor isn't co-present in this result set (the
|
|
249
257
|
// supersededIds filter further down only catches co-presence). A
|
|
250
258
|
// record with no validTo, or a future validTo, is unaffected.
|
|
251
259
|
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
252
260
|
continue;
|
|
253
|
-
// Attribution for cross-agent (
|
|
254
|
-
// SemanticSearch.ts already uses: formatMemory() below only USES this
|
|
261
|
+
// Attribution for cross-agent (any other in-org agent's) records — same
|
|
262
|
+
// convention SemanticSearch.ts already uses: formatMemory() below only USES this
|
|
255
263
|
// when the record also carries _safetyFlags (labels the untrusted-data
|
|
256
264
|
// wrapper with whose memory it is), it never forces wrapping on its own.
|
|
257
265
|
// Real Harper's search() results are non-extensible objects — mutating
|
|
@@ -268,11 +276,12 @@ export class BootstrapMemories extends Resource {
|
|
|
268
276
|
}
|
|
269
277
|
const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
|
|
270
278
|
// #550 design boundary: the permanent / recent / predicted sections are the
|
|
271
|
-
// agent's OWN working context — own-only, always.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
279
|
+
// agent's OWN working context — own-only, always. `activeMemories` also
|
|
280
|
+
// carries every other in-org agent's non-private records (`_source` set,
|
|
281
|
+
// open-within-org read — no grant involved), but that cross-agent
|
|
282
|
+
// visibility exists to feed the task-relevant "Teammate findings"
|
|
283
|
+
// surfacing (#550) below, NOT to blend a teammate's memories into the
|
|
284
|
+
// reader's recent/permanent/predicted view. So these three sections
|
|
276
285
|
// filter to own (`!m._source`); team knowledge surfaces only when
|
|
277
286
|
// task-relevant (the teammate section) or via an explicit memory_search.
|
|
278
287
|
const ownMemories = activeMemories.filter((m) => !m._source);
|
|
@@ -419,13 +428,14 @@ export class BootstrapMemories extends Resource {
|
|
|
419
428
|
.filter((s) => s.score > 0.3)
|
|
420
429
|
.sort((a, b) => b.score - a.score);
|
|
421
430
|
// #550: split the scored, task-relevant set by origin. Own findings
|
|
422
|
-
// go to `relevant` as before;
|
|
423
|
-
// read-scoped by
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
431
|
+
// go to `relevant` as before; any other in-org agent's non-private
|
|
432
|
+
// record — already read-scoped by resolveReadScope(), no grant
|
|
433
|
+
// required (`m._source` is only ever set for a cross-agent record,
|
|
434
|
+
// see the allMemories loop above) — goes to the new `teammate`
|
|
435
|
+
// section so the agent can tell it apart at a glance. Both draw from
|
|
436
|
+
// the SAME `tokenBudget` in one score-ordered pass — highest-relevance
|
|
437
|
+
// memories win the remaining budget regardless of which section they
|
|
438
|
+
// land in, so neither section double-spends.
|
|
429
439
|
for (const { memory: m } of scored) {
|
|
430
440
|
const line = formatMemory(m, agentId);
|
|
431
441
|
const cost = estimateTokens(line);
|
|
@@ -499,7 +509,7 @@ export class BootstrapMemories extends Resource {
|
|
|
499
509
|
}
|
|
500
510
|
// #550: teammate findings relevant to the current task — right after the
|
|
501
511
|
// agent's own task-relevant knowledge. Empty section renders nothing
|
|
502
|
-
// (no header) so a bootstrap with no
|
|
512
|
+
// (no header) so a bootstrap with no task-relevant teammate findings for
|
|
503
513
|
// this task looks exactly as it did before this feature.
|
|
504
514
|
if (sections.teammate.length > 0) {
|
|
505
515
|
parts.push("## Teammate findings relevant to your task\n" + sections.teammate.join("\n"));
|
|
@@ -19,7 +19,7 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
22
|
-
* read-gate family fix
|
|
22
|
+
* read-gate family fix — same pattern as Memory.ts/Soul.ts/
|
|
23
23
|
* WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
|
|
24
24
|
* leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
|
|
25
25
|
* describe (`GET /MemoryGrant`) outside search(), so neither was gated
|