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,666 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The entity registry: the canonical owner of the six-field universal
5
+ * records (docs/entity-model.md). Adapters observe and PROPOSE; only this
6
+ * module validates, versions, and commits — and only its lifecycle verbs
7
+ * ever write `status`. The evidence path has no status parameter at all,
8
+ * so detection cannot write lifecycle by construction.
9
+ *
10
+ * The rules it enforces are exactly the ratified axioms, nothing more:
11
+ * - ONE identity database holds every registered tree: each tree is one
12
+ * active parentless record — a workspace, a repo.git, or, a tree of
13
+ * one, a single registered file or explicitly registered link
14
+ * (`amalgm register <path>` names an address, and a pointer at that
15
+ * address is an entity of its own; docs/realtime-rails.md). A tree
16
+ * boundary is never an identity boundary: an entity moves between
17
+ * trees as a parent update in one transaction, and a whole tree
18
+ * joining another (ADOPTION) is the same lawful move performed on a
19
+ * parentless record — the UUID and every descendant ride along;
20
+ * - one active owner per (parentUUID, name) slot (the schema convicts
21
+ * too); parentless records are exempt — a top-level tree is placed by
22
+ * its machine binding, never by name;
23
+ * - only containers — workspace, folder, repo.git — hold active children;
24
+ * - the parent graph is acyclic;
25
+ * - valid type transitions preserve the UUID; a folder with stored-active
26
+ * children never becomes a file (that flip would canonicalize subtree
27
+ * destruction);
28
+ * - lifecycle is explicit: trash (active → trashed), restore
29
+ * (trashed → active, refused while another active entity owns the slot),
30
+ * and empty-trash (trashed → deleted, owner-only via the injected
31
+ * `authorizeDelete`; unwired means unavailable, never fail-open);
32
+ * - lifecycle inherits DOWN the chain as derived effective status — one
33
+ * flip on a folder, no descendant record rewritten.
34
+ *
35
+ * The canonical version is DERIVED, never stored: a column could drift
36
+ * from its inputs, a pure function cannot. Container payloads (workspace,
37
+ * folder) are their sorted active direct membership; other types attest
38
+ * their payload version through the evidence path.
39
+ *
40
+ * First initialization is distinguished from loss: an empty database on a
41
+ * fresh machine enrolls; a database missing tables where the sentinel says
42
+ * a registry existed fails loudly and never silently mints replacement
43
+ * UUIDs. A DURABLE REGISTRY REQUIRES A SENTINEL — only a real in-memory
44
+ * database may declare `ephemeral: true` instead. Losing both the database
45
+ * and the sentinel stays honestly unsolvable until remote identity storage
46
+ * exists.
47
+ *
48
+ * Registry state is truth; notifications are doorbells. `entity.committed`
49
+ * payloads are an optimization, never authoritative mutations — every
50
+ * consumer rereads and compares registry versions, and startup attachment
51
+ * always compares (a restart loses the pending flag). A failed ring leaves
52
+ * one pending bell, cleared only by a successful `registry.changed`;
53
+ * `flush()` always requests comparison regardless of the flag.
54
+ *
55
+ * NOTHING RINGS UNTIL ITS TRUTH COMMITS: an event born inside an open
56
+ * transaction waits for the outermost commit and a rollback silences it,
57
+ * so a consumer can never hear a record the database never held. The
58
+ * registry owns the identity database's one transition primitive
59
+ * (`transaction`) for exactly this reason — the ringer must own the
60
+ * commit boundary its bells wait on.
61
+ */
62
+
63
+ const crypto = require('crypto');
64
+ const fs = require('fs');
65
+
66
+ const { createStore, inspect, isMigratableGenerationShape } = require('./store');
67
+
68
+ // `link` is a filesystem symlink as ratified: payload = the literal
69
+ // target text, resolution derived, death honest. `reference` is the
70
+ // OTHER link kind, never conflated: Amalgm-created by a deliberate act,
71
+ // its payload is the TARGET ENTITY'S UUID — identity, not an address —
72
+ // and its local rendering as an ordinary symlink is derived state the
73
+ // registration boundary maintains.
74
+ // The alphabet is PHYSICAL kinds only. Constructs — agents, apps,
75
+ // automations, tools, skills — are organized filesystem ground with a
76
+ // known structure: physically folders and files, with their construct
77
+ // meaning layered ABOVE the physical type, never encoded as one.
78
+ // (The former `automation`/`agent` placeholder types contradicted
79
+ // themselves: declared as folder-shaped ground yet barred from holding
80
+ // children. Removed by ratification 2026-08-02; promoting a construct
81
+ // to a physical type would take proof that it truly travels or applies
82
+ // differently — a deliberate future decision, never a default.)
83
+ const TYPES = new Set([
84
+ 'workspace', 'folder', 'repo.git', 'file.text', 'file.binary',
85
+ 'link', 'reference',
86
+ ]);
87
+ const CONTAINERS = new Set(['workspace', 'folder', 'repo.git']);
88
+ const ROOT_TYPES = new Set(['workspace', 'repo.git', 'file.text', 'file.binary', 'link']);
89
+ // Types whose payload IS their membership: derived from the records
90
+ // beneath them, so no observation may attest one.
91
+ const DERIVED_PAYLOAD = new Set(['workspace', 'folder']);
92
+
93
+ // The one entity alphabet, published for the layers that map onto it
94
+ // (adapters/ serves every member) — read from here, never restated,
95
+ // so the alphabet cannot drift from the registry that enforces it.
96
+ const ENTITY_TYPES = Object.freeze([...TYPES].sort());
97
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
98
+
99
+ /**
100
+ * What an attestation may say — asked of both the type a record wears
101
+ * now and the type its new bytes claim, because an attestation may
102
+ * carry a record across leaf kinds (a pointer replaced by bytes) and
103
+ * both ends must be ground an observation can speak for.
104
+ */
105
+ function attestable(type) {
106
+ if (DERIVED_PAYLOAD.has(type)) {
107
+ throw new Error(`${type} payload is derived membership, never attested`);
108
+ }
109
+ if (type === 'reference') {
110
+ throw new Error('a reference\'s payload is the target\'s identity, set by the deliberate act that created it — never an attestation; the rendered symlink is derived state, not evidence');
111
+ }
112
+ if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
113
+ }
114
+
115
+ /** The ratified formula: version = hash(type, parentUUID, name, status, payloadVersion). */
116
+ function canonicalVersion({ type, parentUUID = null, name, status, payloadVersion = null }) {
117
+ return crypto.createHash('sha256')
118
+ .update(JSON.stringify([type, parentUUID, name, status, payloadVersion]))
119
+ .digest('hex');
120
+ }
121
+
122
+ function createRegistry(options) {
123
+ const {
124
+ db,
125
+ emit,
126
+ sentinelPath = null,
127
+ ephemeral = false,
128
+ authorizeDelete = null,
129
+ journal = null,
130
+ } = options;
131
+ if (!db) throw new Error('createRegistry requires { db }');
132
+ if (typeof emit !== 'function') throw new Error('createRegistry requires { emit }');
133
+ if (journal !== null && typeof journal !== 'function') {
134
+ throw new Error('createRegistry journal must be a function when supplied');
135
+ }
136
+ if (ephemeral) {
137
+ if (sentinelPath) throw new Error('ephemeral and sentinelPath are contradictory: pick one');
138
+ if (!db.memory) throw new Error(`ephemeral is for a real in-memory database; this one lives at ${db.name}`);
139
+ } else if (!sentinelPath) {
140
+ throw new Error('a durable registry requires a sentinel: pass sentinelPath, or ephemeral: true for a real in-memory database');
141
+ }
142
+
143
+ // ---- Sentinel: first init vs loss, decided before any schema exists ----
144
+ // The registry tables — records, evidence memory, and repo state — are
145
+ // born together, so
146
+ // any missing one in an established database is loss, never first init:
147
+ // silently recreating a table would be forgetting that resolves identity.
148
+ const inspection = inspect(db);
149
+ const { fresh, missing } = inspection;
150
+ if (!fresh && missing.length > 0 && !isMigratableGenerationShape(db, inspection)) {
151
+ throw new Error(`registry corrupt: ${missing.join(', ')} gone from a database where a registry lived — refusing to touch it`);
152
+ }
153
+ const marker = sentinelPath && fs.existsSync(sentinelPath)
154
+ ? fs.readFileSync(sentinelPath, 'utf8').trim()
155
+ : null;
156
+ if (fresh && marker !== null) {
157
+ throw new Error(`registry lost: sentinel ${sentinelPath} says epoch ${marker} lived here, but this database holds no registry — refusing to mint replacement identities`);
158
+ }
159
+ const store = createStore(db);
160
+ if (fresh) store.initMeta({ epochUuid: crypto.randomUUID(), createdAtMs: Date.now() });
161
+ const meta = store.meta();
162
+ if (!meta) {
163
+ throw new Error('registry corrupt: registry_meta exists but holds no epoch row — refusing to re-initialize over it');
164
+ }
165
+ if (sentinelPath) {
166
+ if (marker === null) {
167
+ fs.writeFileSync(sentinelPath, `${meta.epochUuid}\n`);
168
+ if (!fresh) console.warn(`[Registry] sentinel marker was missing at ${sentinelPath}; rewrote epoch ${meta.epochUuid}`);
169
+ } else if (marker !== meta.epochUuid) {
170
+ throw new Error(`registry mismatch: sentinel epoch ${marker} is not this database's epoch ${meta.epochUuid} — this is not the registry that lived here`);
171
+ }
172
+ }
173
+
174
+ // ---- Internals ----
175
+
176
+ // Registry state is truth; notifications are DOORBELLS. `entity.committed`
177
+ // payloads are an optimization, never authoritative mutations — every
178
+ // consumer rereads and compares registry versions. A failed ring leaves
179
+ // the bell pending: multiple misses collapse into the one comparison the
180
+ // consumer owes anyway, so no event content is ever stored for replay.
181
+ // The flag is in-memory only — a restart loses it — which is why startup
182
+ // attachment must always compare, pending bell or not.
183
+ let bellPending = false;
184
+ const deferred = []; // bells born inside an open transaction: they ring at commit, or a rollback silences them
185
+
186
+ function ring(event) {
187
+ try {
188
+ emit(event);
189
+ return true;
190
+ } catch (error) {
191
+ console.warn('[Registry] ring failed:', error?.message || error);
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function publish(event) {
197
+ // Nothing rings until its truth commits: inside a transaction the
198
+ // bell waits — transaction() flushes it after the outermost commit,
199
+ // and a rollback discards exactly what it rolled back.
200
+ if (db.inTransaction) {
201
+ deferred.push(event);
202
+ return;
203
+ }
204
+ if (bellPending) {
205
+ if (!ring({ type: 'registry.changed' })) return; // still down; truth waits in the registry
206
+ bellPending = false;
207
+ }
208
+ if (!ring(event)) bellPending = true;
209
+ }
210
+
211
+ function transaction(fn) {
212
+ const outermost = !db.inTransaction;
213
+ const mark = deferred.length;
214
+ try {
215
+ const result = db.transaction(fn)();
216
+ if (outermost) {
217
+ while (deferred.length > 0) publish(deferred.shift());
218
+ }
219
+ return result;
220
+ } catch (error) {
221
+ deferred.length = mark;
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ function mustGet(uuid) {
227
+ const row = store.byUuid(uuid);
228
+ if (!row) throw new Error(`no entity ${uuid}`);
229
+ return row;
230
+ }
231
+
232
+ function validName(name) {
233
+ if (typeof name !== 'string' || name === '' || name === '.' || name === '..'
234
+ || name.includes('/') || name.includes('\0')) {
235
+ throw new Error(`invalid entity name ${JSON.stringify(name)}: a name is one non-empty path segment`);
236
+ }
237
+ }
238
+
239
+ /** A UUID may arrive only through a trusted identity binding (for example
240
+ * a cloud user-home bootstrap). The registry still owns the actual insert:
241
+ * accepting a caller-supplied UUID is not accepting a caller-supplied
242
+ * record, parent, status, or version. */
243
+ function mintOrValidateUuid(uuid) {
244
+ if (uuid === undefined || uuid === null) return crypto.randomUUID();
245
+ if (typeof uuid !== 'string' || !UUID.test(uuid)) {
246
+ throw new Error(`entity identity must be a UUID, got ${JSON.stringify(uuid)}`);
247
+ }
248
+ if (store.byUuid(uuid)) throw new Error(`entity identity ${uuid} is already registered`);
249
+ return uuid.toLowerCase();
250
+ }
251
+
252
+ /** Ancestor uuids from the given parent up to the root. */
253
+ function ancestorsOf(parentUuid) {
254
+ const chain = [];
255
+ const seen = new Set();
256
+ let cursor = parentUuid;
257
+ while (cursor !== null) {
258
+ if (seen.has(cursor)) throw new Error(`registry corrupt: parent cycle at ${cursor}`);
259
+ seen.add(cursor);
260
+ chain.push(cursor);
261
+ cursor = mustGet(cursor).parentUuid;
262
+ }
263
+ return chain;
264
+ }
265
+
266
+ function effectiveStatusOf(row) {
267
+ const statuses = [row.status, ...ancestorsOf(row.parentUuid).map((uuid) => mustGet(uuid).status)];
268
+ if (statuses.includes('deleted')) return 'deleted';
269
+ if (statuses.includes('trashed')) return 'trashed';
270
+ return row.status;
271
+ }
272
+
273
+ /** Container payload: the sorted active direct membership (childUUID, name). */
274
+ function membershipHash(uuid) {
275
+ const pairs = store.activeChildrenOf(uuid).map((child) => [child.uuid, child.name]);
276
+ return crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex');
277
+ }
278
+
279
+ function payloadFor(row) {
280
+ return DERIVED_PAYLOAD.has(row.type) ? membershipHash(row.uuid) : row.payloadVersion;
281
+ }
282
+
283
+ function record(row) {
284
+ return {
285
+ uuid: row.uuid,
286
+ type: row.type,
287
+ parentUUID: row.parentUuid,
288
+ name: row.name,
289
+ version: canonicalVersion({
290
+ type: row.type, parentUUID: row.parentUuid, name: row.name,
291
+ status: row.status, payloadVersion: payloadFor(row),
292
+ }),
293
+ status: row.status,
294
+ };
295
+ }
296
+
297
+ /** The cloud form contains identity and the content/head token, never an
298
+ * address, watcher, inode, or SQLite bytes. `payloadVersion` is the
299
+ * unambiguous current head for leaf types; containers derive theirs from
300
+ * membership and retain null here; a repo.git retains its immutable
301
+ * Card/Checkpoint head. */
302
+ function syncRecord(row) {
303
+ return {
304
+ ...record(row),
305
+ payloadVersion: row.payloadVersion,
306
+ };
307
+ }
308
+
309
+ function journalRecord(next, previous) {
310
+ // This call intentionally happens inside the identity transaction and
311
+ // before notification. A failed journal write rolls the entity mutation
312
+ // back; a later process can never observe local truth without its cloud
313
+ // delivery intent.
314
+ if (journal) journal({ record: syncRecord(next), previous: previous ? syncRecord(previous) : null });
315
+ }
316
+
317
+ function normalizeCloudRecord(value) {
318
+ if (!value || typeof value !== 'object'
319
+ || typeof value.uuid !== 'string' || !UUID.test(value.uuid)
320
+ || typeof value.type !== 'string' || !TYPES.has(value.type)
321
+ || (value.parentUUID !== null && (typeof value.parentUUID !== 'string' || !UUID.test(value.parentUUID)))
322
+ || typeof value.name !== 'string'
323
+ || !['active', 'trashed', 'deleted'].includes(value.status)
324
+ || (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')
325
+ || typeof value.version !== 'string' || !/^[0-9a-f]{64}$/i.test(value.version)) {
326
+ throw new Error('cloud entity operation has an invalid record');
327
+ }
328
+ validName(value.name);
329
+ if (value.parentUUID === null && !ROOT_TYPES.has(value.type)) {
330
+ throw new Error(`cloud entity ${value.uuid} is not a valid tree root`);
331
+ }
332
+ if (value.parentUUID !== null && value.type === 'workspace') {
333
+ throw new Error('cloud entity workspace is the root type only');
334
+ }
335
+ return {
336
+ uuid: value.uuid.toLowerCase(),
337
+ type: value.type,
338
+ parentUuid: value.parentUUID === null ? null : value.parentUUID.toLowerCase(),
339
+ name: value.name,
340
+ status: value.status,
341
+ payloadVersion: value.payloadVersion,
342
+ expectedVersion: value.version.toLowerCase(),
343
+ };
344
+ }
345
+
346
+ function sameLogicalRecord(left, right) {
347
+ return left.uuid === right.uuid
348
+ && left.type === right.type
349
+ && left.parentUuid === right.parentUuid
350
+ && left.name === right.name
351
+ && left.status === right.status
352
+ && left.payloadVersion === right.payloadVersion;
353
+ }
354
+
355
+ /** Every write funnels through here: previous captured, change applied, one event. */
356
+ function commit(row, changes) {
357
+ if (!db.inTransaction) return transaction(() => commit(row, changes));
358
+ const previous = record(row);
359
+ store.update(row.uuid, { ...row, ...changes });
360
+ const nextRow = mustGet(row.uuid);
361
+ const next = record(nextRow);
362
+ journalRecord(nextRow, row);
363
+ publish({ type: 'entity.committed', record: next, previous });
364
+ return next;
365
+ }
366
+
367
+ function requireActiveContainerParent(parentUuid) {
368
+ const parent = mustGet(parentUuid);
369
+ if (!CONTAINERS.has(parent.type)) {
370
+ throw new Error(`only containers hold active children: ${parentUuid} is ${parent.type}, not workspace/folder/repo.git`);
371
+ }
372
+ if (effectiveStatusOf(parent) !== 'active') {
373
+ throw new Error(`parent ${parentUuid} is not effectively active — restore it before putting entities under it`);
374
+ }
375
+ return parent;
376
+ }
377
+
378
+ function requireFreeSlot(parentUuid, name, forUuid = null) {
379
+ if (parentUuid === null) return; // top level: address places a tree, never its name
380
+ const occupant = store.activeAtSlot(parentUuid, name);
381
+ if (occupant && occupant.uuid !== forUuid) {
382
+ throw new Error(`slot taken: active entity ${occupant.uuid} already owns (${parentUuid}, ${name}) — move or trash it first`);
383
+ }
384
+ }
385
+
386
+ /**
387
+ * One batch of moves, one transition. The slot law and acyclicity are
388
+ * STATE invariants: they constrain the graph after the batch, never the
389
+ * order inside it — so movers may hold each other's slots (directories
390
+ * exchange addresses on disk, and detection reports the endpoints). A
391
+ * single move is a batch of one. A PARENTLESS mover is ADOPTION: the
392
+ * whole tree joins another under one parent update — a tree boundary
393
+ * is never an identity boundary, so this is the same lawful move, not
394
+ * a special ceremony.
395
+ */
396
+ function moveMany(moves) {
397
+ if (!db.inTransaction) return transaction(() => moveMany(moves));
398
+ const rows = new Map(); // uuid -> current row
399
+ const post = new Map(); // uuid -> its move
400
+ for (const move of moves) {
401
+ const { uuid, name } = move;
402
+ if (post.has(uuid)) throw new Error(`one batch moves ${uuid} once, not twice`);
403
+ const row = mustGet(uuid);
404
+ if (row.status !== 'active') {
405
+ throw new Error(`only an active entity moves: ${uuid} is ${row.status}`);
406
+ }
407
+ validName(name);
408
+ rows.set(uuid, row);
409
+ post.set(uuid, move);
410
+ }
411
+ const postParentOf = (uuid) => post.get(uuid)?.parentUUID ?? mustGet(uuid).parentUuid;
412
+ const targets = new Set();
413
+ for (const { uuid, parentUUID, name } of moves) {
414
+ const parent = mustGet(parentUUID);
415
+ if (!CONTAINERS.has(parent.type)) {
416
+ throw new Error(`only containers hold active children: ${parentUUID} is ${parent.type}, not workspace/folder/repo.git`);
417
+ }
418
+ const slot = `${parentUUID}\0${name}`;
419
+ if (targets.has(slot)) throw new Error(`slot taken: two movers claim (${parentUUID}, ${name}) in one batch`);
420
+ targets.add(slot);
421
+ const occupant = store.activeAtSlot(parentUUID, name);
422
+ if (occupant && occupant.uuid !== uuid && !post.has(occupant.uuid)) {
423
+ throw new Error(`slot taken: active entity ${occupant.uuid} already owns (${parentUUID ?? 'root'}, ${name}) — move or trash it first`);
424
+ }
425
+ // The target parent's whole post-state chain: acyclic and effectively
426
+ // active, walked once.
427
+ const seen = new Set();
428
+ let cursor = parentUUID;
429
+ while (cursor !== null) {
430
+ if (cursor === uuid) {
431
+ throw new Error(`the parent graph is acyclic: ${uuid} cannot live under itself`);
432
+ }
433
+ if (seen.has(cursor)) throw new Error(`registry corrupt: parent cycle at ${cursor}`);
434
+ seen.add(cursor);
435
+ if (mustGet(cursor).status !== 'active') {
436
+ throw new Error(`parent ${parentUUID} is not effectively active — restore it before putting entities under it`);
437
+ }
438
+ cursor = postParentOf(cursor);
439
+ }
440
+ }
441
+ const previous = moves.map(({ uuid }) => record(rows.get(uuid)));
442
+ // Adoption sheds the root shape in the SAME transition: workspace is
443
+ // the root type only, so a parentless workspace landing under a
444
+ // parent lands as a folder — one write, never a two-step that could
445
+ // tear into a nested workspace.
446
+ store.applyMoves(moves.map(({ uuid, parentUUID, name }) => {
447
+ const row = rows.get(uuid);
448
+ const adoptedWorkspace = row.parentUuid === null && row.type === 'workspace';
449
+ return { uuid, parentUuid: parentUUID, name, ...(adoptedWorkspace ? { type: 'folder' } : {}) };
450
+ }));
451
+ return moves.map(({ uuid }, index) => {
452
+ const nextRow = mustGet(uuid);
453
+ const next = record(nextRow);
454
+ journalRecord(nextRow, rows.get(uuid));
455
+ publish({ type: 'entity.committed', record: next, previous: previous[index] });
456
+ return next;
457
+ });
458
+ }
459
+
460
+ // ---- The registry ----
461
+
462
+ return {
463
+ epoch: () => meta.epochUuid,
464
+ canonicalVersion,
465
+
466
+ /**
467
+ * The identity database's one transition primitive: everything the
468
+ * callback writes — records and evidence bindings alike, they share
469
+ * this database — lands or none of it does, and every bell those
470
+ * writes rang waits here: bells ring only after the OUTERMOST
471
+ * commit, and a rollback silences exactly what it rolled back
472
+ * (a nested savepoint truncates to its own mark). A tree's birth
473
+ * uses it so a minted record and its witness binding never tear
474
+ * apart — and never announce a record that rolled back.
475
+ */
476
+ transaction,
477
+
478
+ /** Always requests comparison — it never trusts the in-memory flag,
479
+ * because a restart loses that memory. Returns whether the bell rang. */
480
+ flush() {
481
+ if (ring({ type: 'registry.changed' })) {
482
+ bellPending = false;
483
+ return true;
484
+ }
485
+ bellPending = true;
486
+ return false;
487
+ },
488
+
489
+ /** Births one registered tree's top record. The one registry holds
490
+ * every tree, so enrolling another root is ordinary — each is one
491
+ * active parentless record, placed by its machine binding. */
492
+ enrollRoot({ type, name, payloadVersion = null, uuid = undefined }) {
493
+ if (!db.inTransaction) {
494
+ return transaction(() => this.enrollRoot({ type, name, payloadVersion, uuid }));
495
+ }
496
+ if (!ROOT_TYPES.has(type)) {
497
+ throw new Error(`the root is workspace, repo.git, a registered file, or a registered link, never ${JSON.stringify(type)}`);
498
+ }
499
+ validName(name);
500
+ const row = {
501
+ uuid: mintOrValidateUuid(uuid), type, parentUuid: null, name, status: 'active', payloadVersion,
502
+ };
503
+ store.insert(row);
504
+ const next = record(row);
505
+ journalRecord(row, null);
506
+ publish({ type: 'entity.committed', record: next, previous: null });
507
+ return next;
508
+ },
509
+
510
+ create({ type, parentUUID, name, payloadVersion = null, uuid = undefined }) {
511
+ if (!db.inTransaction) {
512
+ return transaction(() => this.create({ type, parentUUID, name, payloadVersion, uuid }));
513
+ }
514
+ if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
515
+ if (type === 'workspace') throw new Error('workspace is the root type only; a nested container is a folder');
516
+ validName(name);
517
+ requireActiveContainerParent(parentUUID);
518
+ requireFreeSlot(parentUUID, name);
519
+ const row = {
520
+ uuid: mintOrValidateUuid(uuid), type, parentUuid: parentUUID, name,
521
+ status: 'active', payloadVersion,
522
+ };
523
+ store.insert(row);
524
+ const next = record(row);
525
+ journalRecord(row, null);
526
+ publish({ type: 'entity.committed', record: next, previous: null });
527
+ return next;
528
+ },
529
+
530
+ move(uuid, { parentUUID, name }) {
531
+ return moveMany([{ uuid, parentUUID, name }])[0];
532
+ },
533
+ moveMany,
534
+
535
+ /**
536
+ * The evidence path, and ONE OBSERVATION IS ONE TRANSITION: the
537
+ * payload a look found and the type that same look implies land in
538
+ * a single commit, or neither does. Two writes could tear — new
539
+ * bytes wearing an old type — and the equal-payload silence on the
540
+ * next pass would never heal it, because the payload it compares
541
+ * already matches. One write, one bell: what a consumer hears is a
542
+ * state disk actually held.
543
+ *
544
+ * Equal attestations are silence.
545
+ */
546
+ attest(uuid, { payloadVersion, type }) {
547
+ const row = mustGet(uuid);
548
+ if (row.status !== 'active') {
549
+ throw new Error(`only an active entity attests payload: ${uuid} is ${row.status}`);
550
+ }
551
+ attestable(row.type);
552
+ attestable(type);
553
+ if (row.parentUuid === null && !ROOT_TYPES.has(type)) {
554
+ throw new Error(`the root is workspace, repo.git, a registered file, or a registered link, never ${JSON.stringify(type)}`);
555
+ }
556
+ if (row.type === type && row.payloadVersion === payloadVersion) return record(row);
557
+ return commit(row, { type, payloadVersion });
558
+ },
559
+
560
+ changeType(uuid, type, { payloadVersion = null } = {}) {
561
+ const row = mustGet(uuid);
562
+ if (row.status !== 'active') {
563
+ throw new Error(`only an active entity changes type: ${uuid} is ${row.status}`);
564
+ }
565
+ if (!TYPES.has(type)) throw new Error(`unknown entity type ${JSON.stringify(type)}`);
566
+ if (row.parentUuid === null) {
567
+ if (!ROOT_TYPES.has(type)) throw new Error(`the root is workspace, repo.git, a registered file, or a registered link, never ${JSON.stringify(type)}`);
568
+ } else if (type === 'workspace') {
569
+ throw new Error('workspace is the root type only; a nested container is a folder');
570
+ }
571
+ if (!CONTAINERS.has(type)) {
572
+ const children = store.activeChildrenOf(uuid);
573
+ if (children.length > 0) {
574
+ throw new Error(`refusing ${row.type} → ${type} on ${uuid}: ${children.length} stored-active child(ren) would be stranded — only containers hold active children`);
575
+ }
576
+ }
577
+ if (type === row.type) return record(row);
578
+ return commit(row, {
579
+ type, payloadVersion: DERIVED_PAYLOAD.has(type) ? null : payloadVersion,
580
+ });
581
+ },
582
+
583
+ trash(uuid) {
584
+ const row = mustGet(uuid);
585
+ if (row.status !== 'active') throw new Error(`trash is active → trashed: ${uuid} is ${row.status}`);
586
+ if (row.parentUuid === null) {
587
+ throw new Error('a tree\'s top record is never trashed: removing a whole tree is an explicit future operation, never a lifecycle flip');
588
+ }
589
+ return commit(row, { status: 'trashed' });
590
+ },
591
+
592
+ restore(uuid) {
593
+ const row = mustGet(uuid);
594
+ if (row.status === 'deleted') throw new Error(`deleted is permanent: ${uuid} cannot be restored`);
595
+ if (row.status !== 'trashed') throw new Error(`restore is trashed → active: ${uuid} is ${row.status}`);
596
+ requireFreeSlot(row.parentUuid, row.name);
597
+ return commit(row, { status: 'active' });
598
+ },
599
+
600
+ deletePermanently(uuid) {
601
+ if (typeof authorizeDelete !== 'function') {
602
+ throw new Error('permanent deletion is unavailable: no ownership authorization is wired (authorizeDelete)');
603
+ }
604
+ const row = mustGet(uuid);
605
+ if (row.status === 'deleted') throw new Error(`${uuid} is already deleted`);
606
+ if (row.status !== 'trashed') {
607
+ throw new Error(`empty-trash deletes from Trash only: ${uuid} is ${row.status} — trash it first`);
608
+ }
609
+ if (!authorizeDelete(record(row))) {
610
+ throw new Error(`permanent deletion is owner-only: not authorized for ${uuid}`);
611
+ }
612
+ return commit(row, { status: 'deleted' });
613
+ },
614
+
615
+ /**
616
+ * Apply one already-authorized cloud operation without creating a new
617
+ * local outbox row. This is intentionally a graph operation only: no
618
+ * observer root, path, inode, or watcher is created by an inbound UUID.
619
+ */
620
+ applyCloudRecord(value) {
621
+ const incoming = normalizeCloudRecord(value);
622
+ return transaction(() => {
623
+ const previous = store.byUuid(incoming.uuid);
624
+ if (!previous && incoming.parentUuid !== null && !store.byUuid(incoming.parentUuid)) {
625
+ throw new Error(`cloud entity ${incoming.uuid} names missing parent ${incoming.parentUuid}`);
626
+ }
627
+ if (previous) {
628
+ store.update(incoming.uuid, incoming);
629
+ } else {
630
+ store.insert(incoming);
631
+ }
632
+ const current = mustGet(incoming.uuid);
633
+ const synced = syncRecord(current);
634
+ if (synced.version !== incoming.expectedVersion) {
635
+ throw new Error(`cloud entity ${incoming.uuid} version does not match its logical record`);
636
+ }
637
+ const previousRecord = previous ? record(previous) : null;
638
+ if (!previous || !sameLogicalRecord(previous, current)) {
639
+ publish({ type: 'entity.committed', record: record(current), previous: previousRecord });
640
+ }
641
+ return synced;
642
+ });
643
+ },
644
+
645
+ record: (uuid) => record(mustGet(uuid)),
646
+ syncRecord: (uuid) => syncRecord(mustGet(uuid)),
647
+ /** Full logical graph for cloud snapshots. Machine bindings and evidence
648
+ * are intentionally absent. */
649
+ syncRecords: () => store.allEntities().map(syncRecord),
650
+ /** Every registered tree's top record. */
651
+ roots: () => store.activeRoots().map(record),
652
+ /** Every active managed reference with the identity it targets. The
653
+ * six-field record law holds: the target UUID is the payload, and it
654
+ * travels beside the record here, never as a seventh field. */
655
+ references: () => store.activeByType('reference')
656
+ .map((row) => ({ record: record(row), targetUUID: row.payloadVersion })),
657
+ activeAt: (parentUUID, name) => {
658
+ const row = store.activeAtSlot(parentUUID, name);
659
+ return row ? record(row) : null;
660
+ },
661
+ children: (uuid) => store.childrenOf(uuid).map(record),
662
+ effectiveStatus: (uuid) => effectiveStatusOf(mustGet(uuid)),
663
+ };
664
+ }
665
+
666
+ module.exports = { createRegistry, canonicalVersion, ENTITY_TYPES };