@tpsdev-ai/flair 0.47.1 → 0.49.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.
@@ -555,12 +555,57 @@ function opsBaseUrl(opsPortOrUrl) {
555
555
  function basicAuthHeader(adminUser, adminPass) {
556
556
  return `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
557
557
  }
558
+ /** The resolver's own predicate, verbatim (resources/mcp-handler.ts
559
+ * `resolveAgentFromSub`): a credential is resolvable unless it is explicitly
560
+ * revoked. The linking layer MUST use the same test — a credential the linker
561
+ * considers inactive but the resolver would still serve is exactly the
562
+ * invisible-duplicate hole #1317 is about. */
563
+ function isResolvableCredential(cred) {
564
+ return cred?.status !== "revoked";
565
+ }
558
566
  /**
559
567
  * Map the operator's IdP subject to their principal via `Credential(kind:
560
568
  * "idp")` — the SAME credential surface resources/mcp-handler.ts's
561
- * `resolveAgentFromSub` reads at request time. Idempotent: an existing
562
- * mapping for (provider, subject) is reused (lastUsedAt bumped) rather than
563
- * duplicated; the principal Agent is created only if missing.
569
+ * `resolveAgentFromSub` reads at request time.
570
+ *
571
+ * ## The uniqueness constraint (flair#1317, K&S ruling 2026-08-21)
572
+ *
573
+ * **At most one ACTIVE `Credential(kind:"idp", idpSubject:<sub>)` exists at a
574
+ * time, regardless of `idpProvider`.** This function is where that invariant is
575
+ * enforced, because it is the only supported writer of the mapping.
576
+ *
577
+ * It used to dedup on `(kind, idpProvider, idpSubject)` while the resolver read
578
+ * `(kind, idpSubject)`. A re-link under a different provider name therefore
579
+ * matched nothing, INSERTED a second active credential, and left
580
+ * `resolveAgentFromSub` picking whichever row its search iterator served first
581
+ * — identity resolution by iteration order, on a security-relevant mapping.
582
+ *
583
+ * The resolver's key is the correct one and does not change: an IdP subject is
584
+ * an identity, and "who is this subject?" has exactly one answer. `idpProvider`
585
+ * stays on the row as audit/diagnostic metadata, but it does not participate in
586
+ * uniqueness. So:
587
+ *
588
+ * - same provider, existing active credential → RE-POINT it (`credentialReused`);
589
+ * - any OTHER active credential for the subject → SUPERSEDE it: terminal
590
+ * `status: "revoked"`, never a soft flag a later path could flip back
591
+ * (revoked rows are never reused here — a re-link after a revoke mints a
592
+ * fresh credential);
593
+ * - the re-point/insert and every revocation go out as ONE ops-API `upsert`
594
+ * batch, so there is no observable window with two active credentials or
595
+ * zero. If the batch fails, nothing is claimed and the call throws;
596
+ * - after the write the invariant is RE-READ and asserted. A store that
597
+ * somehow holds ≠1 active credential for the subject is a hard error, not a
598
+ * silent nondeterministic mapping.
599
+ *
600
+ * The principal Agent is created only if missing.
601
+ *
602
+ * RESIDUAL RISK, by design (Sherlock, #1317): whoever can call this for a
603
+ * subject can revoke that subject's prior credential. If two genuinely
604
+ * different people ever shared a subject string across providers, one's link
605
+ * kills the other's mapping. IdP subjects are opaque per-IdP identifiers so the
606
+ * collision is remote, and the alternative — duplicate active credentials with
607
+ * order-dependent resolution — is strictly worse. `credentialSuperseded` exists
608
+ * so the operator is told, not so the event is hidden.
564
609
  */
565
610
  export async function provisionIdpIdentityMapping(params, deps = {}) {
566
611
  const fetchImpl = deps.fetchImpl ?? fetch;
@@ -628,26 +673,43 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
628
673
  }
629
674
  principalCreated = true;
630
675
  }
631
- // Reuse an existing Credential(kind:idp, provider, subject) mapping if present.
632
- const searchCredRes = await fetchImpl(opsUrl, {
633
- method: "POST",
634
- headers: { "Content-Type": "application/json", Authorization: authHeader },
635
- body: JSON.stringify({
636
- operation: "search_by_conditions",
637
- database: "flair",
638
- table: "Credential",
639
- operator: "and",
640
- conditions: [
641
- { search_attribute: "kind", search_type: "equals", search_value: "idp" },
642
- { search_attribute: "idpProvider", search_type: "equals", search_value: params.idpProvider },
643
- { search_attribute: "idpSubject", search_type: "equals", search_value: params.idpSubject },
644
- ],
645
- get_attributes: ["id", "principalId"],
646
- }),
647
- });
648
- const existingCreds = searchCredRes.ok ? await searchCredRes.json().catch(() => []) : [];
649
- const existing = Array.isArray(existingCreds) ? existingCreds[0] : undefined;
650
- const credentialId = existing?.id ?? `cred_idp_${params.idpProvider}_${randomBytes(6).toString("hex")}`;
676
+ // ── flair#1317: look SUBJECT-WIDE, not (provider, subject) ─────────────────
677
+ // The resolver's key is (kind, idpSubject); anything narrower here leaves
678
+ // credentials that dedup cannot see but resolution can.
679
+ const findCredentialsForSubject = async () => {
680
+ const res = await fetchImpl(opsUrl, {
681
+ method: "POST",
682
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
683
+ body: JSON.stringify({
684
+ operation: "search_by_conditions",
685
+ database: "flair",
686
+ table: "Credential",
687
+ operator: "and",
688
+ conditions: [
689
+ { search_attribute: "kind", search_type: "equals", search_value: "idp" },
690
+ { search_attribute: "idpSubject", search_type: "equals", search_value: params.idpSubject },
691
+ ],
692
+ get_attributes: ["id", "principalId", "idpProvider", "idpSubject", "status", "label", "createdAt"],
693
+ }),
694
+ });
695
+ const body = res.ok ? await res.json().catch(() => []) : [];
696
+ return Array.isArray(body) ? body : [];
697
+ };
698
+ const subjectCreds = await findCredentialsForSubject();
699
+ const activeCreds = subjectCreds.filter(isResolvableCredential);
700
+ // Survivor: an ACTIVE same-provider credential is re-pointed (the idempotent
701
+ // re-run and the documented same-provider link). A revoked one is never
702
+ // resurrected — a re-link after a revoke mints a fresh credential.
703
+ const reused = activeCreds.find((c) => c?.idpProvider === params.idpProvider && c?.id);
704
+ const credentialId = reused?.id ?? `cred_idp_${params.idpProvider}_${randomBytes(6).toString("hex")}`;
705
+ // Everything else active for this subject is superseded. Under the old
706
+ // (provider, subject) key these rows were simply invisible; they are what made
707
+ // resolution order-dependent.
708
+ const superseded = activeCreds.filter((c) => c?.id && c.id !== credentialId);
709
+ // ONE batched write: the survivor first, then the revocations. A single
710
+ // ops-API operation is the strongest atomicity this surface can express, and
711
+ // ordering the survivor first means even a partially-applied batch can never
712
+ // leave the subject with ZERO resolvable credentials (the fail-open denial).
651
713
  const upsertRes = await fetchImpl(opsUrl, {
652
714
  method: "POST",
653
715
  headers: { "Content-Type": "application/json", Authorization: authHeader },
@@ -664,9 +726,25 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
664
726
  status: "active",
665
727
  idpProvider: params.idpProvider,
666
728
  idpSubject: params.idpSubject,
667
- createdAt: existing ? undefined : now,
729
+ createdAt: reused ? undefined : now,
668
730
  lastUsedAt: now,
669
731
  },
732
+ // Retained, not deleted: the revocation stays legible in storage and in
733
+ // Harper's table audit log (which records the full record image of
734
+ // every write). Identifying fields are echoed back so the row survives
735
+ // as a well-formed, revoked credential whichever merge semantics the
736
+ // ops API applies.
737
+ ...superseded.map((c) => ({
738
+ id: c.id,
739
+ principalId: c.principalId,
740
+ kind: "idp",
741
+ label: c.label,
742
+ status: "revoked",
743
+ idpProvider: c.idpProvider,
744
+ idpSubject: params.idpSubject,
745
+ createdAt: c.createdAt,
746
+ updatedAt: now,
747
+ })),
670
748
  ],
671
749
  }),
672
750
  });
@@ -674,7 +752,26 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
674
752
  const text = await upsertRes.text().catch(() => "");
675
753
  throw new Error(`Identity mapping: failed to write Credential(kind:idp) mapping (HTTP ${upsertRes.status}): ${text}`);
676
754
  }
677
- return { principalCreated, credentialId, credentialReused: Boolean(existing) };
755
+ // ── The invariant, RE-READ ────────────────────────────────────────────────
756
+ // Asserting what we intended to write proves nothing. This asks the store.
757
+ // ≠1 active credential means the resolver's answer for this subject is
758
+ // order-dependent, so this fails LOUDLY rather than returning a mapping the
759
+ // operator would reasonably believe is deterministic.
760
+ const afterCreds = (await findCredentialsForSubject()).filter(isResolvableCredential);
761
+ if (afterCreds.length !== 1 || afterCreds[0]?.id !== credentialId) {
762
+ const seen = afterCreds.map((c) => `${c?.id} → ${c?.principalId} (provider '${c?.idpProvider}')`).join("; ") || "none";
763
+ throw new Error(`Identity mapping: the uniqueness invariant does not hold after the write — subject '${params.idpSubject}' ` +
764
+ `has ${afterCreds.length} active Credential(kind:idp) row(s) [${seen}], expected exactly 1 (${credentialId}). ` +
765
+ `Runtime resolution for this subject would be iteration-order-dependent (flair#1317). ` +
766
+ `Inspect the Credential table for kind:"idp" idpSubject:"${params.idpSubject}" and revoke the rows that should not resolve.`);
767
+ }
768
+ return {
769
+ principalCreated,
770
+ credentialId,
771
+ credentialReused: Boolean(reused),
772
+ credentialSuperseded: superseded.length > 0,
773
+ supersededCredentialIds: superseded.map((c) => String(c.id)),
774
+ };
678
775
  }
679
776
  // ─── Restart only ────────────────────────────────────────────────────────────
680
777
  /** `restart` only — used by `disableMcp` (flag off + restart, no config
@@ -1069,10 +1166,21 @@ export async function enableMcp(params, deps = {}) {
1069
1166
  // and the one silent failure mode this surface has is discovering that
1070
1167
  // via an empty bootstrap. So the step that creates the mapping states it
1071
1168
  // plainly, names the link remedy, and points at the runtime diagnostic.
1169
+ // flair#1317 — a cross-provider re-link REVOKES the subject's prior
1170
+ // credential (one active credential per subject is the invariant). That is
1171
+ // a credential dying, so it is stated as such, by id: an operator must
1172
+ // never discover it later from something that stopped working.
1173
+ const supersedeNote = mapping.credentialSuperseded
1174
+ ? ` SUPERSEDED: ${mapping.supersededCredentialIds.length} prior Credential(kind:idp) row(s) for this subject ` +
1175
+ `were REVOKED, not de-duplicated — ${mapping.supersededCredentialIds.join(", ")}. ` +
1176
+ `They no longer resolve, and anything relying on them stops working. ` +
1177
+ `Exactly one active credential per (kind, idpSubject) is the invariant that keeps resolution deterministic.`
1178
+ : "";
1072
1179
  push(true, `connector identity: sub '${params.idpSubject}' (provider '${idpProvider}') resolves to Agent '${principal}' — ` +
1073
1180
  `every /mcp call reads and writes AS '${principal}'. ` +
1074
1181
  `principal ${mapping.principalCreated ? "created" : "already existed"}; ` +
1075
- `Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId}). ` +
1182
+ `Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId}).` +
1183
+ `${supersedeNote} ` +
1076
1184
  `If your CLI signs as a DIFFERENT agent id, the connector sees that agent's DISTINCT memory scope (by design) — ` +
1077
1185
  `re-run with --principal <your-agent-id> to link them. ` +
1078
1186
  `Diagnostic: the bootstrap tool's agentId/scope fields always say who the server resolved you to.`);
@@ -19,6 +19,7 @@
19
19
  import { Resource, databases } from "harper";
20
20
  import { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
21
21
  import { reconcileAdminFields } from "./agent-admin.js";
22
+ import { noteMemoryUpsert } from "./bm25-index-service.js";
22
23
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
23
24
  name: displayName,
24
25
  role,
@@ -124,6 +125,7 @@ export class AgentSeed extends Resource {
124
125
  archived: false,
125
126
  };
126
127
  await databases.flair.Memory.put(record);
128
+ noteMemoryUpsert(record);
127
129
  memories.push(record);
128
130
  }
129
131
  } // end !hasOnboardingMemory
@@ -13,6 +13,7 @@ import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeSco
13
13
  import { RECORD_TYPES } from "./record-types.js";
14
14
  import { attachTrust } from "./trust-block.js";
15
15
  import { recordCitations } from "./usage-recording.js";
16
+ import { noteMemoryUpsert, noteMemoryDelete } from "./bm25-index-service.js";
16
17
  /**
17
18
  * flair#744 slice 1 — read the opt-in `includeTrust` flag for a by-id get.
18
19
  * Two entry shapes: an in-process caller (resources/mcp-tools.ts's memory_get)
@@ -306,7 +307,11 @@ async function closeSupersededRecord(ctx, oldId, patch) {
306
307
  if (!existing) {
307
308
  throw new Error(`supersede-close: record ${oldId} not found`);
308
309
  }
309
- await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...existing, ...patch }));
310
+ const closed = { ...existing, ...patch };
311
+ await withDetachedTxn(ctx, () => databases.flair.Memory.put(closed));
312
+ // flair#1357 — a supersede-close sets `validTo`, which the retrieval filters
313
+ // read, so the lexical index has to see it as eagerly as a content write.
314
+ noteMemoryUpsert(closed);
310
315
  }
311
316
  /** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
312
317
  * pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
@@ -614,8 +619,22 @@ export class Memory extends databases.flair.Memory {
614
619
  }
615
620
  }
616
621
  content.durability ||= "standard";
617
- content.createdAt = new Date().toISOString();
618
- content.updatedAt = content.createdAt;
622
+ // ── flair#1336: honor a caller-supplied createdAt (parity with put()) ──
623
+ // put() — the other HTTP-reachable create path — has always preserved the
624
+ // caller's createdAt (`content.createdAt ?? now`), and adk-flair's
625
+ // add_memory forwards MemoryEntry.timestamp through it for historical
626
+ // imports. When #1336 moved client creates onto POST, this line's
627
+ // unconditional re-stamp silently discarded those timestamps (caught by
628
+ // the #1334 list-pagination live test: rows written with backdated
629
+ // timestamps came back stamped "now"). Honoring the caller grants no new
630
+ // capability — PUT already accepted arbitrary createdAt from the same
631
+ // principals. validFrom below keys off createdAt and follows it, exactly
632
+ // as on the put() path; updatedAt stays the true write moment; the
633
+ // ephemeral expiresAt stamp keys off Date.now(), so a backdated create
634
+ // cannot stretch the #1257 exposure window.
635
+ const nowIso = new Date().toISOString();
636
+ content.createdAt = content.createdAt ?? nowIso;
637
+ content.updatedAt = nowIso;
619
638
  content.archived = content.archived ?? false;
620
639
  // ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
621
640
  // post() only ever creates a NEW record — patchRecord/supersede-close/
@@ -658,9 +677,13 @@ export class Memory extends databases.flair.Memory {
658
677
  if (content.visibility === undefined || content.visibility === null) {
659
678
  content.visibility = defaultVisibilityForDurability(content.durability);
660
679
  }
661
- // Validate derivedFrom source IDs exist (best-effort, non-blocking)
680
+ // Validate derivedFrom source IDs exist (best-effort, non-blocking).
681
+ // lastReflected keys off updatedAt (the write moment), NOT createdAt —
682
+ // since #1336 a create may carry a backdated caller createdAt, and the
683
+ // reflection bookkeeping must record when the derivation actually ran.
684
+ // (Pre-#1336 the two were always identical here.)
662
685
  if (Array.isArray(content.derivedFrom) && content.derivedFrom.length > 0) {
663
- const now = content.createdAt;
686
+ const now = content.updatedAt;
664
687
  for (const sourceId of content.derivedFrom) {
665
688
  try {
666
689
  const src = await databases.flair.Memory.get(sourceId);
@@ -748,6 +771,11 @@ export class Memory extends databases.flair.Memory {
748
771
  await stampOriginatorInstanceId(content);
749
772
  // ── Write the new record FIRST ──────────────────────────────────────────
750
773
  const result = await super.post(content);
774
+ // flair#1357 — read-your-write for the lexical leg. The table change feed
775
+ // (resources/bm25-index-service.ts) is the CORRECTNESS mechanism; this
776
+ // synchronous hook is what makes a store immediately searchable rather
777
+ // than searchable-after-the-feed-turns.
778
+ noteMemoryUpsert(content);
751
779
  // ── THEN close the superseded record ────────────────────────────────────
752
780
  // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
753
781
  // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
@@ -789,7 +817,9 @@ export class Memory extends databases.flair.Memory {
789
817
  });
790
818
  }
791
819
  delete content._reindex;
792
- return super.put(content);
820
+ const reindexed = await super.put(content);
821
+ noteMemoryUpsert(content);
822
+ return reindexed;
793
823
  }
794
824
  // Create/update ownership (same rule as post): a non-admin agent may only
795
825
  // write memories it owns, via resolveAgentAuth (gate annotation), not
@@ -1025,6 +1055,8 @@ export class Memory extends databases.flair.Memory {
1025
1055
  await stampOriginatorInstanceId(content);
1026
1056
  // ── Write the new/updated record FIRST ──────────────────────────────────
1027
1057
  const result = await super.put(content);
1058
+ // flair#1357 — read-your-write for the lexical leg (see post()).
1059
+ noteMemoryUpsert(content);
1028
1060
  // ── THEN close the superseded record (see post()) ───────────────────────
1029
1061
  await closeSupersededIfNeeded(ctx, content, "put");
1030
1062
  // flair#744 slice A: citation-on-write — POST-COMMIT, fully
@@ -1049,8 +1081,11 @@ export class Memory extends databases.flair.Memory {
1049
1081
  // before the read-gate fix — the read-scoping override must not leak
1050
1082
  // into delete()'s internal record lookup.
1051
1083
  const record = await super.get(id);
1052
- if (!record)
1053
- return super.delete(id);
1084
+ if (!record) {
1085
+ const gone = await super.delete(id);
1086
+ noteMemoryDelete(id);
1087
+ return gone;
1088
+ }
1054
1089
  if (record.durability === "permanent") {
1055
1090
  // Middleware already guards this for non-admins, but belt-and-suspenders
1056
1091
  const ctx = this.getContext?.();
@@ -1063,6 +1098,8 @@ export class Memory extends databases.flair.Memory {
1063
1098
  });
1064
1099
  }
1065
1100
  }
1066
- return super.delete(id);
1101
+ const deleted = await super.delete(id);
1102
+ noteMemoryDelete(id);
1103
+ return deleted;
1067
1104
  }
1068
1105
  }
@@ -4,6 +4,7 @@ import { computeContentHash, findExistingMemoryByContentHash } from "./memory-fe
4
4
  import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
5
5
  import { assertValidVisibility, assertVisibilityAllowedForDurability, PRIVATE_VISIBILITY } from "./memory-visibility.js";
6
6
  import { assertValidDurability } from "./memory-durability.js";
7
+ import { noteMemoryUpsert } from "./bm25-index-service.js";
7
8
  export class FeedMemories extends Resource {
8
9
  // Self-authorize via the Ed25519 agent verify (the auth reshape removes the
9
10
  // gate's admin elevation).
@@ -125,6 +126,8 @@ export class FeedMemories extends Resource {
125
126
  record.visibility = PRIVATE_VISIBILITY;
126
127
  }
127
128
  await databases.flair.Memory.put(record);
129
+ // flair#1357 — raw-table write: hook it explicitly (see bm25-index-service).
130
+ noteMemoryUpsert(record);
128
131
  return record;
129
132
  }
130
133
  async *connect(target, incomingMessages) {
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { Resource, databases } from "harper";
20
20
  import { isAdmin } from "./agent-auth.js";
21
+ import { noteMemoryUpsert, noteMemoryDelete } from "./bm25-index-service.js";
21
22
  export class MemoryMaintenance extends Resource {
22
23
  /** POST requires auth — either an agent acting on its own memories, or admin. */
23
24
  allowCreate() {
@@ -65,6 +66,9 @@ export class MemoryMaintenance extends Resource {
65
66
  if (!dryRun) {
66
67
  try {
67
68
  await databases.flair.Memory.delete(record.id);
69
+ // flair#1357 — ephemeral expiry removes the row from what the
70
+ // lexical leg may score.
71
+ noteMemoryDelete(record.id);
68
72
  stats.expired++;
69
73
  }
70
74
  catch {
@@ -88,11 +92,16 @@ export class MemoryMaintenance extends Resource {
88
92
  if (ageDays > 30) {
89
93
  if (!dryRun) {
90
94
  try {
91
- await databases.flair.Memory.update(record.id, {
95
+ const archivedRow = {
92
96
  ...record,
93
97
  archived: true,
94
98
  archivedAt: now.toISOString(),
95
- });
99
+ };
100
+ await databases.flair.Memory.update(record.id, archivedRow);
101
+ // flair#1357 — an `archived` flip changes what the retrieval
102
+ // conditions (`archived not_equal true`) admit, so the lexical
103
+ // index has to see it, not just content writes.
104
+ noteMemoryUpsert(archivedRow);
96
105
  stats.archived++;
97
106
  }
98
107
  catch {
@@ -61,7 +61,7 @@ export class SemanticSearch extends Resource {
61
61
  // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
62
62
  // before reconsidering this default if the compositeScore formula or
63
63
  // corpus changes.
64
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, abstain = false, explain = false } = data || {};
64
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false } = data || {};
65
65
  // Authenticated identity lives on the Harper Resource context (getContext().request).
66
66
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
67
67
  // silently returned undefined and the defense-in-depth scope check below
@@ -234,7 +234,20 @@ export class SemanticSearch extends Resource {
234
234
  // default projection omits. Widen the select ONLY when the caller opts
235
235
  // in — passing undefined otherwise keeps the default (no `provenance`)
236
236
  // so a non-trust recall response stays byte-identical.
237
- select: includeTrust ? [...DEFAULT_SELECT, "provenance"] : undefined,
237
+ //
238
+ // flair#1332: same idiom for the client-writable `metadata` JSON blob
239
+ // (ADK custom_metadata store-and-return). DEFAULT_SELECT deliberately
240
+ // does NOT grow it (K&S projection ruling — the shared retrieval core
241
+ // serves every consumer, and none of the others should pay result-size
242
+ // for an opaque blob they never read); adk-flair opts in per-request
243
+ // with `includeMetadata: true`. `subject` needs no widening — it is
244
+ // already in DEFAULT_SELECT. Neither flag ⇒ select stays undefined ⇒
245
+ // response bytes unchanged.
246
+ select: (includeTrust || includeMetadata)
247
+ ? [...DEFAULT_SELECT,
248
+ ...(includeTrust ? ["provenance"] : []),
249
+ ...(includeMetadata ? ["metadata"] : [])]
250
+ : undefined,
238
251
  // flair#744 slice 2 + confidence-band refinement: attach the absolute
239
252
  // per-result cosine confidence when the caller opts into abstention OR
240
253
  // the trust block — abstention reads the best of it for its verdict, and