@tpsdev-ai/flair 0.44.13 → 0.45.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/dist/cli.js CHANGED
@@ -3743,6 +3743,7 @@ agent
3743
3743
  .option("--name <name>", "Display name (defaults to id)")
3744
3744
  .option("--port <port>", "Harper HTTP port")
3745
3745
  .option("--admin-pass <pass>", "Admin password for registration")
3746
+ .option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
3746
3747
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
3747
3748
  .option("--ops-port <port>", "Harper operations API port")
3748
3749
  .option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
@@ -3759,6 +3760,22 @@ agent
3759
3760
  // `flair import`: explicit --ops-target > derive from --target > localhost.
3760
3761
  const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
3761
3762
  const isRemoteTarget = typeof seedOpsTarget === "string";
3763
+ // flair#1259 — --admin-pass-file resolves into the same explicit slot the
3764
+ // inline flag uses (same shape as `flair federation sync`), read in-process
3765
+ // via readAdminPassFileSecure so the secret never appears in ps or shell
3766
+ // history. This does NOT weaken the #1085 remote guard below: an explicit
3767
+ // flag naming a file IS operator intent toward this target, exactly like an
3768
+ // explicit inline --admin-pass — what the guard blocks is the AMBIENT
3769
+ // env/local-file fallbacks silently traveling to a third-party host.
3770
+ if (!opts.adminPass && opts.adminPassFile) {
3771
+ try {
3772
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
3773
+ }
3774
+ catch (err) {
3775
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
3776
+ process.exit(1);
3777
+ }
3778
+ }
3762
3779
  // #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
3763
3780
  // ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
3764
3781
  // applied for a remote target — see resolveLocalAdminPass.
@@ -3772,12 +3789,13 @@ agent
3772
3789
  }
3773
3790
  if (!adminPass) {
3774
3791
  if (isRemoteTarget) {
3775
- console.error("Error: --admin-pass is required for agent add when targeting a remote instance " +
3776
- "(--target/--ops-target) — the local ~/.flair/admin-pass fallback is not used for remote targets.");
3792
+ console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add when targeting " +
3793
+ "a remote instance (--target/--ops-target) — the local ~/.flair/admin-pass and FLAIR_ADMIN_PASS " +
3794
+ "fallbacks are never used for remote targets. Prefer --admin-pass-file: it keeps the secret out of ps.");
3777
3795
  }
3778
3796
  else {
3779
- console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table). " +
3780
- "Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
3797
+ console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add (needed to insert " +
3798
+ "into Agent table). Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
3781
3799
  }
3782
3800
  process.exit(1);
3783
3801
  }
@@ -13891,18 +13909,26 @@ sessionSnapshot
13891
13909
  console.error(` Pass --target <new-path> or remove the existing dir.`);
13892
13910
  process.exit(1);
13893
13911
  }
13912
+ // flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
13913
+ // do contain malicious entries (leading "/" stripped, ".." entries
13914
+ // dropped, no writing through a symlink — verified on the pinned tar
13915
+ // against all four vectors), but they discard those entries SILENTLY: the
13916
+ // restore printed the target dir as a plain success minus the parts it
13917
+ // never mentioned. Validation runs BEFORE the target directory is even
13918
+ // created — a tampered snapshot aborts the whole restore, names the
13919
+ // offending entry, and writes nothing (same posture as the data-dir
13920
+ // restore's extractSnapshotSafely). The default extract flags stay as
13921
+ // containment defense-in-depth — deliberately NOT preservePaths; if that
13922
+ // flag is ever added here, this call MUST move to extractSnapshotSafely.
13923
+ // See src/lib/safe-snapshot-extract.ts.
13924
+ try {
13925
+ await validateSnapshotArchive({ file: snapshotPath, targetDir });
13926
+ }
13927
+ catch (err) {
13928
+ console.error(`Error: ${err.message}`);
13929
+ process.exit(1);
13930
+ }
13894
13931
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
13895
- // Deliberately NOT preservePaths, and deliberately NOT routed through
13896
- // extractSnapshotSafely. --snapshot is an operator-supplied path, so
13897
- // provenance here is no more controlled than the data-dir restore's — the
13898
- // difference is the flag, not the trust. node-tar's defaults keep their
13899
- // own containment: leading "/" stripped from entry paths, ".." entries
13900
- // dropped, and no writing through a symlink (including one created
13901
- // earlier in the same archive). Verified against the pinned tar (7.5.20)
13902
- // on all four cases, each contained, with a benign control entry landing
13903
- // to prove the archives parsed. Add `preservePaths` here and that
13904
- // containment disappears — this call would then need extractSnapshotSafely,
13905
- // exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
13906
13932
  await tarExtract({ file: snapshotPath, cwd: targetDir });
13907
13933
  console.log(targetDir);
13908
13934
  console.error(` extracted to: ${targetDir}`);
@@ -68,6 +68,18 @@ function hasDotDotSegment(p) {
68
68
  * `linkpath` is the entry's link target, when it has one.
69
69
  */
70
70
  export function checkSnapshotEntry(entryPath, type, linkpath, resolvedTargetDir) {
71
+ // flair#901 fail-closed shape checks: an entry this check cannot READ is an
72
+ // entry it must not PASS. An empty path used to resolve to the target dir
73
+ // itself (inside, so ok), and a link entry with no linkpath skipped the
74
+ // link branches entirely — both silently approved exactly when the input
75
+ // was least trustworthy (tampered archive, or an upstream property rename
76
+ // surviving to runtime).
77
+ if (!entryPath) {
78
+ return { ok: false, reason: "entry has an empty or unreadable path — refusing an entry this check cannot classify" };
79
+ }
80
+ if ((type === "Link" || type === "SymbolicLink") && !linkpath) {
81
+ return { ok: false, reason: `${type === "Link" ? "hard link" : "symlink"} "${entryPath}" has no readable link target — refusing an entry this check cannot classify` };
82
+ }
71
83
  if (looksAbsolute(entryPath)) {
72
84
  return { ok: false, reason: `entry has an absolute path ("${entryPath}"), which would write outside the target directory` };
73
85
  }
@@ -154,14 +166,20 @@ export async function validateSnapshotArchive(opts) {
154
166
  const violations = [];
155
167
  await tarList({
156
168
  file: opts.file,
169
+ // Typed against tar's ReadEntry (flair#901) — a property rename upstream
170
+ // now fails compilation instead of coercing to ""/undefined at runtime.
171
+ // The coercions below remain as the runtime belt, and they no longer
172
+ // default to safe: checkSnapshotEntry refuses an empty path and a
173
+ // link-typed entry with no link target.
157
174
  onReadEntry: (entry) => {
158
- const verdict = checkSnapshotEntry(String(entry.path ?? ""), entry.type, entry.linkpath ? String(entry.linkpath) : undefined, resolvedTargetDir);
175
+ const entryPath = typeof entry.path === "string" ? entry.path : "";
176
+ const verdict = checkSnapshotEntry(entryPath, entry.type, typeof entry.linkpath === "string" && entry.linkpath !== "" ? entry.linkpath : undefined, resolvedTargetDir);
159
177
  // `verdict.ok === false` rather than `!verdict.ok`: an explicit
160
178
  // comparison against the literal discriminant is what narrows the union
161
179
  // to its failure member, so `reason` is known to exist here. The union
162
180
  // is deliberately shaped so a caller cannot read a reason off a success.
163
181
  if (verdict.ok === false) {
164
- violations.push({ entryPath: String(entry.path ?? ""), reason: verdict.reason });
182
+ violations.push({ entryPath, reason: verdict.reason });
165
183
  }
166
184
  },
167
185
  });
@@ -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
  }
@@ -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
@@ -79,3 +79,51 @@ export function assertValidVisibility(visibility) {
79
79
  `(got: ${JSON.stringify(visibility)}). Omit it to use the durability-keyed default: ` +
80
80
  `permanent/persistent -> shared, standard/ephemeral -> private.`);
81
81
  }
82
+ /** Deliberately a LOCAL literal, not an import from memory-durability.ts: this
83
+ * module's zero-imports property is load-bearing (see the header — src/cli.ts
84
+ * must be able to import it with no transitive baggage), and the tripwire
85
+ * tests in test/unit/visibility-write-validation.test.ts pin the value to the
86
+ * durability enum so the two cannot drift apart silently. */
87
+ export const EPHEMERAL_DURABILITY = "ephemeral";
88
+ /**
89
+ * ─── flair#1257 hard precondition: ephemeral memories are private-only ───────
90
+ *
91
+ * `ephemeral` is the continuity-journal tier: auto-captured working state,
92
+ * self-pruning, never meant to leave its owner. `defaultVisibilityForDurability`
93
+ * keys it to `private`, but a DEFAULT is not a CONSTRAINT — before this guard,
94
+ * an explicit `visibility:"shared"` on an ephemeral write was accepted, which
95
+ * would have made journal entries org-readable AND federation-pushed. Kern's
96
+ * #1257 ruling requires the server to REFUSE the combination so the boundary
97
+ * holds for every caller (REST, in-process, any adapter), not just the hooks
98
+ * that promise to send `private` explicitly.
99
+ *
100
+ * The rule is deliberately "ephemeral may only carry `private` or nothing",
101
+ * NOT "refuse ephemeral+shared": on the read side any value other than the
102
+ * literal "private" resolves to non-private (the migration invariant above),
103
+ * so an unknown value on an ephemeral row would leak exactly like "shared".
104
+ * assertValidVisibility refuses unknowns first at both call sites, but this
105
+ * guard must stay fail-closed on its own — unknown means refused, not allowed.
106
+ *
107
+ * Absent (`undefined`/`null`) is accepted: it resolves through the
108
+ * durability-keyed default, which for ephemeral is `private` — the documented,
109
+ * intentional path (and the one the continuity hooks use, belt-and-suspenders
110
+ * with an explicit `private`).
111
+ *
112
+ * Returns an error message, or null when the combination is acceptable.
113
+ * `durability` is the EFFECTIVE durability of the row being written — for
114
+ * Memory.put() updates, where the payload may omit durability, the caller
115
+ * passes the pre-existing row's durability as the fallback so a PUT that flips
116
+ * a stored ephemeral row to shared refuses too.
117
+ */
118
+ export function assertVisibilityAllowedForDurability(durability, visibility) {
119
+ if (durability !== EPHEMERAL_DURABILITY)
120
+ return null;
121
+ if (visibility === undefined || visibility === null || visibility === PRIVATE_VISIBILITY) {
122
+ return null;
123
+ }
124
+ return (`ephemeral memories are private-only (continuity journal tier, flair#1257): ` +
125
+ `durability "${EPHEMERAL_DURABILITY}" cannot be written with visibility ${JSON.stringify(visibility)}. ` +
126
+ `Omit visibility (the durability-keyed default is "${PRIVATE_VISIBILITY}") or set it to ` +
127
+ `"${PRIVATE_VISIBILITY}"; for a memory other agents should read, use durability ` +
128
+ `"standard", "persistent", or "permanent".`);
129
+ }
@@ -29,14 +29,29 @@
29
29
  // withDetachedTxn's transaction-chain workaround — both SemanticSearch and
30
30
  // MemoryBootstrap are Harper Resources with their own `ctx`).
31
31
  //
32
- // Returns results AFTER all filters, sorted best-first by `_score` bounded
33
- // ONLY by the `limit` the caller chose to push down (the core never
32
+ // Returns results AFTER all filters, sorted best-first by RETRIEVAL RANK
33
+ // bounded ONLY by the `limit` the caller chose to push down (the core never
34
34
  // multiplies `limit` internally; any overfetch policy — SemanticSearch's
35
35
  // CANDIDATE_MULTIPLIER, MemoryBootstrap's K formula — is the CALLER's
36
36
  // decision, made
37
37
  // before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
38
38
  // legacy HNSW-only vs. keyword-only fallback) produced a given result — the
39
39
  // output shape is identical regardless of `hybrid`.
40
+ //
41
+ // ── SCORE CONTRACT (flair#985) ───────────────────────────────────────────────
42
+ // `_score` under `scoring:"raw"` is ALWAYS an ABSOLUTE similarity (cosine of
43
+ // the query and the record's stored embedding, plus the legacy +0.05 substring
44
+ // keyword bump) — on every path, hybrid included. It is NEVER a
45
+ // rank-normalized value. Ordering and score are deliberately decoupled on the
46
+ // hybrid path: results are ORDERED by the fused RRF rank (that ordering is the
47
+ // hybrid recall win), but each result's `_score` reports its true evidence, so
48
+ // order and `_score` can disagree. The pre-#985 hybrid path reported the
49
+ // normalized RRF value AS `_score`, which pinned the top result of ANY query
50
+ // at 1.0 — and every consumer thresholding `_score` as a similarity (the
51
+ // pre-0.18 flair-client dedup gate at 0.95, `minScore`, `flair doctor`'s
52
+ // embed-verify probe) failed open at maximal confidence. For the dedup gate
53
+ // that meant EVERY memory_store from a stale client silently dropped its
54
+ // content into the arbitrary top-1 match — the #985 data-loss report.
40
55
  import { databases } from "harper";
41
56
  import { withDetachedTxn } from "./table-helpers.js";
42
57
  import { wrapUntrusted } from "./content-safety.js";
@@ -85,10 +100,12 @@ export async function retrieveCandidates(params) {
85
100
  // ── (a) Semantic candidate records (best-first) ──────────────────────
86
101
  const semRecords = [];
87
102
  const semIds = [];
88
- // flair#744 slice 2: absolute cosine similarity per semantic candidate
89
- // (from the HNSW `$distance`), captured HERE before `$distance` is stripped
90
- // downstream the confidence signal the abstention decision reads (only
91
- // when `withSemSimilarity`; empty/unused otherwise).
103
+ // Absolute cosine similarity per semantic candidate (from the HNSW
104
+ // `$distance`), captured HERE before `$distance` is stripped downstream.
105
+ // Captured UNCONDITIONALLY (flair#985): this is the value `_score` reports
106
+ // under `scoring:"raw"` see the fused loop below — and, when
107
+ // `withSemSimilarity` (flair#744 slice 2), also the confidence signal the
108
+ // abstention decision reads via the opt-in `_semSimilarity` field.
92
109
  const semSimById = new Map();
93
110
  if (qEmb) {
94
111
  const semQuery = {
@@ -119,9 +136,20 @@ export async function retrieveCandidates(params) {
119
136
  continue;
120
137
  if (!passesAllowed(record))
121
138
  continue;
122
- if (withSemSimilarity && record.$distance !== undefined) {
139
+ if (record.$distance !== undefined) {
123
140
  semSimById.set(record.id, distanceToSimilarity(record.$distance));
124
141
  }
142
+ else {
143
+ // Harper's cosine-sort query omits `$distance` for a SINGLETON
144
+ // post-filter result set (see the legacy path below and
145
+ // resources/SemanticSearch.ts's original writeup). Point-lookup the
146
+ // record and compute cosine ourselves from its real stored
147
+ // embedding — a missing/empty stored embedding yields 0 (safe "no
148
+ // semantic evidence"), never a false-high score.
149
+ const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
150
+ const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
151
+ semSimById.set(record.id, cosineSimilarity(qEmb, storedEmbedding));
152
+ }
125
153
  semRecords.push(record);
126
154
  semIds.push(record.id);
127
155
  }
@@ -175,40 +203,78 @@ export async function retrieveCandidates(params) {
175
203
  _score: Math.round(finalScore * 1000) / 1000,
176
204
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
177
205
  _source: source,
206
+ _rank: finalScore,
178
207
  });
179
208
  }
180
209
  }
181
210
  else {
182
- // ── Candidate-union RRF → normalized [0,1] rawScore ────────────────
211
+ // ── Candidate-union RRF → normalized [0,1] RANKING value ────────────
212
+ // flair#985: the fused RRF value ORDERS results but is never REPORTED
213
+ // as a score. RRF normalization pins the top candidate at exactly 1.0
214
+ // regardless of how weak the real match is — reporting it as `_score`
215
+ // (the pre-#985 behavior) silently changed the meaning of `_score` from
216
+ // "absolute similarity, 0.95 ≈ near-duplicate" to "relative rank". Every
217
+ // consumer that thresholds `_score` as a similarity then fails OPEN at
218
+ // maximal confidence: the pre-0.18 flair-client dedup gate (`score >=
219
+ // 0.95` → suppress the write) suppressed EVERY memory_store into the
220
+ // arbitrary top-1 — however unrelated — which is the #985 field report
221
+ // (4/5 writes silently lost cross-topic). `minScore`, `flair doctor`'s
222
+ // embed-verify probe, and compositeScore's relevance floors all carry
223
+ // the same absolute-scale expectation. So: rank by fusion (`_rank`,
224
+ // internal, stripped before return — the hybrid recall win lives in the
225
+ // fused ORDER), report absolute evidence (`_score` = true cosine + the
226
+ // legacy keyword bump, same scale as the legacy HNSW-only path below).
183
227
  const fused = fuseRrfNormalized(semIds, bm25Ids);
184
228
  for (const [id, rrfRaw] of fused) {
185
229
  const record = allowedById.get(id);
186
230
  if (!record)
187
231
  continue; // should not happen — union ⊆ allowed
188
- const rawScore = rrfRaw; // already normalized to [0,1]
189
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
232
+ // Absolute semantic similarity for this candidate. Sem-leg candidates
233
+ // already carry it (captured above, incl. the singleton-`$distance`
234
+ // fallback). A BM25-only candidate never went through the HNSW leg —
235
+ // point-lookup its stored embedding and compute the true cosine, so a
236
+ // genuinely-relevant lexical rescue reports its real similarity
237
+ // instead of a fabricated one (missing/legacy embedding ⇒ 0, safe).
238
+ let semSim = semSimById.get(id);
239
+ if (semSim === undefined && qEmb) {
240
+ const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(id));
241
+ const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
242
+ semSim = cosineSimilarity(qEmb, storedEmbedding);
243
+ semSimById.set(id, semSim);
244
+ }
245
+ let keywordHit = false;
246
+ if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
247
+ keywordHit = true;
248
+ }
249
+ const rawScore = (semSim ?? 0) + (keywordHit ? 0.05 : 0);
250
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rrfRaw, record);
190
251
  if (temporalBoost > 1.0)
191
252
  finalScore *= temporalBoost;
192
253
  const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
193
254
  const source = record.agentId !== agentId ? record.agentId : undefined;
194
- // flair#744 slice 2: absolute cosine confidence for the abstention
195
- // decision — only for records that had a semantic (HNSW) candidate; a
196
- // BM25-lexical-only match carries no cosine and contributes none.
197
- const semSim = withSemSimilarity ? semSimById.get(id) : undefined;
198
255
  results.push({
199
256
  ...record,
200
257
  content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
201
258
  _score: Math.round(finalScore * 1000) / 1000,
202
259
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
203
260
  _source: source,
204
- ...(semSim !== undefined ? { _semSimilarity: semSim } : {}),
261
+ // Ordering key: fused rank for raw mode; composite value for
262
+ // composite mode (composite ordering is unchanged by #985 — its
263
+ // rrfRaw input and result order are exactly the pre-#985 behavior).
264
+ _rank: scoring === "raw" ? rrfRaw : finalScore,
265
+ // flair#744 slice 2: the opt-in absolute-confidence field for the
266
+ // abstention decision. Attach remains OPT-IN so non-abstain
267
+ // responses stay byte-identical (the capture above is now
268
+ // unconditional, but the response field is not).
269
+ ...(withSemSimilarity && semSim !== undefined ? { _semSimilarity: semSim } : {}),
205
270
  });
206
271
  }
207
272
  }
208
273
  }
209
274
  else if (qEmb) {
210
- // ─── HNSW vector search path (legacy, hybrid flag OFF — or a caller
211
- // like MemoryBootstrap forcing HNSW-leg-only regardless of the flag) ────
275
+ // ─── HNSW vector search path (legacy, hybrid flag OFF — the
276
+ // FLAIR_HYBRID_RETRIEVAL kill-switch path for BOTH production callers
277
+ // since flair#1246) ─────────────────────────────────────────────────────
212
278
  const query = {
213
279
  sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
214
280
  select: hnswSelect,
@@ -269,6 +335,7 @@ export async function retrieveCandidates(params) {
269
335
  _score: Math.round(finalScore * 1000) / 1000,
270
336
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
271
337
  _source: source,
338
+ _rank: finalScore,
272
339
  ...(withSemSimilarity ? { _semSimilarity: semanticScore } : {}),
273
340
  });
274
341
  }
@@ -314,6 +381,7 @@ export async function retrieveCandidates(params) {
314
381
  _score: Math.round(finalScore * 1000) / 1000,
315
382
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
316
383
  _source: source,
384
+ _rank: finalScore,
317
385
  });
318
386
  }
319
387
  }
@@ -331,10 +399,22 @@ export async function retrieveCandidates(params) {
331
399
  }
332
400
  filteredResults = results.filter((r) => !supersededIds.has(r.id));
333
401
  }
334
- // Apply minimum score filter
402
+ // Apply minimum score filter — against `_score`, which is ALWAYS on the
403
+ // absolute-similarity scale after flair#985 (the hybrid path used to report
404
+ // the rank-normalized RRF value here, so `minScore: 0.95` matched the
405
+ // always-1.0 top-1 of ANY query instead of meaning "similarity ≥ 0.95").
335
406
  if (minScore > 0) {
336
407
  filteredResults = filteredResults.filter((r) => r._score >= minScore);
337
408
  }
338
- filteredResults.sort((a, b) => b._score - a._score);
409
+ // Order by the internal ranking key (fused RRF rank on the hybrid raw path;
410
+ // identical to `_score` everywhere else), then strip it — `_rank` is an
411
+ // ordering key, never part of the response shape. Note the hybrid raw
412
+ // ordering is deliberately NOT by `_score`: the recall win of hybrid
413
+ // retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
414
+ // semantic hits), while `_score` carries the honest absolute evidence for
415
+ // each result — the two can disagree, and that is correct.
416
+ filteredResults.sort((a, b) => b._rank - a._rank);
417
+ for (const r of filteredResults)
418
+ delete r._rank;
339
419
  return filteredResults;
340
420
  }
@@ -50,7 +50,7 @@ The target defaults to `https://<cluster>.<org>.harperfabric.com`. Override with
50
50
 
51
51
  A fleet-verify table follows. That HTTPS origin is your `FLAIR_URL`.
52
52
 
53
- Then set an admin password in Fabric Studio (Cluster Settings → Admin). `flair agent add` against a remote instance requires `--admin-pass` — it will not reuse `~/.flair/admin-pass` or `FLAIR_ADMIN_PASS` from your laptop.
53
+ Then set an admin password in Fabric Studio (Cluster Settings → Admin). `flair agent add` against a remote instance requires an explicit `--admin-pass-file` or `--admin-pass` — it will not reuse `~/.flair/admin-pass` or `FLAIR_ADMIN_PASS` from your laptop.
54
54
 
55
55
  ## 3. Register an agent against the remote instance
56
56
 
@@ -61,10 +61,15 @@ On Fabric, ops lives on the **same hostname at port 9925**, not the CLI's defaul
61
61
  ```bash
62
62
  export FLAIR_URL=https://<cluster>.<org>.harperfabric.com
63
63
 
64
+ # Keep the Fabric admin password in an owner-only file; the CLI reads it in-process.
65
+ printf '%s\n' '<fabric-admin-password>' > ~/.flair/fabric-admin-pass && chmod 600 ~/.flair/fabric-admin-pass
66
+
64
67
  # Fabric ops is :9925 on the same host, not derived :442. docs-freshness-allow: Fabric ops API
65
- flair agent add mybot --target "$FLAIR_URL" --ops-target https://<cluster>.<org>.harperfabric.com:9925 --admin-pass <fabric-admin-password>
68
+ flair agent add mybot --target "$FLAIR_URL" --ops-target https://<cluster>.<org>.harperfabric.com:9925 --admin-pass-file ~/.flair/fabric-admin-pass
66
69
  ```
67
70
 
71
+ `--admin-pass-file` reads the file inside the CLI process (mode `0600` enforced), so the password never appears in shell history **or** `ps`. Inline `--admin-pass <pass>` still works but lands in both; the older `--admin-pass "$(cat <path>)"` workaround stays out of history but is still visible to local `ps` while the command runs. Remote `agent add` honors **only** an explicit flag by design — `FLAIR_ADMIN_PASS` and `~/.flair/admin-pass` are this machine's *local* credentials and are never reused against a remote target.
72
+
68
73
  ```
69
74
  Keypair written: ~/.flair/keys/mybot.key
70
75
  ✅ Agent 'mybot' (mybot) registered (ops: https://<cluster>.<org>.harperfabric.com:9925) <!-- docs-freshness-allow: Fabric ops API -->
@@ -86,7 +91,41 @@ In Cursor: **Plugins → Configure**
86
91
 
87
92
  Those are the two plugin schema fields. Local Cursor's `npx` can use the key from step 3 at `~/.flair/keys/mybot.key`. A cloud agent's `npx` runs on a different machine — that VM needs the key (or host-env admin credentials). See [`packages/cursor-flair/README.md`](../packages/cursor-flair/README.md).
88
93
 
89
- ## 5. Verify
94
+ ## 5. Wire a Grok Bot agent
95
+
96
+ Same connector, different panel. This is the field-verified sequence — including the two places it fails first.
97
+
98
+ **One identity per agent.** Short lowercase ids (`grok-cos` for a Chief-of-Staff agent). Never share an id across agents, and never leave admin credentials as an agent's standing auth — the admin password is for registration, once.
99
+
100
+ 1. In the Grok Bot agent's **Tools & MCPs** panel, add the Flair connector (the same `flair-mcp` stdio server the Cursor plugin runs):
101
+
102
+ | Variable | Value |
103
+ |---|---|
104
+ | `FLAIR_URL` | `https://<cluster>.<org>.harperfabric.com` |
105
+ | `FLAIR_AGENT_ID` | `grok-cos` (this agent's own id) |
106
+
107
+ 2. Ask for a bootstrap. **The first one returns 401.** That is fail-closed working as designed: the id is not registered yet and the machine has no key. Do not "fix" it by pasting admin credentials into the agent's environment.
108
+
109
+ 3. Provide the Fabric admin password through the platform's secret mechanism — Grok Bot's secure secret card — never pasted in chat. It is needed once, for registration only.
110
+
111
+ 4. On the machine that runs the MCP process (for Grok Bot, that is the agent VM): Node.js **22 or newer** — the field machine had 20 and had to upgrade before anything else worked. Then:
112
+
113
+ ```bash
114
+ npm i -g @tpsdev-ai/flair
115
+
116
+ # Fabric ops is :9925 on the same host, as in step 3. docs-freshness-allow: Fabric ops API
117
+ flair agent add grok-cos --target https://<cluster>.<org>.harperfabric.com --ops-target https://<cluster>.<org>.harperfabric.com:9925 --admin-pass "$(cat <pass-file>)"
118
+ ```
119
+
120
+ Keep the pass file mode `0600`. Newer releases add `--admin-pass-file <path>`, which reads the file in-process (invisible to `ps`) — check `flair agent add --help` and prefer it when present.
121
+
122
+ 5. **Restart the MCP process** if it started before the key existed — it does not pick the key up mid-session. Still 401 with the key on disk? Set the key path explicitly in the agent's MCP env: `FLAIR_KEY_PATH=~/.flair/keys/grok-cos.key`. Known papercut, tracked in [flair#1271](https://github.com/tpsdev-ai/flair/issues/1271).
123
+
124
+ 6. Verify: ask the agent to "load my Flair bootstrap". You should get soul + memories **including shared org context** — findings written by teammate agents. A shared-visibility write from this agent is now readable by every org agent.
125
+
126
+ **Account-wide connectors are shared.** A connector added at the Grok Bot account level is one identity used by every agent on that account. For per-agent identity, give each agent its own MCP entry with its own `FLAIR_AGENT_ID` — and register each id (steps 2–5).
127
+
128
+ ## 6. Verify
90
129
 
91
130
  ```bash
92
131
  flair status --target "$FLAIR_URL"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.13",
3
+ "version": "0.45.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",