@tpsdev-ai/flair 0.50.0 → 0.51.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.
- package/README.md +15 -4
- package/dist/build-info.json +3 -3
- package/dist/cli.js +630 -199
- package/dist/doctor-client.js +3 -3
- package/dist/install/clients.js +28 -17
- package/dist/lib/daemon-liveness.js +207 -0
- package/dist/lib/doctor-run.js +481 -0
- package/dist/lib/launchd-management.js +7 -26
- package/dist/lib/upgrade-migrations.js +181 -0
- package/dist/resources/Federation.js +42 -20
- package/dist/resources/MemoryArchive.js +94 -0
- package/dist/resources/MemoryBootstrap.js +3 -1
- package/dist/resources/RecordUsage.js +13 -6
- package/dist/resources/SemanticSearch.js +14 -2
- package/dist/resources/federation-classify.js +90 -0
- package/dist/resources/health.js +9 -10
- package/dist/resources/mcp-tools.js +115 -7
- package/dist/resources/record-types.js +3 -1
- package/dist/resources/search-readiness.js +33 -10
- package/dist/resources/semantic-retrieval-core.js +39 -20
- package/dist/resources/usage-ids.js +63 -0
- package/docs/federation.md +11 -0
- package/docs/supply-chain-policy.md +1 -1
- package/docs/upgrade.md +17 -1
- package/package.json +2 -2
- package/schemas/federation.graphql +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upgrade-migrations.ts — flair#1439
|
|
3
|
+
*
|
|
4
|
+
* Version-keyed migrations that `flair upgrade` applies automatically during
|
|
5
|
+
* the post-restart verification step. These are distinct from the Harper data
|
|
6
|
+
* migrations in `resources/migrations/`: those run inside the server and
|
|
7
|
+
* transform stored data. These run in the CLI and bring the LOCAL dev-env
|
|
8
|
+
* (hook files, config snippets) up to the state a fresh `flair init` would
|
|
9
|
+
* produce for the harnesses already wired on this machine.
|
|
10
|
+
*
|
|
11
|
+
* ## Consent model
|
|
12
|
+
*
|
|
13
|
+
* A migration runs when ALL of the following hold:
|
|
14
|
+
* 1. The installed harness/artifact is ALREADY present on this machine
|
|
15
|
+
* (the user consented to that integration when they ran `flair init`).
|
|
16
|
+
* 2. The target version (`toVersion`) introduces the artifact for the first
|
|
17
|
+
* time (`toAtLeast`), AND the previous version did not have it
|
|
18
|
+
* (`fromBefore`).
|
|
19
|
+
* 3. The artifact is currently absent (idempotent: nothing to do if it's
|
|
20
|
+
* already there).
|
|
21
|
+
*
|
|
22
|
+
* Because (1) is the consent gate, no extra flag or TTY interaction is
|
|
23
|
+
* required. The user already said "wire Codex" — the migration just applies
|
|
24
|
+
* the piece their old init couldn't know about.
|
|
25
|
+
*
|
|
26
|
+
* ## Adding a new migration
|
|
27
|
+
*
|
|
28
|
+
* 1. Push a new entry onto UPGRADE_MIGRATIONS.
|
|
29
|
+
* 2. Set `fromBefore` to the first version that includes the new artifact.
|
|
30
|
+
* 3. Set `toAtLeast` to the same version.
|
|
31
|
+
* 4. Implement `apply(ctx)` — return ok:false (surfaced as a warning) on
|
|
32
|
+
* partial failure; throw only on hard failure that should be logged.
|
|
33
|
+
*
|
|
34
|
+
* Migrations run in order; later entries may assume earlier ones completed.
|
|
35
|
+
*/
|
|
36
|
+
import { installHook, hookSettingsPath, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
37
|
+
import { checkSessionStartHook, readClientMcpBlock } from "../doctor-client.js";
|
|
38
|
+
import { parseSemverCore } from "../fabric-upgrade.js";
|
|
39
|
+
// ─── Semver helpers ───────────────────────────────────────────────────────────
|
|
40
|
+
/** True if version `a` is strictly less than `b`. */
|
|
41
|
+
function semverLt(a, b) {
|
|
42
|
+
const pa = parseSemverCore(a);
|
|
43
|
+
const pb = parseSemverCore(b);
|
|
44
|
+
if (!pa || !pb)
|
|
45
|
+
return false;
|
|
46
|
+
if (pa[0] !== pb[0])
|
|
47
|
+
return pa[0] < pb[0];
|
|
48
|
+
if (pa[1] !== pb[1])
|
|
49
|
+
return pa[1] < pb[1];
|
|
50
|
+
return pa[2] < pb[2];
|
|
51
|
+
}
|
|
52
|
+
/** True if version `a` is greater than or equal to `b`. */
|
|
53
|
+
function semverGte(a, b) {
|
|
54
|
+
return !semverLt(a, b);
|
|
55
|
+
}
|
|
56
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Determine if a harness is "already wired" — the user previously ran
|
|
59
|
+
* `flair init` for it and the MCP block (config.toml / mcpServers JSON) is
|
|
60
|
+
* present. The hook itself is what we're migrating IN, so we deliberately
|
|
61
|
+
* do NOT check for the hook's presence here.
|
|
62
|
+
*
|
|
63
|
+
* Uses readClientMcpBlock (which temporarily overrides HOME) so the homeDir
|
|
64
|
+
* arg is honoured correctly in tests.
|
|
65
|
+
*/
|
|
66
|
+
function isHarnessWired(homeDir, harness) {
|
|
67
|
+
const clientId = harness === "codex" ? "codex" : "claude-code";
|
|
68
|
+
const block = readClientMcpBlock(clientId, homeDir);
|
|
69
|
+
return block.present;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Install the SessionStart hook for a harness, reading the agent id and flair
|
|
73
|
+
* URL from the existing MCP block (same as `resolveUpgradeHookInstall` in
|
|
74
|
+
* doctor-run.ts, but self-contained to avoid a circular dep).
|
|
75
|
+
*/
|
|
76
|
+
function applySessionStartHookForHarness(homeDir, harness, port) {
|
|
77
|
+
const id = `session-start-hook@0.50.0/${harness}`;
|
|
78
|
+
// Already present → idempotent no-op.
|
|
79
|
+
const settingsPath = hookSettingsPath(homeDir, harness);
|
|
80
|
+
const existing = checkSessionStartHook(homeDir, settingsPath);
|
|
81
|
+
if (existing.present) {
|
|
82
|
+
return { id, message: `SessionStart hook (${harness}): already present — no-op`, ok: true, wrote: false };
|
|
83
|
+
}
|
|
84
|
+
// Not wired at all → skip (don't wire a harness the user never had).
|
|
85
|
+
if (!isHarnessWired(homeDir, harness)) {
|
|
86
|
+
return { id, message: `SessionStart hook (${harness}): harness not wired — skip`, ok: true, wrote: false };
|
|
87
|
+
}
|
|
88
|
+
// Resolve agentId + flairUrl from the existing MCP block.
|
|
89
|
+
const clientId = harness === "codex" ? "codex" : "claude-code";
|
|
90
|
+
const block = readClientMcpBlock(clientId, homeDir);
|
|
91
|
+
const agentId = (typeof process.env.FLAIR_AGENT_ID === "string" && process.env.FLAIR_AGENT_ID) ||
|
|
92
|
+
block.agentId;
|
|
93
|
+
const flairUrl = (typeof process.env.FLAIR_URL === "string" && process.env.FLAIR_URL) ||
|
|
94
|
+
block.flairUrl ||
|
|
95
|
+
`http://127.0.0.1:${port}`;
|
|
96
|
+
if (!agentId) {
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
message: `SessionStart hook (${harness}): no agent id found in MCP config — run: flair hook install --harness ${harness}`,
|
|
100
|
+
ok: false,
|
|
101
|
+
wrote: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const result = installHook({ homeDir, harness, agentId, flairUrl });
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
message: result.message,
|
|
108
|
+
ok: result.ok,
|
|
109
|
+
wrote: result.ok,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// ─── Migration registry ───────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* Ordered list of all CLI upgrade migrations.
|
|
115
|
+
*
|
|
116
|
+
* To add a migration: push a new entry, set fromBefore/toAtLeast, implement
|
|
117
|
+
* apply(). The runner calls apply() only when the version window matches.
|
|
118
|
+
*/
|
|
119
|
+
export const UPGRADE_MIGRATIONS = [
|
|
120
|
+
{
|
|
121
|
+
id: "session-start-hook@0.50.0",
|
|
122
|
+
description: "Install missing SessionStart hook(s) for harnesses already wired in a pre-0.50.0 init",
|
|
123
|
+
fromBefore: "0.50.0",
|
|
124
|
+
toAtLeast: "0.50.0",
|
|
125
|
+
apply(ctx) {
|
|
126
|
+
// Only act on detected hook-capable harnesses already wired on this machine.
|
|
127
|
+
const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h) && isHarnessWired(ctx.homeDir, h));
|
|
128
|
+
if (harnesses.length === 0)
|
|
129
|
+
return [];
|
|
130
|
+
return harnesses.map((h) => applySessionStartHookForHarness(ctx.homeDir, h, ctx.port));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
/**
|
|
135
|
+
* Run all CLI upgrade migrations whose version window matches
|
|
136
|
+
* `fromVersion → toVersion`. Migrations whose window is not matched are
|
|
137
|
+
* silently skipped (they're not pending for this upgrade pair).
|
|
138
|
+
*
|
|
139
|
+
* @param fromVersion The previously installed version (null → unknown, skip
|
|
140
|
+
* migrations that need a version gate).
|
|
141
|
+
* @param toVersion The newly installed version (null → unknown, skip all).
|
|
142
|
+
* @param ctx Runtime context (homeDir, port, detectedClientIds).
|
|
143
|
+
*/
|
|
144
|
+
export function applyUpgradeMigrations(fromVersion, toVersion, ctx,
|
|
145
|
+
// Injectable for tests (e.g. the throwing-migration case); production callers
|
|
146
|
+
// use the real registry.
|
|
147
|
+
migrations = UPGRADE_MIGRATIONS) {
|
|
148
|
+
const applied = [];
|
|
149
|
+
if (!fromVersion || !toVersion) {
|
|
150
|
+
// Cannot gate on version — skip all version-keyed migrations.
|
|
151
|
+
return { applied, allOk: true };
|
|
152
|
+
}
|
|
153
|
+
for (const migration of migrations) {
|
|
154
|
+
const pending = semverLt(fromVersion, migration.fromBefore) &&
|
|
155
|
+
semverGte(toVersion, migration.toAtLeast);
|
|
156
|
+
if (!pending)
|
|
157
|
+
continue;
|
|
158
|
+
// A migration must never CRASH the upgrade: a thrown apply() is strictly
|
|
159
|
+
// worse than the pre-migration doctor failure it was meant to prevent — no
|
|
160
|
+
// summary, partial writes, and the doctor catalog never runs. Catch it,
|
|
161
|
+
// surface it as a non-fatal ok:false result, and let the catalog proceed
|
|
162
|
+
// (flair#1439, per Kern review).
|
|
163
|
+
let results;
|
|
164
|
+
try {
|
|
165
|
+
results = migration.apply(ctx);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
results = [{
|
|
169
|
+
id: migration.id,
|
|
170
|
+
message: `${migration.id}: migration failed (${err instanceof Error ? err.message : String(err)}) — run \`flair doctor\` to check the current state`,
|
|
171
|
+
ok: false,
|
|
172
|
+
wrote: false,
|
|
173
|
+
}];
|
|
174
|
+
}
|
|
175
|
+
if (results.length > 0) {
|
|
176
|
+
applied.push({ migration, results });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const allOk = applied.every((a) => a.results.every((r) => r.ok));
|
|
180
|
+
return { applied, allOk };
|
|
181
|
+
}
|
|
@@ -5,8 +5,8 @@ import { allowAdmin } from "./agent-auth.js";
|
|
|
5
5
|
import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
|
|
6
6
|
import { initFederationCleanup } from "./federation-cleanup.js";
|
|
7
7
|
import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
|
|
8
|
-
import { classifyRecord } from "./federation-classify.js";
|
|
9
|
-
export { classifyRecord } from "./federation-classify.js";
|
|
8
|
+
import { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, } from "./federation-classify.js";
|
|
9
|
+
export { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, recordSignatureVersion, PRINCIPAL_OWNING_TABLES, FEDERATION_TABLE_POLICY, FEDERATION_SYNC_TABLES, } from "./federation-classify.js";
|
|
10
10
|
// Module-level nonce store for federation anti-replay.
|
|
11
11
|
// Shared across FederationPair + FederationSync — nonces are globally unique
|
|
12
12
|
// (generated by signBodyFresh per request with 128-bit random nonces).
|
|
@@ -37,6 +37,17 @@ export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodyS
|
|
|
37
37
|
function requireRecordSignatures() {
|
|
38
38
|
return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES ?? "").toLowerCase() === "true";
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Phase 3 of flair#1416 — skip leftover v:1 records on principal-owning
|
|
42
|
+
* tables that lack principalId. Default OFF. Flip only once every paired
|
|
43
|
+
* peer is emitting v:2 (check SyncLog for unsigned / v:1 Memory). Same
|
|
44
|
+
* operator-decision pattern as requireRecordSignatures() — never
|
|
45
|
+
* auto-flipped. v:2 Memory is already mandatory-principal regardless of
|
|
46
|
+
* this flag (see checkPrincipalEntitlement).
|
|
47
|
+
*/
|
|
48
|
+
function requireRecordPrincipal() {
|
|
49
|
+
return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL ?? "").toLowerCase() === "true";
|
|
50
|
+
}
|
|
40
51
|
// ─── Conflict resolution ─────────────────────────────────────────────────────
|
|
41
52
|
/**
|
|
42
53
|
* Field-level Last-Write-Wins merge.
|
|
@@ -359,7 +370,10 @@ export class FederationSync extends Resource {
|
|
|
359
370
|
skipped++;
|
|
360
371
|
skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
|
|
361
372
|
}
|
|
362
|
-
// Table name → Harper database table mapping
|
|
373
|
+
// Table name → Harper database table mapping.
|
|
374
|
+
// Typed against FEDERATION_TABLE_POLICY so adding a federated table
|
|
375
|
+
// without deciding principalOwning is a type error, not a silent
|
|
376
|
+
// default (flair#1416 — refuse by whitelist, never by field presence).
|
|
363
377
|
const tableMap = {
|
|
364
378
|
Memory: databases.flair.Memory,
|
|
365
379
|
Soul: databases.flair.Soul,
|
|
@@ -369,7 +383,9 @@ export class FederationSync extends Resource {
|
|
|
369
383
|
const knownTables = new Set(Object.keys(tableMap));
|
|
370
384
|
for (const record of records) {
|
|
371
385
|
try {
|
|
372
|
-
const table =
|
|
386
|
+
const table = (record.table in tableMap)
|
|
387
|
+
? tableMap[record.table]
|
|
388
|
+
: undefined;
|
|
373
389
|
const local = table ? await table.get(record.id) : null;
|
|
374
390
|
const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
|
|
375
391
|
if (decision.action === "skip") {
|
|
@@ -403,22 +419,15 @@ export class FederationSync extends Resource {
|
|
|
403
419
|
recordSkip("unknown_originator_key");
|
|
404
420
|
continue;
|
|
405
421
|
}
|
|
406
|
-
// CONTRACT —
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
table: record.table,
|
|
416
|
-
id: record.id,
|
|
417
|
-
data: record.data,
|
|
418
|
-
updatedAt: record.updatedAt,
|
|
419
|
-
originatorInstanceId: originator,
|
|
420
|
-
signature: record.signature,
|
|
421
|
-
}, originatorPublicKey);
|
|
422
|
+
// CONTRACT — reconstruct from the record (v defaults to 1 when
|
|
423
|
+
// absent — v is NOT on the wire today). Must match
|
|
424
|
+
// reconstructRecordVerifyBody / src/cli.ts's signing payload.
|
|
425
|
+
// A hardcoded { v: 1, table, id, data, updatedAt,
|
|
426
|
+
// originatorInstanceId } field set can only ever verify one
|
|
427
|
+
// shape; building from the record is what makes v:2 (principalId
|
|
428
|
+
// in the signed body) verifiable without breaking existing
|
|
429
|
+
// records. See flair#1416.
|
|
430
|
+
const signatureValid = verifyBodySignature(reconstructRecordVerifyBody(record, originator), originatorPublicKey);
|
|
422
431
|
if (!signatureValid) {
|
|
423
432
|
recordSkip("invalid_signature");
|
|
424
433
|
continue;
|
|
@@ -431,6 +440,19 @@ export class FederationSync extends Resource {
|
|
|
431
440
|
recordSkip("missing_signature");
|
|
432
441
|
continue;
|
|
433
442
|
}
|
|
443
|
+
// ── Per-record principal entitlement (flair#1416 / slice 3a) ──
|
|
444
|
+
// After signature verification, before table.put. Scoped by the
|
|
445
|
+
// explicit PRINCIPAL_OWNING_TABLES set (Memory), never by whether
|
|
446
|
+
// principalId happens to be present — absent Memory principalId
|
|
447
|
+
// is a skip, not an accept. Soul/Agent/Relationship are not in
|
|
448
|
+
// the set and are not consulted. No Agent.get.
|
|
449
|
+
const principalSkip = checkPrincipalEntitlement(record, {
|
|
450
|
+
enforceV1Principal: requireRecordPrincipal(),
|
|
451
|
+
});
|
|
452
|
+
if (principalSkip) {
|
|
453
|
+
recordSkip(principalSkip);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
434
456
|
const mergedData = mergeRecord(local, record);
|
|
435
457
|
mergedData._originatorInstanceId = decision.originator;
|
|
436
458
|
mergedData._syncedFrom = instanceId;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryArchive.ts — user-facing archive action (flair#1472, Deliverable A).
|
|
3
|
+
*
|
|
4
|
+
* POST /MemoryArchive — sets or clears the `archived` visibility flag on a
|
|
5
|
+
* memory by id:
|
|
6
|
+
* - `action: "basement"` → archived=true + stamps archivedAt (and archivedBy)
|
|
7
|
+
* - `action: "restore"` → archived=false + clears archivedAt/archivedBy
|
|
8
|
+
*
|
|
9
|
+
* `archived` is a VISIBILITY flag, not a deletion: basementing removes a
|
|
10
|
+
* memory from bootstrap + default search but leaves the row, its provenance,
|
|
11
|
+
* and its history fully intact (still retrievable via memory_get and
|
|
12
|
+
* memory_search(includeArchived:true)). Restore is the deliberate, GLOBAL
|
|
13
|
+
* inverse — it un-retires the memory for EVERY session, not a session-local
|
|
14
|
+
* view (per-session reuse is drawers, Deliverable B, which does not exist
|
|
15
|
+
* yet). The CLI help text must make that global scope explicit.
|
|
16
|
+
*
|
|
17
|
+
* Own-lane scope: the read uses Memory.get()'s read-scope gate and the write
|
|
18
|
+
* uses Memory.put()'s ownership gate (stampAttribution), so a caller can
|
|
19
|
+
* neither read nor write another agent's memory here. Anonymous HTTP is
|
|
20
|
+
* denied (401).
|
|
21
|
+
*
|
|
22
|
+
* Registered automatically at /MemoryArchive via config.yaml's
|
|
23
|
+
* `jsResource: files: dist/resources/*.js` (named export → export name).
|
|
24
|
+
*/
|
|
25
|
+
import { Resource } from "harper";
|
|
26
|
+
import { Memory } from "./Memory.js";
|
|
27
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
28
|
+
function json(status, body) {
|
|
29
|
+
return new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Unwrap a Harper Response (has .json() + .status) into a plain object, else pass through. */
|
|
35
|
+
async function unwrap(value) {
|
|
36
|
+
if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
|
|
37
|
+
try {
|
|
38
|
+
const body = await value.json();
|
|
39
|
+
return { ...body, status: value.status };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { error: "request failed", status: value.status };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
export class MemoryArchive extends Resource {
|
|
48
|
+
/** POST requires auth — an agent acting on its own memories (or admin). */
|
|
49
|
+
async allowCreate() {
|
|
50
|
+
return allowVerified(this.getContext?.());
|
|
51
|
+
}
|
|
52
|
+
async post(data) {
|
|
53
|
+
const { id, action } = data || {};
|
|
54
|
+
if (!id)
|
|
55
|
+
return json(400, { error: "id required" });
|
|
56
|
+
if (action !== "basement" && action !== "restore") {
|
|
57
|
+
return json(400, { error: "action must be 'basement' or 'restore'" });
|
|
58
|
+
}
|
|
59
|
+
const ctx = this.getContext?.();
|
|
60
|
+
const auth = await resolveAgentAuth(ctx);
|
|
61
|
+
if (auth.kind === "anonymous")
|
|
62
|
+
return json(401, { error: "authentication required" });
|
|
63
|
+
if (auth.kind !== "agent")
|
|
64
|
+
return json(403, { error: "forbidden" });
|
|
65
|
+
// Read the existing record — Memory.get()'s read-scope gate applies (own +
|
|
66
|
+
// org-non-private only). A non-readable id returns a 404 Response.
|
|
67
|
+
const existing = await Memory.get(id, ctx);
|
|
68
|
+
const record = await unwrap(existing);
|
|
69
|
+
if (!record || typeof record !== "object" || !record.id) {
|
|
70
|
+
return json(404, { error: "memory not found" });
|
|
71
|
+
}
|
|
72
|
+
const archived = action === "basement";
|
|
73
|
+
const merged = {
|
|
74
|
+
...record,
|
|
75
|
+
archived,
|
|
76
|
+
updatedAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
if (archived) {
|
|
79
|
+
// archivedBy is set by the caller (Memory.put() stamps archivedAt when
|
|
80
|
+
// archived===true). The content is unchanged, so the existing embedding
|
|
81
|
+
// stays valid — do NOT clear it (clearing would force a needless re-embed
|
|
82
|
+
// and, if the embedding engine is unavailable, silently drop the vector).
|
|
83
|
+
merged.archivedBy = auth.agentId;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
delete merged.archivedAt;
|
|
87
|
+
delete merged.archivedBy;
|
|
88
|
+
}
|
|
89
|
+
// Write back — Memory.put()'s ownership gate applies (stampAttribution), so
|
|
90
|
+
// a non-admin caller cannot flip another agent's memory (403).
|
|
91
|
+
const result = await Memory.put(merged, ctx);
|
|
92
|
+
return unwrap(result);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -697,6 +697,7 @@ export class BootstrapMemories extends Resource {
|
|
|
697
697
|
conditions: [
|
|
698
698
|
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
699
699
|
{ attribute: "durability", comparator: "equals", value: "permanent" },
|
|
700
|
+
{ attribute: "archived", comparator: "not_equal", value: true },
|
|
700
701
|
],
|
|
701
702
|
select: OWN_SELECT,
|
|
702
703
|
}));
|
|
@@ -767,6 +768,7 @@ export class BootstrapMemories extends Resource {
|
|
|
767
768
|
conditions: [
|
|
768
769
|
{ attribute: "agentId", comparator: "equals", value: agentId },
|
|
769
770
|
{ attribute: "durability", comparator: "not_equal", value: "permanent" },
|
|
771
|
+
{ attribute: "archived", comparator: "not_equal", value: true },
|
|
770
772
|
],
|
|
771
773
|
select: OWN_SELECT,
|
|
772
774
|
sort: { attribute: "createdAt", descending: true },
|
|
@@ -952,7 +954,7 @@ export class BootstrapMemories extends Resource {
|
|
|
952
954
|
const candidatePoolK = Math.min(MAX_CANDIDATE_POOL, Math.max(3 * expectedFill, 5 * teammateIds.length, MIN_CANDIDATE_POOL));
|
|
953
955
|
const candidates = await retrieveCandidates({
|
|
954
956
|
queryEmbedding,
|
|
955
|
-
conditions: [scope.condition],
|
|
957
|
+
conditions: [scope.condition, { attribute: "archived", comparator: "not_equal", value: true }],
|
|
956
958
|
limit: candidatePoolK,
|
|
957
959
|
// flair#1246 — ONE RANKER, ONE SCALE: this pass now invokes the
|
|
958
960
|
// core in the SAME mode memory_search does (hybrid + q via the
|
|
@@ -97,6 +97,7 @@ import { Resource } from "harper";
|
|
|
97
97
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
98
98
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
99
99
|
import { recordUsageContribution, MAX_USAGE_IDS_PER_CALL } from "./usage-recording.js";
|
|
100
|
+
import { resolveRecordUsageIds } from "./usage-ids.js";
|
|
100
101
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
101
102
|
const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
102
103
|
// flair#744 slice A: sourced from the shared module (./usage-recording.ts)
|
|
@@ -159,14 +160,20 @@ export class RecordUsage extends Resource {
|
|
|
159
160
|
const rl = checkRateLimit(agentId, "usage");
|
|
160
161
|
if (!rl.allowed)
|
|
161
162
|
return rateLimitResponse(rl.retryAfterMs, "usage");
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
// flair#1410: MERGE memoryId + memoryIds (union, then dedupe). The
|
|
164
|
+
// previous `data?.memoryIds ?? [data?.memoryId]` preferred the plural
|
|
165
|
+
// and silently dropped the singular — quiet data loss. Unioning HERE
|
|
166
|
+
// means a client that POSTs both fields straight through (without
|
|
167
|
+
// flattening first) still credits both. Native `/mcp` also unions
|
|
168
|
+
// before calling this; the endpoint is the guarantee, not the client.
|
|
169
|
+
const resolved = resolveRecordUsageIds(data, MAX_IDS_PER_CALL);
|
|
170
|
+
if (!resolved.ok) {
|
|
171
|
+
if (resolved.error === "cap") {
|
|
172
|
+
return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
|
|
173
|
+
}
|
|
164
174
|
return BAD_REQUEST("memoryIds must be a non-empty array of memory id strings");
|
|
165
175
|
}
|
|
166
|
-
|
|
167
|
-
return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
|
|
168
|
-
}
|
|
169
|
-
const memoryIds = [...new Set(rawIds)]; // dedupe within THIS call too
|
|
176
|
+
const memoryIds = resolved.ids;
|
|
170
177
|
const attribution = sanitizeAttribution(data?.attribution);
|
|
171
178
|
const now = new Date().toISOString();
|
|
172
179
|
for (const memoryId of memoryIds) {
|
|
@@ -61,7 +61,7 @@ export class SemanticSearch extends Resource {
|
|
|
61
61
|
// recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
|
|
62
62
|
// before reconsidering this default if the compositeScore formula or
|
|
63
63
|
// corpus changes.
|
|
64
|
-
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false } = data || {};
|
|
64
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
|
|
65
65
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
66
66
|
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
67
67
|
// silently returned undefined and the defense-in-depth scope check below
|
|
@@ -160,7 +160,12 @@ export class SemanticSearch extends Resource {
|
|
|
160
160
|
}
|
|
161
161
|
// Exclude archived records. Use "not_equal" (Harper v5 comparator) instead of
|
|
162
162
|
// "equals false" so records without the archived field are included.
|
|
163
|
-
|
|
163
|
+
// flair#1472 — `includeArchived` opts back IN to the basement: when true the
|
|
164
|
+
// archived predicate is omitted entirely, so basemented memories are returned
|
|
165
|
+
// under the SAME read-scope gate as a normal search (never a wider scope).
|
|
166
|
+
if (!includeArchived) {
|
|
167
|
+
conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
|
|
168
|
+
}
|
|
164
169
|
if (tag) {
|
|
165
170
|
conditions.push({ attribute: "tags", comparator: "equals", value: tag });
|
|
166
171
|
}
|
|
@@ -215,6 +220,7 @@ export class SemanticSearch extends Resource {
|
|
|
215
220
|
// composite re-scoring headroom to reorder before the final slice.
|
|
216
221
|
const candidateLimit = limit * CANDIDATE_MULTIPLIER;
|
|
217
222
|
const ctx = this.getContext?.();
|
|
223
|
+
let legs;
|
|
218
224
|
const filteredResults = await retrieveCandidates({
|
|
219
225
|
queryEmbedding: qEmb,
|
|
220
226
|
q,
|
|
@@ -230,6 +236,7 @@ export class SemanticSearch extends Resource {
|
|
|
230
236
|
isAllowed: scope?.isAllowed,
|
|
231
237
|
hybrid,
|
|
232
238
|
ctx,
|
|
239
|
+
onLegs: includeLegs ? (l) => { legs = l; } : undefined,
|
|
233
240
|
// flair#744 slice 1: the trust block needs `provenance`, which the
|
|
234
241
|
// default projection omits. Widen the select ONLY when the caller opts
|
|
235
242
|
// in — passing undefined otherwise keeps the default (no `provenance`)
|
|
@@ -330,6 +337,11 @@ export class SemanticSearch extends Resource {
|
|
|
330
337
|
if (!qEmb && q && getMode() === "none") {
|
|
331
338
|
response._warning = "semantic search unavailable — results are keyword-only";
|
|
332
339
|
}
|
|
340
|
+
// flair#1358: opt-in per-leg candidate ids for the bench instrument.
|
|
341
|
+
// Default OFF ⇒ response is byte-identical (no `legs` key). The ranked
|
|
342
|
+
// `results` slice is unchanged either way — this is observation only.
|
|
343
|
+
if (includeLegs && legs)
|
|
344
|
+
response.legs = legs;
|
|
333
345
|
return response;
|
|
334
346
|
}
|
|
335
347
|
}
|
|
@@ -5,6 +5,96 @@
|
|
|
5
5
|
* spinning up Harper's database module. The same SkipReason names are used
|
|
6
6
|
* in SyncLog.skippedReasons so operators can grep for them.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Static policy for every table FederationSync will merge.
|
|
10
|
+
*
|
|
11
|
+
* Lives next to SkipReason / SyncRecord so the principal-owning decision is
|
|
12
|
+
* one visible list, not a condition scattered through the apply path.
|
|
13
|
+
* `Federation.ts` types its `tableMap` as `Record<FederationSyncTable, …>`,
|
|
14
|
+
* so adding a federated table without deciding `principalOwning` here is a
|
|
15
|
+
* type error rather than a silent default.
|
|
16
|
+
*
|
|
17
|
+
* Scope the principalId requirement by TABLE, never by field presence.
|
|
18
|
+
* Memory carries agentId / a provenance stamp; Soul, Agent, and
|
|
19
|
+
* Relationship do not, and will legitimately have no principalId.
|
|
20
|
+
*/
|
|
21
|
+
export const FEDERATION_TABLE_POLICY = {
|
|
22
|
+
Memory: { principalOwning: true },
|
|
23
|
+
Soul: { principalOwning: false },
|
|
24
|
+
Agent: { principalOwning: false },
|
|
25
|
+
Relationship: { principalOwning: false },
|
|
26
|
+
};
|
|
27
|
+
export const FEDERATION_SYNC_TABLES = Object.keys(FEDERATION_TABLE_POLICY);
|
|
28
|
+
export const PRINCIPAL_OWNING_TABLES = new Set(FEDERATION_SYNC_TABLES.filter((t) => FEDERATION_TABLE_POLICY[t].principalOwning));
|
|
29
|
+
/** Wire `v` when present; assume 1 when absent (today's records omit it). */
|
|
30
|
+
export function recordSignatureVersion(record) {
|
|
31
|
+
return record.v ?? 1;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rebuild the object FederationSync verifies against the originator's key.
|
|
35
|
+
*
|
|
36
|
+
* Load-bearing details, both of which fail every existing record if missed:
|
|
37
|
+
*
|
|
38
|
+
* 1. `v` is not on the wire today. The push side signs a body containing
|
|
39
|
+
* `v: 1` but sends a SyncRecord without it. Verification works today
|
|
40
|
+
* only because the receiver pinned the same literal. Default it.
|
|
41
|
+
* Apply `v` AFTER the spread so an absent/undefined `record.v` cannot
|
|
42
|
+
* overwrite the default.
|
|
43
|
+
*
|
|
44
|
+
* 2. `principalId` IS on the wire today for some records, but it is
|
|
45
|
+
* attached AFTER signing (informational). Spreading it into a v:1
|
|
46
|
+
* verify body changes the field set and fails those records. v:1
|
|
47
|
+
* therefore strips it; v:2 signs it, so it stays.
|
|
48
|
+
*
|
|
49
|
+
* `originatorInstanceId` is the classifyRecord originator (same override
|
|
50
|
+
* the pre-3a hardcoded reconstruction used), not a blind spread of the
|
|
51
|
+
* wire field.
|
|
52
|
+
*/
|
|
53
|
+
export function reconstructRecordVerifyBody(record, originator) {
|
|
54
|
+
const v = recordSignatureVersion(record);
|
|
55
|
+
const { signature, v: _wireV, principalId, ...payload } = record;
|
|
56
|
+
const verifyPayload = v >= 2 && principalId !== undefined ? { ...payload, principalId } : payload;
|
|
57
|
+
return {
|
|
58
|
+
...verifyPayload,
|
|
59
|
+
v,
|
|
60
|
+
originatorInstanceId: originator,
|
|
61
|
+
signature,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Per-record principal entitlement — apply-site check, DB-free.
|
|
66
|
+
*
|
|
67
|
+
* Table in PRINCIPAL_OWNING_TABLES → principalId is mandatory on v:2
|
|
68
|
+
* (absent is a skip, mismatched is a skip). Table not in the set →
|
|
69
|
+
* principalId is not consulted at all.
|
|
70
|
+
*
|
|
71
|
+
* Does not load Agent, does not read originatorInstanceId off an Agent
|
|
72
|
+
* row. The record's own stamp is the binding.
|
|
73
|
+
*
|
|
74
|
+
* `enforceV1Principal` is Phase 3 (FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL):
|
|
75
|
+
* skip leftover v:1 records on principal-owning tables that lack
|
|
76
|
+
* principalId. Off by default — v:1 Memory keeps merging until an
|
|
77
|
+
* operator flips the flag after the fleet is on v:2.
|
|
78
|
+
*/
|
|
79
|
+
export function checkPrincipalEntitlement(record, opts = {}) {
|
|
80
|
+
if (!PRINCIPAL_OWNING_TABLES.has(record.table)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const v = recordSignatureVersion(record);
|
|
84
|
+
if (v >= 2) {
|
|
85
|
+
if (typeof record.principalId !== "string" ||
|
|
86
|
+
record.principalId.length === 0 ||
|
|
87
|
+
record.principalId !== record.data?.agentId) {
|
|
88
|
+
return "principal_mismatch";
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (opts.enforceV1Principal &&
|
|
93
|
+
(typeof record.principalId !== "string" || record.principalId.length === 0)) {
|
|
94
|
+
return "principal_mismatch";
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
8
98
|
export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
|
|
9
99
|
if (!knownTables.has(record.table)) {
|
|
10
100
|
return { action: "skip", reason: "unknown_table" };
|
package/dist/resources/health.js
CHANGED
|
@@ -121,22 +121,18 @@ export class Health extends Resource {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
/** Same sources /Health and /HealthDetail consult so they cannot disagree. */
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// (Sherlock on #1406) for the injectable/test path — not an accident.
|
|
124
|
+
export function currentSearchReadiness() {
|
|
125
|
+
// Fail-open when the registry is missing (Sherlock on #1406 / flair#1411):
|
|
126
|
+
// do not 503 forever. resolveSearchReadiness warns once and names the
|
|
127
|
+
// degradation; we do not treat "registry should always be here" as a given.
|
|
129
128
|
const resources = server.resources ?? null;
|
|
130
|
-
if (!resources && !_warnedMissingRegistry) {
|
|
131
|
-
_warnedMissingRegistry = true;
|
|
132
|
-
logger.warn?.("Health: server.resources is absent — skipping the search-route mount check (table-only fail-open). Shipped Harper launch always exposes the registry.");
|
|
133
|
-
}
|
|
134
129
|
return resolveSearchReadiness({
|
|
135
130
|
resources,
|
|
136
131
|
memoryTable: db.flair?.Memory,
|
|
137
132
|
bm25: bm25IndexStatus(),
|
|
138
133
|
hybridEnabled: hybridEnabled(),
|
|
139
134
|
bm25IndexEnabled: bm25IndexEnabled(),
|
|
135
|
+
warn: (message) => { logger.warn?.(message); },
|
|
140
136
|
});
|
|
141
137
|
}
|
|
142
138
|
/**
|
|
@@ -168,7 +164,10 @@ export class HealthDetail extends Resource {
|
|
|
168
164
|
// and a warning name the lag so `flair status` / operators can see it.
|
|
169
165
|
const readiness = currentSearchReadiness();
|
|
170
166
|
stats.searchReady = readiness.searchReady;
|
|
171
|
-
|
|
167
|
+
// Public/detail shape is unchanged: searchReadyReason stays a lag signal
|
|
168
|
+
// (present iff !searchReady). Ready-path verification constants stay on
|
|
169
|
+
// the decision object (flair#1411).
|
|
170
|
+
if (!readiness.searchReady && readiness.searchReadyReason) {
|
|
172
171
|
stats.searchReadyReason = readiness.searchReadyReason;
|
|
173
172
|
warnings.push({ level: "warn", message: readiness.searchReadyReason });
|
|
174
173
|
}
|