@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
@@ -38,7 +38,7 @@ function getAdminPass() {
38
38
  // an admin — one implementation guarantees they can't diverge.
39
39
  // ─── Crypto + replay-guard helpers ────────────────────────────────────────────
40
40
  // WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
41
- // importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
41
+ // importEd25519Key all live in ./ed25519-auth.ts — the single
42
42
  // shared implementation imported by auth-middleware.ts, agent-auth.ts, and
43
43
  // Presence.ts so a nonce recorded via any one of the three call sites is
44
44
  // visible to the other two, and the crypto/decoder logic can't drift.
@@ -97,9 +97,28 @@ server.http(async (request, nextLayer) => {
97
97
  // instances (rockit-local works only because authorizeLocal=true).
98
98
  url.pathname === "/ObservationCenter" ||
99
99
  // Presence roster is public-safe (field-allowlisted); GET serves the
100
- // Office Space renderer without auth. POST handles Ed25519 auth internally
101
- // (allowCreate=true on the Resource).
102
- url.pathname === "/Presence")
100
+ // Office Space renderer without auth. Scoped to GET only (#604): the
101
+ // exact-path match used to match ANY method, so a bare `PUT /Presence`
102
+ // (collection-level, no id — Harper routes it to the same .put() as
103
+ // by-id PUT) early-returned here too, skipping this middleware entirely.
104
+ // A credential-less loopback PUT then reached Presence.put()'s
105
+ // resolveAgentAuth() call with NO tpsAnonymous/tpsAgent annotation, which
106
+ // fell through to raw `context.user` — populated by Harper's
107
+ // `authorizeLocal` (config true) ambient super_user injection for ANY
108
+ // credential-less loopback request — so the ownership check saw an
109
+ // "admin" caller (isAdmin=true) and let the write through unauthenticated
110
+ // (`super.put()`, no signature, no password). Mirrors the A2A GET-only
111
+ // pattern above: POST/PUT/DELETE now always transit the general
112
+ // middleware path below, which marks a genuinely headerless request
113
+ // tpsAnonymous BEFORE Harper's ambient elevation lands (resolveAgentAuth
114
+ // checks tpsAnonymous first — see agent-auth.ts's resolution order), so
115
+ // the ownership check in Presence.put()/delete() correctly denies it.
116
+ // POST (the heartbeat) is unaffected in practice: it already prefers
117
+ // request.tpsAgent when the middleware set it, and falls back to its own
118
+ // Ed25519 header parse otherwise — transiting the general path now just
119
+ // means a genuinely headerless POST gets marked anonymous (still 401)
120
+ // instead of skipping straight to that fallback parse.
121
+ (request.method === "GET" && url.pathname === "/Presence"))
103
122
  return nextLayer(request);
104
123
  // If Harper has already authorized this request (e.g. Basic admin, or
105
124
  // authorizeLocal=true on localhost), trust Harper's auth decision and pass
@@ -477,7 +496,7 @@ server.http(async (request, nextLayer) => {
477
496
  if (memId) {
478
497
  const record = await databases.flair.Memory.get(memId);
479
498
  if (record && record.agentId && record.agentId !== agentId) {
480
- // Centralized read-scope (ops-2dm3 Layer 1): a grant only covers
499
+ // Centralized read-scope (Layer 1): a grant only covers
481
500
  // the owner's SHARED memories, never their private ones. This
482
501
  // used to be a `visibility === "office"` bypass (any authenticated
483
502
  // agent, no grant needed) — that's gone; the private-exclusion is
@@ -72,7 +72,7 @@ export function passesRecordFilters(record, f = {}) {
72
72
  return false;
73
73
  if (f.asOf && record?.validTo && record.validTo <= f.asOf)
74
74
  return false;
75
- // ops-9rc6: a past validTo ALWAYS means the record has been closed out —
75
+ // A past validTo ALWAYS means the record has been closed out —
76
76
  // either by the server supersede path (Memory.ts closeSupersededRecord sets
77
77
  // validTo without necessarily setting `archived`) or any other writer. This
78
78
  // is unconditional (not gated on `f.asOf`) so a server-superseded record
@@ -1,5 +1,5 @@
1
1
  // ─── BM25 + union-RRF hybrid retrieval (Harper-free, unit-testable) ──────────
2
- // Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved, ops-i39b). This module is
2
+ // Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved). This module is
3
3
  // deliberately Harper-free — same rationale as ./scoring.ts — so the BM25
4
4
  // scoring, the candidate-union RRF fusion, and the security conditions-filter
5
5
  // can be unit-tested directly against the SHIPPED code without a live Harper.
@@ -30,10 +30,10 @@ export const DEDUP_MIN_CONTENT_LENGTH = 20;
30
30
  * Cosine similarity of two equal-length embedding vectors, computed directly
31
31
  * in JS. Used as a fallback for Harper's HNSW cosine-sort query omitting a
32
32
  * computed `$distance` on its top candidate when the query's post-filter
33
- * result set contains exactly ONE matching record (ops-ume4, found in
33
+ * result set contains exactly ONE matching record (found in
34
34
  * resources/Memory.ts's findConservativeDedupMatch; the identical quirk is
35
35
  * also fixed in resources/SemanticSearch.ts's scoring loop for the same
36
- * reason — see ops-syzm there). A mismatched length, empty vector, or
36
+ * reason). A mismatched length, empty vector, or
37
37
  * zero-magnitude side yields 0 (no signal, never treated as "identical" by a
38
38
  * degenerate computation).
39
39
  */
@@ -11,8 +11,8 @@
11
11
  * `importEd25519Key`. Three independent replay windows meant a nonce
12
12
  * recorded as "seen" via one path was invisible to the other two — a
13
13
  * defense-in-depth gap, and a drift hazard (any future fix to one copy
14
- * silently didn't apply to the other two). bd ops-c4op consolidates all
15
- * three into this one module so there is exactly ONE replay guard and ONE
14
+ * silently didn't apply to the other two). Consolidating all three into
15
+ * this one module means there is exactly ONE replay guard and ONE
16
16
  * key-import implementation, imported by all 3 sites.
17
17
  *
18
18
  * `b64ToArrayBuffer` was already unified into resources/b64.ts in a prior
@@ -65,7 +65,7 @@ async function ensureInit() {
65
65
  // Not initialized — init with modelsDir pointing at a USER-WRITABLE
66
66
  // location. On a sudo/root-owned global install the Flair package dir
67
67
  // (process.cwd()) is root-owned, so a model download into <cwd>/models
68
- // fails with EACCES and semantic search silently dies (ops-am0v). The
68
+ // fails with EACCES and semantic search silently dies. The
69
69
  // model — and everything else Flair writes — must live under ~/.flair.
70
70
  //
71
71
  // NOTE: import.meta.dirname and __dirname are both undefined in Harper v5's
@@ -0,0 +1,195 @@
1
+ /**
2
+ * federation-nonce-store.ts — persistent backing for the federation
3
+ * anti-replay NonceStore (federation-edge-hardening slice 4).
4
+ *
5
+ * The gap: `federationNonceStore` in Federation.ts was a bare in-memory Map
6
+ * (`createNonceStore()` in federation-crypto.ts). On process restart it's
7
+ * wiped, so a signed federation request captured within its ±30s freshness
8
+ * window (verifyBodySignatureFresh) could be replayed once, post-restart.
9
+ * This module closes that window by backing the SAME `NonceStore` interface
10
+ * with the `Nonce` Harper table, while keeping every method synchronous —
11
+ * `verifyBodySignatureFresh` and its 2 call sites in Federation.ts are
12
+ * UNCHANGED (they still call `.has()` / `.set()` / `.evict()` with no
13
+ * `await`), so the entire existing federation-crypto-replay test suite
14
+ * (all synchronous assertions) keeps working unmodified.
15
+ *
16
+ * How the sync interface stays sync against an inherently-async DB API:
17
+ * - `has()` / `evict()` read/mutate an in-memory Map (via the existing
18
+ * `createNonceStore()`), exactly as before — no DB round trip.
19
+ * - `set()` updates that same in-memory Map synchronously (so replay
20
+ * detection within THIS process's lifetime is unchanged), and fires an
21
+ * async `Nonce.put()` in the background (not awaited) to persist the
22
+ * nonce for the NEXT restart. A failed/slow persist never blocks or
23
+ * fails request handling — see the perf/durability tradeoff note below.
24
+ * - `hydrate()` is the new piece: an explicit async step that loads
25
+ * not-yet-expired rows from the `Nonce` table into the in-memory Map.
26
+ * Call it once, on startup, BEFORE the store starts guarding live
27
+ * traffic — that's what makes a nonce recorded by the PREVIOUS process
28
+ * visible to a FRESH store instance after a restart (the whole point).
29
+ *
30
+ * Known tradeoff (flagged, not fixed here — would require making NonceStore
31
+ * async, which ripples into ~40 synchronous call sites across
32
+ * federation-crypto-replay.test.ts + federation-sync-e2e.test.ts):
33
+ * `set()`'s persistence is fire-and-forget. A crash in the narrow gap
34
+ * between "response sent" and "async put() resolved" can still lose that
35
+ * one nonce — narrowing the replay window to that gap, not eliminating it
36
+ * outright. Defense-in-depth: the ±30s freshness check + signature
37
+ * verification are still the primary guards; this closes the common case
38
+ * (graceful restart / redeploy), not the crash-mid-request edge case.
39
+ */
40
+ import { createNonceStore } from "./federation-crypto.js";
41
+ /** 2x the standard 30s freshness window — matches the in-memory `evict()` cutoff already used by verifyBodySignatureFresh. */
42
+ export const DEFAULT_RETENTION_MS = 60_000;
43
+ async function resolveDb(opts, cache) {
44
+ if (opts.db)
45
+ return opts.db;
46
+ if (!cache.db) {
47
+ cache.db = import("@harperfast/harper").then((h) => h.databases);
48
+ }
49
+ return cache.db;
50
+ }
51
+ /**
52
+ * Create a `NonceStore` backed by the `Nonce` Harper table.
53
+ *
54
+ * Wraps the existing in-memory `createNonceStore()` (unchanged behavior for
55
+ * `has()`/`set()`/`evict()` within this process) and adds:
56
+ * - background persistence of newly-set nonces (`set()`)
57
+ * - an explicit `hydrate()` to reload previously-persisted nonces (used
58
+ * at startup, and by tests simulating a restart with a fresh instance)
59
+ */
60
+ export function createPersistentNonceStore(opts = {}) {
61
+ const memory = createNonceStore();
62
+ const dbCache = {};
63
+ return {
64
+ has(key) {
65
+ return memory.has(key);
66
+ },
67
+ evict(olderThan) {
68
+ memory.evict(olderThan);
69
+ },
70
+ set(key, value) {
71
+ memory.set(key, value);
72
+ // Fire-and-forget persistence — see module header for the tradeoff.
73
+ void (async () => {
74
+ try {
75
+ const db = await resolveDb(opts, dbCache);
76
+ await db.flair.Nonce.put({ id: key, seenAt: value });
77
+ }
78
+ catch (err) {
79
+ console.error("[federation-nonce-store] failed to persist nonce (in-memory record already made — this process's replay guard is unaffected):", err?.message ?? err);
80
+ }
81
+ })();
82
+ },
83
+ async hydrate(hydrateOpts) {
84
+ const retentionMs = hydrateOpts?.retentionMs ?? DEFAULT_RETENTION_MS;
85
+ const now = hydrateOpts?.now ?? Date.now();
86
+ const cutoff = now - retentionMs;
87
+ try {
88
+ const db = await resolveDb(opts, dbCache);
89
+ for await (const row of db.flair.Nonce.search()) {
90
+ if (row?.id != null && typeof row.seenAt === "number" && row.seenAt >= cutoff) {
91
+ memory.set(String(row.id), row.seenAt);
92
+ }
93
+ }
94
+ }
95
+ catch (err) {
96
+ console.error("[federation-nonce-store] failed to hydrate from Nonce table (starting with an empty replay guard):", err?.message ?? err);
97
+ }
98
+ },
99
+ };
100
+ }
101
+ // ─── Eviction sweep — periodic Nonce table hygiene ─────────────────────────
102
+ //
103
+ // Mirrors federation-cleanup.ts's 5-min setInterval hub-pattern. Decoupled
104
+ // from the in-memory `evict()` above (which runs per-request and only trims
105
+ // the local Map): this sweep deletes rows from the `Nonce` TABLE itself so
106
+ // it doesn't grow unbounded across restarts. No native TTL in this repo —
107
+ // same app-managed-sweep idiom as PairingToken cleanup.
108
+ //
109
+ // Runs on every instance (hub AND spoke) — unlike PairingToken cleanup
110
+ // (hub-only, since only hubs issue pairing tokens), both FederationPair and
111
+ // FederationSync can be the RECEIVING side of a signed request on either
112
+ // role, so both roles accumulate rows in the Nonce table and both need the
113
+ // sweep.
114
+ const NONCE_CLEANUP_INTERVAL_MS = 300_000; // 5 minutes
115
+ let nonceCleanupTimer = null;
116
+ /**
117
+ * Core sweep logic — exposed for unit testing. Deletes `Nonce` rows with
118
+ * `seenAt` older than `retentionMs` (default: 2x the 30s freshness window).
119
+ */
120
+ export async function runNonceCleanupTick(opts = {}) {
121
+ let db;
122
+ if (opts.db) {
123
+ db = opts.db;
124
+ }
125
+ else {
126
+ const harper = await import("@harperfast/harper");
127
+ db = harper.databases;
128
+ }
129
+ const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
130
+ const now = opts.now ?? Date.now();
131
+ const cutoff = now - retentionMs;
132
+ const toDelete = [];
133
+ let scanned = 0;
134
+ try {
135
+ for await (const row of db.flair.Nonce.search()) {
136
+ scanned++;
137
+ if (typeof row?.seenAt === "number" && row.seenAt < cutoff) {
138
+ toDelete.push(row.id);
139
+ }
140
+ }
141
+ }
142
+ catch (err) {
143
+ console.error("[federation-nonce-store] failed to query Nonce records:", err?.message ?? err);
144
+ return { deleted: 0, scanned: 0 };
145
+ }
146
+ let deleted = 0;
147
+ for (const id of toDelete) {
148
+ try {
149
+ await db.flair.Nonce.delete(id);
150
+ deleted++;
151
+ }
152
+ catch (err) {
153
+ console.error("[federation-nonce-store] failed to delete expired nonce:", err?.message ?? err);
154
+ }
155
+ }
156
+ return { deleted, scanned };
157
+ }
158
+ /**
159
+ * Initialise the periodic Nonce-table eviction sweep (5-min cadence).
160
+ *
161
+ * In test environments, callers pass a mock `db` via `opts` so this module
162
+ * never imports @harperfast/harper at the top level (mirrors
163
+ * federation-cleanup.ts's `initFederationCleanup`).
164
+ */
165
+ export async function initNonceStoreCleanup(opts) {
166
+ const immediate = opts?.immediateTick ?? true;
167
+ let db;
168
+ if (opts?.db) {
169
+ db = opts.db;
170
+ }
171
+ else {
172
+ try {
173
+ const harper = await import("@harperfast/harper");
174
+ db = harper.databases;
175
+ }
176
+ catch (err) {
177
+ console.error("[federation-nonce-store] failed to load @harperfast/harper:", err?.message ?? err);
178
+ return;
179
+ }
180
+ }
181
+ const retentionMs = opts?.retentionMs ?? DEFAULT_RETENTION_MS;
182
+ console.log("[federation-nonce-store] starting nonce eviction sweep (5-min cadence)");
183
+ if (nonceCleanupTimer)
184
+ clearInterval(nonceCleanupTimer);
185
+ nonceCleanupTimer = setInterval(() => {
186
+ runNonceCleanupTick({ db, retentionMs }).catch((err) => {
187
+ console.error("[federation-nonce-store] cleanup tick error:", err?.message ?? err);
188
+ });
189
+ }, NONCE_CLEANUP_INTERVAL_MS);
190
+ if (immediate) {
191
+ runNonceCleanupTick({ db, retentionMs }).catch((err) => {
192
+ console.error("[federation-nonce-store] initial cleanup tick error:", err?.message ?? err);
193
+ });
194
+ }
195
+ }
@@ -0,0 +1,53 @@
1
+ import { databases } from "@harperfast/harper";
2
+ /**
3
+ * ─── Local instance identity (federation-edge-hardening slice 1) ────────────
4
+ *
5
+ * The write-time `originatorInstanceId` stamp (Memory.ts/Soul.ts/Agent.ts/
6
+ * Relationship.ts post()/put()) needs to know THIS instance's own federation
7
+ * identity — the same `id` FederationInstance.get() (resources/Federation.ts)
8
+ * finds-or-creates on first boot and persists in the `Instance` table
9
+ * (schemas/federation.graphql). Exactly one row is expected in a given
10
+ * instance's own Instance table — that row IS this instance; other instances
11
+ * it has paired with live in the separate `Peer` table, never here.
12
+ *
13
+ * Deliberately READ-ONLY: this module never creates an Instance row — that
14
+ * first-boot bootstrap (keypair generation + keystore write) stays
15
+ * FederationInstance.get()'s job (resources/Federation.ts). If no Instance
16
+ * row exists yet (a fresh, never-federated instance, or a unit-test
17
+ * environment with no Instance table at all), localInstanceId() resolves to
18
+ * null and callers stamp nothing — originatorInstanceId is nullable by
19
+ * design (schemas/memory.graphql), and a null tag reads as "pre-tag /
20
+ * local-origin by default" per the federation-edge-hardening design.
21
+ *
22
+ * Cached at module scope after the FIRST successful resolution — a write
23
+ * must not pay a DB lookup every call. An unresolved (null) result is NOT
24
+ * cached, since that state can legitimately change later (federation gets
25
+ * bootstrapped after this process already started serving writes) and the
26
+ * cost of re-checking only applies to instances that have never federated.
27
+ */
28
+ let cachedInstanceId = null;
29
+ export async function localInstanceId() {
30
+ if (cachedInstanceId)
31
+ return cachedInstanceId;
32
+ try {
33
+ for await (const row of databases.flair.Instance.search()) {
34
+ if (row?.id) {
35
+ cachedInstanceId = row.id;
36
+ }
37
+ break; // exactly one row expected — this instance's own identity
38
+ }
39
+ }
40
+ catch {
41
+ // Instance table not present (test env / not yet migrated) — no local id.
42
+ }
43
+ return cachedInstanceId;
44
+ }
45
+ /**
46
+ * Test-only: force re-resolution on the next localInstanceId() call. The
47
+ * module-level cache otherwise persists across test files that share the
48
+ * same `bun test` process (same collision class documented in
49
+ * memory-integrity.test.ts re: the Memory class singleton).
50
+ */
51
+ export function _resetLocalInstanceIdCacheForTests() {
52
+ cachedInstanceId = null;
53
+ }
@@ -38,5 +38,5 @@ export function formatTeamLine(teammateIds) {
38
38
  const plural = teammateIds.length === 1 ? "agent shares" : "agents share";
39
39
  return (`${teammateIds.length} other ${plural} this Flair office (${wrapUntrusted(teammateIds.join(", "))}). ` +
40
40
  `Before deep-diving an unfamiliar problem, search their memories for related work — ` +
41
- `\`memory_search\` covers any agent you hold a search grant from.`);
41
+ `\`memory_search\` covers any agent's non-private memories on this instance (open-within-org read; no grant required).`);
42
42
  }
@@ -1,6 +1,7 @@
1
1
  import { databases } from "@harperfast/harper";
2
+ import { PRIVATE_VISIBILITY, isPrivateVisibility } from "./memory-visibility.js";
2
3
  /**
3
- * ─── Centralized Memory read-scoping (ops-2dm3 Layer 1) ─────────────────────
4
+ * ─── Centralized Memory read-scoping (the original grant-gated read model → within-org-read-open)
4
5
  *
5
6
  * The SINGLE source every cross-agent Memory read path resolves its scope
6
7
  * through: Memory.search()/Memory.get() (resources/Memory.ts), SemanticSearch
@@ -9,40 +10,49 @@ import { databases } from "@harperfast/harper";
9
10
  * existed, SemanticSearch had its OWN inline grant-resolution + a
10
11
  * `visibility === "office"` global OR-clause that leaked ANY authenticated
11
12
  * agent's read of ANY other agent's memories once that memory happened to
12
- * carry `visibility: "office"` (ops-nzxa). Scattering the scoping rule per
13
+ * carry `visibility: "office"` (the office-visibility read leak). Scattering the scoping rule per
13
14
  * path is exactly how that leak happened — this module exists so it can't
14
15
  * happen again: one rule, one place, every path imports it.
15
16
  *
16
- * This is a plain-function module (no `class X extends databases.flair.X`)
17
- * deliberately, so it can be safely imported + exercised under DIFFERENT
18
- * `@harperfast/harper` mocks from multiple test/unit/ files in the same bun
19
- * process without the class-capture collision documented in
20
- * test/unit/memory-soul-read-gate.test.ts (that collision is specific to a
21
- * class whose `extends` clause evaluates `databases.flair.X` at module-eval
22
- * time; a plain function that reads `databases` inside its body at CALL time
23
- * does not have that problem).
24
- *
25
- * ── The model (private | shared) ─────────────────────────────────────────────
26
- * `Memory.visibility` is writer intent: "private" (owner-only) or "shared"
27
- * (visible to anyone holding a read/search MemoryGrant on the owner). A
28
- * reader's full read-scope is:
17
+ * ── The model (open-within-org read) ─────────────────────────────────────────
18
+ * Knowledge-refinement, not access-control (per the MEMORY-MODEL-REFRAME):
19
+ * an org/instance is one shared knowledge base. `Memory.visibility` is writer
20
+ * intent: "private" is the ONLY owner-only exception; anything else (`shared`,
21
+ * null/absent) is org-open readable by ANY verified agent on this instance,
22
+ * not just owners who happen to hold a per-owner MemoryGrant. A reader's full
23
+ * read-scope is:
29
24
  * - ALL of the reader's own records, any visibility, unrestricted.
30
- * - A GRANTED owner's records EXCEPT that owner's `private` ones.
25
+ * - EVERY other agent's non-private record on this instance.
26
+ * The federation edge (resources/Federation.ts's push filter / src/cli.ts's
27
+ * runFederationSyncOnce) is the only remaining hard access boundary — it
28
+ * already excludes `private` rows from ever leaving this instance. Within an
29
+ * instance, there is no per-owner grant gate on READS anymore: MemoryGrant is
30
+ * no longer consulted by this module at all (see resolveAllowedOwners's doc
31
+ * below for why the function itself still exists).
31
32
  *
32
- * ── The migration invariant (non-negotiable) ─────────────────────────────────
33
- * Existing memories (written before this field existed) have NO `visibility`
34
- * field. They must read EXACTLY as before: to whoever holds a grant today.
35
- * So a record with no `visibility` field is treated as "shared" — this is why
36
- * the exclusion condition is `visibility != 'private'` (`not_equal`, which
33
+ * ── The no-visibility-field invariant (non-negotiable) ───────────────────────
34
+ * Existing memories (written before the `visibility` field existed) have NO
35
+ * `visibility` field. A record with no `visibility` field is NOT private it
36
+ * reads exactly like an explicit `shared` record (org-open). This is why the
37
+ * exclusion condition is `visibility != 'private'` (`not_equal`, which
37
38
  * INCLUDES records missing the field entirely), never `visibility == 'shared'`
38
- * (`equals`, which would EXCLUDE them and silently break every existing grant
39
- * relationship — nothing is retroactively made private, nothing broadened).
39
+ * (`equals`, which would EXCLUDE them and silently retroactively privatize
40
+ * every legacy row) — nothing is retroactively made private, nothing is
41
+ * excluded from the broadening that wasn't already excluded before it. There
42
+ * is no migration/backfill step: pure-open means every pre-existing record
43
+ * reads as non-private automatically, the moment this code is deployed —
44
+ * gating that on an operator-run step would itself be a knob the
45
+ * emergent-trust reframe rejects (zero knobs).
40
46
  */
41
47
  /**
42
- * Owner ids a non-admin agent may READ: itself, plus any owner who has
43
- * granted it a "read" or "search" scoped MemoryGrant. This is the pre-existing
44
- * owner-set resolution (unchanged in shape/behavior) resolveReadScope()
45
- * below builds the full private-exclusion-aware scope on top of it.
48
+ * Owner ids a non-admin agent holds an explicit "read" or "search" scoped
49
+ * MemoryGrant from (plus itself). NO LONGER used by resolveReadScope() below
50
+ * — reads are open-within-org now, so a per-read grant lookup is dead weight
51
+ * on every read path. Kept exported and unchanged in shape/behavior because
52
+ * it is still the right tool for "who has this agent explicitly granted
53
+ * itself to" listings / admin tooling (grants remain a real, inspectable
54
+ * relationship — they just no longer gate reads). Do NOT delete this
55
+ * function, and do NOT reintroduce a call to it from resolveReadScope().
46
56
  */
47
57
  export async function resolveAllowedOwners(authAgentId) {
48
58
  const allowedOwners = [authAgentId];
@@ -58,55 +68,32 @@ export async function resolveAllowedOwners(authAgentId) {
58
68
  catch { /* MemoryGrant table not yet populated — ignore */ }
59
69
  return allowedOwners;
60
70
  }
61
- const PRIVATE = "private";
62
71
  /**
63
- * Resolve the full read-scope (owner set + condition + in-process predicate)
64
- * for a reader. This is the ONE function every cross-agent Memory read path
65
- * must call — see the module doc above.
72
+ * Resolve the full read-scope (condition + in-process predicate) for a
73
+ * reader. This is the ONE function every cross-agent Memory read path must
74
+ * call — see the module doc above. No longer async-dependent on a DB lookup
75
+ * (MemoryGrant is not consulted for reads), but stays declared `async` /
76
+ * Promise-returning for call-site stability — every existing caller already
77
+ * `await`s it.
66
78
  */
67
79
  export async function resolveReadScope(authAgentId) {
68
- const allowedOwners = await resolveAllowedOwners(authAgentId);
69
- const grantedOwners = allowedOwners.filter((id) => id !== authAgentId);
70
- const selfCondition = { attribute: "agentId", comparator: "equals", value: authAgentId };
71
- let condition;
72
- if (grantedOwners.length === 0) {
73
- // No grants held the reader's scope is just its own records. Emitting
74
- // the plain leaf condition here (rather than an `or` with a single
75
- // branch) keeps the common case's query shape identical to what
76
- // Memory.search() emitted before this change.
77
- condition = selfCondition;
78
- }
79
- else {
80
- const grantedOwnerCondition = grantedOwners.length === 1
81
- ? { attribute: "agentId", comparator: "equals", value: grantedOwners[0] }
82
- : { operator: "or", conditions: grantedOwners.map((id) => ({ attribute: "agentId", comparator: "equals", value: id })) };
83
- condition = {
84
- operator: "or",
85
- conditions: [
86
- selfCondition,
87
- {
88
- operator: "and",
89
- conditions: [
90
- grantedOwnerCondition,
91
- // not_equal (NOT equals 'shared') — see module doc: a record with
92
- // NO visibility field must still read as shared. This is the
93
- // migration-equivalence invariant, enforced in the condition
94
- // itself so every path that uses it gets it for free.
95
- { attribute: "visibility", comparator: "not_equal", value: PRIVATE },
96
- ],
97
- },
98
- ],
99
- };
100
- }
101
- const grantedSet = new Set(grantedOwners);
80
+ // Open-within-org read: own records (any visibility) OR any non-private
81
+ // record on the instance. No grant lookup — see module doc.
82
+ const condition = {
83
+ operator: "or",
84
+ conditions: [
85
+ { attribute: "agentId", comparator: "equals", value: authAgentId },
86
+ // not_equal (NOT equals 'private') see module doc: a record with NO
87
+ // visibility field must still read as non-private (the migration-
88
+ // equivalence invariant), enforced in the condition itself so every
89
+ // path that uses it gets it for free.
90
+ { attribute: "visibility", comparator: "not_equal", value: PRIVATE_VISIBILITY },
91
+ ],
92
+ };
102
93
  const isAllowed = (record) => {
103
94
  if (!record)
104
95
  return false;
105
- if (record.agentId === authAgentId)
106
- return true;
107
- if (!record.agentId || !grantedSet.has(record.agentId))
108
- return false;
109
- return record.visibility !== PRIVATE;
96
+ return record.agentId === authAgentId || !isPrivateVisibility(record.visibility);
110
97
  };
111
- return { allowedOwners, condition, isAllowed };
98
+ return { allowedOwners: [authAgentId], condition, isAllowed };
112
99
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ─── The single "is this Memory private" predicate (federation-edge-hardening
3
+ * slice 2: one rule, one place) ───────────────────────────────────
4
+ *
5
+ * Shared by BOTH:
6
+ * - resources/memory-read-scope.ts's resolveReadScope() — the cross-agent
7
+ * READ scope every read path (Memory.search/get, SemanticSearch,
8
+ * MemoryBootstrap, the by-id auth-middleware guard) resolves through.
9
+ * - src/cli.ts's runFederationSyncOnce() — the federation-sync PUSH filter
10
+ * that must not replicate `private` memories to peer instances.
11
+ *
12
+ * Deliberately has ZERO imports — not even "@harperfast/harper". That is
13
+ * intentional and load-bearing: src/cli.ts is a standalone CLI entrypoint
14
+ * that runs OUTSIDE any running Harper instance (e.g. `flair federation
15
+ * sync` invoked from a cron/launchd job). resources/memory-read-scope.ts
16
+ * imports `databases` from "@harperfast/harper", and that package's
17
+ * top-level init eagerly resolves storage paths and THROWS when there is no
18
+ * live Harper runtime backing it (confirmed empirically — it takes down
19
+ * even `flair --help`). So src/cli.ts must never import
20
+ * resources/memory-read-scope.ts (or anything else that drags that
21
+ * side-effecting import in) directly. This module is the safe seam: a pure
22
+ * function + constant that both sides can import without dragging in
23
+ * "@harperfast/harper".
24
+ *
25
+ * ── The migration invariant (non-negotiable, mirrors memory-read-scope.ts) ──
26
+ * A record with NO `visibility` field (written before the field existed) is
27
+ * NOT private — it must keep syncing/reading exactly as before. This is why
28
+ * the predicate is "is this exactly 'private'", never "is this not 'shared'":
29
+ * missing/null/anything-other-than-'private' all count as non-private.
30
+ */
31
+ export const PRIVATE_VISIBILITY = "private";
32
+ /** True only when visibility is the literal string "private". Null, undefined,
33
+ * "shared", or any other value are all non-private (see migration invariant
34
+ * above) — never invert this to an allowlist of "shared". */
35
+ export function isPrivateVisibility(visibility) {
36
+ return visibility === PRIVATE_VISIBILITY;
37
+ }