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