amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -32,15 +32,40 @@ const path = require('path');
32
32
  const ROOT_MANIFEST_NAME = '.amalgm-root.json';
33
33
  const USER_MANIFEST_NAME = '.amalgm-user.json';
34
34
  const LAYOUT_VERSION = 1;
35
+ const USER_HOME_MANIFEST_NAME = 'home.json';
36
+ const USER_HOME_SCHEMA_VERSION = 1;
35
37
 
36
- // Per-user roots for platform primitives. Everything Amalgm owns for a user
37
- // lives under one of these; anything else on disk is the user's own project.
38
+ // Stable per-user product namespaces. Their authority is NOT inferred from
39
+ // their path: user content and exact platform-managed targets are declared
40
+ // separately below (docs/user-cloud-bootstrap.md).
38
41
  const PRIMITIVE_DIRS = Object.freeze([
39
42
  'agents',
40
43
  'apps',
41
44
  'automations',
42
45
  'toolbox',
43
46
  'workspaces',
47
+ 'system',
48
+ ]);
49
+
50
+ const USER_HOME_ROOTS = Object.freeze([
51
+ { key: 'agents', relativePath: 'agents' },
52
+ { key: 'apps', relativePath: 'apps' },
53
+ { key: 'automations', relativePath: 'automations' },
54
+ { key: 'toolbox', relativePath: 'toolbox' },
55
+ { key: 'workspaces', relativePath: 'workspaces' },
56
+ { key: 'system', relativePath: 'system' },
57
+ ]);
58
+
59
+ // Platform management is an exact-node fact. It deliberately does not
60
+ // cascade from `system/`: human permissions may still inherit normally, while
61
+ // only these particular entities are product-managed.
62
+ const USER_HOME_EXACT_MANAGED = Object.freeze([
63
+ ...USER_HOME_ROOTS.map(({ key, relativePath }) => ({
64
+ key: `${key}-root`, relativePath, rootKey: key,
65
+ })),
66
+ { key: 'home-manifest', relativePath: 'system/home.json', rootKey: 'system' },
67
+ { key: 'defaults', relativePath: 'system/defaults', rootKey: 'system' },
68
+ { key: 'policy', relativePath: 'system/policy', rootKey: 'system' },
44
69
  ]);
45
70
 
46
71
  function readJson(file, fallback = null) {
@@ -56,6 +81,51 @@ function writeJson(file, data) {
56
81
  fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
57
82
  }
58
83
 
84
+ function declaredUserHome(userDir) {
85
+ const root = path.resolve(userDir);
86
+ return {
87
+ schemaVersion: USER_HOME_SCHEMA_VERSION,
88
+ userDir: root,
89
+ roots: USER_HOME_ROOTS.map((entry) => ({
90
+ ...entry,
91
+ path: path.join(root, entry.relativePath),
92
+ })),
93
+ exactManaged: USER_HOME_EXACT_MANAGED.map((entry) => ({
94
+ ...entry,
95
+ path: path.join(root, entry.relativePath),
96
+ scope: 'exact',
97
+ })),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Materialize the fixed, safe bootstrap surface. This does not register or
103
+ * synchronize anything; runtime bootstrap calls the shared `amalgm register`
104
+ * boundary for that. Keeping those acts separate prevents login from growing
105
+ * a second identity registrar or silently enrolling machine-local state.
106
+ */
107
+ function ensureUserHome(userDir) {
108
+ const declaration = declaredUserHome(userDir);
109
+ for (const root of declaration.roots) {
110
+ fs.mkdirSync(root.path, { recursive: true, mode: 0o700 });
111
+ }
112
+ for (const managed of declaration.exactManaged) {
113
+ if (path.extname(managed.path)) continue;
114
+ fs.mkdirSync(managed.path, { recursive: true, mode: 0o700 });
115
+ }
116
+
117
+ const manifest = declaration.exactManaged.find((entry) => entry.key === 'home-manifest');
118
+ if (manifest && !fs.existsSync(manifest.path)) {
119
+ writeJson(manifest.path, {
120
+ schemaVersion: USER_HOME_SCHEMA_VERSION,
121
+ kind: 'amalgm-user-home',
122
+ roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
123
+ exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
124
+ });
125
+ }
126
+ return declaration;
127
+ }
128
+
59
129
  function lstatOrNull(target) {
60
130
  try {
61
131
  return fs.lstatSync(target);
@@ -249,17 +319,25 @@ function ensureUserDir(homeDir, { userId, email }) {
249
319
  const existing = findUserDirByUserId(homeDir, cleanId);
250
320
  if (existing) {
251
321
  if (cleanEmail && readUserManifest(existing)?.canonicalEmail !== cleanEmail) {
252
- return adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
322
+ const adopted = adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
323
+ ensureUserHome(adopted);
324
+ return adopted;
253
325
  }
254
326
  indexUserFolder(homeDir, path.basename(existing), { userId: cleanId, email: cleanEmail });
327
+ ensureUserHome(existing);
255
328
  return existing;
256
329
  }
257
330
 
258
331
  const legacyDir = path.join(homeDir, 'users', sanitizeFolderSegment(cleanId));
259
332
  if (lstatOrNull(legacyDir)?.isDirectory()) {
260
333
  writeUserManifest(legacyDir, { userId: cleanId, email: cleanEmail });
261
- if (cleanEmail) return adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
334
+ if (cleanEmail) {
335
+ const adopted = adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
336
+ ensureUserHome(adopted);
337
+ return adopted;
338
+ }
262
339
  indexUserFolder(homeDir, path.basename(legacyDir), { userId: cleanId, email: '' });
340
+ ensureUserHome(legacyDir);
263
341
  return legacyDir;
264
342
  }
265
343
 
@@ -268,6 +346,7 @@ function ensureUserDir(homeDir, { userId, email }) {
268
346
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
269
347
  writeUserManifest(dir, { userId: cleanId, email: cleanEmail });
270
348
  indexUserFolder(homeDir, folderName, { userId: cleanId, email: cleanEmail });
349
+ ensureUserHome(dir);
271
350
  return dir;
272
351
  }
273
352
 
@@ -323,8 +402,14 @@ module.exports = {
323
402
  PRIMITIVE_DIRS,
324
403
  ROOT_MANIFEST_NAME,
325
404
  USER_MANIFEST_NAME,
405
+ USER_HOME_EXACT_MANAGED,
406
+ USER_HOME_MANIFEST_NAME,
407
+ USER_HOME_ROOTS,
408
+ USER_HOME_SCHEMA_VERSION,
326
409
  adoptUserEmail,
410
+ declaredUserHome,
327
411
  emailFolderName,
412
+ ensureUserHome,
328
413
  ensureRootManifest,
329
414
  ensureUserDir,
330
415
  createCompatLink,
@@ -0,0 +1,259 @@
1
+ # Observer — the change-detection layer
2
+
3
+ ## Purpose
4
+
5
+ Notice that bytes on disk changed, and say so exactly once. The observer is
6
+ the single place in the runtime where a local change is born. It never syncs,
7
+ never merges, never decides truth — it emits facts for the layers above.
8
+
9
+ ## Axioms
10
+
11
+ 1. **Registration is coverage, and every path has exactly one owner —
12
+ permanently.** One observer root per enrolled workspace, repo, or
13
+ single ground (the smallest root there is: one channel, relPath `''`,
14
+ a parent-directory doorbell; a file under the file laws, or an
15
+ explicitly enrolled pointer under the link laws). No caps, no
16
+ side-effect watchers, no "watched because open". A root's identity is
17
+ its rootId and its path is only the current address: each root
18
+ carries its own physical identity (a dir root its inode, a
19
+ single-ground root its channel row's), and enrollment rescues moved
20
+ ground by continuity — unique inode match against a path-orphan
21
+ rebinds the address (`root.moved`, the whole family in ONE store
22
+ transaction), every channel intact; a lookalike at the old address
23
+ is new ground. A scan's evidence horizon is the root's ground, and
24
+ the root's own address is the horizon's edge: ground missing there —
25
+ or a directory root's address wearing a different identity — is
26
+ undecidable (moved? deleted? replaced?), so the scan CONCLUDES
27
+ NOTHING about identity. IDENTITY PERSISTS; LOCAL PRESENCE IS
28
+ SEPARATE EVIDENCE: the undecided address flips one durable `present`
29
+ bit and says so — `root.unavailable` on the way down,
30
+ `root.available` on the way back, one symmetric pair at one choke
31
+ point, never a lifecycle verdict and never a grammar of unrelated
32
+ events. Beliefs freeze, the referee convicts the divergence, and
33
+ the next enrollment moment decides: naming the new address rescues,
34
+ re-naming this one adopts the ground, and explicit removal is the
35
+ only deletion. THE PARENT OBSERVES ADDRESS CHANGES; THE ENTITY
36
+ OBSERVES CONTENT CHANGES: every top-level root also carries a
37
+ non-recursive doorbell on its parent directory, and that doorbell
38
+ HEARS EVERY EVENT THERE — a rename may lawfully deliver only the NEW
39
+ name, so a name filter would decide the answer before looking; the
40
+ cheap address check is what decides (an intact, present address
41
+ returns in one lstat). An undecided address gets ONE DECISION, AT
42
+ EVERY ENTRANCE: the parent doorbell and the root's own scans (the
43
+ settle scan racing a slow delivery, the catch-up scan at start) can
44
+ learn of one rename in either order, and the verdict may never
45
+ depend on which arrived first — whichever entrance finds the
46
+ address undecided makes the same directed look before concluding
47
+ anything, so a followed move has no unavailable moment regardless
48
+ of arrival order, and a same-parent rename made while nothing ran
49
+ is followed at start. The rename is followed for EVERY kind when
50
+ WITNESSED CONTINUITY EVIDENCE claims it — the root's
51
+ kind-appropriate identity as of the last completed scan: a dir root
52
+ its own inode, a single ground its channel row's (which absorbs
53
+ atomic-save churn each time a scan witnesses it) — under the
54
+ exactly-one rule: one candidate of the root's own shape in the
55
+ parent, or no conclusion. The path still claims first (file ground
56
+ is only undecided when nothing file-shaped holds the address),
57
+ ambiguity — two entries wearing one inode, a hardlink beside a
58
+ rename — refuses to guess, and so does MISSING evidence: an atomic
59
+ save renamed away inside its own settle window leaves ground no scan
60
+ ever witnessed (new inode, new bytes, nothing to tie it to the
61
+ channel), so the follow refuses and even a later re-add of the new
62
+ address is honestly a NEW identity — the follow rides evidence,
63
+ never clairvoyance. A departure beyond the parent is honest absence,
64
+ LIVE: `root.unavailable` on the same delivery, reattachment the
65
+ explicit act that names the new address. The remaining honest edge: a
66
+ move BEYOND THE PARENT made while nothing was running, or to ground
67
+ nothing watches,
68
+ stays unavailable until an enrollment moment names it (a move INTO
69
+ registered ground is adopted by that tree's evidence, one level up). A `.git` claims its
70
+ subtree, and
71
+ territory is decided by disk, not by callers: a directory with a `.git`
72
+ enrolls as repo territory; a `.git` appearing inside a live files root
73
+ closes the file channels under it and births a repo root
74
+ (`repo.discovered`); a `.git` vanishing returns the ground to files
75
+ (`repo.removed`) and the channels are reborn. A repo's changes travel as
76
+ one card, never as thousands of file events. Enrollment names ground
77
+ by ADDRESS: the parent chain resolves, the last segment is inspected,
78
+ never traversed — an explicitly enrolled symlink enrolls as the
79
+ pointer itself.
80
+ 2. **The doorbell is a hint, never a fact — but a hint only ever raises
81
+ scrutiny.** OS watchers are lossy by contract. Truth is always:
82
+ settle → walk → compare → hash. Stats may excuse a file from hashing
83
+ only when all three hold: nothing named it, its stats are identical,
84
+ and its mtime is strictly older than the moment we last read its bytes
85
+ (git's racily-clean rule). A file the doorbell named is always hashed —
86
+ timestamps can be backdated, a delivered event cannot.
87
+ 3. **Every scan is the same code, and every scan has an event.** There is no
88
+ clock. `scanNow` runs at exactly four moments: enrollment (birth), start
89
+ (catch-up for the window we were dead), watcher death (rebuild the
90
+ doorbell, reconcile the gap once), and whenever a consumer asks — a sync
91
+ session opening, a document opening, an app regaining focus are events
92
+ too, and they belong to the layer above.
93
+ 4. **Echo suppression is content, not bookkeeping.** A writer records its own
94
+ write (`noteWrite`); a scan that finds the recorded hash finds nothing.
95
+ 5. **Identity has two axes, never mixed — and KIND decides which axis
96
+ testifies first.** `fileId → path/inode` answers "which file";
97
+ `fileId → contentHash` answers "did it change". For files the path
98
+ claims the channel — an atomic save replaces the inode in place, so
99
+ the address must absorb that churn — and the inode only rescues
100
+ path-orphans (renames), exactly-one match both sides. Directories are
101
+ channels under the same two rules in the OPPOSITE order, minus
102
+ content: nothing replaces a directory's inode in place, so the inode
103
+ claims first (two directories exchanging names cross by inode,
104
+ exactly like the files inside them) and the path claims only
105
+ recreation — a never-seen inode under an old name is the same
106
+ channel, silently retagged. Nothing to hash: a directory's meaning
107
+ (membership) is derived by the layers above, so empty folders exist
108
+ and folder renames — including exchanges — survive by inode. A
109
+ symlink is a channel in the FILES' order for the file reason (a
110
+ retarget is unlink + recreate, so the address absorbs the inode
111
+ churn; `mv` preserves the inode, so the inode rescues renames) whose
112
+ whole payload is the written target string, read every scan and NEVER
113
+ followed — the real target is watched at its real enrolled address,
114
+ and the edge-following enrollment operation (`edges.js`), not any
115
+ walk, is what turns unclaimed targets into ground. Every root kind
116
+ exposes its edges (`linkEdges`): files ground from detected truth,
117
+ repo ground from a fresh worktree census — a repo's changes travel
118
+ as one card, but its edges are enrollment-policy inputs like any
119
+ other root's.
120
+
121
+ ## Shape
122
+
123
+ - `watch.js` — recursive OS doorbell (macOS FSEvents, Windows RDCW, Linux
124
+ inotify via Node ≥20), plus `watchDir`: the non-recursive doorbell
125
+ that hears EVERY event in one directory and delivers the raw name —
126
+ filtering is the consumer's business, because a rename may deliver
127
+ only the new name. Two consumers ride it: the single-file root's main
128
+ doorbell (the file's own inode goes deaf on atomic saves; the parent
129
+ directory does not — events naming the file ring content, foreign
130
+ names ring the cheap address check) and every top-level root's
131
+ ADDRESS doorbell (the parent observes address changes; the entity
132
+ observes content changes). Allowed to die; death is an event — the
133
+ owner rebuilds the doorbell and reconciles once.
134
+ - `scan.js` — walk a root, and the pure compare functions: `diffRootState`
135
+ for files, `diffDirState` for directories (same rules, no content),
136
+ `diffLinkState` for symlinks (the file rules, target string as payload);
137
+ `censusLinks` reads a repo worktree's symlink edges fresh (stored
138
+ nowhere — git owns a repo's memory); `gitDirTruth` is the three-state
139
+ territory look every consumer asks.
140
+ - `apply.js` — where a compare result becomes truth: events published
141
+ and store rows landed, deterministic order, one applier per channel
142
+ kind. The content read is injected, because unsettled suspicion
143
+ surviving onto the root's retry is the wiring's law.
144
+ - `continuity.js` — a root's WITNESSED physical identity, the
145
+ moved-root claim, the undecided-address verdict, and the presence
146
+ bit's only two writers. Questions only: the actions a verdict
147
+ triggers (rebind, follow) re-aim watchers, so they live in the wiring.
148
+ - `verify.js` — the referee, deliberately built from none of the
149
+ machinery above (see Verification below).
150
+ - `store.js` — the layer's whole memory: `observer_roots` (with each
151
+ root's own `device, inode` — identity is permanent, the address is
152
+ not, and a family of roots changes address as ONE transaction — and
153
+ its durable `present` bit: presence survives a restart),
154
+ `observer_files`
155
+ (`file_id, rel_path, device, inode, size, mtime, content_hash`),
156
+ `observer_dirs` (`dir_id, rel_path, device, inode`), and
157
+ `observer_links` (`link_id, rel_path, device, inode, target`), on an
158
+ injected database handle, stamped with its schema generation (5).
159
+ - `index.js` — the wiring, and only the wiring: enroll, settle, scan,
160
+ emit. It owns what the pieces above may not — when a look happens
161
+ (the settle timer and its backoff), what a doorbell means, territory
162
+ births and burials, and the rescue actions that re-aim watchers.
163
+ - `edges.js` — the ratified enrollment policy acting on every root's
164
+ edges (`linkEdges` — repo roots via census, single-ground pointer
165
+ roots via their own one edge): adding a workspace authorizes every
166
+ reachable real target, so this operation resolves each pointer to
167
+ real ground and enrolls EXACTLY the target the link names — a repo
168
+ only when the named target IS the repo root; a file as a
169
+ single-ground root, never its parent — to a fixpoint, idempotent by
170
+ real path (shared targets deduplicate, cycles terminate), add-only
171
+ (a retarget enrolls the new ground and never removes the old). Two
172
+ refusals, both reported: ground the enrollment policy excludes
173
+ (`observer.shouldEnroll` — the SAME policy every route asks, about
174
+ the COMPLETE candidate path, so a benign name inside excluded ground
175
+ like `.ssh/config` is still refused; an edge is never a way around
176
+ it) and a target containing enrolled ground. Enrollment FAILURES are
177
+ never silence: they propagate, the boundary above stays dirty, and
178
+ the retry decides (a vanished target is broken ground next pass).
179
+ The registration boundary above (`registration/`) is what makes
180
+ watched ground REGISTERED ground — a tree per top-level root, keyed
181
+ by permanent rootId — and owns derived link resolution across trees
182
+ (answering with the OWNING entity: repo territory resolves to its
183
+ boundary's record).
184
+
185
+ The observer imports nothing from the rest of the runtime. Enrollment policy
186
+ (`shouldEnroll`) is injected; the default is the workspace inclusion rules.
187
+
188
+ ## Events
189
+
190
+ - `file.created { rootId, fileId, path, hash, size }`
191
+ - `file.changed { rootId, fileId, path, hash, previousHash, size }`
192
+ - `file.renamed { rootId, fileId, from, to }`
193
+ - `file.deleted { rootId, fileId, path }`
194
+ - `dir.created { rootId, dirId, path }` / `dir.renamed { rootId, dirId,
195
+ from, to }` / `dir.deleted { rootId, dirId, path }` — no `dir.changed`:
196
+ a directory has no bytes of its own, and a recreation under the same
197
+ name is the same channel wearing a new inode (silent).
198
+ - `link.created { rootId, linkId, path, target }` / `link.changed {
199
+ rootId, linkId, path, target, previousTarget }` (a retarget — the
200
+ written address is the payload) / `link.renamed { rootId, linkId,
201
+ from, to }` / `link.deleted { rootId, linkId, path }` — recreation
202
+ with the same address is silence, the same pointer wearing a new
203
+ inode.
204
+ - `repo.discovered { rootId, path }` / `repo.removed { rootId, path }` —
205
+ territory handoff; the consumer attaches or drops a follower.
206
+ - `root.moved { rootId, from, to }` — continuity rescued a moved root:
207
+ same identity, new address, channels untouched; the follow-up scan
208
+ proves silence. Consumers re-aim address-derived state, nothing else.
209
+ - `root.unavailable { rootId, path }` / `root.available { rootId,
210
+ path }` — the symmetric presence pair: identity persists, local
211
+ presence is separate evidence. Unavailable is never deleted (no
212
+ lifecycle is concluded), available is never new (the same channels
213
+ wake), and a followed move emits neither — continuity never broke.
214
+ - `repo.changed { rootId, areas: ['git'|'worktree'] }` — a raw doorbell,
215
+ forwarded instantly with no settling: quieting and identity belong to the
216
+ follower (repocard/follow.js), and a delayed doorbell would blind its
217
+ torn-read guard. On any catch-up scan a repo root rings unconditionally —
218
+ the observer cannot know what happened while nobody watched; the
219
+ follower's ids turn "maybe" into silence.
220
+
221
+ The store is the identity half of detection's memory: `file_id`/`dir_id`
222
+ are the durable channel identities that survive renames, and the root's own
223
+ id is the store's WITNESS — the registry side binds it, validates it before
224
+ every canonical proposal, and convicts an unknown witness loudly, so a
225
+ lost store is never mistaken for a fresh start. Loss is judged by
226
+ completeness too: an established store either has its whole schema or it
227
+ convicts at open — a silently recreated table would end every channel it
228
+ held behind a witness that still checks out. Bytes are the disposable
229
+ half: re-detection is idempotent because equal hashes are silence.
230
+ Committed store state is always some truth disk actually had: the file loop
231
+ satisfies this per row (file renames never cross — the address claims first,
232
+ so a rename only ever targets a row-free slot), and a directory batch, whose
233
+ movers may cross, lands as ONE transaction with doorbells rung only after
234
+ truth has landed — a crash mid-batch changes nothing, and the next scan
235
+ re-detects the same batch.
236
+
237
+ ## The referee
238
+
239
+ `verify()` re-reads enrolled ground and reports any divergence between disk
240
+ truth and the observer's beliefs — including unclaimed territory: a `.git`
241
+ on disk with no registered repo root. It is the referee of the INCREMENTAL
242
+ detector, and only that: it shares no part of the path it judges — no stats
243
+ shortcut, no doorbells, no dirty sets, no compare functions, no applier —
244
+ so no belief the scan reached by shortcut is ever confirmed by that same
245
+ shortcut.
246
+
247
+ It does share detection's PRIMITIVES: the same enrollment policy, the same
248
+ walk, the same truth adapter. That is deliberate — one definition of each,
249
+ none left to drift — and it bounds the verdict exactly: `verify()` grades
250
+ the machinery, never the primitives it asks. Grading those is the oracle
251
+ referee's job (`tests/entity-oracle/referee.js`), which enumerates and
252
+ hashes with its own code and none of this layer's.
253
+
254
+ So the two referees answer different questions and run in different places:
255
+ the storms and the oracle exams end with both, while an audit or shadow
256
+ mode in production has only `verify()` — the oracle is test ground and
257
+ production imports none of it. An empty `verify()` means no change went
258
+ undetected in the ground the primitives reported; that the primitives are
259
+ themselves right is the oracle's proof, not this one's.
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Where a compare result becomes truth: events published, store rows
5
+ * landed, deterministic order — one applier per channel kind. The
6
+ * scan compares (scan.js), this file concludes; the wiring (index.js)
7
+ * decides when. `scanContent` is injected because a file's content read
8
+ * is the wiring's law (unsettled suspicion survives onto the root's
9
+ * retry) — the applier only honors its answer. That answer is the WHOLE
10
+ * content truth, fingerprint and kind together, because one read
11
+ * produced both; the applier never splits them.
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+
16
+ const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
17
+
18
+ function createApply({ store, publish, scanContent }) {
19
+ /**
20
+ * Directory channels: same shape as files, minus content — a rename is
21
+ * the whole event, a retag (recreated under its old name) is silence,
22
+ * and there is nothing to hash. Movers may exchange addresses and land
23
+ * on freshly-deleted ones (directories cross by inode), so the compare
24
+ * result is one truth transition in the store too: one transaction, and
25
+ * the doorbells ring only after truth has landed — a crash mid-batch
26
+ * changes nothing, and the next scan re-detects the same batch.
27
+ * Deterministic order.
28
+ */
29
+ function applyDirDiff(root, diff) {
30
+ const deletes = diff.deletes.sort((a, b) => byPath(a.relPath, b.relPath));
31
+ const renames = diff.renames.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath));
32
+ const creates = diff.creates.sort((a, b) => byPath(a.relPath, b.relPath))
33
+ .map((entry) => ({ dirId: crypto.randomUUID(), ...entry }));
34
+ store.applyDirDiff({
35
+ deletes: deletes.map((row) => row.dirId),
36
+ moves: renames.map(({ row, entry }) => ({ dirId: row.dirId, rootId: root.rootId, ...entry })),
37
+ retags: diff.retags.map(({ row, entry }) => ({ dirId: row.dirId, ...entry })),
38
+ creates: creates.map((entry) => ({ rootId: root.rootId, ...entry })),
39
+ });
40
+ for (const row of deletes) {
41
+ publish({ type: 'dir.deleted', rootId: root.rootId, dirId: row.dirId, path: row.relPath });
42
+ }
43
+ for (const { row, entry } of renames) {
44
+ publish({ type: 'dir.renamed', rootId: root.rootId, dirId: row.dirId, from: row.relPath, to: entry.relPath });
45
+ }
46
+ for (const entry of creates) {
47
+ publish({ type: 'dir.created', rootId: root.rootId, dirId: entry.dirId, path: entry.relPath });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Link channels: the file shape with the target string as the whole
53
+ * payload — a rename is the move, a retarget is the change (with the
54
+ * previous pointer), and a recreation under an old name with an old
55
+ * target is silence. Renames never cross (the address claims first), so
56
+ * rows commit per-row like files. Deterministic order.
57
+ */
58
+ function applyLinkDiff(root, diff) {
59
+ for (const { row, entry } of diff.renames.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
60
+ publish({ type: 'link.renamed', rootId: root.rootId, linkId: row.linkId, from: row.relPath, to: entry.relPath });
61
+ if (row.target !== entry.target) {
62
+ publish({
63
+ type: 'link.changed', rootId: root.rootId, linkId: row.linkId,
64
+ path: entry.relPath, target: entry.target, previousTarget: row.target,
65
+ });
66
+ }
67
+ store.updateLink(row.linkId, entry);
68
+ }
69
+ for (const { row, entry } of diff.changes.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
70
+ publish({
71
+ type: 'link.changed', rootId: root.rootId, linkId: row.linkId,
72
+ path: entry.relPath, target: entry.target, previousTarget: row.target,
73
+ });
74
+ store.updateLink(row.linkId, entry);
75
+ }
76
+ for (const { row, entry } of diff.retags) {
77
+ store.updateLink(row.linkId, entry); // the same pointer wearing a new inode
78
+ }
79
+ for (const entry of diff.creates.sort((a, b) => byPath(a.relPath, b.relPath))) {
80
+ const linkId = crypto.randomUUID();
81
+ store.insertLink({ linkId, rootId: root.rootId, ...entry });
82
+ publish({ type: 'link.created', rootId: root.rootId, linkId, path: entry.relPath, target: entry.target });
83
+ }
84
+ for (const row of diff.deletes.sort((a, b) => byPath(a.relPath, b.relPath))) {
85
+ store.removeLink(row.linkId);
86
+ publish({ type: 'link.deleted', rootId: root.rootId, linkId: row.linkId, path: row.relPath });
87
+ }
88
+ }
89
+
90
+ /** Turn one compare result into events + store updates. Deterministic order. */
91
+ function applyDiff(root, diff) {
92
+
93
+ for (const { row, entry } of diff.renames.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
94
+ publish({ type: 'file.renamed', rootId: root.rootId, fileId: row.fileId, from: row.relPath, to: entry.relPath });
95
+ // The move is a fact on its own: record it before content is examined,
96
+ // so a file that vanishes mid-hash still deletes under its new name.
97
+ store.updateFile(row.fileId, {
98
+ ...entry, relPath: entry.relPath,
99
+ contentHash: row.contentHash, binary: row.binary, hashedAtMs: row.hashedAtMs,
100
+ });
101
+ const hashedAtMs = Date.now(); // Stamped BEFORE reading: a write during our read must force a future re-hash.
102
+ const content = scanContent(root, entry.relPath);
103
+ if (content === null || content.contentHash === row.contentHash) {
104
+ if (content !== null) store.updateFile(row.fileId, { ...entry, relPath: entry.relPath, ...content, hashedAtMs });
105
+ continue;
106
+ }
107
+ publish({
108
+ type: 'file.changed', rootId: root.rootId, fileId: row.fileId,
109
+ path: entry.relPath, hash: content.contentHash, previousHash: row.contentHash, size: entry.size,
110
+ });
111
+ store.updateFile(row.fileId, { ...entry, relPath: entry.relPath, ...content, hashedAtMs });
112
+ }
113
+
114
+ for (const { row, entry } of diff.checks.sort((a, b) => byPath(a.entry.relPath, b.entry.relPath))) {
115
+ const hashedAtMs = Date.now();
116
+ const content = scanContent(root, entry.relPath);
117
+ if (content === null) continue;
118
+ if (content.contentHash !== row.contentHash) {
119
+ publish({
120
+ type: 'file.changed', rootId: root.rootId, fileId: row.fileId,
121
+ path: entry.relPath, hash: content.contentHash, previousHash: row.contentHash, size: entry.size,
122
+ });
123
+ }
124
+ // Same hash = a touch or our own write echoing back: silence, but the
125
+ // stat fields advance so the next scan skips it.
126
+ store.updateFile(row.fileId, { ...entry, relPath: entry.relPath, ...content, hashedAtMs });
127
+ }
128
+
129
+ for (const entry of diff.creates.sort((a, b) => byPath(a.relPath, b.relPath))) {
130
+ const hashedAtMs = Date.now();
131
+ const content = scanContent(root, entry.relPath);
132
+ if (content === null) continue;
133
+ const fileId = crypto.randomUUID();
134
+ store.insertFile({ fileId, rootId: root.rootId, ...entry, ...content, hashedAtMs });
135
+ publish({
136
+ type: 'file.created', rootId: root.rootId, fileId,
137
+ path: entry.relPath, hash: content.contentHash, size: entry.size,
138
+ });
139
+ }
140
+
141
+ for (const row of diff.deletes.sort((a, b) => byPath(a.relPath, b.relPath))) {
142
+ store.removeFile(row.fileId);
143
+ publish({ type: 'file.deleted', rootId: root.rootId, fileId: row.fileId, path: row.relPath });
144
+ }
145
+ }
146
+
147
+ return { applyDiff, applyDirDiff, applyLinkDiff };
148
+ }
149
+
150
+ module.exports = { createApply };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Root continuity primitives: what a root's WITNESSED physical identity
5
+ * is, whether its recorded address still holds its ground, and the one
6
+ * presence bit. Pure questions plus the two presence transitions — the
7
+ * ACTIONS a verdict triggers (rebind, the undecided-address decision)
8
+ * stay in the wiring, because they re-aim watchers and scans.
9
+ */
10
+
11
+ const fs = require('fs');
12
+
13
+ function createContinuity({ store, publish }) {
14
+ /**
15
+ * A root's WITNESSED physical identity, for continuity rescue.
16
+ * Directory roots carry their own device:inode (nothing replaces a
17
+ * directory's inode in place). A single-ground root's identity lives
18
+ * in its one channel row — current as of the last completed scan: the
19
+ * file laws absorb atomic-save and retarget churn each time a scan
20
+ * witnesses it, and continuity can only ever claim what a scan has
21
+ * witnessed. Ground that changed inodes inside a still-open settle
22
+ * window is unwitnessed — no evidence, no claim.
23
+ */
24
+ function rootIdentity(root) {
25
+ if (root.kind !== 'file') {
26
+ return root.device == null ? null : { device: root.device, inode: root.inode };
27
+ }
28
+ const row = store.fileByPath(root.rootId, '') ?? store.linksForRoot(root.rootId)[0];
29
+ return row ? { device: row.device, inode: row.inode } : null;
30
+ }
31
+
32
+ /** Orphaned means the recorded address no longer holds this ground. */
33
+ function orphanedAtAddress(root) {
34
+ const identity = rootIdentity(root);
35
+ if (!identity) return false;
36
+ try {
37
+ const stat = fs.lstatSync(root.path);
38
+ return stat.dev !== identity.device || stat.ino !== identity.inode;
39
+ } catch {
40
+ return true;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Continuity claims a root exactly like it claims the directories
46
+ * inside one: unique device:inode match, exactly one candidate, and
47
+ * only for a path-orphan — a root whose recorded address still holds
48
+ * its ground is never stolen by a lookalike.
49
+ */
50
+ function findMovedRoot(stat) {
51
+ const wantDir = stat.isDirectory();
52
+ const matches = store.listRoots().filter((root) => {
53
+ if (wantDir !== (root.kind !== 'file')) return false;
54
+ const identity = rootIdentity(root);
55
+ return Boolean(identity) && identity.device === stat.dev && identity.inode === stat.ino;
56
+ });
57
+ if (matches.length !== 1) return null;
58
+ return orphanedAtAddress(matches[0]) ? matches[0] : null;
59
+ }
60
+
61
+ /**
62
+ * A scan's evidence horizon is the root's ground; the root's own
63
+ * address is the horizon's edge. Ground missing there — or a directory
64
+ * root's address wearing a different physical identity — is
65
+ * UNDECIDABLE from here: moved away, deleted, or replaced by a
66
+ * lookalike all present the same address, and concluding "deleted"
67
+ * would end every channel a rescue could have saved. So the scan
68
+ * concludes nothing: beliefs freeze, the referee convicts the
69
+ * divergence, and the next enrollment moment decides — naming the new
70
+ * address rescues, re-naming this one adopts, and explicit removal is
71
+ * the only deletion (an external filesystem change is never permission
72
+ * to end identity). A single-ground root's address lawfully churns
73
+ * inodes (atomic saves, retargets), so only absence — or foreign
74
+ * directory ground — freezes it; presence is the file laws' business.
75
+ */
76
+ function addressUndecided(root) {
77
+ let stat;
78
+ try {
79
+ stat = fs.lstatSync(root.path);
80
+ } catch {
81
+ return true;
82
+ }
83
+ if (root.kind === 'file') return !stat.isFile() && !stat.isSymbolicLink();
84
+ if (!stat.isDirectory()) return true;
85
+ return root.device != null && (stat.dev !== root.device || stat.ino !== root.inode);
86
+ }
87
+
88
+ /**
89
+ * Identity persists; local presence is separate evidence. These two
90
+ * transitions are the ONLY writers of `present`, and every scan entry
91
+ * funnels through them — so consumers track one bit through one
92
+ * symmetric event pair, never a grammar of unrelated events. Neither
93
+ * ever concludes lifecycle: unavailable is not deleted, and available
94
+ * is not new.
95
+ */
96
+ function markAbsent(root) {
97
+ if (!root.present) return;
98
+ store.setRootPresent(root.rootId, false);
99
+ publish({ type: 'root.unavailable', rootId: root.rootId, path: root.path });
100
+ }
101
+
102
+ function markPresent(root) {
103
+ if (root.present) return;
104
+ store.setRootPresent(root.rootId, true);
105
+ publish({ type: 'root.available', rootId: root.rootId, path: root.path });
106
+ }
107
+
108
+ return { addressUndecided, findMovedRoot, markAbsent, markPresent, rootIdentity };
109
+ }
110
+
111
+ module.exports = { createContinuity };