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,826 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The evidence intake: observer truth flows onto registry records here, and
5
+ * nowhere else. The registry stays the sole owner of identity and lifecycle;
6
+ * this layer only PROPOSES through the registry's public verbs — it holds no
7
+ * private handle to the record store and can do nothing detection may not.
8
+ *
9
+ * The inbound doorbell contract (docs/entity-model.md):
10
+ * 1. an observer event arrives;
11
+ * 2. `onEvent` marks intake dirty and returns — it is INFALLIBLE, so a
12
+ * throwing consumer can never swallow an observer event;
13
+ * 3. fallible reconciliation happens afterward by reading OBSERVER-STORE
14
+ * truth — events are hints, never the data;
15
+ * 4. a failed reconciliation leaves dirty set; the next event or
16
+ * `flushIfDirty()` reattempts it;
17
+ * 5. startup performs the identical reconciliation.
18
+ * At-most-once event delivery is never load-bearing.
19
+ *
20
+ * The channel law, four sentences — KIND-BLIND, now that directories are
21
+ * channels too: one channel claims at most one entity. An entity may
22
+ * temporarily hold several evidence channels. Only the channel on the
23
+ * record's own address is canonical ground. Every other bound channel's
24
+ * ground is CONTESTED — reported as divergence, moving nothing — so a
25
+ * bound channel moves its record only onto unowned ground, and only while
26
+ * its entity has no canonical ground elsewhere.
27
+ *
28
+ * A contest is two established identity claims on one address (a bound
29
+ * channel's continuity against another active record's slot), and Amalgm
30
+ * never picks the winner: it resolves only when the contested ground
31
+ * actually leaves the address — ground of the mark's own kind; a file
32
+ * replaced by a directory has left — or by an explicit identity-carrying
33
+ * action. Ground reappearing at the claimant's old address resolves
34
+ * NOTHING — it could be a copy; presence proves nothing about identity.
35
+ * And an address that runs THROUGH contested ground is itself contested
36
+ * for whatever claimant sits there: a moved subtree's children cannot be
37
+ * lawfully addressed while their parent's ground is undecided. That
38
+ * coverage is ARITHMETIC, never storage — a contest writes one durable
39
+ * marker at its root, and the subtree beneath it derives by prefix,
40
+ * however large it is.
41
+ *
42
+ * Evidence has a DURABLE memory (`createEvidenceMemory`, two tables beside
43
+ * the records under the same sentinel): channel bindings (channelId →
44
+ * entityUUID — the schema itself enforces one claim per channel, and each
45
+ * binding carries its SCOPE, the observer rootId whose lens owns it) and
46
+ * contested-ground markers. Bindings make the contest DERIVED state at any
47
+ * moment — startup, flush, mid-session — instead of state anyone repairs;
48
+ * without them, a restart would silently hand contested ground to the
49
+ * slot's owner. The observer root is itself a bound channel — the
50
+ * WITNESS, and the witness binding is how a lens finds its tree's record:
51
+ * identity is looked up, never guessed from a name or an address. The
52
+ * loss law lives on the boundary above now that ONE registry holds every
53
+ * tree: any active parentless record no live witness claims convicts
54
+ * loudly before a single lens reconciles (registration/index.js) — a
55
+ * lost observer store is never a fresh start, and no replacement mints
56
+ * over surviving records. Markers survive the same loss: forgetting the
57
+ * evidence cannot authorize assigning contested ground to anyone. Both
58
+ * are internal evidence — never a seventh public record field. The
59
+ * registry schema creates both tables so its inspect convicts their loss
60
+ * like any other registry loss, and every durable write lands BEFORE the
61
+ * in-memory cache, so a failed write is retried by the next
62
+ * reconciliation, never believed.
63
+ *
64
+ * What reconcile maps (the claimed slice today):
65
+ * - the root record (workspace ↔ repo.git under the root lens; a
66
+ * single-ground root's record IS the file or link entity — bytes
67
+ * attested like any file, a pointer attested exactly as written —
68
+ * and disk deciding the ground's kind is a type transition on the
69
+ * same UUID);
70
+ * - file, folder, AND link records under one law: birth, payload
71
+ * attestation (files attest content, links attest the pointer exactly
72
+ * as written — a container's payload is derived membership; what a
73
+ * pointer currently REACHES is derived resolution, owned by the
74
+ * registration boundary that can see every tree, recomputed on
75
+ * demand and never stored),
76
+ * classification flips, moves per the channel law above, slot
77
+ * reconnection (a fresh channel landing on an absent active owner's
78
+ * unmarked slot rebinds — whatever kind it returns as — never a second
79
+ * entity). Folder identity rides the directory channel: one move on
80
+ * the folder record and every descendant's derived address follows,
81
+ * which is why folders reconcile before files, parents before
82
+ * children;
83
+ * - repo territories as repo.git type transitions on the slot's record;
84
+ * - ADOPTION, the channel law completing across trees: unclaimed
85
+ * ground wearing the physical identity of an orphaned top-level tree
86
+ * is that tree's arrival — one lawful parent update on its
87
+ * parentless record, never a birth (dir ground adopts a files tree,
88
+ * territory news adopts a repo tree; single-ground trees reattach
89
+ * explicitly — a cross-tree file claim has no parent census to
90
+ * bound its ambiguity, so the deliberate act decides);
91
+ * - divergence, from two sources in one report: contested ground (null
92
+ * bytes when the ground is a directory), and the container refusal
93
+ * (file ground on a folder that still has active children) — ground
94
+ * detection cannot lawfully assign to any entity.
95
+ *
96
+ * Classification is an INJECTED policy (the Axiom-2 pattern) asked about
97
+ * the observed bytes' own report; the model owns no such rule. And ONE
98
+ * OBSERVATION IS ONE TRANSITION: a look's payload and the type it implies
99
+ * reach the registry in a single call (`registry.attest`), so a record can
100
+ * never durably wear a type from one read beside a payload from another.
101
+ */
102
+
103
+ const fs = require('fs');
104
+ const path = require('path');
105
+
106
+ const FILE_TYPES = new Set(['file.text', 'file.binary']);
107
+ const LEAF_TYPES = new Set(['file.text', 'file.binary', 'link']);
108
+ // Adoption candidates by ground shape: a dir channel can be an arrived
109
+ // files tree; a territory can be an arrived repo tree. Single-ground
110
+ // (file/link) trees stay outside adoption — a file's inode is weak
111
+ // identity (atomic saves churn it), so their return is reattachment.
112
+ const FILES_TREE = new Set(['files']);
113
+ const REPO_TREE = new Set(['repo']);
114
+
115
+ const MEMORY_TABLES = ['registry_meta', 'channel_bindings', 'contested_ground'];
116
+
117
+ /**
118
+ * The evidence layer's durable memory, living in the ONE identity
119
+ * database so one sentinel guards records and evidence together — but
120
+ * owned here: the registry never reads these tables, and evidence never
121
+ * touches its. The registry SCHEMA is what creates them (all four tables
122
+ * are born together), so this factory only verifies and reads: a table
123
+ * gone from an established database is loss, and forgetting evidence
124
+ * must fail loudly — a silently recreated empty table would resolve
125
+ * every open contest.
126
+ *
127
+ * Every binding and contest marker carries a SCOPE — the observer rootId
128
+ * whose evidence namespace owns it. Lenses over different trees share
129
+ * this memory without ever touching each other's channels; channel ids
130
+ * themselves stay globally unique, so `boundTo` answers across scopes
131
+ * (adoption asks whose record an orphaned tree's witness claims).
132
+ * Retiring a tree drops its whole scope — bindings and markers together.
133
+ */
134
+ function createEvidenceMemory(db) {
135
+ const present = db.prepare(
136
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${MEMORY_TABLES.map(() => '?').join(', ')})`,
137
+ ).all(...MEMORY_TABLES).map((row) => row.name);
138
+ const missing = MEMORY_TABLES.filter((name) => !present.includes(name));
139
+ if (missing.length > 0) {
140
+ throw new Error(`evidence memory lost: ${missing.join(', ')} missing from this database — the registry schema creates these tables, and a gap in an established database is loss, never a fresh start`);
141
+ }
142
+ const meta = db.prepare('SELECT epoch_uuid FROM registry_meta WHERE singleton = 1').get();
143
+ if (!meta) {
144
+ throw new Error('evidence memory requires an initialized registry: registry_meta holds no epoch row — create the registry before its evidence memory');
145
+ }
146
+ return {
147
+ epoch: () => meta.epoch_uuid,
148
+ bindings: (scopeId) => db.prepare('SELECT channel_id, entity_uuid FROM channel_bindings WHERE scope_id = ?')
149
+ .all(scopeId).map((row) => ({ channelId: row.channel_id, uuid: row.entity_uuid })),
150
+ bind: (scopeId, channelId, uuid) => db.prepare(
151
+ 'INSERT INTO channel_bindings (channel_id, scope_id, entity_uuid) VALUES (?, ?, ?)'
152
+ + ' ON CONFLICT(channel_id) DO UPDATE SET scope_id = excluded.scope_id, entity_uuid = excluded.entity_uuid',
153
+ ).run(channelId, scopeId, uuid),
154
+ unbind: (channelId) => db.prepare('DELETE FROM channel_bindings WHERE channel_id = ?').run(channelId),
155
+ /** Across scopes — channel ids are globally unique. */
156
+ boundTo: (channelId) => db.prepare('SELECT entity_uuid FROM channel_bindings WHERE channel_id = ?')
157
+ .get(channelId)?.entity_uuid,
158
+ allBindings: () => db.prepare('SELECT channel_id, scope_id, entity_uuid FROM channel_bindings').all()
159
+ .map((row) => ({ channelId: row.channel_id, scopeId: row.scope_id, uuid: row.entity_uuid })),
160
+ /** A retired tree's whole evidence namespace, gone as one act. */
161
+ dropScope: (scopeId) => {
162
+ db.prepare('DELETE FROM channel_bindings WHERE scope_id = ?').run(scopeId);
163
+ db.prepare('DELETE FROM contested_ground WHERE scope_id = ?').run(scopeId);
164
+ },
165
+ contests: (scopeId) => db.prepare('SELECT rel_path, claimant_uuid, kind FROM contested_ground WHERE scope_id = ?')
166
+ .all(scopeId).map((row) => ({ relPath: row.rel_path, uuid: row.claimant_uuid, kind: row.kind })),
167
+ contest: (scopeId, relPath, uuid, kind) => db.prepare(
168
+ 'INSERT INTO contested_ground (scope_id, rel_path, claimant_uuid, kind) VALUES (?, ?, ?, ?)'
169
+ + ' ON CONFLICT(scope_id, rel_path) DO UPDATE SET claimant_uuid = excluded.claimant_uuid, kind = excluded.kind',
170
+ ).run(scopeId, relPath, uuid, kind),
171
+ clearContest: (scopeId, relPath) => db.prepare('DELETE FROM contested_ground WHERE scope_id = ? AND rel_path = ?')
172
+ .run(scopeId, relPath),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * THE LOSS LAW, one function, asked at the only dangerous moments: every
178
+ * active parentless record must be claimed by a LIVE observer root's
179
+ * witness binding. A lost or torn observer store leaves registered trees
180
+ * unclaimed — the evidence behind every binding is gone — and that
181
+ * convicts loudly. A lens asks before minting any new tree record; the
182
+ * registration boundary asks before any lens reconciles. A lost store is
183
+ * never a fresh start, no replacement identity ever mints over surviving
184
+ * records, and re-enrollment after loss is an explicit act.
185
+ */
186
+ function assertWitnessed({ observer, registry, memory }) {
187
+ const liveIds = new Set(observer.listRoots().map((row) => row.rootId));
188
+ const claimed = new Set(memory.allBindings()
189
+ .filter((row) => liveIds.has(row.channelId))
190
+ .map((row) => row.uuid));
191
+ const unclaimed = registry.roots().filter((record) => !claimed.has(record.uuid));
192
+ if (unclaimed.length > 0) {
193
+ throw new Error(`identity evidence lost: registered tree(s) ${unclaimed.map((record) => record.uuid).join(', ')} have no living witness — a lost observer store cannot re-earn its claims, and re-enrollment must be explicit`);
194
+ }
195
+ }
196
+
197
+ function createEvidence(options) {
198
+ const { observer, registry, memory, rootId, classify } = options;
199
+ if (!observer || !registry) throw new Error('createEvidence requires { observer, registry }');
200
+ if (!memory) {
201
+ throw new Error('createEvidence requires { memory }: channel bindings and contested ground must survive a restart — in-memory evidence is the silent-handoff hole');
202
+ }
203
+ if (typeof rootId !== 'string') {
204
+ throw new Error('createEvidence requires { rootId }: a lens is keyed by the tree\'s permanent identity, never its address — the address is derived fresh each reconcile');
205
+ }
206
+ if (typeof classify !== 'function') {
207
+ throw new Error('createEvidence requires { classify }: ({ binary }) => file type — an injected policy over the OBSERVED BYTES\' own report, never a built-in rule and never a fresh read of the ground');
208
+ }
209
+ if (memory.epoch() !== registry.epoch()) {
210
+ throw new Error(`evidence memory epoch ${memory.epoch()} is not this registry's ${registry.epoch()} — bindings from another registry's database cannot claim these records`);
211
+ }
212
+
213
+ let dirty = false;
214
+ let scheduled = false;
215
+ let stopped = false;
216
+ let groundPath = null; // the tree's CURRENT address, read from the observer at each reconcile
217
+ const channelToUuid = new Map(memory.bindings(rootId).map((row) => [row.channelId, row.uuid]));
218
+ const contested = new Map(memory.contests(rootId).map((row) => [row.relPath, { uuid: row.uuid, kind: row.kind }]));
219
+ let divergent = new Map(); // relPath -> hash (null for dir ground), ground no entity may claim
220
+
221
+ // Durable truth first, cache second — here and in the marker sync below.
222
+ // A failed database write leaves the cache unset, so the retrying
223
+ // reconciliation re-derives and re-attempts it; cache-first would believe
224
+ // a write that never happened and skip it forever.
225
+ function bind(channelId, uuid) {
226
+ memory.bind(rootId, channelId, uuid);
227
+ channelToUuid.set(channelId, uuid);
228
+ }
229
+
230
+ function release(channelId) {
231
+ if (!channelToUuid.has(channelId)) return;
232
+ memory.unbind(channelId);
233
+ channelToUuid.delete(channelId);
234
+ }
235
+
236
+ /** Derived, never stored: the record's current address, via the chain. */
237
+ function pathOf(uuid) {
238
+ const names = [];
239
+ let record = registry.record(uuid);
240
+ while (record.parentUUID !== null) {
241
+ names.unshift(record.name);
242
+ record = registry.record(record.parentUUID);
243
+ }
244
+ return names.join('/');
245
+ }
246
+
247
+ /** Walk an address slot by slot WITHOUT materializing anything. */
248
+ function activeAtPath(relPath, root) {
249
+ let cursor = root.uuid;
250
+ const parts = relPath.split('/');
251
+ for (let i = 0; i < parts.length - 1; i++) {
252
+ const at = registry.activeAt(cursor, parts[i]);
253
+ if (!at) return null;
254
+ cursor = at.uuid;
255
+ }
256
+ return registry.activeAt(cursor, parts[parts.length - 1]);
257
+ }
258
+
259
+ /** Every active record under the root, with its derived address. */
260
+ function walkActive(uuid, prefix, out) {
261
+ for (const child of registry.children(uuid)) {
262
+ if (child.status !== 'active') continue;
263
+ const rel = prefix === '' ? child.name : `${prefix}/${child.name}`;
264
+ out.push({ record: child, rel });
265
+ walkActive(child.uuid, rel, out);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /** Materialize the folder chain for a path; file → folder is always valid. */
271
+ function ensureDir(relDir, root) {
272
+ let cursor = root.uuid;
273
+ if (relDir === '' || relDir === '.') return cursor;
274
+ for (const segment of relDir.split('/')) {
275
+ const at = registry.activeAt(cursor, segment);
276
+ if (!at) {
277
+ cursor = registry.create({ type: 'folder', parentUUID: cursor, name: segment }).uuid;
278
+ } else if (FILE_TYPES.has(at.type)) {
279
+ cursor = registry.changeType(at.uuid, 'folder').uuid;
280
+ } else {
281
+ cursor = at.uuid;
282
+ }
283
+ }
284
+ return cursor;
285
+ }
286
+
287
+ /**
288
+ * Leaf payload attestation — ONE OBSERVATION, ONE TRANSITION. The
289
+ * payload and the type reach the registry in a single call because
290
+ * they describe a single look: bytes carry the policy's answer about
291
+ * the OBSERVED BYTES' own report (produced by the very read that
292
+ * produced this hash — this layer never opens the ground), and a
293
+ * pointer carries `link`, which is disk's decision and not a policy's.
294
+ * A look that finds the other kind on the slot is that same one
295
+ * transition, not a type flip followed by an attestation.
296
+ *
297
+ * Only leaf ground attests: a container's payload is its derived
298
+ * membership, and a reference's payload is the target's IDENTITY,
299
+ * written by the deliberate act that created it — no ground speaks
300
+ * for either, so both are silence here.
301
+ */
302
+ function attestPayload(kind, uuid, evidence) {
303
+ if (!LEAF_TYPES.has(registry.record(uuid).type)) return;
304
+ if (kind === 'file') {
305
+ registry.attest(uuid, { payloadVersion: evidence.hash, type: classify(evidence) });
306
+ } else if (kind === 'link') {
307
+ registry.attest(uuid, { payloadVersion: evidence.hash, type: 'link' });
308
+ }
309
+ }
310
+
311
+ /** Orphaned: the tree's recorded address no longer holds its ground. */
312
+ function addressGone(row) {
313
+ try {
314
+ const stat = fs.lstatSync(row.path);
315
+ return stat.dev !== row.device || stat.ino !== row.inode;
316
+ } catch {
317
+ return true;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * ADOPTION — the channel law completing across trees: unclaimed ground
323
+ * wearing the physical identity of an orphaned top-level tree is that
324
+ * tree's ARRIVAL, never a birth. A tree boundary is not an identity
325
+ * boundary, so the whole tree joins this one as one lawful parent
326
+ * update on its parentless record; every descendant record rides
327
+ * along, and their channels reconnect slot by slot under the ordinary
328
+ * arrival law. The claim rules are every continuity rescue's: exactly
329
+ * one candidate, only for a tree whose own address no longer holds its
330
+ * ground, and only a tree that was actually registered — ground that
331
+ * never earned a record has nothing to preserve, so a birth is honest.
332
+ */
333
+ function arrivedTree(device, inode, kinds) {
334
+ if (device == null) return null;
335
+ const roots = observer.listRoots();
336
+ const hasShape = (row) => {
337
+ if (kinds.has(row.kind)) return true;
338
+ // A repo moved across parent directories can be noticed first at its
339
+ // old address. The observer honestly demotes that absent address to
340
+ // files before the enclosing tree sees the arriving `.git`; its
341
+ // durable witness still names repo.git, so the arriving territory
342
+ // must use the registered shape rather than scan order to preserve
343
+ // one identity.
344
+ if (kinds !== REPO_TREE) return false;
345
+ const uuid = memory.boundTo(row.rootId);
346
+ return uuid !== undefined && registry.record(uuid).type === 'repo.git';
347
+ };
348
+ const matches = roots.filter((row) => row.rootId !== rootId && hasShape(row)
349
+ && row.device === device && row.inode === inode
350
+ && !roots.some((other) => other.rootId !== row.rootId && row.path.startsWith(`${other.path}/`))
351
+ && addressGone(row));
352
+ if (matches.length !== 1) return null;
353
+ const uuid = memory.boundTo(matches[0].rootId);
354
+ if (uuid === undefined) return null;
355
+ const record = registry.record(uuid);
356
+ return record.parentUUID === null && record.status === 'active' ? record : null;
357
+ }
358
+
359
+ function reconcile() {
360
+ dirty = false; // an event landing mid-reconcile re-dirties honestly
361
+ try {
362
+ const rootRow = observer.listRoots().find((row) => row.rootId === rootId);
363
+ if (!rootRow) return; // not enrolled yet — nothing is claimed
364
+ groundPath = rootRow.path; // the address is derived, never the lens's identity
365
+
366
+ // The root record: enrollment is its birth; a .git at the enrolled
367
+ // root is a type transition on the same uuid, never a second root.
368
+ // The observer root is itself a channel claiming that record — the
369
+ // WITNESS, and the witness binding IS the lookup: this lens's tree
370
+ // is whatever record its observer root claims, so the record store
371
+ // is never asked to guess by name or address. The loss law lives
372
+ // one level up now that the registry holds every tree: the
373
+ // boundary convicts any active parentless record no live witness
374
+ // claims (registration/index.js) before any lens reconciles —
375
+ // identity evidence is durable, or its loss is explicit, and a
376
+ // fresh observer store can never silently mint over surviving
377
+ // records. A witness claiming a record that now has a PARENT means
378
+ // this tree was ADOPTED into another: the lens's work is done, and
379
+ // the boundary retires it.
380
+ const fileRow = rootRow.kind === 'file'
381
+ ? (observer.filesForRoot(rootRow.rootId)[0] ?? null)
382
+ : null;
383
+ const linkRow = rootRow.kind === 'file'
384
+ ? (observer.linksForRoot(rootRow.rootId)[0] ?? null)
385
+ : null;
386
+ const rootType = rootRow.kind === 'repo' ? 'repo.git'
387
+ : rootRow.kind === 'file' ? null // the ground's own attestation owns its type
388
+ : 'workspace';
389
+ const witnessed = channelToUuid.get(rootRow.rootId);
390
+ let root = witnessed === undefined ? null : registry.record(witnessed);
391
+ // An adopted ordinary tree retires its lens: its enclosing tree now
392
+ // owns the ground. An adopted repo.git is different: it is a semantic
393
+ // Card + Checkpoint boundary inside that tree, so its own child
394
+ // channels retain a lens keyed by its observer root.
395
+ if (root && root.parentUUID !== null && !(rootRow.kind === 'repo' && root.type === 'repo.git')) return;
396
+ if (root) {
397
+ if (rootType !== null && root.type !== rootType) root = registry.changeType(root.uuid, rootType);
398
+ } else {
399
+ if (rootRow.kind === 'file' && !fileRow && !linkRow) return; // no ground yet: nothing to enroll
400
+ assertWitnessed({ observer, registry, memory }); // minting is the dangerous moment
401
+ // The birth is ONE transition: the record and its witness binding
402
+ // land together or not at all — a tear between them would leave a
403
+ // parentless record no witness claims, which IS the loss shape.
404
+ // The registry's primitive also holds the bell: entity.committed
405
+ // rings only after this commits, never for a birth that rolled back.
406
+ root = registry.transaction(() => {
407
+ const minted = registry.enrollRoot(rootRow.kind !== 'file'
408
+ ? { type: rootType, name: path.basename(groundPath) }
409
+ : fileRow
410
+ ? { type: classify(fileRow), name: path.basename(groundPath), payloadVersion: fileRow.contentHash }
411
+ : { type: 'link', name: path.basename(groundPath), payloadVersion: linkRow.target });
412
+ memory.bind(rootId, rootRow.rootId, minted.uuid);
413
+ return minted;
414
+ });
415
+ channelToUuid.set(rootRow.rootId, root.uuid); // cache after the commit, never inside it
416
+ }
417
+
418
+ // A single-ground tree: the root record IS the file or link entity
419
+ // — one channel of ground, disk deciding its kind, attested under
420
+ // the same law as anywhere else (files re-ask classification only
421
+ // when content changed; a pointer's payload is its written text),
422
+ // behind the same witness gate above. Ground returning as the
423
+ // other kind is a type transition on the same UUID. Absent ground
424
+ // releases the channel and moves nothing: the entity lives, its
425
+ // record stays.
426
+ if (rootRow.kind === 'file') {
427
+ if (fileRow) {
428
+ if (channelToUuid.get(fileRow.fileId) !== root.uuid) bind(fileRow.fileId, root.uuid);
429
+ attestPayload('file', root.uuid, { hash: fileRow.contentHash, binary: fileRow.binary });
430
+ } else if (linkRow) {
431
+ if (channelToUuid.get(linkRow.linkId) !== root.uuid) bind(linkRow.linkId, root.uuid);
432
+ attestPayload('link', root.uuid, { hash: linkRow.target });
433
+ }
434
+ for (const channelId of [...channelToUuid.keys()]) {
435
+ if (channelId !== rootRow.rootId && channelId !== fileRow?.fileId && channelId !== linkRow?.linkId) {
436
+ release(channelId);
437
+ }
438
+ }
439
+ divergent = new Map();
440
+ return;
441
+ }
442
+
443
+ // Observer-store truth, never event payloads (events are hints).
444
+ // Both maps arrive sorted by path, so folders reconcile parents
445
+ // before children.
446
+ const desiredDirs = new Map(); // relPath -> { channelId }
447
+ const desiredFiles = new Map(); // relPath -> { channelId, hash, binary } — one read's whole report
448
+ const desiredLinks = new Map(); // relPath -> { channelId, hash: the pointer as written }
449
+ // A Git root keeps ordinary child channels too, but they are only
450
+ // refreshed after its Repocard follower settles a new state. The
451
+ // observer never lets raw worktree doorbells reach this branch.
452
+ if (rootRow.kind === 'files' || rootRow.kind === 'repo') {
453
+ for (const row of observer.dirsForRoot(rootRow.rootId)) {
454
+ desiredDirs.set(row.relPath, { channelId: row.dirId, device: row.device, inode: row.inode });
455
+ }
456
+ for (const row of observer.filesForRoot(rootRow.rootId)) {
457
+ desiredFiles.set(row.relPath, { channelId: row.fileId, hash: row.contentHash, binary: row.binary });
458
+ }
459
+ for (const row of observer.linksForRoot(rootRow.rootId)) {
460
+ desiredLinks.set(row.relPath, { channelId: row.linkId, hash: row.target });
461
+ }
462
+ }
463
+ const territories = new Set(observer.listRoots()
464
+ .filter((row) => row.kind === 'repo' && row.path.startsWith(`${groundPath}/`))
465
+ .map((row) => row.path.slice(groundPath.length + 1)));
466
+
467
+ // Continuity pass — the channel law applied, kind-blind. Folders
468
+ // move FIRST (before territories and files), because one folder move
469
+ // carries every descendant's derived address with it — including a
470
+ // territory's — and files judged after it see live truth. "No
471
+ // canonical ground elsewhere" is asked live for the same reason.
472
+ //
473
+ // A contest is recorded at its ROOT only: everything beneath a
474
+ // contested address is contested by arithmetic (`ancestorContested`),
475
+ // reported as divergence each reconcile, never stored per descendant
476
+ // — one contest, one durable marker, however large the subtree.
477
+ const contestedNow = new Map(); // relPath -> { uuid, kind } — roots only
478
+ const nowDivergent = new Map(); // relPath -> hash (null for dir ground)
479
+ const ancestorContested = (relPath) => {
480
+ const parts = relPath.split('/');
481
+ for (let i = 1; i < parts.length; i++) {
482
+ const prefix = parts.slice(0, i).join('/');
483
+ if (contested.has(prefix) || contestedNow.has(prefix)) return true;
484
+ }
485
+ return false;
486
+ };
487
+ const underContest = (relPath) => contested.has(relPath) || contestedNow.has(relPath)
488
+ || ancestorContested(relPath);
489
+ const channelPaths = new Map(); // uuid -> [relPath] of its live channels
490
+ for (const desired of [desiredDirs, desiredFiles, desiredLinks]) {
491
+ for (const [relPath, { channelId }] of desired) {
492
+ const uuid = channelToUuid.get(channelId);
493
+ if (uuid === undefined) continue;
494
+ if (!channelPaths.has(uuid)) channelPaths.set(uuid, []);
495
+ channelPaths.get(uuid).push(relPath);
496
+ }
497
+ }
498
+ const groundedElsewhere = (uuid) => {
499
+ const home = pathOf(uuid);
500
+ return (channelPaths.get(uuid) ?? []).some((rel) => rel === home);
501
+ };
502
+ // Each channel whose record must move is settled in rounds, because
503
+ // directories cross by inode: a claim whose slot is held by another
504
+ // waiting mover waits with it, and when no one can move, the waiters
505
+ // hold each other's addresses — one permutation, one transition
506
+ // (registry.moveMany). Everything else is the channel law verbatim:
507
+ // a parked or immovable occupant, ground under an undecided
508
+ // ancestor, or an entity already grounded at home is a contest —
509
+ // move neither, write nothing.
510
+ const claimsOf = (desired, kind) => {
511
+ const claims = [];
512
+ for (const [relPath, entry] of desired) {
513
+ const uuid = channelToUuid.get(entry.channelId);
514
+ if (uuid === undefined) continue;
515
+ if (pathOf(uuid) === relPath) {
516
+ attestPayload(kind, uuid, entry);
517
+ continue;
518
+ }
519
+ claims.push({ relPath, uuid, entry });
520
+ }
521
+ return claims;
522
+ };
523
+ const parentOf = (relPath) => (path.dirname(relPath) === '.' ? '' : path.dirname(relPath));
524
+ const settleClaims = (claims, kind) => {
525
+ let waiting = claims;
526
+ while (waiting.length > 0) {
527
+ const moving = new Set(waiting.map((claim) => claim.uuid));
528
+ const again = [];
529
+ for (const claim of waiting) {
530
+ const { relPath, uuid, entry } = claim;
531
+ if (pathOf(uuid) === relPath) {
532
+ // a parent's move respelled home mid-flight: recorded once,
533
+ // descendants silent
534
+ attestPayload(kind, uuid, entry);
535
+ continue;
536
+ }
537
+ if (contested.has(relPath) || contestedNow.has(relPath)) {
538
+ contestedNow.set(relPath, { uuid, kind });
539
+ continue;
540
+ }
541
+ if (ancestorContested(relPath)) {
542
+ nowDivergent.set(relPath, kind === 'file' ? entry.hash : null);
543
+ continue;
544
+ }
545
+ const occupant = activeAtPath(relPath, root);
546
+ if (occupant) {
547
+ if (moving.has(occupant.uuid)) again.push(claim);
548
+ else contestedNow.set(relPath, { uuid, kind });
549
+ continue;
550
+ }
551
+ if (groundedElsewhere(uuid)) {
552
+ contestedNow.set(relPath, { uuid, kind });
553
+ continue;
554
+ }
555
+ const parentUuid = ensureDir(parentOf(relPath), root);
556
+ registry.move(uuid, { parentUUID: parentUuid, name: path.basename(relPath) });
557
+ attestPayload(kind, uuid, entry);
558
+ }
559
+ if (again.length < waiting.length) {
560
+ waiting = again;
561
+ continue;
562
+ }
563
+ // Every waiter's slot is held by another waiter: an address
564
+ // permutation among their own records. Target parents resolve in
565
+ // POST state — a parent address about to be owned by a fellow
566
+ // mover already names its record.
567
+ const landing = new Map(waiting.map((claim) => [claim.relPath, claim.uuid]));
568
+ registry.moveMany(waiting.map(({ relPath, uuid }) => {
569
+ const parentRel = parentOf(relPath);
570
+ const parentUUID = parentRel === ''
571
+ ? root.uuid
572
+ : landing.get(parentRel) ?? activeAtPath(parentRel, root).uuid;
573
+ return { uuid, parentUUID, name: path.basename(relPath) };
574
+ }));
575
+ for (const { uuid, entry } of waiting) {
576
+ attestPayload(kind, uuid, entry);
577
+ }
578
+ waiting = [];
579
+ }
580
+ };
581
+ settleClaims(claimsOf(desiredDirs, 'dir'), 'dir');
582
+
583
+ // Territories after folder continuity, before file ground: a
584
+ // boundary is a repo.git type transition on the slot's record, and
585
+ // the slot must already be where its folder channel says it is.
586
+ for (const territory of [...territories].sort()) {
587
+ const parentUuid = ensureDir(path.dirname(territory) === '.' ? '' : path.dirname(territory), root);
588
+ const name = path.basename(territory);
589
+ const at = registry.activeAt(parentUuid, name);
590
+ if (!at) {
591
+ // Adoption before birth here too: a registered top-level repo
592
+ // TREE that moved in arrives as its own record, never a fresh
593
+ // territory record. The enclosed observer root's identity is
594
+ // the evidence — the same physical dir the orphaned tree owns.
595
+ const enclosed = observer.listRoots().find((row) => row.path === path.join(groundPath, territory));
596
+ const arrivedRecord = enclosed ? arrivedTree(enclosed.device, enclosed.inode, REPO_TREE) : null;
597
+ if (arrivedRecord) registry.move(arrivedRecord.uuid, { parentUUID: parentUuid, name });
598
+ else registry.create({ type: 'repo.git', parentUUID: parentUuid, name });
599
+ } else if (at.type !== 'repo.git') registry.changeType(at.uuid, 'repo.git');
600
+ }
601
+ // A lifted lens is the reverse transition on the same record.
602
+ for (const { record, rel } of walkActive(root.uuid, '', [])) {
603
+ if (record.type === 'repo.git' && !territories.has(rel)) {
604
+ registry.changeType(record.uuid, 'folder');
605
+ }
606
+ }
607
+
608
+ settleClaims(claimsOf(desiredFiles, 'file'), 'file');
609
+ settleClaims(claimsOf(desiredLinks, 'link'), 'link');
610
+
611
+ // Marker sync — the durable half of the contest. Forgetting cannot
612
+ // authorize assignment: a marked address stays contested while ground
613
+ // of the mark's own kind remains there, even when the evidence that
614
+ // raised the contest is gone (an observer store lost mid-contest).
615
+ // The ground leaving the address is the one detection-visible
616
+ // resolution.
617
+ for (const [relPath, mark] of [...contested]) {
618
+ const present = mark.kind === 'file' ? desiredFiles.has(relPath)
619
+ : mark.kind === 'link' ? desiredLinks.has(relPath)
620
+ : desiredDirs.has(relPath);
621
+ if (!present) {
622
+ memory.clearContest(rootId, relPath);
623
+ contested.delete(relPath);
624
+ }
625
+ }
626
+ for (const [relPath, mark] of contestedNow) {
627
+ const held = contested.get(relPath);
628
+ if (held?.uuid !== mark.uuid || held?.kind !== mark.kind) {
629
+ memory.contest(rootId, relPath, mark.uuid, mark.kind);
630
+ contested.set(relPath, mark);
631
+ }
632
+ }
633
+
634
+ // Arrival pass: an unbound channel reconnects to its slot's absent
635
+ // active owner — whatever kind it returns as; ownership is identity,
636
+ // not kind — or is a birth. Ground on or under a contested address
637
+ // gets no assignment and no birth; file ground on a folder that
638
+ // still has active children is the container refusal. Folders
639
+ // arrive first, parents before children, so files land under real
640
+ // records, never fresh scaffolding.
641
+ for (const [relPath, { channelId, device, inode }] of desiredDirs) {
642
+ if (channelToUuid.has(channelId)) continue;
643
+ if (underContest(relPath)) {
644
+ if (!contested.has(relPath)) nowDivergent.set(relPath, null);
645
+ continue; // reported, assigned to nobody, resolved by nothing here
646
+ }
647
+ // Adoption before birth: this directory may BE an orphaned
648
+ // registered tree, arrived here whole.
649
+ const arrivedRecord = arrivedTree(device, inode, FILES_TREE);
650
+ if (arrivedRecord) {
651
+ const parentUuid = ensureDir(path.dirname(relPath) === '.' ? '' : path.dirname(relPath), root);
652
+ const name = path.basename(relPath);
653
+ if (registry.activeAt(parentUuid, name)) {
654
+ nowDivergent.set(relPath, null); // two identity claims on one address: assign nothing
655
+ continue;
656
+ }
657
+ registry.move(arrivedRecord.uuid, { parentUUID: parentUuid, name });
658
+ bind(channelId, arrivedRecord.uuid);
659
+ continue;
660
+ }
661
+ const parentUuid = ensureDir(path.dirname(relPath) === '.' ? '' : path.dirname(relPath), root);
662
+ const name = path.basename(relPath);
663
+ const at = registry.activeAt(parentUuid, name);
664
+ if (at && at.type === 'folder') {
665
+ bind(channelId, at.uuid);
666
+ } else if (at && LEAF_TYPES.has(at.type)) {
667
+ bind(channelId, registry.changeType(at.uuid, 'folder').uuid);
668
+ } else if (at && at.type === 'reference') {
669
+ // Real ground on a rendering's slot: the projection never
670
+ // overwrites it, and detection assigns it to nobody — divergence.
671
+ nowDivergent.set(relPath, null);
672
+ } else if (at) {
673
+ continue; // a repo.git slot never transitions on ground: territory news owns it
674
+ } else {
675
+ bind(channelId, registry.create({ type: 'folder', parentUUID: parentUuid, name }).uuid);
676
+ }
677
+ }
678
+ for (const [relPath, entry] of desiredFiles) {
679
+ const { channelId, hash } = entry;
680
+ if (channelToUuid.has(channelId)) continue;
681
+ if (underContest(relPath)) {
682
+ if (!contested.has(relPath)) nowDivergent.set(relPath, hash);
683
+ continue; // reported, assigned to nobody, resolved by nothing here
684
+ }
685
+ const parentUuid = ensureDir(path.dirname(relPath) === '.' ? '' : path.dirname(relPath), root);
686
+ const name = path.basename(relPath);
687
+ const at = registry.activeAt(parentUuid, name);
688
+ if (at && at.type === 'folder') {
689
+ if (registry.children(at.uuid).some((child) => child.status === 'active')) {
690
+ nowDivergent.set(relPath, hash);
691
+ continue;
692
+ }
693
+ const asFile = registry.changeType(at.uuid, classify(entry), { payloadVersion: hash });
694
+ bind(channelId, asFile.uuid);
695
+ } else if (at && LEAF_TYPES.has(at.type)) {
696
+ // Slot reconnection is identity, not kind: the absent owner
697
+ // returns as file ground, and the record transitions with it —
698
+ // one attestation, whichever leaf kind it was wearing.
699
+ bind(channelId, at.uuid);
700
+ attestPayload('file', at.uuid, entry);
701
+ } else if (at && at.type === 'reference') {
702
+ // Real ground on a rendering's slot: the projection never
703
+ // overwrites it, and detection assigns it to nobody — divergence.
704
+ nowDivergent.set(relPath, hash);
705
+ } else if (at) {
706
+ continue; // a repo.git slot never transitions on ground: territory news owns it
707
+ } else {
708
+ const born = registry.create({
709
+ type: classify(entry),
710
+ parentUUID: parentUuid, name, payloadVersion: hash,
711
+ });
712
+ bind(channelId, born.uuid);
713
+ }
714
+ }
715
+ // Link arrivals: same law, third kind — the payload is the pointer
716
+ // exactly as disk wrote it, and no classification is asked (linkness
717
+ // is decided by disk, not by policy).
718
+ for (const [relPath, { channelId, hash: target }] of desiredLinks) {
719
+ if (channelToUuid.has(channelId)) continue;
720
+ if (underContest(relPath)) {
721
+ if (!contested.has(relPath)) nowDivergent.set(relPath, target);
722
+ continue; // reported, assigned to nobody, resolved by nothing here
723
+ }
724
+ const parentUuid = ensureDir(path.dirname(relPath) === '.' ? '' : path.dirname(relPath), root);
725
+ const name = path.basename(relPath);
726
+ const at = registry.activeAt(parentUuid, name);
727
+ if (at && at.type === 'reference') {
728
+ // The rendered symlink IS the reference's local ground: its
729
+ // channel binds, but its target text never attests — the
730
+ // reference's payload is the target's IDENTITY, written by the
731
+ // deliberate act that created it, and the rendered address is
732
+ // derived state the boundary maintains.
733
+ bind(channelId, at.uuid);
734
+ } else if (at && LEAF_TYPES.has(at.type)) {
735
+ // The same reconnection law, third kind: the owner returns as a
736
+ // pointer, and one attestation carries the record across.
737
+ bind(channelId, at.uuid);
738
+ attestPayload('link', at.uuid, { hash: target });
739
+ } else if (at && at.type === 'folder') {
740
+ if (registry.children(at.uuid).some((child) => child.status === 'active')) {
741
+ nowDivergent.set(relPath, target); // the container refusal, kind-blind
742
+ continue;
743
+ }
744
+ bind(channelId, registry.changeType(at.uuid, 'link', { payloadVersion: target }).uuid);
745
+ } else if (at) {
746
+ continue; // a repo.git slot never carries a link channel; territory news owns it
747
+ } else {
748
+ bind(channelId, registry.create({ type: 'link', parentUUID: parentUuid, name, payloadVersion: target }).uuid);
749
+ }
750
+ }
751
+
752
+ // Departure pass: a channel gone from truth closed; the entity LIVES,
753
+ // its record unmoved — releasing the binding is all that happens.
754
+ const liveIds = new Set([
755
+ rootRow.rootId, // the witness: live for as long as the root is enrolled
756
+ ...[...desiredDirs.values()].map((entry) => entry.channelId),
757
+ ...[...desiredFiles.values()].map((entry) => entry.channelId),
758
+ ...[...desiredLinks.values()].map((entry) => entry.channelId),
759
+ ]);
760
+ for (const channelId of [...channelToUuid.keys()]) {
761
+ if (!liveIds.has(channelId)) release(channelId);
762
+ }
763
+
764
+ // One divergence report, two sources: contested ground (marked, with
765
+ // its current bytes — null when the ground is a directory) and the
766
+ // container refusal.
767
+ divergent = new Map();
768
+ for (const [relPath, mark] of contested) {
769
+ divergent.set(relPath, mark.kind === 'file' ? desiredFiles.get(relPath).hash
770
+ : mark.kind === 'link' ? desiredLinks.get(relPath).hash
771
+ : null);
772
+ }
773
+ for (const [relPath, hash] of nowDivergent) divergent.set(relPath, hash);
774
+ } catch (error) {
775
+ dirty = true;
776
+ throw error;
777
+ }
778
+ }
779
+
780
+ function tryReconcile() {
781
+ try {
782
+ reconcile();
783
+ } catch (error) {
784
+ console.warn('[Evidence] reconcile failed (intake stays dirty):', error?.message || error);
785
+ }
786
+ }
787
+
788
+ return {
789
+ /** INFALLIBLE by construction: mark dirty, schedule, return. */
790
+ onEvent() {
791
+ if (stopped) return;
792
+ dirty = true;
793
+ if (scheduled) return;
794
+ scheduled = true;
795
+ const immediate = setImmediate(() => {
796
+ scheduled = false;
797
+ if (stopped) return; // the world closed under an already-queued pass
798
+ if (dirty) tryReconcile();
799
+ });
800
+ immediate.unref?.();
801
+ },
802
+
803
+ /**
804
+ * Shut down: honor no more hints, and let no queued pass run. A lens
805
+ * owns no handles — its stores belong to whoever opened them — but it
806
+ * DOES own a scheduled reconciliation, and one that lands after those
807
+ * stores close would warn about a shutdown that went exactly right.
808
+ * Dirty state stays dirty on purpose: the next lens over this tree
809
+ * reconciles at birth, which is the same act.
810
+ */
811
+ stop() {
812
+ stopped = true;
813
+ },
814
+
815
+ reconcile,
816
+ flushIfDirty() {
817
+ if (dirty) tryReconcile();
818
+ return !dirty;
819
+ },
820
+ dirty: () => dirty,
821
+ divergence: () => new Map(divergent),
822
+ boundUuid: (channelId) => channelToUuid.get(channelId),
823
+ };
824
+ }
825
+
826
+ module.exports = { assertWitnessed, createEvidence, createEvidenceMemory };