@tpsdev-ai/flair 0.44.9 → 0.44.11

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.
@@ -40,7 +40,7 @@ import { Resource, databases, models, logger } from "harper";
40
40
  import { randomBytes } from "node:crypto";
41
41
  import { isAdmin, allowVerified } from "./agent-auth.js";
42
42
  import { patchRecordSilent } from "./table-helpers.js";
43
- import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, } from "./memory-reflect-lib.js";
43
+ import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, } from "./memory-reflect-lib.js";
44
44
  export class ReflectMemories extends Resource {
45
45
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
46
46
  // admin elevation). Any verified agent may reflect; the isAdmin checks in post()
@@ -75,15 +75,12 @@ export class ReflectMemories extends Resource {
75
75
  continue;
76
76
  if (record.durability === "permanent")
77
77
  continue; // permanent memories don't need reflection
78
- if (scope === "tagged") {
79
- if (!tag || !(record.tags ?? []).includes(tag))
80
- continue;
81
- }
82
- else if (scope === "recent") {
83
- if (!record.createdAt || new Date(record.createdAt) < sinceDate)
84
- continue;
85
- }
86
- // scope="all" passes everything
78
+ // Scope selection — the cross-user-bleed boundary (#1205b-1). See
79
+ // memoryMatchesReflectScope's doc: scope:"tagged" admits ONLY the one
80
+ // adk:<app>:<user> tag's memories, so a candidate distilled here can
81
+ // never cite another user's memory.
82
+ if (!memoryMatchesReflectScope(record, { scope, tag, sinceDate }))
83
+ continue;
87
84
  const { embedding, ...rest } = record;
88
85
  memories.push(rest);
89
86
  if (memories.length >= maxMemories)
@@ -152,7 +149,11 @@ export class ReflectMemories extends Resource {
152
149
  const generatedAt = new Date().toISOString();
153
150
  const staged = [];
154
151
  for (const c of toStage) {
155
- const row = {
152
+ // #1205b-1: buildStagedCandidateRow stamps `scopeTag` when this run was
153
+ // scope:"tagged" — the authoritative per-user tag promotion consumes
154
+ // directly (closing the #1205a source-re-read seam). Non-tagged runs
155
+ // leave scopeTag absent, unchanged.
156
+ const row = buildStagedCandidateRow({
156
157
  id: `cand_${randomBytes(8).toString("hex")}`,
157
158
  agentId,
158
159
  claim: c.claim,
@@ -160,8 +161,9 @@ export class ReflectMemories extends Resource {
160
161
  rationalePrompt: executePrompt,
161
162
  generatedBy: resolvedModel,
162
163
  generatedAt,
163
- status: "pending",
164
- };
164
+ scope,
165
+ tag,
166
+ });
165
167
  await databases.flair.MemoryCandidate.put(row);
166
168
  staged.push(row);
167
169
  }
@@ -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
+ }
@@ -116,14 +116,34 @@ async function unwrap(value) {
116
116
  return value;
117
117
  }
118
118
  /**
119
- * flair#1188 — remove the raw `embedding` vector from a record before it is
120
- * returned over the MCP surface. A stored memory carries a 768-float
121
- * `embedding` (the HNSW vector); inlined into a tool result that is thousands
122
- * of noise tokens per record on chat connectors that have a fixed context
123
- * budget, and the caller can never do anything useful with it. Returns a
124
- * shallow copy WITHOUT `embedding` (never mutates the source record), and
125
- * passes through anything that is not a plain record — null, primitives,
126
- * arrays, and the `{ error, status }` shapes `unwrap` produces — untouched.
119
+ * flair#1188 — the internal, embedding-engine-owned fields a memory record
120
+ * carries that must NEVER cross the MCP surface. Both are server-managed and
121
+ * useless (or misleading) to a connector:
122
+ *
123
+ * - `embedding` — the raw 768-float HNSW vector; thousands of noise
124
+ * tokens per record on a fixed-budget chat connector, and
125
+ * the caller can do nothing with it (flair#1188).
126
+ * - `embeddingModel` the model id stamped on every write
127
+ * (resources/Memory.ts stamps `content.embeddingModel =
128
+ * getModelId()`; schemas/memory.graphql declares it
129
+ * @indexed). The WRITE wrappers already treat it as
130
+ * internal — memory_update `delete`s it from both the
131
+ * overwrite and the supersede record — so the READ path
132
+ * must strip it too, or memory_get leaks an internal
133
+ * field the write echoes hide (flair#1213, Sherlock #1).
134
+ *
135
+ * Exported so the flair#1213 conformance "no leaked internal fields" invariant
136
+ * enumerates the SAME list this function strips: the strip and the assertion
137
+ * cannot drift, and adding a field here automatically extends both.
138
+ */
139
+ export const INTERNAL_MEMORY_FIELDS = ["embedding", "embeddingModel"];
140
+ /**
141
+ * flair#1188 / flair#1213 — remove the internal embedding-engine fields (see
142
+ * `INTERNAL_MEMORY_FIELDS`) from a record before it is returned over the MCP
143
+ * surface. Returns a shallow copy WITHOUT those fields (never mutates the
144
+ * source record), and passes through anything that is not a plain record —
145
+ * null, primitives, arrays, and the `{ error, status }` shapes `unwrap`
146
+ * produces — untouched.
127
147
  *
128
148
  * `memory_search` already projects with an explicit select that omits
129
149
  * `embedding` (resources/semantic-retrieval-core.ts's DEFAULT_SELECT), and
@@ -131,13 +151,21 @@ async function unwrap(value) {
131
151
  * needed on the FULL-record read/write paths (memory_get, and the write
132
152
  * responses that echo the stored row).
133
153
  */
134
- function stripEmbedding(value) {
154
+ function stripInternalFields(value) {
135
155
  if (!value || typeof value !== "object" || Array.isArray(value))
136
156
  return value;
137
- if (!("embedding" in value))
138
- return value;
139
- const { embedding, ...rest } = value;
140
- return rest;
157
+ let out = value;
158
+ let copied = false;
159
+ for (const field of INTERNAL_MEMORY_FIELDS) {
160
+ if (field in out) {
161
+ if (!copied) {
162
+ out = { ...out };
163
+ copied = true;
164
+ }
165
+ delete out[field];
166
+ }
167
+ }
168
+ return out;
141
169
  }
142
170
  // ── Tool implementations (thin wrappers over existing handlers) ──────────────
143
171
  //
@@ -217,7 +245,7 @@ async function memoryStore(agent, args) {
217
245
  // flair#1188 — memory_store's response goes through the same buildWriteResponse
218
246
  // echo as memory_update; strip the server-regenerated embedding so no write
219
247
  // tool ever inlines the vector. No-op when the response carries none.
220
- return stripEmbedding(await unwrap(await h.post(body)));
248
+ return stripInternalFields(await unwrap(await h.post(body)));
221
249
  }
222
250
  /**
223
251
  * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
@@ -248,8 +276,18 @@ async function memoryUpdate(agent, args) {
248
276
  // `new Cls(undefined, ctx).get(id)` returned `undefined` for the caller's own
249
277
  // record (getProperty on an unloaded instance), so memory_update 404'd
250
278
  // ("memory not found") on the connector path before it ever reached a write.
251
- const existing = await Cls.get(id, delegationContext(agent));
252
- if (!existing) {
279
+ //
280
+ // flair#1213 — the get MUST be `unwrap`ped (as memoryGet does), not consumed
281
+ // raw. Memory.get()'s makeByIdReadGate returns a NOT_FOUND() *Response* (404)
282
+ // for an absent or non-readable id — a TRUTHY object with no `id` — so the
283
+ // bare `if (!existing)` guard never fired: the code fell through and PUT a
284
+ // record whose id spread off the Response as `undefined`, throwing the
285
+ // MISDIRECTING "Invalid primary key of null" instead of a clean 404. This is
286
+ // the same by-id-read-on-the-connector-seam class as #1181, caught by the
287
+ // conformance error contract (Kern #5). Unwrap, then treat a 404/error/absent
288
+ // result as "not found".
289
+ const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
290
+ if (!existing || existing.error != null || existing.status === 404) {
253
291
  return { error: "memory not found", status: 404 };
254
292
  }
255
293
  if (preserveHistory) {
@@ -291,7 +329,7 @@ async function memoryUpdate(agent, args) {
291
329
  // flair#1188 — the write response echoes the stored row (Memory.post
292
330
  // regenerates the embedding server-side), so strip the vector before it
293
331
  // returns over the MCP surface. No-op when the response carries none.
294
- return stripEmbedding(await unwrap(await coll.post(record)));
332
+ return stripInternalFields(await unwrap(await coll.post(record)));
295
333
  }
296
334
  const merged = { ...existing, content, updatedAt: new Date().toISOString() };
297
335
  delete merged.embedding;
@@ -310,7 +348,7 @@ async function memoryUpdate(agent, args) {
310
348
  // Memory.put()'s own ownership gate — no scope change, same as the read.
311
349
  // flair#1188 — strip the embedding from the echoed write response (Memory.put
312
350
  // regenerates the vector server-side); no-op when the response carries none.
313
- return stripEmbedding(await unwrap(await Cls.put(merged, delegationContext(agent))));
351
+ return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
314
352
  }
315
353
  async function memoryGet(agent, args) {
316
354
  const Cls = await handler("Memory");
@@ -338,7 +376,7 @@ async function memoryGet(agent, args) {
338
376
  // Strip it by default so a chat connector isn't flooded with thousands of
339
377
  // useless tokens per record; return it only when the caller explicitly opts
340
378
  // in via includeEmbedding.
341
- return args?.includeEmbedding === true ? result : stripEmbedding(result);
379
+ return args?.includeEmbedding === true ? result : stripInternalFields(result);
342
380
  }
343
381
  async function memoryDelete(agent, args) {
344
382
  const Cls = await handler("Memory");
@@ -380,6 +418,14 @@ async function bootstrap(agent, args) {
380
418
  // ONLY when requested so a plain bootstrap delegates a byte-identical body.
381
419
  if (args?.abstain === true)
382
420
  body.abstain = true;
421
+ // flair#1199 — org-event knobs. `includeEventDetail` opts the verbose per-event
422
+ // `detail` JSON back in (default OFF: a connector reads lean events); `maxEvents`
423
+ // overrides the default cap. Both forwarded ONLY when set, so a plain bootstrap
424
+ // delegates a byte-identical body.
425
+ if (args?.includeEventDetail === true)
426
+ body.includeEventDetail = true;
427
+ if (args?.maxEvents !== undefined)
428
+ body.maxEvents = args.maxEvents;
383
429
  // flair#831 — attach the running Flair version to the RESPONSE (not the
384
430
  // delegated request body) so the calling agent learns the server version
385
431
  // on its very first call.
@@ -497,6 +543,51 @@ async function recordUsage(agent, args) {
497
543
  : typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
498
544
  return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
499
545
  }
546
+ /**
547
+ * flair#1213 completeness gate — FAIL-CLOSED (the flair#953 lesson, Sherlock
548
+ * #3). Every tool shipped in `tools` must carry a `.contract`; a new /mcp tool
549
+ * without one fails the build.
550
+ *
551
+ * The fail-closed part is the point: if the registry cannot be enumerated — it
552
+ * is not a plain object (a broken import left it `undefined`), or it is empty —
553
+ * this returns `ok:false` with `examined:0`, NEVER a vacuous "0 tools examined,
554
+ * 0 missing, pass". A check that could not run must not render as passed. The
555
+ * conformance suite asserts both `ok` AND `examined > 0` so the vacuous path
556
+ * cannot masquerade as coverage; the fail-closed unit test exercises every
557
+ * branch.
558
+ */
559
+ export function checkContractCompleteness(tools) {
560
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) {
561
+ return {
562
+ ok: false,
563
+ examined: 0,
564
+ missing: [],
565
+ reason: "TOOLS registry is not an enumerable object (unloadable import?) — refusing to pass vacuously",
566
+ };
567
+ }
568
+ const names = Object.keys(tools);
569
+ if (names.length === 0) {
570
+ return {
571
+ ok: false,
572
+ examined: 0,
573
+ missing: [],
574
+ reason: "TOOLS registry is empty — refusing to pass vacuously (a new tool must carry a conformance contract)",
575
+ };
576
+ }
577
+ const missing = names.filter((n) => {
578
+ const entry = tools[n];
579
+ return !entry || !entry.contract || typeof entry.contract !== "object";
580
+ });
581
+ return {
582
+ ok: missing.length === 0,
583
+ examined: names.length,
584
+ missing,
585
+ reason: missing.length
586
+ ? `${missing.length} tool(s) shipped with no conformance contract: ${missing.join(", ")}. `
587
+ + "Add a `contract` to each in resources/mcp-tools.ts (co-located with its def+impl)."
588
+ : undefined,
589
+ };
590
+ }
500
591
  /**
501
592
  * Verb→tool-name overrides — the three naming quirks where the shipped tool
502
593
  * name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
@@ -547,6 +638,16 @@ export const TOOLS = {
547
638
  },
548
639
  },
549
640
  impl: memorySearch,
641
+ contract: {
642
+ summary: "{ results: MemoryRecord[] } — semantic hits scoped to the caller's own + granted memories; each hit carries content, never the raw embedding.",
643
+ requiredFields: ["results"],
644
+ fieldTypes: { results: "array" },
645
+ invariants: {
646
+ selfDescribingEmpty: [{ path: "results", type: "array" }],
647
+ containerRules: [{ container: "results", requiredFields: ["id", "content"], forbiddenFields: INTERNAL_MEMORY_FIELDS }],
648
+ fullyResolved: true,
649
+ },
650
+ },
550
651
  },
551
652
  memory_store: {
552
653
  def: {
@@ -574,6 +675,14 @@ export const TOOLS = {
574
675
  },
575
676
  },
576
677
  impl: memoryStore,
678
+ contract: {
679
+ summary: "Write echo { id, written:true, deduplicated } — the new id + confirmation. No internal embedding fields; round-trips via memory_get.",
680
+ requiredFields: ["id", "written"],
681
+ fieldTypes: { id: "string", written: "boolean", deduplicated: "boolean" },
682
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
683
+ invariants: { fullyResolved: true },
684
+ errorShape: { trigger: "an unrecognized visibility value (e.g. \"prvate\")", fields: ["error", "status"], mustNotLeak: INTERNAL_MEMORY_FIELDS },
685
+ },
577
686
  },
578
687
  memory_update: {
579
688
  def: {
@@ -592,6 +701,14 @@ export const TOOLS = {
592
701
  },
593
702
  },
594
703
  impl: memoryUpdate,
704
+ contract: {
705
+ summary: "Write echo { id, written:true } for the in-place overwrite (or supersede). No internal embedding fields; the change round-trips via memory_get.",
706
+ requiredFields: ["id", "written"],
707
+ fieldTypes: { id: "string", written: "boolean" },
708
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
709
+ invariants: { fullyResolved: true },
710
+ errorShape: { trigger: "updating a non-existent id", fields: ["error", "status"] },
711
+ },
595
712
  },
596
713
  memory_get: {
597
714
  def: {
@@ -609,6 +726,14 @@ export const TOOLS = {
609
726
  },
610
727
  },
611
728
  impl: memoryGet,
729
+ contract: {
730
+ summary: "The full memory record { id, agentId, content, durability, createdAt, ... } for the caller's own id — embedding + embeddingModel stripped by default.",
731
+ requiredFields: ["id", "agentId", "content", "createdAt"],
732
+ fieldTypes: { id: "string", agentId: "string", content: "string" },
733
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
734
+ invariants: { fullyResolved: true },
735
+ errorShape: { trigger: "get a non-existent / unowned id (makeByIdReadGate 404)", fields: ["error", "status"] },
736
+ },
612
737
  },
613
738
  memory_delete: {
614
739
  def: {
@@ -622,6 +747,11 @@ export const TOOLS = {
622
747
  },
623
748
  },
624
749
  impl: memoryDelete,
750
+ contract: {
751
+ summary: "Deletes the caller's own memory (success echo is thin). The permanent-memory guard returns { error, status:403 } for a non-admin; the row round-trips as gone via memory_get.",
752
+ invariants: { fullyResolved: true },
753
+ errorShape: { trigger: "a non-admin deletes a permanent memory", fields: ["error", "status"] },
754
+ },
625
755
  },
626
756
  bootstrap: {
627
757
  def: {
@@ -644,10 +774,70 @@ export const TOOLS = {
644
774
  includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
645
775
  abstain: { type: "boolean", description: "Opt into a task-relevance abstention verdict: also return an `abstention` object ({ abstained, bestScore, threshold }) reporting whether any memory covered `currentTask` above a global confidence threshold. Default false." },
646
776
  includeContext: { type: "boolean", description: "Also return the prose `context` string — a human-readable mirror of the structured soul/memories/predicted/teammateFindings containers (which are the canonical payload). Default false here: the structured fields already carry everything, so shipping the prose too would double the payload." },
777
+ maxEvents: { type: "number", description: "Cap on how many recent org events to return (default 10). Events are counted against maxTokens like every other content section." },
778
+ includeEventDetail: { type: "boolean", description: "Also include each org event's verbose `detail` JSON (migration internals, etc.). Default false: bootstrap ships lean events (id/kind/summary/createdAt/targetIds/scope); `detail` mostly restates the summary and is pure bloat for a connector." },
647
779
  },
648
780
  },
649
781
  },
650
782
  impl: bootstrap,
783
+ contract: {
784
+ summary: "Session context: { agentId, soul, memories, predicted, teammateFindings, events, sections, tokenEstimate, memoriesIncluded, ..., context, flairVersion }. "
785
+ + "Structured containers are canonical and always present; prose `context` is a pointer at the /mcp default (includeContext opt-in).",
786
+ requiredFields: [
787
+ "agentId", "soul", "memories", "predicted", "teammateFindings", "events",
788
+ "sections", "tokenEstimate", "maxTokens", "memoriesIncluded", "memoriesAvailable",
789
+ "memoriesTruncated", "teammateFindingsIncluded", "teammateFindingsTruncated",
790
+ "teammateFindingsMatched", "context", "flairVersion",
791
+ ],
792
+ fieldTypes: {
793
+ agentId: "string", soul: "object", memories: "array", predicted: "array",
794
+ teammateFindings: "array", events: "array", sections: "object",
795
+ tokenEstimate: "number", maxTokens: "number", memoriesIncluded: "number",
796
+ memoriesAvailable: "number", memoriesTruncated: "number",
797
+ teammateFindingsIncluded: "number", teammateFindingsTruncated: "number",
798
+ teammateFindingsMatched: "number", context: "string", flairVersion: "string",
799
+ },
800
+ invariants: {
801
+ // count == delivered — the historical count/charge/deliver drift.
802
+ countEqualsDelivered: [
803
+ // memoriesIncluded spans BOTH own-memory containers (see the type doc).
804
+ { count: "memoriesIncluded", containers: ["memories", "predicted"] }, // #1199
805
+ { count: "teammateFindingsIncluded", containers: ["teammateFindings"] }, // #1199
806
+ { count: "sections.events", containers: ["events"] }, // #1206
807
+ ],
808
+ // present + typed even when empty — never a bare {} / missing key (#1182).
809
+ selfDescribingEmpty: [
810
+ { path: "soul", type: "object" }, { path: "memories", type: "array" },
811
+ { path: "predicted", type: "array" }, { path: "teammateFindings", type: "array" },
812
+ { path: "events", type: "array" }, { path: "sections", type: "object" },
813
+ ],
814
+ // #1200 — dedup by the SEMANTIC content key (excludes id/createdAt, which
815
+ // vary across physical duplicate rows). See ToolInvariants.dedupSignature.
816
+ dedupSignature: { container: "events", signatureFields: ["kind", "summary", "detail", "targetIds"] },
817
+ // #1199 — tokenEstimate via the wrapper's own estimator over the delivered
818
+ // payload (minus the two fields added after it was measured).
819
+ tokenEstimate: { field: "tokenEstimate", excludeKeys: ["tokenEstimate", "flairVersion"] },
820
+ // #1199 — the reported estimate must respect the requested budget: the
821
+ // events blowout (uncounted org events) drove maxTokens=4000 → 6286. The
822
+ // tolerance covers the fixed JSON scaffolding + the #1207 prose-vs-
823
+ // structured charge gap; uncounted content does not fit under it.
824
+ budgetCap: { estimate: "tokenEstimate", budget: "maxTokens", tolerance: 0.25 },
825
+ // #1207 — count arithmetic: included + truncated <= available, for own
826
+ // memories AND teammate findings (each a disjoint split of its pool).
827
+ countCoherence: [
828
+ { included: "memoriesIncluded", truncated: "memoriesTruncated", available: "memoriesAvailable" },
829
+ { included: "teammateFindingsIncluded", truncated: "teammateFindingsTruncated", available: "teammateFindingsMatched" },
830
+ ],
831
+ // #1199 — prose is a pointer at the default, not a second copy.
832
+ proseContextIsPointerAtDefault: { field: "context" },
833
+ // shape of the structured containers a connector reads; #1188 leak bites on memories.
834
+ containerRules: [
835
+ { container: "events", requiredFields: ["id", "kind", "summary", "createdAt"] },
836
+ { container: "memories", requiredFields: ["id", "content"], forbiddenFields: INTERNAL_MEMORY_FIELDS },
837
+ ],
838
+ fullyResolved: true, // #1182 — never a spread pending Promise collapsing to {flairVersion}.
839
+ },
840
+ },
651
841
  },
652
842
  soul_set: {
653
843
  def: {
@@ -663,6 +853,10 @@ export const TOOLS = {
663
853
  },
664
854
  },
665
855
  impl: soulSet,
856
+ contract: {
857
+ summary: "Writes a soul entry keyed `${agentId}:${key}`, attributed to the caller. Correctness is proven by the soul_get round-trip — this is the write flair#1181 broke on the connector path.",
858
+ invariants: { fullyResolved: true },
859
+ },
666
860
  },
667
861
  soul_get: {
668
862
  def: {
@@ -676,6 +870,12 @@ export const TOOLS = {
676
870
  },
677
871
  },
678
872
  impl: soulGet,
873
+ contract: {
874
+ summary: "The soul entry { id, agentId, key, value, createdAt } for the caller's own `${agentId}:${key}`.",
875
+ requiredFields: ["id", "agentId", "key", "value", "createdAt"],
876
+ fieldTypes: { id: "string", agentId: "string", key: "string", value: "string" },
877
+ invariants: { fullyResolved: true },
878
+ },
679
879
  },
680
880
  flair_workspace_set: {
681
881
  def: {
@@ -695,6 +895,10 @@ export const TOOLS = {
695
895
  },
696
896
  },
697
897
  impl: workspaceSet,
898
+ contract: {
899
+ summary: "Writes the caller's workspace state keyed `${agentId}:${ref}`, attributed to the caller (never the body). The echo is thin; persistence is verified in storage.",
900
+ invariants: { fullyResolved: true },
901
+ },
698
902
  },
699
903
  flair_orgevent: {
700
904
  def: {
@@ -713,6 +917,10 @@ export const TOOLS = {
713
917
  },
714
918
  },
715
919
  impl: orgEvent,
920
+ contract: {
921
+ summary: "Publishes an org event attributed to the caller (authorId from identity, never the body). The echo is thin; persistence is verified in storage.",
922
+ invariants: { fullyResolved: true },
923
+ },
716
924
  },
717
925
  attention: {
718
926
  def: {
@@ -731,6 +939,15 @@ export const TOOLS = {
731
939
  },
732
940
  },
733
941
  impl: attention,
942
+ contract: {
943
+ summary: "Grouped-by-source view { entity, windowDays, since, groups:{memory,relationship,workspaceState,presence,orgEvent}, counts } for entity E over N days.",
944
+ requiredFields: ["entity", "windowDays", "groups", "counts"],
945
+ fieldTypes: { entity: "string", windowDays: "number", groups: "object", counts: "object" },
946
+ invariants: {
947
+ selfDescribingEmpty: [{ path: "groups", type: "object" }, { path: "counts", type: "object" }],
948
+ fullyResolved: true,
949
+ },
950
+ },
734
951
  },
735
952
  record_usage: {
736
953
  def: {
@@ -748,6 +965,12 @@ export const TOOLS = {
748
965
  },
749
966
  },
750
967
  impl: recordUsage,
968
+ contract: {
969
+ summary: "Invariant acknowledgement { recorded:true } — byte-identical regardless of how many ids counted (no id enumeration, Sherlock).",
970
+ requiredFields: ["recorded"],
971
+ fieldTypes: { recorded: "boolean" },
972
+ invariants: { fullyResolved: true },
973
+ },
751
974
  },
752
975
  };
753
976
  /** The tool definitions for a tools/list response (exactly the 12 curated tools). */