@tpsdev-ai/flair 0.44.10 → 0.44.12

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
@@ -7918,6 +7918,11 @@ remNightly
7918
7918
  if (row.candidates) {
7919
7919
  console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
7920
7920
  }
7921
+ // row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
7922
+ // this cycle — i.e. a non-dry-run cycle for an ADK agentId.
7923
+ if (row.autoPromoted) {
7924
+ console.log(`Auto-promoted: ${row.autoPromoted.promoted} to own memory (${row.autoPromoted.skipped} left pending)`);
7925
+ }
7921
7926
  // row.dedup populates when step 6 (instance-wide dedup-cluster stat,
7922
7927
  // flair-quality Slice 1c) succeeded this cycle. Absent on dry-run skip
7923
7928
  // or a non-fatal failure (see Errors below — e.g. non-admin caller).
@@ -86,6 +86,17 @@ export const DEFAULT_DISTILL_LOOKBACK_MS = 48 * 3600_000;
86
86
  * recorded in `errors` and picked up on subsequent cycles (they stay active).
87
87
  */
88
88
  export const DEFAULT_MAX_TAGS_PER_CYCLE = 200;
89
+ /**
90
+ * Per-cycle ceiling on ADK candidate auto-promotions (#1205b-2). The nightly
91
+ * cycle sweeps this agent's pending, scopeTag-bearing candidates and promotes
92
+ * the eligible ones to own memory server-side (POST /AutoPromoteCandidates).
93
+ * This bounds the blast radius of one cycle (Kern's cost-ceiling note); the
94
+ * resource applies the same cap, and any overflow stays `pending` for a later
95
+ * cycle. Mirror of resources/auto-promote-lib.ts's DEFAULT_MAX_AUTO_PROMOTE_
96
+ * PER_CYCLE, duplicated across the npm-packaging boundary (src/ can't import
97
+ * resources/ — see src/cli.ts) and kept in sync by value.
98
+ */
99
+ export const DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE = 200;
89
100
  function readPauseSentinel(path) {
90
101
  try {
91
102
  const contents = readFileSync(path, "utf-8");
@@ -351,6 +362,9 @@ export async function runNightlyCycle(opts) {
351
362
  // exactly as before. The single-node-runs-the-timer property is unchanged:
352
363
  // this all runs inside the one cycle on the one node.
353
364
  let candidates;
365
+ // #1205b-2: outcome of the post-distillation auto-promote step, assigned
366
+ // inside the !dryRun block below (only when this is an ADK agentId).
367
+ let autoPromoted;
354
368
  const collectStagedIds = (obj) => asArray(obj.candidates)
355
369
  .map((c) => (c && typeof c === "object" ? c.id : c))
356
370
  .filter((id) => typeof id === "string");
@@ -424,6 +438,35 @@ export async function runNightlyCycle(opts) {
424
438
  errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
425
439
  }
426
440
  }
441
+ // ── Step 5b (#1205b-2): server-side ADK auto-promote ───────────────────────
442
+ // Only for an ADK agentId (active adk: tags this cycle) — a non-ADK agent
443
+ // has no scopeTag-bearing candidates, so there is nothing to auto-promote
444
+ // and no call is made. The SERVER enforces every security invariant
445
+ // (memory-only target, fail-closed tag lineage, content-safety, machine
446
+ // reviewerId) — the runner only TRIGGERS the sweep; it never itself decides
447
+ // where a claim lands. Non-fatal like distillation: a failure is recorded
448
+ // and the candidates stay pending (re-swept next cycle, or promotable by the
449
+ // human `rem promote` path). Bounded by the per-cycle cap.
450
+ if (activeAdkTags.length > 0) {
451
+ try {
452
+ const apRaw = await opts.apiCall("POST", "/AutoPromoteCandidates", {
453
+ agentId: opts.agentId,
454
+ limit: opts.maxAutoPromotePerCycle ?? DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE,
455
+ });
456
+ const obj = (apRaw && typeof apRaw === "object") ? apRaw : {};
457
+ if (obj.error) {
458
+ errors.push(`auto-promote: ${describeApiError(obj.error)}`);
459
+ }
460
+ else {
461
+ const promotedCount = typeof obj.count === "number" ? obj.count : asArray(obj.promoted).length;
462
+ const skippedCount = asArray(obj.skipped).length;
463
+ autoPromoted = { promoted: promotedCount, skipped: skippedCount };
464
+ }
465
+ }
466
+ catch (err) {
467
+ errors.push(`auto-promote: ${describeApiError(err?.message ?? err)}`);
468
+ }
469
+ }
427
470
  }
428
471
  // Step 6 (flair-quality Slice 1c): instance-wide dedup-cluster stat.
429
472
  // Distinct from every step above — NOT scoped to opts.agentId. Runs ONCE
@@ -481,6 +524,7 @@ export async function runNightlyCycle(opts) {
481
524
  archived,
482
525
  expired,
483
526
  candidates,
527
+ autoPromoted,
484
528
  dedup,
485
529
  durationMs: Date.now() - startedMs,
486
530
  errors,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * POST /AutoPromoteCandidates (#1205b-2 — the UNATTENDED promotion path)
3
+ *
4
+ * Sweeps this agent's PENDING, ADK-sourced (scopeTag-bearing) MemoryCandidates
5
+ * and auto-promotes each eligible one to the agent's OWN persistent Memory — no
6
+ * human reviewer, replacing `flair rem promote` for this one narrow path. Wired
7
+ * into the nightly runner (src/rem/runner.ts) as a post-distillation step.
8
+ *
9
+ * This is the SERVER-SIDE trust-tier enforcement cli.ts noted as deferred. The
10
+ * whole reason it is a resource and not a CLI flag is Sherlock's req 1: the
11
+ * "never Soul" invariant must live where a compromised agent key cannot flip it.
12
+ *
13
+ * Req 1 — memory-only, enforced HERE, structurally. There is NO soul code
14
+ * path in this resource: the only write it can perform is a Memory write.
15
+ * Soul is agentId-scoped and cannot carry a per-user `adk:<app>:<user>` tag,
16
+ * so an ADK-sourced → Soul promotion is cross-user BY CONSTRUCTION. On top
17
+ * of the structural absence, an explicit `target` in the request body that
18
+ * is anything other than "memory" is REFUSED loudly (400) rather than
19
+ * silently ignored — so a caller trying to flip the target gets a hard no.
20
+ * Req 2 — fail-closed tag lineage: decideAutoPromote (auto-promote-lib.ts)
21
+ * refuses any candidate without an authoritative `adk:` stamped scopeTag,
22
+ * and the promoted Memory carries that scopeTag as its first tag.
23
+ * Req 3 — content-safety: decideAutoPromote scans the claim strict (always
24
+ * refuses a flag, independent of FLAIR_CONTENT_SAFETY), and the Memory.put()
25
+ * override below scans again on the write (defense-in-depth).
26
+ * Req 4 — machine reviewerId: the promoted Memory's `promotedBy` and the
27
+ * candidate row's `reviewerId` both record machine:adk-auto-promote.
28
+ *
29
+ * Request:
30
+ * agentId string? — whose candidates to sweep. A non-admin caller may only
31
+ * sweep its OWN (resolveReflectActor); admin may name any.
32
+ * limit number? — per-call cap (default DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE).
33
+ * target string? — MUST be absent or "memory". Any other value is refused
34
+ * (the hard-lock made explicit + testable).
35
+ *
36
+ * Response:
37
+ * { agentId, promoted: string[], skipped: {id, reason}[], count, considered }
38
+ *
39
+ * Thin orchestrator over the pure, tested policy in ./auto-promote-lib.ts.
40
+ */
41
+ import { Resource, databases, logger } from "harper";
42
+ import { isAdmin, allowVerified } from "./agent-auth.js";
43
+ import { resolveReflectActor } from "./memory-reflect-lib.js";
44
+ import { agentContext } from "./in-process.js";
45
+ import { decideAutoPromote, buildAutoPromotedTags, DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE, } from "./auto-promote-lib.js";
46
+ export class AutoPromoteCandidates extends Resource {
47
+ // Any verified agent may trigger a sweep of ITS OWN candidates; the actor
48
+ // resolution in post() enforces the own-only scope. Same gate ReflectMemories
49
+ // uses (allowVerified — verified agents, admins, trusted internal calls).
50
+ async allowCreate() {
51
+ return allowVerified(this.getContext?.());
52
+ }
53
+ async post(data) {
54
+ const { agentId: bodyAgentId, limit, target } = data || {};
55
+ // ── Req 1: target hard-lock, made explicit ────────────────────────────────
56
+ // This resource NEVER writes Soul — there is no soul branch anywhere below.
57
+ // An explicit non-memory `target` in the body is refused loudly so a caller
58
+ // (or a compromised agent key) attempting to flip the target gets a hard no,
59
+ // not a silent memory write it did not ask for.
60
+ if (target !== undefined && target !== "memory") {
61
+ return new Response(JSON.stringify({
62
+ error: "auto_promote_target_locked",
63
+ message: "auto-promote is hard-locked to memory server-side; soul (or any non-memory target) is refused — an ADK-sourced promotion to Soul would leak across users",
64
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
65
+ }
66
+ // ── Identity / actor resolution (own candidates only, unless admin) ────────
67
+ const ctx = this.getContext?.();
68
+ const request = ctx?.request ?? ctx;
69
+ const actorId = request?.tpsAgent;
70
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true || (actorId ? await isAdmin(actorId) : false);
71
+ const actorResolution = resolveReflectActor({ bodyAgentId, actorId, callerIsAdmin });
72
+ if (actorResolution.error) {
73
+ return new Response(JSON.stringify(actorResolution.error.body), { status: actorResolution.error.status });
74
+ }
75
+ const agentId = actorResolution.agentId;
76
+ const cap = typeof limit === "number" && Number.isFinite(limit) && limit > 0
77
+ ? Math.floor(limit)
78
+ : DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE;
79
+ // ── Sweep this agent's pending candidates ──────────────────────────────────
80
+ // Owner-scoped in JS by `c.agentId !== agentId` (the raw table search is
81
+ // org-wide — same discipline ReflectMemories uses), so a non-admin actor can
82
+ // only ever sweep its own candidates: no cross-agent promotion, no oracle.
83
+ //
84
+ // decideAutoPromote (auto-promote-lib.ts) is the SINGLE fail-closed gate:
85
+ // it — not this loop — decides eligibility (ADK scope tag present + content
86
+ // safe). The `status !== "pending"` skip here is a pure enumeration
87
+ // optimization (don't build a skip record for every historical decided row);
88
+ // decideAutoPromote re-checks status authoritatively. The `cap` bounds the
89
+ // number PROMOTED (the expensive part — a Memory write + embedding each),
90
+ // Kern's cost ceiling; overflow stays pending for a later cycle.
91
+ const MemoryCls = (await import("./Memory.js")).Memory;
92
+ const promoted = [];
93
+ const skipped = [];
94
+ let considered = 0;
95
+ // Bounded enumeration (Sherlock non-blocking + Kern's cost ceiling): filter
96
+ // to THIS agent's PENDING candidates in the DB query so a large backlog of
97
+ // other-status / other-agent rows is never scanned each cycle. The JS
98
+ // agentId/status re-checks below stay as FAIL-CLOSED defense-in-depth — the
99
+ // owner-scope guarantee does not rely on the query alone.
100
+ const candidateQuery = {
101
+ operator: "and",
102
+ conditions: [
103
+ { attribute: "agentId", comparator: "equals", value: agentId },
104
+ { attribute: "status", comparator: "equals", value: "pending" },
105
+ ],
106
+ };
107
+ for await (const c of databases.flair.MemoryCandidate.search(candidateQuery)) {
108
+ if (!c || typeof c !== "object")
109
+ continue;
110
+ if (c.agentId !== agentId)
111
+ continue; // owner scope (no cross-agent) — defense-in-depth
112
+ if (c.status !== "pending")
113
+ continue; // defense-in-depth
114
+ considered++;
115
+ const decision = decideAutoPromote(c);
116
+ if (!decision.promote) {
117
+ skipped.push({ id: c.id, reason: decision.reason });
118
+ continue;
119
+ }
120
+ const decidedAt = new Date().toISOString();
121
+ const memId = `${agentId}-promoted-${Date.now()}-${promoted.length}`;
122
+ const memRow = {
123
+ id: memId,
124
+ agentId,
125
+ content: c.claim,
126
+ durability: "persistent",
127
+ // ── visibility: PRIVATE, explicit (Sherlock cross-agent leak fix) ──────
128
+ // MUST be set. Memory.put() defaults an unset visibility from durability
129
+ // (Memory.ts) and "persistent" defaults to "shared" — and "shared" is
130
+ // ORG-OPEN (memory-read-scope.ts): readable by EVERY verified agent on
131
+ // the instance. The source episodes are the user's PRIVATE session data
132
+ // (durability:"standard" → default private), and the per-user boundary
133
+ // is adk-flair's CLIENT-SIDE tag re-verification, which OTHER agents do
134
+ // NOT run. So a shared auto-promoted claim would leak a user's distilled
135
+ // private data to every agent on the box — unattended. "private" is
136
+ // owner-only, so the claim is reachable ONLY through the app agent's own
137
+ // tag-filtered search (which re-verifies the tag) and is invisible to
138
+ // every other agent. This keeps the blast radius inside the one agentId,
139
+ // which is the entire #1205 safety argument.
140
+ visibility: "private",
141
+ // scopeTag FIRST — the per-user access-control boundary (Req 2).
142
+ tags: buildAutoPromotedTags(c.id, decision.scopeTag),
143
+ derivedFrom: Array.isArray(c.sourceMemoryIds) ? c.sourceMemoryIds : [],
144
+ promotionStatus: "approved",
145
+ promotedAt: decidedAt,
146
+ // Req 4 — non-impersonating machine reviewerId.
147
+ promotedBy: decision.reviewerId,
148
+ createdAt: decidedAt,
149
+ };
150
+ // ── The write — MEMORY ONLY ────────────────────────────────────────────
151
+ // Static Cls.put(row, context) routes THROUGH Memory.put()'s override
152
+ // (Req 3 content-safety scan on the write + embedding + provenance +
153
+ // per-agent ownership), acting as the agent itself (agentContext). There
154
+ // is deliberately no Soul equivalent here (Req 1). decideAutoPromote has
155
+ // already refused any content-flagged claim strict, so a normal claim
156
+ // sails through; a Memory.put refusal (e.g. instance-wide strict mode) is
157
+ // treated as a skip and the candidate is left pending — never a lost claim.
158
+ let writeRes;
159
+ try {
160
+ writeRes = await MemoryCls.put(memRow, agentContext(agentId));
161
+ }
162
+ catch (err) {
163
+ logger.warn?.(`AutoPromoteCandidates: Memory write threw for candidate ${c.id}: ${err?.message ?? err}`);
164
+ skipped.push({ id: c.id, reason: "memory_write_error" });
165
+ continue;
166
+ }
167
+ if (writeRes instanceof Response && !writeRes.ok) {
168
+ skipped.push({ id: c.id, reason: `memory_write_rejected:${writeRes.status}` });
169
+ continue;
170
+ }
171
+ // ── Mark the candidate promoted (commit point) ─────────────────────────
172
+ // Ordered AFTER the Memory write, matching the human promote path: the
173
+ // safe failure state is a promoted Memory whose candidate is still pending
174
+ // (re-swept next cycle; the Memory dedup gate absorbs the duplicate),
175
+ // never a candidate marked promoted with no Memory behind it.
176
+ try {
177
+ await databases.flair.MemoryCandidate.put({
178
+ ...c,
179
+ status: "promoted",
180
+ target: "memory",
181
+ reviewerId: decision.reviewerId,
182
+ reviewRationale: decision.rationale,
183
+ decidedAt,
184
+ });
185
+ }
186
+ catch (err) {
187
+ logger.warn?.(`AutoPromoteCandidates: candidate row update failed for ${c.id} (Memory ${memId} written): ${err?.message ?? err}`);
188
+ }
189
+ promoted.push(memId);
190
+ // Cost ceiling (Kern): cap the number PROMOTED this cycle. Remaining
191
+ // eligible candidates stay pending and are swept on a later cycle.
192
+ if (promoted.length >= cap)
193
+ break;
194
+ }
195
+ return {
196
+ agentId,
197
+ promoted,
198
+ skipped,
199
+ count: promoted.length,
200
+ considered,
201
+ };
202
+ }
203
+ }
@@ -7,6 +7,7 @@ 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
9
  import { assertValidVisibility } from "./memory-visibility.js";
10
+ import { assertValidDurability } from "./memory-durability.js";
10
11
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
11
12
  import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
12
13
  import { RECORD_TYPES } from "./record-types.js";
@@ -599,6 +600,19 @@ export class Memory extends databases.flair.Memory {
599
600
  const usedMemoryIds = content?.usedMemoryIds;
600
601
  if (content && typeof content === "object")
601
602
  delete content.usedMemoryIds;
603
+ // ── flair#1238: refuse an unrecognised durability BEFORE defaulting ──
604
+ // defaultVisibilityForDurability treats any non-permanent/persistent string
605
+ // as the private branch, so an unknown durability via raw REST (or a future
606
+ // non-Python adapter) is silently accepted and lands on the narrower private
607
+ // branch by accident — fail-safe, but unvalidated by contract. Refusing at
608
+ // the schema boundary makes it safe by construction (mirrors the visibility
609
+ // guard below). Absent durability is accepted and defaulted to "standard".
610
+ {
611
+ const durabilityError = assertValidDurability(content.durability);
612
+ if (durabilityError) {
613
+ return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), { status: 400, headers: { "content-type": "application/json" } });
614
+ }
615
+ }
602
616
  content.durability ||= "standard";
603
617
  content.createdAt = new Date().toISOString();
604
618
  content.updatedAt = content.createdAt;
@@ -789,6 +803,17 @@ export class Memory extends databases.flair.Memory {
789
803
  const usedMemoryIds = content?.usedMemoryIds;
790
804
  if (content && typeof content === "object")
791
805
  delete content.usedMemoryIds;
806
+ // ── flair#1238: refuse an unrecognised durability (mirrors post()) ──
807
+ // put() is the other HTTP-reachable write path (fresh create via CLI, and
808
+ // the update/patch path). Same guard as post(): a present-but-unknown
809
+ // durability is refused with 400; absent is accepted (no default stamped
810
+ // here — put() leaves durability untouched for updates).
811
+ {
812
+ const durabilityError = assertValidDurability(content.durability);
813
+ if (durabilityError) {
814
+ return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), { status: 400, headers: { "content-type": "application/json" } });
815
+ }
816
+ }
792
817
  const now = new Date().toISOString();
793
818
  content.updatedAt = now;
794
819
  // Set defaults that post() sets — put() is also used for new records via CLI
@@ -94,20 +94,35 @@ import { estimateTokens } from "./token-estimate.js";
94
94
  *
95
95
  * CAP CONTRACT: `maxTokens` is the HARD cap on CONTENT SELECTION — the shared
96
96
  * `tokenBudget` starts at `maxTokens` and every admitted soul/memory/finding
97
- * line AND every org event (flair#1199 — events are content too; before this
98
- * they were assembled but NEVER charged, so a maxTokens=4000 request serialized
99
- * at 6286) is gated against the remaining budget, so the sum of selected CONTENT
100
- * never exceeds `maxTokens`. `tokenEstimate` HONESTLY reports the real serialized
101
- * payload (`JSON.stringify(responseBody)`), which includes the FIXED structured-
102
- * container JSON scaffolding (keys/braces, counters, the sections map) and so may
103
- * exceed `maxTokens` slightly by that bounded overhead measurement is decoupled
104
- * from budgeting, but the overhead is now scaffolding ONLY, never uncounted
105
- * content. The connector-conformance suite asserts tokenEstimate <= maxTokens
106
- * within a small tolerance for that scaffolding. flair#1207: #1199 had ALSO
107
- * folded a per-item structured overhead + a scaffolding reserve INTO the
108
- * selection budget, which silently shrank recall below 0.44.6 for the same
109
- * `maxTokens`; that per-item overhead is a reporting concern (already captured by
110
- * `tokenEstimate`) and no longer shrinks the content budget.
97
+ * AND every org event (flair#1199 — events are content too; before this they
98
+ * were assembled but NEVER charged, so a maxTokens=4000 request serialized at
99
+ * 6286) is gated against the remaining budget, so the sum of selected CONTENT
100
+ * never exceeds `maxTokens`. Each item is charged the cost of what it ACTUALLY
101
+ * SHIPS on the requested surface (see contentCost): on the /mcp connector path
102
+ * (includeContext=false) the prose `context` is a pointer, so only the
103
+ * STRUCTURED container object ships and is charged; on the REST/CLI prose path
104
+ * (includeContext=true) the prose IS the shipped surface and is charged (0.44.6
105
+ * capacity — flair#1207). `tokenEstimate` HONESTLY reports the real serialized
106
+ * payload (`JSON.stringify(responseBody)`). On the connector path where the
107
+ * selection charge and `tokenEstimate` measure the SAME structured bytes
108
+ * `tokenEstimate` exceeds `maxTokens` only by the FIXED JSON scaffolding
109
+ * (keys/braces, counters, the sections map, hints); the connector-conformance
110
+ * budgetCap asserts tokenEstimate <= maxTokens within a small tolerance for
111
+ * exactly that scaffolding. On the prose path the payload ALSO carries the
112
+ * structured mirror, so `tokenEstimate` may exceed `maxTokens` by that mirror —
113
+ * that overage is honest measurement, and shrinking prose selection to hide it
114
+ * is the flair#1207 regression (below). flair#1199 (0.44.11): charging the
115
+ * PROSE line but shipping the heavier STRUCTURED object on the /mcp path let
116
+ * teammate findings ride OUTSIDE the enforced budget (soulTokens 377 +
117
+ * memoryTokens 3574 = 3951 prose, just under a 4000 cap, yet tokenEstimate 5337
118
+ * = +33%; teammateFindingsIncluded crept 4→5) — fixed by charging structured
119
+ * on the connector path. flair#1207: #1199 had ALSO folded a per-item
120
+ * structured overhead + a scaffolding reserve INTO the selection budget, which
121
+ * silently shrank recall below 0.44.6 for the same `maxTokens`; that flat
122
+ * per-item overhead is a reporting concern (already captured by `tokenEstimate`)
123
+ * and no longer shrinks the content budget — the 0.44.11 fix charges the
124
+ * item's REAL shipped serialization, not a flat surcharge, and only on the path
125
+ * where that serialization is the shipped surface.
111
126
  *
112
127
  * COUNT CONTRACT (flair#1207): `memoriesIncluded + memoriesTruncated <=
113
128
  * memoriesAvailable` — included and truncated are disjoint sets of UNIQUE own
@@ -122,6 +137,40 @@ import { estimateTokens } from "./token-estimate.js";
122
137
  // Collision surfacing (flair#681) tunables.
123
138
  const COLLISION_WINDOW_DAYS = 7;
124
139
  const MAX_COLLISION_ENTRIES = 10;
140
+ // flair#1201/#1225 — trust-block sections that are a lifecycle-window LOAD, not
141
+ // a retrieval surface. A trust entry from one of these carries `matchQuality:
142
+ // null` (no relevance score to band), which is CORRECT (Kern's #1220 ruling),
143
+ // never a scoring failure — see the `matchQualityNote` in the response tail.
144
+ const LIFECYCLE_SECTIONS = new Set(["permanent", "recent", "predicted"]);
145
+ // flair#1199 trust-admission — build the EXACT trust entry that ships for one
146
+ // included memory (id + section + block + the conditional matchQualityNote), so
147
+ // the admission loop can charge its REAL serialized cost at the same moment it
148
+ // charges the content cost. Single source of truth: the response tail reuses
149
+ // this same builder, so the charged size and the shipped size can never drift
150
+ // (the #1226 "charge what ships" principle, extended to the per-item trust
151
+ // block). The block is assembled purely for SIZING + the response — its content
152
+ // never enters any authority/scope/attribution/dedup decision (the #735/#744
153
+ // zero-authority invariant).
154
+ //
155
+ // flair#1225 — Kern ruled (on #1220) that a null `matchQuality` on an own-recent
156
+ // (lifecycle) entry is CORRECT: lifecycle sections (permanent/recent/predicted)
157
+ // are a window LOAD, not a retrieval surface, so there is no similarity to band
158
+ // — the null means "not scored here", never a scoring failure on the caller's
159
+ // own records (the #1201 misread: own-recent null beside a teammate band).
160
+ // Behavior is unchanged (per Kern); the matchQualityNote only makes the null
161
+ // self-describing in the payload — Fix 3's "any absent field says why" — so a
162
+ // connector reads it right without knowing the #1201 contract.
163
+ function buildTrustEntry(m, section) {
164
+ const block = buildTrustBlock(m);
165
+ const matchQualityNote = block.matchQuality === null
166
+ ? (LIFECYCLE_SECTIONS.has(section)
167
+ ? `matchQuality is null because '${section}' is a lifecycle-window section, not a retrieval `
168
+ + `surface — there is no relevance score to band. This is correct (per flair#1225), not a scoring failure.`
169
+ : "matchQuality is null because no semantic similarity was attached to this result "
170
+ + "(e.g. a by-id read or a keyword-only degraded match).")
171
+ : undefined;
172
+ return { id: m.id, section, ...block, ...(matchQualityNote ? { matchQualityNote } : {}) };
173
+ }
125
174
  // flair#1199/#1206 — the default cap on how many org events bootstrap ships.
126
175
  // Overridable per-request via `maxEvents`. Event slots are scarce AND (as of
127
176
  // #1199) token-charged, so this bounds both the count and the spend; the shared
@@ -368,6 +417,34 @@ export class BootstrapMemories extends Resource {
368
417
  subject: m.subject ?? null,
369
418
  section,
370
419
  });
420
+ // flair#1199 (0.44.11) — the REAL cost an admitted content item adds to the
421
+ // serialized payload the caller RECEIVES, which is exactly what
422
+ // `tokenEstimate` measures. This is the fix for the teammate-findings
423
+ // budget blowout: the selector used to charge every memory/finding its
424
+ // PROSE line (`formatMemory`) but, on the /mcp connector path, ship the
425
+ // heavier STRUCTURED container object — and `tokenEstimate` measures the
426
+ // structured object. A teammate finding carries `id` + TWO ISO timestamps +
427
+ // `source` + `section` + JSON field names/quotes that the prose line does
428
+ // not, so the shipped object runs ~1.5–1.7× its prose line. Charging prose
429
+ // but shipping structured let several teammate findings ride OUTSIDE the
430
+ // enforced budget: a maxTokens=4000 bootstrap reported soulTokens 377 +
431
+ // memoryTokens 3574 = 3951 (prose, just under cap) yet serialized at 5337
432
+ // (+33%), and teammateFindingsIncluded crept 4→5. So charge what SHIPS:
433
+ // - /mcp connector path (includeContext=false): the prose `context` is a
434
+ // compact pointer (no bodies), so ONLY the structured container ships —
435
+ // charge its serialized size. Now the sum of admitted content ≈
436
+ // `tokenEstimate` minus the FIXED JSON scaffolding, so `tokenEstimate`
437
+ // stays within maxTokens + the small scaffolding tolerance.
438
+ // - REST/CLI prose path (includeContext=true): the prose IS the primary
439
+ // shipped surface and flair#1207 deliberately fixed the selection budget
440
+ // at 0.44.6 (prose) capacity — `tokenEstimate` is HONEST there and may
441
+ // exceed maxTokens by the structured mirror it also ships. Charging
442
+ // structured on THAT path would re-shrink prose recall below 0.44.6
443
+ // (the exact #1207 regression). So keep charging prose on the prose
444
+ // path. This is "fix the budget INPUT, not the cap": the figure the
445
+ // selector tests against maxTokens is now the figure that becomes
446
+ // `tokenEstimate` on each path.
447
+ const contentCost = (structured, proseLine) => includeContext ? estimateTokens(proseLine) : estimateTokens(JSON.stringify(structured));
371
448
  // --- 1. Soul records (budgeted — prioritized by key importance) ---
372
449
  // Soul is who you are, but we still need to respect token budgets.
373
450
  // Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
@@ -617,13 +694,22 @@ export class BootstrapMemories extends Resource {
617
694
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
618
695
  for (const m of permanent) {
619
696
  const line = formatMemory(m, agentId);
620
- const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
621
- if (cost <= tokenBudget) {
697
+ const struct = leanMemory(m, "permanent");
698
+ // #1199 (0.44.11) charge what SHIPS (structured on the /mcp path, prose
699
+ // on the REST path); see contentCost. #1207 stays honored: on the prose
700
+ // path this is still the prose-line cost, so REST recall is unchanged.
701
+ const cost = contentCost(struct, line);
702
+ // flair#1199 trust-admission — charge the trust block's real serialized
703
+ // cost at the same moment as the content cost (per-item trust is CONTENT,
704
+ // not fixed scaffolding; see buildTrustEntry).
705
+ const trustEntry = includeTrust ? buildTrustEntry(m, "permanent") : null;
706
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
707
+ if (cost + trustCost <= tokenBudget) {
622
708
  sections.permanent.push(line);
623
- includedOwnMemories.push(leanMemory(m, "permanent"));
624
- if (includeTrust)
625
- includedTrustMemories.push({ m, section: "permanent" });
626
- tokenBudget -= cost;
709
+ includedOwnMemories.push(struct);
710
+ if (trustEntry)
711
+ includedTrustMemories.push(trustEntry);
712
+ tokenBudget -= cost + trustCost;
627
713
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
628
714
  }
629
715
  else {
@@ -689,17 +775,22 @@ export class BootstrapMemories extends Resource {
689
775
  let recentSpent = 0;
690
776
  for (const m of recent) {
691
777
  const line = formatMemory(m, agentId);
692
- const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
693
- if (recentSpent + cost > recentBudget) {
778
+ const struct = leanMemory(m, "recent");
779
+ const cost = contentCost(struct, line); // #1199 (0.44.11) — charge what ships; see contentCost
780
+ // flair#1199 trust-admission — charge the trust block's real serialized
781
+ // cost against BOTH the recent sub-budget and the shared tokenBudget.
782
+ const trustEntry = includeTrust ? buildTrustEntry(m, "recent") : null;
783
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
784
+ if (recentSpent + cost + trustCost > recentBudget) {
694
785
  truncatedOwnIds.add(m.id); // #1207 — budget-skip; may still be admitted later via the task-relevant loop (deduped at the end)
695
786
  continue;
696
787
  }
697
788
  sections.recent.push(line);
698
- includedOwnMemories.push(leanMemory(m, "recent"));
699
- if (includeTrust)
700
- includedTrustMemories.push({ m, section: "recent" });
701
- recentSpent += cost;
702
- tokenBudget -= cost;
789
+ includedOwnMemories.push(struct);
790
+ if (trustEntry)
791
+ includedTrustMemories.push(trustEntry);
792
+ recentSpent += cost + trustCost;
793
+ tokenBudget -= cost + trustCost;
703
794
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
704
795
  }
705
796
  // --- 3b. Subject-predicted context ---
@@ -732,17 +823,22 @@ export class BootstrapMemories extends Resource {
732
823
  let predictedSpent = 0;
733
824
  for (const m of subjectMemories) {
734
825
  const line = formatMemory(m, agentId);
735
- const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
736
- if (predictedSpent + cost > predictedBudget) {
826
+ const struct = leanMemory(m, "predicted");
827
+ const cost = contentCost(struct, line); // #1199 (0.44.11) — charge what ships; see contentCost
828
+ // flair#1199 trust-admission — charge the trust block's real serialized
829
+ // cost against BOTH the predicted sub-budget and the shared tokenBudget.
830
+ const trustEntry = includeTrust ? buildTrustEntry(m, "predicted") : null;
831
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
832
+ if (predictedSpent + cost + trustCost > predictedBudget) {
737
833
  truncatedOwnIds.add(m.id); // #1207 — budget-skip (deduped against inclusions at the end)
738
834
  continue;
739
835
  }
740
836
  sections.predicted.push(line);
741
- includedPredicted.push(leanMemory(m, "predicted"));
742
- if (includeTrust)
743
- includedTrustMemories.push({ m, section: "predicted" });
744
- predictedSpent += cost;
745
- tokenBudget -= cost;
837
+ includedPredicted.push(struct);
838
+ if (trustEntry)
839
+ includedTrustMemories.push(trustEntry);
840
+ predictedSpent += cost + trustCost;
841
+ tokenBudget -= cost + trustCost;
746
842
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id; also the task-relevant loop's exclusion set (no predicted→relevant double-admit)
747
843
  }
748
844
  }
@@ -908,8 +1004,36 @@ export class BootstrapMemories extends Resource {
908
1004
  // section double-spends.
909
1005
  for (const { memory: m } of scored) {
910
1006
  const line = formatMemory(m, agentId);
911
- const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
912
- if (cost > tokenBudget) {
1007
+ // flair#1199 (0.44.11) — build the STRUCTURED container object BEFORE
1008
+ // the budget check so the finding is charged the cost of what actually
1009
+ // ships (structured on the /mcp path), not its cheaper prose line. A
1010
+ // teammate finding's structured object (id + two ISO timestamps +
1011
+ // source + section) runs well over its prose line, and it — not the
1012
+ // prose — is what `tokenEstimate` measures on the connector path. This
1013
+ // is the fix for the teammate-findings blowout: charging prose but
1014
+ // shipping structured let extra findings ride outside the enforced
1015
+ // budget (see contentCost). Cross-agent findings (`m._source` set)
1016
+ // ship in `teammateFindings`; own findings in `memories`.
1017
+ const struct = m._source
1018
+ ? {
1019
+ id: m.id,
1020
+ content: m.content,
1021
+ durability: m.durability ?? null,
1022
+ createdAt: m.createdAt ?? null,
1023
+ updatedAt: m.updatedAt ?? null,
1024
+ subject: m.subject ?? null,
1025
+ source: m._source,
1026
+ section: "teammate",
1027
+ }
1028
+ : leanMemory(m, "relevant");
1029
+ const cost = contentCost(struct, line);
1030
+ // flair#1199 trust-admission — charge the trust block's real serialized
1031
+ // cost at the same moment as the content cost. The section (teammate vs
1032
+ // relevant) is decided by `m._source`, so build the entry with the right
1033
+ // section before the budget check.
1034
+ const trustEntry = includeTrust ? buildTrustEntry(m, m._source ? "teammate" : "relevant") : null;
1035
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
1036
+ if (cost + trustCost > tokenBudget) {
913
1037
  // flair#1207 — a size-skip in the score-ordered task-relevant loop
914
1038
  // is no longer silent: record it on the denominator matching the
915
1039
  // record's origin (own → truncatedOwnIds, teammate → the separate
@@ -929,29 +1053,20 @@ export class BootstrapMemories extends Resource {
929
1053
  // off. Counted separately (teammateFindingsIncluded), NOT into
930
1054
  // memoriesIncluded — that different-denominator mix is what let
931
1055
  // included exceed available.
932
- includedTeammateFindings.push({
933
- id: m.id,
934
- content: m.content,
935
- durability: m.durability ?? null,
936
- createdAt: m.createdAt ?? null,
937
- updatedAt: m.updatedAt ?? null,
938
- subject: m.subject ?? null,
939
- source: m._source,
940
- section: "teammate",
941
- });
942
- if (includeTrust)
943
- includedTrustMemories.push({ m, section: "teammate" });
944
- tokenBudget -= cost;
1056
+ includedTeammateFindings.push(struct);
1057
+ if (trustEntry)
1058
+ includedTrustMemories.push(trustEntry);
1059
+ tokenBudget -= cost + trustCost;
945
1060
  teammateFindingsIncluded++;
946
1061
  }
947
1062
  else {
948
1063
  sections.relevant.push(line);
949
1064
  // flair#1182 — own task-relevant records join the `memories`
950
1065
  // container.
951
- includedOwnMemories.push(leanMemory(m, "relevant"));
952
- if (includeTrust)
953
- includedTrustMemories.push({ m, section: "relevant" });
954
- tokenBudget -= cost;
1066
+ includedOwnMemories.push(struct);
1067
+ if (trustEntry)
1068
+ includedTrustMemories.push(trustEntry);
1069
+ tokenBudget -= cost + trustCost;
955
1070
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
956
1071
  }
957
1072
  }
@@ -1276,10 +1391,15 @@ export class BootstrapMemories extends Resource {
1276
1391
  // absent ⇒ the response is byte-identical to pre-slice-1. flair#1201 — each
1277
1392
  // entry carries its `section` so `matchQuality: null` on a lifecycle section
1278
1393
  // reads as "not a retrieval surface", not as a scoring failure on the
1279
- // caller's own records.
1280
- const trust = includeTrust
1281
- ? includedTrustMemories.map(({ m, section }) => ({ id: m.id, section, ...buildTrustBlock(m) }))
1282
- : undefined;
1394
+ // caller's own records. flair#1225 (0.44.11) — the null on a lifecycle
1395
+ // section is now SELF-EXPLAINING (matchQualityNote below), not just legible
1396
+ // via `section`.
1397
+ // flair#1199 trust-admission — the trust entries were built (and their real
1398
+ // serialized cost charged) at admission time via buildTrustEntry, so the
1399
+ // response tail just ships them as-is. The #1225 matchQualityNote logic now
1400
+ // lives in buildTrustEntry (single source of truth — the charged size and
1401
+ // the shipped size can never drift).
1402
+ const trust = includeTrust ? includedTrustMemories : undefined;
1283
1403
  // flair#744 slice 2 — opt-in abstention verdict for the task-relevance
1284
1404
  // surface. Present ONLY when `abstain` is requested (byte-identical to
1285
1405
  // pre-slice-2 otherwise); scoped to whether any memory covered
@@ -1317,6 +1437,36 @@ export class BootstrapMemories extends Resource {
1317
1437
  + `predicted surfaces your own non-permanent memories whose subject matches one of the provided `
1318
1438
  + `subjects — it fills as you store memories tagged with these subjects.`
1319
1439
  : undefined;
1440
+ // flair#1182 (0.44.11) — GENERALIZE the empty-container "say why" rule
1441
+ // beyond predictedHint. `events: []` (deliberate no-op filtering) was
1442
+ // byte-indistinguishable from the 0.44.8 silent-drop regression, where a
1443
+ // container that SHOULD have had content shipped empty — a connector could
1444
+ // only tell the difference by diffing against a previous payload. The rule
1445
+ // (now applied consistently across the structured containers): any container
1446
+ // that ships EMPTY carries a short hint naming WHY it is empty and what
1447
+ // fills it, so "deliberately empty" is never confused with "silently
1448
+ // dropped". Present ONLY when the container is empty (a populated container
1449
+ // needs no hint), so a healthy payload is unchanged.
1450
+ // events: [] — no org event in the lookback window was relevant to the
1451
+ // caller after zero-row no-op auto-heal filtering (#1200). Present-but-empty
1452
+ // by design, not a drop.
1453
+ const eventsHint = includedEvents.length === 0
1454
+ ? "No org events in the lookback window were relevant to you (org-wide, or targeted at you) "
1455
+ + "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped."
1456
+ : undefined;
1457
+ // 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.
1460
+ const teammateFindingsHint = includedTeammateFindings.length === 0
1461
+ ? (!taskProvided
1462
+ ? "No teammateFindings: cross-agent findings are retrieved against your currentTask, and none was provided. "
1463
+ + "Pass currentTask to populate this."
1464
+ : teammateFindingsTruncated > 0
1465
+ ? `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. "
1468
+ + "This container is present-but-empty by design, not dropped.")
1469
+ : undefined;
1320
1470
  const responseBody = {
1321
1471
  context,
1322
1472
  // flair#1182 (part 1) — always-present self-describing keys: who the
@@ -1341,6 +1491,11 @@ export class BootstrapMemories extends Resource {
1341
1491
  events: includedEvents,
1342
1492
  ...(currentTaskHint ? { currentTaskHint } : {}),
1343
1493
  ...(predictedHint ? { predictedHint } : {}),
1494
+ // flair#1182 (0.44.11) — empty-container hints, present only when the
1495
+ // container ships empty (see above), so a deliberately-empty container is
1496
+ // never confused with a silent drop.
1497
+ ...(eventsHint ? { eventsHint } : {}),
1498
+ ...(teammateFindingsHint ? { teammateFindingsHint } : {}),
1344
1499
  ...(trust ? { trust } : {}),
1345
1500
  ...(abstention ? { abstention } : {}),
1346
1501
  sections: {
@@ -1392,16 +1547,20 @@ export class BootstrapMemories extends Resource {
1392
1547
  // the real serialized size. Every CONTENT section — soul, memories, findings,
1393
1548
  // AND events (flair#1199) — is now gated against the shared `maxTokens`
1394
1549
  // budget, so no section blows the budget with uncounted content the way the
1395
- // 0.44.9 events array did (maxTokens=4000 → 6286). tokenEstimate may still
1396
- // exceed `maxTokens` slightly, by the FIXED structural JSON scaffolding
1397
- // (container keys/braces, counters, sections map, char/4 rounding)that is
1398
- // genuine payload the caller pays for, and it is small and bounded, NOT
1399
- // uncounted content. The connector-conformance suite asserts
1400
- // tokenEstimate <= maxTokens within a small tolerance for exactly that
1401
- // scaffolding. Do NOT "fix" a small over-maxTokens tokenEstimate by shrinking
1402
- // memory selection that is the #1199→#1207 regression (it dropped relevant
1403
- // findings, charging a per-item overhead against the content budget). If the
1404
- // real payload consistently overruns for a use case, raise `maxTokens`.
1550
+ // 0.44.9 events array did (maxTokens=4000 → 6286). flair#1199 (0.44.11): on
1551
+ // the /mcp connector path each content item is charged its STRUCTURED shipped
1552
+ // cost the same bytes tokenEstimate measuresso tokenEstimate exceeds
1553
+ // `maxTokens` only by the FIXED structural JSON scaffolding (container
1554
+ // keys/braces, counters, sections map, hints, char/4 rounding): genuine
1555
+ // payload the caller pays for, small and bounded, NOT uncounted content. The
1556
+ // connector-conformance suite asserts tokenEstimate <= maxTokens within a
1557
+ // small tolerance for exactly that scaffolding. On the PROSE path
1558
+ // (includeContext=true) the payload ALSO carries the structured mirror, so
1559
+ // tokenEstimate legitimately exceeds `maxTokens` by that mirror do NOT
1560
+ // "fix" THAT overrun by shrinking prose selection: that is the #1199→#1207
1561
+ // regression (it dropped relevant findings, charging a flat per-item overhead
1562
+ // against the content budget). If the real payload consistently overruns for
1563
+ // a use case, raise `maxTokens`.
1405
1564
  const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1406
1565
  return { ...responseBody, tokenEstimate };
1407
1566
  }
@@ -0,0 +1,137 @@
1
+ // ─── ADK auto-promote — pure policy for /AutoPromoteCandidates (#1205b-2) ─────
2
+ //
3
+ // The UNATTENDED promotion path. After the tag-aware nightly cycle (#1205b-1)
4
+ // stages per-user MemoryCandidates each carrying a `scopeTag`, this policy
5
+ // decides whether an ADK-sourced candidate may be auto-promoted to the user's
6
+ // OWN memory with NO human reviewer in the loop — replacing the human
7
+ // `rem promote` for this one narrow path.
8
+ //
9
+ // Because there is no human to catch a mistake, every one of Sherlock's four
10
+ // hard requirements is a load-bearing gate here (issue #1205 authz review):
11
+ //
12
+ // Req 1 (memory-only, server-side): NOT decided here. The target is
13
+ // hard-locked to `memory` STRUCTURALLY in resources/AutoPromoteCandidates.ts
14
+ // — this lib has no notion of a target at all, so no value it returns can
15
+ // ever route a write to Soul. Keeping the target out of the policy object is
16
+ // the point: a policy field could be flipped; an absent one cannot.
17
+ //
18
+ // Req 2 (tag lineage, FAIL-CLOSED): decideAutoPromote REFUSES any candidate
19
+ // whose stamped `scopeTag` is absent/empty or not an `adk:` scope tag. A
20
+ // tagless promoted claim lands in the SHARED agentId namespace and becomes
21
+ // retrievable by every other user of the app (cross-user leak) — so a
22
+ // missing scope tag is a hard STOP, never a benign "promote untagged". The
23
+ // stamped scopeTag (resources/memory-reflect-lib.ts buildStagedCandidateRow)
24
+ // is AUTHORITATIVE and consumed directly — we never re-read source memories
25
+ // (the seam #1205b-1 closed).
26
+ //
27
+ // Req 3 (content-safety, STRICT for the unattended path): the human gate was
28
+ // also a content-safety gate. decideAutoPromote scans the claim through the
29
+ // SAME scanFields path Memory.ts uses (content-safety.ts) and, unlike
30
+ // Memory.ts's write scan, ALWAYS refuses a flagged claim regardless of
31
+ // FLAIR_CONTENT_SAFETY — an unattended write must not silently promote a
32
+ // prompt-injection payload merely because the instance runs in `warn` mode.
33
+ //
34
+ // Req 4 (non-impersonating machine reviewerId): a promoted claim records
35
+ // MACHINE_REVIEWER_ADK_AUTO_PROMOTE in the reserved `machine:` namespace, so
36
+ // audit/attribution can never mistake an automated decision for a human or
37
+ // agent reviewer.
38
+ //
39
+ // Pure and Harper-free (its only import, content-safety.ts, is pure regex), so
40
+ // the whole fail-closed/strict-safety decision is unit-testable directly with no
41
+ // Harper process — the same split resources/memory-reflect-lib.ts uses.
42
+ import { scanFields } from "./content-safety.js";
43
+ // ─── ADK scope tag (the per-user access-control boundary) ────────────────────
44
+ // adk-flair collapses (app, user) → ONE Flair agentId, separating users ONLY by
45
+ // a compound tag `adk:<app>:<user>`. That tag IS the access-control boundary, so
46
+ // an auto-promoted claim that does not carry it is a cross-user leak.
47
+ export const ADK_SCOPE_TAG_PREFIX = "adk:";
48
+ // ─── Machine reviewer namespace (Sherlock req 4) ─────────────────────────────
49
+ // A promotion records a reviewerId that feeds audit/attribution
50
+ // (schemas/memory.graphql). An automated path must record one that can NEVER be
51
+ // mistaken for a human/agent reviewer. Reserved `machine:` namespace; canonical
52
+ // id for this consumer is machine:adk-auto-promote.
53
+ //
54
+ // NOTE ON DUPLICATION: src/cli.ts declares its own copies of these constants
55
+ // (and validateHumanReviewerId, which refuses the reserved namespace on the
56
+ // HUMAN promote path). The two live on opposite sides of the npm-packaging
57
+ // boundary — src/ ships as the CLI bundle, resources/ ships as the Harper
58
+ // component, and cli.ts's own header notes imports across that boundary "don't
59
+ // survive npm packaging". They are kept in sync by the shared canonical string;
60
+ // there is no runtime path that imports one into the other.
61
+ export const MACHINE_REVIEWER_PREFIX = "machine:";
62
+ export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
63
+ /** Standard, honest rationale recorded on every auto-promoted claim + its
64
+ * candidate row, so the audit trail states plainly that no human reviewed it. */
65
+ export const AUTO_PROMOTE_RATIONALE = "auto-promoted from ADK session distillation (#1205) — unattended, own-memory only, scope-tag verified, content-safety scanned; no human reviewer";
66
+ /**
67
+ * Per-call ceiling on auto-promotions (Kern's cost-ceiling note). Auto-promote
68
+ * runs once per nightly cycle, not on every write, and each promotion is a
69
+ * bounded DB write (plus at most one embedding compute on the Memory.put path),
70
+ * so this caps the blast radius of a single cycle rather than throttling a hot
71
+ * path. Overflow stays `pending` and is swept on subsequent cycles.
72
+ */
73
+ export const DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE = 200;
74
+ /**
75
+ * Decide whether an ADK-sourced candidate may be auto-promoted to own memory.
76
+ *
77
+ * FAIL-CLOSED throughout: any condition that cannot be positively confirmed
78
+ * results in `{ promote: false }` (the candidate is left pending for the human
79
+ * `rem promote` path), never a promotion. This function decides ONLY whether to
80
+ * promote and with what per-user scope tag / reviewer — never WHERE (the target
81
+ * is memory-only and enforced structurally by the resource; see this file's
82
+ * header, Req 1).
83
+ */
84
+ export function decideAutoPromote(candidate) {
85
+ // Idempotency (Kern 2d): only ever act on a still-pending candidate. A
86
+ // re-run after a crash re-enumerates and skips anything already promoted.
87
+ if (candidate.status !== "pending") {
88
+ return { promote: false, reason: "not_pending" };
89
+ }
90
+ // Req 2 — tag lineage, FAIL CLOSED. Consume the stamped scopeTag directly
91
+ // (authoritative; never re-read sources). Absent, empty, or non-`adk:` ⇒
92
+ // refuse: a tagless claim in the shared agentId namespace is a cross-user
93
+ // leak, and auto-promote is ONLY for ADK-sourced (scopeTag-bearing)
94
+ // candidates — a non-ADK candidate still requires human `rem promote`.
95
+ const scopeTag = candidate.scopeTag;
96
+ if (typeof scopeTag !== "string" || !scopeTag.startsWith(ADK_SCOPE_TAG_PREFIX)) {
97
+ return { promote: false, reason: "no_adk_scope_tag" };
98
+ }
99
+ const claim = candidate.claim;
100
+ if (typeof claim !== "string" || claim.trim().length === 0) {
101
+ return { promote: false, reason: "empty_claim" };
102
+ }
103
+ // Req 3 — content-safety, STRICT for the unattended path. Same scanFields the
104
+ // Memory write path uses (content-safety.ts), but here a flag is ALWAYS a
105
+ // refusal, independent of FLAIR_CONTENT_SAFETY: an unattended promotion must
106
+ // never let a prompt-injection payload through merely because the instance is
107
+ // in `warn` mode. (The Memory.put() write scan still runs on top of this as
108
+ // defense-in-depth.)
109
+ const safety = scanFields({ content: claim }, ["content"]);
110
+ if (!safety.safe) {
111
+ return { promote: false, reason: `content_safety:${safety.flags.join(",")}` };
112
+ }
113
+ return {
114
+ promote: true,
115
+ scopeTag,
116
+ reviewerId: MACHINE_REVIEWER_ADK_AUTO_PROMOTE,
117
+ rationale: AUTO_PROMOTE_RATIONALE,
118
+ };
119
+ }
120
+ /** True iff `id` is in the reserved machine-reviewer namespace (an automated
121
+ * path, never a human/agent reviewer). Mirror of src/cli.ts isMachineReviewerId
122
+ * on the resources side of the packaging boundary. */
123
+ export function isMachineReviewerId(id) {
124
+ return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
125
+ }
126
+ /**
127
+ * The tag set for an auto-promoted Memory. The per-user `scopeTag` MUST come
128
+ * first and is load-bearing — it is the access-control boundary that keeps the
129
+ * promoted claim visible only to its own user's tag filter. `auto-promoted`
130
+ * marks the whole class as machine-written so every auto-promoted claim is
131
+ * identifiable and bulk-removable if the policy is ever rolled back (Kern 2b);
132
+ * `nightly-rem-promoted` matches the human promote path; `from:<id>` preserves
133
+ * candidate lineage.
134
+ */
135
+ export function buildAutoPromotedTags(candidateId, scopeTag) {
136
+ return [scopeTag, "nightly-rem-promoted", "auto-promoted", `from:${candidateId}`];
137
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ─── The single "is this a valid durability" writer-intent guard ────────────
3
+ *
4
+ * Mirror of resources/memory-visibility.ts's assertValidVisibility, for the
5
+ * durability enum. Same asymmetry, same reason:
6
+ *
7
+ * - READING an unknown durability must be permissive. A row written before a
8
+ * tier existed (or by a non-Python adapter) may hold anything, and the read
9
+ * side must keep resolving it exactly as before — defaultVisibilityForDurability
10
+ * treats any non-permanent/persistent string as the private branch, and that
11
+ * fail-safe must not change.
12
+ * - WRITING an unknown durability must be refused. Today an unknown value via
13
+ * raw REST (or a future non-Python adapter) is silently accepted and lands on
14
+ * the narrower private branch by accident — fail-safe, but unvalidated by
15
+ * contract. Refusing at the schema boundary makes it safe by construction and
16
+ * makes adk-flair's "validated server-side" claim true as written (flair#1238,
17
+ * from Sherlock's #1237 review).
18
+ *
19
+ * Deliberately has ZERO imports — same load-bearing reason as memory-visibility.ts:
20
+ * this module is a pure function + constant that any caller can import without
21
+ * dragging in "harper".
22
+ */
23
+ /** The only values a WRITER may supply. */
24
+ export const WRITABLE_DURABILITIES = ["permanent", "persistent", "standard", "ephemeral"];
25
+ /**
26
+ * Reject a durability a writer supplied that is not one of the four valid values.
27
+ * Returns an error message, or null when the value is acceptable.
28
+ *
29
+ * `undefined`/`null` are accepted: omitting the field is how a caller asks for
30
+ * the default ("standard"), and that is a documented, intentional path.
31
+ */
32
+ export function assertValidDurability(durability) {
33
+ if (durability === undefined || durability === null)
34
+ return null;
35
+ if (typeof durability === "string" && WRITABLE_DURABILITIES.includes(durability)) {
36
+ return null;
37
+ }
38
+ return (`durability must be ${WRITABLE_DURABILITIES.map((v) => `"${v}"`).join(" or ")} ` +
39
+ `(got: ${JSON.stringify(durability)}). Omit it to use the default "standard".`);
40
+ }
@@ -240,6 +240,14 @@ Writes are scoped per-agent (your `FLAIR_AGENT_ID`) and enforced by Flair's serv
240
240
 
241
241
  Which memories are non-private is decided at write time, and the default is not "shared". `memory_store` defaults `durability` to `standard`, and the server derives visibility from durability — `permanent`/`persistent` → `shared`, `standard`/`ephemeral` → `private` — so **a bare `memory_store` call writes an owner-only memory that no other agent can read.** Pass `visibility: "shared"` (or `"private"`, to be explicit) to say what you mean; the tool reports the visibility the write actually landed on so an agent can confirm it rather than assume.
242
242
 
243
+ ### Reading the `bootstrap` payload
244
+
245
+ `bootstrap` returns the canonical structured containers — `soul`, `memories`, `predicted`, `teammateFindings`, `events` — plus counts and a `tokenEstimate`. The containers are **always present** (empty `[]`/`{}` when there's nothing), so an empty container is distinguishable from an unsupported one.
246
+
247
+ **Empty containers say why they're empty (flair#1182).** When a structured container ships empty, the payload carries a short hint naming the reason and what fills it — `eventsHint`, `teammateFindingsHint`, `predictedHint`. This is present *only* when the container is empty, so a deliberately-empty container is never confused with a silent drop (a connector never has to diff against a previous payload to tell the two apart).
248
+
249
+ **`matchQuality` is null on lifecycle sections — by design (flair#1225).** With `includeTrust: true`, each included memory carries a per-memory trust block, section-tagged, whose `matchQuality` is a `strong`/`moderate`/`breadcrumb` confidence band. On the **lifecycle sections** (`permanent`, `recent`, `predicted`) `matchQuality` is `null`: those are a lifecycle-window *load*, not a retrieval surface, so there is no relevance score to band. This is **correct, not a scoring failure** — an own-recent `null` next to a teammate's band does not mean your own records "scored worse". A retrieval band is only meaningful on the retrieval sections (`relevant`, `teammate`). The entry's `section` field makes this legible, and a `matchQualityNote` on any null entry states the reason inline.
250
+
243
251
  ---
244
252
 
245
253
  ## Configuration reference
package/docs/rem.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # REM — reflection, distillation, and review
2
2
 
3
- REM (Reflect · Extract · Merge) is Flair's memory-curation cycle: it reads an agent's recent memories, distills them into candidate insights, and stages those candidates for explicit human/agent review — nothing is ever auto-promoted. `flair rem rapid` runs it on demand; `flair rem nightly enable` runs it on a schedule. See [`docs/notes/rem-ux.md`](notes/rem-ux.md) for the full trigger model, locality guarantees, and the review-loop UX this page's commands feed into.
3
+ REM (Reflect · Extract · Merge) is Flair's memory-curation cycle: it reads an agent's recent memories, distills them into candidate insights, and stages those candidates for explicit human/agent review — nothing is auto-promoted except the narrow ADK per-user path (see [Auto-promote](#auto-promote-adk-only)). `flair rem rapid` runs it on demand; `flair rem nightly enable` runs it on a schedule. See [`docs/notes/rem-ux.md`](notes/rem-ux.md) for the full trigger model, locality guarantees, and the review-loop UX this page's commands feed into.
4
4
 
5
5
  > **⚠️ Prerequisite: a configured generative backend.** All REM commands (`rapid`, `nightly`, `candidates`, `promote`, `reject`) require Harper's `models.generate()` to be wired — without it, REM calls fail with `Reflection error: No generative backend configured`. Set up a backend first (see [Configuration](#configuration) below) before running any REM command. The fastest path is Ollama with a non-thinking model, which needs zero credentials and keeps all traffic local.
6
6
 
@@ -59,10 +59,21 @@ Snapshot locality follows from this: a nightly cycle's pre-run snapshot (`~/.fla
59
59
  - **Interactive (`flair rem rapid`):** one bounded, synchronous distillation call — gather cap 50 memories, bounded output tokens, seconds not minutes. Executes by default, staging candidates and printing a summary; `--prompt-only` returns the reflection prompt instead, for the bring-your-own-model handoff.
60
60
  - **Nightly (`flair rem nightly enable` / `run-once`):** fully detached — the scheduler runs the full cycle (snapshot → maintenance → distillation), candidates land as pending rows, and an audit row lands in `~/.flair/logs/rem-nightly.jsonl`. The operator reviews in the morning via `flair rem candidates`.
61
61
 
62
- Either path, the review loop is the same: `flair rem candidates` lists pending rows, `flair rem promote <id> --rationale "<why>"` / `flair rem reject <id> --reason "<why>"` decide them. Nothing self-promotes — see [`docs/notes/rem-ux.md`](notes/rem-ux.md) for why that gate is load-bearing and how the surface is expected to evolve.
62
+ Either path, the review loop is the same: `flair rem candidates` lists pending rows, `flair rem promote <id> --rationale "<why>"` / `flair rem reject <id> --reason "<why>"` decide them. Nothing self-promotes except the narrow ADK per-user path ([Auto-promote](#auto-promote-adk-only)) — see [`docs/notes/rem-ux.md`](notes/rem-ux.md) for why that gate is load-bearing and how the surface is expected to evolve.
63
63
 
64
64
  ### ADK agents — per-user (per-tag) distillation
65
65
 
66
66
  adk-flair collapses every `(app, user)` into **one** Flair agentId, separating users only by a per-user tag `adk:<app>:<user>`. Distilling such an agentId with the default `scope:"recent"` would mix every user's sessions into shared claims — cross-user bleed. The nightly cycle therefore detects the agent's active `adk:<app>:<user>` tags (from the memories it already loads for the snapshot, with a recency cutoff that skips idle users and is scoped to the agent's own records) and runs distillation **once per tag** under `scope:"tagged"`, so each user's candidates come only from that user's own sessions. Agents with no `adk:` tags distill agentId-wide exactly as before.
67
67
 
68
68
  A candidate distilled under a tag records that tag in its `scopeTag` field. `flair rem promote` reads `scopeTag` as the authoritative per-user lineage tag and propagates it onto the promoted memory — so the promoted claim stays in that user's retrieval scope even if the source episodes are later archived or deleted. The single-node timer rule above is unchanged; the per-tag loop runs inside the one cycle on the one node. The non-thinking-model requirement (above) still holds — the per-tag path calls the same `models.generate()` route.
69
+
70
+ #### Auto-promote (ADK only)
71
+
72
+ For ADK agents, the nightly cycle **auto-promotes** these `scopeTag`-bearing candidates to the user's own persistent memory immediately after distillation — the one place REM does not wait for a human `rem promote`. The safety argument is blast-radius, not identity: the claim is distilled from a user's own sessions into that same user's own tag scope, so no cross-agent or Soul trust boundary is crossed. The promotion is enforced entirely server-side (`POST /AutoPromoteCandidates`), never by a CLI flag a compromised agent key could flip, and holds four invariants:
73
+
74
+ - **Memory only, never Soul.** The target is hard-locked to `memory`; there is no Soul code path (Soul is agentId-scoped and cannot carry a per-user tag, so an ADK-sourced Soul promotion would be cross-user by construction).
75
+ - **Fail-closed tag lineage.** A candidate is promoted only if it carries an authoritative `adk:<app>:<user>` scope tag, which the promoted memory then carries. The promoted memory is written `visibility:"private"` (owner-only) — not the org-open `shared` default a `persistent` write would otherwise get — so it is reachable only through the app agent's own tag-filtered search (which re-verifies the tag), invisible both to another user's tag filter and to every other agent on the instance. A candidate whose scope tag is absent or blank is left pending, never promoted tagless into the shared agentId namespace.
76
+ - **Content-safety, strict.** The claim is scanned for prompt injection and refused on a flag regardless of `FLAIR_CONTENT_SAFETY` — an unattended write does not fall back to warn-and-tag.
77
+ - **Non-impersonating reviewer.** The promoted memory and its candidate record `machine:adk-auto-promote`, never a value mistakable for a human or agent reviewer.
78
+
79
+ Anything ineligible (no scope tag, flagged content, already decided) is left pending for the human `rem promote` path. The step is bounded per cycle and non-fatal; `flair rem nightly run-once` reports the count auto-promoted. **Non-ADK candidates never auto-promote** — the human review gate below is unchanged for them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.10",
3
+ "version": "0.44.12",
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",