@tpsdev-ai/flair 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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");
@@ -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
  });
@@ -110,12 +110,18 @@ async function backfillEmbedding(memoryId) {
110
110
  // ─── HTTP middleware ──────────────────────────────────────────────────────────
111
111
  server.http(async (request, nextLayer) => {
112
112
  const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
113
+ // A2A discovery endpoints: GET returns public agent-card metadata (per
114
+ // A2A spec, cards are intentionally public). POST invokes JSON-RPC
115
+ // actions (message/send writes OrgEvents on behalf of agents,
116
+ // tasks/list reads Beads issues, message/stream subscribes to
117
+ // OrgEvents) — those must be authenticated. Narrowing to GET-only
118
+ // closes the P0 where any caller could forge OrgEvents as any agent
119
+ // and read all internal Beads issues unauthenticated.
120
+ const isA2APath = url.pathname === "/a2a" || url.pathname === "/A2AAdapter" || url.pathname.startsWith("/A2AAdapter/");
113
121
  if (url.pathname === "/health" ||
114
122
  url.pathname === "/Health" ||
115
- url.pathname === "/a2a" ||
116
- url.pathname === "/A2AAdapter" ||
123
+ (request.method === "GET" && isA2APath) ||
117
124
  url.pathname === "/AgentCard" ||
118
- url.pathname.startsWith("/A2AAdapter/") ||
119
125
  url.pathname.startsWith("/AgentCard/") ||
120
126
  // FederationSync uses Ed25519 body-signature auth with anti-replay, validated
121
127
  // by the resource handler (allowCreate=true, same pattern as FederationPair).
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pure classifier for federation sync records — no Harper imports.
3
+ *
4
+ * Extracted from Federation.ts so the decision can be unit-tested without
5
+ * spinning up Harper's database module. The same SkipReason names are used
6
+ * in SyncLog.skippedReasons so operators can grep for them.
7
+ */
8
+ export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
9
+ if (!knownTables.has(record.table)) {
10
+ return { action: "skip", reason: "unknown_table" };
11
+ }
12
+ const originator = record.originatorInstanceId ?? receiverInstanceId;
13
+ if (originator !== receiverInstanceId && peerRole !== "hub") {
14
+ return { action: "skip", reason: "non_originator" };
15
+ }
16
+ const fiveMinFromNow = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
17
+ if (record.updatedAt > fiveMinFromNow) {
18
+ return { action: "skip", reason: "future_timestamp" };
19
+ }
20
+ const remoteContentHash = record.data?.contentHash;
21
+ if (local &&
22
+ local.contentHash &&
23
+ remoteContentHash &&
24
+ local.contentHash === remoteContentHash &&
25
+ record.updatedAt <= (local.updatedAt ?? "")) {
26
+ return { action: "skip", reason: "no_op_same_hash" };
27
+ }
28
+ return { action: "merge", originator };
29
+ }
@@ -201,13 +201,18 @@ export class HealthDetail extends Resource {
201
201
  for await (const s of db.flair.Soul.search({}))
202
202
  souls.push(s);
203
203
  stats.soulEntries = souls.length;
204
- const byPriority = { critical: 0, high: 0, standard: 0, low: 0 };
204
+ // Soul entries have no severity dimension they are keyed identity facts
205
+ // (role / project / standards / …). A per-priority breakdown was dead
206
+ // telemetry: nothing writes Soul.priority to anything but "standard", and
207
+ // the `?? "standard"` fallback also mislabelled *unset* as *standard*, so
208
+ // it always read 100% standard regardless of the data. Report the honest
209
+ // dimension instead — a count per key.
210
+ const byKey = {};
205
211
  for (const s of souls) {
206
- const p = (s.priority ?? "standard");
207
- if (p in byPriority)
208
- byPriority[p]++;
212
+ const k = (s.key ?? "(unkeyed)");
213
+ byKey[k] = (byKey[k] ?? 0) + 1;
209
214
  }
210
- stats.soul = { total: souls.length, byPriority };
215
+ stats.soul = { total: souls.length, byKey };
211
216
  }
212
217
  catch {
213
218
  stats.soulEntries = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -30,7 +30,8 @@ type Peer @table(database: "flair") @export {
30
30
  role: String @indexed # "hub" | "spoke"
31
31
  endpoint: String # wss:// URL for the peer
32
32
  status: String @indexed # "paired" | "connected" | "disconnected" | "revoked"
33
- lastSyncAt: String # last successful sync timestamp
33
+ lastSyncAt: String # last contact with this peer (liveness signal)
34
+ lastMergeAt: String # last sync where merged > 0 (data-progress signal)
34
35
  lastSyncCursor: String # Lamport clock or ISO timestamp for incremental sync
35
36
  relayOnly: Boolean # true for spoke-to-spoke peers announced by hub
36
37
  pairedAt: String
@@ -43,9 +44,11 @@ type SyncLog @table(database: "flair") {
43
44
  id: ID @primaryKey
44
45
  peerId: String! @indexed # which peer this sync was with
45
46
  direction: String! @indexed # "push" | "pull"
46
- recordCount: Int # how many records in this sync batch
47
+ recordCount: Int # how many records merged in this batch
48
+ skippedCount: Int # how many records skipped (sum of skippedReasons)
49
+ skippedReasons: String # JSON: { unknown_table: N, non_originator: N, future_timestamp: N, no_op_same_hash: N, merge_error: N }
47
50
  status: String @indexed # "success" | "partial" | "failed"
48
- error: String # error message if failed
51
+ error: String # human-readable summary if partial/failed (includes per-record error messages, capped)
49
52
  durationMs: Int # how long the sync took
50
53
  createdAt: String! @indexed
51
54
  }