@tpsdev-ai/flair 0.23.0 → 0.25.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.
@@ -9,6 +9,24 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
9
9
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
10
10
  import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
11
11
  import { RECORD_TYPES } from "./record-types.js";
12
+ import { attachTrust } from "./trust-block.js";
13
+ import { recordCitations } from "./usage-recording.js";
14
+ /**
15
+ * flair#744 slice 1 — read the opt-in `includeTrust` flag for a by-id get.
16
+ * Two entry shapes: an in-process caller (resources/mcp-tools.ts's memory_get)
17
+ * passes it explicitly via the `opts` arg; an HTTP `GET /Memory/<id>?includeTrust=true`
18
+ * carries it as a query param on the RequestTarget. Defensive across the
19
+ * RequestTarget/URLSearchParams shapes Harper may hand us; anything other than
20
+ * a literal "true" reads as off, so the default response stays byte-identical.
21
+ */
22
+ function wantsTrust(target, opts) {
23
+ if (opts?.includeTrust === true)
24
+ return true;
25
+ const raw = target?.get?.("includeTrust") ??
26
+ target?.searchParams?.get?.("includeTrust") ??
27
+ undefined;
28
+ return raw === "true" || raw === true;
29
+ }
12
30
  /**
13
31
  * Owner ids a non-admin agent may READ (resolveAllowedOwners) live in
14
32
  * ./memory-read-scope.ts — still exported/used elsewhere (admin tooling).
@@ -291,8 +309,26 @@ async function hasWriteGrant(granteeId, ownerId) {
291
309
  * requires a "write" MemoryGrant from the target's owner (reuses the existing
292
310
  * agent-auth/grant machinery — no parallel auth logic). Returns a Response to
293
311
  * short-circuit with (400/403), or null to continue.
312
+ *
313
+ * flair#704: an explicit `supersedes: null` — the shape most JSON writers
314
+ * produce for an unset optional field (`JSON.stringify({supersedes: undefined})`
315
+ * drops the key, but plenty of writers instead do `{supersedes: x ?? null}`)
316
+ * — must be treated identically to the key being OMITTED, per the
317
+ * additive-schema convention (flair#695: an explicit null on an
318
+ * optional/nullable field reads as absent, not as a distinct value). Fixed by
319
+ * deleting the key BEFORE the type check below, so (a) the check never
320
+ * rejects it, and (b) `super.put()`/`super.post()` — Harper full-record
321
+ * replacement, see table-helpers.ts's header comment — never persists a
322
+ * literal `null` where "absent" was intended: the stored row ends up
323
+ * byte-for-byte identical to the omitted-key case, so every downstream
324
+ * `!content.supersedes` / `content.supersedes &&` check below (and in
325
+ * closeSupersededIfNeeded) already treats it as unset with no further
326
+ * changes needed.
294
327
  */
295
328
  async function validateAndAuthorizeSupersedes(content, auth) {
329
+ if (content.supersedes === null) {
330
+ delete content.supersedes;
331
+ }
296
332
  if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
297
333
  return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
298
334
  status: 400, headers: { "Content-Type": "application/json" },
@@ -421,7 +457,7 @@ export class Memory extends databases.flair.Memory {
421
457
  * with Memory's own "open-within-org" read-scope resolver above — same
422
458
  * dispatch shape Relationship.ts/WorkspaceState.ts's get() overrides use.
423
459
  */
424
- async get(target) {
460
+ async get(target, opts) {
425
461
  // Collection / query reads — the `GET /Memory/?<query>` form and the bare
426
462
  // collection — arrive as a RequestTarget with `isCollection === true`, and
427
463
  // are governed by search() (same owner/grant scoping). Only a genuine by-id
@@ -433,11 +469,24 @@ export class Memory extends databases.flair.Memory {
433
469
  // get (RequestTarget with isCollection false, or a bare id) falls through.
434
470
  // makeByIdReadGate re-applies this same guard internally (delegating to
435
471
  // this.search via `.call(this, ...)`) — kept here too as documentation of
436
- // the invariant at the call site, harmless no-op double-check.
472
+ // the invariant at the call site, harmless no-op double-check. The trust
473
+ // block (flair#744) is NOT attached on the collection path — that routes to
474
+ // search(), which is out of this slice's by-id `get` surface.
437
475
  if (!target || (typeof target === "object" && target.isCollection)) {
438
476
  return this.search(target);
439
477
  }
440
- return memoryByIdReadGate.call(this, target, (t) => super.get(t));
478
+ const result = await memoryByIdReadGate.call(this, target, (t) => super.get(t));
479
+ // flair#744 slice 1 — opt-in inline trust-evidence block, attached ONLY to
480
+ // a genuine by-id record (never a NOT_FOUND `Response`, never null), and
481
+ // ONLY after the ownership/read-scope gate above has already resolved. The
482
+ // block informs the reader; it never re-enters an authority decision
483
+ // (#735-spirit zero-authority invariant). Default OFF ⇒ the record is
484
+ // returned untouched (attachTrust returns the same reference) ⇒
485
+ // byte-identical to pre-slice-1.
486
+ if (result && typeof result === "object" && !(result instanceof Response) && typeof result.agentId === "string") {
487
+ return attachTrust(result, wantsTrust(target, opts));
488
+ }
489
+ return result;
441
490
  }
442
491
  /**
443
492
  * Override search() to scope collection GETs by authenticated agent.
@@ -520,6 +569,16 @@ export class Memory extends databases.flair.Memory {
520
569
  if (attr.denied)
521
570
  return attr.denied;
522
571
  }
572
+ // flair#744 slice A: citation-on-write — consume-and-strip, same
573
+ // discipline as `claimedClient` below. Pull the optional
574
+ // `usedMemoryIds` off the write body now, BEFORE anything else touches
575
+ // `content`, so it is NEVER persisted on the Memory record itself.
576
+ // Recording happens POST-COMMIT, only when this was present (see the
577
+ // failure-isolated recordCitations() call near the return below) —
578
+ // omitted ⇒ `undefined` ⇒ zero new calls, byte-identical behavior.
579
+ const usedMemoryIds = content?.usedMemoryIds;
580
+ if (content && typeof content === "object")
581
+ delete content.usedMemoryIds;
523
582
  content.durability ||= "standard";
524
583
  content.createdAt = new Date().toISOString();
525
584
  content.updatedAt = content.createdAt;
@@ -631,6 +690,20 @@ export class Memory extends databases.flair.Memory {
631
690
  // Now the safe failure state is two active records (recoverable), never
632
691
  // a lost write — and the failure is logged, never silently swallowed.
633
692
  await closeSupersededIfNeeded(ctx, content, "post");
693
+ // flair#744 slice A: citation-on-write — POST-COMMIT, fully
694
+ // failure-isolated. The write above already succeeded and `result` is
695
+ // final; crediting each cited memory through the shared usage ledger
696
+ // (same path as POST /RecordUsage) must never affect this response —
697
+ // any failure here is logged server-side and swallowed, never surfaced
698
+ // to the caller, never rolls back or retries the write.
699
+ if (usedMemoryIds !== undefined) {
700
+ try {
701
+ await recordCitations(ctx, auth, usedMemoryIds, new Date().toISOString());
702
+ }
703
+ catch (err) {
704
+ console.error("Memory.post: citation recording failed (write already committed)", err);
705
+ }
706
+ }
634
707
  return buildWriteResponse(content, result, dedupMatch);
635
708
  }
636
709
  async put(content) {
@@ -672,6 +745,13 @@ export class Memory extends databases.flair.Memory {
672
745
  if (attr.denied)
673
746
  return attr.denied;
674
747
  }
748
+ // flair#744 slice A: citation-on-write — same consume-and-strip
749
+ // discipline as post() above. Strip BEFORE anything else touches
750
+ // `content` so it is never persisted on the row; recorded post-commit
751
+ // below only when present.
752
+ const usedMemoryIds = content?.usedMemoryIds;
753
+ if (content && typeof content === "object")
754
+ delete content.usedMemoryIds;
675
755
  const now = new Date().toISOString();
676
756
  content.updatedAt = now;
677
757
  // Set defaults that post() sets — put() is also used for new records via CLI
@@ -803,6 +883,16 @@ export class Memory extends databases.flair.Memory {
803
883
  const result = await super.put(content);
804
884
  // ── THEN close the superseded record (see post()) ───────────────────────
805
885
  await closeSupersededIfNeeded(ctx, content, "put");
886
+ // flair#744 slice A: citation-on-write — POST-COMMIT, fully
887
+ // failure-isolated (see post()'s identical comment above).
888
+ if (usedMemoryIds !== undefined) {
889
+ try {
890
+ await recordCitations(ctx, auth, usedMemoryIds, new Date().toISOString());
891
+ }
892
+ catch (err) {
893
+ console.error("Memory.put: citation recording failed (write already committed)", err);
894
+ }
895
+ }
806
896
  return buildWriteResponse(content, result, dedupMatch);
807
897
  }
808
898
  async delete(id) {
@@ -14,7 +14,9 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
14
14
  // bootstrap call never trips SemanticSearch's rate-limit/reranker/
15
15
  // retrievalCount hit-tracking side effects (see resources/
16
16
  // semantic-retrieval-core.ts's module doc for the full boundary).
17
- import { retrieveCandidates } from "./semantic-retrieval-core.js";
17
+ import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
18
+ import { buildTrustBlock } from "./trust-block.js";
19
+ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
18
20
  /**
19
21
  * POST /MemoryBootstrap
20
22
  *
@@ -141,6 +143,8 @@ export class BootstrapMemories extends Resource {
141
143
  const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
142
144
  surface, // e.g., "tps-build", "tps-review", "cli-session"
143
145
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
146
+ includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
147
+ abstain = false, // flair#744 slice 2 — opt-in task-relevance abstention
144
148
  } = data || {};
145
149
  // Authenticated identity lives on getContext().request — `this.request` is
146
150
  // NOT populated on Harper v5 Resources. Reading it returned undefined and
@@ -348,8 +352,28 @@ export class BootstrapMemories extends Resource {
348
352
  memoriesAvailable = ownMemoriesAvailable;
349
353
  // Fields every own-scoped lifecycle slice below needs — explicit (no raw
350
354
  // `embedding`), matching the "select, no raw embedding" pushdown
351
- // requirement.
352
- const OWN_SELECT = ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
355
+ // requirement. flair#744 slice 1: when the caller opts into the per-memory
356
+ // trust block, widen the projection with the extra stored fields the block
357
+ // reads (`provenance`, `usageCount`, `validFrom`) — additive, and only when
358
+ // requested, so a non-trust bootstrap fetches (and returns) exactly what it
359
+ // did before. These records are never returned raw when the block is off,
360
+ // so widening the select cannot change the off-path response bytes.
361
+ const OWN_SELECT = includeTrust
362
+ ? ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags", "provenance", "usageCount", "validFrom"]
363
+ : ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
364
+ // flair#744 slice 1 — the Memory records that became visible lines in the
365
+ // memory-bearing sections (permanent/recent/predicted/relevant/teammate),
366
+ // collected as they're added so the opt-in `trust` array below can carry a
367
+ // self-contained block per included memory. Stays empty (and unused) when
368
+ // includeTrust is off.
369
+ const includedTrustMemories = [];
370
+ // flair#744 slice 2 — the best absolute semantic similarity seen while
371
+ // scoring the task-relevant candidate pool (section 4). Drives the opt-in
372
+ // abstention verdict on the *task-relevance* surface only (bootstrap always
373
+ // returns identity/permanent/recent regardless — abstention is about "does
374
+ // any memory cover your current task", not the whole session load). Stays
375
+ // null when there's no currentTask / no embedding ⇒ never abstains.
376
+ let taskBestSimilarity = null;
353
377
  // --- 2. Permanent memories (always included, highest priority) ---
354
378
  // Own-scoped pushdown: `agentId==self` + `durability==permanent`, both
355
379
  // @indexed (a seek, not a scan) — strictly narrower than the prior
@@ -400,6 +424,8 @@ export class BootstrapMemories extends Resource {
400
424
  const cost = estimateTokens(line);
401
425
  if (cost <= tokenBudget) {
402
426
  sections.permanent.push(line);
427
+ if (includeTrust)
428
+ includedTrustMemories.push(m);
403
429
  tokenBudget -= cost;
404
430
  memoriesIncluded++;
405
431
  }
@@ -472,6 +498,8 @@ export class BootstrapMemories extends Resource {
472
498
  continue;
473
499
  }
474
500
  sections.recent.push(line);
501
+ if (includeTrust)
502
+ includedTrustMemories.push(m);
475
503
  recentSpent += cost;
476
504
  tokenBudget -= cost;
477
505
  memoriesIncluded++;
@@ -512,6 +540,8 @@ export class BootstrapMemories extends Resource {
512
540
  continue;
513
541
  }
514
542
  sections.predicted.push(line);
543
+ if (includeTrust)
544
+ includedTrustMemories.push(m);
515
545
  predictedSpent += cost;
516
546
  tokenBudget -= cost;
517
547
  memoriesIncluded++;
@@ -612,7 +642,37 @@ export class BootstrapMemories extends Resource {
612
642
  // even though `conditions` already scoped the query.
613
643
  isAllowed: scope.isAllowed,
614
644
  ctx,
645
+ // flair#744 slice 1: widen the projection with `provenance` (omitted
646
+ // by the default select) ONLY when the trust block was requested, so
647
+ // the block's provenance fields are populated for teammate/relevant
648
+ // records without changing a non-trust bootstrap's fetch.
649
+ select: includeTrust ? [...DEFAULT_SELECT, "provenance"] : undefined,
650
+ // flair#744 slice 2 + confidence-band refinement: attach the absolute
651
+ // cosine confidence (HNSW-leg, pre keyword bump) when abstention OR
652
+ // the trust block is requested — abstention reads the best of it for
653
+ // its verdict, and the per-memory trust block classifies each
654
+ // task-relevant candidate's into a `matchQuality` band (Kern BINDING
655
+ // condition 2: matchQuality needs `_semSimilarity`, so includeTrust
656
+ // must turn this on too). Only the task-relevant candidates get a
657
+ // similarity — permanent/recent/predicted come from raw own-scoped
658
+ // reads with no retrieval score, so their block's matchQuality is
659
+ // null (honest: those are lifecycle-window loads, not a retrieval
660
+ // surface — confidence bands are a retrieval-surface feature). The
661
+ // field is never returned raw (bootstrap renders memories as text
662
+ // lines + a trust array), so no strip is needed here. Neither flag ⇒
663
+ // candidates carry no `_semSimilarity` and the response is unchanged.
664
+ withSemSimilarity: abstain || includeTrust,
615
665
  });
666
+ // flair#744 slice 2: the best-match confidence for the abstention
667
+ // decision — the max absolute cosine across the retrieved pool, read
668
+ // ONLY from `_semSimilarity` (never any principal/authority field). Note
669
+ // this floor (ABSTENTION_THRESHOLD ≈ 0.15) sits BELOW bootstrap's own
670
+ // long-standing TASK_RELEVANCE_FLOOR (0.3) that gates `scored` below, so
671
+ // an abstaining task (bestSim < 0.15) already has no candidate passing
672
+ // the floor — abstention only ADDS an explicit "nothing covered this"
673
+ // signal, it never removes a memory the reader would otherwise have seen.
674
+ if (abstain)
675
+ taskBestSimilarity = bestSemanticSimilarity(candidates);
616
676
  // Preserve the ORIGINAL score > 0.3 floor exactly (bootstrap's own
617
677
  // historical relevance floor — distinct from SemanticSearch's
618
678
  // `minScore` request param — strict inequality, applied client-side;
@@ -658,6 +718,8 @@ export class BootstrapMemories extends Resource {
658
718
  else {
659
719
  sections.relevant.push(line);
660
720
  }
721
+ if (includeTrust)
722
+ includedTrustMemories.push(m);
661
723
  tokenBudget -= cost;
662
724
  memoriesIncluded++;
663
725
  }
@@ -865,8 +927,28 @@ export class BootstrapMemories extends Resource {
865
927
  const context = parts.join("\n\n");
866
928
  const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
867
929
  const memoryTokens = maxTokens - tokenBudget;
930
+ // flair#744 slice 1 — opt-in per-memory trust block. Bootstrap renders
931
+ // memories as text lines rather than result objects, so the block is
932
+ // surfaced as a `trust` array of self-contained entries (each carries its
933
+ // own `id` to correlate to a rendered line), one per INCLUDED memory. Built
934
+ // HERE, in the response tail, strictly after all read-scope resolution and
935
+ // purely for the response — never consulted for any authority decision
936
+ // (#735-spirit zero-authority invariant). Default OFF ⇒ the `trust` key is
937
+ // absent ⇒ the response is byte-identical to pre-slice-1.
938
+ const trust = includeTrust
939
+ ? includedTrustMemories.map((m) => ({ id: m.id, ...buildTrustBlock(m) }))
940
+ : undefined;
941
+ // flair#744 slice 2 — opt-in abstention verdict for the task-relevance
942
+ // surface. Present ONLY when `abstain` is requested (byte-identical to
943
+ // pre-slice-2 otherwise); scoped to whether any memory covered
944
+ // `currentTask` (bestScore null ⇒ no task/embedding ⇒ abstained:false). The
945
+ // decision reads only the confidence number — never a principal — against
946
+ // the single GLOBAL threshold.
947
+ const abstention = abstain ? evaluateAbstention(taskBestSimilarity) : undefined;
868
948
  return {
869
949
  context,
950
+ ...(trust ? { trust } : {}),
951
+ ...(abstention ? { abstention } : {}),
870
952
  sections: {
871
953
  soul: sections.soul.length,
872
954
  skills: sections.skills.length,
@@ -32,7 +32,7 @@ import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { createRequire } from "node:module";
34
34
  import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
35
- import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
35
+ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
36
36
  // ─── Constants ────────────────────────────────────────────────────────────────
37
37
  const CURRENT_TASK_MAX_LENGTH = 200;
38
38
  const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "debugging", "idle"]);
@@ -409,11 +409,11 @@ export class Presence extends databases.flair.Presence {
409
409
  agentId = middlewareAgent;
410
410
  }
411
411
  else {
412
- const m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
413
- if (!m) {
412
+ const parsed = parseTpsEd25519Header(authHeader);
413
+ if (!parsed) {
414
414
  return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
415
415
  }
416
- const [, headerAgentId, tsRaw, nonce, sigB64] = m;
416
+ const { agentId: headerAgentId, tsRaw, nonce, signatureB64: sigB64 } = parsed;
417
417
  const ts = Number(tsRaw);
418
418
  const now = Date.now();
419
419
  if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
@@ -11,6 +11,14 @@
11
11
  * REPLACES `retrievalBoost` in `compositeScore` outright (see that
12
12
  * function's doc).
13
13
  *
14
+ * flair#744 slice A: the actual ledger-write core below (formerly this
15
+ * class's private `_recordOne()`) has moved to ./usage-recording.ts's
16
+ * `recordUsageContribution()` — a shared, single-implementation extraction
17
+ * also used by citation-on-write (resources/Memory.ts's post()/put()) so
18
+ * there is exactly ONE place the (agentId, memoryId) ledger logic lives.
19
+ * This endpoint's own request handling (auth, rate limit, batch validation,
20
+ * the no-enumeration response) is unchanged.
21
+ *
14
22
  * WHY THIS IS ITS OWN ENDPOINT, NOT `Memory.put()` (Sherlock, K&S verdict —
15
23
  * FLAIR-USAGE-FEEDBACK-SIGNAL.md): usage feedback is fundamentally a
16
24
  * CROSS-AGENT write — agent B reports using agent A's memory, so the write
@@ -21,10 +29,10 @@
21
29
  * memory through this surface — not just the count. So this endpoint does
22
30
  * the narrowest possible thing instead: a TARGETED `usageCount`-only
23
31
  * bump (read-full-record, merge just this one field, write — see
24
- * _recordOne() below) against the RAW `Memory` table object, entirely
25
- * bypassing the `Memory` RESOURCE class (and its ownership gate) — its own
26
- * auth model is verified-agent + within-org + explicitly NO ownership
27
- * requirement.
32
+ * ./usage-recording.ts's recordUsageContribution()) against the RAW
33
+ * `Memory` table object, entirely bypassing the `Memory` RESOURCE class
34
+ * (and its ownership gate) — its own auth model is verified-agent +
35
+ * within-org + explicitly NO ownership requirement.
28
36
  *
29
37
  * WHY THIS ISN'T A @table-BACKED RESOURCE: the actual dedup ledger (one row
30
38
  * per (agentId, memoryId) contribution) lives in the `MemoryUsage` table
@@ -37,13 +45,14 @@
37
45
  * SAME class of gotcha resources/Memory.ts documents for a raw HTTP POST to
38
46
  * `/Memory`, but here it bites even an in-process call). So the ledger row
39
47
  * itself is written via `.put()` (upsert with the deterministic composite
40
- * id — see _recordOne()), and this endpoint's OWN HTTP surface lives on a
41
- * plain action `Resource` (same shape as resources/MemoryReflect.ts /
42
- * resources/SemanticSearch.ts) rather than extending a @table class, so POST
43
- * actually routes here. It talks to BOTH `Memory` and `MemoryUsage` via
44
- * their raw table objects (bypassing each resource class's own auth
45
- * wrapper — the same "call the other table directly" pattern
46
- * resources/Memory.ts already uses for `MemoryGrant` in hasWriteGrant()).
48
+ * id — see ./usage-recording.ts's recordUsageContribution()), and this
49
+ * endpoint's OWN HTTP surface lives on a plain action `Resource` (same shape
50
+ * as resources/MemoryReflect.ts / resources/SemanticSearch.ts) rather than
51
+ * extending a @table class, so POST actually routes here. It talks to BOTH
52
+ * `Memory` and `MemoryUsage` via their raw table objects (bypassing each
53
+ * resource class's own auth wrapper — the same "call the other table
54
+ * directly" pattern resources/Memory.ts already uses for `MemoryGrant` in
55
+ * hasWriteGrant()).
47
56
  *
48
57
  * ANTI-GAMING (Sherlock): usage feedback is a write that affects RANKING —
49
58
  * an abuse surface (inflate usageCount to boost a memory). Three-layer
@@ -73,13 +82,16 @@
73
82
  * sanitized (control-character-stripped, length-capped) and stored as-is,
74
83
  * for audit/analytics only. Treat it as untrusted data, always.
75
84
  */
76
- import { Resource, databases } from "@harperfast/harper";
85
+ import { Resource } from "@harperfast/harper";
77
86
  import { resolveAgentAuth } from "./agent-auth.js";
78
87
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
79
- import { withDetachedTxn } from "./table-helpers.js";
88
+ import { recordUsageContribution, MAX_USAGE_IDS_PER_CALL } from "./usage-recording.js";
80
89
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
81
90
  const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
82
- const MAX_IDS_PER_CALL = 20;
91
+ // flair#744 slice A: sourced from the shared module (./usage-recording.ts)
92
+ // so RecordUsage.post()'s validated-batch cap and citation-on-write's
93
+ // advisory-batch cap can never drift apart.
94
+ const MAX_IDS_PER_CALL = MAX_USAGE_IDS_PER_CALL;
83
95
  const MAX_ATTRIBUTION_LENGTH = 500;
84
96
  /**
85
97
  * Strip control/non-printable characters and cap length. Attribution is
@@ -148,7 +160,12 @@ export class RecordUsage extends Resource {
148
160
  const now = new Date().toISOString();
149
161
  for (const memoryId of memoryIds) {
150
162
  try {
151
- await this._recordOne(ctx, agentId, memoryId, attribution, now);
163
+ // flair#744 slice A: the ledger-write core now lives in
164
+ // ./usage-recording.ts's recordUsageContribution() — a shared,
165
+ // single-implementation extraction (also used by citation-on-write,
166
+ // resources/Memory.ts's post()/put()). Byte-identical behavior to
167
+ // the former private _recordOne() this replaced.
168
+ await recordUsageContribution(ctx, agentId, memoryId, attribution, now);
152
169
  }
153
170
  catch (err) {
154
171
  // Never let one bad id fail the whole batch, and never let an
@@ -162,69 +179,4 @@ export class RecordUsage extends Resource {
162
179
  // counted / were not found — see RECORDED_RESPONSE's doc.
163
180
  return RECORDED_RESPONSE;
164
181
  }
165
- /**
166
- * One (agentId, memoryId) contribution.
167
- *
168
- * Ledger-row-create FIRST, THEN the Memory.usageCount bump — so a crash
169
- * between the two leaves the SAFE failure state (ledger row exists, count
170
- * not yet bumped: a later retry just re-checks and no-ops) rather than the
171
- * reverse (count bumped, no ledger row → a retry would double-count).
172
- *
173
- * Every discrete Harper call is wrapped INDIVIDUALLY in its own
174
- * withDetachedTxn — never one wrap around a multi-step helper. A request
175
- * that reads/writes MULTIPLE tables (or the same table twice) in sequence
176
- * can otherwise inherit a closed transaction from a prior call's drained
177
- * chain (table-helpers.ts's withDetachedTxn doc); resources/Memory.ts's
178
- * closeSupersededRecord documents exactly why a single wrap around a
179
- * multi-await async function does NOT protect a later call inside it —
180
- * this mirrors that function's literal shape (get wrapped, then a
181
- * SEPARATE put wrapped) rather than delegating to the generic
182
- * patchRecord() helper, which would combine both into one un-safe wrap.
183
- *
184
- * The final get-then-put for the increment is a best-effort (non-atomic)
185
- * read-modify-write, same class of race already accepted elsewhere in
186
- * this codebase for count fields (e.g. retrievalCount's bump in
187
- * SemanticSearch.ts) — a concurrent contribution from a DIFFERENT agent
188
- * landing between this call's read and write could lose one increment.
189
- * Re-fetching immediately before the write (rather than reusing the
190
- * earlier existence-check read) narrows, without eliminating, that
191
- * window. Not solved here: bounded, low-severity (an undercount, never an
192
- * inflation), and orthogonal to the anti-gaming properties the cap/floor
193
- * and dedup ledger actually defend.
194
- */
195
- async _recordOne(ctx, agentId, memoryId, attribution, now) {
196
- const ledgerId = `${agentId}:${memoryId}`;
197
- // Bypasses resources/MemoryUsage.ts's own auth wrapper by design — this
198
- // IS the trusted internal caller that resource's module doc describes
199
- // (same "raw table object" pattern resources/Memory.ts uses for
200
- // MemoryGrant).
201
- const existingContribution = await withDetachedTxn(ctx, () => databases.flair.MemoryUsage.get(ledgerId)).catch(() => null);
202
- if (existingContribution)
203
- return; // already counted by this agent — silent no-op
204
- const memoryExists = await withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
205
- if (!memoryExists)
206
- return; // no such memory — silent no-op (no enumeration)
207
- const ledgerRecord = { id: ledgerId, agentId, memoryId, createdAt: now };
208
- if (attribution)
209
- ledgerRecord.attribution = attribution;
210
- // .put(), not .post(): Harper's raw TableResource has no default post()
211
- // implementation for a static-style (non-`isCollection`-instantiated)
212
- // call — confirmed live ("The MemoryUsage does not have a post method
213
- // implemented", statusCode 405) — the SAME class of gotcha
214
- // resources/Memory.ts documents for HTTP POST, but here it bites even
215
- // this in-process call. Irrelevant anyway: ledgerId is already a
216
- // deterministic composite key, so this is a create-with-explicit-id —
217
- // exactly what PUT (upsert) is for, not an auto-generated-id insert.
218
- await withDetachedTxn(ctx, () => databases.flair.MemoryUsage.put(ledgerRecord));
219
- // Targeted usageCount-ONLY bump: read-full-record, merge just this one
220
- // field, write — against the RAW Memory table, NEVER Memory.put() (the
221
- // resource class): that would 403 this cross-agent write via its
222
- // ownership check, and bypassing that check directly would risk letting
223
- // this endpoint smuggle OTHER field changes through instead of just the
224
- // count (module doc's "WHY THIS IS ITS OWN ENDPOINT").
225
- const fresh = await withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
226
- if (!fresh)
227
- return; // deleted between the checks above and now — no-op
228
- await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...fresh, usageCount: (fresh.usageCount ?? 0) + 1 }));
229
- }
230
182
  }
@@ -17,7 +17,9 @@ import { hybridEnabled } from "./bm25.js";
17
17
  // extraction) — MemoryBootstrap.ts calls the SAME core bare, without
18
18
  // tripping this file's rate-limit/reranker/hit-tracking side effects. See
19
19
  // resources/semantic-retrieval-core.ts's module doc for the full boundary.
20
- import { retrieveCandidates } from "./semantic-retrieval-core.js";
20
+ import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
21
+ import { attachTrust } from "./trust-block.js";
22
+ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
21
23
  // Candidate multiplier: fetch more candidates than needed from the HNSW index
22
24
  // so composite re-ranking has enough headroom to reorder results.
23
25
  const CANDIDATE_MULTIPLIER = 5;
@@ -60,7 +62,7 @@ export class SemanticSearch extends Resource {
60
62
  // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
61
63
  // before reconsidering this default if the compositeScore formula or
62
64
  // corpus changes.
63
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf } = data || {};
65
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, abstain = false } = data || {};
64
66
  // Authenticated identity lives on the Harper Resource context (getContext().request).
65
67
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
66
68
  // silently returned undefined and the defense-in-depth scope check below
@@ -216,7 +218,41 @@ export class SemanticSearch extends Resource {
216
218
  isAllowed: scope?.isAllowed,
217
219
  hybrid,
218
220
  ctx,
221
+ // flair#744 slice 1: the trust block needs `provenance`, which the
222
+ // default projection omits. Widen the select ONLY when the caller opts
223
+ // in — passing undefined otherwise keeps the default (no `provenance`)
224
+ // so a non-trust recall response stays byte-identical.
225
+ select: includeTrust ? [...DEFAULT_SELECT, "provenance"] : undefined,
226
+ // flair#744 slice 2 + confidence-band refinement: attach the absolute
227
+ // per-result cosine confidence when the caller opts into abstention OR
228
+ // the trust block — abstention reads the best of it for its verdict, and
229
+ // the trust block classifies each result's into a `matchQuality` band
230
+ // (Kern BINDING condition 2: matchQuality needs `_semSimilarity`, so
231
+ // includeTrust must also turn this on). Neither flag ⇒ result objects stay
232
+ // byte-identical (no `_semSimilarity` attached).
233
+ withSemSimilarity: abstain || includeTrust,
219
234
  });
235
+ // ─── flair#744 slice 2 — first-class abstention ("no memory covers this")
236
+ // Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the
237
+ // reranker / final slice / hit-tracking, so an abstaining recall does no
238
+ // rerank work and never bumps retrievalCount for memories it declines to
239
+ // surface. The decision reads ONLY the best absolute semantic similarity
240
+ // (never any principal/authority signal — abstention.ts is pure and
241
+ // authority-free), against the single GLOBAL threshold. Default OFF ⇒ this
242
+ // whole block is skipped and the response is byte-identical to pre-slice-2.
243
+ let abstention = null;
244
+ if (abstain) {
245
+ abstention = evaluateAbstention(bestSemanticSimilarity(filteredResults));
246
+ if (abstention.abstained) {
247
+ return {
248
+ abstained: true,
249
+ reason: abstention.reason,
250
+ bestScore: abstention.bestScore,
251
+ threshold: abstention.threshold,
252
+ results: [],
253
+ };
254
+ }
255
+ }
220
256
  // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
221
257
  // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
222
258
  // can't do) and reorders before the final slice. Reorders whatever
@@ -248,8 +284,40 @@ export class SemanticSearch extends Resource {
248
284
  lastRetrieved: now,
249
285
  }).catch(() => { });
250
286
  }
287
+ // flair#744 slice 1 — opt-in inline trust-evidence block. Assembled HERE,
288
+ // in the response tail, strictly AFTER read-scope resolution
289
+ // (retrieveCandidates + scope.isAllowed already ran) and purely for the
290
+ // response — it never feeds back into any authority/scope/attribution/dedup
291
+ // decision (the #735-spirit zero-authority invariant; structurally guarded
292
+ // by test/unit/trust-block-zero-authority-tripwire.test.ts). Default OFF ⇒
293
+ // `results` is the untouched `topResults`, byte-identical to pre-slice-1.
294
+ //
295
+ // Order matters (confidence-band refinement): attachTrust must run BEFORE
296
+ // the `_semSimilarity` strip below, because buildTrustBlock reads that field
297
+ // off the record to classify `matchQuality`. attachTrust returns a shallow
298
+ // copy carrying `trust` (and still-present `_semSimilarity`); the strip then
299
+ // drops the internal field from the copy.
300
+ const trusted = includeTrust ? topResults.map((r) => attachTrust(r, true)) : topResults;
301
+ // flair#744 slice 2 + refinement: strip the internal `_semSimilarity`
302
+ // confidence field from the consumer-facing results — it exists ONLY to feed
303
+ // the abstention decision and the matchQuality classification, never the
304
+ // response shape. Attached whenever abstain OR includeTrust turned
305
+ // withSemSimilarity on, so strip in both cases. A no-op (and byte-identical)
306
+ // when neither flag is set (the field was never attached).
307
+ const results = (abstain || includeTrust)
308
+ ? trusted.map(({ _semSimilarity, ...r }) => r)
309
+ : trusted;
251
310
  // Surface degradation warning when semantic search was unavailable
252
- const response = { results: topResults };
311
+ const response = { results };
312
+ // flair#744 slice 2: in opt-in abstain mode that did NOT abstain, carry the
313
+ // (negative) verdict so a consumer building against the abstention shape
314
+ // always reads a stable `abstained`/`bestScore`/`threshold`. Absent when
315
+ // abstain is off ⇒ byte-identical to pre-slice-2.
316
+ if (abstention) {
317
+ response.abstained = abstention.abstained;
318
+ response.bestScore = abstention.bestScore;
319
+ response.threshold = abstention.threshold;
320
+ }
253
321
  if (!qEmb && q && getMode() === "none") {
254
322
  response._warning = "semantic search unavailable — results are keyword-only";
255
323
  }