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,1031 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The operation model — one of the oracle's two independent truths
5
+ * (docs/entity-model.md; the other is the disk referee in referee.js).
6
+ *
7
+ * The test performs a real filesystem operation and records it here. The
8
+ * model keeps its own simulated disk (paths → content hash + a physical
9
+ * continuity tag standing in for device:inode, for directories too) and, at
10
+ * each settle point, applies the written identity axioms to the NET state —
11
+ * never per-op, because identity is only ever resolved at proven quiet
12
+ * points:
13
+ *
14
+ * 1. Address and physical continuity are the two identity witnesses, and
15
+ * KIND decides which testifies first. For files, path claims the
16
+ * channel — an atomic save replaces the physical file under a
17
+ * standing address, so the address must absorb that churn — and
18
+ * exactly-one physical continuity rescues path-orphans. For
19
+ * directories the same rules run in the OPPOSITE order: nothing
20
+ * replaces a directory's inode in place, so physical continuity
21
+ * claims first (two directories exchanging names cross by inode,
22
+ * exactly like the files inside them) and the address claims only
23
+ * recreation — a never-seen inode at a known address is the same
24
+ * channel wearing a new tag.
25
+ * 2. Continuity moves a record — exactly one candidate on each side, or
26
+ * nothing is guessed — and only onto UNOWNED ground.
27
+ * Continuity arriving on a parked ACTIVE owner's slot is CONTESTED
28
+ * GROUND: two established identity claims on one address, and the
29
+ * model picks no winner — neither record moves, the ground is
30
+ * reported divergence, carried by ADDRESS continuity within the
31
+ * ground's kind (an atomic replace keeps it, exactly like path
32
+ * claims; a file replaced by a directory has left) until the ground
33
+ * leaves the address or an explicit identity-carrying action
34
+ * resolves it. Reappearance at the claimant's old address resolves
35
+ * nothing — presence proves nothing about identity — and losing
36
+ * evidence never resolves a contest. Folder ground carries a channel
37
+ * now, so the folder half of this rule is LIVE — and an address that
38
+ * runs through contested ground is itself contested for whatever
39
+ * claimant sits there: a moved subtree's children cannot be lawfully
40
+ * addressed while their parent's ground is undecided.
41
+ * 3. Leftover appearances are births. A leftover disappearance is
42
+ * EVIDENCE, never lifecycle: the channel closes, the entity leaves
43
+ * local ground, and its record does not move. Status (active |
44
+ * trashed | deleted) is registry law, changed only by explicit owner
45
+ * actions — trash, restore, empty-trash — which detection may never
46
+ * infer from disk. No explicit action exists in these tests, so every
47
+ * record here stays `active` forever.
48
+ * 4. An ACTIVE entity owns its (parent, name) slot, present or absent —
49
+ * one parent has at most one active child with a given name. Ground
50
+ * reappearing at the slot reconnects to the same uuid — WHATEVER kind
51
+ * it returns as: ownership is identity, not kind, so folder ground at
52
+ * a file-owned slot (or the reverse) is a type transition on the same
53
+ * uuid, even when the swap happens inside a single interval. New bytes
54
+ * are absorbed as the owner's edit (production accepts them or resets
55
+ * from canonical). Only an explicit trash/delete vacates a slot for a
56
+ * new entity — and no explicit action exists in these tests, so
57
+ * reappearance here always reconnects. Restoration from trash stays
58
+ * explicit, never inferred.
59
+ * 5. Git is a LENS, not a lifecycle boundary: a .git appearing flips the
60
+ * folder's type to repo.git on the SAME uuid — including at the
61
+ * enrolled root, where the record flips workspace ↔ repo.git — and
62
+ * every entity under it keeps existing with its identity. Exactly one
63
+ * adapter owns detection at a time: under the lens the repo adapter
64
+ * owns the subtree; lift the lens and file detection returns. Covered
65
+ * ground is therefore UNWITNESSED by leaf detection — no leaf entity
66
+ * is born under the lens, and physical continuity never crosses the
67
+ * boundary. Entities whose ground the lens covers park with their
68
+ * records untouched; the slot law reconnects them when the lens
69
+ * lifts, and ground first written under the lens surfaces as a NEW
70
+ * entity at the lift. (Today's detector re-mints its low-level
71
+ * channels at these boundaries; channels are evidence, and the judge
72
+ * absorbs that at the binder, never in the model.)
73
+ * 6. Only CONTAINERS — workspace, folder, repo.git — hold active
74
+ * children. Identity survives every VALID type transition; a folder
75
+ * that still has stored-active children may not become a file, because
76
+ * accepting that flip would canonicalize the subtree destruction that
77
+ * axiom 3 already refused to infer. File ground landing on such a slot
78
+ * is DIVERGENCE: reported honestly (the channel and its events are
79
+ * real), claimed by no entity, canonical records unmoved, pending
80
+ * explicit resolution — amalgm resets local from canonical, or the
81
+ * owner moves/trashes the children first.
82
+ * 7. A LINK is leaf ground whose whole payload is its target exactly as
83
+ * written — never what it reaches. Its identity follows the FILE
84
+ * rules for the file reason: a symlink cannot be retargeted in place
85
+ * (retarget is unlink + recreate), so the address absorbs that churn
86
+ * and claims first, and exactly-one physical continuity rescues
87
+ * path-orphans (`mv` preserves a symlink's inode). Links are a THIRD
88
+ * ground kind: continuity never crosses kinds, a contest carries by
89
+ * address continuity within link kind, and slot ownership stays
90
+ * identity-not-kind — ground of any kind returning at an owned slot
91
+ * is a type transition on the same uuid, container guard included.
92
+ *
93
+ * Every settled entity is EXACTLY the six-field universal record —
94
+ * { uuid, type, parentUUID, name, version, status } — with folders resolved
95
+ * first so files derive real parent UUIDs. Payloads (content hash,
96
+ * membership, the repo card) are adapter EVIDENCE, returned in a separate
97
+ * map keyed by uuid, never stored inside the record. Versions follow the
98
+ * ratified formula hash(type, parentUUID, name, status, payloadVersion); a
99
+ * container's payload is its sorted STORED-active direct membership as
100
+ * (childUUID, name) pairs. A locally-absent entity is still an active
101
+ * child — its restore is the registry's business — which is why a
102
+ * disappearance changes NO record at all. repo.git payload is null until
103
+ * the card/checkpoint evidence is adapted (a documented gate, not a
104
+ * formula).
105
+ *
106
+ * File type (file.text | file.binary) comes from the INJECTED classify
107
+ * policy, exactly like enrollment comes from the injected shouldEnroll:
108
+ * explicit policy decides, the model never owns a heuristic. A classify
109
+ * flip is a type transition on the same uuid.
110
+ *
111
+ * Truth is tracked per address between settles and absorbed into the owning
112
+ * entity at each settle; the just-closed interval stays queryable per
113
+ * address, so even a channel born and dead between settles is judged at the
114
+ * address it claimed — no global pool.
115
+ *
116
+ * KNOWN GATE: enrollment inside repo ground uses the root-relative policy
117
+ * while the boundary census re-scopes per boundary — settles with the
118
+ * registry.
119
+ *
120
+ * Deliberately shares no code with the detector: this file must not require
121
+ * anything from observer/ or repocard/ (enforced by a meta-test).
122
+ */
123
+
124
+ const crypto = require('crypto');
125
+
126
+ function sha256(bytes) {
127
+ return crypto.createHash('sha256').update(bytes).digest('hex');
128
+ }
129
+
130
+ function createModel({ shouldEnroll, classify }) {
131
+ if (typeof shouldEnroll !== 'function' || typeof classify !== 'function') {
132
+ throw new Error('createModel requires the injected enrollment and classification policies');
133
+ }
134
+
135
+ let nextNode = 1;
136
+ let nextTag = 1;
137
+
138
+ // Simulated disk.
139
+ const diskFiles = new Map(); // relPath -> { hash, tag, type }
140
+ const diskLinks = new Map(); // relPath -> { target, tag }
141
+ const diskDirs = new Map(); // relDir -> { tag } ('' is the workspace, never listed)
142
+ const gitDirs = new Set(); // dirs holding a .git ('' = the root itself)
143
+
144
+ // Settled entities. Nodes that leave the live maps stay forever; frozen
145
+ // holds the record fields as of the settle that parked them.
146
+ const workspace = { uuid: `w${nextNode++}` };
147
+ const fileNodes = new Map(); // uuid -> { uuid, relPath, hash, tag, type, status, history:Set, frozen? }
148
+ const fileByPath = new Map(); // relPath -> uuid, on-disk only
149
+ const linkNodes = new Map(); // uuid -> { uuid, relPath, target, tag, type: 'link', status, history:Set, frozen? }
150
+ const linkByPath = new Map(); // relPath -> uuid, on-disk only
151
+ const folderNodes = new Map(); // uuid -> { uuid, relDir, tag, type, status, frozen? }
152
+ const folderByPath = new Map(); // relDir -> uuid, on-disk only
153
+
154
+ // Truth: per-address for the open interval, absorbed per-entity at
155
+ // settle; the just-closed interval stays queryable for channels that
156
+ // never lived long enough to settle. Link ground keeps its own truth
157
+ // space — a pointer's payload is an address, never a content hash.
158
+ let pathTruth = new Map(); // relPath -> Set(hash)
159
+ let lastTruth = new Map();
160
+ let linkTruth = new Map(); // relPath -> Set(target)
161
+ let lastLinkTruth = new Map();
162
+
163
+ // Leaf addresses that held visible same-kind ground as of the last
164
+ // settle — entity ground, divergence, and contested ground alike. The
165
+ // address absorbs replacement at the CHANNEL layer: ground arriving
166
+ // where same-kind ground already stood is absorbed by whatever channel
167
+ // stands there, so the arriving file's own channel dies — physical
168
+ // continuity cannot rescue a node ONTO standing ground.
169
+ let leafGroundAtOpen = { file: new Set(), link: new Set() };
170
+
171
+ // Contested ground: two identity claims on one address (axiom 2 meeting
172
+ // axiom 4). Keyed by address, valued with the claimant, the ground's
173
+ // physical tag — bookkeeping for the claimant's still-open channel,
174
+ // refreshed while the contest stands — and the ground's KIND, because a
175
+ // contest carries by address continuity within its kind only.
176
+ const contestedGround = new Map(); // relPath -> { uuid, tag, kind: 'file' | 'dir' }
177
+ let contestedAtOpen = new Map(); // relPath -> claimant uuid, as the interval opened
178
+
179
+ /** Does this address run through contested ground (itself included)? */
180
+ function underContest(relPath) {
181
+ const parts = relPath.split('/');
182
+ for (let i = 1; i <= parts.length; i++) {
183
+ if (contestedGround.has(parts.slice(0, i).join('/'))) return true;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ /** Policy prunes at directories: every ancestor must enroll. */
189
+ function enrolledRel(relPath) {
190
+ const parts = relPath.split('/');
191
+ for (let i = 1; i <= parts.length; i++) {
192
+ if (!shouldEnroll(parts.slice(0, i).join('/'))) return false;
193
+ }
194
+ return true;
195
+ }
196
+
197
+ function noteTruth(relPath, hash) {
198
+ if (!enrolledRel(relPath)) return;
199
+ if (!pathTruth.has(relPath)) pathTruth.set(relPath, new Set());
200
+ pathTruth.get(relPath).add(hash);
201
+ }
202
+
203
+ function noteLinkTruth(relPath, target) {
204
+ if (!enrolledRel(relPath)) return;
205
+ if (!linkTruth.has(relPath)) linkTruth.set(relPath, new Set());
206
+ linkTruth.get(relPath).add(target);
207
+ }
208
+
209
+ const nameOf = (relPath) => relPath.slice(relPath.lastIndexOf('/') + 1);
210
+ const parentDirOf = (relPath) => {
211
+ const cut = relPath.lastIndexOf('/');
212
+ return cut === -1 ? null : relPath.slice(0, cut);
213
+ };
214
+
215
+ /** mkdir -p semantics: every missing strict ancestor gets a dir entry. */
216
+ function ensureAncestors(relPath) {
217
+ const parts = relPath.split('/');
218
+ for (let i = 1; i < parts.length; i++) {
219
+ const dir = parts.slice(0, i).join('/');
220
+ if (!diskDirs.has(dir)) diskDirs.set(dir, { tag: nextTag++ });
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Every repo boundary visible to detection, at any depth. Within each
226
+ * base (the root, or an enclosing boundary), only minimal boundaries are
227
+ * its own; deeper ones belong to the child's census, with the enrollment
228
+ * policy re-applied relative to the child — the root-relative contract
229
+ * each root's census obeys.
230
+ */
231
+ function territoriesUnder(baseDir) {
232
+ const relTo = (dir) => (baseDir === '' ? dir : dir.slice(baseDir.length + 1));
233
+ const under = [...gitDirs].filter(
234
+ (dir) => dir !== baseDir && (baseDir === '' || dir.startsWith(`${baseDir}/`)),
235
+ );
236
+ const minimal = under
237
+ .filter((dir) => !under.some((other) => other !== dir && dir.startsWith(`${other}/`)))
238
+ .filter((dir) => enrolledScoped(relTo(dir)));
239
+ const all = [];
240
+ for (const dir of minimal) all.push(dir, ...territoriesUnder(dir));
241
+ return all;
242
+
243
+ function enrolledScoped(rel) {
244
+ const parts = rel.split('/');
245
+ for (let i = 1; i <= parts.length; i++) {
246
+ if (!shouldEnroll(parts.slice(0, i).join('/'))) return false;
247
+ }
248
+ return true;
249
+ }
250
+ }
251
+
252
+ function visibleTerritories() {
253
+ return gitDirs.has('')
254
+ ? new Set(['', ...territoriesUnder('')])
255
+ : new Set(territoriesUnder(''));
256
+ }
257
+
258
+ return {
259
+ // --- op recorders: call each one AFTER performing the real fs op ------
260
+
261
+ /** In-place write: the physical file (its tag) survives. */
262
+ write(relPath, bytes) {
263
+ ensureAncestors(relPath);
264
+ const hash = sha256(bytes);
265
+ const type = classify(bytes);
266
+ const existing = diskFiles.get(relPath);
267
+ if (existing) {
268
+ existing.hash = hash;
269
+ existing.type = type;
270
+ } else {
271
+ diskFiles.set(relPath, { hash, tag: nextTag++, type });
272
+ }
273
+ noteTruth(relPath, hash);
274
+ },
275
+
276
+ /** Atomic replacement: a new physical file lands on the address. */
277
+ replace(relPath, bytes) {
278
+ ensureAncestors(relPath);
279
+ const hash = sha256(bytes);
280
+ diskFiles.set(relPath, { hash, tag: nextTag++, type: classify(bytes) });
281
+ noteTruth(relPath, hash);
282
+ },
283
+
284
+ /** mtime moved, bytes did not: nothing to record. */
285
+ touch() {},
286
+
287
+ mkdir(relDir) {
288
+ ensureAncestors(`${relDir}/.`);
289
+ if (!diskDirs.has(relDir)) diskDirs.set(relDir, { tag: nextTag++ });
290
+ },
291
+
292
+ rename(from, to) {
293
+ const entry = diskFiles.get(from);
294
+ if (!entry) throw new Error(`model: rename of unknown path ${from}`);
295
+ ensureAncestors(to);
296
+ diskFiles.delete(from);
297
+ diskFiles.set(to, entry);
298
+ noteTruth(to, entry.hash);
299
+ },
300
+
301
+ renameDir(from, to) {
302
+ if (!diskDirs.has(from)) throw new Error(`model: renameDir of unknown dir ${from}`);
303
+ ensureAncestors(`${to}/.`);
304
+ const moveKey = (key) => `${to}${key.slice(from.length)}`;
305
+ for (const [relDir, entry] of [...diskDirs]) {
306
+ if (relDir !== from && !relDir.startsWith(`${from}/`)) continue;
307
+ diskDirs.delete(relDir);
308
+ diskDirs.set(moveKey(relDir), entry);
309
+ }
310
+ for (const [relPath, entry] of [...diskFiles]) {
311
+ if (!relPath.startsWith(`${from}/`)) continue;
312
+ diskFiles.delete(relPath);
313
+ const movedPath = moveKey(relPath);
314
+ diskFiles.set(movedPath, entry);
315
+ noteTruth(movedPath, entry.hash);
316
+ }
317
+ for (const [relPath, entry] of [...diskLinks]) {
318
+ if (!relPath.startsWith(`${from}/`)) continue;
319
+ diskLinks.delete(relPath);
320
+ const movedPath = moveKey(relPath);
321
+ diskLinks.set(movedPath, entry);
322
+ noteLinkTruth(movedPath, entry.target);
323
+ }
324
+ for (const dir of [...gitDirs]) {
325
+ if (dir !== from && !dir.startsWith(`${from}/`)) continue;
326
+ gitDirs.delete(dir);
327
+ gitDirs.add(moveKey(dir));
328
+ }
329
+ },
330
+
331
+ remove(relPath) {
332
+ if (!diskFiles.delete(relPath)) throw new Error(`model: remove of unknown path ${relPath}`);
333
+ },
334
+
335
+ /**
336
+ * Link ground. A written pointer always lands on a fresh inode —
337
+ * creation and retarget alike (retarget IS unlink + recreate) — so
338
+ * every write wears a new tag; only `mv` preserves one.
339
+ */
340
+ writeLink(relPath, target) {
341
+ ensureAncestors(relPath);
342
+ diskLinks.set(relPath, { target, tag: nextTag++ });
343
+ noteLinkTruth(relPath, target);
344
+ },
345
+
346
+ renameLink(from, to) {
347
+ const entry = diskLinks.get(from);
348
+ if (!entry) throw new Error(`model: renameLink of unknown path ${from}`);
349
+ ensureAncestors(to);
350
+ diskLinks.delete(from);
351
+ diskLinks.set(to, entry);
352
+ noteLinkTruth(to, entry.target);
353
+ },
354
+
355
+ removeLink(relPath) {
356
+ if (!diskLinks.delete(relPath)) throw new Error(`model: removeLink of unknown path ${relPath}`);
357
+ },
358
+
359
+ removeDir(relDir) {
360
+ for (const relPath of [...diskFiles.keys()]) {
361
+ if (relPath.startsWith(`${relDir}/`)) diskFiles.delete(relPath);
362
+ }
363
+ for (const relPath of [...diskLinks.keys()]) {
364
+ if (relPath.startsWith(`${relDir}/`)) diskLinks.delete(relPath);
365
+ }
366
+ for (const dir of [...diskDirs.keys()]) {
367
+ if (dir === relDir || dir.startsWith(`${relDir}/`)) diskDirs.delete(dir);
368
+ }
369
+ for (const dir of [...gitDirs]) {
370
+ if (dir === relDir || dir.startsWith(`${relDir}/`)) gitDirs.delete(dir);
371
+ }
372
+ },
373
+
374
+ gitInit(relDir) {
375
+ if (relDir !== '' && !diskDirs.has(relDir)) diskDirs.set(relDir, { tag: nextTag++ });
376
+ gitDirs.add(relDir);
377
+ },
378
+
379
+ gitRemove(relDir) {
380
+ gitDirs.delete(relDir);
381
+ },
382
+
383
+ // --- settle: apply the axioms to the net state ------------------------
384
+
385
+ /**
386
+ * Resolve identity for everything that happened since the last settle
387
+ * and return the expected snapshot: six-field records for every entity
388
+ * ever settled, the payload evidence map, the local-presence evidence,
389
+ * the exposed (non-repo-ground) file slice the adapter compares, and
390
+ * the territory census. Git never partitions lifecycle here — every
391
+ * enrolled path resolves under one set of rules, and the lens only
392
+ * decides types and exposure. Mirrors nothing: this is the axioms
393
+ * applied a second time, independently.
394
+ */
395
+ settle() {
396
+ // The contest attributions as the interval OPENED, kept beside the
397
+ // closing state below: events are judged after the settle, but a
398
+ // contest moves with its ground mid-interval, so an honest event at
399
+ // the old address must answer with the boundary it fired under.
400
+ contestedAtOpen = new Map([...contestedGround].map(([relPath, mark]) => [relPath, mark.uuid]));
401
+
402
+ // Directory addressing as the interval OPENED. A departing record
403
+ // moves not one field, so its frozen parent is the entity that
404
+ // held its parent address at the LAST settle — never the closing
405
+ // state, where that dir may have moved away (or another moved in).
406
+ const dirsAtOpen = new Map(folderByPath);
407
+
408
+ const territories = visibleTerritories();
409
+
410
+ // Covered ground is unwitnessed: leaf detection cannot see under the
411
+ // lens, so covered addresses take no part in any identity phase —
412
+ // their owners park at the drop, reconnect by slot at the lift, and
413
+ // ground first written under the lens is nobody until it surfaces.
414
+ // (A territory's own dir stays visible: it IS the repo's ground.)
415
+ const covered = (relPath) => {
416
+ for (const dir of territories) {
417
+ if (dir === '' || relPath.startsWith(`${dir}/`)) return true;
418
+ }
419
+ return false;
420
+ };
421
+ const visibleFiles = new Map();
422
+ for (const [relPath, entry] of diskFiles) {
423
+ if (enrolledRel(relPath) && !covered(relPath)) visibleFiles.set(relPath, entry);
424
+ }
425
+ const visibleLinks = new Map();
426
+ for (const [relPath, entry] of diskLinks) {
427
+ if (enrolledRel(relPath) && !covered(relPath)) visibleLinks.set(relPath, entry);
428
+ }
429
+ const visibleDirs = new Map();
430
+ for (const [relDir, entry] of diskDirs) {
431
+ if (enrolledRel(relDir) && !covered(relDir)) visibleDirs.set(relDir, entry);
432
+ }
433
+
434
+ const folderType = (relDir) => (territories.has(relDir) ? 'repo.git' : 'folder');
435
+
436
+ // Contested ground carries by ADDRESS continuity within its KIND,
437
+ // before anything else is judged: ground of the mark's kind still at
438
+ // a marked address stays contested (an atomic replace only refreshes
439
+ // the tag its open channel now wears; ground swapping kind has
440
+ // left). Ground gone from a marked address ends that contest — and
441
+ // the claimant's channel never closed, so its continuity may resume:
442
+ // a still-parked claimant rejoins this settle's rescue pool of its
443
+ // kind, while a re-grounded claimant's SURPLUS channel drags the
444
+ // contest along to wherever its physical tag landed (exactly one,
445
+ // or nothing).
446
+ const rescueEligible = [];
447
+ const linkRescueEligible = [];
448
+ const folderRescueEligible = [];
449
+ const KIND = {
450
+ file: { ground: visibleFiles, nodes: fileNodes, byPath: fileByPath, rescue: rescueEligible },
451
+ link: { ground: visibleLinks, nodes: linkNodes, byPath: linkByPath, rescue: linkRescueEligible },
452
+ dir: { ground: visibleDirs, nodes: folderNodes, byPath: folderByPath, rescue: folderRescueEligible },
453
+ };
454
+ for (const [relPath, mark] of [...contestedGround]) {
455
+ const kind = KIND[mark.kind];
456
+ const entry = kind.ground.get(relPath);
457
+ const node = kind.nodes.get(mark.uuid);
458
+ if (entry) {
459
+ mark.tag = entry.tag;
460
+ if (node?.frozen) node.tag = entry.tag;
461
+ continue;
462
+ }
463
+ contestedGround.delete(relPath);
464
+ if (!node) continue;
465
+ if (node.frozen) {
466
+ kind.rescue.push(node);
467
+ continue;
468
+ }
469
+ const landed = [...kind.ground]
470
+ .filter(([p, e]) => e.tag === mark.tag && !kind.byPath.has(p)
471
+ && !(mark.kind !== 'dir' && leafGroundAtOpen[mark.kind].has(p)));
472
+ if (landed.length === 1) {
473
+ contestedGround.set(landed[0][0], { uuid: mark.uuid, tag: mark.tag, kind: mark.kind });
474
+ }
475
+ }
476
+
477
+ const absorb = (node, relPath) => {
478
+ const truths = pathTruth.get(relPath);
479
+ if (truths) for (const hash of truths) node.history.add(hash);
480
+ };
481
+ const absorbLink = (node, relPath) => {
482
+ const truths = linkTruth.get(relPath);
483
+ if (truths) for (const target of truths) node.history.add(target);
484
+ };
485
+
486
+ // The occupant law, consulted by every rescue guard (phase 1) and
487
+ // the slot phase (phase 3), kind-blind: who owns this (parent, name)
488
+ // SLOT? An active record owns its slot wherever its ground stands —
489
+ // still on it, gone this very interval (the record has not moved),
490
+ // or parked settles ago. A node's slot is its RECORD's slot: the
491
+ // frozen fields once parked; the settled folder graph once this
492
+ // settle resolved it; the OPENING state while it is still pending —
493
+ // because records move only when continuity moves them, never by an
494
+ // address string drifting underneath. The asked address is spelled
495
+ // through the settled folder graph, so a parent renamed this very
496
+ // interval still spells the same slot on both sides.
497
+ const settledNodes = new Set();
498
+ const slotOfNode = (node) => {
499
+ if (node.frozen) return [node.frozen.parentUUID, node.frozen.name];
500
+ const rel = node.relDir ?? node.relPath;
501
+ const parent = parentDirOf(rel);
502
+ if (parent === null) return [workspace.uuid, nameOf(rel)];
503
+ return [(settledNodes.has(node) ? folderByPath : dirsAtOpen).get(parent), nameOf(rel)];
504
+ };
505
+ const occupantAt = (relPath) => {
506
+ const parent = parentDirOf(relPath);
507
+ const destParent = parent === null ? workspace.uuid : folderByPath.get(parent);
508
+ if (destParent === undefined) return undefined;
509
+ const destName = nameOf(relPath);
510
+ for (const node of [...folderNodes.values(), ...fileNodes.values(), ...linkNodes.values()]) {
511
+ const [slotParent, slotName] = slotOfNode(node);
512
+ if (slotParent === destParent && slotName === destName) return node;
513
+ }
514
+ return undefined;
515
+ };
516
+
517
+ // --- phase 1: match what continued. For FILES, path claims first —
518
+ // an atomic save replaces the physical file under a standing address,
519
+ // so the address absorbs that churn — then exactly-one tag rescues
520
+ // path-orphans, never across kinds (ground kind is a disk fact).
521
+ // DIRECTORIES apply the same two rules in the OPPOSITE order, for the
522
+ // mirror reason: nothing replaces a directory's inode in place, so
523
+ // physical continuity claims first — a known tag at a new address IS
524
+ // the directory, moved, even when its old address was reoccupied in
525
+ // the same interval (two directories exchanging names cross by tag,
526
+ // exactly like the files inside them) — and the address claims only
527
+ // what no tag claims: recreation under an old name, the same channel
528
+ // wearing a new tag. What neither rule matched falls through:
529
+ // unmatched ground to the slot phase, unmatched nodes to departure.
530
+ // No move lands on another's slot: a parked ACTIVE owner there —
531
+ // parked in an earlier settle or departing in this very one — is a
532
+ // CONTEST, the ground divergence, the claimant parked, nobody
533
+ // winning by address. Directory movers may hold each other's
534
+ // addresses, so they resolve in rounds: whoever's ground is free
535
+ // moves, and when nobody's is, the waiters cross — vacate all, land
536
+ // all.
537
+ const folderAppearances = []; // parents first, so children derive live ground
538
+ const departingFolders = [];
539
+ {
540
+ const liveNodes = [...folderByPath.values()].map((uuid) => folderNodes.get(uuid));
541
+ const nodesByTag = new Map();
542
+ // A territory's dir carries NO dir channel — its physical tag is
543
+ // unwitnessed, so it cannot rescue onto a new address (it still
544
+ // continues IN PLACE: the address-claim step below owns that).
545
+ for (const node of [...liveNodes, ...folderRescueEligible]) {
546
+ if (node.type === 'repo.git') continue;
547
+ nodesByTag.set(node.tag, nodesByTag.has(node.tag) ? 'many' : node);
548
+ }
549
+ const dirsByTag = new Map();
550
+ // Territory ground is just as unwitnessed on the entry side: a dir
551
+ // that became (or stayed) a boundary offers no dir channel, so its
552
+ // tag can testify for nobody — the address-claim step and the slot
553
+ // phase own whatever stands there.
554
+ for (const [relDir, entry] of visibleDirs) {
555
+ if (territories.has(relDir)) continue;
556
+ dirsByTag.set(entry.tag, dirsByTag.has(entry.tag) ? 'many' : { relDir, entry });
557
+ }
558
+ const consumedNodes = new Set();
559
+ const consumedDirs = new Set();
560
+ const candidates = [];
561
+ for (const [tag, found] of dirsByTag) {
562
+ const node = nodesByTag.get(tag);
563
+ if (found === 'many' || !node || node === 'many') continue;
564
+ consumedNodes.add(node);
565
+ consumedDirs.add(found.relDir);
566
+ if (!node.frozen && node.relDir === found.relDir) {
567
+ node.type = folderType(found.relDir); // continued in place
568
+ settledNodes.add(node);
569
+ } else {
570
+ candidates.push({ node, found });
571
+ }
572
+ }
573
+ // The address claims what no tag claims: recreation, silently.
574
+ for (const [relDir, entry] of visibleDirs) {
575
+ if (consumedDirs.has(relDir)) continue;
576
+ const node = folderNodes.get(folderByPath.get(relDir));
577
+ if (node && !consumedNodes.has(node)) {
578
+ node.tag = entry.tag;
579
+ node.type = folderType(relDir);
580
+ consumedNodes.add(node);
581
+ consumedDirs.add(relDir);
582
+ settledNodes.add(node);
583
+ }
584
+ }
585
+ const departing = new Set(liveNodes.filter((node) => !consumedNodes.has(node)));
586
+ departingFolders.push(...departing);
587
+ const land = (batch) => {
588
+ for (const { node } of batch) {
589
+ if (node.frozen) delete node.frozen; // an ex-contest claimant resuming: it left the path maps when it parked
590
+ else folderByPath.delete(node.relDir);
591
+ }
592
+ for (const { node, found } of batch) {
593
+ node.relDir = found.relDir;
594
+ node.type = folderType(found.relDir);
595
+ node.tag = found.entry.tag;
596
+ folderByPath.set(node.relDir, node.uuid);
597
+ settledNodes.add(node);
598
+ }
599
+ };
600
+ let pending = candidates;
601
+ while (pending.length > 0) {
602
+ const moving = new Set(pending.map((candidate) => candidate.node));
603
+ const wins = [];
604
+ const again = [];
605
+ for (const candidate of pending) {
606
+ const { node, found } = candidate;
607
+ const contest = () => {
608
+ // Two established identity claims on one address (or ground
609
+ // arriving under an undecided one): the model picks no
610
+ // winner — the claimant parks, the entry stays divergence.
611
+ contestedGround.set(found.relDir, { uuid: node.uuid, tag: found.entry.tag, kind: 'dir' });
612
+ consumedDirs.delete(found.relDir);
613
+ departing.add(node);
614
+ if (!node.frozen) departingFolders.push(node);
615
+ };
616
+ if (underContest(found.relDir)) {
617
+ contest();
618
+ continue;
619
+ }
620
+ const occupant = occupantAt(found.relDir);
621
+ if (occupant && occupant !== node && moving.has(occupant)) {
622
+ again.push(candidate); // its slot's owner is itself mid-move
623
+ continue;
624
+ }
625
+ // An occupant that IS the moving node is no contest: its own
626
+ // channel came home (a parent's move can respell home
627
+ // mid-flight). Anyone else — parked settles ago or departing
628
+ // in this very interval, whatever its kind — owns the slot.
629
+ if (occupant && occupant !== node) {
630
+ contest();
631
+ continue;
632
+ }
633
+ wins.push(candidate);
634
+ }
635
+ land(wins);
636
+ if (wins.length === 0 && again.length === pending.length) {
637
+ land(again); // the waiters hold each other's addresses: they cross
638
+ pending = [];
639
+ } else {
640
+ pending = again;
641
+ }
642
+ }
643
+ for (const [relDir, entry] of [...visibleDirs]
644
+ .sort((a, b) => a[0].split('/').length - b[0].split('/').length)) {
645
+ if (!consumedDirs.has(relDir)) folderAppearances.push([relDir, entry]);
646
+ }
647
+ }
648
+ // Leaf ground — files and links — shares one phase-1 shape: the path
649
+ // claims the channel first (the address absorbs replace/retarget
650
+ // churn), then exactly-one physical continuity rescues path-orphans,
651
+ // never across kinds. `land` is the kind's whole difference: which
652
+ // payload the entry carries into the node.
653
+ const settleLeaves = (kindName, land) => {
654
+ const { ground: visible, byPath, rescue } = KIND[kindName];
655
+ const appearances = [];
656
+ const departing = [];
657
+ const claimed = new Set();
658
+ const orphanNodes = [];
659
+ for (const [relPath, uuid] of [...byPath]) {
660
+ const node = KIND[kindName].nodes.get(uuid);
661
+ const entry = visible.get(relPath);
662
+ if (entry) {
663
+ land(node, relPath, entry);
664
+ claimed.add(relPath);
665
+ settledNodes.add(node);
666
+ } else {
667
+ orphanNodes.push(node);
668
+ }
669
+ }
670
+ const orphanEntries = [...visible].filter(([relPath]) => !claimed.has(relPath));
671
+ // Ex-contest claimants whose ground just left the marked address
672
+ // rejoin the pool: their channel never closed, so exactly-one
673
+ // physical continuity may still resume for them.
674
+ const nodesByTag = new Map();
675
+ for (const node of [...orphanNodes, ...rescue]) {
676
+ nodesByTag.set(node.tag, nodesByTag.has(node.tag) ? 'many' : node);
677
+ }
678
+ const entriesByTag = new Map();
679
+ for (const [relPath, entry] of orphanEntries) {
680
+ entriesByTag.set(entry.tag, entriesByTag.has(entry.tag) ? 'many' : { relPath, entry });
681
+ }
682
+ const movedNodes = new Set();
683
+ const movedPaths = new Set();
684
+ const landCandidate = ({ node, found }) => {
685
+ if (node.frozen) delete node.frozen; // an ex-contest claimant resuming: it left the path maps when it parked
686
+ else byPath.delete(node.relPath);
687
+ land(node, found.relPath, found.entry);
688
+ byPath.set(node.relPath, node.uuid);
689
+ settledNodes.add(node);
690
+ movedNodes.add(node.uuid);
691
+ movedPaths.add(found.relPath);
692
+ };
693
+ let pending = [];
694
+ for (const [tag, found] of entriesByTag) {
695
+ const node = nodesByTag.get(tag);
696
+ if (found === 'many' || !node || node === 'many') continue;
697
+ pending.push({ node, found });
698
+ }
699
+ while (pending.length > 0) {
700
+ const moving = new Set(pending.map((candidate) => candidate.node));
701
+ const wins = [];
702
+ const again = [];
703
+ for (const candidate of pending) {
704
+ const { node, found } = candidate;
705
+ // Standing same-kind ground absorbed the arrival at the
706
+ // channel layer: the mover's channel died there, no continuity
707
+ // claim was ever witnessed, and its record simply parks — no
708
+ // rescue, no contest.
709
+ if (leafGroundAtOpen[kindName].has(found.relPath)) continue;
710
+ // A parked owner that IS the rescuing node is no contest: its
711
+ // own channel came home (a parent's move can respell home
712
+ // mid-flight). Anyone else standing on the slot — parked in an
713
+ // earlier settle or departing in this very one — is a CONTEST:
714
+ // two established identity claims on one address, and the
715
+ // model picks no winner — the claimant stays (or goes) parked
716
+ // with its record unmoved, the owner stays with its record
717
+ // unmoved, and the ground is divergence.
718
+ const occupant = occupantAt(found.relPath);
719
+ if (occupant && occupant !== node && moving.has(occupant)) {
720
+ again.push(candidate); // its slot's owner is itself mid-move
721
+ continue;
722
+ }
723
+ if ((occupant && occupant !== node) || underContest(found.relPath)) {
724
+ contestedGround.set(found.relPath, { uuid: node.uuid, tag: found.entry.tag, kind: kindName });
725
+ continue;
726
+ }
727
+ wins.push(candidate);
728
+ }
729
+ for (const candidate of wins) landCandidate(candidate);
730
+ if (wins.length === 0 && again.length === pending.length) {
731
+ for (const candidate of again) landCandidate(candidate); // the waiters hold each other's addresses: they cross
732
+ pending = [];
733
+ } else {
734
+ pending = again;
735
+ }
736
+ }
737
+ for (const pair of orphanEntries) {
738
+ if (!movedPaths.has(pair[0])) appearances.push(pair);
739
+ }
740
+ departing.push(...orphanNodes.filter((node) => !movedNodes.has(node.uuid)));
741
+ return { appearances, departing };
742
+ };
743
+ const { appearances: fileAppearances, departing: departingFiles } = settleLeaves(
744
+ 'file', (node, relPath, entry) => {
745
+ node.relPath = relPath;
746
+ node.hash = entry.hash;
747
+ node.tag = entry.tag;
748
+ node.type = entry.type;
749
+ node.history.add(entry.hash);
750
+ absorb(node, relPath);
751
+ });
752
+ const { appearances: linkAppearances, departing: departingLinks } = settleLeaves(
753
+ 'link', (node, relPath, entry) => {
754
+ node.relPath = relPath;
755
+ node.target = entry.target;
756
+ node.tag = entry.tag;
757
+ node.history.add(entry.target);
758
+ absorbLink(node, relPath);
759
+ });
760
+
761
+ // --- phase 2: park what departed, BOTH kinds, before any appearance
762
+ // resolves. Absence is evidence — not one record field moves — and
763
+ // the owner must already be parked when its slot's returning ground
764
+ // (possibly of the other kind, inside this very interval) is judged.
765
+ const parentAtOpen = (rel) => {
766
+ const parent = parentDirOf(rel);
767
+ return parent === null ? workspace.uuid : dirsAtOpen.get(parent);
768
+ };
769
+ for (const node of departingFolders) {
770
+ folderByPath.delete(node.relDir);
771
+ }
772
+ for (const node of departingFolders) {
773
+ // A lifted lens is a type transition even on a parked record: the
774
+ // registry's lift pass flips every uncovered repo.git to folder,
775
+ // ground present or not.
776
+ node.type = folderType(node.relDir);
777
+ node.frozen = { parentUUID: parentAtOpen(node.relDir), name: nameOf(node.relDir) };
778
+ }
779
+ for (const node of departingFiles) {
780
+ node.frozen = { parentUUID: parentAtOpen(node.relPath), name: nameOf(node.relPath) };
781
+ fileByPath.delete(node.relPath);
782
+ }
783
+ for (const node of departingLinks) {
784
+ node.frozen = { parentUUID: parentAtOpen(node.relPath), name: nameOf(node.relPath) };
785
+ linkByPath.delete(node.relPath);
786
+ }
787
+
788
+ // --- phase 3: the slot phase (axiom 4). Each remaining appearance
789
+ // belongs to the parked ACTIVE owner of its (parent, name) slot —
790
+ // searched across ALL entities, because ownership is identity, not
791
+ // kind. Only an unowned slot births a new entity. Folders resolve
792
+ // first, parents before children, so children derive live ground.
793
+ // Waking an owner: same node, frozen shed. When the returning ground
794
+ // is the other kind, the node crosses the kind maps — a type
795
+ // transition on the same uuid — shedding only the old kind's ground
796
+ // evidence; uuid, status, and content history persist.
797
+ const wake = (owner, kindNodes) => {
798
+ delete owner.frozen;
799
+ if (!kindNodes.has(owner.uuid)) {
800
+ for (const other of [fileNodes, linkNodes, folderNodes]) {
801
+ if (other !== kindNodes) other.delete(owner.uuid);
802
+ }
803
+ delete owner.relPath;
804
+ delete owner.relDir;
805
+ delete owner.hash;
806
+ delete owner.target;
807
+ kindNodes.set(owner.uuid, owner);
808
+ }
809
+ return owner;
810
+ };
811
+ const divergent = new Map(); // relPath -> hash (null for dir ground), entity-less
812
+ for (const [relDir, entry] of folderAppearances) {
813
+ if (underContest(relDir)) {
814
+ divergent.set(relDir, null);
815
+ continue; // contested ground (or under it): reported, claimed by no entity
816
+ }
817
+ const occupant = occupantAt(relDir);
818
+ const owner = occupant?.frozen ? occupant : undefined;
819
+ const node = owner
820
+ ? wake(owner, folderNodes)
821
+ : { uuid: `d${nextNode++}`, status: 'active' };
822
+ node.relDir = relDir;
823
+ node.tag = entry.tag;
824
+ node.type = folderType(relDir);
825
+ folderNodes.set(node.uuid, node);
826
+ folderByPath.set(relDir, node.uuid);
827
+ settledNodes.add(node);
828
+ }
829
+ // Axiom 6's guard: file ground on a slot whose owner is a folder
830
+ // that still HAS stored-active children is a swap the canonical
831
+ // graph refuses — the ground is divergence, not a transition. (A
832
+ // parked folder's subtree parked with it, so its stored-active
833
+ // children are found among the frozen nodes.)
834
+ const hasActiveChildren = (uuid) => [...folderNodes.values(), ...fileNodes.values(), ...linkNodes.values()]
835
+ .some((node) => node.status === 'active' && node.frozen?.parentUUID === uuid);
836
+ for (const [relPath, entry] of fileAppearances) {
837
+ if (underContest(relPath)) {
838
+ divergent.set(relPath, entry.hash);
839
+ continue; // contested ground (or under it): reported, claimed by no entity, resolved by nothing here
840
+ }
841
+ const occupant = occupantAt(relPath);
842
+ const owner = occupant?.frozen ? occupant : undefined;
843
+ if (owner && folderNodes.has(owner.uuid) && hasActiveChildren(owner.uuid)) {
844
+ divergent.set(relPath, entry.hash);
845
+ continue;
846
+ }
847
+ const node = owner
848
+ ? wake(owner, fileNodes)
849
+ : { uuid: `n${nextNode++}`, status: 'active' };
850
+ node.relPath = relPath;
851
+ node.hash = entry.hash;
852
+ node.tag = entry.tag;
853
+ node.type = entry.type;
854
+ (node.history ??= new Set()).add(entry.hash);
855
+ absorb(node, relPath);
856
+ fileNodes.set(node.uuid, node);
857
+ fileByPath.set(relPath, node.uuid);
858
+ settledNodes.add(node);
859
+ }
860
+ // Link arrivals: the same slot law, third kind — ownership is
861
+ // identity, not kind, and the container guard is kind-blind: link
862
+ // ground on an occupied folder's slot is the same refused swap.
863
+ for (const [relPath, entry] of linkAppearances) {
864
+ if (underContest(relPath)) {
865
+ divergent.set(relPath, entry.target);
866
+ continue; // contested ground (or under it): reported, claimed by no entity
867
+ }
868
+ const occupant = occupantAt(relPath);
869
+ const owner = occupant?.frozen ? occupant : undefined;
870
+ if (owner && folderNodes.has(owner.uuid) && hasActiveChildren(owner.uuid)) {
871
+ divergent.set(relPath, entry.target);
872
+ continue;
873
+ }
874
+ const node = owner
875
+ ? wake(owner, linkNodes)
876
+ : { uuid: `l${nextNode++}`, status: 'active' };
877
+ node.relPath = relPath;
878
+ node.target = entry.target;
879
+ node.tag = entry.tag;
880
+ node.type = 'link';
881
+ (node.history ??= new Set()).add(entry.target);
882
+ absorbLink(node, relPath);
883
+ linkNodes.set(node.uuid, node);
884
+ linkByPath.set(relPath, node.uuid);
885
+ settledNodes.add(node);
886
+ }
887
+
888
+ lastTruth = pathTruth;
889
+ pathTruth = new Map();
890
+ lastLinkTruth = linkTruth;
891
+ linkTruth = new Map();
892
+ leafGroundAtOpen = {
893
+ file: new Set(visibleFiles.keys()),
894
+ link: new Set(visibleLinks.keys()),
895
+ };
896
+
897
+ // Six-field records + the payload evidence map. Live parents derive
898
+ // through the settled folder graph; parked nodes carry the fields
899
+ // frozen when they left the maps.
900
+ const liveParent = (relPath) => {
901
+ const parent = parentDirOf(relPath);
902
+ return parent === null ? workspace.uuid : folderByPath.get(parent);
903
+ };
904
+ const records = [];
905
+ const payloads = new Map(); // uuid -> payload evidence (feeds the version hash)
906
+ const children = new Map(); // parentUUID -> [childUUID, name] pairs, STORED-active only
907
+ const note = (record, payload) => {
908
+ records.push(record);
909
+ payloads.set(record.uuid, payload);
910
+ if (record.status !== 'active' || record.parentUUID === null) return;
911
+ if (!children.has(record.parentUUID)) children.set(record.parentUUID, []);
912
+ children.get(record.parentUUID).push([record.uuid, record.name]);
913
+ };
914
+ for (const node of folderNodes.values()) {
915
+ note({
916
+ uuid: node.uuid,
917
+ type: node.type,
918
+ parentUUID: node.frozen ? node.frozen.parentUUID : liveParent(node.relDir),
919
+ name: node.frozen ? node.frozen.name : nameOf(node.relDir),
920
+ version: null,
921
+ status: node.status,
922
+ }, null);
923
+ }
924
+ for (const node of fileNodes.values()) {
925
+ note({
926
+ uuid: node.uuid,
927
+ type: node.type,
928
+ parentUUID: node.frozen ? node.frozen.parentUUID : liveParent(node.relPath),
929
+ name: node.frozen ? node.frozen.name : nameOf(node.relPath),
930
+ version: null,
931
+ status: node.status,
932
+ }, node.hash);
933
+ }
934
+ for (const node of linkNodes.values()) {
935
+ note({
936
+ uuid: node.uuid,
937
+ type: node.type,
938
+ parentUUID: node.frozen ? node.frozen.parentUUID : liveParent(node.relPath),
939
+ name: node.frozen ? node.frozen.name : nameOf(node.relPath),
940
+ version: null,
941
+ status: node.status,
942
+ }, node.target);
943
+ }
944
+ // The root record: the workspace — or, when a .git sits at the
945
+ // enrolled root, the repo the lens makes of it. Same uuid both ways.
946
+ note({
947
+ uuid: workspace.uuid,
948
+ type: territories.has('') ? 'repo.git' : 'workspace',
949
+ parentUUID: null,
950
+ name: '', version: null, status: 'active',
951
+ }, null);
952
+
953
+ // The ratified version formula, both halves: containers hash their
954
+ // sorted stored-active membership; repo.git evidence is null-gated
955
+ // until the card/checkpoint payload is adapted; every record's
956
+ // version is the canonical envelope over its fields + payload.
957
+ const membership = (uuid) => sha256(JSON.stringify((children.get(uuid) ?? []).sort()));
958
+ for (const record of records) {
959
+ if (record.type === 'folder' || record.type === 'workspace') {
960
+ payloads.set(record.uuid, membership(record.uuid));
961
+ }
962
+ record.version = sha256(JSON.stringify(
963
+ [record.type, record.parentUUID, record.name, record.status, payloads.get(record.uuid)]));
964
+ }
965
+
966
+ // The exposed slice: what the observer is answerable for — exactly
967
+ // the live maps, because covered ground never enters a phase (a
968
+ // territory's own dir is repo ground, never a dir channel).
969
+ const files = new Map();
970
+ for (const [relPath, uuid] of fileByPath) {
971
+ files.set(relPath, { uuid, hash: fileNodes.get(uuid).hash });
972
+ }
973
+ const links = new Map();
974
+ for (const [relPath, uuid] of linkByPath) {
975
+ links.set(relPath, { uuid, target: linkNodes.get(uuid).target });
976
+ }
977
+ const dirs = new Map();
978
+ for (const [relDir, uuid] of folderByPath) {
979
+ if (!territories.has(relDir)) dirs.set(relDir, { uuid });
980
+ }
981
+
982
+ // Local presence is EVIDENCE, judged beside the records — never a
983
+ // record field: which entities local ground currently holds.
984
+ const present = new Set([
985
+ workspace.uuid, ...folderByPath.values(), ...fileByPath.values(), ...linkByPath.values(),
986
+ ]);
987
+
988
+ return {
989
+ records, payloads, files, links, dirs, territories, present, divergent,
990
+ contested: new Map([...contestedGround].map(([relPath, mark]) => [relPath, mark.uuid])),
991
+ };
992
+ },
993
+
994
+ /** Was this version ever true for THIS entity? (per-entity emitted-truth;
995
+ * one history space per entity, so a leaf that crossed kinds still
996
+ * answers for everything it ever was) */
997
+ nodeEverTrue(uuid, value) {
998
+ return (fileNodes.get(uuid) ?? linkNodes.get(uuid))?.history.has(value) === true;
999
+ },
1000
+
1001
+ /**
1002
+ * Was this version ever true at this address during the just-closed
1003
+ * interval? The floor for a channel born and dead between settles: it
1004
+ * never gained an entity history, but it DID claim an address, and the
1005
+ * address remembers.
1006
+ */
1007
+ addressEverTrue(relPath, hash) {
1008
+ return lastTruth.get(relPath)?.has(hash) === true;
1009
+ },
1010
+
1011
+ /** The link-kind floor: was this target ever written at this address
1012
+ * during the just-closed interval? */
1013
+ addressLinkEverTrue(relPath, target) {
1014
+ return lastLinkTruth.get(relPath)?.has(target) === true;
1015
+ },
1016
+
1017
+ /**
1018
+ * Was this address contested FOR this claimant at either boundary of
1019
+ * the just-closed interval? A contest moves with its ground
1020
+ * mid-interval, so an honest event at the old address answers with the
1021
+ * opening state, while an event at the new address answers with the
1022
+ * closing one — the same interval scope `addressEverTrue` uses.
1023
+ */
1024
+ addressContestedFor(relPath, uuid) {
1025
+ return contestedGround.get(relPath)?.uuid === uuid
1026
+ || contestedAtOpen.get(relPath) === uuid;
1027
+ },
1028
+ };
1029
+ }
1030
+
1031
+ module.exports = { createModel, sha256 };