@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.
- package/README.md +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +482 -32
- package/dist/deploy.js +280 -18
- 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
package/dist/rem/restore.js
CHANGED
|
@@ -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.
|
|
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)}`));
|
package/dist/resources/Admin.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
7
|
+
* allowRead()=allowAdmin (defense-in-depth): see Admin.ts.
|
|
8
8
|
*/
|
|
9
9
|
export class AdminPrincipals extends Resource {
|
|
10
10
|
async allowRead() {
|
package/dist/resources/Agent.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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,
|
|
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
|
}
|