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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.262",
3
+ "version": "0.1.264",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -80,7 +80,10 @@ never merges, never decides truth — it emits facts for the layers above.
80
80
  never traversed — an explicitly enrolled symlink enrolls as the
81
81
  pointer itself.
82
82
  2. **The doorbell is a hint, never a fact — but a hint only ever raises
83
- scrutiny.** OS watchers are lossy by contract. Truth is always:
83
+ scrutiny.** One OS watcher per outermost tree (coverage.js — see
84
+ docs/watchers.md): enclosed roots hold no handles; the encloser
85
+ forwards their dings, rebased, synchronously. OS watchers are lossy
86
+ by contract. Truth is always:
84
87
  settle → walk → compare → hash. Stats may excuse a file from hashing
85
88
  only when all three hold: nothing named it, its stats are identical,
86
89
  and its mtime is strictly older than the moment we last read its bytes
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The coverage law: one OS watcher per outermost tree.
5
+ *
6
+ * A recursive watcher on a directory hears every event beneath it, at any
7
+ * depth — so a root enclosed by watched ground already has its content
8
+ * observed, exactly as its ADDRESS is already observed (the enclosed()
9
+ * rule that predates this module). Opening a second watcher over enclosed
10
+ * ground buys nothing on any platform and, on Linux — where a recursive
11
+ * watch is silently one inotify slot PER DIRECTORY against a small kernel
12
+ * budget — it is what exhausted the T3 machine. Doorbells are shared;
13
+ * what a root DOES with its dings (files channels, repo Card+Checkpoint)
14
+ * stays its own type's business, routed by ring().
15
+ *
16
+ * Pure functions over root rows — no handles, no state. index.js owns
17
+ * opening and closing; this module only answers "who guards whom".
18
+ */
19
+
20
+ const path = require('path');
21
+
22
+ /** The ground a root's doorbell must cover: a directory root guards
23
+ * itself; a single-file root's bell lives on its parent directory (the
24
+ * file's own inode goes deaf on atomic saves). */
25
+ function doorbellGround(root) {
26
+ return root.kind === 'file' ? path.dirname(root.path) : root.path;
27
+ }
28
+
29
+ /**
30
+ * The root whose OS watcher covers `root`'s doorbell ground, or null when
31
+ * `root` guards itself. The OUTERMOST enclosing directory root wins: with
32
+ * A ⊃ B ⊃ C only A holds a handle, and every deeper root — B, C, nested
33
+ * repos, single files — rides it. A file root can never cover anything.
34
+ */
35
+ function watchOwnerOf(root, roots) {
36
+ const ground = doorbellGround(root);
37
+ let owner = null;
38
+ for (const other of roots) {
39
+ if (other.rootId === root.rootId || other.kind === 'file') continue;
40
+ if (ground !== other.path && !ground.startsWith(`${other.path}/`)) continue;
41
+ if (!owner || other.path.length < owner.path.length) owner = other;
42
+ }
43
+ return owner;
44
+ }
45
+
46
+ /** Every live root covered by `owner` (owner excluded). */
47
+ function coveredRoots(owner, roots) {
48
+ return roots.filter((root) => root.rootId !== owner.rootId
49
+ && watchOwnerOf(root, roots)?.rootId === owner.rootId);
50
+ }
51
+
52
+ module.exports = { coveredRoots, doorbellGround, watchOwnerOf };
@@ -21,10 +21,12 @@
21
21
  * Nested repositories are territories too: a repo inside a repo is simply
22
22
  * another repo. A census at lifecycle moments (enrollment, conversion,
23
23
  * catch-up scans) plus doorbells live make each nested boundary its own
24
- * root with its own watcher; the parent stays silent for a child's ground.
25
- * A doorbell's filename never proves anything — it only says where to
26
- * look, and disk decides what it means (a repo moved in whole rings only
27
- * the directory that landed, never a path spelling ".git").
24
+ * root — but never its own OS watcher: one watcher per outermost tree
25
+ * (coverage.js), and the encloser FORWARDS a child territory's dings,
26
+ * rebased, synchronous, still raw. A doorbell's filename never proves
27
+ * anything — it only says where to look, and disk decides what it means
28
+ * (a repo moved in whole rings only the directory that landed, never a
29
+ * path spelling ".git").
28
30
  *
29
31
  * Enrollment is amalgm's OWN policy (`shouldEnroll`), applied uniformly to
30
32
  * files territory and repo boundaries alike. Git's ignore rules never
@@ -42,6 +44,7 @@ const { createContinuity } = require('./continuity');
42
44
  const { createStore } = require('./store');
43
45
  const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot } = require('./scan');
44
46
  const { createVerify } = require('./verify');
47
+ const { coveredRoots, watchOwnerOf } = require('./coverage');
45
48
  const { watchDir, watchRecursive } = require('./watch');
46
49
  const { adapterFor } = require('../adapters');
47
50
  const { lookFailed } = require('../adapters/truth');
@@ -80,7 +83,8 @@ function createObserver(options) {
80
83
  if (typeof emit !== 'function') throw new Error('createObserver requires { emit }');
81
84
 
82
85
  const store = createStore(db);
83
- // rootId -> { kind, watcher, settleTimer, dirtyPaths, checkAll, territories }
86
+ // rootId -> { kind, watcher, addressWatcher, settleTimer, dirtyPaths,
87
+ // checkAll, territories, watchEnabled, coveredBy, held }
84
88
  const live = new Map();
85
89
  let started = false;
86
90
 
@@ -338,7 +342,7 @@ function createObserver(options) {
338
342
  detach(root.rootId);
339
343
  store.removeRoot(root.rootId);
340
344
  live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
341
- publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'] });
345
+ publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'], hint: null });
342
346
  return;
343
347
  }
344
348
  if (parent) {
@@ -588,8 +592,9 @@ function createObserver(options) {
588
592
  censusRepoRoot(root);
589
593
  // Catch-up semantics: the observer cannot know what happened to a
590
594
  // repo while nobody watched; the follower's ids turn "maybe" into
591
- // silence, so an unconditional doorbell is the honest answer.
592
- publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'] });
595
+ // silence, so an unconditional doorbell is the honest answer —
596
+ // and a null path, because anything could have changed.
597
+ publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'], hint: null });
593
598
  }
594
599
  }
595
600
  // A scan that leaves no look pending resets the backoff: the
@@ -613,6 +618,10 @@ function createObserver(options) {
613
618
  function ring(root, relPath) {
614
619
  const state = live.get(root.rootId);
615
620
  if (!state) return;
621
+ // Held doorbells: the watcher is proven alive but registration has not
622
+ // committed. Dings conclude nothing — start(rootId)'s initial scan and
623
+ // census are the catch-up for everything heard during the hold.
624
+ if (state.held) return;
616
625
  state.retryMs = settleMs; // new evidence resets a backoff
617
626
 
618
627
  if (state.kind === 'file') {
@@ -661,6 +670,7 @@ function createObserver(options) {
661
670
  // Only absence buries — and an unsettled look keeps the news
662
671
  // pending: the retry looks again, no second doorbell needed.
663
672
  if (childTruth === 'unsettled') armRetry(root, state);
673
+ else forwardToTerritory(root, territory, relPath);
664
674
  return;
665
675
  }
666
676
  const known = store.rootByPath(childPath);
@@ -744,17 +754,22 @@ function createObserver(options) {
744
754
  }
745
755
  }
746
756
  // Forwarded raw and instantly: quieting is the follower's job, and a
747
- // delayed doorbell would blind its torn-read guard.
757
+ // delayed doorbell would blind its torn-read guard. The named path
758
+ // travels as a HINT — scope for the checkpoint to patch one file
759
+ // instead of re-reading the world; null means "anywhere". A hint is
760
+ // never a conviction: the grammar's voice (path/from/to) stays
761
+ // wording-independent, exactly as the wording grid ratifies.
748
762
  const areas = relPath === null
749
763
  ? ['git', 'worktree']
750
764
  : relPath === '.git' || relPath.startsWith('.git/') ? ['git'] : ['worktree'];
751
- publish({ type: 'repo.changed', rootId: root.rootId, areas });
765
+ publish({ type: 'repo.changed', rootId: root.rootId, areas, hint: relPath });
752
766
  return;
753
767
  }
754
768
 
755
- // A child repo's own doorbell covers its territory — while the boundary
756
- // is alive on disk. A dead one is territory news: fall through and let
757
- // the scan's reconcile bury it and reclaim the ground.
769
+ // A child territory's dings arrive on THIS root's watcher (one watcher
770
+ // per outermost tree) and are forwarded to the child — while the
771
+ // boundary is alive on disk. A dead one is territory news: fall through
772
+ // and let the scan's reconcile bury it and reclaim the ground.
758
773
  const filesTerritory = relPath === null ? null : coveringTerritory(state, relPath);
759
774
  if (filesTerritory !== null) {
760
775
  const territoryTruth = gitDirTruth(path.join(root.path, filesTerritory));
@@ -762,7 +777,10 @@ function createObserver(options) {
762
777
  armRetry(root, state); // no conclusion from a failed look — the retry decides
763
778
  return;
764
779
  }
765
- if (territoryTruth === 'present') return;
780
+ if (territoryTruth === 'present') {
781
+ forwardToTerritory(root, filesTerritory, relPath);
782
+ return;
783
+ }
766
784
  }
767
785
  // Enrollment policy applies to doorbells too — except .git itself: it is
768
786
  // never enrollable, but its appearance is territory news and must scan.
@@ -779,6 +797,19 @@ function createObserver(options) {
779
797
  armSettle(root, state);
780
798
  }
781
799
 
800
+ /**
801
+ * A covered child's dings arrive on its encloser's watcher; the encloser
802
+ * forwards them — synchronously, so repo doorbells stay raw and instant —
803
+ * rebased to the child's own ground. A ding naming the territory itself
804
+ * forwards as null (anything in there could have changed). Ringing the
805
+ * child recurses naturally through deeper territories.
806
+ */
807
+ function forwardToTerritory(root, territory, relPath) {
808
+ const child = store.rootByPath(path.join(root.path, territory));
809
+ if (!child) return; // racing a burial: the next reconcile owns it
810
+ ring(child, relPath === territory ? null : relPath.slice(territory.length + 1));
811
+ }
812
+
782
813
  function openWatcher(root) {
783
814
  // A single-file root's doorbell lives on its parent directory: a
784
815
  // watcher on the file itself would follow the inode and go deaf on
@@ -809,6 +840,34 @@ function createObserver(options) {
809
840
  );
810
841
  }
811
842
 
843
+ /**
844
+ * One root's watch health, coverage-aware: a covered root is watching
845
+ * exactly when its OWNER's watcher is live — it holds no handle of its
846
+ * own, by law, and pretending otherwise would hide a dead doorbell.
847
+ * Self-owned roots report their own handle, as always.
848
+ */
849
+ function statusOf(root) {
850
+ const state = live.get(root.rootId);
851
+ const ownerState = state?.coveredBy ? live.get(state.coveredBy) : null;
852
+ const watching = state?.coveredBy
853
+ ? Boolean(ownerState?.watcher) && !ownerState.watcher.degraded
854
+ : Boolean(state?.watcher) && !state.watcher.degraded;
855
+ const degraded = state?.coveredBy
856
+ ? Boolean(ownerState?.watcher?.degraded)
857
+ : Boolean(state?.watcher?.degraded);
858
+ return {
859
+ ...root,
860
+ watching,
861
+ degraded,
862
+ degradedReason: (state?.coveredBy ? ownerState : state)?.watcher?.reason ?? null,
863
+ coveredBy: state?.coveredBy ?? null,
864
+ addressRequired: root.kind !== 'file' && !enclosed(root),
865
+ addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
866
+ addressDegraded: Boolean(state?.addressWatcher?.degraded),
867
+ files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
868
+ };
869
+ }
870
+
812
871
  /** A root enclosed by watched ground has its address observed already. */
813
872
  function enclosed(root) {
814
873
  return store.listRoots().some((other) =>
@@ -925,29 +984,81 @@ function createObserver(options) {
925
984
  const state = live.get(root.rootId);
926
985
  if (!state || !started) return;
927
986
  state.watcher = openWatcher(root);
928
- try {
929
- scanNow(root.rootId);
930
- } catch (error) {
931
- console.warn(`[Observer] post-revive scan failed for "${root.path}":`, error?.message || error);
987
+ // Only owners hold watchers, so this death silenced every covered
988
+ // root too: the gap reconcile belongs to the whole family.
989
+ const family = [root, ...coveredRoots(root, watchCandidates())];
990
+ for (const member of family) {
991
+ try {
992
+ scanNow(member.rootId);
993
+ } catch (error) {
994
+ console.warn(`[Observer] post-revive scan failed for "${member.path}":`, error?.message || error);
995
+ }
932
996
  }
933
997
  }
934
998
 
935
- function startWatching(root, state) {
999
+ /** The roots that participate in watch coverage: attached, and asked to
1000
+ * watch (a registration-phase address-only root is neither an owner nor
1001
+ * covered — it is invisible to coverage until its watch begins). */
1002
+ function watchCandidates() {
1003
+ return store.listRoots().filter((root) => live.get(root.rootId)?.watchEnabled);
1004
+ }
1005
+
1006
+ function ensureOwnWatcher(root, state) {
936
1007
  if (!state.watcher || state.watcher.degraded) {
937
1008
  state.watcher?.close();
938
1009
  state.watcher = openWatcher(root);
939
1010
  }
940
- if ((!state.addressWatcher || state.addressWatcher.degraded)
941
- && root.kind !== 'file' && !enclosed(root)) {
1011
+ }
1012
+
1013
+ function ensureAddressWatcher(root, state) {
1014
+ if (root.kind === 'file') return; // its main doorbell IS the parent
1015
+ if (enclosed(root)) {
1016
+ // The encloser's walk is its address evidence — and adoption can
1017
+ // make this true of a root that once held one: close it.
1018
+ state.addressWatcher?.close();
1019
+ state.addressWatcher = null;
1020
+ return;
1021
+ }
1022
+ if (!state.addressWatcher || state.addressWatcher.degraded) {
942
1023
  state.addressWatcher?.close();
943
1024
  state.addressWatcher = openAddressWatcher(root);
944
1025
  }
945
1026
  }
946
1027
 
1028
+ /**
1029
+ * THE one decision about who holds OS handles, re-made whenever a root
1030
+ * joins, leaves, or moves: each watch-enabled root is either self-owned
1031
+ * (outermost — opens its own recursive doorbell) or covered (rides its
1032
+ * owner's; ring() forwards). Owners open FIRST, covered close after, so
1033
+ * an adoption never leaves a gap with no watcher over the ground.
1034
+ * Idempotent — settled roots are untouched.
1035
+ */
1036
+ function resolveCoverage() {
1037
+ if (!started) return;
1038
+ const candidates = watchCandidates();
1039
+ const owned = [];
1040
+ const covered = [];
1041
+ for (const root of candidates) {
1042
+ const state = live.get(root.rootId);
1043
+ const owner = watchOwnerOf(root, candidates);
1044
+ state.coveredBy = owner?.rootId ?? null;
1045
+ (owner ? covered : owned).push([root, state]);
1046
+ }
1047
+ for (const [root, state] of owned) ensureOwnWatcher(root, state);
1048
+ for (const [, state] of covered) {
1049
+ state.watcher?.close();
1050
+ state.watcher = null;
1051
+ }
1052
+ for (const [root, state] of [...owned, ...covered]) ensureAddressWatcher(root, state);
1053
+ }
1054
+
947
1055
  function attach(root, { watch = true } = {}) {
948
1056
  const existing = live.get(root.rootId);
949
1057
  if (existing) {
950
- if (started && watch) startWatching(root, existing);
1058
+ if (started && watch) {
1059
+ existing.watchEnabled = true;
1060
+ resolveCoverage();
1061
+ }
951
1062
  return existing;
952
1063
  }
953
1064
  const state = {
@@ -960,12 +1071,18 @@ function createObserver(options) {
960
1071
  dirtyPaths: new Set(),
961
1072
  checkAll: false,
962
1073
  territories: new Set(),
1074
+ watchEnabled: false,
1075
+ coveredBy: null,
1076
+ held: false,
963
1077
  };
964
1078
  for (const child of childRepoRoots(root)) {
965
1079
  state.territories.add(child.path.slice(root.path.length + 1));
966
1080
  }
967
1081
  live.set(root.rootId, state);
968
- if (started && watch) startWatching(root, state);
1082
+ if (started && watch) {
1083
+ state.watchEnabled = true;
1084
+ resolveCoverage();
1085
+ }
969
1086
  return state;
970
1087
  }
971
1088
 
@@ -976,6 +1093,9 @@ function createObserver(options) {
976
1093
  state.addressWatcher?.close();
977
1094
  if (state.settleTimer) clearTimeout(state.settleTimer);
978
1095
  live.delete(rootId);
1096
+ // A departed owner promotes whoever it covered (stop() sets
1097
+ // started=false first, so teardown never reopens handles).
1098
+ resolveCoverage();
979
1099
  }
980
1100
 
981
1101
  return {
@@ -1172,6 +1292,27 @@ function createObserver(options) {
1172
1292
  }
1173
1293
  },
1174
1294
 
1295
+ /**
1296
+ * Open and prove a root's doorbells WITHOUT scanning — the
1297
+ * registration boundary's "watcher first" step. The watcher is born
1298
+ * (or refuses to be, visibly), coverage is resolved, and its dings
1299
+ * are HELD: nothing is scanned, published, or recorded until
1300
+ * start(rootId) releases the hold — whose initial scan and census
1301
+ * are the catch-up for anything heard meanwhile. Returns the root's
1302
+ * coverage-aware status row so the caller can refuse BEFORE any
1303
+ * identity commits.
1304
+ */
1305
+ watchNow(rootId) {
1306
+ started = true; // watching has begun for this runtime
1307
+ const root = store.listRoots().find((row) => row.rootId === rootId);
1308
+ if (!root) throw new Error(`watchNow requires a live root: ${rootId}`);
1309
+ const state = attach(root, { watch: false });
1310
+ state.held = true;
1311
+ state.watchEnabled = true;
1312
+ resolveCoverage();
1313
+ return statusOf(root);
1314
+ },
1315
+
1175
1316
  start(rootId = null) {
1176
1317
  const firstStart = !started;
1177
1318
  if (firstStart) started = true;
@@ -1181,8 +1322,12 @@ function createObserver(options) {
1181
1322
  : store.listRoots().filter((root) => root.rootId === rootId);
1182
1323
  // Watch begins after registration. The initial scan makes the fresh
1183
1324
  // structural tree concrete; changes during registration are outside
1184
- // this operation's contract and are not reconstructed here.
1185
- for (const root of roots) attach(root, { watch: true });
1325
+ // this operation's contract and are not reconstructed here — held
1326
+ // doorbells (watchNow) release into that same catch-up.
1327
+ for (const root of roots) {
1328
+ const state = attach(root, { watch: true });
1329
+ state.held = false;
1330
+ }
1186
1331
  try {
1187
1332
  for (const root of roots) scanNow(root.rootId);
1188
1333
  } catch (error) {
@@ -1196,18 +1341,7 @@ function createObserver(options) {
1196
1341
  },
1197
1342
 
1198
1343
  status() {
1199
- return store.listRoots().map((root) => {
1200
- const state = live.get(root.rootId);
1201
- return {
1202
- ...root,
1203
- watching: Boolean(state?.watcher) && !state.watcher.degraded,
1204
- degraded: Boolean(state?.watcher?.degraded),
1205
- addressRequired: root.kind !== 'file' && !enclosed(root),
1206
- addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
1207
- addressDegraded: Boolean(state?.addressWatcher?.degraded),
1208
- files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
1209
- };
1210
- });
1344
+ return store.listRoots().map((root) => statusOf(root));
1211
1345
  },
1212
1346
  };
1213
1347
  }
@@ -23,13 +23,17 @@ function install(dirPath, recursive, onDirty, onDead) {
23
23
  watcher = fs.watch(dirPath, { recursive, persistent: false }, (_event, filename) => {
24
24
  onDirty(filename ? String(filename).split('\\').join('/') : null);
25
25
  });
26
- } catch {
26
+ } catch (error) {
27
27
  handle.degraded = true;
28
+ // WHY the birth failed travels with the handle: ENOSPC (the kernel's
29
+ // file-watch budget) earns the caller an actionable message, not a shrug.
30
+ handle.reason = error?.code || 'unknown';
28
31
  return handle;
29
32
  }
30
- watcher.on('error', () => {
33
+ watcher.on('error', (error) => {
31
34
  if (handle.degraded) return; // dead is dead: one death, one report
32
35
  handle.degraded = true;
36
+ handle.reason = error?.code || 'unknown';
33
37
  try { watcher.close(); } catch { /* already dead */ }
34
38
  if (onDead) onDead();
35
39
  });
@@ -92,6 +92,31 @@ function assertRecord(value) {
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * The repo travel law: a record with a `repo.git` PROPER ancestor never
97
+ * travels to the cloud on its own. The repository is the sync boundary —
98
+ * its one Card + Checkpoint state carries the worktree's bytes AND its
99
+ * children's permanent UUIDs (the transport identity map), so a register
100
+ * or a rebase is one parcel, never one postcard per file. A nested
101
+ * repository inside another repo's worktree is enclosed too: it becomes
102
+ * cloud-visible only when registered explicitly as its own tree.
103
+ * Missing ancestry keeps the record traveling rather than risking a
104
+ * silently unsynced entity.
105
+ */
106
+ function enclosedByRepositoryAncestor(record, lookup) {
107
+ const seen = new Set([record.uuid]);
108
+ let parentUUID = record.parentUUID;
109
+ while (parentUUID !== null && parentUUID !== undefined) {
110
+ if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
111
+ seen.add(parentUUID);
112
+ const parent = lookup(parentUUID);
113
+ if (!parent) return false;
114
+ if (parent.type === 'repo.git') return true;
115
+ parentUUID = parent.parentUUID;
116
+ }
117
+ return false;
118
+ }
119
+
95
120
  function snapshotFromRecords(records) {
96
121
  const normalized = records.map(assertRecord).sort((left, right) => left.uuid.localeCompare(right.uuid));
97
122
  if (new Set(normalized.map((record) => record.uuid)).size !== normalized.length) {
@@ -180,28 +205,14 @@ function createEntityCloud({
180
205
  return fallback ? cloudRecord(fallback) : null;
181
206
  };
182
207
 
183
- function enclosedByRepository(record) {
184
- const seen = new Set([record.uuid]);
185
- let parentUUID = record.parentUUID;
186
- while (parentUUID !== null) {
187
- if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
188
- seen.add(parentUUID);
189
- const parent = lookup(parentUUID);
190
- // Missing ancestry retains content rather than risking an ordinary
191
- // file whose only bytes were silently skipped.
192
- if (!parent) return false;
193
- if (parent.type === 'repo.git') return true;
194
- parentUUID = parent.parentUUID;
195
- }
196
- return false;
197
- }
198
-
199
208
  const heads = [];
200
209
  const seen = new Set();
201
210
  for (const record of normalized) {
202
211
  const contentHash = contentHead(record);
203
212
  if (!contentHash) continue;
204
- if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepository(record)) continue;
213
+ // Missing ancestry retains content rather than risking an ordinary
214
+ // file whose only bytes were silently skipped.
215
+ if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepositoryAncestor(record, lookup)) continue;
205
216
  if (seen.has(contentHash)) continue;
206
217
  seen.add(contentHash);
207
218
  heads.push({ record, contentHash });
@@ -432,6 +443,14 @@ function createEntityCloud({
432
443
  // Bootstrap roots are included in the first immutable snapshot. No
433
444
  // unscoped row is written before the cloud replica exists.
434
445
  if (!current || current.state !== 'active') return;
446
+ // The repo travel law: a record enclosed by a repository never mails
447
+ // its own postcard. The repo's one settled state is the parcel — the
448
+ // transport carries the worktree's bytes and its children's UUIDs, so
449
+ // a 14k-file repo register (or a 300-file rebase) is ONE mutation on
450
+ // the repo entity, never thousands of child mutations.
451
+ if (enclosedByRepositoryAncestor(record, (uuid) => (typeof recordAt === 'function' ? recordAt(uuid) : null))) {
452
+ return;
453
+ }
435
454
  // The SQLite mutation is the durable delivery intent. Its post-commit
436
455
  // content drain creates the filesystem upload cursor; doing that here
437
456
  // would let a rolled-back identity write upload unreachable bytes.
@@ -470,7 +489,15 @@ function createEntityCloud({
470
489
  if (Number(unsettled.count) !== 0) {
471
490
  throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
472
491
  }
473
- const snapshot = cloudSnapshot(records);
492
+ // The repo travel law applies to the atomic graph too: repo children
493
+ // stay out of the snapshot, so a workspace of large repositories
494
+ // publishes a graph proportional to its traveling records — the
495
+ // repos' bytes and child identity ride each repo's transport.
496
+ const byUuid = new Map(records.map((candidate) => [candidate.uuid, candidate]));
497
+ const traveling = records.filter((candidate) => (
498
+ !enclosedByRepositoryAncestor(candidate, (uuid) => byUuid.get(uuid) || null)
499
+ ));
500
+ const snapshot = cloudSnapshot(traveling);
474
501
  const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
475
502
  const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
476
503
  if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
@@ -671,7 +698,14 @@ function createEntityCloud({
671
698
  }
672
699
  if (!bootstrapping) {
673
700
  const cloudByUuid = new Map(snapshot.records.map((record) => [record.uuid, record]));
674
- for (const local of registry.syncRecords()) {
701
+ const locals = registry.syncRecords();
702
+ const localByUuid = new Map(locals.map((record) => [record.uuid, record]));
703
+ for (const local of locals) {
704
+ // Repo-enclosed local records evolve locally (Detect corrects
705
+ // their types and payloads) without journaling; a snapshot from
706
+ // before the repo travel law may still carry their stale rows.
707
+ // They are not cloud truth, so they cannot veto an install.
708
+ if (enclosedByRepositoryAncestor(local, (uuid) => localByUuid.get(uuid) || null)) continue;
675
709
  const remote = cloudByUuid.get(local.uuid);
676
710
  if (remote && !sameRecord(cloudRecord(local), remote)) {
677
711
  throw new Error('cloud entity graph conflicts with locally bound ground; resolve the local mutation before importing a new cloud head');
@@ -1196,6 +1230,7 @@ module.exports = {
1196
1230
  ENTITY_CLOUD_CONTRACT,
1197
1231
  ENTITY_CLOUD_SCHEMA_VERSION,
1198
1232
  createEntityCloud,
1233
+ enclosedByRepositoryAncestor,
1199
1234
  parseSnapshot,
1200
1235
  privateEntityResourceId,
1201
1236
  snapshotFromRecords,