amalgm 0.1.246 → 0.1.247

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 (49) hide show
  1. package/README.md +5 -3
  2. package/lib/cli.js +92 -4
  3. package/lib/shared-realtime-tunnel.js +82 -19
  4. package/package.json +1 -1
  5. package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
  6. package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
  7. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
  8. package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
  9. package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
  10. package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
  11. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
  13. package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
  14. package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
  15. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
  16. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
  17. package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
  18. package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
  19. package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
  20. package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
  21. package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
  22. package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
  23. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
  24. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
  25. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
  26. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
  27. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
  28. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
  29. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
  30. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
  31. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
  32. package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
  33. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
  34. package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
  35. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
  36. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
  37. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
  38. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
  39. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
  40. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
  41. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
  42. package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
  43. package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
  44. package/runtime/scripts/chat-core/contract.js +21 -3
  45. package/runtime/scripts/chat-core/engine.js +8 -0
  46. package/runtime/scripts/chat-core/server.js +11 -1
  47. package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
  48. package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
  49. package/runtime/scripts/local-gateway.js +0 -1
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('crypto');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const Database = require('better-sqlite3');
11
+ const { createRegistry } = require('../registry');
12
+ const { createEntityCloud } = require('../registration/entity-cloud');
13
+ const { CHUNK_BYTES, createEntityContent } = require('../registration/entity-content');
14
+
15
+ function sha256(bytes) {
16
+ return crypto.createHash('sha256').update(bytes).digest('hex');
17
+ }
18
+
19
+ test('file bytes are captured as bounded immutable chunks, then acknowledged before their entity record is claimable', () => {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-'));
21
+ const source = path.join(dir, 'notes.bin');
22
+ const bytes = Buffer.concat([Buffer.alloc(CHUNK_BYTES, 0x61), Buffer.from('tail')]);
23
+ const contentHash = sha256(bytes);
24
+ fs.writeFileSync(source, bytes);
25
+
26
+ const content = createEntityContent({ dir });
27
+ const captured = content.captureFile({ filePath: source, contentHash });
28
+ assert.equal(captured.chunks.length, 2, 'transport chunks bound frame size without changing the file head');
29
+ assert.equal(captured.bytes, bytes.length);
30
+
31
+ const db = new Database(':memory:');
32
+ const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1', content });
33
+ const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
34
+ const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
35
+ const bootstrap = cloud.bootstrap(registry);
36
+ cloud.install({
37
+ registry,
38
+ resourceId: bootstrap.resourceId,
39
+ authorityEpoch: 1,
40
+ headVersion: 0,
41
+ snapshotBase64: bootstrap.snapshotBase64,
42
+ snapshotChecksum: bootstrap.snapshotChecksum,
43
+ });
44
+
45
+ const file = registry.create({
46
+ type: 'file.binary', parentUUID: root.uuid, name: 'notes.bin', payloadVersion: contentHash,
47
+ });
48
+ assert.deepEqual(cloud.claim(), [], 'the cloud record is held until its immutable bytes are acknowledged');
49
+
50
+ const first = cloud.claimContent();
51
+ assert.equal(first.kind, 'chunk');
52
+ assert.equal(first.partIndex, 0);
53
+ assert.equal(first.bytes, CHUNK_BYTES);
54
+ cloud.acknowledgeContent({ ...first, contentHash, resourceId: bootstrap.resourceId });
55
+
56
+ const second = cloud.claimContent();
57
+ assert.equal(second.kind, 'chunk');
58
+ assert.equal(second.partIndex, 1);
59
+ assert.equal(Buffer.from(second.dataBase64, 'base64').toString(), 'tail');
60
+ cloud.acknowledgeContent({ ...second, contentHash, resourceId: bootstrap.resourceId });
61
+
62
+ const manifest = cloud.claimContent();
63
+ assert.equal(manifest.kind, 'manifest');
64
+ assert.equal(manifest.partIndex, 2);
65
+ cloud.acknowledgeContent({ ...manifest, contentHash, resourceId: bootstrap.resourceId });
66
+
67
+ const mutation = cloud.claim();
68
+ assert.equal(mutation.length, 1);
69
+ assert.equal(mutation[0].operation.record.uuid, file.uuid);
70
+ assert.equal(mutation[0].operation.record.payloadVersion, contentHash);
71
+
72
+ fs.rmSync(dir, { recursive: true, force: true });
73
+ });
74
+
75
+ test('a later file version never substitutes its bytes for an earlier captured head', () => {
76
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-race-'));
77
+ const source = path.join(dir, 'notes.txt');
78
+ fs.writeFileSync(source, 'first');
79
+ const firstHead = sha256(Buffer.from('first'));
80
+ fs.writeFileSync(source, 'second');
81
+ const content = createEntityContent({ dir });
82
+ assert.throws(
83
+ () => content.captureFile({ filePath: source, contentHash: firstHead }),
84
+ /file changed while its content was being captured/,
85
+ );
86
+ assert.equal(content.uploaded({ resourceId: 'pvr_test', contentHash: firstHead }), false);
87
+ fs.rmSync(dir, { recursive: true, force: true });
88
+ });
89
+
90
+ test('a cloud manifest and its immutable chunks rebuild exact bytes in another machine cache', () => {
91
+ const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-source-'));
92
+ const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-content-target-'));
93
+ const sourceFile = path.join(sourceDir, 'message.txt');
94
+ const bytes = Buffer.concat([Buffer.alloc(CHUNK_BYTES, 0x62), Buffer.from('from another machine')]);
95
+ const contentHash = sha256(bytes);
96
+ fs.writeFileSync(sourceFile, bytes);
97
+
98
+ const source = createEntityContent({ dir: sourceDir });
99
+ source.captureFile({ filePath: sourceFile, contentHash });
100
+ source.ensureUpload({ resourceId: 'pvr_test', contentHash });
101
+ const parts = [];
102
+ while (true) {
103
+ const part = source.claim({ resourceId: 'pvr_test' });
104
+ if (!part) break;
105
+ parts.push(part);
106
+ source.acknowledge({
107
+ resourceId: 'pvr_test', contentHash, kind: part.kind, partIndex: part.partIndex,
108
+ });
109
+ }
110
+
111
+ const target = createEntityContent({ dir: targetDir });
112
+ const manifest = parts.find((part) => part.kind === 'manifest');
113
+ target.receive({
114
+ contentHash, kind: 'manifest', partIndex: 0, sha256: manifest.sha256, dataBase64: manifest.dataBase64,
115
+ });
116
+ for (const part of parts.filter((item) => item.kind === 'chunk')) {
117
+ target.receive({
118
+ contentHash, kind: 'chunk', partIndex: part.partIndex, sha256: part.sha256, dataBase64: part.dataBase64,
119
+ });
120
+ }
121
+
122
+ assert.equal(target.complete({ contentHash }), true);
123
+ assert.deepEqual(target.bytes({ contentHash }), bytes);
124
+ fs.rmSync(sourceDir, { recursive: true, force: true });
125
+ fs.rmSync(targetDir, { recursive: true, force: true });
126
+ });
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('crypto');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const Database = require('better-sqlite3');
11
+ const { createRegistry } = require('../registry');
12
+ const { createEntityCloud, snapshotFromRecords } = require('../registration/entity-cloud');
13
+ const { createEntityContent } = require('../registration/entity-content');
14
+ const { createRegistrationService } = require('../registration/service');
15
+
16
+ function sha256(bytes) {
17
+ return crypto.createHash('sha256').update(bytes).digest('hex');
18
+ }
19
+
20
+ function uploadedParts(content, resourceId, contentHash) {
21
+ content.ensureUpload({ resourceId, contentHash });
22
+ const parts = [];
23
+ while (true) {
24
+ const part = content.claim({ resourceId });
25
+ if (!part) return parts;
26
+ parts.push(part);
27
+ content.acknowledge({ resourceId, contentHash, kind: part.kind, partIndex: part.partIndex });
28
+ }
29
+ }
30
+
31
+ test('add materializes one compacted cloud head, preserves its UUIDs, and starts a local watcher without creating an outbound history replay', async () => {
32
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-materialization-'));
33
+ const sourceDir = path.join(rootDir, 'source');
34
+ const targetState = path.join(rootDir, 'target-state');
35
+ const targetParent = path.join(rootDir, 'checkout');
36
+ fs.mkdirSync(sourceDir, { recursive: true });
37
+ fs.mkdirSync(targetParent, { recursive: true });
38
+ const bytes = Buffer.from('# arrives from cloud\n');
39
+ const contentHash = sha256(bytes);
40
+ const sourceFile = path.join(sourceDir, 'readme.md');
41
+ fs.writeFileSync(sourceFile, bytes);
42
+
43
+ const sourceDb = new Database(':memory:');
44
+ const sourceContent = createEntityContent({ dir: sourceDir });
45
+ const sourceCloud = createEntityCloud({
46
+ db: sourceDb, userId: 'user-1', deviceId: 'source-device', content: sourceContent,
47
+ });
48
+ const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
49
+ const root = sourceRegistry.enrollRoot({ type: 'workspace', name: 'project' });
50
+ const file = sourceRegistry.create({
51
+ type: 'file.text', parentUUID: root.uuid, name: 'readme.md', payloadVersion: contentHash,
52
+ });
53
+ sourceContent.captureFile({ filePath: sourceFile, contentHash });
54
+ const parts = uploadedParts(sourceContent, sourceCloud.identity().resourceId, contentHash);
55
+ const snapshot = snapshotFromRecords(sourceRegistry.syncRecords());
56
+ const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
57
+
58
+ const target = createRegistrationService({
59
+ dir: targetState,
60
+ homeDir: path.join(rootDir, 'target-home'),
61
+ cloudUserId: 'user-1',
62
+ cloudDeviceId: 'target-device',
63
+ });
64
+ try {
65
+ target.entityCloud.install({
66
+ registry: target.registration.registry,
67
+ resourceId: sourceCloud.identity().resourceId,
68
+ authorityEpoch: 7,
69
+ headVersion: 41,
70
+ snapshotBase64: snapshotBytes.toString('base64'),
71
+ snapshotChecksum: sha256(snapshotBytes),
72
+ });
73
+ assert.deepEqual(target.registration.registry.syncRecords(), [], 'the compacted cloud graph is not watched before add');
74
+
75
+ const manifest = parts.find((part) => part.kind === 'manifest');
76
+ target.entityCloud.receiveDownload({
77
+ resourceId: sourceCloud.identity().resourceId,
78
+ contentHash,
79
+ kind: 'manifest',
80
+ partIndex: 0,
81
+ sha256: manifest.sha256,
82
+ dataBase64: manifest.dataBase64,
83
+ });
84
+ for (const part of parts.filter((entry) => entry.kind === 'chunk')) {
85
+ target.entityCloud.receiveDownload({
86
+ resourceId: sourceCloud.identity().resourceId,
87
+ contentHash,
88
+ kind: 'chunk',
89
+ partIndex: part.partIndex,
90
+ sha256: part.sha256,
91
+ dataBase64: part.dataBase64,
92
+ });
93
+ }
94
+
95
+ const job = await target.add({ uuid: root.uuid, targetPath: targetParent });
96
+ assert.equal(job.state, 'complete');
97
+ const destination = path.join(targetParent, 'project');
98
+ assert.deepEqual(fs.readFileSync(path.join(destination, 'readme.md')), bytes);
99
+ assert.equal(target.registration.registry.syncRecord(root.uuid).uuid, root.uuid);
100
+ assert.equal(target.registration.registry.syncRecord(file.uuid).uuid, file.uuid);
101
+ const watchers = target.observer.status();
102
+ assert.equal(watchers.find((entry) => entry.path === fs.realpathSync(destination))?.watching, true, JSON.stringify(watchers));
103
+ const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
104
+ try {
105
+ assert.equal(
106
+ targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count,
107
+ 0,
108
+ 'binding an authoritative cloud snapshot does not emit a new outbound mutation history',
109
+ );
110
+ } finally {
111
+ targetDb.close();
112
+ }
113
+ } finally {
114
+ target.close();
115
+ sourceDb.close();
116
+ fs.rmSync(rootDir, { recursive: true, force: true });
117
+ }
118
+ });
@@ -83,6 +83,72 @@ test('first initialization mints the sentinel; reopening finds the same registry
83
83
  assert.deepEqual(rootOf(reopened), reopened.record(root.uuid));
84
84
  });
85
85
 
86
+ test('the additive entity-cloud migration preserves a generation-2 registry', () => {
87
+ const db = new Database(':memory:');
88
+ db.exec(`
89
+ CREATE TABLE registry_meta (singleton INTEGER PRIMARY KEY, epoch_uuid TEXT NOT NULL, created_at_ms INTEGER NOT NULL);
90
+ CREATE TABLE entities (uuid TEXT PRIMARY KEY, type TEXT NOT NULL, parent_uuid TEXT, name TEXT NOT NULL, status TEXT NOT NULL, payload_version TEXT);
91
+ CREATE TABLE channel_bindings (channel_id TEXT PRIMARY KEY, scope_id TEXT NOT NULL, entity_uuid TEXT NOT NULL);
92
+ CREATE TABLE contested_ground (scope_id TEXT NOT NULL, rel_path TEXT NOT NULL, claimant_uuid TEXT NOT NULL, kind TEXT NOT NULL, PRIMARY KEY (scope_id, rel_path));
93
+ `);
94
+ db.prepare('INSERT INTO registry_meta (singleton, epoch_uuid, created_at_ms) VALUES (1, ?, ?)')
95
+ .run('legacy-epoch', 1);
96
+ db.pragma('user_version = 2');
97
+
98
+ const registry = createRegistry({ db, ephemeral: true, emit: () => {} });
99
+ assert.equal(registry.epoch(), 'legacy-epoch', 'the existing identity epoch survives');
100
+ assert.equal(db.pragma('user_version', { simple: true }), 6);
101
+ assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'repo_states'").get(),
102
+ 'the repo-state document table is added without replacing identity');
103
+ assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_mutations'").get(),
104
+ 'the entity-cloud outbox is added beside the existing UUID graph');
105
+ assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_catalog_state'").get(),
106
+ 'the snapshot-head marker is added beside the existing UUID graph');
107
+ });
108
+
109
+ test('the active-snapshot marker migrates beside an existing materialization catalog', () => {
110
+ const db = new Database(':memory:');
111
+ createRegistry({ db, ephemeral: true, emit: () => {} });
112
+ db.exec('DROP TABLE entity_cloud_catalog_state');
113
+ db.pragma('user_version = 5');
114
+
115
+ const reopened = createRegistry({ db, ephemeral: true, emit: () => {} });
116
+ assert.ok(reopened.epoch(), 'the previous identity database remains usable');
117
+ assert.equal(db.pragma('user_version', { simple: true }), 6);
118
+ assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_catalog_state'").get(),
119
+ 'the separate pinned-snapshot marker is created without rewriting identities');
120
+ });
121
+
122
+ test('an entity commit and its cloud delivery intent are one SQLite transaction', () => {
123
+ const db = new Database(':memory:');
124
+ let nextMutation = 0;
125
+ const registry = createRegistry({
126
+ db,
127
+ ephemeral: true,
128
+ emit: () => {},
129
+ journal: ({ record }) => {
130
+ db.prepare(`
131
+ INSERT INTO entity_cloud_mutations (
132
+ resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at
133
+ ) VALUES ('pvr_test', ?, ?, 'pending', 'now', 'now')
134
+ `).run(`ent_test_${nextMutation += 1}`, JSON.stringify({ kind: 'entity.upsert', record }));
135
+ },
136
+ });
137
+ const root = registry.enrollRoot({ type: 'workspace', name: 'clouded' });
138
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1);
139
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entities WHERE uuid = ?').get(root.uuid).count, 1);
140
+
141
+ const failing = createRegistry({
142
+ db: new Database(':memory:'),
143
+ ephemeral: true,
144
+ emit: () => {},
145
+ journal: () => { throw new Error('outbox unavailable'); },
146
+ });
147
+ assert.throws(() => failing.enrollRoot({ type: 'workspace', name: 'must-not-land' }), /outbox unavailable/);
148
+ assert.equal(failing.roots().length, 0,
149
+ 'a failed cloud intent rolls the entity birth back instead of leaving local-only identity');
150
+ });
151
+
86
152
  test('a registry that existed is never silently replaced', () => {
87
153
  const dir = fs.mkdtempSync(path.join(tempRoot, 'loss-'));
88
154
  const sentinelPath = path.join(dir, 'registry.epoch');
@@ -154,11 +220,11 @@ test('a failed notification is a pending bell, never a permanent miss', () => {
154
220
  const renamed = registry.move(file.uuid, { parentUUID: root.uuid, name: 'b.txt' });
155
221
  assert.equal(renamed.name, 'b.txt', 'the commit succeeded — the registry is truth');
156
222
  assert.equal(events.length, 0, 'the notification was missed');
157
- registry.setPayloadVersion(file.uuid, 'h2');
223
+ registry.attest(file.uuid, { payloadVersion: 'h2', type: 'file.text' });
158
224
  assert.equal(events.length, 0, 'two misses collapse into one owed comparison');
159
225
 
160
226
  down = false;
161
- registry.setPayloadVersion(file.uuid, 'h3');
227
+ registry.attest(file.uuid, { payloadVersion: 'h3', type: 'file.text' });
162
228
  assert.deepEqual(events.map((event) => event.type), ['registry.changed', 'entity.committed'],
163
229
  'the next commit rings the pending bell first, then its own event');
164
230
  assert.deepEqual(registry.record(file.uuid), events[1].record,
@@ -202,7 +268,7 @@ test('trees in one registry: parentless records are roots, and a tree boundary i
202
268
  assert.deepEqual(registry.roots().map((record) => record.uuid), [other.uuid],
203
269
  'the adopted tree is no longer parentless — it lives inside its new tree');
204
270
 
205
- // The ratified extension (`amalgm add <file>`): a single registered
271
+ // The ratified extension (`amalgm register <file>`): a single registered
206
272
  // file is a tree of one — its root record IS the file entity.
207
273
  const { registry: soloRegistry } = makeRegistry();
208
274
  const solo = soloRegistry.enrollRoot({ type: 'file.text', name: 'notes.md', payloadVersion: 'h1' });
@@ -210,7 +276,7 @@ test('trees in one registry: parentless records are roots, and a tree boundary i
210
276
  assert.throws(() => soloRegistry.create({ type: 'file.text', parentUUID: solo.uuid, name: 'x' }),
211
277
  /only containers hold active children/, 'a tree of one stays a tree of one');
212
278
 
213
- // And its sibling (amended 2026-08-01): `amalgm add` names an ADDRESS,
279
+ // And its sibling (amended 2026-08-01): `amalgm register` names an ADDRESS,
214
280
  // so a pointer added explicitly is an entity of its own — a tree of
215
281
  // one of the pointer kind, its payload the written target text.
216
282
  const { registry: ptrRegistry } = makeRegistry();
@@ -227,7 +293,9 @@ test('a reference\'s payload is identity, never an attestation', () => {
227
293
  type: 'reference', parentUUID: root.uuid, name: 'proj', payloadVersion: 'target-uuid-123',
228
294
  });
229
295
  assert.equal(ref.type, 'reference');
230
- assert.throws(() => registry.setPayloadVersion(ref.uuid, 'other'), /never an attestation/,
296
+ assert.throws(
297
+ () => registry.attest(ref.uuid, { payloadVersion: 'other', type: 'reference' }),
298
+ /never an attestation/,
231
299
  'the evidence path cannot write a reference\'s payload: ground attests bytes, never identity');
232
300
  assert.throws(() => registry.create({ type: 'file.text', parentUUID: ref.uuid, name: 'x' }),
233
301
  /only containers hold active children/, 'a reference holds no children');
@@ -266,10 +334,57 @@ test('rename and move preserve the uuid; the version is a state fingerprint', ()
266
334
  assert.equal(home.version, born.version,
267
335
  'identical state is the identical version — a fingerprint, not a history counter');
268
336
 
269
- const edited = registry.setPayloadVersion(file.uuid, 'hash-2');
337
+ const edited = registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.text' });
270
338
  assert.notEqual(edited.version, home.version);
271
- assert.equal(registry.setPayloadVersion(file.uuid, 'hash-2').version, edited.version,
272
- 'an equal attestation is silence');
339
+ assert.equal(
340
+ registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.text' }).version,
341
+ edited.version, 'an equal attestation is silence');
342
+ });
343
+
344
+ test('one observation is one transition: payload and type land in a single commit', () => {
345
+ const { registry, events, file } = makeGraph();
346
+ events.length = 0;
347
+
348
+ // The same look that found these bytes says they are bytes, not text.
349
+ const after = registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.binary' });
350
+ assert.equal(after.type, 'file.binary');
351
+ assert.equal(events.length, 1, 'one look, one bell — never a hash commit and a type commit');
352
+ assert.equal(events[0].record.type, 'file.binary');
353
+ assert.equal(events[0].previous.type, 'file.text');
354
+ // The state nobody may ever hear or store: new bytes wearing the old
355
+ // type. Two writes would publish exactly that, and the equal-payload
356
+ // silence on the next pass would never heal it.
357
+ assert.equal(registry.record(file.uuid).type, 'file.binary');
358
+
359
+ // A pointer replacing bytes on the same slot is the same one transition.
360
+ events.length = 0;
361
+ const asLink = registry.attest(file.uuid, { payloadVersion: '../elsewhere', type: 'link' });
362
+ assert.equal(asLink.type, 'link');
363
+ assert.equal(events.length, 1);
364
+ });
365
+
366
+ test('only leaf ground attests: membership and identity are never observed', () => {
367
+ const { registry, root, folder, file } = makeGraph();
368
+ for (const [uuid, what] of [[root.uuid, 'workspace'], [folder.uuid, 'folder']]) {
369
+ assert.throws(() => registry.attest(uuid, { payloadVersion: 'h', type: 'file.text' }),
370
+ /payload is derived membership/, `${what} payload is its members, never a look's answer`);
371
+ }
372
+ // Both ends are judged: what the record is now, and what the bytes claim.
373
+ assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h', type: 'folder' }),
374
+ /payload is derived membership/);
375
+ assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h', type: 'reference' }),
376
+ /never an attestation/);
377
+ assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h', type: 'directory' }),
378
+ /unknown entity type/);
379
+ });
380
+
381
+ test('a tree\'s top record attests only a type a root may wear', () => {
382
+ const { registry } = makeRegistry();
383
+ const root = registry.enrollRoot({ type: 'file.text', name: 'notes.md', payloadVersion: 'h1' });
384
+ assert.equal(registry.attest(root.uuid, { payloadVersion: 'h2', type: 'file.binary' }).type,
385
+ 'file.binary', 'a registered single file changes kind on its own UUID');
386
+ assert.throws(() => registry.attest(root.uuid, { payloadVersion: 'h3', type: 'folder' }),
387
+ /payload is derived membership/);
273
388
  });
274
389
 
275
390
  test('the slot admits one active owner — and the schema itself convicts', () => {
@@ -377,7 +492,7 @@ test('a trashed record is not ground: it never moves, never attests, never re-ty
377
492
  registry.trash(file.uuid);
378
493
  assert.throws(() => registry.move(file.uuid, { parentUUID: root.uuid, name: 'x.txt' }),
379
494
  /only an active entity moves/);
380
- assert.throws(() => registry.setPayloadVersion(file.uuid, 'h9'),
495
+ assert.throws(() => registry.attest(file.uuid, { payloadVersion: 'h9', type: 'file.text' }),
381
496
  /only an active entity attests/);
382
497
  assert.throws(() => registry.changeType(file.uuid, 'folder'),
383
498
  /only an active entity changes type/);
@@ -442,7 +557,7 @@ test('a container\'s version is its active membership, nothing else', () => {
442
557
  const { registry, folder, file } = makeGraph();
443
558
  const v0 = registry.record(folder.uuid).version;
444
559
 
445
- registry.setPayloadVersion(file.uuid, 'hash-2');
560
+ registry.attest(file.uuid, { payloadVersion: 'hash-2', type: 'file.text' });
446
561
  assert.equal(registry.record(folder.uuid).version, v0,
447
562
  'a child\'s content edit does not change its parent\'s version');
448
563
 
@@ -483,7 +598,7 @@ test('every commit emits the record and its previous; no-ops emit nothing', () =
483
598
  assert.notEqual(events[0].previous.version, moved.version);
484
599
 
485
600
  events.length = 0;
486
- registry.setPayloadVersion(file.uuid, 'hash-1'); // equal attestation
601
+ registry.attest(file.uuid, { payloadVersion: 'hash-1', type: 'file.text' }); // equal attestation
487
602
  registry.changeType(file.uuid, 'file.text'); // same type
488
603
  assert.equal(events.length, 0, 'silence when nothing changed');
489
604
 
@@ -32,8 +32,14 @@ const defaultPolicy = require('../workspace/tree/inclusion').shouldEnroll;
32
32
  // The RIG's explicit classification policy — injected into the model
33
33
  // exactly like the enrollment policy. "NUL byte means binary" is a choice
34
34
  // these tests make; the model owns no such rule.
35
- const classify = (bytes) => (
36
- (Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)).includes(0) ? 'file.binary' : 'file.text');
35
+ //
36
+ // ONE law, two askers. The oracle holds the bytes and looks for the NUL
37
+ // itself; the evidence lens is handed the observer's report on the bytes
38
+ // it already hashed. Neither re-reads the ground to answer, so the two
39
+ // can never grade the same file differently.
40
+ const typeOf = (binary) => (binary ? 'file.binary' : 'file.text');
41
+ const classify = (bytes) => typeOf(
42
+ (Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)).includes(0));
37
43
 
38
44
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-entity-rig-'));
39
45
 
@@ -113,9 +119,10 @@ function makeKit(name, { policy = defaultPolicy, watch, settleMs } = {}) {
113
119
  // born here — birth reads store truth, exactly like the boundary.
114
120
  evidence = createEvidence({
115
121
  observer, registry, memory, rootId: kit.rootId,
116
- // The evidence layer's injected classification policy reads the
117
- // ground it is classifying — the same NUL rule the model got.
118
- classify: (absPath) => classify(fs.readFileSync(absPath)),
122
+ // The SAME rule the model got, asked of the SAME evidence: the
123
+ // observer's own report on the bytes it hashed. No bridge, no
124
+ // second read — that is the point of the law.
125
+ classify: ({ binary }) => typeOf(binary),
119
126
  });
120
127
  kit.evidence = evidence;
121
128
  evidence.reconcile();
@@ -207,7 +214,14 @@ function fakeWatchKit() {
207
214
  return {
208
215
  watch: (rootPath, onDirty) => {
209
216
  bells.set(rootPath, onDirty);
210
- return { degraded: false, close: () => bells.delete(rootPath) };
217
+ // A doorbell held in a Map has no arming window: pressing it answers.
218
+ const handle = {
219
+ degraded: false,
220
+ heard: true,
221
+ press: () => { handle.heard = true; },
222
+ close: () => bells.delete(rootPath),
223
+ };
224
+ return handle;
211
225
  },
212
226
  ring: (rootPath, relPath) => bells.get(rootPath)(relPath),
213
227
  /** Every open watcher whose ground covers this absolute path rings,
@@ -22,6 +22,14 @@ function installFakeWatch() {
22
22
  return entry;
23
23
  };
24
24
  return {
25
+ /**
26
+ * An OS that delivers only what the exam delivers has no arming
27
+ * window to cross, so there is nothing to prove about its doorbells.
28
+ * Pass this as the observer's `prove` wherever this stand-in is
29
+ * installed; the real proof is exercised where the real OS is
30
+ * (registration.service.test.js).
31
+ */
32
+ prove: async () => false,
25
33
  /** Ring every open doorbell on `target` with one raw name. */
26
34
  deliver(target, name) {
27
35
  for (const entry of [...watchers]) {
@@ -91,7 +91,14 @@ function fakeWatchKit() {
91
91
  return {
92
92
  watch: (rootPath, onDirty) => {
93
93
  bells.set(rootPath, onDirty);
94
- return { degraded: false, close: () => bells.delete(rootPath) };
94
+ // A doorbell held in a Map has no arming window: pressing it answers.
95
+ const handle = {
96
+ degraded: false,
97
+ heard: true,
98
+ press: () => { handle.heard = true; },
99
+ close: () => bells.delete(rootPath),
100
+ };
101
+ return handle;
95
102
  },
96
103
  ring: (rootPath, relPath) => bells.get(rootPath)(relPath),
97
104
  };