brainclaw 1.20.4 → 1.22.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.
Files changed (51) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-cloud.js +63 -0
  3. package/dist/cli.js +2 -3
  4. package/dist/commands/cloud.js +198 -0
  5. package/dist/commands/export.js +3 -3
  6. package/dist/commands/init.js +11 -0
  7. package/dist/commands/mcp-write-claims.js +162 -46
  8. package/dist/commands/mcp-write-entities.js +67 -0
  9. package/dist/commands/mcp.js +64 -1
  10. package/dist/commands/session-end.js +0 -102
  11. package/dist/commands/session-start.js +0 -23
  12. package/dist/commands/switch.js +41 -12
  13. package/dist/core/actions.js +25 -1
  14. package/dist/core/agent-files.js +19 -0
  15. package/dist/core/agentruns.js +68 -10
  16. package/dist/core/assignments.js +94 -19
  17. package/dist/core/claims.js +13 -24
  18. package/dist/core/config.js +58 -0
  19. package/dist/core/context-diff.js +28 -11
  20. package/dist/core/coordination.js +1 -3
  21. package/dist/core/entity-locator.js +404 -0
  22. package/dist/core/federation-attestation.js +96 -0
  23. package/dist/core/federation-canonical.js +95 -0
  24. package/dist/core/federation-hpke.js +213 -0
  25. package/dist/core/federation-inbound.js +187 -0
  26. package/dist/core/federation-keyring.js +241 -0
  27. package/dist/core/federation-message.js +5 -5
  28. package/dist/core/federation-outbox-v2.js +125 -0
  29. package/dist/core/federation-pairing.js +213 -0
  30. package/dist/core/federation-projection.js +336 -0
  31. package/dist/core/federation-relay.js +223 -0
  32. package/dist/core/federation-state.js +270 -0
  33. package/dist/core/identity.js +9 -1
  34. package/dist/core/ids.js +5 -0
  35. package/dist/core/io.js +39 -1
  36. package/dist/core/operations/relocate.js +40 -10
  37. package/dist/core/schema.js +24 -17
  38. package/dist/core/sequence.js +47 -6
  39. package/dist/core/store-resolution.js +99 -26
  40. package/dist/core/workspace-projects.js +23 -2
  41. package/dist/core/worktree.js +59 -1
  42. package/dist/facts.js +7 -7
  43. package/dist/facts.json +6 -6
  44. package/docs/cli.md +73 -40
  45. package/docs/concepts/federation-v2-rfc.md +275 -0
  46. package/docs/index.md +1 -0
  47. package/package.json +2 -2
  48. package/dist/cli/register-federation.js +0 -258
  49. package/dist/core/federation-cloud.js +0 -245
  50. package/dist/core/federation-outbox.js +0 -292
  51. package/dist/core/federation-signing.js +0 -115
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { readAuditLog } from './audit.js';
4
3
  import { listCandidates } from './candidates.js';
5
- import { resolveEntityDir } from './io.js';
4
+ import { entityRecordPaths } from './io.js';
6
5
  import { loadVersionedJsonFile } from './migration.js';
7
6
  import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
8
7
  import { SessionSnapshotSchema } from './schema.js';
@@ -28,17 +27,35 @@ export function resolveContextDiffSince(options) {
28
27
  // everyone's diff baseline.
29
28
  return {};
30
29
  }
30
+ /**
31
+ * THE LAST BY-ID SITE THAT COULD ACTUALLY FIRE, and the only one with live two-layout
32
+ * data in the field: this store holds 173 sessions in the legacy layout next to 1019
33
+ * canonical ones (dec#153-T2's dual write). `resolveEntityDir(..., 'read')` answers a
34
+ * DIRECTORY question with a `hasContent` heuristic, so one canonical file made every
35
+ * legacy record invisible — the same malformed abstraction pln#649 removed from the
36
+ * entity locator and the by-id loaders, still here because nothing routed sessions.
37
+ *
38
+ * The consequence was a SILENT WRONG ANSWER, which is why this one was worth fixing
39
+ * while the sibling sites were not: an invisible snapshot falls through to the audit-log
40
+ * scan, and if that misses too `resolveContextDiffSince` returns no `since`, so
41
+ * `buildContextDiff` returns undefined and the caller is told "no changes" over a window
42
+ * where there were changes. An agent cannot tell that apart from a quiet period.
43
+ *
44
+ * Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
45
+ */
31
46
  function loadSessionSnapshot(sessionId, cwd) {
32
- const snapshotPath = path.join(resolveEntityDir('sessions', cwd ?? process.cwd(), 'read'), `${sessionId}.json`);
33
- if (!fs.existsSync(snapshotPath)) {
34
- return undefined;
35
- }
36
- try {
37
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
38
- }
39
- catch {
40
- return undefined;
47
+ for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
48
+ if (!fs.existsSync(snapshotPath))
49
+ continue;
50
+ try {
51
+ return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
52
+ }
53
+ catch {
54
+ // An unparseable record in one layout must not mask a good one in the other.
55
+ continue;
56
+ }
41
57
  }
58
+ return undefined;
42
59
  }
43
60
  export function buildContextDiff(options = {}) {
44
61
  const resolved = resolveContextDiffSince(options);
@@ -300,9 +300,7 @@ function buildIncomingSignalsSummary(cwd) {
300
300
  try {
301
301
  const fedSignals = pullSignalsFromLinkedProjects(cwd);
302
302
  for (const sig of fedSignals) {
303
- const payloadPreview = typeof sig.payload === 'string'
304
- ? sig.payload.slice(0, 120)
305
- : JSON.stringify(sig.payload).slice(0, 120);
303
+ const payloadPreview = JSON.stringify(sig.payload).slice(0, 120);
306
304
  incomingSignals.push({
307
305
  id: sig.id,
308
306
  entity_type: sig.type,
@@ -0,0 +1,404 @@
1
+ /**
2
+ * pln#649 step 2 (dec#153) — locate the store that OWNS an entity, by id.
3
+ *
4
+ * THE PROBLEM THIS SOLVES. dec#153 made the entity the routing authority: for any
5
+ * work unit, `owner_project` is fixed at creation and every read/mutation must
6
+ * reach THAT store, regardless of pid, cwd, session or the shared global pointer.
7
+ * Step 1 persisted the owner *inside* the record — but to read that field you must
8
+ * first find the file, which is the very thing ambient resolution gets wrong. This
9
+ * module breaks that circle: it finds the record by probing a BOUNDED candidate
10
+ * set, so a worker holding only an `assignment_id` can be routed correctly without
11
+ * anything ambient being trusted.
12
+ *
13
+ * WHY A PROBE AND NOT AN INDEX. The plan allowed an index; the code does not need
14
+ * one, and an index would be strictly worse here:
15
+ * - the candidate set is already enumerable and small (the store itself, the
16
+ * workspace's nested children, declared cross-project links) — one `existsSync`
17
+ * each, and the current store is probed FIRST so the overwhelmingly common
18
+ * single-project case costs exactly one stat;
19
+ * - an index is a second source of truth that can go stale, be half-written, or
20
+ * disagree with the filesystem — and the whole point of dec#153 is to stop
21
+ * trusting a derived answer over the record itself;
22
+ * - `resolveEntityDir` is store-LOCAL (io.ts:79-107 — it joins memoryDir(cwd)
23
+ * and falls back only to a legacy path inside the SAME store), so each probe
24
+ * is properly isolated and cannot leak a parent store's hit.
25
+ * If enumeration ever becomes expensive (hundreds of projects), a cache belongs in
26
+ * FRONT of this function, not instead of it.
27
+ *
28
+ * AMBIGUITY IS A RESULT, NOT AN ERROR. Two stores holding the same id is exactly
29
+ * the divergence T3 must refuse on (step 4), so it is reported as `ambiguous` with
30
+ * every match rather than silently resolved by first-wins. Callers decide; this
31
+ * module never picks a winner it cannot justify.
32
+ *
33
+ * @module
34
+ */
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { loadConfig } from './config.js';
38
+ import { resolveCrossProjectLinks } from './cross-project.js';
39
+ import { entityRecordPaths, MEMORY_DIR } from './io.js';
40
+ import { resolvePrimaryStore, resolveWorkspaceRoot } from './store-resolution.js';
41
+ import { scanNestedBrainclawProjectsDetailed, summarizeWorkspaceProjects } from './workspace-projects.js';
42
+ /**
43
+ * Depth ceiling for nested-store scans.
44
+ *
45
+ * Review P1-2 offered two remedies for the false `not_found` at depth 7: raise the
46
+ * ceiling, or report incompleteness. I tried raising it to 12 and MEASURED the
47
+ * result — a test whose resolved workspace root was a large shared directory took
48
+ * 130 SECONDS in enumeration alone. Trading a false `not_found` for a pathological
49
+ * walk on a routing path is the worse defect, and it validated this review's own
50
+ * warning about hidden enumeration cost. So the ceiling stays at the underlying
51
+ * default and the enumeration REPORTS when it stopped early (`incomplete`), which
52
+ * lets a caller distinguish "not there" from "I could not look that far".
53
+ */
54
+ export const DEFAULT_SCAN_DEPTH = 6;
55
+ /** Directory key in ENTITY_DIR_MAP for each mapped entity kind. */
56
+ function subdirFor(entity) {
57
+ return entity === 'assignment' ? 'assignments'
58
+ : entity === 'claim' ? 'claims'
59
+ : entity === 'agent_run' ? 'runs'
60
+ : 'plans';
61
+ }
62
+ /**
63
+ * EVERY file path a record with this id could occupy in one store.
64
+ *
65
+ * Deliberately NOT `resolveEntityDir(..., 'read')` any more (review P1-1, which the
66
+ * reviewer reproduced): that helper picks the canonical directory as soon as it
67
+ * contains ANY file (`hasContent`), which answers "where do records generally
68
+ * live" — not "where is THIS record". A store mid-migration holding
69
+ * `coordination/assignments/asgn_new.json` plus a legacy `assignments/asgn_old.json`
70
+ * made `asgn_old` unroutable: the canonical dir had content, so the legacy path was
71
+ * never looked at. Choosing a directory is the wrong primitive for a per-file
72
+ * question, so both layouts are probed by FILE and the caller sees one hit.
73
+ */
74
+ /**
75
+ * The identifier shape `JsonStore` enforces before building a record path
76
+ * (json-store.ts:78). Duplicated HERE rather than trusted from the caller (review
77
+ * P2-6): this module joins an id into a filesystem path, so an id containing `..`
78
+ * or a separator would escape the store it is supposed to be probing. Callers on
79
+ * the MCP surface validate too and return a clean input_error — this is the
80
+ * backstop that makes the escape impossible rather than merely unlikely.
81
+ */
82
+ export function isLocatableId(id) {
83
+ return /^[A-Za-z0-9_-]+$/.test(id);
84
+ }
85
+ function recordPaths(entity, id, storeCwd) {
86
+ if (entity === 'loop') {
87
+ // Loops are not in ENTITY_DIR_MAP — their threads live under loops/threads.
88
+ return [path.join(storeCwd, MEMORY_DIR, 'loops', 'threads', `${id}.json`)];
89
+ }
90
+ // Shared primitive (io.ts): this module had a hand-made copy, and so did two
91
+ // loaders. One export, four consumers — a fourth review would otherwise have
92
+ // found the same defect one layer further down.
93
+ return entityRecordPaths(subdirFor(entity), id, storeCwd);
94
+ }
95
+ /**
96
+ * One candidate, all its possible layouts. A store that is mid-write,
97
+ * permission-denied or otherwise unreadable counts as a miss rather than an
98
+ * exception: routing must not depend on the health of a project the caller has
99
+ * nothing to do with.
100
+ */
101
+ function recordExists(entity, id, storeCwd) {
102
+ for (const candidate of recordPaths(entity, id, storeCwd)) {
103
+ try {
104
+ if (fs.existsSync(candidate))
105
+ return true;
106
+ }
107
+ catch { /* unreadable — treat as a miss and keep looking */ }
108
+ }
109
+ return false;
110
+ }
111
+ /**
112
+ * Canonical identity of a store path, for deduplication (review P2-3, reproduced).
113
+ * `path.resolve` is LEXICAL: a Windows directory junction — or a symlink, or a
114
+ * case-different spelling — declared as a cross-project link survived as a SECOND
115
+ * candidate pointing at the same physical store, and the same record was then found
116
+ * twice and reported `ambiguous`. That is a false positive that blocks a healthy
117
+ * mutation, i.e. the exact opposite of what the ambiguity contract is for.
118
+ *
119
+ * Falls back to the lexical form when the path cannot be realpath'd (missing or
120
+ * unreadable): a candidate we cannot canonicalise must still be probed, not dropped.
121
+ */
122
+ function canonicalKey(abs) {
123
+ try {
124
+ const real = fs.realpathSync.native(abs);
125
+ // Windows paths are case-insensitive; lower-casing the KEY only (never the
126
+ // stored path) makes `C:\Repo` and `c:\repo` one candidate without changing
127
+ // what is reported back to the caller.
128
+ return process.platform === 'win32' ? real.toLowerCase() : real;
129
+ }
130
+ catch {
131
+ return process.platform === 'win32' ? abs.toLowerCase() : abs;
132
+ }
133
+ }
134
+ function describe(storeCwd) {
135
+ try {
136
+ const config = loadConfig(storeCwd);
137
+ return { cwd: storeCwd, project_id: config.project_id, project_name: config.project_name };
138
+ }
139
+ catch {
140
+ // A store we can enumerate but not read config for is still a valid location.
141
+ return { cwd: storeCwd };
142
+ }
143
+ }
144
+ /**
145
+ * Candidate stores, in probe order: the caller's own store first (so the common
146
+ * single-project case stops after one stat), then the workspace's children, then
147
+ * declared cross-project links. Deduplicated by resolved absolute path.
148
+ *
149
+ * Enumeration mirrors the loop project-resolution gate (loops/project-resolution.ts)
150
+ * on purpose — two different candidate sets for "which projects exist here" is how
151
+ * routing surfaces drift apart.
152
+ */
153
+ export function enumerateCandidateStores(cwd, options = {}) {
154
+ return enumerateCandidates(cwd, options).stores;
155
+ }
156
+ /**
157
+ * Per-process memo of the CANDIDATE LIST — never of the probes.
158
+ *
159
+ * This module's own header said "a cache belongs in FRONT of this function" and
160
+ * nobody wrote it, which a Fable audit ranked as the second live risk of the whole
161
+ * routing change: `enumerateCandidates` re-walks the workspace tree on EVERY routed
162
+ * mutation, and a worker emits repeated PROGRESS calls for the length of a run.
163
+ * Measured by a reviewer on Windows: p50 3.36 ms vs 0.23 ms for a direct load (~15x
164
+ * per call) — and a depth-12 experiment of mine spent 130 SECONDS in enumeration
165
+ * alone, which is the shape of the tail.
166
+ *
167
+ * WHAT IS AND IS NOT CACHED, deliberately. Only "which stores exist here" is memoised
168
+ * — a filesystem-topology answer that changes when someone creates a project. The
169
+ * PROBES (does this store hold this id?) are never cached: that is the state a
170
+ * mutation is about to change, and a stale answer there would route a write to the
171
+ * wrong store, which is the entire class dec#153 exists to kill.
172
+ *
173
+ * ASSUMED TRADE-OFF: for up to TTL milliseconds after a store is created, it is not a
174
+ * candidate. Consequences are bounded and safe in both directions — a `not_found`
175
+ * carries `enumeration_incomplete`, and an ambiguity that appears late is caught on
176
+ * the next call. The TTL is deliberately short: long enough to collapse a burst of
177
+ * calls from one operation, far too short to matter to a human.
178
+ */
179
+ const ENUMERATION_MEMO_TTL_MS = 2_000;
180
+ const enumerationMemo = new Map();
181
+ /**
182
+ * Drop the memo. Called by tests, and by `runInit` — because a store that has just come
183
+ * into existence is exactly what a cached candidate list cannot know about.
184
+ *
185
+ * This comment used to name that second caller before it existed (Fable audit: intent
186
+ * described as code). Now it does.
187
+ */
188
+ export function clearEnumerationMemo() {
189
+ enumerationMemo.clear();
190
+ }
191
+ export function enumerateCandidates(cwd, options = {}) {
192
+ const maxDepth = options.maxDepth ?? DEFAULT_SCAN_DEPTH;
193
+ // Keyed on the canonical cwd AND the depth, so two callers asking different
194
+ // questions cannot read each other's answer.
195
+ const memoKey = `${canonicalKey(path.resolve(cwd))}::${maxDepth}`;
196
+ if (!options.noMemo) {
197
+ const hit = enumerationMemo.get(memoKey);
198
+ if (hit && Date.now() - hit.at < ENUMERATION_MEMO_TTL_MS) {
199
+ // Copy the array so a caller mutating its result cannot corrupt the memo.
200
+ return { stores: [...hit.value.stores], incomplete: hit.value.incomplete };
201
+ }
202
+ }
203
+ const computed = enumerateCandidatesUncached(cwd, maxDepth);
204
+ if (!options.noMemo)
205
+ enumerationMemo.set(memoKey, { at: Date.now(), value: computed });
206
+ return { stores: [...computed.stores], incomplete: computed.incomplete };
207
+ }
208
+ function enumerateCandidatesUncached(cwd, maxDepth) {
209
+ const seen = new Set();
210
+ const ordered = [];
211
+ let incomplete = false;
212
+ const add = (candidate) => {
213
+ if (!candidate)
214
+ return;
215
+ const abs = path.resolve(candidate);
216
+ // Only real stores are candidates — an unreadable path costs nothing later.
217
+ if (!fs.existsSync(path.join(abs, MEMORY_DIR, 'config.yaml')))
218
+ return;
219
+ // Dedup on the CANONICAL path so a junction/symlink/case alias of a store
220
+ // already in the list cannot become a second candidate (review P2-3).
221
+ const key = canonicalKey(abs);
222
+ if (seen.has(key))
223
+ return;
224
+ seen.add(key);
225
+ ordered.push(abs);
226
+ };
227
+ /**
228
+ * ONE nested scan per root, with truncation reported BY THE WALK.
229
+ *
230
+ * An earlier version inferred it from the deepest RESULT — a store found at the
231
+ * ceiling depth meant deeper ones might exist. Review P1-1 reproduced why that is
232
+ * wrong: with a store at `root/d1/…/d7` and nothing in `d1…d6`, the walk cut the
233
+ * branch without returning anything near the ceiling, so the heuristic reported
234
+ * completeness while the target sat just below the cut — and the handler emitted a
235
+ * CONFIDENT `not_found`. Truncation is a property of the traversal, not of what it
236
+ * happened to find, so the scanner now says it (still one scan, no extra I/O).
237
+ */
238
+ const addNested = (root) => {
239
+ const scan = scanNestedBrainclawProjectsDetailed(root, maxDepth);
240
+ for (const project of scan.projects)
241
+ add(project.path);
242
+ if (scan.truncated)
243
+ incomplete = true;
244
+ };
245
+ const here = path.resolve(cwd);
246
+ add(here);
247
+ let config;
248
+ try {
249
+ config = loadConfig(here);
250
+ }
251
+ catch {
252
+ // Uninitialised store: `here` was already rejected by `add`, and there is
253
+ // nothing to enumerate from. Returning what we have keeps callers simple.
254
+ return { stores: ordered, incomplete };
255
+ }
256
+ if (config.project_mode === 'multi-project') {
257
+ for (const project of summarizeWorkspaceProjects(here, config).discovered_projects) {
258
+ // `config`-sourced entries are declared namespaces without a path of their own.
259
+ if (project.source !== 'config')
260
+ add(project.path);
261
+ }
262
+ }
263
+ // A workspace root can host nested project stores; so can the workspace root
264
+ // ABOVE us, which is how a child project reaches its siblings.
265
+ const wsRoot = resolveWorkspaceRoot(here);
266
+ if (wsRoot) {
267
+ add(wsRoot);
268
+ addNested(wsRoot);
269
+ }
270
+ else if (resolvePrimaryStore(here)?.role === 'workspace') {
271
+ addNested(here);
272
+ }
273
+ // A cross-project link can point at a WORKSPACE, whose own children are then
274
+ // reachable by the linked side (review P1-2, reproduced: A → linked B → B/apps/C
275
+ // returned candidates [A, B] and not_found for a record living in C). Adding the
276
+ // link root alone was an arbitrary stop one level short of what the product can
277
+ // reach, so nested stores under each link are enumerated too.
278
+ for (const link of resolveCrossProjectLinks(here)) {
279
+ add(link.absolutePath);
280
+ addNested(link.absolutePath);
281
+ }
282
+ return { stores: ordered, incomplete };
283
+ }
284
+ /**
285
+ * Find the store holding `id`. Never throws for a missing store or unreadable
286
+ * config — a locator that fails loudly on an unrelated broken sibling would make
287
+ * routing depend on the health of projects the caller has nothing to do with.
288
+ */
289
+ /**
290
+ * The owner project id shared by EVERY match, or undefined when the records do not
291
+ * agree on one — which includes all the ways they can fail to answer:
292
+ *
293
+ * - a record with no `project_id` (legacy, created before step 1 persisted it):
294
+ * step 1 decided a missing owner must NOT trigger new behaviour, so one absent
295
+ * field makes the whole set undecided rather than letting the others vote;
296
+ * - records naming DIFFERENT owners — a genuine divergence, the case the hard
297
+ * refusal exists for;
298
+ * - a record that cannot be read or parsed. NEVER throws: this module refuses to
299
+ * make routing depend on the health of a project the caller has nothing to do
300
+ * with, and an unreadable record must degrade to `ambiguous`, not to a crash.
301
+ */
302
+ function sharedRecordOwner(entity, id, matches) {
303
+ let owner;
304
+ for (const match of matches) {
305
+ let recordOwner;
306
+ for (const candidate of recordPaths(entity, id, match.cwd)) {
307
+ if (!fs.existsSync(candidate))
308
+ continue;
309
+ try {
310
+ const raw = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
311
+ const value = raw?.project_id;
312
+ if (typeof value === 'string' && value.trim())
313
+ recordOwner = value;
314
+ }
315
+ catch { /* unreadable → undecided, handled below */ }
316
+ break;
317
+ }
318
+ if (!recordOwner)
319
+ return undefined;
320
+ if (owner === undefined)
321
+ owner = recordOwner;
322
+ else if (owner !== recordOwner)
323
+ return undefined;
324
+ }
325
+ return owner;
326
+ }
327
+ export function locateEntity(entity, id, cwd, options = {}) {
328
+ // An unusable id never touches the filesystem: no path is built, nothing is
329
+ // probed. Reported as not_found rather than thrown so an MCP handler can turn it
330
+ // into a clean input_error without a crash, while an internal caller that forgot
331
+ // to validate still cannot escape a store.
332
+ if (!isLocatableId(id)) {
333
+ return { status: 'not_found', matches: [], probed: [], enumeration_incomplete: false };
334
+ }
335
+ const enumeration = options.candidates
336
+ ? { stores: options.candidates, incomplete: false }
337
+ : enumerateCandidates(cwd, { maxDepth: options.maxDepth });
338
+ const matches = [];
339
+ const probed = [];
340
+ // Alias-aware dedup lives HERE, not only in enumeration: a caller-supplied
341
+ // candidate list can contain two spellings of one physical store just as easily
342
+ // (found while pinning review P2-3 — the enumeration-only dedup left this path
343
+ // open). Probing an alias twice would report a single record as `ambiguous`.
344
+ const probedKeys = new Set();
345
+ for (const candidate of enumeration.stores) {
346
+ const key = canonicalKey(path.resolve(candidate));
347
+ if (probedKeys.has(key))
348
+ continue;
349
+ probedKeys.add(key);
350
+ probed.push(candidate);
351
+ if (recordExists(entity, id, candidate))
352
+ matches.push(describe(candidate));
353
+ }
354
+ const incomplete = enumeration.incomplete;
355
+ if (matches.length === 1) {
356
+ return { status: 'found', location: matches[0], matches, probed, enumeration_incomplete: incomplete };
357
+ }
358
+ if (matches.length > 1) {
359
+ // ── dec#155's robustness guard: ≥2 matches carrying the SAME owner route to the
360
+ // owner instead of refusing. Written from that sentence, not from this code.
361
+ //
362
+ // This does NOT weaken the ambiguity contract, and the distinction is the whole
363
+ // point of the module header: "never picks a winner it cannot justify". Here the
364
+ // RECORD names its owner, so the winner is justified BY THE DATA — it is not a
365
+ // first-wins tie-break. Every path where the data does not justify a winner
366
+ // stays `ambiguous`, which is what the two entity-routed surfaces refuse on.
367
+ //
368
+ // It uses the field step 1 persisted (`project_id` on assignment / agent_run /
369
+ // claim) and that this function has ignored ever since, and it exists to avoid
370
+ // REFUSING a healthy mutation when an alias or a mirror surfaces one record
371
+ // twice — a false refusal is the exact opposite of what the contract is for.
372
+ //
373
+ // Paid only on the ambiguous path: one read per match, in a branch that is
374
+ // already the rare case. The single-store hit still costs what it cost.
375
+ //
376
+ // ONE COUNTEREXAMPLE WAS RAISED AND IS EMPTY TODAY — recorded because it will not
377
+ // stay empty by itself. `bclaw_move` deliberately leaves a recoverable duplicate
378
+ // if it crashes between writing the target and removing the source, and it copies
379
+ // the record VERBATIM (relocate.ts) — so the residue would be two copies naming
380
+ // the SAME owner, which this guard would route instead of surfacing, freezing the
381
+ // repair signal. It cannot happen now: `plan` is the only entity both relocatable
382
+ // and locatable, and plan records carry NO `project_id` (checked against real
383
+ // records on disk, not just the schema), so `sharedRecordOwner` returns undefined
384
+ // and the residue stays `ambiguous`. THE DAY PLANS GAIN AN OWNER, relocate must
385
+ // rewrite `project_id` on the moved copy first — then the residue carries
386
+ // divergent owners and stays loudly ambiguous, which is the better companion fix
387
+ // anyway. (Fable audit; the counterexample was sound reasoning about a field the
388
+ // entity does not have.)
389
+ const owner = sharedRecordOwner(entity, id, matches);
390
+ if (owner) {
391
+ const owned = matches.filter((m) => m.project_id === owner);
392
+ // Exactly one match must BE the owner store. Zero means the owner is not among
393
+ // the stores holding the record (so routing there would name a store that does
394
+ // not have it); more than one means two stores claim the same project id, which
395
+ // is itself a divergence and not something to resolve here.
396
+ if (owned.length === 1) {
397
+ return { status: 'found', location: owned[0], matches, probed, enumeration_incomplete: incomplete };
398
+ }
399
+ }
400
+ return { status: 'ambiguous', matches, probed, enumeration_incomplete: incomplete };
401
+ }
402
+ return { status: 'not_found', matches, probed, enumeration_incomplete: incomplete };
403
+ }
404
+ //# sourceMappingURL=entity-locator.js.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contrat d'attestation de clé, côté CLIENT (pln#651 étape 4, RFC §5.2).
3
+ *
4
+ * ── CE FICHIER A UN JUMEAU, ET C'EST DÉLIBÉRÉ ─────────────────────────────────
5
+ * Le même contrat existe côté Cloud dans `brainclaw-cloud/src/lib/attestation.ts`.
6
+ * Les deux DOIVENT produire octet pour octet la même chaîne : le CLI signe, le Worker
7
+ * vérifie. Ils ne peuvent pas partager un module — deux dépôts, deux runtimes (Node ici,
8
+ * Workers là-bas) — donc la duplication est assumée et le gel de la forme est verrouillé
9
+ * DES DEUX CÔTÉS par un test sur le littéral exact.
10
+ *
11
+ * Ce n'est pas une précaution théorique. La première livraison côté Cloud reconstruisait
12
+ * `created_at` avec l'horloge du serveur au moment de l'approbation : la signature portait
13
+ * sur d'autres octets que ceux vérifiés, et AUCUN appairage ne pouvait aboutir. Le défaut
14
+ * a survécu à un typecheck vert parce que rien n'exerçait les deux côtés ensemble.
15
+ *
16
+ * RÈGLE QUI EN DÉCOULE, valable au-delà de ce fichier : tout champ couvert par une
17
+ * signature doit venir du signataire, ou d'une valeur qu'il connaît déjà. Un champ que le
18
+ * vérificateur fabrique lui-même ne peut pas être signé.
19
+ */
20
+ import crypto from 'node:crypto';
21
+ /**
22
+ * Payload canonique de l'attestation.
23
+ *
24
+ * LA FORME EST GELÉE. Changer l'ordre des clés ou ajouter un champ invalide toutes les
25
+ * attestations déjà émises — ce qui est voulu, mais doit être un acte délibéré, jamais
26
+ * l'effet de bord d'une refactorisation. Le test de gel existe pour cela.
27
+ */
28
+ export function attestationPayload(input) {
29
+ return JSON.stringify({
30
+ v: 1,
31
+ kind: 'brainclaw.federation.v2.key_attestation',
32
+ enrollment_id: input.enrollment_id,
33
+ project_id: input.project_id,
34
+ agent_id: input.agent_id,
35
+ key_type: input.key_type,
36
+ key_purpose: input.key_purpose,
37
+ key_fingerprint: input.key_fingerprint,
38
+ key_epoch: input.key_epoch,
39
+ created_at: input.created_at,
40
+ });
41
+ }
42
+ /**
43
+ * Empreinte canonique d'un PEM — MÊME RÈGLE que `fingerprintPublicKeyPem` d'agent-registry
44
+ * et que `fingerprintPem` côté Cloud.
45
+ *
46
+ * Le retrait des CR et le trim ne sont pas cosmétiques : le même PEM traversant un champ
47
+ * JSON ou un éditeur Windows ressort avec un CRLF ou un saut de ligne final. Sans
48
+ * canonicalisation, deux représentations de LA MÊME clé donnent deux empreintes
49
+ * différentes — et la comparaison locale↔distante, qui EST la preuve d'identité de la
50
+ * clé, échouerait sur une différence invisible à l'œil.
51
+ */
52
+ export function fingerprintPem(pem) {
53
+ return crypto.createHash('sha256').update(pem.replace(/\r/g, '').trim()).digest('hex');
54
+ }
55
+ /**
56
+ * Signe des octets avec une clé privée Ed25519 au format PEM, en base64.
57
+ *
58
+ * Ed25519 ne prend pas d'algorithme de hachage séparé — d'où le `null` en premier
59
+ * argument, qui n'est pas un oubli : passer un digest ici lèverait.
60
+ */
61
+ export function signEd25519(privateKeyPem, message) {
62
+ const key = crypto.createPrivateKey(privateKeyPem);
63
+ return crypto.sign(null, Buffer.from(message), key).toString('base64');
64
+ }
65
+ /**
66
+ * Construit et signe l'attestation liant la clé de chiffrement X25519 de l'appareil à son
67
+ * identité Ed25519.
68
+ *
69
+ * C'EST LA PIÈCE QUI EMPÊCHE LE MEMBRE FANTÔME. Sans elle, le Cloud — qui orchestre
70
+ * l'appairage — pourrait insérer sa propre clé dans la liste d'enveloppement : un
71
+ * chiffrement de bout en bout dont l'échange de clés serait arbitré par la partie même
72
+ * qu'il prétend neutraliser.
73
+ *
74
+ * Retourne aussi l'horodatage, que l'appelant DOIT transmettre au serveur : sans lui, le
75
+ * vérificateur ne peut pas reconstruire les octets signés.
76
+ */
77
+ export function buildKeyAttestation(params) {
78
+ const keyFingerprint = fingerprintPem(params.encryptionPublicKeyPem);
79
+ const payload = attestationPayload({
80
+ enrollment_id: params.enrollmentId,
81
+ project_id: params.projectId,
82
+ agent_id: params.agentId,
83
+ key_type: 'encryption',
84
+ key_purpose: 'envelope',
85
+ key_fingerprint: keyFingerprint,
86
+ key_epoch: params.keyEpoch ?? 1,
87
+ created_at: params.createdAt,
88
+ });
89
+ return {
90
+ payload,
91
+ signature: signEd25519(params.identityPrivateKeyPem, new TextEncoder().encode(payload)),
92
+ created_at: params.createdAt,
93
+ key_fingerprint: keyFingerprint,
94
+ };
95
+ }
96
+ //# sourceMappingURL=federation-attestation.js.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Sérialisation canonique — fédération v2 (pln#651 étape 5, RFC §3.1).
3
+ *
4
+ * ── POURQUOI « CANONIQUE » ET PAS SIMPLEMENT `JSON.stringify` ─────────────────
5
+ * Ces octets sont hachés, chiffrés et signés par UN programme, puis vérifiés par UN
6
+ * AUTRE. Une différence d'un seul octet — un espace, un ordre de clés, un `1e3` au lieu
7
+ * de `1000` — fait échouer la vérification sans qu'aucun message ne dise laquelle des
8
+ * deux implémentations a tort.
9
+ *
10
+ * `JSON.stringify` sur un OBJET n'est déterministe que si l'ordre d'insertion l'est. Il
11
+ * ne l'est pas quand l'objet vient d'un `JSON.parse`, d'un spread ou d'un tri différent.
12
+ * D'où un sérialiseur explicite qui trie par point de code, comme l'exige le RFC.
13
+ *
14
+ * Le RFC dit aussi : « Core et Cloud partagent les vecteurs de test ; ils ne
15
+ * réimplémentent pas chacun une quasi-canonicalisation. » Les vecteurs vivent dans les
16
+ * tests des deux dépôts, sur les mêmes chaînes littérales.
17
+ */
18
+ import crypto from 'node:crypto';
19
+ /**
20
+ * Trie par POINT DE CODE et non par `localeCompare`.
21
+ *
22
+ * `Array.prototype.sort()` sans comparateur trie déjà par unité de code UTF-16, ce qui
23
+ * diffère du point de code pour les caractères hors du plan multilingue de base. Un
24
+ * emoji dans un nom de clé suffirait à faire diverger deux implémentations qui croient
25
+ * toutes deux « trier les clés ». On compare donc explicitement les points de code.
26
+ */
27
+ function compareCodePoints(a, b) {
28
+ const ai = Array.from(a);
29
+ const bi = Array.from(b);
30
+ const n = Math.min(ai.length, bi.length);
31
+ for (let i = 0; i < n; i++) {
32
+ const ca = ai[i].codePointAt(0);
33
+ const cb = bi[i].codePointAt(0);
34
+ if (ca !== cb)
35
+ return ca - cb;
36
+ }
37
+ return ai.length - bi.length;
38
+ }
39
+ /**
40
+ * JSON canonique : clés triées, aucune espace, chaînes NFC, entiers finis sans notation
41
+ * exponentielle.
42
+ *
43
+ * REFUSE plutôt que d'inventer une représentation pour ce que JSON ne porte pas
44
+ * fidèlement : `undefined`, `NaN`, `Infinity`, fonctions, symboles, `BigInt`. Les
45
+ * sérialiser en `null` — ce que fait `JSON.stringify` pour certains — produirait deux
46
+ * objets différents avec les mêmes octets, donc une signature valide pour un contenu
47
+ * qu'on n'a pas signé.
48
+ */
49
+ export function canonicalJson(value) {
50
+ if (value === null)
51
+ return 'null';
52
+ switch (typeof value) {
53
+ case 'boolean':
54
+ return value ? 'true' : 'false';
55
+ case 'number':
56
+ if (!Number.isFinite(value)) {
57
+ throw new Error(`Canonicalisation impossible : nombre non fini (${String(value)}).`);
58
+ }
59
+ // `String(1e21)` rend "1e+21". Le RFC interdit la notation exponentielle, et un
60
+ // vérificateur qui lirait "1e+21" produirait d'autres octets que celui qui écrit
61
+ // "1000000000000000000000". Refuser est plus sûr qu'une conversion approximative.
62
+ if (Number.isInteger(value) && Math.abs(value) >= 1e21) {
63
+ throw new Error(`Canonicalisation impossible : entier hors de la plage sérialisable sans exposant (${value}).`);
64
+ }
65
+ return JSON.stringify(value);
66
+ case 'string':
67
+ // NFC : « é » composé et « e + accent » combinés sont visuellement identiques et
68
+ // produisent des octets différents. Sans normalisation, un titre saisi sur macOS
69
+ // (NFD par défaut) et le même titre saisi sur Windows ne se vérifieraient pas.
70
+ return JSON.stringify(value.normalize('NFC'));
71
+ case 'object': {
72
+ if (Array.isArray(value)) {
73
+ return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
74
+ }
75
+ const obj = value;
76
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(compareCodePoints);
77
+ return `{${keys.map((k) => `${JSON.stringify(k.normalize('NFC'))}:${canonicalJson(obj[k])}`).join(',')}}`;
78
+ }
79
+ default:
80
+ throw new Error(`Canonicalisation impossible : type '${typeof value}' non sérialisable en JSON.`);
81
+ }
82
+ }
83
+ /** base64url sans padding — la seule forme admise par le RFC pour les champs binaires. */
84
+ export function b64url(bytes) {
85
+ return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
86
+ }
87
+ export function b64urlDecode(s) {
88
+ const padded = s.replace(/-/g, '+').replace(/_/g, '/');
89
+ return new Uint8Array(Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64'));
90
+ }
91
+ /** SHA-256 des octets canoniques d'une valeur, rendu en base64url (RFC §3.2). */
92
+ export function canonicalSha256(value) {
93
+ return b64url(new Uint8Array(crypto.createHash('sha256').update(canonicalJson(value), 'utf-8').digest()));
94
+ }
95
+ //# sourceMappingURL=federation-canonical.js.map