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,967 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The entity registry proving the constitution's registry chapter
5
+ * (docs/entity-model.md): durable identity with a first-init-vs-loss
6
+ * sentinel, the ratified version formula with status finally moving
7
+ * through it, explicit-only lifecycle verbs, slot and container and
8
+ * acyclicity law, and derived lifecycle inheritance — every rule exercised
9
+ * through the public surface, with the database schema shown to convict
10
+ * independently when application checks are bypassed.
11
+ *
12
+ * Wave A tests exercise the durable core alone. The Wave B tests at the
13
+ * bottom exercise the evidence intake's doorbell contract (infallible
14
+ * onEvent, dirty retry, startup reconciliation) and its durable memory —
15
+ * channel bindings and contested-ground markers surviving restart and
16
+ * even observer-store loss; the entity oracle grades the combined system
17
+ * through the widened boundary adapter.
18
+ */
19
+
20
+ const test = require('node:test');
21
+ const assert = require('node:assert/strict');
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+
26
+ const Database = require('better-sqlite3');
27
+ const { createObserver } = require('../observer');
28
+ const { createRegistry, canonicalVersion } = require('../registry');
29
+ const { createEvidence, createEvidenceMemory } = require('../registry/evidence');
30
+ const { createStore } = require('../registry/store');
31
+
32
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-registry-'));
33
+
34
+ test.after(() => {
35
+ fs.rmSync(tempRoot, { recursive: true, force: true });
36
+ });
37
+
38
+ /** In-memory registry with an event log; enough for every non-sentinel test. */
39
+ function makeRegistry(options = {}) {
40
+ const events = [];
41
+ const registry = createRegistry({
42
+ db: new Database(':memory:'),
43
+ emit: (event) => events.push(event),
44
+ ephemeral: true,
45
+ ...options,
46
+ });
47
+ return { registry, events };
48
+ }
49
+
50
+ /** The single-tree harnesses register exactly one tree, so its top record
51
+ * is the one parentless active. */
52
+ const rootOf = (registry) => registry.roots()[0] ?? null;
53
+
54
+ /** Root + one folder + one file under it: the smallest interesting graph. */
55
+ function makeGraph(options = {}) {
56
+ const made = makeRegistry(options);
57
+ const root = made.registry.enrollRoot({ type: 'workspace', name: 'ws' });
58
+ const folder = made.registry.create({ type: 'folder', parentUUID: root.uuid, name: 'den' });
59
+ const file = made.registry.create({
60
+ type: 'file.text', parentUUID: folder.uuid, name: 'cub.txt', payloadVersion: 'hash-1',
61
+ });
62
+ return { ...made, root, folder, file };
63
+ }
64
+
65
+ test('first initialization mints the sentinel; reopening finds the same registry', () => {
66
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'sentinel-'));
67
+ const dbPath = path.join(dir, 'registry.db');
68
+ const sentinelPath = path.join(dir, 'registry.epoch');
69
+
70
+ const first = createRegistry({ db: new Database(dbPath), emit: () => {}, sentinelPath });
71
+ const epoch = first.epoch();
72
+ assert.equal(fs.readFileSync(sentinelPath, 'utf8').trim(), epoch,
73
+ 'the sentinel marker carries the minted epoch');
74
+ const root = first.enrollRoot({ type: 'workspace', name: 'ws' });
75
+ const file = first.create({
76
+ type: 'file.text', parentUUID: root.uuid, name: 'a.txt', payloadVersion: 'h1',
77
+ });
78
+
79
+ const reopened = createRegistry({ db: new Database(dbPath), emit: () => {}, sentinelPath });
80
+ assert.equal(reopened.epoch(), epoch, 'reopening is the same registry, not a new one');
81
+ assert.deepEqual(reopened.record(file.uuid), file,
82
+ 'every field of the record — version included — survives the restart byte for byte');
83
+ assert.deepEqual(rootOf(reopened), reopened.record(root.uuid));
84
+ });
85
+
86
+ test('a registry that existed is never silently replaced', () => {
87
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'loss-'));
88
+ const sentinelPath = path.join(dir, 'registry.epoch');
89
+
90
+ // Loss: the sentinel says an epoch lived here, the fresh database has nothing.
91
+ createRegistry({ db: new Database(path.join(dir, 'one.db')), emit: () => {}, sentinelPath });
92
+ assert.throws(
93
+ () => createRegistry({ db: new Database(':memory:'), emit: () => {}, sentinelPath }),
94
+ /registry lost: .*refusing to mint replacement identities/,
95
+ 'a marker over an empty database is loss, not first init');
96
+
97
+ // Corruption: half the tables gone.
98
+ const halfDb = new Database(path.join(dir, 'two.db'));
99
+ const halfSentinel = path.join(dir, 'two.epoch');
100
+ createRegistry({ db: halfDb, emit: () => {}, sentinelPath: halfSentinel });
101
+ halfDb.exec('DROP TABLE entities');
102
+ assert.throws(
103
+ () => createRegistry({ db: halfDb, emit: () => {}, sentinelPath: halfSentinel }),
104
+ /registry corrupt: entities gone from a database where a registry lived/);
105
+
106
+ // Mismatch: the marker belongs to a different registry's epoch.
107
+ const otherDbPath = path.join(dir, 'three.db');
108
+ createRegistry({ db: new Database(otherDbPath), emit: () => {}, sentinelPath: path.join(dir, 'three.epoch') });
109
+ assert.throws(
110
+ () => createRegistry({ db: new Database(otherDbPath), emit: () => {}, sentinelPath }),
111
+ /registry mismatch: sentinel epoch .* is not this database's epoch/);
112
+ });
113
+
114
+ test('a durable registry requires a sentinel; ephemeral is for real memory only', () => {
115
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'require-'));
116
+ assert.throws(
117
+ () => createRegistry({ db: new Database(path.join(dir, 'bare.db')), emit: () => {} }),
118
+ /a durable registry requires a sentinel/,
119
+ 'a durable database without a sentinel is fail-open loss protection — refused');
120
+ assert.throws(
121
+ () => createRegistry({ db: new Database(':memory:'), emit: () => {} }),
122
+ /a durable registry requires a sentinel/,
123
+ 'even memory must SAY it is ephemeral — the default is never silence');
124
+ assert.throws(
125
+ () => createRegistry({ db: new Database(path.join(dir, 'liar.db')), emit: () => {}, ephemeral: true }),
126
+ /ephemeral is for a real in-memory database/,
127
+ 'the flag is not an escape hatch for durable databases');
128
+ assert.throws(
129
+ () => createRegistry({
130
+ db: new Database(':memory:'), emit: () => {},
131
+ ephemeral: true, sentinelPath: path.join(dir, 'x.epoch'),
132
+ }),
133
+ /contradictory/, 'declaring both is a confused caller, not a choice');
134
+ assert.ok(createRegistry({ db: new Database(':memory:'), emit: () => {}, ephemeral: true }).epoch(),
135
+ 'a real in-memory database, declared ephemeral, runs');
136
+ });
137
+
138
+ test('a failed notification is a pending bell, never a permanent miss', () => {
139
+ let down = false;
140
+ const events = [];
141
+ const registry = createRegistry({
142
+ db: new Database(':memory:'),
143
+ ephemeral: true,
144
+ emit: (event) => {
145
+ if (down) throw new Error('consumer offline');
146
+ events.push(event);
147
+ },
148
+ });
149
+ const root = registry.enrollRoot({ type: 'workspace', name: 'ws' });
150
+ const file = registry.create({ type: 'file.text', parentUUID: root.uuid, name: 'a.txt', payloadVersion: 'h1' });
151
+ events.length = 0;
152
+
153
+ down = true;
154
+ const renamed = registry.move(file.uuid, { parentUUID: root.uuid, name: 'b.txt' });
155
+ assert.equal(renamed.name, 'b.txt', 'the commit succeeded — the registry is truth');
156
+ assert.equal(events.length, 0, 'the notification was missed');
157
+ registry.setPayloadVersion(file.uuid, 'h2');
158
+ assert.equal(events.length, 0, 'two misses collapse into one owed comparison');
159
+
160
+ down = false;
161
+ registry.setPayloadVersion(file.uuid, 'h3');
162
+ assert.deepEqual(events.map((event) => event.type), ['registry.changed', 'entity.committed'],
163
+ 'the next commit rings the pending bell first, then its own event');
164
+ assert.deepEqual(registry.record(file.uuid), events[1].record,
165
+ 'and the registry read gives everything the missed events would have');
166
+
167
+ events.length = 0;
168
+ assert.equal(registry.flush(), true);
169
+ assert.deepEqual(events.map((event) => event.type), ['registry.changed'],
170
+ 'flush always requests comparison — it never trusts the in-memory flag');
171
+
172
+ down = true;
173
+ assert.equal(registry.flush(), false, 'a failed flush reports itself and leaves the bell pending');
174
+ down = false;
175
+ events.length = 0;
176
+ registry.trash(file.uuid);
177
+ assert.deepEqual(events.map((event) => event.type), ['registry.changed', 'entity.committed']);
178
+ });
179
+
180
+ test('trees in one registry: parentless records are roots, and a tree boundary is not identity', () => {
181
+ const { registry } = makeRegistry();
182
+ assert.throws(() => registry.enrollRoot({ type: 'folder', name: 'ws' }),
183
+ /the root is workspace, repo\.git, a registered file, or a registered link/,
184
+ 'a folder inside no workspace is not a thing amalgm registers');
185
+ const root = registry.enrollRoot({ type: 'workspace', name: 'ws' });
186
+ const other = registry.enrollRoot({ type: 'workspace', name: 'ws' });
187
+ assert.notEqual(other.uuid, root.uuid,
188
+ 'the ONE registry holds every tree — even two trees sharing a basename, placed by address, never by name');
189
+ assert.deepEqual(registry.roots().map((record) => record.uuid).sort(),
190
+ [root.uuid, other.uuid].sort());
191
+ assert.throws(() => registry.changeType(root.uuid, 'folder'),
192
+ /the root is workspace, repo\.git, a registered file, or a registered link/);
193
+ assert.throws(() => registry.trash(root.uuid), /never trashed/);
194
+ assert.throws(() => registry.move(root.uuid, { parentUUID: root.uuid, name: 'x' }),
195
+ /cannot live under itself/, 'acyclicity binds adoption like any move');
196
+
197
+ // ADOPTION: a parentless mover is the whole tree joining another —
198
+ // the same lawful move, one parent update, same UUID.
199
+ const adopted = registry.move(root.uuid, { parentUUID: other.uuid, name: 'ws' });
200
+ assert.equal(adopted.uuid, root.uuid, 'a tree boundary is not identity: the UUID rides the move');
201
+ assert.equal(adopted.parentUUID, other.uuid);
202
+ assert.deepEqual(registry.roots().map((record) => record.uuid), [other.uuid],
203
+ 'the adopted tree is no longer parentless — it lives inside its new tree');
204
+
205
+ // The ratified extension (`amalgm add <file>`): a single registered
206
+ // file is a tree of one — its root record IS the file entity.
207
+ const { registry: soloRegistry } = makeRegistry();
208
+ const solo = soloRegistry.enrollRoot({ type: 'file.text', name: 'notes.md', payloadVersion: 'h1' });
209
+ assert.equal(rootOf(soloRegistry).uuid, solo.uuid);
210
+ assert.throws(() => soloRegistry.create({ type: 'file.text', parentUUID: solo.uuid, name: 'x' }),
211
+ /only containers hold active children/, 'a tree of one stays a tree of one');
212
+
213
+ // And its sibling (amended 2026-08-01): `amalgm add` names an ADDRESS,
214
+ // so a pointer added explicitly is an entity of its own — a tree of
215
+ // one of the pointer kind, its payload the written target text.
216
+ const { registry: ptrRegistry } = makeRegistry();
217
+ const ptr = ptrRegistry.enrollRoot({ type: 'link', name: 'current', payloadVersion: '../v2' });
218
+ assert.equal(rootOf(ptrRegistry).uuid, ptr.uuid);
219
+ assert.throws(() => ptrRegistry.create({ type: 'file.text', parentUUID: ptr.uuid, name: 'x' }),
220
+ /only containers hold active children/, 'a pointer holds no children');
221
+ });
222
+
223
+ test('a reference\'s payload is identity, never an attestation', () => {
224
+ const { registry } = makeRegistry();
225
+ const root = registry.enrollRoot({ type: 'workspace', name: 'ws' });
226
+ const ref = registry.create({
227
+ type: 'reference', parentUUID: root.uuid, name: 'proj', payloadVersion: 'target-uuid-123',
228
+ });
229
+ assert.equal(ref.type, 'reference');
230
+ assert.throws(() => registry.setPayloadVersion(ref.uuid, 'other'), /never an attestation/,
231
+ 'the evidence path cannot write a reference\'s payload: ground attests bytes, never identity');
232
+ assert.throws(() => registry.create({ type: 'file.text', parentUUID: ref.uuid, name: 'x' }),
233
+ /only containers hold active children/, 'a reference holds no children');
234
+ });
235
+
236
+ test('only containers take children, names are single segments, types are known', () => {
237
+ const { registry, root, file } = makeGraph();
238
+ assert.throws(
239
+ () => registry.create({ type: 'file.text', parentUUID: file.uuid, name: 'child.txt' }),
240
+ /only containers hold active children/);
241
+ assert.throws(
242
+ () => registry.create({ type: 'workspace', parentUUID: root.uuid, name: 'nested' }),
243
+ /workspace is the root type only/);
244
+ assert.throws(
245
+ () => registry.create({ type: 'directory', parentUUID: root.uuid, name: 'x' }),
246
+ /unknown entity type/);
247
+ for (const name of ['', '.', '..', 'a/b', 'a\0b']) {
248
+ assert.throws(() => registry.create({ type: 'folder', parentUUID: root.uuid, name }),
249
+ /a name is one non-empty path segment/, `name ${JSON.stringify(name)} must be refused`);
250
+ }
251
+ });
252
+
253
+ test('rename and move preserve the uuid; the version is a state fingerprint', () => {
254
+ const { registry, root, folder, file } = makeGraph();
255
+ const born = registry.record(file.uuid);
256
+
257
+ const renamed = registry.move(file.uuid, { parentUUID: folder.uuid, name: 'wolf.txt' });
258
+ assert.equal(renamed.uuid, born.uuid);
259
+ assert.notEqual(renamed.version, born.version, 'a rename is a record change');
260
+
261
+ const moved = registry.move(file.uuid, { parentUUID: root.uuid, name: 'wolf.txt' });
262
+ assert.equal(moved.uuid, born.uuid);
263
+ assert.notEqual(moved.version, renamed.version);
264
+
265
+ const home = registry.move(file.uuid, { parentUUID: folder.uuid, name: 'cub.txt' });
266
+ assert.equal(home.version, born.version,
267
+ 'identical state is the identical version — a fingerprint, not a history counter');
268
+
269
+ const edited = registry.setPayloadVersion(file.uuid, 'hash-2');
270
+ assert.notEqual(edited.version, home.version);
271
+ assert.equal(registry.setPayloadVersion(file.uuid, 'hash-2').version, edited.version,
272
+ 'an equal attestation is silence');
273
+ });
274
+
275
+ test('the slot admits one active owner — and the schema itself convicts', () => {
276
+ const { registry, folder } = makeGraph();
277
+ assert.throws(
278
+ () => registry.create({ type: 'file.text', parentUUID: folder.uuid, name: 'cub.txt' }),
279
+ /slot taken: active entity .* already owns/);
280
+
281
+ // Bypass every application check: the database's partial unique index is
282
+ // an independent second layer, so a registry bug cannot slip a duplicate in.
283
+ // Parentless rows are DELIBERATELY exempt — one registry holds many
284
+ // trees, and a top-level tree is placed by address, never by name.
285
+ const db = new Database(':memory:');
286
+ createRegistry({ db, emit: () => {}, ephemeral: true });
287
+ const store = createStore(db);
288
+ store.insert({ uuid: 'a', type: 'folder', parentUuid: null, name: 'ws', status: 'active', payloadVersion: null });
289
+ assert.doesNotThrow(
290
+ () => store.insert({ uuid: 'b', type: 'folder', parentUuid: null, name: 'ws', status: 'active', payloadVersion: null }),
291
+ 'two trees may even share a basename: the slot law binds under a parent, not at the top level');
292
+ assert.throws(
293
+ () => store.insert({ uuid: 'x', type: 'folder', parentUuid: 'ghost', name: 'n', status: 'active', payloadVersion: null }),
294
+ /FOREIGN KEY/, 'a phantom parent violates the schema too — better-sqlite3 enforces the reference');
295
+ store.insert({ uuid: 'c', type: 'file.text', parentUuid: 'a', name: 'n.txt', status: 'active', payloadVersion: null });
296
+ assert.throws(
297
+ () => store.insert({ uuid: 'd', type: 'file.text', parentUuid: 'a', name: 'n.txt', status: 'active', payloadVersion: null }),
298
+ /UNIQUE/, 'a second active slot owner violates the schema, whatever code allowed it');
299
+ });
300
+
301
+ test('an address permutation is one transition: the slot law judges the state after the batch', () => {
302
+ const { registry, events } = makeRegistry();
303
+ const root = registry.enrollRoot({ type: 'workspace', name: 'ws' });
304
+ const north = registry.create({ type: 'folder', parentUUID: root.uuid, name: 'north' });
305
+ const south = registry.create({ type: 'folder', parentUUID: root.uuid, name: 'south' });
306
+
307
+ // Sequential moves cannot thread an exchange — the slot law convicts each
308
+ // half — but the same two moves as ONE batch are a lawful transition:
309
+ // the invariant constrains the graph after the batch, never the order
310
+ // inside it.
311
+ assert.throws(() => registry.move(north.uuid, { parentUUID: root.uuid, name: 'south' }),
312
+ /slot taken/);
313
+ const mark = events.length;
314
+ const next = registry.moveMany([
315
+ { uuid: north.uuid, parentUUID: root.uuid, name: 'south' },
316
+ { uuid: south.uuid, parentUUID: root.uuid, name: 'north' },
317
+ ]);
318
+ assert.deepEqual(next.map((record) => [record.name, record.uuid]),
319
+ [['south', north.uuid], ['north', south.uuid]], 'both records crossed on their own uuids');
320
+ assert.equal(events.slice(mark).filter((event) => event.type === 'entity.committed').length, 2,
321
+ 'an exchange is two recorded facts, nothing more');
322
+
323
+ // The invariant still judges the end state: a batch landing on an owned
324
+ // slot is refused whole, and so is a batch that collides with itself.
325
+ assert.throws(() => registry.moveMany([{ uuid: north.uuid, parentUUID: root.uuid, name: 'north' }]),
326
+ /slot taken: active entity/);
327
+ assert.throws(() => registry.moveMany([
328
+ { uuid: north.uuid, parentUUID: root.uuid, name: 'same' },
329
+ { uuid: south.uuid, parentUUID: root.uuid, name: 'same' },
330
+ ]), /slot taken: two movers/);
331
+ });
332
+
333
+ test('the parent graph stays acyclic', () => {
334
+ const { registry, root, folder } = makeGraph();
335
+ const inner = registry.create({ type: 'folder', parentUUID: folder.uuid, name: 'inner' });
336
+ assert.throws(() => registry.move(folder.uuid, { parentUUID: inner.uuid, name: 'den' }),
337
+ /the parent graph is acyclic/);
338
+ assert.throws(() => registry.move(folder.uuid, { parentUUID: folder.uuid, name: 'den' }),
339
+ /the parent graph is acyclic/);
340
+ // A legal reparent still works after the refusals.
341
+ assert.equal(registry.move(inner.uuid, { parentUUID: root.uuid, name: 'inner' }).uuid, inner.uuid);
342
+ });
343
+
344
+ test('lifecycle inherits down as derived status; no descendant record is rewritten', () => {
345
+ const { registry, folder, file } = makeGraph();
346
+ const fileBefore = registry.record(file.uuid);
347
+
348
+ registry.trash(folder.uuid);
349
+ assert.equal(registry.record(file.uuid).status, 'active',
350
+ 'the child\'s STORED status never moved');
351
+ assert.deepEqual(registry.record(file.uuid), fileBefore,
352
+ 'trashing the folder changed nothing on the child record — version included');
353
+ assert.equal(registry.effectiveStatus(file.uuid), 'trashed',
354
+ 'yet the child is effectively trashed through its ancestor');
355
+
356
+ registry.restore(folder.uuid);
357
+ assert.equal(registry.effectiveStatus(file.uuid), 'active',
358
+ 'one flip back restores the whole subtree in effect');
359
+ });
360
+
361
+ test('trash frees the slot; restore refuses an occupied one', () => {
362
+ const { registry, folder, file } = makeGraph();
363
+ registry.trash(file.uuid);
364
+ const usurper = registry.create({
365
+ type: 'file.text', parentUUID: folder.uuid, name: 'cub.txt', payloadVersion: 'other',
366
+ });
367
+ assert.throws(() => registry.restore(file.uuid),
368
+ /slot taken: active entity .* already owns/,
369
+ 'a simple status flip would seat two active owners — refused');
370
+ registry.trash(usurper.uuid);
371
+ assert.equal(registry.restore(file.uuid).status, 'active',
372
+ 'with the slot vacated, restore is one flip');
373
+ });
374
+
375
+ test('a trashed record is not ground: it never moves, never attests, never re-types', () => {
376
+ const { registry, root, file } = makeGraph();
377
+ registry.trash(file.uuid);
378
+ assert.throws(() => registry.move(file.uuid, { parentUUID: root.uuid, name: 'x.txt' }),
379
+ /only an active entity moves/);
380
+ assert.throws(() => registry.setPayloadVersion(file.uuid, 'h9'),
381
+ /only an active entity attests/);
382
+ assert.throws(() => registry.changeType(file.uuid, 'folder'),
383
+ /only an active entity changes type/);
384
+ });
385
+
386
+ test('valid type transitions ride one uuid; an occupied folder never becomes a file', () => {
387
+ const { registry, folder, file } = makeGraph();
388
+ assert.throws(() => registry.changeType(folder.uuid, 'file.text'),
389
+ /would be stranded — only containers hold active children/,
390
+ 'the flip that would canonicalize subtree destruction is refused');
391
+
392
+ const asFolder = registry.changeType(file.uuid, 'folder');
393
+ assert.equal(asFolder.uuid, file.uuid, 'file → folder is always valid, same uuid');
394
+ assert.equal(asFolder.type, 'folder');
395
+
396
+ const asFile = registry.changeType(file.uuid, 'file.text', { payloadVersion: 'reborn' });
397
+ assert.equal(asFile.uuid, file.uuid, 'empty folder → file is valid, same uuid');
398
+
399
+ registry.trash(file.uuid);
400
+ const emptied = registry.changeType(folder.uuid, 'file.binary', { payloadVersion: 'bytes' });
401
+ assert.equal(emptied.uuid, folder.uuid,
402
+ 'once its children are explicitly out of the way, the folder may become a file');
403
+ });
404
+
405
+ test('permanent deletion is owner-only, from Trash only, and unavailable unwired', () => {
406
+ const unwired = makeGraph();
407
+ unwired.registry.trash(unwired.file.uuid);
408
+ assert.throws(() => unwired.registry.deletePermanently(unwired.file.uuid),
409
+ /permanent deletion is unavailable: no ownership authorization is wired/,
410
+ 'no authorizeDelete means the verb does not exist — never fail-open');
411
+
412
+ let allow = false;
413
+ const { registry, folder, file } = makeGraph({ authorizeDelete: () => allow });
414
+ assert.throws(() => registry.deletePermanently(file.uuid),
415
+ /empty-trash deletes from Trash only/);
416
+ registry.trash(file.uuid);
417
+ assert.throws(() => registry.deletePermanently(file.uuid),
418
+ /permanent deletion is owner-only/);
419
+ allow = true;
420
+ const deleted = registry.deletePermanently(file.uuid);
421
+ assert.equal(deleted.status, 'deleted');
422
+ assert.equal(registry.record(file.uuid).status, 'deleted',
423
+ 'the record remains — it IS the tombstone');
424
+ assert.throws(() => registry.restore(file.uuid), /deleted is permanent/);
425
+ assert.equal(
426
+ registry.create({ type: 'file.text', parentUUID: folder.uuid, name: 'cub.txt' }).name,
427
+ 'cub.txt', 'deletion vacates the slot for a genuinely new entity');
428
+ });
429
+
430
+ test('status sits inside the version: every lifecycle flip is a version change', () => {
431
+ const { registry, file } = makeGraph({ authorizeDelete: () => true });
432
+ const active = registry.record(file.uuid).version;
433
+ const trashed = registry.trash(file.uuid).version;
434
+ const deleted = registry.deletePermanently(file.uuid).version;
435
+ assert.notEqual(trashed, active, 'trash moves the version');
436
+ assert.notEqual(deleted, trashed, 'deletion moves it again');
437
+ assert.equal(new Set([active, trashed, deleted]).size, 3,
438
+ 'three statuses, three distinct fingerprints — catch-up is one comparison');
439
+ });
440
+
441
+ test('a container\'s version is its active membership, nothing else', () => {
442
+ const { registry, folder, file } = makeGraph();
443
+ const v0 = registry.record(folder.uuid).version;
444
+
445
+ registry.setPayloadVersion(file.uuid, 'hash-2');
446
+ assert.equal(registry.record(folder.uuid).version, v0,
447
+ 'a child\'s content edit does not change its parent\'s version');
448
+
449
+ registry.move(file.uuid, { parentUUID: folder.uuid, name: 'wolf.txt' });
450
+ const v1 = registry.record(folder.uuid).version;
451
+ assert.notEqual(v1, v0, 'a child rename changes the parent\'s membership');
452
+
453
+ registry.trash(file.uuid);
454
+ const v2 = registry.record(folder.uuid).version;
455
+ assert.notEqual(v2, v1, 'a child trash changes the parent\'s membership');
456
+
457
+ registry.restore(file.uuid);
458
+ assert.equal(registry.record(folder.uuid).version, v1,
459
+ 'restore returns the membership, so it returns the version');
460
+ });
461
+
462
+ test('the public record is exactly the six ratified fields', () => {
463
+ const { registry, file } = makeGraph();
464
+ const record = registry.record(file.uuid);
465
+ assert.deepEqual(Object.keys(record).sort(),
466
+ ['name', 'parentUUID', 'status', 'type', 'uuid', 'version'],
467
+ 'payload attestations are evidence inputs, never a seventh public field');
468
+ assert.equal(record.version, canonicalVersion({
469
+ type: 'file.text', parentUUID: record.parentUUID, name: 'cub.txt',
470
+ status: 'active', payloadVersion: 'hash-1',
471
+ }), 'the exposed version is the ratified formula, recomputable by anyone');
472
+ });
473
+
474
+ test('every commit emits the record and its previous; no-ops emit nothing', () => {
475
+ const { registry, events, file } = makeGraph();
476
+ events.length = 0;
477
+
478
+ const moved = registry.move(file.uuid, { parentUUID: registry.record(file.uuid).parentUUID, name: 'wolf.txt' });
479
+ assert.equal(events.length, 1);
480
+ assert.equal(events[0].type, 'entity.committed');
481
+ assert.deepEqual(events[0].record, moved);
482
+ assert.equal(events[0].previous.uuid, moved.uuid);
483
+ assert.notEqual(events[0].previous.version, moved.version);
484
+
485
+ events.length = 0;
486
+ registry.setPayloadVersion(file.uuid, 'hash-1'); // equal attestation
487
+ registry.changeType(file.uuid, 'file.text'); // same type
488
+ assert.equal(events.length, 0, 'silence when nothing changed');
489
+
490
+ events.length = 0;
491
+ const birth = registry.create({
492
+ type: 'folder', parentUUID: rootOf(registry).uuid, name: 'new',
493
+ });
494
+ assert.deepEqual(events[0], { type: 'entity.committed', record: birth, previous: null },
495
+ 'a birth has no previous');
496
+ });
497
+
498
+ // --- Wave B: the evidence intake's doorbell contract ----------------------
499
+
500
+ /** A real observer over a scratch dir, wired into a registry's intake.
501
+ * A lens is keyed by the tree's permanent identity, so `enroll()` is
502
+ * what births it — enroll the ground, then the lens over its rootId. */
503
+ function makeIntake(name, { registryDb, sentinelPath, classify, wrapMemory } = {}) {
504
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(tempRoot, `${name}-`)));
505
+ const regEvents = [];
506
+ const kit = { dir, regEvents, evidence: null };
507
+ kit.observer = createObserver({
508
+ db: new Database(':memory:'),
509
+ emit: () => kit.evidence?.onEvent(),
510
+ });
511
+ const db = registryDb ?? new Database(':memory:');
512
+ kit.db = db;
513
+ kit.registry = createRegistry({
514
+ db,
515
+ ...(registryDb ? { sentinelPath } : { ephemeral: true }),
516
+ emit: (event) => regEvents.push(event),
517
+ });
518
+ kit.memory = (wrapMemory ?? ((real) => real))(createEvidenceMemory(db));
519
+ kit.lens = (rootId) => {
520
+ kit.evidence = createEvidence({
521
+ observer: kit.observer, registry: kit.registry, memory: kit.memory,
522
+ rootId, classify: classify ?? (() => 'file.text'),
523
+ });
524
+ return kit.evidence;
525
+ };
526
+ kit.enroll = () => {
527
+ const rootId = kit.observer.enrollRoot(dir).rootId;
528
+ kit.lens(rootId);
529
+ return rootId;
530
+ };
531
+ return kit;
532
+ }
533
+
534
+ test('the intake is infallible: a failed reconciliation leaves dirty set, and retries recover', async () => {
535
+ let broken = true;
536
+ const kit = makeIntake('intake', {
537
+ classify: () => {
538
+ if (broken) throw new Error('classifier down');
539
+ return 'file.text';
540
+ },
541
+ });
542
+ fs.writeFileSync(path.join(kit.dir, 'a.txt'), 'alpha');
543
+ kit.enroll();
544
+
545
+ // The enrollment scan rang onEvent; the scheduled attempt fails inside
546
+ // its own catch — nothing throws upward, the intake just stays dirty.
547
+ assert.doesNotThrow(() => kit.evidence.onEvent(), 'onEvent is infallible by construction');
548
+ await new Promise((resolve) => setImmediate(resolve));
549
+ assert.equal(kit.evidence.dirty(), true, 'a failed reconciliation leaves the intake dirty');
550
+ assert.equal(kit.evidence.flushIfDirty(), false, 'flush reports honestly while reconciliation fails');
551
+ assert.throws(() => kit.evidence.reconcile(), /classifier down/,
552
+ 'the direct call is the loud variant of the same reconciliation');
553
+ assert.equal(kit.evidence.dirty(), true);
554
+
555
+ // The consumer heals; the SAME reconciliation the retries were attempting
556
+ // now succeeds — no event content was needed, observer-store truth held it.
557
+ broken = false;
558
+ assert.equal(kit.evidence.flushIfDirty(), true, 'the retry recovers with nothing lost');
559
+ assert.equal(kit.evidence.dirty(), false);
560
+ const root = rootOf(kit.registry);
561
+ const file = kit.registry.activeAt(root.uuid, 'a.txt');
562
+ assert.ok(file, 'the file the events hinted at was reconciled from store truth');
563
+ assert.equal(file.type, 'file.text');
564
+ });
565
+
566
+ test('startup performs the identical reconciliation: a fresh process rebinds with zero commits', () => {
567
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'restart-'));
568
+ const dbPath = path.join(dir, 'registry.db');
569
+ const sentinelPath = path.join(dir, 'registry.epoch');
570
+
571
+ const first = makeIntake('restart-a', { registryDb: new Database(dbPath), sentinelPath });
572
+ fs.writeFileSync(path.join(first.dir, 'keep.txt'), 'kept');
573
+ fs.mkdirSync(path.join(first.dir, 'sub'));
574
+ fs.writeFileSync(path.join(first.dir, 'sub', 'deep.txt'), 'deep');
575
+ const rootId = first.enroll();
576
+ first.evidence.reconcile();
577
+ const rootUuid = rootOf(first.registry).uuid;
578
+ const keepUuid = first.registry.activeAt(rootUuid, 'keep.txt').uuid;
579
+
580
+ // "Restart": a new registry over the same durable database and sentinel,
581
+ // a new evidence layer over the same durable memory — same observer
582
+ // store. Startup reconciliation must find every claim already true and
583
+ // commit NOTHING: the records already say everything disk still says.
584
+ const reborn = [];
585
+ const db2 = new Database(dbPath);
586
+ const registry2 = createRegistry({
587
+ db: db2, sentinelPath, emit: (event) => reborn.push(event),
588
+ });
589
+ const evidence2 = createEvidence({
590
+ observer: first.observer, registry: registry2, rootId,
591
+ memory: createEvidenceMemory(db2),
592
+ classify: () => 'file.text',
593
+ });
594
+ evidence2.reconcile();
595
+ assert.deepEqual(reborn, [], 'startup reconciliation over unchanged truth is silence');
596
+ assert.equal(rootOf(registry2).uuid, rootUuid, 'the root uuid survived the restart');
597
+ assert.equal(registry2.activeAt(rootUuid, 'keep.txt').uuid, keepUuid,
598
+ 'file uuids survived the restart — bindings are evidence, records are identity');
599
+ const keepChannel = first.observer.filesForRoot(first.observer.listRoots()[0].rootId)
600
+ .find((row) => row.relPath === 'keep.txt').fileId;
601
+ assert.equal(evidence2.boundUuid(keepChannel), keepUuid,
602
+ 'the durable binding carried the channel\'s claim across the restart');
603
+ });
604
+
605
+ test('folder identity survives restart: the durable dir binding carries a rename made after reopen', () => {
606
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'dir-restart-'));
607
+ const dbPath = path.join(dir, 'registry.db');
608
+ const sentinelPath = path.join(dir, 'registry.epoch');
609
+
610
+ const first = makeIntake('dir-restart-a', { registryDb: new Database(dbPath), sentinelPath });
611
+ fs.mkdirSync(path.join(first.dir, 'shelf'));
612
+ fs.writeFileSync(path.join(first.dir, 'shelf', 'book.txt'), 'book');
613
+ const rootId = first.enroll();
614
+ first.evidence.reconcile();
615
+ const rootUuid = rootOf(first.registry).uuid;
616
+ const shelfUuid = first.registry.activeAt(rootUuid, 'shelf').uuid;
617
+
618
+ // "Restart", then a directory rename: the binding restored from the
619
+ // database — not process memory — must move the SAME folder record.
620
+ const reborn = [];
621
+ const db2 = new Database(dbPath);
622
+ const registry2 = createRegistry({ db: db2, sentinelPath, emit: (event) => reborn.push(event) });
623
+ const evidence2 = createEvidence({
624
+ observer: first.observer, registry: registry2, rootId,
625
+ memory: createEvidenceMemory(db2),
626
+ classify: () => 'file.text',
627
+ });
628
+ evidence2.reconcile();
629
+ assert.deepEqual(reborn, [], 'startup over unchanged truth is silence — dir bindings included');
630
+
631
+ fs.renameSync(path.join(first.dir, 'shelf'), path.join(first.dir, 'case'));
632
+ first.observer.scanNow(rootId);
633
+ evidence2.reconcile();
634
+ assert.equal(registry2.activeAt(rootUuid, 'case').uuid, shelfUuid,
635
+ 'the durable dir binding carried the folder\'s identity across the restart');
636
+ assert.equal(reborn.filter((event) => event.type === 'entity.committed').length, 1,
637
+ 'one move, one commit — the file under it stayed silent');
638
+ });
639
+
640
+ test('a contest survives restart: the durable binding, not process memory, carries the claim', () => {
641
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'contest-restart-'));
642
+ const dbPath = path.join(dir, 'registry.db');
643
+ const sentinelPath = path.join(dir, 'registry.epoch');
644
+
645
+ // Process one: two files, then b.txt departs — its record keeps the slot.
646
+ const first = makeIntake('contest-a', { registryDb: new Database(dbPath), sentinelPath });
647
+ fs.writeFileSync(path.join(first.dir, 'a.txt'), 'alpha');
648
+ fs.writeFileSync(path.join(first.dir, 'b.txt'), 'beta');
649
+ const rootId = first.enroll();
650
+ first.evidence.reconcile();
651
+ const rootUuid = rootOf(first.registry).uuid;
652
+ const uuidA = first.registry.activeAt(rootUuid, 'a.txt').uuid;
653
+ const recordB = first.registry.activeAt(rootUuid, 'b.txt');
654
+ fs.rmSync(path.join(first.dir, 'b.txt'));
655
+ first.observer.scanNow(rootId);
656
+ first.evidence.reconcile();
657
+
658
+ // While the process is down, the physical a.txt lands on b.txt's slot.
659
+ fs.renameSync(path.join(first.dir, 'a.txt'), path.join(first.dir, 'b.txt'));
660
+
661
+ // Process two: same registry database, same durable evidence memory,
662
+ // same observer store. Startup must DERIVE the contest from the durable
663
+ // binding — with process-memory bindings this exact path silently hands
664
+ // the contested ground to the slot's owner.
665
+ const reborn = [];
666
+ const db2 = new Database(dbPath);
667
+ const registry2 = createRegistry({ db: db2, sentinelPath, emit: (event) => reborn.push(event) });
668
+ const evidence2 = createEvidence({
669
+ observer: first.observer, registry: registry2, rootId,
670
+ memory: createEvidenceMemory(db2),
671
+ classify: () => 'file.text',
672
+ });
673
+ first.observer.scanNow(rootId);
674
+ evidence2.reconcile();
675
+ assert.deepEqual(reborn, [], 'a contest commits nothing — not even across a restart');
676
+ assert.deepEqual([...evidence2.divergence().keys()], ['b.txt'],
677
+ 'the contested ground is derived, reported, and assigned to nobody');
678
+ assert.deepEqual(registry2.activeAt(rootUuid, 'b.txt'), recordB,
679
+ 'the slot\'s owner is byte-for-byte untouched — it never absorbed the arriving bytes');
680
+ assert.equal(registry2.activeAt(rootUuid, 'a.txt').uuid, uuidA,
681
+ 'the claimant\'s record never moved');
682
+ });
683
+
684
+ test('losing the observer store never resolves a contest: forgetting cannot authorize assignment', () => {
685
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'contest-wipe-'));
686
+ const dbPath = path.join(dir, 'registry.db');
687
+ const sentinelPath = path.join(dir, 'registry.epoch');
688
+
689
+ // A full contest, established and reported.
690
+ const first = makeIntake('contest-w', { registryDb: new Database(dbPath), sentinelPath });
691
+ fs.writeFileSync(path.join(first.dir, 'a.txt'), 'alpha');
692
+ fs.writeFileSync(path.join(first.dir, 'b.txt'), 'beta');
693
+ const rootId = first.enroll();
694
+ first.evidence.reconcile();
695
+ const rootUuid = rootOf(first.registry).uuid;
696
+ const recordB = first.registry.activeAt(rootUuid, 'b.txt');
697
+ fs.rmSync(path.join(first.dir, 'b.txt'));
698
+ first.observer.scanNow(rootId);
699
+ first.evidence.reconcile();
700
+ fs.renameSync(path.join(first.dir, 'a.txt'), path.join(first.dir, 'b.txt'));
701
+ first.observer.scanNow(rootId);
702
+ first.evidence.reconcile();
703
+ assert.deepEqual([...first.evidence.divergence().keys()], ['b.txt']);
704
+
705
+ // The observer store is LOST: a brand-new observer re-mints every
706
+ // channel — including the root's, and the root channel is the bound
707
+ // WITNESS. Losing evidence does not resolve ambiguity: the intake
708
+ // refuses the unknown witness loudly, commits nothing, and the durable
709
+ // marker keeps the contest exactly where it was. Forgetting cannot
710
+ // authorize assignment — the forgetter is never even heard.
711
+ const reborn = [];
712
+ const db2 = new Database(dbPath);
713
+ const registry2 = createRegistry({ db: db2, sentinelPath, emit: (event) => reborn.push(event) });
714
+ const observer2 = createObserver({ db: new Database(':memory:'), emit: () => {} });
715
+ const rebornRootId = observer2.enrollRoot(first.dir).rootId;
716
+ const evidence2 = createEvidence({
717
+ observer: observer2, registry: registry2, rootId: rebornRootId,
718
+ memory: createEvidenceMemory(db2),
719
+ classify: () => 'file.text',
720
+ });
721
+ assert.throws(() => evidence2.reconcile(), /identity evidence lost/,
722
+ 'a fresh observer store over surviving records is loss, never a fresh start');
723
+ assert.deepEqual(reborn, [], 'forgetting commits nothing');
724
+ assert.deepEqual(db2.prepare('SELECT rel_path FROM contested_ground').all(), [{ rel_path: 'b.txt' }],
725
+ 'the durable marker holds the contest through the loss');
726
+ assert.deepEqual(registry2.activeAt(rootUuid, 'b.txt'), recordB,
727
+ 'the slot\'s owner never absorbs — not even when Amalgm forgot why the ground was contested');
728
+ });
729
+
730
+ test('identity evidence is durable or its loss is explicit: a lost observer store convicts, never mints', () => {
731
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'witness-'));
732
+ const dbPath = path.join(dir, 'registry.db');
733
+ const sentinelPath = path.join(dir, 'registry.epoch');
734
+
735
+ const first = makeIntake('witness-a', { registryDb: new Database(dbPath), sentinelPath });
736
+ fs.mkdirSync(path.join(first.dir, 'alpha'));
737
+ fs.writeFileSync(path.join(first.dir, 'alpha', 'note.txt'), 'hello');
738
+ const rootId = first.enroll();
739
+ first.evidence.reconcile();
740
+ const rootUuid = rootOf(first.registry).uuid;
741
+ const alphaUuid = first.registry.activeAt(rootUuid, 'alpha').uuid;
742
+
743
+ // Down: the observer database is gone, and the folder moves while
744
+ // nobody watched. The one witness that could prove alpha and beta are
745
+ // the same folder no longer exists — and nothing may guess.
746
+ fs.renameSync(path.join(first.dir, 'alpha'), path.join(first.dir, 'beta'));
747
+ const reborn = [];
748
+ const db2 = new Database(dbPath);
749
+ const registry2 = createRegistry({ db: db2, sentinelPath, emit: (event) => reborn.push(event) });
750
+ const observer2 = createObserver({ db: new Database(':memory:'), emit: () => {} });
751
+ const rebornRootId = observer2.enrollRoot(first.dir).rootId;
752
+ const evidence2 = createEvidence({
753
+ observer: observer2, registry: registry2, rootId: rebornRootId,
754
+ memory: createEvidenceMemory(db2), classify: () => 'file.text',
755
+ });
756
+ assert.throws(() => evidence2.reconcile(), /identity evidence lost/,
757
+ 'an unknown observer root while bindings still claim these records is loss');
758
+ assert.equal(evidence2.dirty(), true, 'the intake stays dirty: loud and unresolved, never absorbed');
759
+ assert.deepEqual(reborn, [], 'no record was minted, moved, or trashed over the surviving registry');
760
+ assert.equal(registry2.activeAt(rootUuid, 'alpha').uuid, alphaUuid, 'the folder record holds its identity');
761
+ assert.ok(!registry2.activeAt(rootUuid, 'beta'), 'no replacement was minted at the new address');
762
+
763
+ // A TRUE fresh start — no prior claim on the root record — seats a new
764
+ // witness without ceremony.
765
+ const kit = makeIntake('witness-fresh');
766
+ fs.writeFileSync(path.join(kit.dir, 'a.txt'), 'a');
767
+ kit.enroll();
768
+ kit.evidence.reconcile();
769
+ assert.ok(kit.registry.activeAt(rootOf(kit.registry).uuid, 'a.txt'), 'a fresh start is not loss');
770
+ });
771
+
772
+ test('the loss gate runs before any canonical proposal: conviction leaves the root record untouched', () => {
773
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'gate-'));
774
+ const dbPath = path.join(dir, 'registry.db');
775
+ const sentinelPath = path.join(dir, 'registry.epoch');
776
+
777
+ const first = makeIntake('gate-a', { registryDb: new Database(dbPath), sentinelPath });
778
+ fs.writeFileSync(path.join(first.dir, 'a.txt'), 'alpha');
779
+ const rootId = first.enroll();
780
+ first.evidence.reconcile();
781
+ assert.equal(rootOf(first.registry).type, 'workspace');
782
+
783
+ // Down: the observer store is gone AND `git init` ran. The fresh
784
+ // observer truthfully reports repo ground — but it cannot prove it is
785
+ // the witness, so even the root's own lens transition may not land on
786
+ // its word. Loss convicts BEFORE the first proposal, never between two.
787
+ fs.mkdirSync(path.join(first.dir, '.git'));
788
+ fs.writeFileSync(path.join(first.dir, '.git', 'HEAD'), 'ref: refs/heads/main');
789
+ const reborn = [];
790
+ const db2 = new Database(dbPath);
791
+ const registry2 = createRegistry({ db: db2, sentinelPath, emit: (event) => reborn.push(event) });
792
+ const observer2 = createObserver({ db: new Database(':memory:'), emit: () => {} });
793
+ const rebornRootId = observer2.enrollRoot(first.dir).rootId;
794
+ const evidence2 = createEvidence({
795
+ observer: observer2, registry: registry2, rootId: rebornRootId,
796
+ memory: createEvidenceMemory(db2), classify: () => 'file.text',
797
+ });
798
+ assert.throws(() => evidence2.reconcile(), /identity evidence lost/,
799
+ 'the unknown witness convicts even while its ground report is true');
800
+ assert.deepEqual(reborn, [], 'conviction happened before the first proposal — zero commits');
801
+ assert.equal(rootOf(registry2).type, 'workspace',
802
+ 'the root record did not transition on the word of evidence that cannot prove who it is');
803
+ });
804
+
805
+ // The link-record exam — pointer-as-payload, derived resolution, honest
806
+ // death — lives in tests/registration.test.js now: resolution is owned by
807
+ // the registration boundary (the layer that can see every tree), so the
808
+ // exam runs against the composed system it actually describes.
809
+
810
+ test('a slot survives file ↔ link replacement: reconnection is identity, and the kind transitions with the ground', () => {
811
+ const kit = makeIntake('link-flip');
812
+ fs.writeFileSync(path.join(kit.dir, 'config'), 'inline');
813
+ fs.writeFileSync(path.join(kit.dir, 'real.conf'), 'real');
814
+ const rootId = kit.enroll();
815
+ kit.evidence.reconcile();
816
+ const rootUuid = rootOf(kit.registry).uuid;
817
+ const uuid = kit.registry.activeAt(rootUuid, 'config').uuid;
818
+
819
+ // The file is replaced by a pointer at its own address.
820
+ fs.rmSync(path.join(kit.dir, 'config'));
821
+ fs.symlinkSync('real.conf', path.join(kit.dir, 'config'));
822
+ kit.observer.scanNow(rootId);
823
+ kit.evidence.reconcile();
824
+ let record = kit.registry.activeAt(rootUuid, 'config');
825
+ assert.equal(record.uuid, uuid, 'the slot reconnected the same entity');
826
+ assert.equal(record.type, 'link', 'and the record transitioned with its ground');
827
+
828
+ // And back: the pointer is replaced by bytes again.
829
+ fs.unlinkSync(path.join(kit.dir, 'config'));
830
+ fs.writeFileSync(path.join(kit.dir, 'config'), 'inline again');
831
+ kit.observer.scanNow(rootId);
832
+ kit.evidence.reconcile();
833
+ record = kit.registry.activeAt(rootUuid, 'config');
834
+ assert.equal(record.uuid, uuid, 'identity survives the round trip');
835
+ assert.equal(record.type, 'file.text');
836
+ });
837
+
838
+ test('one contest, one row: a contested subtree stores its root marker only — coverage is arithmetic', () => {
839
+ const kit = makeIntake('contest-rows');
840
+ fs.mkdirSync(path.join(kit.dir, 'a'));
841
+ kit.enroll();
842
+ kit.evidence.reconcile();
843
+ fs.rmSync(path.join(kit.dir, 'a'), { recursive: true }); // the owner parks
844
+ kit.observer.scanNow();
845
+ kit.evidence.reconcile();
846
+
847
+ fs.mkdirSync(path.join(kit.dir, 'c', 'sub'), { recursive: true });
848
+ fs.writeFileSync(path.join(kit.dir, 'c', 'f.txt'), 'body');
849
+ kit.observer.scanNow();
850
+ kit.evidence.reconcile();
851
+ fs.renameSync(path.join(kit.dir, 'c'), path.join(kit.dir, 'a')); // the subtree arrives on the parked slot
852
+ kit.observer.scanNow();
853
+ kit.evidence.reconcile();
854
+
855
+ assert.deepEqual(kit.db.prepare('SELECT rel_path, kind FROM contested_ground').all(),
856
+ [{ rel_path: 'a', kind: 'dir' }],
857
+ 'however large the subtree, the contest is one durable marker at its root');
858
+ assert.deepEqual([...kit.evidence.divergence().keys()].sort(), ['a', 'a/f.txt', 'a/sub'],
859
+ 'the report still covers every path under the contest, derived by prefix');
860
+
861
+ fs.renameSync(path.join(kit.dir, 'a'), path.join(kit.dir, 'd')); // the ground leaves: resolution
862
+ kit.observer.scanNow();
863
+ kit.evidence.reconcile();
864
+ assert.equal(kit.db.prepare('SELECT count(*) AS n FROM contested_ground').get().n, 0);
865
+ assert.equal(kit.evidence.divergence().size, 0);
866
+ });
867
+
868
+ test('a failed durable write is retried, never believed: memory changes only after the database', () => {
869
+ let hiccups = 1;
870
+ const kit = makeIntake('flaky', {
871
+ wrapMemory: (real) => ({
872
+ ...real,
873
+ bind: (scopeId, channelId, uuid) => {
874
+ if (hiccups > 0) { hiccups -= 1; throw new Error('durable write hiccup'); }
875
+ return real.bind(scopeId, channelId, uuid);
876
+ },
877
+ }),
878
+ });
879
+ fs.writeFileSync(path.join(kit.dir, 'a.txt'), 'alpha');
880
+ kit.enroll();
881
+ assert.throws(() => kit.evidence.reconcile(), /durable write hiccup/);
882
+ assert.equal(kit.evidence.dirty(), true, 'a failed durable write leaves the intake dirty, never silently bound');
883
+ assert.equal(kit.regEvents.filter((event) => event.type === 'entity.committed').length, 0,
884
+ 'nothing rings until its truth commits: the rolled-back birth was never announced');
885
+
886
+ // The retry re-derives from observer truth and re-attempts the SAME
887
+ // durable write. Cache-first ordering would have believed the first
888
+ // attempt, skipped the write forever, and lost the claim at the next
889
+ // restart while reconciliation finished clean.
890
+ kit.evidence.reconcile();
891
+ assert.equal(kit.evidence.dirty(), false);
892
+ const rootUuid = rootOf(kit.registry).uuid;
893
+ const children = kit.registry.children(rootUuid);
894
+ assert.equal(children.length, 1, 'the retry reconnected the already-born record — no second birth');
895
+ const observerRoot = kit.observer.listRoots()[0];
896
+ const channel = kit.observer.filesForRoot(observerRoot.rootId)
897
+ .find((row) => row.relPath === 'a.txt').fileId;
898
+ assert.deepEqual(createEvidenceMemory(kit.db).bindings(observerRoot.rootId), [
899
+ { channelId: observerRoot.rootId, uuid: rootUuid },
900
+ { channelId: channel, uuid: children[0].uuid },
901
+ ], 'the retried writes landed durably — the witness and the claim both survive a restart');
902
+ assert.ok(kit.regEvents.some((event) => event.type === 'entity.committed'
903
+ && event.record.uuid === rootUuid),
904
+ 'the committed bell rang once the birth actually held');
905
+ });
906
+
907
+ test('nothing rings until its truth commits: bells wait for the outermost commit, and a rollback silences them', () => {
908
+ const events = [];
909
+ const db = new Database(':memory:');
910
+ const registry = createRegistry({ db, emit: (event) => events.push(event), ephemeral: true });
911
+
912
+ // A rollback silences what it rolled back: the phantom is never announced.
913
+ assert.throws(() => registry.transaction(() => {
914
+ registry.enrollRoot({ type: 'workspace', name: 'phantom' });
915
+ throw new Error('torn');
916
+ }), /torn/);
917
+ assert.equal(registry.roots().length, 0);
918
+ assert.equal(events.length, 0, 'no bell for a record the database never held');
919
+
920
+ // A commit rings everything it held — after the truth is durable.
921
+ const born = registry.transaction(() => registry.enrollRoot({ type: 'workspace', name: 'real' }));
922
+ assert.deepEqual(events.map((event) => event.type), ['entity.committed']);
923
+ assert.equal(events[0].record.uuid, born.uuid);
924
+
925
+ // Nested: a caught inner savepoint's failure silences ONLY its own bells.
926
+ registry.transaction(() => {
927
+ registry.create({ type: 'folder', parentUUID: born.uuid, name: 'kept' });
928
+ try {
929
+ registry.transaction(() => {
930
+ registry.create({ type: 'folder', parentUUID: born.uuid, name: 'gone' });
931
+ throw new Error('inner torn');
932
+ });
933
+ } catch { /* the savepoint rolled back */ }
934
+ });
935
+ assert.deepEqual(events.slice(1).map((event) => event.record.name), ['kept'],
936
+ 'the inner bell died with its savepoint; the outer rang at commit');
937
+ assert.equal(registry.activeAt(born.uuid, 'gone'), null);
938
+ });
939
+
940
+ test('dropped evidence tables are loss, not first init: nothing silently recreates them', () => {
941
+ const db = new Database(':memory:');
942
+ createRegistry({ db, emit: () => {}, ephemeral: true });
943
+ createEvidenceMemory(db);
944
+ db.exec('DROP TABLE channel_bindings; DROP TABLE contested_ground');
945
+
946
+ // Before this law, CREATE IF NOT EXISTS quietly replaced the dropped
947
+ // tables and reported no bindings and no contests — an empty memory that
948
+ // would resolve every open contest by forgetting it.
949
+ assert.throws(() => createEvidenceMemory(db),
950
+ /evidence memory lost: channel_bindings, contested_ground missing/);
951
+ assert.throws(() => createRegistry({ db, emit: () => {}, ephemeral: true }),
952
+ /registry corrupt: channel_bindings, contested_ground gone/);
953
+ });
954
+
955
+ test('evidence memory from another registry\'s database is refused by epoch', () => {
956
+ const dbA = new Database(':memory:');
957
+ const registryA = createRegistry({ db: dbA, emit: () => {}, ephemeral: true });
958
+ const dbB = new Database(':memory:');
959
+ createRegistry({ db: dbB, emit: () => {}, ephemeral: true });
960
+ assert.throws(
961
+ () => createEvidence({
962
+ observer: {}, registry: registryA, rootId: 'nowhere',
963
+ memory: createEvidenceMemory(dbB), classify: () => 'file.text',
964
+ }),
965
+ /evidence memory epoch .* is not this registry's/,
966
+ 'cross-database wiring is the misassignment family, refused at construction');
967
+ });