@tpsdev-ai/flair 0.44.13 → 0.46.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.
@@ -20,6 +20,7 @@ import { mkdirSync, writeFileSync, statSync, chmodSync, rmSync, existsSync, read
20
20
  import { resolve } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
  import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
23
+ import { validateSnapshotArchive } from "../lib/safe-snapshot-extract.js";
23
24
  export const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
24
25
  /** Validates the agent id and returns its snapshot directory. */
25
26
  export function remSnapshotDir(agent) {
@@ -133,20 +134,20 @@ export async function extractSnapshot(opts) {
133
134
  if (existsSync(targetDir)) {
134
135
  throw new Error(`target directory already exists: ${targetDir}`);
135
136
  }
137
+ // flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
138
+ // do contain malicious entries (leading "/" stripped, ".." entries dropped,
139
+ // no writing through symlinks — verified on the pinned tar against all four
140
+ // vectors), but they discard those entries SILENTLY: the restore reported
141
+ // success minus the parts it never mentioned, and the operator held a
142
+ // partial restore they believed was complete. Validation runs BEFORE the
143
+ // target directory is even created: a tampered snapshot aborts the whole
144
+ // restore, names the offending entry, and writes nothing (same posture as
145
+ // the data-dir restore's extractSnapshotSafely). The default extract flags
146
+ // are kept as containment defense-in-depth — deliberately NOT
147
+ // preservePaths; if that flag is ever added here, this call MUST move to
148
+ // extractSnapshotSafely (see src/lib/safe-snapshot-extract.ts).
149
+ await validateSnapshotArchive({ file: opts.snapshotPath, targetDir });
136
150
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
137
- // Deliberately NOT preservePaths, and deliberately NOT routed through
138
- // src/lib/safe-snapshot-extract.ts. The snapshot path comes from the
139
- // operator, so provenance here is no more controlled than the data-dir
140
- // restore's — the difference is the flag, not the trust. With node-tar's
141
- // defaults its own containment applies: it strips a leading "/" from entry
142
- // paths, drops ".." entries, and refuses to write through a symlink,
143
- // including one created earlier in the same archive. Verified against the
144
- // pinned tar (7.5.20) on all four cases — absolute path, ".." traversal,
145
- // in-archive symlink, pre-existing symlink in the target — each contained,
146
- // with a benign control entry landing to prove the archives were valid.
147
- // If `preservePaths` is ever added here, that containment is gone and this
148
- // call MUST move to extractSnapshotSafely, which is why the data-dir
149
- // restore needs the wrapper and this does not.
150
151
  await tarExtract({ file: opts.snapshotPath, cwd: targetDir });
151
152
  return { targetDir, entries };
152
153
  }
@@ -6,7 +6,7 @@ import { getEmbedding, getModelId } from "./embeddings-provider.js";
6
6
  import { scanFields, isStrictMode } from "./content-safety.js";
7
7
  import { invalidEntitiesResponse } from "./entity-vocab.js";
8
8
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
9
- import { assertValidVisibility } from "./memory-visibility.js";
9
+ import { assertValidVisibility, assertVisibilityAllowedForDurability } from "./memory-visibility.js";
10
10
  import { assertValidDurability } from "./memory-durability.js";
11
11
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
12
12
  import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
@@ -641,6 +641,20 @@ export class Memory extends databases.flair.Memory {
641
641
  return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
642
642
  }
643
643
  }
644
+ // ── flair#1257: ephemeral memories are private-only (hard precondition) ──
645
+ // The durability-keyed default below sends ephemeral to "private", but a
646
+ // default is not a constraint — an explicit visibility:"shared" here would
647
+ // make continuity-journal entries org-readable AND federation-pushed.
648
+ // Refused at the server so the boundary holds for every caller, not just
649
+ // the hooks that promise to send "private". content.durability is already
650
+ // defaulted ("standard" when absent) and enum-validated above, so this
651
+ // sees the row's effective durability.
652
+ {
653
+ const tierError = assertVisibilityAllowedForDurability(content.durability, content.visibility);
654
+ if (tierError) {
655
+ return new Response(JSON.stringify({ error: "invalid_visibility_for_durability", message: tierError }), { status: 400, headers: { "content-type": "application/json" } });
656
+ }
657
+ }
644
658
  if (content.visibility === undefined || content.visibility === null) {
645
659
  content.visibility = defaultVisibilityForDurability(content.durability);
646
660
  }
@@ -853,6 +867,23 @@ export class Memory extends databases.flair.Memory {
853
867
  return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
854
868
  }
855
869
  }
870
+ // ── flair#1257: ephemeral memories are private-only (hard precondition) ──
871
+ // Same rule as post(), with one PUT-specific wrinkle: an update payload
872
+ // may omit durability entirely (put() stamps no durability default), so
873
+ // `PUT /Memory/<id> {"visibility":"shared"}` against a stored ephemeral
874
+ // row names no durability of its own — the EFFECTIVE durability is the
875
+ // pre-existing row's, and the flip must refuse just like a fresh
876
+ // ephemeral+shared create. An explicit durability on the write wins: a
877
+ // write that promotes the row OUT of ephemeral (e.g. distillation lifting
878
+ // it to persistent, #1205) while sharing it is a legitimate promotion,
879
+ // not an ephemeral share. preExisting was fetched above.
880
+ {
881
+ const effectiveDurability = content.durability ?? preExisting?.durability;
882
+ const tierError = assertVisibilityAllowedForDurability(effectiveDurability, content.visibility);
883
+ if (tierError) {
884
+ return new Response(JSON.stringify({ error: "invalid_visibility_for_durability", message: tierError }), { status: 400, headers: { "content-type": "application/json" } });
885
+ }
886
+ }
856
887
  if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
857
888
  content.visibility = defaultVisibilityForDurability(content.durability);
858
889
  }
@@ -15,6 +15,7 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
15
15
  // retrievalCount hit-tracking side effects (see resources/
16
16
  // semantic-retrieval-core.ts's module doc for the full boundary).
17
17
  import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
18
+ import { hybridEnabled } from "./bm25.js";
18
19
  import { buildTrustBlock } from "./trust-block.js";
19
20
  import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
20
21
  import { estimateTokens } from "./token-estimate.js";
@@ -51,8 +52,9 @@ import { estimateTokens } from "./token-estimate.js";
51
52
  * (no new embedding code — Memory is the semantic surface, WorkspaceState/
52
53
  * OrgEvent are the entity surface, per the K&S verdict). Gated on
53
54
  * freshness (Presence, via the SAME internal roster path, never the raw
54
- * table) and #550's existing relevance floor. See resources/
55
- * collision-lib.ts for the pure join/rank/format logic.
55
+ * table); drawn from #550's fused-rank scored pool (flair#1246 — no
56
+ * relevance floor). See resources/collision-lib.ts for the pure
57
+ * join/rank/format logic.
56
58
  *
57
59
  * Prediction: when context signals (channel, surface, subjects) are provided,
58
60
  * the bootstrap loads more aggressively — Flair is fast enough that the
@@ -128,9 +130,12 @@ import { estimateTokens } from "./token-estimate.js";
128
130
  * memoriesAvailable` — included and truncated are disjoint sets of UNIQUE own
129
131
  * memories (a memory budget-skipped in one section but admitted in another counts
130
132
  * as included, never both). `teammateFindingsMatched` is the teammate match pool
131
- * that cleared the relevance floor; `teammateFindingsIncluded +
132
- * teammateFindingsTruncated == teammateFindingsMatched`, so "truncated" means
133
- * "relevant but no budget," not "every candidate not selected."
133
+ * that entered the scored candidate set — since flair#1246 that is the
134
+ * K-bounded fused-rank retrieval pool (no relevance floor; the historical
135
+ * `_score > 0.3` gate was measured inert and removed); `teammateFindingsIncluded
136
+ * + teammateFindingsTruncated == teammateFindingsMatched` (the #1199 contract,
137
+ * unchanged), so "truncated" means "retrieved but no budget," not "every
138
+ * candidate not selected."
134
139
  * `predictedHint` is present only when subjects were provided but `predicted`
135
140
  * came back empty.
136
141
  */
@@ -204,10 +209,18 @@ const OWN_NONPERMANENT_FETCH_LIMIT = 500;
204
209
  const AVG_LINE_TOKEN_ESTIMATE = 60;
205
210
  const MIN_CANDIDATE_POOL = 50;
206
211
  const MAX_CANDIDATE_POOL = 100;
207
- // Bootstrap's own historical relevance floor (distinct from SemanticSearch's
208
- // `minScore` request param) preserved verbatim from the original raw
209
- // JS dot-product scan's `.filter((s) => s.score > 0.3)`.
210
- const TASK_RELEVANCE_FLOOR = 0.3;
212
+ // flair#1246 the historical TASK_RELEVANCE_FLOOR (`_score > 0.3`, carried
213
+ // verbatim from the original raw JS dot-product scan) is REMOVED, not
214
+ // recalibrated: a 6-variant measurement on the shipped embedding model proved
215
+ // it inert (126 records across field-analog/nonce/contamination/control/
216
+ // max-dilution/scattered-senses shapes — nothing scored under ~0.44, so the
217
+ // floor cut zero records and never delivered "show nothing when nothing's
218
+ // relevant" either; fully-unrelated bland noise sits 0.44–0.63). Selection on
219
+ // the task-relevant surface is fused retrieval rank + the token budget. Do
220
+ // not reintroduce a constant floor — an absolute bar tight enough to exclude
221
+ // bland noise (max-dilution noise cosine 0.6086) also excludes the
222
+ // pathological on-task case (0.5656); a real relevance gate would need a
223
+ // corpus-relative instrument, which is a new feature, not a constant.
211
224
  // flair#1207 — the per-item structured-payload overhead that #1199 charged
212
225
  // against the content-selection budget (a `+ STRUCT_ITEM_OVERHEAD_TOKENS = 70`
213
226
  // added to every item's cost, PLUS a `structOverheadReserve` pre-deducted from
@@ -364,9 +377,10 @@ export class BootstrapMemories extends Resource {
364
377
  // is what let one client see included(9) > available(3).
365
378
  let teammateFindingsIncluded = 0;
366
379
  // flair#1207 — teammate findings SKIPPED for size in the task-relevant
367
- // packing loop (cleared the relevance floor but didn't fit the BUDGET).
368
- // Reported ALONGSIDE `teammateFindingsMatched` (the whole floor-clearing pool
369
- // considered), so "truncated" unambiguously means "relevant-but-no-budget",
380
+ // packing loop (entered the scored retrieval pool but didn't fit the
381
+ // BUDGET; flair#1246 the pool is the K-bounded fused-rank candidate
382
+ // set, no relevance floor). Reported ALONGSIDE `teammateFindingsMatched`
383
+ // (the whole scored pool considered), so "truncated" unambiguously means "relevant-but-no-budget",
370
384
  // NOT "every candidate not selected" — 0.44.9's `truncated:89` beside
371
385
  // `included:4` read as "89 relevant findings cut" with no pool to anchor it
372
386
  // (heskew's #1207 nit). Both are disjoint-and-exhaustive over the matched
@@ -876,12 +890,13 @@ export class BootstrapMemories extends Resource {
876
890
  }
877
891
  }
878
892
  // Collision surfacing's semantic-match candidates (flair#681) — the
879
- // BEST (highest-scoring) cross-agent memory per teammate from #550's
893
+ // BEST (top fused-rank) cross-agent memory per teammate from #550's
880
894
  // `scored` list below, captured here (before that list's tokens get
881
895
  // spent on the relevant/teammate sections) so the collision block can
882
- // reuse the IDENTICAL scored+floor-gated set without recomputing or
883
- // re-embedding anything. Stays empty when there's no currentTask (no
884
- // `scored` list is ever built) or no cross-agent hits.
896
+ // reuse the IDENTICAL scored set (flair#1246 fused retrieval rank, no
897
+ // floor gate) without recomputing or re-embedding anything. Stays empty
898
+ // when there's no currentTask (no `scored` list is ever built) or no
899
+ // cross-agent hits.
885
900
  const semanticTeammateMatches = [];
886
901
  // --- 4. Task-relevant memories (semantic search) ---
887
902
  if (currentTask && tokenBudget > 200) {
@@ -915,12 +930,27 @@ export class BootstrapMemories extends Resource {
915
930
  queryEmbedding,
916
931
  conditions: [scope.condition],
917
932
  limit: candidatePoolK,
918
- // HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion for
919
- // bootstrap a different cost profile, since BM25 over the org
920
- // corpus for a one-shot session-load could be MORE expensive than
921
- // HNSW-only unless cached across sessions. Turning it on is an
922
- // explicit opt-in follow-on, gated on its own harness run.
923
- hybrid: false,
933
+ // flair#1246 ONE RANKER, ONE SCALE: this pass now invokes the
934
+ // core in the SAME mode memory_search does (hybrid + q via the
935
+ // shared hybridEnabled() selector, so the FLAIR_HYBRID_RETRIEVAL
936
+ // kill-switch moves BOTH surfaces together a split mode IS the
937
+ // #1246 bug). HNSW-only here was an accident of early code, and it
938
+ // made bootstrap's teammate picks diverge from search on the same
939
+ // store+query: a record whose task-relevance is LEXICAL (exact task
940
+ // terms in semantically-atypical prose) ranks BELOW bland-generic
941
+ // noise on pure cosine (measured at N=21: on-task 0.5656 vs noise
942
+ // 0.6086 — HNSW rank 6 while search fused it to rank 1), and at
943
+ // field scale (corpus >> candidatePoolK) that inversion becomes
944
+ // exclusion from the K-bounded pool entirely. The BM25 leg is the
945
+ // rescue: it admits the record at lexical rank 1 and union-RRF
946
+ // fusion carries it to the top, same as search. Perf (Kern-ratified
947
+ // trade): the BM25 corpus scan this adds to the bootstrap path is
948
+ // the same per-call scan every memory_search request already runs.
949
+ hybrid: hybridEnabled(),
950
+ // The lexical leg — same query text the embedding was computed
951
+ // from, so both legs rank the same question (parity with search,
952
+ // where `q` drives BM25 and the keyword bump).
953
+ q: currentTask,
924
954
  // Per-set (this K-bounded pool only, never cross-applied to the
925
955
  // permanent/recent/predicted sets above) — see this function's
926
956
  // supersededIds docs above and resources/
@@ -960,30 +990,32 @@ export class BootstrapMemories extends Resource {
960
990
  });
961
991
  // flair#744 slice 2: the best-match confidence for the abstention
962
992
  // decision — the max absolute cosine across the retrieved pool, read
963
- // ONLY from `_semSimilarity` (never any principal/authority field). Note
964
- // this floor (ABSTENTION_THRESHOLD 0.15) sits BELOW bootstrap's own
965
- // long-standing TASK_RELEVANCE_FLOOR (0.3) that gates `scored` below, so
966
- // an abstaining task (bestSim < 0.15) already has no candidate passing
967
- // the floor abstention only ADDS an explicit "nothing covered this"
968
- // signal, it never removes a memory the reader would otherwise have seen.
993
+ // ONLY from `_semSimilarity` (never any principal/authority field).
994
+ // Abstention is unaffected by #1246's floor removal: it only ADDS an
995
+ // explicit "nothing covered this" signal against its own GLOBAL
996
+ // threshold (resources/abstention.ts), it never removes a memory the
997
+ // reader would otherwise have seen.
969
998
  if (abstain)
970
999
  taskBestSimilarity = bestSemanticSimilarity(candidates);
971
- // Preserve the ORIGINAL score > 0.3 floor exactly (bootstrap's own
972
- // historical relevance floor distinct from SemanticSearch's
973
- // `minScore` request param strict inequality, applied client-side;
974
- // `candidates` are already `_score`-sorted best-first, so filtering
975
- // preserves that order). `retrieveCandidates()`'s cosine similarity
976
- // replaces the raw JS dot product as the ranking signal (HNSW-only,
977
- // no BM25) — the K&S-ratified, closest-to-a-wash choice; the
978
- // recall harness gates any regression from this ranking-signal
979
- // change (magnitude-sensitive dot product normalized cosine).
1000
+ // flair#1246 selection is FUSED RETRIEVAL RANK + the token budget
1001
+ // in the packing loop below; there is NO score floor (see the
1002
+ // TASK_RELEVANCE_FLOOR removal note by the pool constants above —
1003
+ // measured inert on the shipped embedding model). `candidates` arrive
1004
+ // best-first on the same ranking memory_search uses (fused RRF order
1005
+ // under hybrid; see retrieveCandidates), so this filter+map preserves
1006
+ // that order. `score` carries `_score` — the honest absolute cosine
1007
+ // (+ legacy keyword bump) per #985/#1267 for display/reporting;
1008
+ // ORDER and score can legitimately disagree (a BM25 rank-1 rescue
1009
+ // outranks higher-cosine bland hits, which is the recall win).
980
1010
  const scored = candidates
981
- .filter((m) => !includedIds.has(m.id) && m._score > TASK_RELEVANCE_FLOOR)
1011
+ .filter((m) => !includedIds.has(m.id))
982
1012
  .map((m) => ({ memory: m, score: m._score }));
983
1013
  // flair#681: the collision block's semantic surface — one candidate
984
- // per teammate (the highest-scoring hit; `scored` is already sorted
985
- // desc, so the first occurrence of a given `_source` IS the best
986
- // one). `m._source` is only ever set for a cross-agent record (see
1014
+ // per teammate (`scored` is sorted best-first by the fused retrieval
1015
+ // rank, so the first occurrence of a given `_source` IS that
1016
+ // teammate's best-ranked hit; its `score` field reports the honest
1017
+ // absolute cosine, which per #985/#1267 can disagree with rank).
1018
+ // `m._source` is only ever set for a cross-agent record (see
987
1019
  // retrieveCandidates()'s `_source` tagging) — an own memory never
988
1020
  // contributes here.
989
1021
  const seenCollisionAgents = new Set();
@@ -1077,7 +1109,8 @@ export class BootstrapMemories extends Resource {
1077
1109
  // - Entity overlap (WorkspaceState + OrgEvent): exact vocabulary-string
1078
1110
  // match, high-precision, no separate relevance score needed.
1079
1111
  // - Semantic match (Memory, via #550/4 above): `semanticTeammateMatches`,
1080
- // already floor-gated (score > 0.3) reused as-is, no new scoring.
1112
+ // each teammate's best-ranked hit from the fused retrieval pool
1113
+ // (flair#1246 — no score floor) — reused as-is, no new scoring.
1081
1114
  // Gated on freshness (Presence, via the internal roster path) — a
1082
1115
  // teammate absent from the roster, or whose presenceStatus is "offline",
1083
1116
  // never surfaces regardless of how strong the entity/semantic match is.
@@ -1311,9 +1344,10 @@ export class BootstrapMemories extends Resource {
1311
1344
  if (!includedOwnIds.has(id))
1312
1345
  memoriesTruncatedUnique++;
1313
1346
  memoriesTruncated = memoriesTruncatedUnique;
1314
- // flair#1207 — the teammate MATCH POOL considered (cleared the relevance
1315
- // floor, drawn from the bounded candidate pool): every matched teammate
1316
- // finding is EITHER included OR budget-truncated, so this equals their sum.
1347
+ // flair#1207 — the teammate MATCH POOL considered (flair#1246: the
1348
+ // cross-agent records that entered the scored set drawn from the
1349
+ // K-bounded fused-rank candidate pool, no relevance floor): every matched
1350
+ // teammate finding is EITHER included OR budget-truncated, so this equals their sum.
1317
1351
  // Reporting it anchors `teammateFindingsTruncated` as "relevant-but-no-budget"
1318
1352
  // rather than an unexplained large number beside a small `included`.
1319
1353
  const teammateFindingsMatched = teammateFindingsIncluded + teammateFindingsTruncated;
@@ -1455,16 +1489,18 @@ export class BootstrapMemories extends Resource {
1455
1489
  + "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped."
1456
1490
  : undefined;
1457
1491
  // teammateFindings: [] — name WHICH legitimate empty this is (no task → no
1458
- // retrieval; matched-but-budget-truncated; or nothing cleared the relevance
1459
- // floor), so it never reads as a silent drop.
1492
+ // retrieval; matched-but-budget-truncated; or no cross-agent record entered
1493
+ // the task-relevant retrieval pool at all flair#1246: there is no
1494
+ // relevance floor, so "empty" means empty retrieval, never a score cut),
1495
+ // so it never reads as a silent drop.
1460
1496
  const teammateFindingsHint = includedTeammateFindings.length === 0
1461
1497
  ? (!taskProvided
1462
1498
  ? "No teammateFindings: cross-agent findings are retrieved against your currentTask, and none was provided. "
1463
1499
  + "Pass currentTask to populate this."
1464
1500
  : teammateFindingsTruncated > 0
1465
1501
  ? `No teammateFindings fit the token budget: ${teammateFindingsTruncated} relevant cross-agent finding(s) `
1466
- + "cleared the relevance floor but were budget-truncated. Raise maxTokens to include them."
1467
- : "No cross-agent (teammate) memory cleared the task-relevance floor for this currentTask. "
1502
+ + "were retrieved but budget-truncated. Raise maxTokens to include them."
1503
+ : "No cross-agent (teammate) memory entered the task-relevant retrieval pool for this currentTask. "
1468
1504
  + "This container is present-but-empty by design, not dropped.")
1469
1505
  : undefined;
1470
1506
  const responseBody = {
@@ -1532,8 +1568,9 @@ export class BootstrapMemories extends Resource {
1532
1568
  // size-skip self-describing: "a relevant teammate finding didn't fit" is
1533
1569
  // now distinguishable from "no relevant teammate finding".
1534
1570
  teammateFindingsTruncated,
1535
- // flair#1207 — the teammate match POOL considered (cleared the relevance
1536
- // floor). teammateFindingsIncluded + teammateFindingsTruncated ==
1571
+ // flair#1207 — the teammate match POOL considered (flair#1246: entered
1572
+ // the scored fused-rank retrieval set; no relevance floor).
1573
+ // teammateFindingsIncluded + teammateFindingsTruncated ==
1537
1574
  // teammateFindingsMatched, so "truncated" reads as "relevant-but-no-budget"
1538
1575
  // against a stated pool, not an unanchored large number.
1539
1576
  teammateFindingsMatched,
@@ -2,6 +2,8 @@ import { Resource, databases } from "harper";
2
2
  import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
3
3
  import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
4
4
  import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
5
+ import { assertValidVisibility, assertVisibilityAllowedForDurability, PRIVATE_VISIBILITY } from "./memory-visibility.js";
6
+ import { assertValidDurability } from "./memory-durability.js";
5
7
  export class FeedMemories extends Resource {
6
8
  // Self-authorize via the Ed25519 agent verify (the auth reshape removes the
7
9
  // gate's admin elevation).
@@ -53,6 +55,46 @@ export class FeedMemories extends Resource {
53
55
  headers: { "Content-Type": "application/json" },
54
56
  });
55
57
  }
58
+ // ── Write-side durability/visibility validation (#1009/#1238/#1257) ─────
59
+ // This endpoint writes via the RAW table object below — NOT the exported
60
+ // Memory resource — so it inherits NONE of Memory.post()/put()'s write
61
+ // guards (Sherlock's #1261 review: ephemeral+shared, and any invalid
62
+ // visibility or durability, landed through POST /FeedMemories untouched).
63
+ // The three guards are applied here in the same order as Memory.post().
64
+ //
65
+ // Placed BEFORE the content-hash dedup early-return, deliberately: a
66
+ // refused combination must refuse deterministically, not return 200 with
67
+ // the existing record whenever a duplicate happens to exist.
68
+ //
69
+ // The effective durability for the tier rule is the one the record below
70
+ // actually stamps — `content.durability ?? "standard"`. A raw table put
71
+ // REPLACES the row, so even an update-in-place that omits durability
72
+ // produces a "standard" row regardless of what it replaces; the stored
73
+ // row's tier is decided entirely by this payload.
74
+ const durability = content.durability ?? "standard";
75
+ {
76
+ const durabilityError = assertValidDurability(content.durability);
77
+ if (durabilityError) {
78
+ return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), {
79
+ status: 400,
80
+ headers: { "Content-Type": "application/json" },
81
+ });
82
+ }
83
+ const visibilityError = assertValidVisibility(content.visibility);
84
+ if (visibilityError) {
85
+ return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), {
86
+ status: 400,
87
+ headers: { "Content-Type": "application/json" },
88
+ });
89
+ }
90
+ const tierError = assertVisibilityAllowedForDurability(durability, content.visibility);
91
+ if (tierError) {
92
+ return new Response(JSON.stringify({ error: "invalid_visibility_for_durability", message: tierError }), {
93
+ status: 400,
94
+ headers: { "Content-Type": "application/json" },
95
+ });
96
+ }
97
+ }
56
98
  const now = new Date().toISOString();
57
99
  const contentHash = computeContentHash(agentId, body);
58
100
  const existing = await findExistingMemoryByContentHash(databases.flair.Memory.search(), agentId, contentHash);
@@ -64,11 +106,24 @@ export class FeedMemories extends Resource {
64
106
  agentId,
65
107
  content: body,
66
108
  contentHash,
67
- durability: content.durability ?? "standard",
109
+ durability,
68
110
  createdAt: content.createdAt ?? now,
69
111
  updatedAt: content.updatedAt ?? now,
70
112
  archived: content.archived ?? false,
71
113
  };
114
+ // flair#1257, omission leak: this endpoint stamps NO durability-keyed
115
+ // visibility default (unlike Memory.post/put — Layer 1), so an ephemeral
116
+ // feed write with visibility omitted would land with no visibility field
117
+ // at all, which the read side resolves to NON-private (the migration
118
+ // invariant). The refusal guard above cannot see an omission, so the
119
+ // private-only tier invariant is closed here by stamping "private" on
120
+ // exactly the ephemeral case. Deliberately NOT the general durability-
121
+ // keyed default: stamping it for standard/persistent/permanent would flip
122
+ // the visibility of every existing feed caller's writes — a behavioural
123
+ // change this fix must not smuggle in.
124
+ if (record.durability === "ephemeral" && (record.visibility === undefined || record.visibility === null)) {
125
+ record.visibility = PRIVATE_VISIBILITY;
126
+ }
72
127
  await databases.flair.Memory.put(record);
73
128
  return record;
74
129
  }
@@ -54,8 +54,14 @@ export class MemoryMaintenance extends Resource {
54
54
  if (targetAgent && record.agentId !== targetAgent)
55
55
  continue;
56
56
  stats.total++;
57
- // 1. Delete expired memories
58
- if (record.expiresAt && new Date(record.expiresAt) < now) {
57
+ // 1. Delete expired ephemeral memories. expiresAt is only a reap
58
+ // signal for the ephemeral tier (docstring + Memory.post() TTL
59
+ // stamp). A non-ephemeral row that acquired one (bug, import, API
60
+ // misuse) must survive — missing / unexpected durability is treated
61
+ // as non-ephemeral so we do not silently reap durable rows.
62
+ if (record.durability === "ephemeral" &&
63
+ record.expiresAt &&
64
+ new Date(record.expiresAt) < now) {
59
65
  if (!dryRun) {
60
66
  try {
61
67
  await databases.flair.Memory.delete(record.id);
@@ -46,11 +46,13 @@
46
46
  * GLOBAL / data-driven, NEVER per-principal (Sherlock binding condition 2).
47
47
  *
48
48
  * CONSERVATIVE hand-set value (0.15): well below the strong-match band real
49
- * embeddings produce for genuinely relevant memories, and below bootstrap's
50
- * own long-standing task-relevance floor (0.3, resources/MemoryBootstrap.ts's
51
- * TASK_RELEVANCE_FLOOR) — so abstention fires only when there is essentially
52
- * nothing semantically near the query, erring toward returning results rather
53
- * than over-abstaining. Promoting abstention to the DEFAULT recall mode, and
49
+ * embeddings produce for genuinely relevant memories (and below anything the
50
+ * flair#1246 measurement observed on the shipped model — 126 records across 6
51
+ * synthetic variants all scored ≥ ~0.44) — so abstention fires only when
52
+ * there is essentially nothing semantically near the query, erring toward
53
+ * returning results rather than over-abstaining. (Bootstrap's own
54
+ * TASK_RELEVANCE_FLOOR, which this comment once referenced, was removed by
55
+ * flair#1246 — that same measurement proved it inert.) Promoting abstention to the DEFAULT recall mode, and
54
56
  * tuning this value on the recall-bench corpus, is a SEPARATE follow-up (see
55
57
  * flair#744) — this slice ships the response shape at a safe opt-in floor, not
56
58
  * the calibrated default.
@@ -100,10 +102,11 @@ export const STRONG_BAND = 0.55;
100
102
  * Reads ONLY the `_semSimilarity` number the retrieval core
101
103
  * (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
102
104
  * WHEN abstention is requested — an absolute cosine similarity in [0,1],
103
- * independent of the RRF normalization that makes the ranking `_score`
104
- * a *relative* signal (the top RRF-fused result is normalized to 1.0 regardless
105
- * of how weak the actual match is, so `_score` is unusable as a confidence
106
- * floor this is why abstention reads the absolute similarity instead).
105
+ * independent of the RRF fusion that orders hybrid results. (Historically the
106
+ * hybrid `_score` was itself RRF rank-normalized — top result pinned at 1.0
107
+ * however weak the match — which is why this reads the dedicated absolute
108
+ * field; since flair#985 the raw `_score` is absolute too, and
109
+ * `_semSimilarity` stays the explicit opt-in confidence channel.)
107
110
  *
108
111
  * Returns null when NO candidate carries a `_semSimilarity` (no embedding-based
109
112
  * match at all — e.g. a keyword-only degraded search, or an empty pool). Per
@@ -4,6 +4,7 @@ import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, isPrincipalDeactivated, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
5
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
+ import { NOT_FOUND } from "./record-type-kit.js";
7
8
  import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
8
9
  import { checkHttpRateLimit } from "./rate-limit.js";
9
10
  // --- Admin credentials ---
@@ -638,11 +639,18 @@ server.http(async (request, nextLayer) => {
638
639
  // used to be a `visibility === "office"` bypass (any authenticated
639
640
  // agent, no grant needed) — that's gone; the private-exclusion is
640
641
  // now enforced the same way every other read path enforces it.
642
+ //
643
+ // Denial is the SAME 404 the resource layer returns (flair#1264):
644
+ // Memory.get() deliberately answers NOT_FOUND for a cross-agent
645
+ // private id so a denied caller can't distinguish "doesn't exist"
646
+ // from "exists but not yours" — a 403 here, worse yet one naming
647
+ // the owning agent, confirmed the id exists AND disclosed its
648
+ // owner, defeating that anti-enumeration contract one layer up.
649
+ // Reuses record-type-kit's NOT_FOUND so the two layers cannot
650
+ // drift apart in shape.
641
651
  const scope = await resolveReadScope(agentId);
642
652
  if (!scope.isAllowed(record)) {
643
- return new Response(JSON.stringify({
644
- error: `forbidden: cannot read memory owned by ${record.agentId}`,
645
- }), { status: 403, headers: { "Content-Type": "application/json" } });
653
+ return NOT_FOUND();
646
654
  }
647
655
  }
648
656
  }
@@ -107,9 +107,13 @@ export function rrfScores(rankings, universe) {
107
107
  return score;
108
108
  }
109
109
  // Fuse semantic + BM25 candidate id-lists via candidate-union RRF and return a
110
- // per-id score normalized to [0,1] (rrf / max_rrf_in_union). This normalized
111
- // value is the rawScore fed to compositeScore so durability/recency/rBoost and
112
- // the RBOOST_RELEVANCE_FLOOR / minScore thresholds still apply unchanged.
110
+ // per-id score normalized to [0,1] (rrf / max_rrf_in_union). The top-ranked id
111
+ // is pinned at exactly 1.0 BY CONSTRUCTION this is a RANKING value, not a
112
+ // similarity, and must never be reported as one (flair#985: reporting it as
113
+ // `_score` made every stale flair-client dedup gate see a ≥0.95 "similarity"
114
+ // on EVERY store and silently drop the write). semantic-retrieval-core.ts uses
115
+ // it to ORDER hybrid results (and as compositeScore's ranking input); the
116
+ // reported raw `_score` is the absolute cosine, computed separately.
113
117
  //
114
118
  // semIds — semantic candidate ids, best-first (from the HNSW pass).
115
119
  // bm25Ids — BM25 candidate ids, best-first, already sliced to SEM_LIMIT and
@@ -28,6 +28,26 @@
28
28
  *
29
29
  * Read from `FLAIR_MCP_OAUTH` — truthy values: "1", "true", "yes", "on"
30
30
  * (case-insensitive). Anything else (incl. unset / empty) → OFF.
31
+ *
32
+ * ASYMMETRY (load-bearing, flair#1152, measured on oauth 2.5.0): config.yaml's
33
+ * `mcp.enabled: ${FLAIR_MCP_OAUTH}` hands the SAME env var to
34
+ * @harperfast/oauth, but the two readers accept DIFFERENT vocabularies. The
35
+ * component's coerceConfigBoolean takes ONLY "true"/"false" and DELETES any
36
+ * other string (unresolved placeholder, "1", "yes", garbage) so its disabled
37
+ * default applies; this function takes 1/true/yes/on. Consequences:
38
+ * - "true" is the ONE value that enables both sides (`flair mcp enable`
39
+ * stages exactly that).
40
+ * - "1"/"yes"/"on" turn flair's /mcp handler ON while the component AS
41
+ * stays OFF — fail-closed broken-on (every request 401s, no AS
42
+ * advertised).
43
+ * - garbage (e.g. "maybe") disables BOTH: the component deletes it, this
44
+ * stays false, no /mcp handler exists — no data path (flair's own
45
+ * discovery documents still serve whenever this flag is off, by design).
46
+ * If the component's vocabulary ever widens back to truthy-string, a garbage
47
+ * value would mount a live AS next to an unregistered /mcp — re-derive the
48
+ * garbage case (test/integration/mcp-oauth-boot-safety.test.ts) before
49
+ * relying on it, and NEVER let component `enabled` drive flair's handler
50
+ * registration directly without re-deriving that table.
31
51
  */
32
52
  export function mcpOAuthEnabled() {
33
53
  const raw = (process.env.FLAIR_MCP_OAUTH ?? "").trim().toLowerCase();
@@ -123,7 +123,12 @@ export async function registerMcpOAuthRoute(deps = {}) {
123
123
  return decide({
124
124
  mounted: false,
125
125
  status: "Not enabled",
126
- reason: "Set FLAIR_MCP_OAUTH=1 (and an issuer) to serve MCP over HTTP.",
126
+ // "true" not "1": flair's flag accepts either, but the component's
127
+ // config-side read of the same var (config.yaml `mcp.enabled:
128
+ // ${FLAIR_MCP_OAUTH}`, flair#1152) accepts ONLY "true"/"false" — with
129
+ // "1" the /mcp route registers and every request 401s against a
130
+ // component that never mounted its AS.
131
+ reason: "Set FLAIR_MCP_OAUTH=true (and an issuer) to serve MCP over HTTP.",
127
132
  });
128
133
  }
129
134
  // Boot guard (flair#1021): fail loudly if the operator enabled the flag but