amalgm 0.1.262 → 0.1.264

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.
@@ -15,8 +15,10 @@
15
15
  * (its own future head and rail), never an identity boundary. Every
16
16
  * top-level enrolled ground — a workspace, a repository, a single file,
17
17
  * or an explicitly added link — is such a tree, with one evidence LENS
18
- * over its observer root (nested repo territories live INSIDE their
19
- * tree as repo.git records; they are never trees of their own). A
18
+ * over its observer root. A Git territory noticed inside another watched
19
+ * tree is observer-only until an explicit registration admits it; that
20
+ * selected repo is then a tree of its own even if the outer watcher covers
21
+ * its local address. A
20
22
  * TREE'S IDENTITY IS ITS UUID, PERMANENT; its root path is only the
21
23
  * current local address — lenses are keyed by the observer's rootId
22
24
  * (the witness), the observer rescues moved ground by physical
@@ -66,6 +68,7 @@ const { refusal } = require('./refusal');
66
68
  const { createRepoFollowers } = require('./repo-followers');
67
69
  const { createRepoStates } = require('./repo-states');
68
70
  const { registerTree } = require('./tree');
71
+ const { gitDirTruth } = require('../observer/scan');
69
72
 
70
73
  // Events that can change what the recorded edges reach: a new or
71
74
  // retargeted pointer, repo ground moving under its card (a worktree
@@ -112,12 +115,27 @@ function createRegistration(options) {
112
115
  onSettled: reconcileSettledRepo,
113
116
  });
114
117
 
115
- /** Tree roots are the non-enclosed observer roots: a nested repo
116
- * territory belongs to the tree that encloses it. */
118
+ /**
119
+ * A semantic tree is defined by its parentless identity record, never by
120
+ * directory containment. `amalgm add` may materialize a cloud repo inside
121
+ * the local `workspaces/` directory, and `amalgm register` may select a
122
+ * repo already sitting there: both remain their own cloud root. The outer
123
+ * workspace watcher supplies coverage, but it does not own the repo's
124
+ * Card + Checkpoint or identity tree.
125
+ *
126
+ * An unbound observer root is a tree candidate only when no other observer
127
+ * root physically encloses it. That preserves the admission rule for a
128
+ * newly discovered nested repo: Watch can notice it, but only explicit
129
+ * registration can give it a cloud identity and state.
130
+ */
117
131
  function treeRoots() {
118
132
  const roots = observer.listRoots();
119
- return roots.filter((root) => !roots.some((other) =>
120
- other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`)));
133
+ return roots.filter((root) => {
134
+ const record = rootRecordOf(root.rootId);
135
+ if (record) return record.parentUUID === null;
136
+ return !roots.some((other) => other.rootId !== root.rootId
137
+ && root.path.startsWith(`${other.path}/`));
138
+ });
121
139
  }
122
140
 
123
141
  function treeRootCovering(absPath) {
@@ -359,6 +377,43 @@ function createRegistration(options) {
359
377
  * genuinely live. A filesystem watcher that could not open is not a
360
378
  * warning; it means the requested Register → Watch operation is incomplete
361
379
  * and the caller must hear that. */
380
+ /**
381
+ * Watcher FIRST: prove the doorbell can be born before any identity
382
+ * commits. The birth is held (observer.watchNow) — the watcher exists
383
+ * and is judged, but concludes nothing until start() releases it. One
384
+ * retry absorbs a transient failure; a genuine refusal names its reason,
385
+ * because ENOSPC is a kernel watch budget the caller can actually fix.
386
+ */
387
+ function requireWatchBirth(root) {
388
+ const unhealthy = (row) => !row.watching || row.degraded
389
+ || (row.addressRequired && (!row.addressWatching || row.addressDegraded));
390
+ let health = observer.watchNow(root.rootId);
391
+ if (unhealthy(health)) health = observer.watchNow(root.rootId);
392
+ if (!unhealthy(health)) return;
393
+ const reason = health.degradedReason ? ` (${health.degradedReason})` : '';
394
+ const remedy = health.degradedReason === 'ENOSPC'
395
+ ? ' — the OS file-watch budget is exhausted; raise fs.inotify.max_user_watches or unregister unused ground, then retry'
396
+ : '';
397
+ throw refusal(`refusing to register ${root.path}: its watcher could not be born${reason}${remedy}; nothing was registered`);
398
+ }
399
+
400
+ /**
401
+ * The commit's last word, spoken INSIDE the identity transaction: the
402
+ * watchers proven before the walk are still live as the rows land. A
403
+ * watcher that died mid-walk rolls the whole registration — records and
404
+ * cloud journal together — back to nothing. This is an environment
405
+ * fault, never the caller's path.
406
+ */
407
+ function assertWatchedThroughCommit(rootPath) {
408
+ const dead = observer.status().filter((row) => (row.path === rootPath
409
+ || row.path.startsWith(`${rootPath}/`))
410
+ && (!row.watching || row.degraded));
411
+ if (dead.length > 0) {
412
+ throw new Error(`watcher(s) died during registration of ${rootPath}: `
413
+ + `${dead.map((row) => row.path).join(', ')} — the registration was rolled back`);
414
+ }
415
+ }
416
+
362
417
  function requireLiveWatchers(rootPath) {
363
418
  const belongs = (root) => root.path === rootPath || root.path.startsWith(`${rootPath}/`);
364
419
  let unavailable = observer.status().filter((root) => belongs(root)
@@ -376,6 +431,75 @@ function createRegistration(options) {
376
431
  }
377
432
  }
378
433
 
434
+ /**
435
+ * A repository selected inside already-watched ground is still a new
436
+ * registered TREE, not a child record of the outer workspace. The outer
437
+ * watcher already knows its territory (or this enrollment establishes that
438
+ * same territory), so the only missing fact is the repo's atomic
439
+ * Card+Checkpoint-backed identity birth.
440
+ */
441
+ function registerRepositoryInsideCoveredGround(ground, { identityAt = null, suppressJournal = false } = {}) {
442
+ let root = observer.listRoots().find((candidate) => candidate.path === ground) || null;
443
+ if (!root) root = observer.enrollRoot(ground, { scan: false, watch: false });
444
+ if (root.kind !== 'repo') {
445
+ throw new Error(`expected Git repository ground at ${ground}, found ${root.kind}`);
446
+ }
447
+
448
+ const bound = memory.boundTo(root.rootId);
449
+ if (bound !== undefined) {
450
+ const record = registry.record(bound);
451
+ if (record?.type !== 'repo.git') {
452
+ throw new Error(`repository watcher ${ground} is bound to non-repository entity ${bound}`);
453
+ }
454
+ if (record.parentUUID !== null) {
455
+ // This is the explicit nested-boundary case created as part of an
456
+ // already-registered enclosing repo. It already has a complete
457
+ // Card+Checkpoint state and cannot be duplicated at one address.
458
+ requireLiveWatchers(ground);
459
+ return { rootPath: ground, record };
460
+ }
461
+ requireLiveWatchers(ground);
462
+ return { rootPath: ground, record };
463
+ }
464
+
465
+ try {
466
+ requireWatchBirth(root);
467
+ assertWitnessed({ observer, registry, memory });
468
+ const register = () => registry.transaction(() => {
469
+ registerTree({
470
+ root,
471
+ registry,
472
+ memory,
473
+ shouldEnroll: observer.shouldEnroll,
474
+ captureRepo,
475
+ repoStates,
476
+ repoStateAt,
477
+ identityAt,
478
+ });
479
+ assertWatchedThroughCommit(root.path);
480
+ });
481
+ if (suppressJournal) registry.withoutJournal(register);
482
+ else register();
483
+ } catch (error) {
484
+ // A root that existed only because the outer watcher discovered its
485
+ // territory must remain coverage after a refused explicit admission;
486
+ // do not remove it (the parent still owns the OS watcher). A root this
487
+ // call itself enrolled has no durable identity and can safely retire.
488
+ throw error;
489
+ }
490
+
491
+ const startWatch = () => {
492
+ observer.start(root.rootId);
493
+ ensureTrees();
494
+ reconcileInitialRepoWorktrees(root.path);
495
+ flushIfDirty();
496
+ requireLiveWatchers(root.path);
497
+ };
498
+ if (suppressJournal) registry.withoutJournal(startWatch);
499
+ else startWatch();
500
+ return { rootPath: root.path, record: rootRecordOf(root.rootId) };
501
+ }
502
+
379
503
  /** Real ground -> the UUID of the entity that owns it, across trees. */
380
504
  function resolveGround(real) {
381
505
  const home = treeRootCovering(real);
@@ -505,6 +629,23 @@ function createRegistration(options) {
505
629
  }
506
630
  const covering = treeRootCovering(ground);
507
631
  if (covering) {
632
+ // A repository has a different state adapter from the workspace that
633
+ // happens to contain its local address. Its observer territory may
634
+ // already exist, but it is not registered until this explicit command
635
+ // creates the parentless repo tree with its initial Card + Checkpoint.
636
+ // The same branch is used by cloud `add`: materialized repos commonly
637
+ // land under the default `workspaces/` directory while retaining their
638
+ // cloud root UUID and parentless graph position.
639
+ const isRepository = fs.lstatSync(ground).isDirectory() && gitDirTruth(ground) === 'present';
640
+ if (isRepository) {
641
+ const exact = observer.listRoots().find((root) => root.path === ground);
642
+ const known = exact && memory.boundTo(exact.rootId) !== undefined
643
+ ? registry.record(memory.boundTo(exact.rootId))
644
+ : null;
645
+ if (!known || known.type !== 'repo.git' || known.parentUUID === null) {
646
+ return registerRepositoryInsideCoveredGround(ground, { identityAt, suppressJournal });
647
+ }
648
+ }
508
649
  // This address is already registered ground. Its prior Register →
509
650
  // Watch transition remains the proof of its identity; repeating a
510
651
  // harmless "ensure the safe home exists" request must not rescan every
@@ -525,24 +666,33 @@ function createRegistration(options) {
525
666
  }
526
667
  const root = observer.enrollRoot(ground, { scan: false, watch: false });
527
668
  try {
669
+ // WATCHER FIRST. Registration is synchronous, never optimistic: the
670
+ // doorbell is born and proven (held — concluding nothing) before one
671
+ // identity row exists, and the commit's own last word re-checks it.
672
+ // A record and its cloud journal land together inside registerTree's
673
+ // transaction, so the outcome is registered-and-watched, or nothing.
674
+ requireWatchBirth(root);
528
675
  assertWitnessed({ observer, registry, memory });
529
- const register = () => registerTree({
530
- root,
531
- registry,
532
- memory,
533
- shouldEnroll: observer.shouldEnroll,
534
- captureRepo,
535
- repoStates,
536
- repoStateAt,
537
- identityAt,
676
+ const register = () => registry.transaction(() => {
677
+ registerTree({
678
+ root,
679
+ registry,
680
+ memory,
681
+ shouldEnroll: observer.shouldEnroll,
682
+ captureRepo,
683
+ repoStates,
684
+ repoStateAt,
685
+ identityAt,
686
+ });
687
+ assertWatchedThroughCommit(root.path);
538
688
  });
539
689
  if (suppressJournal) registry.withoutJournal(register);
540
690
  else register();
541
691
  } catch (error) {
542
692
  // The observer root is only an address until structural registration
543
- // commits. Do not leave an unregistered address behind after a refused
544
- // or failed walk; a committed tree, by contrast, is durable truth and
545
- // is never rolled back by a later Watch/Detect fault.
693
+ // commits — and its held watchers are only handles. A refused or
694
+ // failed walk leaves NOTHING behind: removeRoot closes the doorbells
695
+ // and the transaction above rolled every row and journal entry back.
546
696
  observer.removeRoot(root.rootId);
547
697
  throw error;
548
698
  }
@@ -35,6 +35,24 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
35
35
  const followers = new Map(); // observer rootId -> { path, uuid, follower }
36
36
  let stopped = false;
37
37
 
38
+ /**
39
+ * A scoped capture reuses an unsuspected untracked entry WITHOUT its
40
+ * bytes. The persisted state must stay whole — transport cargo packs
41
+ * untracked content straight from it — so the bytes are carried forward
42
+ * from the previous settled state: a memory copy, never a disk read.
43
+ * The transient `reused` flag never persists.
44
+ */
45
+ function hydrateReusedUntracked(next, previous) {
46
+ const entries = next?.checkpoint?.untracked;
47
+ if (!entries?.some((file) => file.reused)) return;
48
+ const prior = new Map((previous?.checkpoint?.untracked ?? []).map((file) => [file.path, file]));
49
+ next.checkpoint.untracked = entries.map((file) => {
50
+ if (!file.reused) return file;
51
+ const { reused, ...kept } = file;
52
+ return { ...kept, content: prior.get(file.path)?.content ?? null };
53
+ });
54
+ }
55
+
38
56
  function close(rootId) {
39
57
  const entry = followers.get(rootId);
40
58
  if (!entry) return;
@@ -74,14 +92,32 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
74
92
  // The service can turn a full capture into immutable cloud cargo
75
93
  // before the one stateId attestation commits. Local-only callers
76
94
  // retain the lightweight bundle-free capture used historically.
77
- captureFn: async (repoPath) => {
78
- const captured = await worker.capture(repoPath, { withBundle: typeof prepareRepo === 'function' });
79
- return typeof prepareRepo === 'function' && !captured?.busy ? prepareRepo(captured) : captured;
95
+ // The repo UUID rides along so the transport packs the current
96
+ // worktree identity map from the registry.
97
+ captureFn: async (repoPath, { suspects } = {}) => {
98
+ const withBundle = typeof prepareRepo === 'function';
99
+ // The warm checkpoint: the last settled state's untracked entries
100
+ // ((path, mode, sha256) — content is never persisted) let a scoped
101
+ // capture reuse every unsuspected file without opening it. Cloud
102
+ // cargo captures (withBundle) need content and read everything;
103
+ // capture() enforces that side of the contract itself.
104
+ const previous = withBundle ? null : repoStates.get(record.uuid);
105
+ const captured = await worker.capture(repoPath, {
106
+ withBundle,
107
+ suspects,
108
+ previousUntracked: previous?.checkpoint?.untracked?.map(
109
+ ({ path: rel, mode, sha256 }) => ({ path: rel, mode, sha256 }),
110
+ ) ?? null,
111
+ });
112
+ return withBundle && !captured?.busy
113
+ ? prepareRepo(captured, { entityUuid: record.uuid })
114
+ : captured;
80
115
  },
81
116
  emit: (next) => {
82
117
  // ONE settled Card + Checkpoint state is ONE registry transition on
83
118
  // the repo entity. Raw filesystem events never write Git state.
84
119
  const previous = repoStates.get(record.uuid);
120
+ hydrateReusedUntracked(next, previous);
85
121
  registry.transaction(() => {
86
122
  repoStates.put(record.uuid, next);
87
123
  registry.attest(record.uuid, { type: 'repo.git', payloadVersion: next.stateId });
@@ -115,7 +151,10 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
115
151
  sync();
116
152
  return;
117
153
  }
118
- if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring();
154
+ // The ding's hint (the named path, rebased to the repo's ground, or
155
+ // null for "anywhere") rides into the follower's scope: it is what
156
+ // lets the next capture patch one file instead of re-reading the world.
157
+ if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring(event.hint ?? null);
119
158
  }
120
159
 
121
160
  function stop() {
@@ -28,13 +28,18 @@ function decodeBytes(encoded) {
28
28
  /** The durable local state deliberately omits a Git bundle. Bundles are
29
29
  * transport cargo: keeping them in identity.db would turn a small registry
30
30
  * into a second, unbounded object store. The immutable content store owns
31
- * the bundle bytes; SQLite retains only the content head that names them. */
32
- function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null } = {}) {
31
+ * the bundle bytes; SQLite retains only the content head that names them.
32
+ * The identity map is transport cargo too: it names the repo's enrolled
33
+ * worktree entities so a receiving machine binds the SAME child UUIDs
34
+ * instead of minting competitors — derived from the registry whenever a
35
+ * transport is packed, never persisted beside the local state. */
36
+ function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null, identity = null } = {}) {
33
37
  return JSON.stringify({
34
38
  stateId: state.stateId,
35
39
  cardId: state.cardId,
36
40
  checkpointId: state.checkpointId,
37
41
  transportVersion,
42
+ ...(identity ? { identity } : {}),
38
43
  card: {
39
44
  ...state.card,
40
45
  bundle: includeBundle && state.card.bundle ? {
@@ -59,6 +64,7 @@ function unpack(text) {
59
64
  return {
60
65
  ...state,
61
66
  transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
67
+ identity: Array.isArray(state.identity) ? state.identity : null,
62
68
  card: {
63
69
  ...state.card,
64
70
  bundle: state.card.bundle && {
@@ -81,15 +87,19 @@ function unpack(text) {
81
87
  /** A Repocard transport document contains its bundle and checkpoint bytes,
82
88
  * but never names itself: its SHA-256 is the immutable cloud content head.
83
89
  * The semantic `stateId` remains on the repo entity, so bundle packing can
84
- * vary without changing repository identity. */
85
- function transportBytes(state) {
86
- return Buffer.from(pack(state, { includeBundle: true, transportVersion: null }), 'utf8');
90
+ * vary without changing repository identity. `identity` is the enrolled
91
+ * worktree's path → { uuid, type } entries: repo children never travel as
92
+ * individual cloud records, so their permanent UUIDs ride here — inside
93
+ * the one state parcel — and `amalgm add` binds them from this map. */
94
+ function transportBytes(state, identity = null) {
95
+ return Buffer.from(pack(state, { includeBundle: true, transportVersion: null, identity }), 'utf8');
87
96
  }
88
97
 
89
98
  function localState(state, transportVersion) {
90
99
  return {
91
100
  ...state,
92
101
  transportVersion,
102
+ identity: null,
93
103
  card: { ...state.card, bundle: null },
94
104
  };
95
105
  }
@@ -52,6 +52,7 @@ const {
52
52
  migrateLegacyToolboxCatalog,
53
53
  } = require('../toolbox/artifacts');
54
54
  const { createObserver } = require('../observer');
55
+ const { gitDirTruth } = require('../observer/scan');
55
56
  const { shouldEnroll } = require('../workspace/tree/inclusion');
56
57
  const { classifyFile } = require('./classify');
57
58
  const { createEntityCloud } = require('./entity-cloud');
@@ -106,13 +107,37 @@ function createRegistrationService({
106
107
  const repoTransportHeads = new Map(); // semantic stateId -> immutable content hash
107
108
  const importedRepoStates = new Map(); // cloud UUID -> verified bundle-free state
108
109
 
110
+ /** The identity map a settled repo state travels with: every enrolled
111
+ * record beneath the repo boundary (stopping at nested repo boundaries,
112
+ * which pack their own maps). Repo children never journal their own
113
+ * cloud mutations, so this map — inside the one transport parcel — is
114
+ * the only way their permanent UUIDs reach another machine. */
115
+ function worktreeIdentity(repoUuid) {
116
+ const registry = boundary.current?.registry;
117
+ if (!registry) return null;
118
+ const entries = [];
119
+ const visit = (parentUuid, base) => {
120
+ for (const child of registry.children(parentUuid)) {
121
+ if (child.status !== 'active') continue;
122
+ const childPath = base ? `${base}/${child.name}` : child.name;
123
+ entries.push({ path: childPath, uuid: child.uuid, type: child.type });
124
+ if (child.type === 'folder') visit(child.uuid, childPath);
125
+ }
126
+ };
127
+ visit(repoUuid, '');
128
+ return entries;
129
+ }
130
+
109
131
  /** Git transport is immutable content, not identity-database cargo. The
110
132
  * semantic repo stateId remains the entity head; this prepares the exact
111
133
  * Card + Checkpoint + bundle payload and leaves only its content hash in
112
- * SQLite so a large clone cannot bloat the device registry. */
113
- function prepareRepo(captured) {
134
+ * SQLite so a large clone cannot bloat the device registry. Registration
135
+ * supplies the freshly assigned identity entries; a settled follower
136
+ * names its repo UUID instead and the map is read from the registry. */
137
+ function prepareRepo(captured, { identity = null, entityUuid = null } = {}) {
114
138
  if (captured?.busy) return captured;
115
- const artifact = entityContent.captureBytes({ bytes: transportBytes(captured) });
139
+ const transportIdentity = identity ?? (entityUuid ? worktreeIdentity(entityUuid) : null);
140
+ const artifact = entityContent.captureBytes({ bytes: transportBytes(captured, transportIdentity) });
116
141
  repoTransportHeads.set(captured.stateId, artifact.contentHash);
117
142
  return localState(captured, artifact.contentHash);
118
143
  }
@@ -148,7 +173,7 @@ function createRegistrationService({
148
173
  db: identityDb,
149
174
  sentinelPath: path.join(dir, 'identity.epoch'),
150
175
  journal: entityCloud.journal,
151
- captureRepo: (repoPath) => prepareRepo(capture(repoPath, { withBundle: true })),
176
+ captureRepo: (repoPath, options) => prepareRepo(capture(repoPath, { withBundle: true }), options),
152
177
  prepareRepo,
153
178
  repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
154
179
  }),
@@ -158,10 +183,12 @@ function createRegistrationService({
158
183
 
159
184
  let closed = false;
160
185
 
161
- /** Count only the same eligible shape that registration will see. This is
186
+ /** Count only the records that will actually TRAVEL to the cloud. This is
162
187
  * not a second registration walker: it is a bounded routing decision that
163
188
  * lets a large tree use one atomic cloud graph replacement instead of
164
- * thousands of individually compacted mutations. */
189
+ * thousands of individually compacted mutations. A repository counts as
190
+ * ONE — its children ride the transport parcel, never the cloud graph —
191
+ * so registering even a 40,000-file repo is a single ordinary mutation. */
165
192
  function estimatedRegistrationRecords(targetPath, limit = ATOMIC_REGISTRATION_THRESHOLD) {
166
193
  let total = 0;
167
194
  const visit = (candidate) => {
@@ -170,6 +197,7 @@ function createRegistrationService({
170
197
  let stat;
171
198
  try { stat = fs.lstatSync(candidate); } catch { return; }
172
199
  if (!stat.isDirectory() || stat.isSymbolicLink()) return;
200
+ if (gitDirTruth(candidate) === 'present') return;
173
201
  let entries;
174
202
  try { entries = fs.readdirSync(candidate, { withFileTypes: true }); } catch { return; }
175
203
  for (const entry of entries) {
@@ -482,15 +510,50 @@ function createRegistrationService({
482
510
  group.push(record);
483
511
  children.set(record.parentUUID, group);
484
512
  }
513
+ // Unpacking a transport decodes the whole bundle from base64; one unpack
514
+ // per repository, not one per caller.
515
+ const repoStatesByUuid = new Map();
485
516
  function repoState(record) {
517
+ const cached = repoStatesByUuid.get(record.uuid);
518
+ if (cached) return cached;
486
519
  if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
487
520
  const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
488
521
  if (state.stateId !== record.payloadVersion) {
489
522
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
490
523
  }
524
+ repoStatesByUuid.set(record.uuid, state);
491
525
  return state;
492
526
  }
493
527
 
528
+ /** The identity a repo's transport carries for one worktree address:
529
+ * the same permanent UUID the registering machine minted, synthesized
530
+ * into the record shape the structural walk verifies. Records under a
531
+ * repository no longer live in the cloud catalog — the map inside the
532
+ * one state parcel is their identity carrier. */
533
+ function repoChildIdentity(repoRecord, repoRelativePath) {
534
+ const state = repoState(repoRecord);
535
+ if (!Array.isArray(state.identity)) return null;
536
+ const byPath = state.identityByPath
537
+ || (state.identityByPath = new Map(state.identity.map((entry) => [entry.path, entry])));
538
+ const entry = byPath.get(repoRelativePath);
539
+ if (!entry) return null;
540
+ // A nested repository does not travel (the repo travel law), so the
541
+ // transport cannot promise this ground materializes as a repo here.
542
+ // The receiving walk decides what the ground actually is and mints
543
+ // fresh local identity for it.
544
+ if (entry.type === 'repo.git') return null;
545
+ const slash = repoRelativePath.lastIndexOf('/');
546
+ const parentEntry = slash === -1 ? null : byPath.get(repoRelativePath.slice(0, slash));
547
+ return {
548
+ uuid: entry.uuid,
549
+ type: entry.type,
550
+ parentUUID: parentEntry ? parentEntry.uuid : repoRecord.uuid,
551
+ name: repoRelativePath.slice(slash + 1),
552
+ status: 'active',
553
+ payloadVersion: null,
554
+ };
555
+ }
556
+
494
557
  for (const { contentHash } of entityCloud.contentHeads(active)) {
495
558
  if (!entityContent.complete({ contentHash })) return false;
496
559
  }
@@ -535,9 +598,21 @@ function createRegistrationService({
535
598
  function identityAt(relativePath) {
536
599
  if (!relativePath) return root;
537
600
  let cursor = root;
538
- for (const name of relativePath.split('/')) {
539
- cursor = (children.get(cursor.uuid) || []).find((record) => record.name === name) || null;
540
- if (!cursor) return null;
601
+ const parts = relativePath.split('/');
602
+ for (let index = 0; index < parts.length; index += 1) {
603
+ const next = (children.get(cursor.uuid) || []).find((record) => record.name === parts[index]) || null;
604
+ if (next) {
605
+ cursor = next;
606
+ continue;
607
+ }
608
+ // Inside a repository boundary the catalog goes quiet: the rest of
609
+ // the address is answered by that repo's transport identity map.
610
+ // (A snapshot from before the repo travel law still carries child
611
+ // records; those keep answering above, exactly as they used to.)
612
+ if (cursor.type === 'repo.git') {
613
+ return repoChildIdentity(records.get(cursor.uuid) || cursor, parts.slice(index).join('/'));
614
+ }
615
+ return null;
541
616
  }
542
617
  return records.get(cursor.uuid) || null;
543
618
  }
@@ -621,12 +696,18 @@ function createRegistrationService({
621
696
  return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
622
697
  }
623
698
 
699
+ // One unpack per repository: the live projection consults the same
700
+ // state for its drift check, its registry preload, and its apply.
701
+ const repoStatesByUuid = new Map();
624
702
  function repoState(record) {
703
+ const cached = repoStatesByUuid.get(record.uuid);
704
+ if (cached) return cached;
625
705
  if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
626
706
  const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
627
707
  if (state.stateId !== record.payloadVersion) {
628
708
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
629
709
  }
710
+ repoStatesByUuid.set(record.uuid, state);
630
711
  return state;
631
712
  }
632
713
 
@@ -908,9 +989,15 @@ function createRegistrationService({
908
989
  estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
909
990
  ));
910
991
  if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
911
- const results = boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
912
- entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
913
- return results;
992
+ // The snapshot must describe whatever actually committed, even when a
993
+ // later path refuses (each path is durable alone): without journal
994
+ // rows, a committed-but-unsnapshotted registration would be local
995
+ // truth with zero cloud delivery intent — a ghost nothing repairs.
996
+ try {
997
+ return boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
998
+ } finally {
999
+ entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
1000
+ }
914
1001
  },
915
1002
  /** `amalgm add <cloud-root-uuid> [parent-directory]` — cloud to local. */
916
1003
  add: async ({ uuid, targetPath }) => {