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
@@ -0,0 +1,1365 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The ENTITY oracle — files, folders, links, and repositories — proving
5
+ * itself against the current detector: the judge's expected state is the
6
+ * six-field universal entity record (docs/entity-model.md), and EVERY
7
+ * field of every record is judged
8
+ * at every quiet point: addresses re-derive through the parent chain,
9
+ * lifecycle is EXPLICIT registry law that detection never writes (a
10
+ * disappearance closes the channel and moves no record), git is a lens
11
+ * (type transition on one uuid — including the enrolled root — with
12
+ * entities persisting beneath it and exactly one adapter owning detection
13
+ * at a time), and both halves of the ratified version formula recompute
14
+ * from records + payload evidence alone. The boundary adapter
15
+ * (entity-oracle/adapter.js) compares the slice today's detector claims;
16
+ * when the registry lands, the adapter widens what the detector is held to.
17
+ *
18
+ * Before folder, link, or registry code exists, the judge must be shown
19
+ * able to catch bugs: these tests drive the real observer through the
20
+ * filesystem operation grammar and demand that four independent witnesses
21
+ * (operation model, disk referee, detector slice, event ledger) agree at
22
+ * every proven quiet point — then deliberately feed the judge lies and
23
+ * demand a conviction. A judge that can be fooled here would silently bless
24
+ * everything built after it.
25
+ *
26
+ * Two categories, named for what they prove:
27
+ * - catch-up tests drive detection with explicit scans and judge
28
+ * convergence;
29
+ * - the event-only test rings doorbells through production wiring after
30
+ * start() and never invokes a manual scan — the referee stays
31
+ * read-only while judging.
32
+ */
33
+
34
+ const test = require('node:test');
35
+ const assert = require('node:assert/strict');
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+
39
+ const { sha256 } = require('./entity-oracle/graph');
40
+ const {
41
+ cleanup, converged, defaultPolicy, fakeWatchKit, fileIdAt, linkIdAt,
42
+ makeKit, sleep,
43
+ } = require('./entity.rig');
44
+
45
+ test.after(cleanup);
46
+
47
+ // --- the grammar, with the whole oracle agreeing at every quiet point -----
48
+
49
+ test('enrollment is the birth of channels, and all four witnesses agree', () => {
50
+ const kit = makeKit('birth');
51
+ kit.write('a.md', 'alpha v1');
52
+ kit.write('sub/nested/b.txt', 'aaaa');
53
+ kit.write('sub/c.md', 'gamma');
54
+ kit.write('bin/data.bin', Buffer.from([0x00, 0xff, 0x00, 0x7f, 0x80, 0x00]));
55
+
56
+ kit.enroll();
57
+ kit.point();
58
+
59
+ assert.equal(kit.events.filter((e) => e.type === 'file.created').length, 4);
60
+ });
61
+
62
+ test('the filesystem grammar converges: edit, same-size, touch, replace, rename, move, swap, duplicate, delete, recreate', () => {
63
+ const kit = makeKit('grammar');
64
+ kit.write('a.md', 'alpha v1');
65
+ kit.write('sub/nested/b.txt', 'aaaa');
66
+ kit.write('sub/c.md', 'gamma');
67
+ kit.enroll();
68
+ kit.point();
69
+ const originalA = fileIdAt(kit, 'a.md');
70
+
71
+ // Content edit.
72
+ kit.write('a.md', 'alpha v2 with more bytes');
73
+ kit.point();
74
+
75
+ // Same-size edit (stats lie about content).
76
+ kit.write('sub/nested/b.txt', 'bbbb');
77
+ kit.point();
78
+
79
+ // A touch with identical bytes is silence.
80
+ kit.touchSame('a.md');
81
+ const before = kit.events.length;
82
+ kit.point();
83
+ assert.equal(kit.events.slice(before).filter((e) => e.type.startsWith('file.')).length, 0,
84
+ 'identical bytes must be silence');
85
+
86
+ // Atomic replacement: new physical file, same address, same identity.
87
+ kit.replace('a.md', 'alpha v3 atomically');
88
+ kit.point();
89
+ assert.equal(fileIdAt(kit, 'a.md'), originalA, 'atomic replacement must not re-mint the channel');
90
+
91
+ // Rename into a new folder: exactly one folder-blind rename event today.
92
+ const preRename = kit.events.length;
93
+ kit.mv('a.md', 'docs/a2.md');
94
+ kit.point();
95
+ const renames = kit.events.slice(preRename).filter((e) => e.type === 'file.renamed');
96
+ assert.equal(renames.length, 1);
97
+ assert.equal(fileIdAt(kit, 'docs/a2.md'), originalA, 'the move must preserve identity');
98
+
99
+ // Ancestor move: every descendant is rescued, no identity re-minted.
100
+ const nestedB = fileIdAt(kit, 'sub/nested/b.txt');
101
+ const subC = fileIdAt(kit, 'sub/c.md');
102
+ kit.mvDir('sub', 'moved-sub');
103
+ kit.point();
104
+ assert.equal(fileIdAt(kit, 'moved-sub/nested/b.txt'), nestedB);
105
+ assert.equal(fileIdAt(kit, 'moved-sub/c.md'), subC);
106
+
107
+ // Path swap: neither path is orphaned, so the address claims the channel
108
+ // and the swap is two content changes — identity is never guessed.
109
+ const preSwap = kit.events.length;
110
+ kit.mv('moved-sub/c.md', 'swap-tmp');
111
+ kit.mv('docs/a2.md', 'moved-sub/c.md');
112
+ kit.mv('swap-tmp', 'docs/a2.md');
113
+ kit.point();
114
+ const swapEvents = kit.events.slice(preSwap);
115
+ assert.equal(swapEvents.filter((e) => e.type === 'file.changed').length, 2);
116
+ assert.equal(swapEvents.filter((e) => e.type === 'file.renamed').length, 0);
117
+
118
+ // Duplicate identical content: two channels, one hash — identity is
119
+ // never derived from bytes.
120
+ kit.copy('docs/a2.md', 'docs/dupe.md', 'gamma');
121
+ kit.point();
122
+ assert.notEqual(fileIdAt(kit, 'docs/dupe.md'), fileIdAt(kit, 'docs/a2.md'));
123
+
124
+ // A settled disappearance closes the channel; the returning ground
125
+ // reconnects to the same ENTITY (the slot's active owner) while the
126
+ // channel itself is re-minted evidence.
127
+ const doomed = fileIdAt(kit, 'docs/dupe.md');
128
+ kit.rm('docs/dupe.md');
129
+ kit.point();
130
+ kit.write('docs/dupe.md', 'reborn, reconnected');
131
+ kit.point();
132
+ assert.notEqual(fileIdAt(kit, 'docs/dupe.md'), doomed,
133
+ 'the re-minted channel is fresh evidence — the entity underneath reconnects');
134
+
135
+ // Delete + recreate BETWEEN quiet points: the address never observably
136
+ // died, so the channel survives (path claims the channel).
137
+ const survivor = fileIdAt(kit, 'docs/dupe.md');
138
+ kit.rm('docs/dupe.md');
139
+ kit.write('docs/dupe.md', 'same interval, same channel');
140
+ kit.point();
141
+ assert.equal(fileIdAt(kit, 'docs/dupe.md'), survivor);
142
+ });
143
+
144
+ test('the universal record: every envelope field moves the version, the uuid never moves', () => {
145
+ const kit = makeKit('record');
146
+ kit.write('nest/doc.txt', 'v1 text');
147
+ kit.write('den/keep.txt', 'keeper');
148
+ kit.enroll();
149
+ let snap = kit.point();
150
+
151
+ const recordOf = (s, id) => s.records.find((r) => r.uuid === id);
152
+ const uuid = snap.files.get('nest/doc.txt').uuid;
153
+ const born = recordOf(snap, uuid);
154
+ assert.equal(born.type, 'file.text');
155
+ assert.equal(born.status, 'active');
156
+ assert.equal(snap.payloads.get(uuid), sha256('v1 text'));
157
+ assert.deepEqual(Object.keys(born).sort(),
158
+ ['name', 'parentUUID', 'status', 'type', 'uuid', 'version'],
159
+ 'the universal record is EXACTLY six fields — payloads are evidence, not record');
160
+ const nestUuid = born.parentUUID;
161
+
162
+ // name: a rename in place moves the version; payload and parent do not.
163
+ kit.mv('nest/doc.txt', 'nest/memo.txt');
164
+ snap = kit.point();
165
+ const renamed = recordOf(snap, uuid);
166
+ assert.equal(renamed.name, 'memo.txt');
167
+ assert.equal(renamed.parentUUID, nestUuid);
168
+ assert.equal(snap.payloads.get(uuid), sha256('v1 text'));
169
+ assert.notEqual(renamed.version, born.version, 'name is in the envelope');
170
+
171
+ // parentUUID: a move across folders moves the version again.
172
+ kit.mv('nest/memo.txt', 'den/memo.txt');
173
+ snap = kit.point();
174
+ const moved = recordOf(snap, uuid);
175
+ assert.notEqual(moved.parentUUID, nestUuid);
176
+ assert.equal(moved.name, 'memo.txt');
177
+ assert.notEqual(moved.version, renamed.version, 'parentUUID is in the envelope');
178
+
179
+ // type: the injected classification policy flips its answer — a type
180
+ // transition on the SAME uuid (Axiom 1: type change preserves identity).
181
+ kit.write('den/memo.txt', Buffer.from([0x74, 0x00, 0x78]));
182
+ snap = kit.point();
183
+ const flipped = recordOf(snap, uuid);
184
+ assert.equal(flipped.type, 'file.binary');
185
+ assert.equal(flipped.uuid, uuid);
186
+ assert.notEqual(flipped.version, moved.version, 'type is in the envelope');
187
+
188
+ // payload: a content edit moves the version through the evidence alone.
189
+ const payloadBefore = snap.payloads.get(uuid);
190
+ kit.write('den/memo.txt', Buffer.from([0x74, 0x00, 0x79]));
191
+ snap = kit.point();
192
+ const edited = recordOf(snap, uuid);
193
+ assert.equal(edited.type, 'file.binary');
194
+ assert.notEqual(snap.payloads.get(uuid), payloadBefore);
195
+ assert.notEqual(edited.version, flipped.version, 'payload is in the envelope');
196
+ });
197
+
198
+ test('disappearance is evidence, not lifecycle: no record moves, and the slot\'s owner reconnects on return', () => {
199
+ const kit = makeKit('vanish');
200
+ kit.write('den/doomed.txt', 'mortal');
201
+ kit.write('den/keep.txt', 'keeper');
202
+ kit.enroll();
203
+ let snap = kit.point();
204
+ const uuid = snap.files.get('den/doomed.txt').uuid;
205
+ const channel = fileIdAt(kit, 'den/doomed.txt');
206
+ const freeze = (s) => new Map(s.records.map((r) => [r.uuid, JSON.stringify(r)]));
207
+ const movedSince = (before, s) => s.records
208
+ .filter((r) => before.get(r.uuid) !== JSON.stringify(r))
209
+ .map((r) => r.uuid);
210
+
211
+ // rm: the watcher reports absence and the channel closes — but lifecycle
212
+ // moves only by explicit registry action, so NOT ONE record moves. (The
213
+ // registry keeps the entity active and restores local from canonical;
214
+ // trash and delete are its explicit, owner-gated acts.)
215
+ let before = freeze(snap);
216
+ kit.rm('den/doomed.txt');
217
+ snap = kit.point();
218
+ assert.deepEqual(movedSince(before, snap), [],
219
+ 'detection never writes lifecycle — a disappearance moves no record');
220
+ assert.equal(snap.records.find((r) => r.uuid === uuid).status, 'active');
221
+ assert.ok(!snap.files.has('den/doomed.txt'), 'the exposed slice reflects ground');
222
+ assert.ok(kit.events.some((e) => e.type === 'file.deleted' && e.fileId === channel),
223
+ 'absence IS reported — as channel evidence, never as a lifecycle claim');
224
+
225
+ // Reappearance at the slot: the SAME entity — an active entity owns its
226
+ // (parent, name) slot even while absent, so the returning ground
227
+ // reconnects and the new bytes are its edit. Exactly one record moves
228
+ // (the entity's own version); the channel is fresh evidence.
229
+ before = freeze(snap);
230
+ kit.write('den/doomed.txt', 'reborn');
231
+ snap = kit.point();
232
+ assert.equal(snap.files.get('den/doomed.txt').uuid, uuid,
233
+ 'reappearing ground reconnects to the active owner of the slot — never a second entity');
234
+ assert.deepEqual(movedSince(before, snap), [uuid],
235
+ 'a reconnect with new bytes moves exactly one record: the entity\'s own version');
236
+ assert.equal(snap.payloads.get(uuid), sha256('reborn'));
237
+ assert.notEqual(fileIdAt(kit, 'den/doomed.txt'), channel,
238
+ 'the channel is re-minted evidence; the entity underneath is the same');
239
+
240
+ // A whole subtree vanishing is still nothing: an external rm -rf, a
241
+ // disconnected drive, a permission failure — none may move a record.
242
+ before = freeze(snap);
243
+ kit.rmDir('den');
244
+ snap = kit.point();
245
+ assert.deepEqual(movedSince(before, snap), [],
246
+ 'a vanished subtree moves no record — lifecycle is explicit only');
247
+
248
+ // And the subtree returning with the same bytes reconnects folder and
249
+ // file alike: a full round trip through absence in which NOTHING in the
250
+ // canonical graph ever moved — because nothing ever happened to it.
251
+ kit.write('den/doomed.txt', 'reborn');
252
+ snap = kit.point();
253
+ assert.equal(snap.files.get('den/doomed.txt').uuid, uuid);
254
+ assert.deepEqual(movedSince(before, snap), [],
255
+ 'a round trip through absence with identical bytes moves nothing at all');
256
+ });
257
+
258
+ test('contested ground is divergence: two identity claims on one address move neither record', () => {
259
+ const kit = makeKit('contested');
260
+ kit.write('a.txt', 'alpha');
261
+ kit.write('b.txt', 'beta');
262
+ kit.enroll();
263
+ let snap = kit.point();
264
+ const uuidA = snap.files.get('a.txt').uuid;
265
+ const uuidB = snap.files.get('b.txt').uuid;
266
+ const channelA = fileIdAt(kit, 'a.txt');
267
+ const freeze = (s) => new Map(s.records.map((r) => [r.uuid, JSON.stringify(r)]));
268
+ const movedSince = (before, s) => s.records
269
+ .filter((r) => before.get(r.uuid) !== JSON.stringify(r))
270
+ .map((r) => r.uuid);
271
+
272
+ // b.txt departs; its active entity keeps the slot.
273
+ kit.rm('b.txt');
274
+ snap = kit.point();
275
+ const before = freeze(snap);
276
+ const regBefore = kit.regEvents.length;
277
+
278
+ // The physical a.txt lands on b.txt's slot. The channel honestly
279
+ // continues (one rename) AND an active entity owns the address: two
280
+ // established identity claims, one address — Amalgm picks no winner.
281
+ // Neither record moves, nothing is written, and the ground is reported
282
+ // as divergence pending resolution.
283
+ kit.mv('a.txt', 'b.txt');
284
+ snap = kit.point();
285
+ assert.deepEqual(movedSince(before, snap), [], 'a contest moves NEITHER record');
286
+ assert.ok(!snap.files.has('b.txt'), 'contested ground is claimed by no entity');
287
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha'),
288
+ 'the contest itself is reported evidence, never hidden');
289
+ assert.equal(fileIdAt(kit, 'b.txt'), channelA,
290
+ 'the observer honestly reports the continued channel — identity is never the channel\'s call');
291
+ assert.equal(kit.regEvents.length, regBefore, 'a contest commits nothing to the registry');
292
+
293
+ // The contest survives quiet points: nothing happened, nothing resolves.
294
+ snap = kit.point();
295
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
296
+
297
+ // An atomic replace at the contested address is an ordinary edit OF THE
298
+ // CONTESTED GROUND: address continuity carries the contest, and the new
299
+ // bytes are never the claimant's payload nor the owner's.
300
+ kit.replace('b.txt', 'alpha v2');
301
+ snap = kit.point();
302
+ assert.deepEqual(movedSince(before, snap), [], 'an edit on contested ground moves no record');
303
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha v2'));
304
+ assert.equal(kit.regEvents.length, regBefore);
305
+
306
+ // Ground returning at the claimant's old address resolves NOTHING: the
307
+ // new file could be a copy — presence proves nothing about identity.
308
+ // The claimant reconnects at home on a fresh channel; the contest stands.
309
+ kit.write('a.txt', 'returned');
310
+ snap = kit.point();
311
+ assert.equal(snap.files.get('a.txt').uuid, uuidA,
312
+ 'the claimant still owns its own slot and reconnects on return');
313
+ assert.notEqual(fileIdAt(kit, 'a.txt'), channelA);
314
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha v2'),
315
+ 'reappearance is not resolution — the contest survives it');
316
+ assert.equal(snap.payloads.get(uuidB), sha256('beta'),
317
+ 'the slot\'s owner never absorbed the contested bytes');
318
+
319
+ // The contest ends ONLY when the conflicting ground actually leaves.
320
+ kit.rm('b.txt');
321
+ snap = kit.point();
322
+ assert.equal(snap.divergent.size, 0, 'the ground left; the contest is over');
323
+
324
+ // And the slot's owner reconnects on genuinely fresh ground, as always.
325
+ kit.write('b.txt', 'beta again');
326
+ snap = kit.point();
327
+ assert.equal(snap.files.get('b.txt').uuid, uuidB);
328
+ assert.equal(snap.payloads.get(uuidB), sha256('beta again'));
329
+ });
330
+
331
+ test('a contest ends when its ground leaves: the open channel resumes ordinary continuity', () => {
332
+ const kit = makeKit('contest-moves');
333
+ kit.write('a.txt', 'alpha');
334
+ kit.write('b.txt', 'beta');
335
+ kit.enroll();
336
+ let snap = kit.point();
337
+ const uuidA = snap.files.get('a.txt').uuid;
338
+ const uuidB = snap.files.get('b.txt').uuid;
339
+ const channelA = fileIdAt(kit, 'a.txt');
340
+ kit.rm('b.txt');
341
+ kit.point();
342
+ kit.mv('a.txt', 'b.txt');
343
+ snap = kit.point();
344
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
345
+
346
+ // The contested ground moves on to UNOWNED ground: the contest dissolves
347
+ // — its ground left the owned address — and the still-open channel's
348
+ // continuity simply resumes. One entity finally moves, and no contested
349
+ // question was ever answered by guessing.
350
+ kit.mv('b.txt', 'c.txt');
351
+ snap = kit.point();
352
+ assert.equal(snap.divergent.size, 0);
353
+ assert.equal(snap.files.get('c.txt').uuid, uuidA, 'continuity resumes on unowned ground');
354
+ assert.equal(fileIdAt(kit, 'c.txt'), channelA, 'the same channel carried it the whole way');
355
+ assert.equal(snap.records.find((r) => r.uuid === uuidB).name, 'b.txt',
356
+ 'the slot\'s owner is untouched throughout');
357
+ });
358
+
359
+ test('a re-grounded claimant\'s surplus channel stays divergence wherever its ground goes', () => {
360
+ const kit = makeKit('contest-surplus');
361
+ kit.write('a.txt', 'alpha');
362
+ kit.write('b.txt', 'beta');
363
+ kit.enroll();
364
+ let snap = kit.point();
365
+ const uuidA = snap.files.get('a.txt').uuid;
366
+ const uuidB = snap.files.get('b.txt').uuid;
367
+ kit.rm('b.txt');
368
+ kit.point();
369
+ kit.mv('a.txt', 'b.txt');
370
+ kit.point();
371
+ kit.write('a.txt', 'returned');
372
+ snap = kit.point();
373
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
374
+
375
+ // The claimant already holds canonical ground at home, so when the
376
+ // contested ground moves — even onto an UNOWNED address — its channel is
377
+ // surplus evidence: only one channel may stand as canonical ground, so
378
+ // the ground stays divergence, following the physical file.
379
+ kit.mv('b.txt', 'c.txt');
380
+ snap = kit.point();
381
+ assert.ok(!snap.divergent.has('b.txt'), 'the owned address is contested no longer');
382
+ assert.equal(snap.divergent.get('c.txt'), sha256('alpha'),
383
+ 'the surplus channel\'s ground is divergence wherever it sits');
384
+ assert.ok(!snap.files.has('c.txt'), 'no entity claims it');
385
+ assert.equal(snap.files.get('a.txt').uuid, uuidA, 'the claimant keeps its canonical ground');
386
+ assert.equal(snap.records.find((r) => r.uuid === uuidB).name, 'b.txt',
387
+ 'the old slot owner is untouched');
388
+
389
+ // The surplus ground leaving closes its channel and ends the divergence;
390
+ // genuinely fresh ground at that address is then an ordinary birth.
391
+ kit.rm('c.txt');
392
+ snap = kit.point();
393
+ assert.equal(snap.divergent.size, 0);
394
+ kit.write('c.txt', 'fresh');
395
+ snap = kit.point();
396
+ const bornUuid = snap.files.get('c.txt').uuid;
397
+ assert.ok(bornUuid !== uuidA && bornUuid !== uuidB,
398
+ 'unmarked, unowned ground births a new entity — divergence never resolves by default');
399
+ });
400
+
401
+ test('an active entity owns its slot across kinds: valid type transitions ride one uuid', () => {
402
+ const kit = makeKit('slot-kind');
403
+ kit.write('shed/tool.txt', 'tool bytes');
404
+ kit.write('keep.md', 'bystander');
405
+ kit.enroll();
406
+ let snap = kit.point();
407
+ const toolUuid = snap.files.get('shed/tool.txt').uuid;
408
+ const wsBefore = JSON.stringify(snap.records.find((r) => r.parentUUID === null));
409
+
410
+ // The FILE departs; its active record keeps the (shed, "tool.txt") slot.
411
+ // FOLDER ground lands on it: ownership is identity, not kind, so this is
412
+ // the same entity changing type (Axiom 1) — never a second claimant. A
413
+ // file → folder transition is ALWAYS valid: a file never holds children.
414
+ kit.rm('shed/tool.txt');
415
+ kit.point();
416
+ kit.mkdir('shed/tool.txt');
417
+ snap = kit.point();
418
+ const asFolder = snap.records.find((r) => r.uuid === toolUuid);
419
+ assert.equal(asFolder.type, 'folder',
420
+ 'the slot owner reconnects across kinds — folder ground, same entity');
421
+ assert.equal(asFolder.status, 'active');
422
+
423
+ // And back — inside ONE interval this time. The folder is EMPTY (it
424
+ // never held an active child), so folder → file is a valid transition
425
+ // too: same uuid, file again, between two quiet points.
426
+ kit.rmDir('shed/tool.txt');
427
+ kit.write('shed/tool.txt', 'a file again');
428
+ snap = kit.point();
429
+ assert.equal(snap.files.get('shed/tool.txt').uuid, toolUuid,
430
+ 'empty folder → file inside one interval still reconnects to the slot\'s owner');
431
+ assert.equal(snap.records.find((r) => r.uuid === toolUuid).type, 'file.text');
432
+ assert.equal(snap.payloads.get(toolUuid), sha256('a file again'));
433
+
434
+ // The workspace never noticed: the same uuids held the same slots
435
+ // through the whole journey, so the root record is byte-for-byte still.
436
+ assert.equal(JSON.stringify(snap.records.find((r) => r.parentUUID === null)), wsBefore,
437
+ 'type transitions on a slot move nothing above it');
438
+ });
439
+
440
+ test('only containers hold active children: an occupied folder swapped for a file is divergence, not a transition', () => {
441
+ const kit = makeKit('container');
442
+ kit.write('den/cub.txt', 'cub');
443
+ kit.enroll();
444
+ let snap = kit.point();
445
+ const cubUuid = snap.files.get('den/cub.txt').uuid;
446
+ const denUuid = snap.records.find((r) => r.uuid === cubUuid).parentUUID;
447
+ const freeze = (s) => new Map(s.records.map((r) => [r.uuid, JSON.stringify(r)]));
448
+ const movedSince = (before, s) => s.records
449
+ .filter((r) => before.get(r.uuid) !== JSON.stringify(r))
450
+ .map((r) => r.uuid);
451
+ const before = freeze(snap);
452
+
453
+ // The folder AND its child are swapped for a file at the folder's slot.
454
+ // Accepting that flip would canonicalize the subtree destruction that
455
+ // explicit-only lifecycle refuses to infer — so NOT ONE record moves.
456
+ // The file is honest, entity-less divergence: its channel and events are
457
+ // real, no entity claims it, resolution is explicit (amalgm resets local
458
+ // from canonical, or the owner moves/trashes the children first).
459
+ kit.rmDir('den');
460
+ kit.write('den', 'impostor');
461
+ snap = kit.point();
462
+ assert.deepEqual(movedSince(before, snap), [],
463
+ 'an invalid type transition moves nothing — the canonical graph refuses the swap');
464
+ assert.equal(snap.records.find((r) => r.uuid === denUuid).type, 'folder');
465
+ assert.equal(snap.records.find((r) => r.uuid === cubUuid).status, 'active');
466
+ assert.ok(!snap.files.has('den'), 'divergent ground is claimed by no entity');
467
+ assert.equal(snap.divergent.get('den'), sha256('impostor'),
468
+ 'the divergence itself is reported evidence, never hidden');
469
+ assert.ok(kit.events.some((e) => e.type === 'file.created' && e.path === 'den'),
470
+ 'detection reports what happened — the refusal is canonical, not observational');
471
+
472
+ // Divergence resolves when ground reverts: the impostor goes, the folder
473
+ // and child return with the same bytes — and across the ENTIRE ordeal
474
+ // not one record ever moved.
475
+ kit.rm('den');
476
+ kit.write('den/cub.txt', 'cub');
477
+ snap = kit.point();
478
+ assert.deepEqual(movedSince(before, snap), [],
479
+ 'a refused swap plus a full local restore is zero canonical history');
480
+ assert.equal(snap.files.get('den/cub.txt').uuid, cubUuid);
481
+ assert.equal(snap.divergent.size, 0);
482
+ });
483
+
484
+ test('an ancestor rename touches its own record only — descendants stay silent', () => {
485
+ const kit = makeKit('silent');
486
+ kit.write('den/cub.txt', 'cub');
487
+ kit.write('den/deep/pup.txt', 'pup');
488
+ kit.enroll();
489
+ let snap = kit.point();
490
+ const cubUuid = snap.files.get('den/cub.txt').uuid;
491
+ const denUuid = snap.records.find((r) => r.uuid === cubUuid).parentUUID;
492
+ const denBefore = snap.records.find((r) => r.uuid === denUuid);
493
+ const denPayloadBefore = snap.payloads.get(denUuid);
494
+ const wsUuid = snap.records.find((r) => r.type === 'workspace').uuid;
495
+ const wsPayloadBefore = snap.payloads.get(wsUuid);
496
+ const cubBefore = snap.records.find((r) => r.uuid === cubUuid);
497
+ const deepBefore = snap.records.find((r) => r.type === 'folder' && r.name === 'deep');
498
+ const pupBefore = snap.records.find((r) => r.name === 'pup.txt');
499
+
500
+ kit.mvDir('den', 'lair');
501
+ snap = kit.point();
502
+ const lair = snap.records.find((r) => r.uuid === denUuid);
503
+ assert.equal(lair.name, 'lair');
504
+ assert.equal(snap.payloads.get(denUuid), denPayloadBefore,
505
+ 'membership is (childUUID, name) pairs — unchanged by the parent\'s own rename');
506
+ assert.notEqual(lair.version, denBefore.version);
507
+ // The workspace membership DOES move: its pair (denUuid, name) changed.
508
+ assert.notEqual(snap.payloads.get(wsUuid), wsPayloadBefore);
509
+ // Every descendant record is byte-for-byte untouched.
510
+ assert.deepEqual(snap.records.find((r) => r.uuid === cubUuid), cubBefore,
511
+ 'descendant records must not move when an ancestor renames');
512
+ assert.deepEqual(snap.records.find((r) => r.uuid === deepBefore.uuid), deepBefore);
513
+ assert.deepEqual(snap.records.find((r) => r.uuid === pupBefore.uuid), pupBefore);
514
+ });
515
+
516
+ // --- folder detection: directories are channels, folders are entities -----
517
+
518
+ test('a folder is an entity: an empty directory is born, dies as evidence only, and reconnects on one uuid', () => {
519
+ const kit = makeKit('hollow');
520
+ kit.mkdir('hollow');
521
+ kit.enroll();
522
+ kit.point();
523
+ const rootUuid = kit.registry.roots()[0].uuid;
524
+ const hollow = kit.registry.activeAt(rootUuid, 'hollow');
525
+ assert.equal(hollow.type, 'folder', 'an empty directory is a folder entity the moment disk holds it');
526
+
527
+ // Disappearance is evidence: the channel closes, the record stays active.
528
+ kit.rmDir('hollow');
529
+ kit.point();
530
+ assert.equal(kit.registry.activeAt(rootUuid, 'hollow').uuid, hollow.uuid,
531
+ 'the record keeps its slot while locally absent — lifecycle is explicit, never inferred');
532
+
533
+ kit.mkdir('hollow');
534
+ kit.point();
535
+ assert.equal(kit.registry.activeAt(rootUuid, 'hollow').uuid, hollow.uuid,
536
+ 'reappearing dir ground reconnects to the absent owner — one entity, one uuid');
537
+ assert.equal(kit.events.filter((e) => e.type === 'dir.created').length, 2);
538
+ assert.equal(kit.events.filter((e) => e.type === 'dir.deleted').length, 1);
539
+ });
540
+
541
+ test('a folder rename is one recorded fact: same uuid, one commit, descendants silent in the registry too', () => {
542
+ const kit = makeKit('one-commit');
543
+ kit.write('docs/note.md', 'note');
544
+ kit.write('docs/deep/plan.md', 'plan');
545
+ kit.enroll();
546
+ kit.point();
547
+ const rootUuid = kit.registry.roots()[0].uuid;
548
+ const docs = kit.registry.activeAt(rootUuid, 'docs');
549
+ const deep = kit.registry.activeAt(docs.uuid, 'deep');
550
+ const note = kit.registry.activeAt(docs.uuid, 'note.md');
551
+
552
+ const mark = kit.regEvents.length;
553
+ kit.mvDir('docs', 'papers');
554
+ kit.point();
555
+ const committed = kit.regEvents.slice(mark).filter((event) => event.type === 'entity.committed');
556
+ assert.equal(committed.length, 1, 'a folder move is recorded once, on the folder\'s own record');
557
+ assert.equal(committed[0].record.uuid, docs.uuid);
558
+ const papers = kit.registry.activeAt(rootUuid, 'papers');
559
+ assert.equal(papers.uuid, docs.uuid, 'the directory channel carried the folder\'s identity by inode');
560
+ assert.equal(kit.registry.activeAt(papers.uuid, 'note.md').uuid, note.uuid);
561
+ assert.equal(kit.registry.activeAt(papers.uuid, 'deep').uuid, deep.uuid);
562
+ });
563
+
564
+ test('two directories exchange names: identity crosses by inode, exactly like the files inside them', () => {
565
+ const kit = makeKit('dir-swap');
566
+ kit.write('alpha/a.txt', 'in-alpha');
567
+ kit.write('beta/b.txt', 'in-beta');
568
+ kit.enroll();
569
+ kit.point();
570
+ const rootUuid = kit.registry.roots()[0].uuid;
571
+ const alpha = kit.registry.activeAt(rootUuid, 'alpha');
572
+ const beta = kit.registry.activeAt(rootUuid, 'beta');
573
+ const fileA = kit.registry.activeAt(alpha.uuid, 'a.txt');
574
+
575
+ // The swap crosses between two scans; detection sees only the endpoints.
576
+ const mark = kit.regEvents.length;
577
+ kit.mvDir('alpha', 'limbo');
578
+ kit.mvDir('beta', 'alpha');
579
+ kit.mvDir('limbo', 'beta');
580
+ const snap = kit.point();
581
+ assert.equal(snap.divergent.size, 0, 'an exchange is continuity, never contest');
582
+ assert.equal(kit.registry.activeAt(rootUuid, 'beta').uuid, alpha.uuid,
583
+ 'the directory once named alpha is the entity now named beta');
584
+ assert.equal(kit.registry.activeAt(rootUuid, 'alpha').uuid, beta.uuid,
585
+ 'the directory once named beta is the entity now named alpha');
586
+ assert.equal(kit.registry.activeAt(alpha.uuid, 'a.txt').uuid, fileA.uuid,
587
+ 'the file rode its physical parent across the exchange');
588
+ const committed = kit.regEvents.slice(mark).filter((event) => event.type === 'entity.committed');
589
+ assert.equal(committed.length, 2, 'an exchange is two recorded facts — one per folder, descendants silent');
590
+ });
591
+
592
+ test('a directory replacing a vanished one is a contest, never an inheritance', () => {
593
+ const kit = makeKit('dir-replace');
594
+ kit.mkdir('a');
595
+ kit.mkdir('b');
596
+ kit.enroll();
597
+ kit.point();
598
+ const rootUuid = kit.registry.roots()[0].uuid;
599
+ const dirA = kit.registry.activeAt(rootUuid, 'a');
600
+ const dirB = kit.registry.activeAt(rootUuid, 'b');
601
+
602
+ // One interval: b's ground vanishes AND a moves onto its name. The owner
603
+ // parks in the very settle the mover arrives — still a parked ACTIVE
604
+ // owner, still two identity claims on one address.
605
+ kit.rmDir('b');
606
+ kit.mvDir('a', 'b');
607
+ let snap = kit.point();
608
+ assert.deepEqual([...snap.divergent], [['b', null]],
609
+ 'the moved directory is divergence on the vanished owner\'s slot');
610
+ assert.equal(kit.registry.activeAt(rootUuid, 'a').uuid, dirA.uuid, 'the mover\'s record did not move');
611
+ assert.equal(kit.registry.activeAt(rootUuid, 'b').uuid, dirB.uuid, 'the vanished owner keeps its slot');
612
+
613
+ kit.mvDir('b', 'c'); // the ground leaves the contested address: resolution
614
+ snap = kit.point();
615
+ assert.equal(snap.divergent.size, 0, 'the contest ended when its ground left');
616
+ assert.equal(kit.registry.activeAt(rootUuid, 'c').uuid, dirA.uuid,
617
+ 'the mover\'s never-closed channel resumed ordinary continuity');
618
+ assert.equal(kit.registry.activeAt(rootUuid, 'b').uuid, dirB.uuid,
619
+ 'the owner still owns its slot, parked and untouched');
620
+ });
621
+
622
+ test('folder ↔ repo.git rides one uuid: the dir channel yields to territory and returns', () => {
623
+ const kit = makeKit('lens-uuid');
624
+ kit.write('pkg/lib.md', 'lib');
625
+ kit.enroll();
626
+ kit.point();
627
+ const rootUuid = kit.registry.roots()[0].uuid;
628
+ const pkg = kit.registry.activeAt(rootUuid, 'pkg');
629
+ assert.equal(pkg.type, 'folder');
630
+
631
+ kit.gitInit('pkg');
632
+ kit.point();
633
+ const asRepo = kit.registry.activeAt(rootUuid, 'pkg');
634
+ assert.equal(asRepo.uuid, pkg.uuid, 'the boundary is a type transition, never a new entity');
635
+ assert.equal(asRepo.type, 'repo.git');
636
+ assert.equal(kit.observer.dirsForRoot(kit.rootId).length, 0,
637
+ 'territory ground carries no dir channel — one adapter owns detection at a time');
638
+
639
+ kit.gitRemove('pkg');
640
+ kit.point();
641
+ const back = kit.registry.activeAt(rootUuid, 'pkg');
642
+ assert.equal(back.uuid, pkg.uuid, 'lifting the lens returns the folder on the same uuid');
643
+ assert.equal(back.type, 'folder');
644
+ });
645
+
646
+ test('contested folder ground: a moved directory onto a parked owner\'s slot is divergence, subtree included', () => {
647
+ const kit = makeKit('dir-contest');
648
+ kit.mkdir('a');
649
+ kit.write('b/f.txt', 'bravo');
650
+ kit.enroll();
651
+ kit.point();
652
+ const rootUuid = kit.registry.roots()[0].uuid;
653
+ const ownerA = kit.registry.activeAt(rootUuid, 'a');
654
+ const claimB = kit.registry.activeAt(rootUuid, 'b');
655
+ const fileF = kit.registry.activeAt(claimB.uuid, 'f.txt');
656
+
657
+ kit.rmDir('a'); // the owner's ground leaves; its record parks, active
658
+ kit.point();
659
+
660
+ kit.mvDir('b', 'a'); // dir continuity arrives on the parked owner's slot
661
+ let snap = kit.point();
662
+ assert.deepEqual([...snap.divergent].sort(), [['a', null], ['a/f.txt', sha256('bravo')]],
663
+ 'the contest covers the moved subtree: dir ground with null bytes, file ground with its own');
664
+ assert.equal(kit.registry.activeAt(rootUuid, 'a').uuid, ownerA.uuid, 'the owner\'s record did not move');
665
+ assert.equal(kit.registry.activeAt(rootUuid, 'b').uuid, claimB.uuid, 'the claimant\'s record did not move');
666
+ assert.equal(kit.registry.activeAt(claimB.uuid, 'f.txt').uuid, fileF.uuid, 'the subtree\'s records did not move');
667
+
668
+ kit.mvDir('a', 'c'); // the ground leaves the contested address: resolution
669
+ snap = kit.point();
670
+ assert.equal(snap.divergent.size, 0, 'the contest ended when its ground left');
671
+ assert.equal(kit.registry.activeAt(rootUuid, 'c').uuid, claimB.uuid,
672
+ 'the claimant\'s never-closed channel resumed ordinary continuity');
673
+ assert.equal(kit.registry.activeAt(claimB.uuid, 'f.txt').uuid, fileF.uuid);
674
+ assert.equal(kit.registry.activeAt(rootUuid, 'a').uuid, ownerA.uuid,
675
+ 'the owner still owns its slot, parked and untouched');
676
+ });
677
+
678
+ test('a contest is kind-strict: file ground swapped for a directory has left, and the owner reconnects across kinds', () => {
679
+ const kit = makeKit('kind-swap');
680
+ kit.write('a.txt', 'alpha');
681
+ kit.write('b.txt', 'beta');
682
+ kit.enroll();
683
+ kit.point();
684
+ const rootUuid = kit.registry.roots()[0].uuid;
685
+ const ownerB = kit.registry.activeAt(rootUuid, 'b.txt');
686
+
687
+ kit.rm('b.txt');
688
+ kit.point();
689
+ kit.mv('a.txt', 'b.txt'); // the claimant's channel lands on the owner's slot
690
+ let snap = kit.point();
691
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
692
+
693
+ // In one interval the contested FILE leaves and a DIRECTORY lands on the
694
+ // same address. A contest carries by address continuity within its kind:
695
+ // ground of the mark's kind is gone, so the contest is over — and the
696
+ // arriving dir ground reconnects to the slot's owner across kinds,
697
+ // because ownership is identity, not kind.
698
+ kit.rm('b.txt');
699
+ kit.mkdir('b.txt');
700
+ snap = kit.point();
701
+ assert.equal(snap.divergent.size, 0, 'a file mark does not carry on dir ground');
702
+ const back = kit.registry.activeAt(rootUuid, 'b.txt');
703
+ assert.equal(back.uuid, ownerB.uuid, 'the owner returned as a folder on its own uuid');
704
+ assert.equal(back.type, 'folder');
705
+ });
706
+
707
+ test('event-only: a folder rename through doorbells alone — identity preserved with no manual scan', async () => {
708
+ const bells = fakeWatchKit();
709
+ const kit = makeKit('dir-doorbell', { watch: bells.watch, settleMs: 1 });
710
+ kit.write('den/cub.md', 'cub');
711
+ kit.mkdir('den/empty');
712
+ kit.enroll();
713
+ kit.point();
714
+ kit.observer.start();
715
+ try {
716
+ const rootUuid = kit.registry.roots()[0].uuid;
717
+ const den = kit.registry.activeAt(rootUuid, 'den');
718
+ const empty = kit.registry.activeAt(den.uuid, 'empty');
719
+
720
+ kit.mvDir('den', 'burrow');
721
+ await converged(kit, () => bells.ring(kit.dir, 'den'));
722
+ kit.point({ scan: false });
723
+ assert.equal(kit.registry.activeAt(rootUuid, 'burrow').uuid, den.uuid,
724
+ 'the doorbell-driven scan carried the folder identity by inode');
725
+ assert.equal(kit.registry.activeAt(den.uuid, 'empty').uuid, empty.uuid,
726
+ 'the empty child folder rode its parent\'s move without its own record moving');
727
+ } finally {
728
+ kit.observer.stop();
729
+ }
730
+ });
731
+
732
+ // --- link detection: a pointer is leaf ground; its payload is its written address ---
733
+
734
+ test('link grammar converges: birth, retarget, rename, silent recreation, name exchange, death and reconnection', () => {
735
+ const kit = makeKit('links');
736
+ kit.write('real.md', 'real bytes');
737
+ kit.symlink('ln.md', 'real.md');
738
+ kit.symlink('ghost.md', 'nowhere/said-so'); // dangling is honest ground
739
+ kit.enroll();
740
+ let snap = kit.point();
741
+ const uuid = snap.links.get('ln.md').uuid;
742
+ assert.equal(snap.payloads.get(uuid), 'real.md', 'the payload is the pointer exactly as written');
743
+
744
+ // Retarget IS unlink + recreate under a standing address — the address
745
+ // absorbs the churn; channel and entity both continue.
746
+ const channel = linkIdAt(kit, 'ln.md');
747
+ kit.retarget('ln.md', 'ghost.md');
748
+ snap = kit.point();
749
+ assert.equal(snap.links.get('ln.md').uuid, uuid);
750
+ assert.equal(linkIdAt(kit, 'ln.md'), channel, 'retarget continues the channel: the address claims first');
751
+ assert.equal(snap.payloads.get(uuid), 'ghost.md');
752
+
753
+ // Rename: mv preserves a symlink's inode; exactly-one continuity rescues.
754
+ kit.mvLink('ln.md', 'renamed.md');
755
+ snap = kit.point();
756
+ assert.equal(snap.links.get('renamed.md').uuid, uuid);
757
+ assert.equal(linkIdAt(kit, 'renamed.md'), channel);
758
+
759
+ // Silent recreation: a new inode under an old name with an old target.
760
+ const quiet = kit.events.length;
761
+ kit.retarget('renamed.md', 'ghost.md');
762
+ snap = kit.point();
763
+ assert.equal(kit.events.slice(quiet).filter((e) => e.type.startsWith('link.')).length, 0,
764
+ 'an equal pointer is silence');
765
+ assert.equal(snap.links.get('renamed.md').uuid, uuid);
766
+
767
+ // Two links exchanging names read as two retargets: a link's meaning IS
768
+ // its written address, so the path claims first and nobody crosses.
769
+ const ghostUuid = snap.links.get('ghost.md').uuid;
770
+ const preSwap = kit.events.length;
771
+ kit.mvLink('renamed.md', 'swap-tmp');
772
+ kit.mvLink('ghost.md', 'renamed.md');
773
+ kit.mvLink('swap-tmp', 'ghost.md');
774
+ snap = kit.point();
775
+ const swapSpeech = kit.events.slice(preSwap).filter((e) => e.type.startsWith('link.'));
776
+ assert.deepEqual(swapSpeech.map((e) => e.type).sort(), ['link.changed', 'link.changed']);
777
+ assert.equal(snap.links.get('renamed.md').uuid, uuid, 'each address kept its entity');
778
+ assert.equal(snap.links.get('ghost.md').uuid, ghostUuid);
779
+ assert.equal(snap.payloads.get(uuid), 'nowhere/said-so');
780
+ assert.equal(snap.payloads.get(ghostUuid), 'ghost.md');
781
+
782
+ // A settled disappearance closes the channel; the record moves not one
783
+ // field, and returning pointer ground reconnects to the slot's owner.
784
+ kit.rmLink('ghost.md');
785
+ snap = kit.point();
786
+ assert.equal(snap.records.find((r) => r.uuid === ghostUuid).status, 'active');
787
+ assert.ok(!snap.links.has('ghost.md'));
788
+ kit.symlink('ghost.md', 'reborn-target');
789
+ snap = kit.point();
790
+ assert.equal(snap.links.get('ghost.md').uuid, ghostUuid,
791
+ 'reappearing pointer ground reconnects to the active owner of the slot');
792
+ assert.equal(snap.payloads.get(ghostUuid), 'reborn-target');
793
+ });
794
+
795
+ test('a slot crosses kinds on one uuid: file → link → file is a type transition, never a second entity', () => {
796
+ const kit = makeKit('link-kinds');
797
+ kit.write('spot.md', 'file bytes');
798
+ kit.write('keep.md', 'bystander');
799
+ kit.enroll();
800
+ let snap = kit.point();
801
+ const uuid = snap.files.get('spot.md').uuid;
802
+
803
+ // The file leaves and a pointer lands on its slot inside one interval:
804
+ // ownership is identity, not kind.
805
+ kit.rm('spot.md');
806
+ kit.symlink('spot.md', 'keep.md');
807
+ snap = kit.point();
808
+ assert.equal(snap.links.get('spot.md').uuid, uuid, 'the slot owner returns as link ground on its own uuid');
809
+ assert.equal(snap.records.find((r) => r.uuid === uuid).type, 'link');
810
+ assert.equal(snap.payloads.get(uuid), 'keep.md');
811
+
812
+ // And back — across a settle this time.
813
+ kit.rmLink('spot.md');
814
+ kit.point();
815
+ kit.write('spot.md', 'a file again');
816
+ snap = kit.point();
817
+ assert.equal(snap.files.get('spot.md').uuid, uuid);
818
+ assert.equal(snap.records.find((r) => r.uuid === uuid).type, 'file.text');
819
+ });
820
+
821
+ test('links ride the git lens: covered ground closes the channel, the entity persists, the lift re-exposes it', () => {
822
+ const kit = makeKit('link-lens');
823
+ kit.write('pkg/lib.md', 'lib');
824
+ kit.symlink('pkg/ptr.md', 'lib.md');
825
+ kit.enroll();
826
+ let snap = kit.point();
827
+ const uuid = snap.links.get('pkg/ptr.md').uuid;
828
+ const channel = linkIdAt(kit, 'pkg/ptr.md');
829
+
830
+ kit.gitInit('pkg');
831
+ snap = kit.point();
832
+ assert.ok(!snap.links.has('pkg/ptr.md'), 'covered ground leaves the exposed slice');
833
+ assert.equal(snap.records.find((r) => r.uuid === uuid).status, 'active',
834
+ 'the entity under the lens keeps existing');
835
+ assert.ok(kit.events.some((e) => e.type === 'link.deleted' && e.linkId === channel),
836
+ 'the channel closes when the repo adapter takes ownership');
837
+
838
+ kit.gitRemove('pkg');
839
+ snap = kit.point();
840
+ assert.equal(snap.links.get('pkg/ptr.md').uuid, uuid, 'the lens lifts: same entity, fresh channel');
841
+ assert.notEqual(linkIdAt(kit, 'pkg/ptr.md'), channel);
842
+ });
843
+
844
+ test('the injected path-dependent policy decides enrollment — not names, not conventions', () => {
845
+ // Deliberately path-dependent: `pkg/private` is excluded ground while
846
+ // `other/private` enrolls. A name-based policy would treat them alike.
847
+ const policy = (rel) => defaultPolicy(rel) && rel !== 'pkg/private' && !rel.startsWith('pkg/private/');
848
+ const kit = makeKit('policy', { policy });
849
+ kit.write('keep.md', 'kept');
850
+ kit.write('pkg/private/hidden.md', 'never an entity');
851
+ kit.write('other/private/seen.md', 'an entity');
852
+ kit.enroll();
853
+ kit.point();
854
+
855
+ const paths = kit.observer.filesForRoot(kit.rootId).map((r) => r.relPath).sort();
856
+ assert.deepEqual(paths, ['keep.md', 'other/private/seen.md']);
857
+
858
+ // Live ops on excluded ground stay invisible; enrolled ground converges.
859
+ kit.write('pkg/private/more.md', 'still not an entity');
860
+ kit.write('keep.md', 'kept v2');
861
+ kit.point();
862
+ });
863
+
864
+ test('git is a lens: the folder transitions in place, entities persist beneath it, channels are re-minted evidence', () => {
865
+ const kit = makeKit('territory');
866
+ kit.write('top.md', 'top');
867
+ kit.write('lib/tool.md', 'tool v1');
868
+ kit.enroll();
869
+ let snap = kit.point();
870
+ const closedId = fileIdAt(kit, 'lib/tool.md');
871
+ const toolUuid = snap.files.get('lib/tool.md').uuid;
872
+ const libUuid = snap.records.find((r) => r.uuid === toolUuid).parentUUID;
873
+ assert.equal(snap.records.find((r) => r.uuid === libUuid).type, 'folder');
874
+
875
+ // A .git appears: the SAME folder entity becomes the repo (a type flip on
876
+ // one uuid), the file entities beneath it keep existing — only their
877
+ // detector channels close — and the census sees exactly one boundary.
878
+ kit.gitInit('lib');
879
+ snap = kit.point();
880
+ assert.ok(kit.events.some((e) => e.type === 'repo.discovered' && e.path === kit.abs('lib')));
881
+ assert.ok(kit.events.some((e) => e.type === 'file.deleted' && e.fileId === closedId));
882
+ const libAsRepo = snap.records.find((r) => r.uuid === libUuid);
883
+ assert.equal(libAsRepo.type, 'repo.git', 'the folder becomes the repo — same entity, new type');
884
+ assert.equal(libAsRepo.status, 'active');
885
+ assert.equal(snap.records.find((r) => r.uuid === toolUuid).status, 'active',
886
+ 'entities under the lens keep existing');
887
+ assert.ok(!snap.files.has('lib/tool.md'), 'covered ground leaves the exposed slice');
888
+
889
+ // Writes inside the lens are the follower's business, never file events —
890
+ // and covered ground is UNWITNESSED by leaf detection: no leaf entity is
891
+ // born under the lens (the repo adapter owns that ground's meaning).
892
+ const preInner = kit.events.length;
893
+ kit.write('lib/inner.md', 'born under the lens');
894
+ snap = kit.point();
895
+ assert.equal(kit.events.slice(preInner).filter((e) => e.type.startsWith('file.')).length, 0,
896
+ 'repo territory must never leak file events');
897
+ assert.ok(!snap.records.some((r) => r.name === 'inner.md'),
898
+ 'ground first written under the lens is nobody until it surfaces');
899
+
900
+ // The .git vanishes: the lens lifts and the SAME entities are exposed
901
+ // again — the slot law reconnects each parked owner — while the detector
902
+ // re-mints its channels, and ground first written under the lens
903
+ // surfaces as a NEW entity at the lift.
904
+ kit.gitRemove('lib');
905
+ snap = kit.point();
906
+ assert.ok(kit.events.some((e) => e.type === 'repo.removed' && e.path === kit.abs('lib')));
907
+ assert.equal(snap.records.find((r) => r.uuid === libUuid).type, 'folder',
908
+ 'the lens lifts: same entity, folder again');
909
+ assert.equal(snap.files.get('lib/tool.md').uuid, toolUuid,
910
+ 'the file entity survived the whole journey under the lens');
911
+ assert.ok(snap.files.get('lib/inner.md'),
912
+ 'ground born under the lens surfaces as an entity when the lens lifts');
913
+ assert.notEqual(fileIdAt(kit, 'lib/tool.md'), closedId,
914
+ 'the reclaimed CHANNEL is fresh — evidence is re-minted even though the entity persisted');
915
+ });
916
+
917
+ test('a git repository is a git repository — including at the enrolled root: same uuid under and after the lens', () => {
918
+ const kit = makeKit('root-lens');
919
+ kit.write('a.md', 'alpha');
920
+ kit.write('sub/b.md', 'beta');
921
+ kit.enroll();
922
+ let snap = kit.point();
923
+ const rootRecord = snap.records.find((r) => r.parentUUID === null);
924
+ assert.equal(rootRecord.type, 'workspace');
925
+ const aUuid = snap.files.get('a.md').uuid;
926
+ const bUuid = snap.files.get('sub/b.md').uuid;
927
+ const aChannel = fileIdAt(kit, 'a.md');
928
+
929
+ // A .git lands at the enrolled root: the SAME root record becomes the
930
+ // repo — no separate workspace-versus-repository problem — and exactly
931
+ // one adapter owns detection: the repo owns everything, so the exposed
932
+ // file slice is empty while every entity beneath persists.
933
+ kit.gitInit('');
934
+ snap = kit.point();
935
+ assert.equal(snap.records.find((r) => r.uuid === rootRecord.uuid).type, 'repo.git',
936
+ 'the root transitions in place on its own uuid');
937
+ assert.equal(snap.files.size, 0, 'under a root lens the repo adapter owns everything');
938
+ assert.ok(kit.events.some((e) => e.type === 'repo.discovered' && e.path === kit.dir));
939
+ assert.ok(kit.events.some((e) => e.type === 'file.deleted' && e.fileId === aChannel),
940
+ 'file channels close when the repo adapter takes ownership');
941
+ assert.equal(snap.records.find((r) => r.uuid === aUuid).status, 'active');
942
+ assert.equal(snap.records.find((r) => r.uuid === bUuid).status, 'active');
943
+
944
+ // The .git goes: the same root uuid is the workspace again, file
945
+ // detection returns, and the same entities are exposed on fresh channels.
946
+ kit.gitRemove('');
947
+ snap = kit.point();
948
+ assert.equal(snap.records.find((r) => r.uuid === rootRecord.uuid).type, 'workspace',
949
+ 'the lens lifts: same root entity, workspace again');
950
+ assert.equal(snap.files.get('a.md').uuid, aUuid);
951
+ assert.equal(snap.files.get('sub/b.md').uuid, bUuid);
952
+ assert.notEqual(fileIdAt(kit, 'a.md'), aChannel,
953
+ 'channels are re-minted evidence; identity never moves');
954
+ });
955
+
956
+ test('a doorbell-named, backdated, same-size edit is caught — timestamps can lie, doorbells cannot', async () => {
957
+ const bells = fakeWatchKit();
958
+ const kit = makeKit('backdate', { watch: bells.watch, settleMs: 1 });
959
+ kit.write('f.md', 'aaaa');
960
+ // Pin the mtime to a SECOND-ALIGNED past instant: whole seconds survive
961
+ // the utimes float conversion exactly, so the forged restore below is
962
+ // deterministic (a millisecond value can shift by 1ms in the round-trip).
963
+ const pinned = new Date(Math.floor(Date.now() / 1000) * 1000 - 10_000);
964
+ fs.utimesSync(kit.abs('f.md'), pinned, pinned);
965
+ kit.enroll();
966
+ kit.point();
967
+ kit.observer.start();
968
+ try {
969
+ const row = kit.observer.filesForRoot(kit.rootId)[0];
970
+ assert.equal(row.mtimeMs, pinned.getTime(),
971
+ 'a second-aligned pin must survive the stat round-trip exactly');
972
+
973
+ // Same size, same inode, mtime restored to the pinned value: to stats,
974
+ // nothing happened.
975
+ fs.writeFileSync(kit.abs('f.md'), 'bbbb');
976
+ kit.model.write('f.md', 'bbbb');
977
+ fs.utimesSync(kit.abs('f.md'), pinned, pinned);
978
+ assert.equal(Math.floor(fs.statSync(kit.abs('f.md')).mtimeMs), row.mtimeMs,
979
+ 'the disguise must be exact for this test to prove anything');
980
+
981
+ // The honest boundary: without a doorbell, stats hide this edit.
982
+ const blind = kit.events.length;
983
+ kit.observer.scanNow(kit.rootId);
984
+ assert.equal(kit.events.length, blind, 'a perfect stat disguise defeats an unnamed scan');
985
+
986
+ // A delivered doorbell names the file; named files are always hashed.
987
+ await converged(kit, () => bells.ring(kit.dir, 'f.md'));
988
+ assert.ok(kit.events.slice(blind).some(
989
+ (e) => e.type === 'file.changed' && e.hash === sha256('bbbb')),
990
+ 'the named doorbell must overrule the stat disguise');
991
+
992
+ kit.point();
993
+ } finally {
994
+ kit.observer.stop();
995
+ }
996
+ });
997
+
998
+ test('event-only: doorbells alone converge the detector — no manual scan after start()', async () => {
999
+ const bells = fakeWatchKit();
1000
+ const kit = makeKit('event-only', { watch: bells.watch, settleMs: 1 });
1001
+ kit.write('seed.md', 'seed');
1002
+ kit.enroll();
1003
+ kit.point(); // the catch-up era ends here
1004
+ kit.observer.start();
1005
+ try {
1006
+ // From now on: rings through production wiring only, never a manual
1007
+ // scan. The verdict runs in { scan: false } mode — the referee stays
1008
+ // read-only, so a detector that needed help would be convicted stale.
1009
+ const ring = (...names) => converged(kit, () => {
1010
+ for (const name of names) bells.ring(kit.dir, name);
1011
+ });
1012
+
1013
+ kit.write('live.md', 'v1');
1014
+ await ring('live.md');
1015
+ kit.point({ scan: false });
1016
+
1017
+ kit.write('live.md', 'v2 with more bytes');
1018
+ await ring('live.md');
1019
+ kit.point({ scan: false });
1020
+
1021
+ const liveId = fileIdAt(kit, 'live.md');
1022
+ kit.mv('live.md', 'docs/live.md');
1023
+ await ring('live.md', 'docs', 'docs/live.md');
1024
+ kit.point({ scan: false });
1025
+ assert.equal(fileIdAt(kit, 'docs/live.md'), liveId,
1026
+ 'an event-driven move must preserve identity');
1027
+
1028
+ kit.replace('docs/live.md', 'v3 atomically');
1029
+ await ring('docs/live.md');
1030
+ kit.point({ scan: false });
1031
+
1032
+ kit.rm('seed.md');
1033
+ await ring('seed.md');
1034
+ kit.point({ scan: false });
1035
+
1036
+ // A settled event-only detector says nothing more.
1037
+ const settled = kit.events.length;
1038
+ await sleep(100);
1039
+ assert.equal(kit.events.length, settled,
1040
+ 'no timer, no scan, no doorbell — silence must hold');
1041
+ } finally {
1042
+ kit.observer.stop();
1043
+ }
1044
+ });
1045
+
1046
+ // --- the judge must convict when fed lies ---------------------------------
1047
+
1048
+ test('the judge convicts a stale detector', () => {
1049
+ const kit = makeKit('convict-stale');
1050
+ kit.write('f.md', 'v1');
1051
+ kit.enroll();
1052
+ kit.point();
1053
+
1054
+ // Disk moves, detection is never given a chance: the referee must object.
1055
+ fs.writeFileSync(kit.abs('f.md'), 'v2');
1056
+ kit.model.write('f.md', 'v2');
1057
+ assert.throws(() => kit.point({ scan: false }), /stale/);
1058
+ });
1059
+
1060
+ test('the judge convicts a ghost believer', () => {
1061
+ const kit = makeKit('convict-ghost');
1062
+ kit.write('f.md', 'v1');
1063
+ kit.enroll();
1064
+ kit.point();
1065
+
1066
+ fs.rmSync(kit.abs('f.md'));
1067
+ kit.model.remove('f.md');
1068
+ assert.throws(() => kit.point({ scan: false }), /ghost/);
1069
+ });
1070
+
1071
+ test('the judge convicts a stale link detector', () => {
1072
+ const kit = makeKit('convict-stale-link');
1073
+ kit.symlink('ln.md', 'v1-target');
1074
+ kit.enroll();
1075
+ kit.point();
1076
+
1077
+ // The pointer moves, detection is never given a chance.
1078
+ fs.rmSync(kit.abs('ln.md'));
1079
+ fs.symlinkSync('v2-target', kit.abs('ln.md'));
1080
+ kit.model.writeLink('ln.md', 'v2-target');
1081
+ assert.throws(() => kit.point({ scan: false }), /stale at link/);
1082
+ });
1083
+
1084
+ test('a link channel may not claim a target never written', () => {
1085
+ const kit = makeKit('convict-link-truth');
1086
+ kit.symlink('ln.md', 'true-target');
1087
+ kit.write('base.md', 'bytes');
1088
+ kit.enroll();
1089
+ kit.point();
1090
+
1091
+ // A forged round trip: the chain holds and the store still matches, so
1092
+ // only per-entity emitted-truth can convict it.
1093
+ const channel = linkIdAt(kit, 'ln.md');
1094
+ kit.events.push(
1095
+ {
1096
+ type: 'link.changed', rootId: kit.rootId, linkId: channel, path: 'ln.md',
1097
+ target: 'forged', previousTarget: 'true-target',
1098
+ },
1099
+ {
1100
+ type: 'link.changed', rootId: kit.rootId, linkId: channel, path: 'ln.md',
1101
+ target: 'true-target', previousTarget: 'forged',
1102
+ },
1103
+ );
1104
+ assert.throws(() => kit.point(), /never written for that entity/);
1105
+ });
1106
+
1107
+ test('the judge convicts its own drifting model — the two truths cross-check', () => {
1108
+ const kit = makeKit('convict-model');
1109
+ kit.write('f.md', 'v1');
1110
+ kit.enroll();
1111
+ kit.point();
1112
+
1113
+ // A file appears that the operation model never recorded: the referee
1114
+ // must refuse to bless the run even though the detector saw it fine.
1115
+ fs.writeFileSync(kit.abs('unrecorded.md'), 'the model never heard of me');
1116
+ assert.throws(() => kit.point(), /model missing/);
1117
+ });
1118
+
1119
+ test('the judge convicts a version claimed for the wrong channel', () => {
1120
+ const kit = makeKit('convict-channel');
1121
+ kit.write('a.md', 'alpha');
1122
+ kit.write('b.md', 'beta');
1123
+ kit.enroll();
1124
+ kit.point();
1125
+
1126
+ // Forge a round trip: B claims hash(alpha) — true for A, never for B —
1127
+ // then claims its real hash back. The ledger chain holds and the store
1128
+ // still matches, so ONLY per-entity truth can convict this.
1129
+ const bId = fileIdAt(kit, 'b.md');
1130
+ kit.events.push(
1131
+ {
1132
+ type: 'file.changed', rootId: kit.rootId, fileId: bId, path: 'b.md',
1133
+ hash: sha256('alpha'), previousHash: sha256('beta'),
1134
+ },
1135
+ {
1136
+ type: 'file.changed', rootId: kit.rootId, fileId: bId, path: 'b.md',
1137
+ hash: sha256('beta'), previousHash: sha256('alpha'),
1138
+ },
1139
+ );
1140
+ assert.throws(() => kit.point(), /never true for that entity/);
1141
+ });
1142
+
1143
+ test('a short-lived channel may not lie either: transients are judged at their address', async () => {
1144
+ const bells = fakeWatchKit();
1145
+ const kit = makeKit('convict-transient', { watch: bells.watch, settleMs: 1 });
1146
+ kit.write('long.md', 'longlived');
1147
+ kit.enroll();
1148
+ kit.point();
1149
+ kit.observer.start();
1150
+ try {
1151
+ const ring = (name) => converged(kit, () => bells.ring(kit.dir, name));
1152
+
1153
+ // Control: a channel born and dead inside one interval, telling the
1154
+ // truth, passes — its address vouches for every version it claimed.
1155
+ kit.write('honest.md', 'true words');
1156
+ await ring('honest.md');
1157
+ kit.rm('honest.md');
1158
+ await ring('honest.md');
1159
+ kit.point({ scan: false });
1160
+
1161
+ // A transient claiming a hash that was never true AT ITS ADDRESS —
1162
+ // only ever true for the long-lived neighbor — must be convicted, even
1163
+ // though it never settles and so never binds to an entity.
1164
+ kit.write('flash.md', 'flash truth');
1165
+ await ring('flash.md');
1166
+ const flashId = fileIdAt(kit, 'flash.md');
1167
+ kit.events.push(
1168
+ {
1169
+ type: 'file.changed', rootId: kit.rootId, fileId: flashId, path: 'flash.md',
1170
+ hash: sha256('longlived'), previousHash: sha256('flash truth'),
1171
+ },
1172
+ {
1173
+ type: 'file.changed', rootId: kit.rootId, fileId: flashId, path: 'flash.md',
1174
+ hash: sha256('flash truth'), previousHash: sha256('longlived'),
1175
+ },
1176
+ );
1177
+ kit.rm('flash.md');
1178
+ await ring('flash.md');
1179
+ assert.throws(() => kit.point({ scan: false }), /never true at that address/);
1180
+ } finally {
1181
+ kit.observer.stop();
1182
+ }
1183
+ });
1184
+
1185
+ test('address history is no alibi for a bound channel: only its contested ground answers there', async () => {
1186
+ const bells = fakeWatchKit();
1187
+ const kit = makeKit('convict-waypoint', { watch: bells.watch, settleMs: 1 });
1188
+ kit.write('a.md', 'alpha');
1189
+ kit.enroll();
1190
+ kit.point();
1191
+ kit.observer.start();
1192
+ try {
1193
+ const ring = (...names) => converged(kit, () => {
1194
+ for (const name of names) bells.ring(kit.dir, name);
1195
+ });
1196
+
1197
+ // Foreign bytes genuinely occupy way.md this interval: a transient
1198
+ // tenant arrives and leaves without ever settling into an entity.
1199
+ kit.write('way.md', 'foreign tenure');
1200
+ await ring('way.md');
1201
+ kit.rm('way.md');
1202
+ await ring('way.md');
1203
+
1204
+ // The entity's channel then passes THROUGH way.md on its way to z.md —
1205
+ // an honest multi-hop move. While parked there, a forged round trip
1206
+ // claims it cycled through the dead tenant's bytes: true AT the
1207
+ // address this interval, never true for THIS entity. The ledger chain
1208
+ // holds and the store ends up matching, and the address is not
1209
+ // contested ground — so an address escape for bound channels would
1210
+ // swallow the lie whole. Only per-entity truth convicts it.
1211
+ const aId = fileIdAt(kit, 'a.md');
1212
+ kit.mv('a.md', 'way.md');
1213
+ await ring('a.md', 'way.md');
1214
+ kit.events.push(
1215
+ {
1216
+ type: 'file.changed', rootId: kit.rootId, fileId: aId, path: 'way.md',
1217
+ hash: sha256('foreign tenure'), previousHash: sha256('alpha'),
1218
+ },
1219
+ {
1220
+ type: 'file.changed', rootId: kit.rootId, fileId: aId, path: 'way.md',
1221
+ hash: sha256('alpha'), previousHash: sha256('foreign tenure'),
1222
+ },
1223
+ );
1224
+ kit.mv('way.md', 'z.md');
1225
+ await ring('way.md', 'z.md');
1226
+ assert.throws(() => kit.point({ scan: false }), /never true for that entity/);
1227
+ } finally {
1228
+ kit.observer.stop();
1229
+ }
1230
+ });
1231
+
1232
+ test('an honest edit on contested ground answers with the boundary it fired under, even after the ground moves', async () => {
1233
+ const bells = fakeWatchKit();
1234
+ const kit = makeKit('contest-boundary', { watch: bells.watch, settleMs: 1 });
1235
+ kit.write('a.txt', 'alpha');
1236
+ kit.write('b.txt', 'beta');
1237
+ kit.enroll();
1238
+ kit.point();
1239
+ kit.observer.start();
1240
+ try {
1241
+ const ring = (...names) => converged(kit, () => {
1242
+ for (const name of names) bells.ring(kit.dir, name);
1243
+ });
1244
+
1245
+ // The surplus arc, event-driven: the owner parks, the claimant's
1246
+ // ground lands on its slot, and fresh ground re-grounds the claimant
1247
+ // at home — its contested channel is now surplus evidence.
1248
+ kit.rm('b.txt');
1249
+ await ring('b.txt');
1250
+ kit.point({ scan: false });
1251
+ kit.mv('a.txt', 'b.txt');
1252
+ await ring('a.txt', 'b.txt');
1253
+ kit.point({ scan: false });
1254
+ kit.write('a.txt', 'returned');
1255
+ await ring('a.txt');
1256
+ let snap = kit.point({ scan: false });
1257
+ assert.equal(snap.divergent.get('b.txt'), sha256('alpha'));
1258
+
1259
+ // ONE interval: an honest edit at contested b.txt, then the ground
1260
+ // moves on to c.txt. At the settle the contest sits at c.txt — but the
1261
+ // change event fired at b.txt, under the OPENING boundary's contest. A
1262
+ // judge reading only the closing snapshot would falsely convict the
1263
+ // honest event it just watched happen.
1264
+ kit.write('b.txt', 'alpha, edited while contested');
1265
+ await ring('b.txt');
1266
+ kit.mv('b.txt', 'c.txt');
1267
+ await ring('b.txt', 'c.txt');
1268
+ snap = kit.point({ scan: false });
1269
+ assert.equal(snap.divergent.get('c.txt'), sha256('alpha, edited while contested'),
1270
+ 'the surplus contest followed the edited ground');
1271
+ assert.ok(!snap.files.has('c.txt'), 'no entity claims it');
1272
+ } finally {
1273
+ kit.observer.stop();
1274
+ }
1275
+ });
1276
+
1277
+ test('the judge convicts tampered event streams', () => {
1278
+ const { createLedger: freshLedger } = require('./entity-oracle/ledger');
1279
+
1280
+ // A fresh identity must be fresh — even after its owner died.
1281
+ let ledger = freshLedger();
1282
+ ledger.ingest([
1283
+ { type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' },
1284
+ { type: 'file.deleted', rootId: 'r', fileId: 'F1', path: 'a.md' },
1285
+ ]);
1286
+ assert.throws(() => ledger.ingest([
1287
+ { type: 'file.created', rootId: 'r', fileId: 'F1', path: 'b.md', hash: 'h2' },
1288
+ ]), /used before/);
1289
+
1290
+ // The hash chain must hold.
1291
+ ledger = freshLedger();
1292
+ ledger.ingest([{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' }]);
1293
+ assert.throws(() => ledger.ingest([
1294
+ { type: 'file.changed', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h3', previousHash: 'h2' },
1295
+ ]), /chain/);
1296
+
1297
+ // Equal versions are silence.
1298
+ ledger = freshLedger();
1299
+ ledger.ingest([{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' }]);
1300
+ assert.throws(() => ledger.ingest([
1301
+ { type: 'file.changed', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1', previousHash: 'h1' },
1302
+ ]), /silence/);
1303
+
1304
+ // A rename must start where the channel lives.
1305
+ ledger = freshLedger();
1306
+ ledger.ingest([{ type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' }]);
1307
+ assert.throws(() => ledger.ingest([
1308
+ { type: 'file.renamed', rootId: 'r', fileId: 'F1', from: 'elsewhere.md', to: 'b.md' },
1309
+ ]), /rename from/);
1310
+
1311
+ // Nothing speaks after deletion.
1312
+ ledger = freshLedger();
1313
+ ledger.ingest([
1314
+ { type: 'file.created', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h1' },
1315
+ { type: 'file.deleted', rootId: 'r', fileId: 'F1', path: 'a.md' },
1316
+ ]);
1317
+ assert.throws(() => ledger.ingest([
1318
+ { type: 'file.changed', rootId: 'r', fileId: 'F1', path: 'a.md', hash: 'h2', previousHash: 'h1' },
1319
+ ]), /after deletion/);
1320
+
1321
+ // Territories: discovered twice without a removal is a duplicate claim.
1322
+ ledger = freshLedger();
1323
+ ledger.ingest([{ type: 'repo.discovered', rootId: 'r2', path: '/tmp/x' }]);
1324
+ assert.throws(() => ledger.ingest([
1325
+ { type: 'repo.discovered', rootId: 'r3', path: '/tmp/x' },
1326
+ ]), /twice/);
1327
+
1328
+ // Link channels obey the same stream law, with the target chain.
1329
+ ledger = freshLedger();
1330
+ ledger.ingest([{ type: 'link.created', rootId: 'r', linkId: 'L1', path: 'a', target: 't1' }]);
1331
+ assert.throws(() => ledger.ingest([
1332
+ { type: 'link.changed', rootId: 'r', linkId: 'L1', path: 'a', target: 't3', previousTarget: 't2' },
1333
+ ]), /target chain/);
1334
+
1335
+ ledger = freshLedger();
1336
+ ledger.ingest([{ type: 'link.created', rootId: 'r', linkId: 'L1', path: 'a', target: 't1' }]);
1337
+ assert.throws(() => ledger.ingest([
1338
+ { type: 'link.changed', rootId: 'r', linkId: 'L1', path: 'a', target: 't1', previousTarget: 't1' },
1339
+ ]), /silence/);
1340
+
1341
+ ledger = freshLedger();
1342
+ ledger.ingest([
1343
+ { type: 'link.created', rootId: 'r', linkId: 'L1', path: 'a', target: 't1' },
1344
+ { type: 'link.deleted', rootId: 'r', linkId: 'L1', path: 'a' },
1345
+ ]);
1346
+ assert.throws(() => ledger.ingest([
1347
+ { type: 'link.created', rootId: 'r', linkId: 'L1', path: 'b', target: 't2' },
1348
+ ]), /used before/);
1349
+ });
1350
+
1351
+ // --- independence: the detector and its judge may not share a bug ---------
1352
+
1353
+ test('the oracle imports nothing from the detector', () => {
1354
+ const oracleDir = path.join(__dirname, 'entity-oracle');
1355
+ const allowed = /^(node:)?(assert|crypto|fs|path)(\/.*)?$|^\.\/(?!\.)[\w-]+$/;
1356
+ for (const name of fs.readdirSync(oracleDir).filter((f) => f.endsWith('.js'))) {
1357
+ const source = fs.readFileSync(path.join(oracleDir, name), 'utf8');
1358
+ for (const [, , spec] of source.matchAll(/require\((['"])([^'"]+)\1\)/g)) {
1359
+ assert.match(spec, allowed,
1360
+ `${name} requires "${spec}" — the oracle may only use node built-ins and its own modules`);
1361
+ assert.doesNotMatch(spec, /observer|repocard|workspace|scan|store|follow/,
1362
+ `${name} requires "${spec}" — the judge must not share the detector's code`);
1363
+ }
1364
+ }
1365
+ });