@skanl/brambo-projection 0.1.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/dist/ledger.js ADDED
@@ -0,0 +1,552 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, relative, resolve, sep } from 'node:path';
5
+ import { BRAMBO_ERROR_CODES, BramboError, PROJECTION_LEDGER_VERSION, isRecord } from '@skanl/brambo-contracts';
6
+ import { acquireLock } from '@skanl/brambo-lock';
7
+ import { atomicWriteText } from './atomic-write.js';
8
+ import { strictFaultLocation } from './document-fault.js';
9
+ // The durable ownership ledger (AD-6, correction-01 C2): brambo's own record of
10
+ // every entry it placed in someone else's file. It lives beside the registry
11
+ // store in brambo's own directory and follows the same atomic temp+rename
12
+ // discipline, but it owns its state alone — @skanl/brambo-projection depends on
13
+ // @skanl/brambo-contracts and nothing else (AD-2), so rendering a config file never
14
+ // drags the Registry store or the microkernel in behind it.
15
+ //
16
+ // The ledger is the ONLY proof of ownership. That is deliberate: no vendor
17
+ // schema can reject a record that lives outside the vendor's file, and no
18
+ // format has to carry a marker it has nowhere to put.
19
+ //
20
+ // Failure policy, and it only bends one way. A ledger that cannot be READ is
21
+ // treated as "brambo has written nothing" for this run, which makes brambo report
22
+ // its own entries as foreign and touch nothing — recoverable. PERSISTING that
23
+ // under-claim is not recoverable: it would orphan every entry brambo has ever
24
+ // written, in every config, with no way back. So an unreadable ledger is never
25
+ // written over; it is reported and left exactly as it is.
26
+ //
27
+ // Writes MERGE: a run replaces only the records for the target and file it just
28
+ // projected, so it can never drop a claim for a target+file pair it did not
29
+ // touch. Merging alone is NOT enough, because `update` rewrites the whole
30
+ // document from a read taken before it: two interleaved read-modify-writes lose
31
+ // one side's claim permanently, and the entry is then a foreign collision
32
+ // forever. Serialisation therefore keys on the LEDGER FILE and is process-wide
33
+ // — two ProjectionLedger INSTANCES over one path share a queue, which is what
34
+ // two concurrent inits in one process actually are.
35
+ const LEDGER_FILE_NAME = 'projection-ledger.json';
36
+ const RECORD_FIELDS = ['targetId', 'filePath', 'nativeLocation', 'entryId', 'contentHash'];
37
+ /**
38
+ * Hash of the CANONICAL form of the text brambo placed at a native location.
39
+ *
40
+ * EOL is normalised because a file that git, an editor or a formatter rewrites
41
+ * from LF to CRLF has not been edited in any sense a vendor can observe — and
42
+ * `~/.claude.json` is rewritten by Claude Code itself. Treating that as an edit
43
+ * would make brambo disown every entry it has in the file. Format-specific
44
+ * canonicalisation (indentation, key order) happens in the strategies, which
45
+ * are the only code that knows what "the same entry" means per format.
46
+ */
47
+ export function hashOwnedText(text) {
48
+ return createHash('sha256').update(text.replaceAll('\r\n', '\n'), 'utf8').digest('hex');
49
+ }
50
+ /**
51
+ * Hash of the exact BYTES brambo copied to a path.
52
+ *
53
+ * Deliberately not {@link hashOwnedText}: a materialised file is copied verbatim
54
+ * from a source brambo does not author, so "the same file" means the same bytes.
55
+ * Normalising EOL here would let brambo overwrite a file whose line endings a
56
+ * user deliberately changed, and — far worse, since this is the delete path —
57
+ * let it REMOVE one.
58
+ */
59
+ export function hashOwnedBytes(bytes) {
60
+ return createHash('sha256').update(bytes).digest('hex');
61
+ }
62
+ /**
63
+ * The EOL-normalised form of the same bytes, for the OVERWRITE comparison only.
64
+ *
65
+ * Never for a removal. A removal is decided byte for byte, because a false
66
+ * match there precedes `rm`; an overwrite decided byte for byte instead makes
67
+ * a skills root kept under `core.autocrlf` report every brambo file as edited
68
+ * forever, and the product has no adopt, force or reclaim path out of that.
69
+ */
70
+ export function canonicalBytesHash(bytes) {
71
+ return hashOwnedText(Buffer.from(bytes).toString('utf8'));
72
+ }
73
+ /** Ownership keys must be one canonical spelling of a path, never two. */
74
+ export function resolveOwnedPath(filePath) {
75
+ return resolve(filePath);
76
+ }
77
+ /** win32 paths differ in drive-letter and directory casing between processes. */
78
+ export function sameOwnedPath(left, right) {
79
+ return process.platform === 'win32'
80
+ ? left.toLowerCase() === right.toLowerCase()
81
+ : left === right;
82
+ }
83
+ /**
84
+ * Whether `path` is strictly inside `root`. Both arguments must already be
85
+ * resolved — this predicate answers about paths, not about strings a caller
86
+ * hopes are paths.
87
+ *
88
+ * It lives beside {@link resolveOwnedPath} because it is the second half of the
89
+ * same rule: a path brambo acts on is CANONICALISED and then proven to be inside
90
+ * the location brambo owns. Every caller that skips either half has been a
91
+ * user-data defect — the removal path took raw ledger strings straight to `rm`,
92
+ * and a relative one resolved against the process working directory.
93
+ */
94
+ export function isUnderRoot(path, root) {
95
+ const rest = relative(root, path);
96
+ return rest !== '' && !rest.startsWith('..') && !rest.startsWith(sep + '..');
97
+ }
98
+ /**
99
+ * The right to replace the WHOLE ownership document without merging.
100
+ *
101
+ * A module-scope symbol rather than a convention: the only way to hold it is to
102
+ * import it, so "one caller" stops being a claim a text scan makes and becomes
103
+ * something the runtime enforces. `test/guard.test.ts` pins who imports it.
104
+ */
105
+ export const LEDGER_REPAIR_AUTHORITY = Symbol('brambo.projection.ledger.repair');
106
+ /**
107
+ * A malformed `ownedPaths` makes the WHOLE record invalid, and that direction is
108
+ * the point: a dropped record claims nothing, so brambo under-claims and removes
109
+ * nothing. Keeping a record whose path list is half-readable would hand the one
110
+ * operation that deletes a user's files an authority brambo cannot vouch for.
111
+ */
112
+ function isOwnedPathList(value) {
113
+ return (Array.isArray(value) &&
114
+ value.every((item) => isRecord(item) &&
115
+ typeof item['path'] === 'string' &&
116
+ item['path'] !== '' &&
117
+ typeof item['contentHash'] === 'string' &&
118
+ item['contentHash'] !== '' &&
119
+ (item['canonicalHash'] === undefined ||
120
+ (typeof item['canonicalHash'] === 'string' && item['canonicalHash'] !== ''))));
121
+ }
122
+ function isLedgerRecord(value) {
123
+ if (!isRecord(value))
124
+ return false;
125
+ if (!RECORD_FIELDS.every((field) => typeof value[field] === 'string' && value[field] !== '')) {
126
+ return false;
127
+ }
128
+ return value['ownedPaths'] === undefined || isOwnedPathList(value['ownedPaths']);
129
+ }
130
+ /**
131
+ * The hash brambo writes for a claim it can still ADDRESS but can no longer
132
+ * VOUCH for.
133
+ *
134
+ * Deliberately not hex. A real `contentHash` is `sha256` output, so this can
135
+ * never compare equal to one by accident — the never-matching property is
136
+ * structural rather than improbable. `isLedgerRecord` asks only for a non-empty
137
+ * string, and nothing anywhere validates the shape, so this survives a round
138
+ * trip and reads as `edited` forever: brambo knows which bytes the claim covers
139
+ * and admits it does not know what it wrote there.
140
+ */
141
+ export const UNVOUCHED_CONTENT_HASH = 'unreadable-after-repair';
142
+ function salvageOwnedPaths(value) {
143
+ if (!Array.isArray(value))
144
+ return undefined;
145
+ const salvaged = [];
146
+ for (const item of value) {
147
+ // The PATH is the claim; the hashes are only the vouching. An item whose
148
+ // path is gone names nothing and cannot be salvaged.
149
+ if (!isRecord(item) || typeof item['path'] !== 'string' || item['path'] === '')
150
+ continue;
151
+ const contentHash = item['contentHash'];
152
+ const canonicalHash = item['canonicalHash'];
153
+ salvaged.push({
154
+ path: item['path'],
155
+ contentHash: typeof contentHash === 'string' && contentHash !== '' ? contentHash : UNVOUCHED_CONTENT_HASH,
156
+ ...(typeof canonicalHash === 'string' && canonicalHash !== ''
157
+ ? { canonicalHash }
158
+ : contentHash === undefined
159
+ ? {}
160
+ : { canonicalHash: UNVOUCHED_CONTENT_HASH }),
161
+ });
162
+ }
163
+ return salvaged.length === 0 ? undefined : salvaged;
164
+ }
165
+ /**
166
+ * A record `isLedgerRecord` rejects, reduced to the smallest claim brambo can
167
+ * still act on — or `undefined` when nothing addressable survives.
168
+ *
169
+ * WHY THIS EXISTS. `repair` used to drop every record it could not read, and
170
+ * dropping the RECORD throws away far more than the broken FIELD: measured, a
171
+ * record whose only damage was its `contentHash` still carried all four identity
172
+ * fields, so brambo knew exactly which bytes it covered. Dropping it left `brambo
173
+ * doctor` reporting NOTHING, `brambo remediate adopt` refusing with exit 1, and
174
+ * `brambo remove` + `brambo init` leaving the entry in the user's config
175
+ * permanently — while `repair` printed that those entries "report as foreign
176
+ * collisions until they are adopted".
177
+ *
178
+ * Only `repair` uses this. An ordinary `read()` still drops a malformed record
179
+ * and warns, because salvaging on every read would silently promote damage into
180
+ * a claim nobody asked brambo to make.
181
+ */
182
+ function salvageRecord(value) {
183
+ if (!isRecord(value))
184
+ return undefined;
185
+ // `entryId` is part of `recordKey`, and the other three say WHERE. Without all
186
+ // four the claim cannot be addressed, updated or removed, so there is nothing
187
+ // to keep.
188
+ const identity = ['targetId', 'filePath', 'nativeLocation', 'entryId'];
189
+ if (!identity.every((field) => typeof value[field] === 'string' && value[field] !== '')) {
190
+ return undefined;
191
+ }
192
+ const owned = value['ownedPaths'];
193
+ let ownedPaths;
194
+ if (owned !== undefined) {
195
+ ownedPaths = salvageOwnedPaths(owned);
196
+ // For a MATERIALISATION record `ownedPaths` IS the claim: a record without
197
+ // it claims nothing, so keeping one would be a claim that authorises and
198
+ // protects nothing while looking like ownership.
199
+ if (ownedPaths === undefined)
200
+ return undefined;
201
+ }
202
+ const contentHash = value['contentHash'];
203
+ return {
204
+ targetId: value['targetId'],
205
+ filePath: value['filePath'],
206
+ nativeLocation: value['nativeLocation'],
207
+ entryId: value['entryId'],
208
+ contentHash: typeof contentHash === 'string' && contentHash !== '' ? contentHash : UNVOUCHED_CONTENT_HASH,
209
+ ...(ownedPaths === undefined ? {} : { ownedPaths }),
210
+ };
211
+ }
212
+ function recordKey(record) {
213
+ return `${record.targetId}\u0000${record.filePath}\u0000${record.entryId}`;
214
+ }
215
+ /**
216
+ * Stable on-disk order with a total ordering over the record key, so an
217
+ * unchanged ledger really is a byte-unchanged file. Later records win a
218
+ * duplicate key: a run's own output must override whatever it is replacing.
219
+ */
220
+ function normalizeRecords(records) {
221
+ const deduped = new Map();
222
+ for (const record of records)
223
+ deduped.set(recordKey(record), record);
224
+ return [...deduped.values()].sort((a, b) => {
225
+ const left = recordKey(a);
226
+ const right = recordKey(b);
227
+ return left < right ? -1 : left > right ? 1 : 0;
228
+ });
229
+ }
230
+ /**
231
+ * The exact bytes of the ledger document for a record set.
232
+ *
233
+ * Exported so a remediation can PREDICT the document it is about to write and
234
+ * report the byte delta before writing it, using the same serialisation the
235
+ * write itself performs. A second spelling here would let a preview report a
236
+ * size the act does not produce.
237
+ */
238
+ export function serialiseLedgerDocument(records) {
239
+ return JSON.stringify({ version: PROJECTION_LEDGER_VERSION, records: normalizeRecords(records) }, null, 2);
240
+ }
241
+ function detailOf(error) {
242
+ return error instanceof Error ? error.message : String(error);
243
+ }
244
+ /**
245
+ * Read-modify-write queues, keyed by resolved ledger path. Module-level on
246
+ * purpose: an instance-level queue serialises one ProjectionLedger object, and
247
+ * every caller that constructs its own — `initMachine` and `initProject` run
248
+ * concurrently, for instance — gets its own object over the SAME file.
249
+ *
250
+ * This is the INNER boundary and it covers one process. The outer one is the
251
+ * `<ledger>.lock` file taken in `#locked`, which covers two brambo PROCESSES —
252
+ * that gap used to lose 10 of 24 claims across three measured rounds, silently,
253
+ * with every writer exiting 0. The queue is kept rather than replaced: it is
254
+ * cheaper than a lockfile and it is exactly right for its own case, so the file
255
+ * lock only ever contends between processes.
256
+ */
257
+ const LEDGER_QUEUES = new Map();
258
+ /**
259
+ * The leaf lock's neutral codes, translated at this package's boundary (AD-7).
260
+ * `@skanl/brambo-lock` may not raise a projection code and this package may not
261
+ * publish a `BRAMBO_LOCK_*` one, so the mapping lives exactly here.
262
+ */
263
+ function asLedgerFailure(filePath, error) {
264
+ if (!(error instanceof BramboError))
265
+ return error;
266
+ if (error.code === BRAMBO_ERROR_CODES.lockContention) {
267
+ return new BramboError(BRAMBO_ERROR_CODES.projectionLedgerContention, `projection ledger '${filePath}' is held by another brambo process: ${error.message}`, { cause: error });
268
+ }
269
+ if (error.code === BRAMBO_ERROR_CODES.lockUnavailable) {
270
+ return new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${filePath}' could not be locked for writing: ${error.message}`, { cause: error });
271
+ }
272
+ return error;
273
+ }
274
+ export class ProjectionLedger {
275
+ filePath;
276
+ #queueKey;
277
+ #lockPath;
278
+ #lockTimeoutMs;
279
+ #onStaleLockBreak;
280
+ constructor(options = {}) {
281
+ this.filePath = options.filePath ?? join(options.homeDir ?? homedir(), '.brambo', LEDGER_FILE_NAME);
282
+ const resolved = resolveOwnedPath(this.filePath);
283
+ this.#queueKey = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
284
+ // Beside the document, like the registry store's. Derived from the RESOLVED
285
+ // path so two processes spelling the same ledger differently — a relative
286
+ // argv, a different drive-letter case on win32 — still contend for one file.
287
+ this.#lockPath = `${resolved}.lock`;
288
+ this.#lockTimeoutMs = options.lockTimeoutMs;
289
+ this.#onStaleLockBreak = options.onStaleLockBreak;
290
+ }
291
+ /** Never throws: the three states are what callers must distinguish. */
292
+ async read() {
293
+ let raw;
294
+ try {
295
+ raw = await readFile(this.filePath, 'utf8');
296
+ }
297
+ catch (error) {
298
+ if (error?.code === 'ENOENT') {
299
+ return { state: 'absent', records: [], salvaged: [], warnings: [] };
300
+ }
301
+ return this.#unreadable(`cannot be read: ${detailOf(error)}`);
302
+ }
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(raw);
306
+ }
307
+ catch {
308
+ // LOCATED, never quoted (`document-fault.ts`). This ledger holds paths and
309
+ // hashes rather than server arguments, so no leak was measured out of it —
310
+ // and it is brought under the rule anyway, because "no credential happens
311
+ // to sit inside V8's snippet window today" is not a property of the code.
312
+ return this.#unreadable(`is not valid JSON: ${strictFaultLocation(raw)}`);
313
+ }
314
+ if (!isRecord(parsed) || parsed['version'] !== PROJECTION_LEDGER_VERSION) {
315
+ return this.#unreadable(`declares version ${JSON.stringify(isRecord(parsed) ? parsed['version'] : undefined)} but this build reads version ${PROJECTION_LEDGER_VERSION}`);
316
+ }
317
+ const records = parsed['records'];
318
+ if (!Array.isArray(records))
319
+ return this.#unreadable('has no records array');
320
+ // One damaged record is not a damaged ledger: keeping the valid claims
321
+ // keeps brambo able to update and remove everything it still recognises.
322
+ const valid = records.filter(isLedgerRecord);
323
+ const dropped = records.length - valid.length;
324
+ // Computed here because this is the only place that holds the RAW records;
325
+ // `repair` is the sole consumer and every other caller sees `records` alone.
326
+ const salvaged = records
327
+ .filter((record) => !isLedgerRecord(record))
328
+ .map(salvageRecord)
329
+ .filter((record) => record !== undefined);
330
+ return {
331
+ state: 'readable',
332
+ records: valid,
333
+ salvaged,
334
+ warnings: dropped === 0
335
+ ? []
336
+ : [
337
+ {
338
+ code: BRAMBO_ERROR_CODES.projectionLedgerUnavailable,
339
+ detail: `projection ledger '${this.filePath}' has ${dropped} malformed record(s); those entries are no longer claimed by brambo`,
340
+ },
341
+ ],
342
+ };
343
+ }
344
+ /**
345
+ * Replaces the records this caller TOOK A POSITION ON inside one scope,
346
+ * keeping every other claim — including claims inside the same scope that
347
+ * landed after the caller's snapshot was taken.
348
+ *
349
+ * `examined` is the third parameter and it is required, because omission used
350
+ * to mean deletion and nobody could see it. The caller passes the entry ids it
351
+ * read out of its own ledger snapshot for this scope; `update` adds the ids it
352
+ * is being handed now. An id in neither set belongs to a writer this caller
353
+ * never saw, and survives untouched.
354
+ *
355
+ * WITHOUT IT, A CONCURRENT RUN ERASES CLAIMS IT DELIBERATELY LEFT ALONE.
356
+ * Measured on the binary: ten rounds of two concurrent `brambo init` over one
357
+ * home lost 20 of 40 claims, 20 of 20 processes exited 0, and no stderr line
358
+ * named foreign, skip, collision or ledger; the one-process control lost 0 of
359
+ * 40. A wider run lost 72 of 144 and left 16 vendor entries owned by nobody,
360
+ * after which `brambo doctor` exited 0 reporting `"drift": []` and a later
361
+ * `remove` + `init` could no longer take those entries back out — brambo had
362
+ * permanently lost the ability to undo bytes it wrote.
363
+ *
364
+ * The mechanism is a granularity mismatch, not a lock. Process B reads the
365
+ * ledger before A persists, so B's snapshot holds none of A's claims. B then
366
+ * reads the vendor file, which by now holds A's entries, and classifies them
367
+ * as foreign — CORRECTLY, since nothing B can see claims them — so they never
368
+ * reach `projected.records`. The merge decided per entry; the write then
369
+ * replaced the whole scope. B left A's bytes alone and erased A's claim in the
370
+ * same breath. Keying the drop to what the caller examined is what makes the
371
+ * two agree.
372
+ *
373
+ * Deliberately NOT one write per entry: that shape preserves the same claims,
374
+ * but measured 99 writes per init instead of 6 (p50 61.5 ms each, 69x the
375
+ * ledger phase) and opened a contention cliff — 17 of 48 scopes failing at
376
+ * eight concurrent inits against 0 of 48 today. This keeps one write per scope.
377
+ *
378
+ * Still serialised against concurrent calls on this instance, and still NOT a
379
+ * fix for the orphan window above it: `engine.ts` writes the vendor file
380
+ * before it reaches this method, so a persist that throws — or a crash in
381
+ * between — leaves bytes no record claims. That is a different defect with a
382
+ * different fix, and it is open.
383
+ */
384
+ async update(scope, records, examined) {
385
+ await this.#queued(async () => {
386
+ const current = await this.read();
387
+ if (current.state === 'unreadable') {
388
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' became unreadable; refusing to overwrite it and orphan every claim it holds`);
389
+ }
390
+ const surrendered = new Set([...examined, ...records.map((record) => record.entryId)]);
391
+ const kept = current.records.filter((record) => record.targetId !== scope.targetId ||
392
+ !sameOwnedPath(record.filePath, scope.filePath) ||
393
+ !surrendered.has(record.entryId));
394
+ await this.#persist([...kept, ...records]);
395
+ });
396
+ }
397
+ /**
398
+ * Replaces ONE entry's record inside one scope, reading the current document
399
+ * INSIDE the queue.
400
+ *
401
+ * The granularity is the point. A caller that read the ledger, decided, and
402
+ * then handed `update` a whole replacement set for the scope would resurrect
403
+ * every claim another writer legitimately dropped in between — brambo would
404
+ * then claim a path it does not own, which on the materialisation path is a
405
+ * delete authority. Only the named entry moves here; every sibling claim is
406
+ * whatever the document says at the moment of the write.
407
+ *
408
+ * `record === undefined` drops the entry instead of replacing it.
409
+ */
410
+ async updateEntry(scope, entryId, record) {
411
+ await this.#queued(async () => {
412
+ const current = await this.read();
413
+ if (current.state === 'unreadable') {
414
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' became unreadable; refusing to overwrite it and orphan every claim it holds`);
415
+ }
416
+ const kept = current.records.filter((candidate) => candidate.entryId !== entryId ||
417
+ candidate.targetId !== scope.targetId ||
418
+ !sameOwnedPath(resolveOwnedPath(candidate.filePath), scope.filePath));
419
+ await this.#persist(record === undefined ? kept : [...kept, record]);
420
+ });
421
+ }
422
+ /**
423
+ * Replaces the WHOLE document with whatever `select` returns for the document
424
+ * as it is INSIDE the queue.
425
+ *
426
+ * This is the one write that does not merge, and it exists for exactly one
427
+ * caller: the user-named `repair` remediation, which is how a ledger holding
428
+ * records brambo cannot read stops being a state with no exit. Nothing else may
429
+ * use it — `update` is the merging write every projection performs, and its
430
+ * refusal to overwrite an unreadable ledger is a load-bearing guarantee that
431
+ * this method deliberately does not have. `test/guard.test.ts` pins the caller
432
+ * list, because a second one would silently reintroduce the orphan-every-claim
433
+ * failure Story 2.8 declared terminal.
434
+ *
435
+ * `select` runs INSIDE the queue and is handed the read the write will be
436
+ * based on. A caller that read the document itself and passed the result would
437
+ * destroy every claim written in between — with no merge to save it, which is
438
+ * exactly what makes this method the dangerous one. It may throw to abort the
439
+ * write, which is how `repair` refuses when the document moved under it.
440
+ */
441
+ async rewriteAll(authority, select) {
442
+ // A CAPABILITY, not a spelling check. The static caller list in
443
+ // `test/guard.test.ts` catches the honest second caller and was evaded by a
444
+ // reviewer with `ledger['rewrite' + 'All']([])` — a scan cannot see a name
445
+ // assembled at runtime. Holding the sentinel can only come from importing
446
+ // it, which both the symbol scan and the package's import graph do see, so
447
+ // the obfuscated route now fails at run time instead of silently working.
448
+ if (authority !== LEDGER_REPAIR_AUTHORITY) {
449
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}': rewriting the whole document is reserved for the repair remediation`);
450
+ }
451
+ await this.#queued(async () => {
452
+ await this.#persist(select(await this.read()));
453
+ });
454
+ }
455
+ /** The read-modify-write queue, keyed by ledger path and shared by instances. */
456
+ async #queued(work) {
457
+ const run = (LEDGER_QUEUES.get(this.#queueKey) ?? Promise.resolve()).then(() => this.#locked(work));
458
+ // The chain must survive a rejection, or one failed target would deadlock
459
+ // every later one.
460
+ const settled = run.catch(() => undefined);
461
+ LEDGER_QUEUES.set(this.#queueKey, settled);
462
+ try {
463
+ await run;
464
+ }
465
+ finally {
466
+ // Drop the entry once this is the tail, so a long-lived process does not
467
+ // accumulate one resolved promise per ledger path it ever touched.
468
+ if (LEDGER_QUEUES.get(this.#queueKey) === settled)
469
+ LEDGER_QUEUES.delete(this.#queueKey);
470
+ }
471
+ }
472
+ /**
473
+ * The OUTER boundary: THIS METHOD's read-modify-write happens while this
474
+ * process holds `<ledger>.lock`, so two calls into `update`, `updateEntry` or
475
+ * `replace` cannot interleave their own read and write. Merging alone never
476
+ * closed that window — the read is what interleaves, and only mutual exclusion
477
+ * over the read AND the write can close it.
478
+ *
479
+ * WHERE THE BOUNDARY ENDS, and it is not where this comment used to imply. A
480
+ * caller that reads the document ITSELF, decides, and then hands `update` a
481
+ * whole replacement set is outside this lock for the part that matters, and
482
+ * `updateEntry`'s own comment names the consequence: it "would resurrect every
483
+ * claim another writer legitimately dropped in between — brambo would then
484
+ * claim a path it does not own, which on the materialisation path is a delete
485
+ * authority."
486
+ *
487
+ * `runProjection` is that caller and its window is still open: `engine.ts`
488
+ * takes `await store.read()` before the target loop and decides every target
489
+ * against that one snapshot. Measured while the window was also AUTHORITY,
490
+ * ten rounds of two concurrent `brambo init` against one home lost 76 of 120
491
+ * claims with zero bytes on stderr, against a one-process control that lost 0
492
+ * of 120.
493
+ *
494
+ * What closed that is NOT this lock — it never could be, because the decision
495
+ * happens outside it. `update` now takes the entry ids the caller EXAMINED and
496
+ * drops only those, so a stale snapshot can no longer speak for entries it
497
+ * never saw; the same harness reports 0 of 120 after. The window remains, and
498
+ * is now merely stale rather than destructive: two runs can still reach
499
+ * different conclusions about the same entry and resolve last-writer-wins.
500
+ *
501
+ * `finally { release }` mirrors `RegistryStore.#persist` exactly: the lock is
502
+ * given back whether the write succeeded or threw, and a release failure is
503
+ * itself coded rather than swallowed.
504
+ */
505
+ async #locked(work) {
506
+ // The lockfile is created inside this directory; on a fresh machine nothing
507
+ // has created ~/.brambo yet, and an exclusive create into a missing directory
508
+ // is an ENOENT the lock would report as an unavailable medium.
509
+ try {
510
+ await mkdir(dirname(this.filePath), { recursive: true });
511
+ }
512
+ catch (error) {
513
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' could not be written: ${detailOf(error)}`, { cause: error });
514
+ }
515
+ const lock = await acquireLock(this.#lockPath, {
516
+ timeoutMs: this.#lockTimeoutMs,
517
+ onStaleBreak: this.#onStaleLockBreak,
518
+ }).catch((error) => {
519
+ throw asLedgerFailure(this.filePath, error);
520
+ });
521
+ try {
522
+ await work();
523
+ }
524
+ finally {
525
+ await lock.release().catch((error) => {
526
+ throw asLedgerFailure(this.filePath, error);
527
+ });
528
+ }
529
+ }
530
+ async #persist(records) {
531
+ try {
532
+ await atomicWriteText(this.filePath, serialiseLedgerDocument(records));
533
+ }
534
+ catch (error) {
535
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionLedgerUnavailable, `projection ledger '${this.filePath}' could not be written: ${detailOf(error)}`, { cause: error });
536
+ }
537
+ }
538
+ #unreadable(reason) {
539
+ return {
540
+ state: 'unreadable',
541
+ records: [],
542
+ // Nothing parsed, so nothing can be addressed, let alone salvaged.
543
+ salvaged: [],
544
+ warnings: [
545
+ {
546
+ code: BRAMBO_ERROR_CODES.projectionLedgerUnavailable,
547
+ detail: `projection ledger '${this.filePath}' ${reason}; treating it as if brambo had written nothing, and leaving the file untouched`,
548
+ },
549
+ ],
550
+ };
551
+ }
552
+ }
@@ -0,0 +1,55 @@
1
+ import type { ProjectionClaim, ProjectionLedgerRecord, ProjectionMaterialiseTarget, ProjectionResult, RegistryEntriesByKind } from '@skanl/brambo-contracts';
2
+ export interface MaterialiseOutcome {
3
+ readonly result: ProjectionResult;
4
+ readonly records: readonly ProjectionLedgerRecord[];
5
+ }
6
+ /**
7
+ * The ledger record that would claim the tree currently at one entry's location
8
+ * — the materialisation half of `adopt`, and the exit from every reported state
9
+ * a skills root can be in: a `foreign-collision` (brambo's own tree left
10
+ * unclaimed by a crash included), an `edited` tree, and a tree that is only
11
+ * PARTLY there.
12
+ *
13
+ * IT LIVES HERE, BESIDE THE REMOVAL RULE, ON PURPOSE. A record this returns is
14
+ * an authority to DELETE on some later run, so every clause of that rule is
15
+ * applied while the record is built rather than trusted afterwards: the paths
16
+ * come from the target's own plan (never from a directory listing, so a file the
17
+ * user put beside brambo's is not swept into the claim and cannot later be
18
+ * removed); each is resolved and containment-checked against the root; a link
19
+ * anywhere between the root and the file disqualifies it; and a path any OTHER
20
+ * record already claims is refused rather than duplicated.
21
+ *
22
+ * A PARTIALLY PRESENT TREE IS CLAIMED AS THE SUBSET THAT IS THERE, and that is
23
+ * the correction that gives that state an exit at all. Claiming the whole
24
+ * planned set would write a record reading `edited` on the very next run — the
25
+ * state adoption exists to leave — so brambo claims exactly the files that exist,
26
+ * the record reads `intact`, and the ordinary run writes the missing ones back.
27
+ * Refusing instead (the first shipped shape) left three separate routes into a
28
+ * tree with no exit but `rm -rf`: `release` on an edited tree, a crash inside
29
+ * `land()` between two file writes, and a user deleting one file from a skill.
30
+ *
31
+ * ponytail: when a record claims MORE paths than the plan wants — a file that
32
+ * left the source — adoption claims the planned subset and lets that path go
33
+ * unclaimed, so the ordinary run stops being authorised to take it back and it
34
+ * stays inside brambo's tree. Under-claiming, which is the safe direction and the
35
+ * one this whole file errs towards; the alternative is claiming the union, which
36
+ * widens what an explicit user action makes deletable. Upgrade path: claim the
37
+ * union once a case exists where the leftover file matters.
38
+ *
39
+ * WHEN THE ENTRY HAS LEFT THE REGISTRY the plan holds nothing for it, and the
40
+ * ledger record is the fallback authority — that is the shape reported as
41
+ * `edited` on the removal path, whose only other exit was `release`, which drops
42
+ * the claim and leaves the tree on disk forever. Adoption there means the next
43
+ * ordinary run REMOVES the tree, which is why the claim carries `removedNext`
44
+ * and the caller has to say so before writing it.
45
+ */
46
+ export declare function claimMaterialised(target: ProjectionMaterialiseTarget, entries: RegistryEntriesByKind, claimed: readonly ProjectionLedgerRecord[], entryId: string): Promise<ProjectionClaim>;
47
+ /**
48
+ * Runs one materialisation target: plan, classify against the ledger, then —
49
+ * under `apply` — land exactly the difference.
50
+ *
51
+ * Under inspection NOTHING touches the filesystem except reads, and
52
+ * `result.written` reads as "these paths WOULD change", which is the one field
53
+ * whose sentence the mode alters.
54
+ */
55
+ export declare function materialiseTarget(target: ProjectionMaterialiseTarget, entries: RegistryEntriesByKind, claimed: readonly ProjectionLedgerRecord[], apply: boolean): Promise<MaterialiseOutcome>;