@tpsdev-ai/flair 0.9.0 → 0.10.1

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.
@@ -101,8 +101,20 @@ export async function applySnapshot(opts) {
101
101
  rmSync(tmp, { recursive: true, force: true });
102
102
  return result;
103
103
  }
104
- // 3. Verify agent id matches.
105
- if (metadata.agentId && metadata.agentId !== opts.agentId) {
104
+ // 3. Verify agent id matches. Hard-fail on missing OR mismatched —
105
+ // the previous `metadata.agentId && ...` short-circuited on missing,
106
+ // which means a snapshot crafted without metadata.agentId would bypass
107
+ // the cross-agent guard entirely. v0.9.0+ snapshots always write
108
+ // metadata.agentId (see src/rem/snapshot.ts), so the missing case can
109
+ // only originate from pre-v0.9.0 snapshots or external/hand-edited
110
+ // input — both of which we must reject.
111
+ if (!metadata.agentId) {
112
+ errors.push(`snapshot is missing metadata.agentId — refusing to restore (pre-v0.9.0 or untrusted snapshot)`);
113
+ result.status = "failed";
114
+ rmSync(tmp, { recursive: true, force: true });
115
+ return result;
116
+ }
117
+ if (metadata.agentId !== opts.agentId) {
106
118
  errors.push(`snapshot agentId (${metadata.agentId}) does not match target (${opts.agentId}) — refusing to restore cross-agent`);
107
119
  result.status = "failed";
108
120
  rmSync(tmp, { recursive: true, force: true });
@@ -197,6 +209,54 @@ export async function applySnapshot(opts) {
197
209
  errors.push(`put-soul ${s.id}: ${err?.message ?? String(err)}`);
198
210
  }
199
211
  }
212
+ // 7. Verify post-restore state (default on; opt-out via verifyPostRestore=false).
213
+ // Catches silent failures: Harper schema coercion, 4xx responses the
214
+ // apiCall layer masked as ok, partial-DELETE leftovers. Per-ID diff,
215
+ // not just counts — count parity can hide simultaneous PUT+DELETE
216
+ // failures that wash out numerically. See ops-90dq.
217
+ if (opts.verifyPostRestore !== false) {
218
+ try {
219
+ const verifyMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
220
+ const verifySouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
221
+ const expectedMemoryIds = memories.filter((m) => m?.id).map((m) => String(m.id));
222
+ const expectedSoulIds = souls.filter((s) => s?.id).map((s) => String(s.id));
223
+ const actualMemoryIds = verifyMem.filter((m) => m?.id).map((m) => String(m.id));
224
+ const actualSoulIds = verifySouls.filter((s) => s?.id).map((s) => String(s.id));
225
+ const expMem = new Set(expectedMemoryIds);
226
+ const expSoul = new Set(expectedSoulIds);
227
+ const actMem = new Set(actualMemoryIds);
228
+ const actSoul = new Set(actualSoulIds);
229
+ const missingMemoryIds = [...expMem].filter((id) => !actMem.has(id));
230
+ const missingSoulIds = [...expSoul].filter((id) => !actSoul.has(id));
231
+ const extraMemoryIds = [...actMem].filter((id) => !expMem.has(id));
232
+ const extraSoulIds = [...actSoul].filter((id) => !expSoul.has(id));
233
+ result.verified = {
234
+ expectedMemoryIds,
235
+ expectedSoulIds,
236
+ actualMemoryIds,
237
+ actualSoulIds,
238
+ missingMemoryIds,
239
+ missingSoulIds,
240
+ extraMemoryIds,
241
+ extraSoulIds,
242
+ };
243
+ if (missingMemoryIds.length > 0) {
244
+ errors.push(`post-restore-verify: ${missingMemoryIds.length} memory rows missing (e.g. ${missingMemoryIds.slice(0, 3).join(", ")})`);
245
+ }
246
+ if (missingSoulIds.length > 0) {
247
+ errors.push(`post-restore-verify: ${missingSoulIds.length} soul rows missing (e.g. ${missingSoulIds.slice(0, 3).join(", ")})`);
248
+ }
249
+ if (extraMemoryIds.length > 0) {
250
+ errors.push(`post-restore-verify: ${extraMemoryIds.length} unexpected memory rows present (e.g. ${extraMemoryIds.slice(0, 3).join(", ")})`);
251
+ }
252
+ if (extraSoulIds.length > 0) {
253
+ errors.push(`post-restore-verify: ${extraSoulIds.length} unexpected soul rows present (e.g. ${extraSoulIds.slice(0, 3).join(", ")})`);
254
+ }
255
+ }
256
+ catch (err) {
257
+ errors.push(`post-restore-verify: ${err?.message ?? String(err)}`);
258
+ }
259
+ }
200
260
  rmSync(tmp, { recursive: true, force: true });
201
261
  if (errors.length > 0) {
202
262
  result.status = "failed";
package/dist/render.js ADDED
@@ -0,0 +1,168 @@
1
+ // CLI output renderer — color, tables, icons, spinner, output-mode resolution.
2
+ //
3
+ // Two output modes coexist across every command:
4
+ //
5
+ // human — pretty default, ANSI color when stdout is a TTY and NO_COLOR/
6
+ // FLAIR_NO_COLOR aren't set
7
+ // json — agent-default, also auto-selected when stdout is piped
8
+ //
9
+ // Precedence: --json flag > FLAIR_OUTPUT=json env > non-TTY stdout > TTY (human).
10
+ // Setting FLAIR_OUTPUT=human forces human mode even when piped.
11
+ //
12
+ // No third-party deps — minimal ANSI by hand keeps the dependency tree flat
13
+ // (the rest of the flair runtime already runs ANSI-free; we don't need
14
+ // chalk/picocolors' edge-case coverage for SGR codes we don't use).
15
+ const stdoutIsTTY = !!process.stdout.isTTY;
16
+ const stderrIsTTY = !!process.stderr.isTTY;
17
+ const noColorEnv = process.env.NO_COLOR != null || process.env.FLAIR_NO_COLOR != null;
18
+ const enableColor = stdoutIsTTY && !noColorEnv;
19
+ const C = (code) => (enableColor ? `\x1b[${code}m` : "");
20
+ export const c = {
21
+ reset: C("0"),
22
+ bold: C("1"),
23
+ dim: C("2"),
24
+ italic: C("3"),
25
+ underline: C("4"),
26
+ red: C("31"),
27
+ green: C("32"),
28
+ yellow: C("33"),
29
+ blue: C("34"),
30
+ magenta: C("35"),
31
+ cyan: C("36"),
32
+ white: C("37"),
33
+ gray: C("90"),
34
+ };
35
+ export function wrap(color, text) {
36
+ if (!color)
37
+ return text;
38
+ return `${color}${text}${c.reset}`;
39
+ }
40
+ export function resolveOutputMode(opts) {
41
+ if (opts.json)
42
+ return "json";
43
+ const envOut = process.env.FLAIR_OUTPUT;
44
+ if (envOut === "json")
45
+ return "json";
46
+ if (envOut === "human")
47
+ return "human";
48
+ // No explicit selection — pipe-friendly default: non-TTY stdout → json.
49
+ return stdoutIsTTY ? "human" : "json";
50
+ }
51
+ export const icons = {
52
+ ok: wrap(c.green, "✓"),
53
+ warn: wrap(c.yellow, "⚠"),
54
+ error: wrap(c.red, "✗"),
55
+ info: wrap(c.cyan, "ℹ"),
56
+ bullet: wrap(c.gray, "·"),
57
+ pending: wrap(c.gray, "○"),
58
+ arrow: wrap(c.gray, "→"),
59
+ };
60
+ // Status header: bullet + bold label. Use for top-level command titles.
61
+ export function header(label) {
62
+ return wrap(c.bold, label);
63
+ }
64
+ // Section divider — used between groups inside one command's output.
65
+ // Subtle line + bold label, no big ═══ banners (those compete with content).
66
+ export function section(label) {
67
+ return `\n${wrap(c.bold, label)}\n${wrap(c.gray, "─".repeat(Math.min(60, label.length + 8)))}`;
68
+ }
69
+ // Key/value pair with aligned label. Default label width 14 cols.
70
+ export function kv(label, value, labelWidth = 14) {
71
+ return ` ${wrap(c.dim, label.padEnd(labelWidth))} ${value}`;
72
+ }
73
+ export function table(columns, rows) {
74
+ if (rows.length === 0)
75
+ return wrap(c.dim, " (no rows)");
76
+ const widths = columns.map((col) => col.label.length);
77
+ const cells = rows.map((row) => columns.map((col, i) => {
78
+ const raw = row[col.key];
79
+ const formatted = col.format ? col.format(raw, row) : raw == null ? "—" : String(raw);
80
+ // Visible-width calculation — strip our own ANSI escapes before counting
81
+ const visibleLen = formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
82
+ if (visibleLen > widths[i])
83
+ widths[i] = visibleLen;
84
+ return formatted;
85
+ }));
86
+ const align = (str, width, side) => {
87
+ const visibleLen = str.replace(/\x1b\[[0-9;]*m/g, "").length;
88
+ const pad = " ".repeat(Math.max(0, width - visibleLen));
89
+ return side === "right" ? pad + str : str + pad;
90
+ };
91
+ const headerRow = " " +
92
+ columns.map((col, i) => wrap(c.dim, align(col.label, widths[i], col.align))).join(" ");
93
+ const bodyRows = cells
94
+ .map((row) => " " + row.map((cell, i) => align(cell, widths[i], columns[i].align)).join(" "))
95
+ .join("\n");
96
+ return `${headerRow}\n${bodyRows}`;
97
+ }
98
+ export function spinner(label) {
99
+ if (!stderrIsTTY || noColorEnv) {
100
+ process.stderr.write(`${label}...\n`);
101
+ return {
102
+ stop: (final) => {
103
+ if (final)
104
+ process.stderr.write(`${final}\n`);
105
+ },
106
+ update: (next) => {
107
+ process.stderr.write(`${next}...\n`);
108
+ },
109
+ };
110
+ }
111
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
112
+ let current = label;
113
+ let i = 0;
114
+ const handle = setInterval(() => {
115
+ process.stderr.write(`\r${wrap(c.cyan, frames[i])} ${current}`);
116
+ i = (i + 1) % frames.length;
117
+ }, 80);
118
+ return {
119
+ stop: (final) => {
120
+ clearInterval(handle);
121
+ process.stderr.write("\r\x1b[K");
122
+ if (final)
123
+ process.stderr.write(`${final}\n`);
124
+ },
125
+ update: (next) => {
126
+ current = next;
127
+ },
128
+ };
129
+ }
130
+ // Canonical JSON output: 2-space indent, trailing newline omitted by caller.
131
+ export function asJSON(value) {
132
+ return JSON.stringify(value, null, 2);
133
+ }
134
+ // Bytes → human readable. Mirror of humanBytes in cli.ts; centralizing here
135
+ // so render-aware code shares the same format.
136
+ export function humanBytes(n) {
137
+ if (!Number.isFinite(n) || n < 0)
138
+ return "—";
139
+ if (n < 1024)
140
+ return `${n} B`;
141
+ if (n < 1024 * 1024)
142
+ return `${(n / 1024).toFixed(1)} KB`;
143
+ if (n < 1024 * 1024 * 1024)
144
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
145
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
146
+ }
147
+ // ISO timestamp → "12m ago" / "2h ago" / "5d ago" / "—" (null) / fallback.
148
+ export function relativeTime(iso) {
149
+ if (!iso)
150
+ return "—";
151
+ const ago = Date.now() - new Date(iso).getTime();
152
+ if (!Number.isFinite(ago) || ago < 0)
153
+ return "—";
154
+ const mins = Math.floor(ago / 60000);
155
+ const hrs = Math.floor(ago / 3600000);
156
+ const days = Math.floor(ago / 86400000);
157
+ return days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
158
+ }
159
+ // Convenience: print the right shape for the resolved output mode.
160
+ // Caller passes both representations and the resolveOutputMode result.
161
+ export function print(mode, jsonValue, humanText) {
162
+ if (mode === "json") {
163
+ console.log(asJSON(jsonValue));
164
+ }
165
+ else {
166
+ console.log(humanText);
167
+ }
168
+ }
@@ -265,9 +265,15 @@ function statusFromOrgEvent(event) {
265
265
  null);
266
266
  }
267
267
  export class A2AAdapter extends Resource {
268
- // A2A discovery surface — agent cards and adapter metadata are intentionally
269
- // public so other agents/clients can discover this Flair as a memory peer.
270
- // Handler enforces auth on actual A2A actions (POST below).
268
+ // A2A discovery surface — agent-card metadata (GET) is intentionally
269
+ // public so other agents/clients can discover this Flair as a memory
270
+ // peer. POST is JSON-RPC for actions (message/send writes OrgEvents,
271
+ // tasks/list reads Beads issues, message/stream subscribes to events)
272
+ // and is auth-gated upstream by auth-middleware's narrowed allow-list
273
+ // (GET /a2a passes through; POST /a2a must carry TPS-Ed25519 or admin
274
+ // Basic). The post() handler below additionally enforces sender ==
275
+ // params.agentId for message/send so an authenticated caller can only
276
+ // act AS themselves, not impersonate another agent.
271
277
  allowRead() { return true; }
272
278
  allowCreate() { return true; }
273
279
  async get() {
@@ -310,6 +316,20 @@ export class A2AAdapter extends Resource {
310
316
  }
311
317
  const id = body.id ?? null;
312
318
  const params = body.params ?? {};
319
+ // Defense-in-depth: auth-middleware narrows the /a2a allow-list so
320
+ // POSTs only reach here after TPS-Ed25519 or admin Basic succeeds,
321
+ // which sets request.tpsAgent / tpsAgentIsAdmin. If middleware was
322
+ // misconfigured or someone added a back-door allow-list entry, fail
323
+ // closed here. Pattern matches WorkspaceState/Memory/etc.
324
+ const ctx = this.getContext?.() ?? _context ?? {};
325
+ const ctxRequest = ctx.request ?? ctx;
326
+ const callerAgent = ctxRequest?.tpsAgent;
327
+ const callerIsAdmin = ctxRequest?.tpsAgentIsAdmin === true;
328
+ if (!callerAgent && !callerIsAdmin) {
329
+ return rpcError(id, -32001, "Unauthorized", {
330
+ detail: "POST /a2a requires TPS-Ed25519 or admin Basic auth",
331
+ });
332
+ }
313
333
  try {
314
334
  if (body.method === "message/stream") {
315
335
  const agentId = cleanText(params.agentId);
@@ -407,6 +427,16 @@ export class A2AAdapter extends Resource {
407
427
  if (!agentId || !message || typeof message !== "object") {
408
428
  return rpcError(id, -32602, "Invalid params: agentId and message are required");
409
429
  }
430
+ // Sender must match params.agentId — you can only send AS yourself.
431
+ // Admin agents may send as anyone (operational convenience).
432
+ // Without this check, an authenticated agent could forge OrgEvents
433
+ // attributed to any other agent — defeats the whole signed-envelopes
434
+ // model that delegationChain enforces for TPS mail.
435
+ if (!callerIsAdmin && callerAgent !== agentId) {
436
+ return rpcError(id, -32001, "Forbidden", {
437
+ detail: `caller ${callerAgent ?? "(anon)"} cannot send as ${agentId}`,
438
+ });
439
+ }
410
440
  const agent = await databases.flair.Agent.get(agentId).catch(() => null);
411
441
  if (!agent) {
412
442
  return rpcError(id, -32004, "Agent not found", { agentId });
@@ -13,17 +13,49 @@ import { fileURLToPath } from "node:url";
13
13
  * 127.0.0.1 binding address Harper sees internally — so the Endpoints
14
14
  * table is copy-pasteable.
15
15
  *
16
- * Fall back to `http://127.0.0.1:${HTTP_PORT}` for local-only installs.
16
+ * Resolution order:
17
+ * 1. `FLAIR_PUBLIC_URL` env var (explicit override, always wins)
18
+ * 2. Request headers (X-Forwarded-Proto + X-Forwarded-Host, or Host)
19
+ * — derives from how the operator actually reached the page
20
+ * 3. `http://127.0.0.1:${HTTP_PORT}` — local-only installs fallback
21
+ *
22
+ * The request-header path closes the common case from flair#404:
23
+ * remote/Fabric deployments without FLAIR_PUBLIC_URL set rendered
24
+ * localhost URLs that operators couldn't copy-paste. Trusting the Host
25
+ * header is correct when Harper terminates TLS directly; behind a
26
+ * reverse proxy that doesn't set X-Forwarded-* headers correctly, the
27
+ * operator should set FLAIR_PUBLIC_URL explicitly (which wins).
17
28
  */
18
- function resolvePublicUrl() {
19
- // Explicit override wins. Production deployments (Fabric, VPS-hosted)
20
- // should set FLAIR_PUBLIC_URL in their launchd / systemd unit so the
21
- // admin pane shows the URL operators actually type, not the binding
22
- // address Harper sees internally. Auto-detecting from request headers
23
- // is brittle across reverse-proxy configurations.
29
+ function resolvePublicUrl(request) {
24
30
  if (process.env.FLAIR_PUBLIC_URL) {
25
31
  return process.env.FLAIR_PUBLIC_URL.replace(/\/$/, "");
26
32
  }
33
+ // Best-effort header derivation. Harper's request.headers exposes
34
+ // both .get(name) and .asObject (case-insensitive). Prefer X-Forwarded-*
35
+ // when present (caller is behind a proxy that set them); fall back
36
+ // to the direct Host header.
37
+ const getHeader = (name) => {
38
+ const h = request?.headers;
39
+ if (!h)
40
+ return undefined;
41
+ if (typeof h.get === "function")
42
+ return h.get(name) ?? h.get(name.toLowerCase()) ?? undefined;
43
+ if (typeof h === "object") {
44
+ const obj = h.asObject ?? h;
45
+ return obj[name] ?? obj[name.toLowerCase()] ?? undefined;
46
+ }
47
+ return undefined;
48
+ };
49
+ const fwdProto = getHeader("X-Forwarded-Proto");
50
+ const fwdHost = getHeader("X-Forwarded-Host");
51
+ const host = fwdHost ?? getHeader("Host");
52
+ if (host && /^[\w.\-:]+$/.test(host)) {
53
+ const scheme = fwdProto && (fwdProto === "http" || fwdProto === "https") ? fwdProto : "https";
54
+ // If no proxy headers and host has no port, assume http for safety —
55
+ // most production deployments are behind a proxy with X-Forwarded-Proto.
56
+ const effectiveScheme = fwdProto ? scheme : (host.includes(":") ? "http" : scheme);
57
+ return `${effectiveScheme}://${host}`;
58
+ }
27
59
  return `http://127.0.0.1:${process.env.HTTP_PORT ?? "19926"}`;
28
60
  }
29
61
  /**
@@ -55,7 +87,11 @@ function resolveVersion() {
55
87
  export class AdminInstance extends Resource {
56
88
  async get() {
57
89
  const version = resolveVersion();
58
- const publicUrl = resolvePublicUrl();
90
+ // Pass the request through so URL resolution can derive from
91
+ // X-Forwarded-* / Host headers when FLAIR_PUBLIC_URL isn't set.
92
+ const ctx = this.getContext?.() ?? {};
93
+ const request = ctx.request ?? ctx;
94
+ const publicUrl = resolvePublicUrl(request);
59
95
  // Try to read instance public key
60
96
  let publicKey = "—";
61
97
  const keyDir = join(homedir(), ".flair", "keys");
@@ -1,10 +1,5 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
- function readSoulKind(entry) {
3
- return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
4
- }
5
- function readSoulContent(entry) {
6
- return String(entry.content ?? entry.value ?? "").trim();
7
- }
2
+ import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
8
3
  export class AgentCard extends Resource {
9
4
  async get(pathInfo) {
10
5
  const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
@@ -25,19 +20,15 @@ export class AgentCard extends Resource {
25
20
  if (row?.agentId === agentId)
26
21
  souls.push(row);
27
22
  }
28
- const descriptionEntry = souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s)) ??
29
- souls.find((s) => readSoulContent(s));
30
- const skills = souls
31
- .filter((s) => readSoulKind(s) === "capability")
32
- .map((s) => readSoulContent(s))
33
- .filter(Boolean);
34
23
  return {
35
24
  name: String(agent.name ?? agent.id ?? agentId),
36
- description: descriptionEntry ? readSoulContent(descriptionEntry) : "",
25
+ // Only an explicit kind="description" soul publishes no private-soul
26
+ // fallback (ops-vz6j). See agentcard-fields.ts for the security rationale.
27
+ description: selectPublicDescription(souls),
37
28
  url: String(agent.url ?? ""),
38
29
  version: String(agent.version ?? "1.0.0"),
39
30
  capabilities: agent.capabilities && typeof agent.capabilities === "object" ? agent.capabilities : {},
40
- skills,
31
+ skills: selectPublicSkills(souls),
41
32
  defaultInputModes: ["text"],
42
33
  defaultOutputModes: ["text"],
43
34
  };
@@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
4
  import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
5
5
  import { initFederationCleanup } from "./federation-cleanup.js";
6
+ import { classifyRecord } from "./federation-classify.js";
7
+ export { classifyRecord } from "./federation-classify.js";
6
8
  // Module-level nonce store for federation anti-replay.
7
9
  // Shared across FederationPair + FederationSync — nonces are globally unique
8
10
  // (generated by signBodyFresh per request with 128-bit random nonces).
@@ -268,6 +270,12 @@ export class FederationSync extends Resource {
268
270
  const startTime = Date.now();
269
271
  let merged = 0;
270
272
  let skipped = 0;
273
+ const skippedReasons = {};
274
+ const mergeErrors = [];
275
+ function recordSkip(reason) {
276
+ skipped++;
277
+ skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
278
+ }
271
279
  // Table name → Harper database table mapping
272
280
  const tableMap = {
273
281
  Memory: databases.flair.Memory,
@@ -275,66 +283,88 @@ export class FederationSync extends Resource {
275
283
  Agent: databases.flair.Agent,
276
284
  Relationship: databases.flair.Relationship,
277
285
  };
286
+ const knownTables = new Set(Object.keys(tableMap));
278
287
  for (const record of records) {
279
- const table = tableMap[record.table];
280
- if (!table) {
281
- skipped++;
282
- continue;
283
- }
284
- // Originator enforcement: peers can only push records they originated.
285
- // The hub can relay records from other peers (role === "hub"),
286
- // but spokes can only push their own records.
287
- const originator = record.originatorInstanceId ?? instanceId;
288
- if (originator !== instanceId && peer.role !== "hub") {
289
- skipped++;
290
- continue;
291
- }
292
288
  try {
293
- const local = await table.get(record.id);
294
- // Timestamp ceiling: reject records with updatedAt more than 5 minutes in the future.
295
- // Prevents attackers from using far-future timestamps to permanently win LWW.
296
- const fiveMinFromNow = new Date(Date.now() + 5 * 60 * 1000).toISOString();
297
- if (record.updatedAt > fiveMinFromNow) {
298
- skipped++;
289
+ const table = tableMap[record.table];
290
+ const local = table ? await table.get(record.id) : null;
291
+ const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
292
+ if (decision.action === "skip") {
293
+ recordSkip(decision.reason);
299
294
  continue;
300
295
  }
301
296
  const mergedData = mergeRecord(local, record);
302
- // Preserve originator for provenance
303
- mergedData._originatorInstanceId = originator;
297
+ mergedData._originatorInstanceId = decision.originator;
304
298
  mergedData._syncedFrom = instanceId;
305
299
  mergedData._syncedAt = new Date().toISOString();
306
300
  await table.put(mergedData);
307
301
  merged++;
308
302
  }
309
- catch {
310
- skipped++;
303
+ catch (err) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ recordSkip("merge_error");
306
+ // Cap the captured-error list so a hostile/buggy peer can't blow up
307
+ // the SyncLog row, but ALWAYS log to console so operators don't lose
308
+ // the per-record signal — the silent-swallow is the bug we're fixing.
309
+ if (mergeErrors.length < 10) {
310
+ mergeErrors.push(`${record.table}/${record.id}: ${msg}`);
311
+ }
312
+ console.warn(`[Federation] merge error for ${record.table}/${record.id} from ${instanceId}: ${msg}`);
311
313
  }
312
314
  }
313
- // Update peer sync cursor
314
- await databases.flair.Peer.put({
315
+ // Peer cursor update — distinguish liveness from progress:
316
+ // lastSyncAt → "we heard from this peer just now" (always update)
317
+ // lastMergeAt → "data actually flowed in" (only when merged > 0)
318
+ // Conflating them produced the "green dashboard while burning" failure
319
+ // mode — dogfood pass 2026-05-26 surfaced unconditional update masking
320
+ // 100%-skip syncs.
321
+ const nowIso = new Date().toISOString();
322
+ const peerUpdate = {
315
323
  ...peer,
316
- lastSyncAt: new Date().toISOString(),
317
- lastSyncCursor: lamportClock?.toString() ?? new Date().toISOString(),
324
+ lastSyncAt: nowIso,
325
+ lastSyncCursor: lamportClock?.toString() ?? nowIso,
318
326
  status: "connected",
319
- updatedAt: new Date().toISOString(),
320
- });
321
- // Log sync operation
327
+ updatedAt: nowIso,
328
+ };
329
+ if (merged > 0) {
330
+ peerUpdate.lastMergeAt = nowIso;
331
+ }
332
+ await databases.flair.Peer.put(peerUpdate);
333
+ // Log sync operation with skip-reason breakdown so the partial state
334
+ // is debuggable instead of a black box.
322
335
  try {
336
+ const errorParts = [];
337
+ if (Object.keys(skippedReasons).length > 0) {
338
+ errorParts.push("skipped: " +
339
+ Object.entries(skippedReasons)
340
+ .map(([reason, n]) => `${n} ${reason}`)
341
+ .join(", "));
342
+ }
343
+ if (mergeErrors.length > 0) {
344
+ errorParts.push("merge_errors: " + mergeErrors.join("; "));
345
+ }
346
+ const errorSummary = errorParts.length > 0 ? errorParts.join(" | ") : undefined;
347
+ const status = mergeErrors.length > 0
348
+ ? "partial"
349
+ : skipped > 0 ? "partial" : "success";
323
350
  await databases.flair.SyncLog.put({
324
351
  id: `sync_${Date.now()}_${randomBytes(4).toString("hex")}`,
325
352
  peerId: instanceId,
326
353
  direction: "pull",
327
354
  recordCount: merged,
328
- status: skipped > 0 ? "partial" : "success",
329
- error: skipped > 0 ? `${skipped} records skipped` : undefined,
355
+ skippedCount: skipped,
356
+ skippedReasons: JSON.stringify(skippedReasons),
357
+ status,
358
+ error: errorSummary,
330
359
  durationMs: Date.now() - startTime,
331
- createdAt: new Date().toISOString(),
360
+ createdAt: nowIso,
332
361
  });
333
362
  }
334
363
  catch { /* non-fatal */ }
335
364
  return {
336
365
  merged,
337
366
  skipped,
367
+ skippedReasons,
338
368
  total: records.length,
339
369
  durationMs: Date.now() - startTime,
340
370
  };
@@ -354,6 +384,7 @@ export class FederationPeers extends Resource {
354
384
  status: p.status,
355
385
  endpoint: p.endpoint,
356
386
  lastSyncAt: p.lastSyncAt,
387
+ lastMergeAt: p.lastMergeAt,
357
388
  relayOnly: p.relayOnly,
358
389
  pairedAt: p.pairedAt,
359
390
  });
@@ -0,0 +1,40 @@
1
+ // Pure selection logic for what an agent's public AgentCard exposes.
2
+ //
3
+ // SECURITY: `GET /AgentCard/{id}` is intentionally UNAUTHENTICATED (A2A spec —
4
+ // agent cards are public discovery metadata, and the auth-middleware allow-list
5
+ // lets it bypass auth). Everything these helpers return is therefore world-
6
+ // readable. They must publish ONLY explicitly-kinded souls — never an implicit
7
+ // fallback that could surface a private soul (an operator's internal note,
8
+ // prompt fragment, or credential reminder) on the public endpoint.
9
+ //
10
+ // Kept free of any @harperfast/harper import so the real logic is unit-testable
11
+ // without spinning up Harper (avoids the simulator-pattern that let the
12
+ // description-fallback leak ship untested — ops-vz6j).
13
+ export function readSoulKind(entry) {
14
+ return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
15
+ }
16
+ export function readSoulContent(entry) {
17
+ return String(entry.content ?? entry.value ?? "").trim();
18
+ }
19
+ /**
20
+ * The public description is ONLY an explicit `kind="description"` soul.
21
+ *
22
+ * There is deliberately no fallback to "the first soul with any content": that
23
+ * was the ops-vz6j leak — an agent with no description soul would publish an
24
+ * arbitrary private soul on the unauthenticated card. Absent an explicit
25
+ * description, publish nothing.
26
+ */
27
+ export function selectPublicDescription(souls) {
28
+ const entry = souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s));
29
+ return entry ? readSoulContent(entry) : "";
30
+ }
31
+ /**
32
+ * Public skills are ONLY `kind="capability"` souls — an explicit, operator-
33
+ * intended allow-list. Any other soul kind is never published.
34
+ */
35
+ export function selectPublicSkills(souls) {
36
+ return souls
37
+ .filter((s) => readSoulKind(s) === "capability")
38
+ .map((s) => readSoulContent(s))
39
+ .filter(Boolean);
40
+ }