@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.
@@ -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
+ }