@ours.network/notifications 0.1.1
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/LICENSE +98 -0
- package/README.md +231 -0
- package/dist/cli.js +446 -0
- package/dist/config.js +104 -0
- package/dist/daemon.js +353 -0
- package/dist/http.js +31 -0
- package/dist/log-store.js +48 -0
- package/dist/presence.js +157 -0
- package/dist/provision.js +29 -0
- package/dist/push.js +6 -0
- package/dist/web-push-sender.js +41 -0
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +21 -0
- package/mufl_code/core/.github/FUNDING.yml +3 -0
- package/mufl_code/core/.github/workflows/docs.yml +39 -0
- package/mufl_code/core/.github/workflows/tests.yml +66 -0
- package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
- package/mufl_code/core/COMPATIBILITY.md +221 -0
- package/mufl_code/core/CONTRIBUTING.md +37 -0
- package/mufl_code/core/LICENSE +98 -0
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
- package/mufl_code/core/README.md +91 -0
- package/mufl_code/core/SECURITY.md +40 -0
- package/mufl_code/core/TRADEMARKS.md +24 -0
- package/mufl_code/core/a2a_capabilities.mm +449 -0
- package/mufl_code/core/a2a_cluster.mm +601 -0
- package/mufl_code/core/a2a_control.mm +90 -0
- package/mufl_code/core/a2a_messaging.mm +4817 -0
- package/mufl_code/core/a2a_monitoring.mm +94 -0
- package/mufl_code/core/a2a_notification_integration.mm +144 -0
- package/mufl_code/core/a2a_notifications.mm +1127 -0
- package/mufl_code/core/a2a_protocol.mm +246 -0
- package/mufl_code/core/a2a_versions.mm +801 -0
- package/mufl_code/core/config.mufl +29 -0
- package/mufl_code/core/docs/.vitepress/config.mts +58 -0
- package/mufl_code/core/docs/citations.json +688 -0
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
- package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
- package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
- package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
- package/mufl_code/core/docs/guide/index.md +63 -0
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
- package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
- package/mufl_code/core/docs/how-it-works/identity.md +56 -0
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
- package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
- package/mufl_code/core/docs/how-it-works/overview.md +45 -0
- package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
- package/mufl_code/core/docs/index.md +27 -0
- package/mufl_code/core/docs/package-lock.json +3467 -0
- package/mufl_code/core/docs/package.json +15 -0
- package/mufl_code/core/docs/reference/contributing.md +26 -0
- package/mufl_code/core/docs/reference/glossary.md +108 -0
- package/mufl_code/core/docs/reference/implementations.md +23 -0
- package/mufl_code/core/docs/reference/modules.md +19 -0
- package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
- package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
- package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
- package/mufl_code/core/docs/workflows/cluster.md +89 -0
- package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
- package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
- package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
- package/mufl_code/core/docs/workflows/index.md +57 -0
- package/mufl_code/core/docs/workflows/introductions.md +60 -0
- package/mufl_code/core/docs/workflows/messaging.md +75 -0
- package/mufl_code/core/docs/workflows/monitoring.md +84 -0
- package/mufl_code/core/docs/workflows/notifications.md +109 -0
- package/mufl_code/core/release-notes/0.1.md +60 -0
- package/mufl_code/core/release-notes/0.3.md +22 -0
- package/mufl_code/core/release-notes/0.4.md +97 -0
- package/mufl_code/core/tests/README.md +83 -0
- package/mufl_code/core/tests/compat/README.md +82 -0
- package/mufl_code/core/tests/compat/compat.mjs +245 -0
- package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
- package/mufl_code/core/tests/compat/run_compat.sh +96 -0
- package/mufl_code/core/tests/corpus.mjs +139 -0
- package/mufl_code/core/tests/corpus_actor.mu +242 -0
- package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
- package/mufl_code/core/tests/mig.mjs +805 -0
- package/mufl_code/core/tests/mig_actor.mu +518 -0
- package/mufl_code/core/tests/migapp.mjs +431 -0
- package/mufl_code/core/tests/migapp_actor.mu +294 -0
- package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
- package/mufl_code/core/tests/notif.mjs +755 -0
- package/mufl_code/core/tests/notif_actor.mu +450 -0
- package/mufl_code/core/tests/protocol_container.mm +18 -0
- package/mufl_code/core/tests/run.sh +65 -0
- package/mufl_code/core/tests/run_corpus.sh +51 -0
- package/mufl_code/core/tests/run_fatcompile.sh +40 -0
- package/mufl_code/core/tests/run_mig.sh +51 -0
- package/mufl_code/core/tests/run_migapp.sh +51 -0
- package/mufl_code/core/tests/run_notif.sh +57 -0
- package/mufl_code/core/tests/test.mjs +750 -0
- package/mufl_code/core/tests/test_actor.mu +564 -0
- package/mufl_code/core/tests/test_common.mjs +93 -0
- package/mufl_code/core/version.mm +24 -0
- package/mufl_code/packet.mu +170 -0
- package/mufl_code/protocol_container.mm +18 -0
- package/package.json +43 -0
- package/scripts/gen-vapid.mjs +16 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase-A migration store gate + Phase-B adapt staged-rotation gate.
|
|
3
|
+
// A) the three 0.9.0 stores round-trip through export_core_state; a pre-0.9 blob
|
|
4
|
+
// imports to empty (= legacy, spec §5.1) — single packet.
|
|
5
|
+
// B) rotate-once property (spec §5.5): two peers establish a live e2e session,
|
|
6
|
+
// stage a FRESH rotation, commit bilaterally, converge on the new session id,
|
|
7
|
+
// and speak on the new session both directions — while staging never disturbs
|
|
8
|
+
// the live session (unilateral-reset regression, crypto half).
|
|
9
|
+
// Runs mig_actor.mu (loads a2a_messaging + e2e, NOT a2a_notifications, so it stays
|
|
10
|
+
// under the per-unit meta-reduction fuel budget). Run via tests/run_mig.sh.
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import { adapt_wrapper } from '@adapt-toolkit/sdk/executables';
|
|
14
|
+
import { PacketWrapperConfigurator } from '@adapt-toolkit/sdk/wrappers';
|
|
15
|
+
import { object_to_adapt_value } from '@adapt-toolkit/sdk/wrapper';
|
|
16
|
+
|
|
17
|
+
const BROKER_URL = process.env.BROKER_URL || 'ws://127.0.0.1:9790';
|
|
18
|
+
const UNIT_DIR = resolve('.');
|
|
19
|
+
const unitHash = fs.readdirSync(UNIT_DIR).find((f) => f.endsWith('.muflo')).slice(0, -'.muflo'.length);
|
|
20
|
+
const UNIT = new Uint8Array(fs.readFileSync(resolve(UNIT_DIR, `${unitHash}.muflo`)));
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
const log = (...a) => process.stderr.write(`[mig] ${a.join(' ')}\n`);
|
|
23
|
+
const scorecard = [];
|
|
24
|
+
const ok = (c, m) => { if (!c) { scorecard.push(`✗ ${m}`); console.log(` ✗ FAIL: ${m}`); } else { console.log(` ✓ ${m}`); } };
|
|
25
|
+
const T = (s) => /true/i.test(String(s));
|
|
26
|
+
|
|
27
|
+
let wrapper;
|
|
28
|
+
function mk(name) { return { name, pw: null, cid: '', pending: [] }; }
|
|
29
|
+
function wire(id) {
|
|
30
|
+
id.pw.on_return_data = (d) => {
|
|
31
|
+
const kind = d.Reduce('kind').Visualize();
|
|
32
|
+
if (kind === 'notify_agent') { try { const pl=d.Reduce('payload'); const ev=pl.Reduce('event').Visualize(); if(/migration|protocol_error/.test(ev)) process.stderr.write(`[notify ${id.name}] ${ev} reason=${pl.Reduce('reason').Visualize()} cid=${pl.Reduce('cid').Visualize().slice(0,10)}\n`); } catch {} return; }
|
|
33
|
+
if (kind === 'save_state') return;
|
|
34
|
+
const p = id.pending.shift(); if (!p) return;
|
|
35
|
+
clearTimeout(p.timer); p.resolve(d.Reduce('payload'));
|
|
36
|
+
};
|
|
37
|
+
id.pw.on_transaction_failure = (msg) => {
|
|
38
|
+
const p = id.pending.shift();
|
|
39
|
+
if (p) { clearTimeout(p.timer); p.reject(new Error(msg)); }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function mkNode(seed, name) {
|
|
43
|
+
const id = mk(name);
|
|
44
|
+
const cfg = new PacketWrapperConfigurator();
|
|
45
|
+
cfg.process_arguments(['--unit_hash', unitHash, '--seed_phrase', seed, '--unit_dir_path', UNIT_DIR]);
|
|
46
|
+
await new Promise((res, rej) => {
|
|
47
|
+
const t = setTimeout(() => rej(new Error(`${name} create timeout`)), 30000);
|
|
48
|
+
wrapper.packet_manager.create_packet(cfg, (pw) => {
|
|
49
|
+
clearTimeout(t); id.pw = pw; id.cid = pw.packet.GetContainerID().Visualize(); wire(id); res();
|
|
50
|
+
}, UNIT);
|
|
51
|
+
});
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
const mutate = (id, name, targ) => new Promise((res, rej) => {
|
|
55
|
+
const timer = setTimeout(() => rej(new Error(`${id.name}.${name} timed out`)), 20000);
|
|
56
|
+
id.pending.push({ resolve: res, reject: rej, timer });
|
|
57
|
+
id.pw.add_client_message(object_to_adapt_value({ name, targ }));
|
|
58
|
+
});
|
|
59
|
+
const binv = (id, buf) => id.pw.packet.NewBinaryFromBuffer(Buffer.from(buf));
|
|
60
|
+
const ro = (id, name, targ) => id.pw.packet.ExecuteTransaction(object_to_adapt_value({ name, targ }));
|
|
61
|
+
const getBin = (p, k) => { try { return Buffer.from(p.Reduce(k).GetBinary()); } catch { return Buffer.alloc(0); } };
|
|
62
|
+
const hex = (buf) => Buffer.from(buf).toString('hex');
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
wrapper = await adapt_wrapper.start(['--broker_address', BROKER_URL, '--test_mode',
|
|
66
|
+
'--logger_config', '--level', 'WARNING', '--stdout', 'stderr', '--logger_config_end']);
|
|
67
|
+
wrapper.start();
|
|
68
|
+
await sleep(1500);
|
|
69
|
+
const A = await mkNode('mig-gate-A', 'A');
|
|
70
|
+
await sleep(800);
|
|
71
|
+
|
|
72
|
+
console.log('=== mig: export round-trip (3 new 0.9.0 stores travel the blob) ===');
|
|
73
|
+
const r = await mutate(A, '::actor::qa_mig_export_roundtrip', {});
|
|
74
|
+
const g = (k) => r.Reduce(k).Visualize();
|
|
75
|
+
ok(T(g('has_migration')), 'export_core_state carries $contact_migration');
|
|
76
|
+
ok(T(g('has_epoch')), 'export_core_state carries $contact_e2e_epoch');
|
|
77
|
+
ok(T(g('has_deferred')), 'export_core_state carries $mig_deferred');
|
|
78
|
+
ok(g('phase') === 'active', 'exported FSM entry preserves $phase (active)');
|
|
79
|
+
ok(T(g('epoch_sid_present')), 'exported epoch pin preserves $session_id (canonical bytes)');
|
|
80
|
+
ok(g('deferred_len') === '1', 'exported mig_deferred preserves the queued message');
|
|
81
|
+
|
|
82
|
+
console.log('=== mig: pre-0.9 import → migration stores empty (absence = legacy) ===');
|
|
83
|
+
const r2 = await mutate(A, '::actor::qa_mig_import_legacy', {});
|
|
84
|
+
const g2 = (k) => r2.Reduce(k).Visualize();
|
|
85
|
+
ok(T(g2('migration_absent')), 'pre-0.9 blob import: contact_migration stays empty');
|
|
86
|
+
ok(T(g2('epoch_absent')), 'pre-0.9 blob import: contact_e2e_epoch stays empty');
|
|
87
|
+
ok(T(g2('deferred_absent')), 'pre-0.9 blob import: mig_deferred stays empty');
|
|
88
|
+
|
|
89
|
+
// ---- A3: PRE-0.11 / v0 blob (no format_version, no e2e_sessions) ----------
|
|
90
|
+
// The #137 migration gap: replay-after-restore was covered, the OLD-blob
|
|
91
|
+
// upgrade path was not. Without the $e2e_sessions NIL-guard this import
|
|
92
|
+
// raises "SAFE cast to record failed" (meta.mm record-path aborts on NIL —
|
|
93
|
+
// verified identical on 0.10.10 and 0.10.12) and the WHOLE import fails.
|
|
94
|
+
console.log('=== mig: pre-0.11 v0 blob import → succeeds, sessions degrade clean ===');
|
|
95
|
+
try {
|
|
96
|
+
const r3 = await mutate(A, '::actor::qa_mig_import_pre_dr', {});
|
|
97
|
+
const g3 = (k) => r3.Reduce(k).Visualize();
|
|
98
|
+
ok(g3('imported_name') === 'pre-dr', `v0 blob import succeeds and carries my_name (got ${g3('imported_name')})`);
|
|
99
|
+
ok(T(g3('format_stamped')), 're-export is format-stamped after v0 import');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
ok(false, `v0 blob import RAISED (the #137 e2e_sessions guard gap): ${String(err).split('\n')[0]}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log('=== mig: rotate-once (two peers stage a fresh session, commit, converge) ===');
|
|
105
|
+
const B = await mkNode('mig-gate-B', 'B'); await sleep(800);
|
|
106
|
+
const aCid = A.cid, bCid = B.cid;
|
|
107
|
+
const aBundle = getBin(ro(A, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
108
|
+
const bBundle = getBin(ro(B, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
109
|
+
const aIk = getBin(ro(A, '::actor::qa_e2e_ik', {}), 'ik');
|
|
110
|
+
const bIk = getBin(ro(B, '::actor::qa_e2e_ik', {}), 'ik');
|
|
111
|
+
|
|
112
|
+
// 1) first contact: A -> B establishes the LIVE session
|
|
113
|
+
const pt1 = Buffer.from('rotate-once-live-1');
|
|
114
|
+
const env1 = await mutate(A, '::actor::qa_e2e_first_send', { cid: bCid, pt: binv(A, pt1), peer: binv(A, bBundle) });
|
|
115
|
+
const rec1 = await mutate(B, '::actor::qa_e2e_recv', { from: aCid, ik: binv(B, aIk),
|
|
116
|
+
olm_type: +env1.Reduce('olm_type').Visualize(), ciphertext: binv(B, getBin(env1, 'ciphertext')) });
|
|
117
|
+
ok(T(rec1.Reduce('ok').Visualize()) && getBin(rec1, 'plaintext').equals(pt1), 'live session established: B decrypts A\'s first message');
|
|
118
|
+
const liveSidA = getBin(ro(A, '::actor::qa_e2e_active', { cid: bCid }), 'sid');
|
|
119
|
+
const liveSidB = getBin(ro(B, '::actor::qa_e2e_active', { cid: aCid }), 'sid');
|
|
120
|
+
ok(liveSidA.length > 0 && hex(liveSidA) === hex(liveSidB), 'live session_id converges A==B');
|
|
121
|
+
|
|
122
|
+
// 2) A stages a FRESH rotation (live session untouched) and encrypts the commit body on it
|
|
123
|
+
const stagedSid = getBin(await mutate(A, '::actor::qa_e2e_stage_out', { cid: bCid, peer: binv(A, bBundle) }), 'sid');
|
|
124
|
+
ok(hex(stagedSid) !== hex(liveSidA), 'staged session_id differs from live (a FRESH key)');
|
|
125
|
+
const liveSidA2 = getBin(ro(A, '::actor::qa_e2e_active', { cid: bCid }), 'sid');
|
|
126
|
+
ok(hex(liveSidA2) === hex(liveSidA), 'staging does NOT disturb A\'s live session (unilateral-reset regression, send side)');
|
|
127
|
+
const pt2 = Buffer.from('rotate-once-commit-body');
|
|
128
|
+
const envC = await mutate(A, '::actor::qa_e2e_enc_staged', { cid: bCid, pt: binv(A, pt2) });
|
|
129
|
+
|
|
130
|
+
// 3) B stages the inbound rotation from the pre-key; B's live session untouched
|
|
131
|
+
const recC = await mutate(B, '::actor::qa_e2e_stage_in', { from: aCid, ik: binv(B, aIk), ciphertext: binv(B, getBin(envC, 'ciphertext')) });
|
|
132
|
+
ok(T(recC.Reduce('ok').Visualize()) && getBin(recC, 'plaintext').equals(pt2), 'B stages inbound rotation + decrypts the commit body');
|
|
133
|
+
const liveSidB2 = getBin(ro(B, '::actor::qa_e2e_active', { cid: aCid }), 'sid');
|
|
134
|
+
ok(hex(liveSidB2) === hex(liveSidB), 'inbound staging does NOT disturb B\'s live session (unilateral-reset regression, recv side)');
|
|
135
|
+
const stagedSidB = getBin(ro(B, '::actor::qa_e2e_staged', { cid: aCid }), 'sid');
|
|
136
|
+
ok(hex(stagedSidB) === hex(stagedSid), 'B staged session_id == A staged session_id (converged fresh session)');
|
|
137
|
+
|
|
138
|
+
// 4) commit both -> promote
|
|
139
|
+
const cA = await mutate(A, '::actor::qa_e2e_commit', { cid: bCid });
|
|
140
|
+
const cB = await mutate(B, '::actor::qa_e2e_commit', { cid: aCid });
|
|
141
|
+
ok(T(cA.Reduce('committed').Visualize()) && T(cB.Reduce('committed').Visualize()), 'both sides commit the rotation');
|
|
142
|
+
const newSidA = getBin(ro(A, '::actor::qa_e2e_active', { cid: bCid }), 'sid');
|
|
143
|
+
const newSidB = getBin(ro(B, '::actor::qa_e2e_active', { cid: aCid }), 'sid');
|
|
144
|
+
ok(hex(newSidA) === hex(stagedSid) && hex(newSidB) === hex(stagedSid), 'after commit: active session_id == the rotation on BOTH sides');
|
|
145
|
+
const cA2 = await mutate(A, '::actor::qa_e2e_commit', { cid: bCid });
|
|
146
|
+
ok(!T(cA2.Reduce('committed').Visualize()), 'commit is idempotent: a second commit is a no-op (staged slot already cleared)');
|
|
147
|
+
ok(hex(newSidA) !== hex(liveSidA), 'the OLD live session is superseded (new session_id != old)');
|
|
148
|
+
|
|
149
|
+
// 5) new session works BOTH ways: B->A first (confirm direction), then A->B
|
|
150
|
+
const pt4 = Buffer.from('post-rotation-confirm-B-to-A');
|
|
151
|
+
const env4 = await mutate(B, '::actor::qa_e2e_first_send', { cid: aCid, pt: binv(B, pt4), peer: binv(B, aBundle) });
|
|
152
|
+
const rec4 = await mutate(A, '::actor::qa_e2e_recv', { from: bCid, ik: binv(A, bIk),
|
|
153
|
+
olm_type: +env4.Reduce('olm_type').Visualize(), ciphertext: binv(A, getBin(env4, 'ciphertext')) });
|
|
154
|
+
ok(T(rec4.Reduce('ok').Visualize()) && getBin(rec4, 'plaintext').equals(pt4), 'post-rotation: B->A works on the NEW session');
|
|
155
|
+
const pt5 = Buffer.from('post-rotation-A-to-B');
|
|
156
|
+
const env5 = await mutate(A, '::actor::qa_e2e_first_send', { cid: bCid, pt: binv(A, pt5), peer: binv(A, bBundle) });
|
|
157
|
+
const rec5 = await mutate(B, '::actor::qa_e2e_recv', { from: aCid, ik: binv(B, aIk),
|
|
158
|
+
olm_type: +env5.Reduce('olm_type').Visualize(), ciphertext: binv(B, getBin(env5, 'ciphertext')) });
|
|
159
|
+
ok(T(rec5.Reduce('ok').Visualize()) && getBin(rec5, 'plaintext').equals(pt5), 'post-rotation: A->B works on the NEW session (bidirectional)');
|
|
160
|
+
|
|
161
|
+
console.log('=== mig: decode-seam (REAL decrypt_and_commit: install vs stage vs self-heal) ===');
|
|
162
|
+
const C = await mkNode('mig-gate-C', 'C');
|
|
163
|
+
const D = await mkNode('mig-gate-D', 'D'); await sleep(800);
|
|
164
|
+
const cCid = C.cid, dCid = D.cid;
|
|
165
|
+
const dBundle = getBin(ro(D, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
166
|
+
const cIk = getBin(ro(C, '::actor::qa_e2e_ik', {}), 'ik');
|
|
167
|
+
await mutate(D, '::actor::qa_e2e_install_mig_hook', {}); // hook reads the togglable flag (default FALSE)
|
|
168
|
+
|
|
169
|
+
// (i) FIRST-CONTACT through the seam: no live session -> installs directly (0.8.0 preserved)
|
|
170
|
+
const dp1 = Buffer.from('seam-first-contact');
|
|
171
|
+
const denv1 = await mutate(C, '::actor::qa_e2e_first_send', { cid: dCid, pt: binv(C, dp1), peer: binv(C, dBundle) });
|
|
172
|
+
const drec1 = await mutate(D, '::actor::qa_e2e_recv', { from: cCid, ik: binv(D, cIk),
|
|
173
|
+
olm_type: +denv1.Reduce('olm_type').Visualize(), ciphertext: binv(D, getBin(denv1, 'ciphertext')) });
|
|
174
|
+
ok(T(drec1.Reduce('ok').Visualize()) && getBin(drec1, 'plaintext').equals(dp1), 'seam: first-contact PRE_KEY installs + decrypts (no live session)');
|
|
175
|
+
const dLive1 = getBin(ro(D, '::actor::qa_e2e_active', { cid: cCid }), 'sid');
|
|
176
|
+
ok(dLive1.length > 0, 'seam: first-contact installed the live session');
|
|
177
|
+
|
|
178
|
+
// (ii) LIVE SESSION + PRE_KEY + migration PENDING -> STAGE (m_sessions untouched)
|
|
179
|
+
await mutate(D, '::actor::qa_e2e_set_mig_pending', { pending: true });
|
|
180
|
+
const stagedC = getBin(await mutate(C, '::actor::qa_e2e_stage_out', { cid: dCid, peer: binv(C, dBundle) }), 'sid');
|
|
181
|
+
const cenvS = await mutate(C, '::actor::qa_e2e_enc_staged', { cid: dCid, pt: binv(C, Buffer.from('seam-migration-prekey')) });
|
|
182
|
+
const drecS = await mutate(D, '::actor::qa_e2e_recv', { from: cCid, ik: binv(D, cIk),
|
|
183
|
+
olm_type: +cenvS.Reduce('olm_type').Visualize(), ciphertext: binv(D, getBin(cenvS, 'ciphertext')) });
|
|
184
|
+
ok(T(drecS.Reduce('ok').Visualize()), 'seam: migration PRE_KEY on a live session decrypts (staged path)');
|
|
185
|
+
const dLive2 = getBin(ro(D, '::actor::qa_e2e_active', { cid: cCid }), 'sid');
|
|
186
|
+
ok(hex(dLive2) === hex(dLive1), 'seam: STAGE — the real decrypt_and_commit did NOT replace the live session');
|
|
187
|
+
const dStaged2 = getBin(ro(D, '::actor::qa_e2e_staged', { cid: cCid }), 'sid');
|
|
188
|
+
ok(hex(dStaged2) === hex(stagedC), 'seam: STAGE — fresh session parked in the staged slot (== sender staged id)');
|
|
189
|
+
|
|
190
|
+
// (iii) LIVE SESSION + PRE_KEY + NO migration -> SELF-HEAL (replace, 0.8.0 preserved)
|
|
191
|
+
await mutate(D, '::actor::qa_e2e_set_mig_pending', { pending: false });
|
|
192
|
+
const stagedC2 = getBin(await mutate(C, '::actor::qa_e2e_stage_out', { cid: dCid, peer: binv(C, dBundle) }), 'sid');
|
|
193
|
+
const cenvH = await mutate(C, '::actor::qa_e2e_enc_staged', { cid: dCid, pt: binv(C, Buffer.from('seam-self-heal')) });
|
|
194
|
+
const drecH = await mutate(D, '::actor::qa_e2e_recv', { from: cCid, ik: binv(D, cIk),
|
|
195
|
+
olm_type: +cenvH.Reduce('olm_type').Visualize(), ciphertext: binv(D, getBin(cenvH, 'ciphertext')) });
|
|
196
|
+
ok(T(drecH.Reduce('ok').Visualize()), 'seam: self-heal PRE_KEY on a live session decrypts');
|
|
197
|
+
const dLive3 = getBin(ro(D, '::actor::qa_e2e_active', { cid: cCid }), 'sid');
|
|
198
|
+
ok(hex(dLive3) === hex(stagedC2) && hex(dLive3) !== hex(dLive1), 'seam: SELF-HEAL — live session REPLACED (no migration pending → 0.8.0 behavior)');
|
|
199
|
+
|
|
200
|
+
console.log('=== mig: cross-lib atomicity gate (abort after adapt-write + core-write + action) ===');
|
|
201
|
+
const E = await mkNode('mig-gate-E', 'E'); await sleep(600);
|
|
202
|
+
let aborted = false;
|
|
203
|
+
await mutate(E, '::actor::qa_atomicity_abort', { cid: dCid, peer: binv(E, dBundle) }).catch(() => { aborted = true; });
|
|
204
|
+
ok(aborted, 'atomicity: the probe tx aborted as designed (adapt-write + core-write + queued action, then abort)');
|
|
205
|
+
const ck = ro(E, '::actor::qa_atomicity_check', { cid: dCid });
|
|
206
|
+
ok(!T(ck.Reduce('core_present').Visualize()), 'atomicity: CORE-library write (a2a_messaging contact_migration) rolled back on abort');
|
|
207
|
+
ok(!T(ck.Reduce('adapt_present').Visualize()), 'atomicity: ADAPT-library write (e2e staged session) rolled back on abort');
|
|
208
|
+
|
|
209
|
+
console.log('=== mig: fault-injection — self-heal replace whose inner dispatch ABORTS → live unchanged ===');
|
|
210
|
+
const F = await mkNode('mig-gate-F', 'F');
|
|
211
|
+
const G = await mkNode('mig-gate-G', 'G'); await sleep(800);
|
|
212
|
+
const fCid = F.cid, gCid = G.cid;
|
|
213
|
+
const gBundle = getBin(ro(G, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
214
|
+
const fIk = getBin(ro(F, '::actor::qa_e2e_ik', {}), 'ik');
|
|
215
|
+
await mutate(G, '::actor::qa_e2e_install_mig_hook', {}); // default FALSE → self-heal path
|
|
216
|
+
const fenv1 = await mutate(F, '::actor::qa_e2e_first_send', { cid: gCid, pt: binv(F, Buffer.from('fi-live')), peer: binv(F, gBundle) });
|
|
217
|
+
await mutate(G, '::actor::qa_e2e_recv', { from: fCid, ik: binv(G, fIk),
|
|
218
|
+
olm_type: +fenv1.Reduce('olm_type').Visualize(), ciphertext: binv(G, getBin(fenv1, 'ciphertext')) });
|
|
219
|
+
const gLive = getBin(ro(G, '::actor::qa_e2e_active', { cid: fCid }), 'sid');
|
|
220
|
+
ok(gLive.length > 0, 'fault-injection: G established a live session with F');
|
|
221
|
+
await mutate(F, '::actor::qa_e2e_stage_out', { cid: gCid, peer: binv(F, gBundle) });
|
|
222
|
+
const fenvH = await mutate(F, '::actor::qa_e2e_enc_staged', { cid: gCid, pt: binv(F, Buffer.from('fi-selfheal')) });
|
|
223
|
+
let fiAborted = false;
|
|
224
|
+
await mutate(G, '::actor::qa_e2e_recv_abort', { from: fCid, ik: binv(G, fIk),
|
|
225
|
+
olm_type: +fenvH.Reduce('olm_type').Visualize(), ciphertext: binv(G, getBin(fenvH, 'ciphertext')) }).catch(() => { fiAborted = true; });
|
|
226
|
+
ok(fiAborted, 'fault-injection: the self-heal recv tx aborted (inner-dispatch failure modelled)');
|
|
227
|
+
const gLive2 = getBin(ro(G, '::actor::qa_e2e_active', { cid: fCid }), 'sid');
|
|
228
|
+
ok(hex(gLive2) === hex(gLive), 'fault-injection: live session UNCHANGED after the aborted self-heal (atomicity protects the immediate-replace)');
|
|
229
|
+
|
|
230
|
+
console.log('=== mig: Phase-C §5.2 helpers (election + epoch) ===');
|
|
231
|
+
// election: exactly one of the pair initiates (lower cid), both agree
|
|
232
|
+
const aInit = T(ro(A, '::actor::qa_mig_initiator', { peer: bCid }).Reduce('initiator').Visualize());
|
|
233
|
+
const bInit = T(ro(B, '::actor::qa_mig_initiator', { peer: aCid }).Reduce('initiator').Visualize());
|
|
234
|
+
ok(aInit !== bInit, 'mig_initiator: exactly ONE of the pair initiates (deterministic election)');
|
|
235
|
+
ok(aInit === (aCid < bCid), 'mig_initiator: the LOWER cid initiates (lexicographic total order)');
|
|
236
|
+
// epoch: both sides derive the SAME epoch from the SAME cid-ordered inputs
|
|
237
|
+
const lo = aCid < bCid ? aCid : bCid, hi = aCid < bCid ? bCid : aCid;
|
|
238
|
+
const nlo = binv(A, Buffer.from('11111111111111111111111111111111', 'hex'));
|
|
239
|
+
const nhi = binv(A, Buffer.from('22222222222222222222222222222222', 'hex'));
|
|
240
|
+
const flo = binv(A, Buffer.from('33333333333333333333333333333333', 'hex'));
|
|
241
|
+
const fhi = binv(A, Buffer.from('44444444444444444444444444444444', 'hex'));
|
|
242
|
+
const epA = getBin(ro(A, '::actor::qa_mig_epoch', { lo, hi, nlo, nhi, flo, fhi }), 'epoch');
|
|
243
|
+
const nloB = binv(B, Buffer.from('11111111111111111111111111111111', 'hex'));
|
|
244
|
+
const nhiB = binv(B, Buffer.from('22222222222222222222222222222222', 'hex'));
|
|
245
|
+
const floB = binv(B, Buffer.from('33333333333333333333333333333333', 'hex'));
|
|
246
|
+
const fhiB = binv(B, Buffer.from('44444444444444444444444444444444', 'hex'));
|
|
247
|
+
const epB = getBin(ro(B, '::actor::qa_mig_epoch', { lo, hi, nlo: nloB, nhi: nhiB, flo: floB, fhi: fhiB }), 'epoch');
|
|
248
|
+
ok(epA.length === 32 && hex(epA) === hex(epB), 'mig_epoch: both sides derive the SAME 32-byte epoch from identical cid-ordered inputs');
|
|
249
|
+
// input sensitivity: swap a nonce → different epoch (domain/agreement binding)
|
|
250
|
+
const epA2 = getBin(ro(A, '::actor::qa_mig_epoch', { lo, hi, nlo: nhi, nhi: nlo, flo, fhi }), 'epoch');
|
|
251
|
+
ok(hex(epA2) !== hex(epA), 'mig_epoch: swapping the nonces yields a DIFFERENT epoch (agreement-bound)');
|
|
252
|
+
// e2e_bundle_fp is deterministic + 32 bytes
|
|
253
|
+
const fpA = getBin(ro(A, '::actor::qa_mig_bundle_fp', {}), 'fp');
|
|
254
|
+
const fpA2 = getBin(ro(A, '::actor::qa_mig_bundle_fp', {}), 'fp');
|
|
255
|
+
ok(fpA.length === 32 && hex(fpA) === hex(fpA2), 'e2e_bundle_fp: deterministic 32-byte fingerprint of my e2e bundle');
|
|
256
|
+
|
|
257
|
+
console.log('=== mig: FULL handshake offer→ack→commit→confirm→ACTIVE (bilateral rotation + pins) ===');
|
|
258
|
+
const I2 = await mkNode('mig-gate-I2', 'I2');
|
|
259
|
+
const R2 = await mkNode('mig-gate-R2', 'R2'); await sleep(1000);
|
|
260
|
+
// establish mutual contact via the invite flow (so the encrypted_channel exists)
|
|
261
|
+
const inv = await mutate(I2, '::a2a_messaging::generate_invite', { name: 'R2' });
|
|
262
|
+
const invBlob = Buffer.from(inv.Reduce('invite').GetBinary());
|
|
263
|
+
await mutate(R2, '::a2a_messaging::add_contact', { invite: binv(R2, invBlob), name: 'I2' });
|
|
264
|
+
await sleep(6000); // invite redeem round-trips over the broker
|
|
265
|
+
// trigger the OFFER from the LOWER cid → the ack handler proceeds to commit, and the whole
|
|
266
|
+
// offer→ack→commit→confirm handshake runs automatically over the broker to ACTIVE.
|
|
267
|
+
const [loN, hiN] = I2.cid < R2.cid ? [I2, R2] : [R2, I2];
|
|
268
|
+
await mutate(loN, '::actor::qa_mig_trigger_offer', { peer: hiN.cid });
|
|
269
|
+
await sleep(10000); // 4 legs: offer → ack → commit → confirm
|
|
270
|
+
{ const la=getBin(ro(loN,'::actor::qa_e2e_active',{cid:hiN.cid}),'sid'); const ha=getBin(ro(hiN,'::actor::qa_e2e_active',{cid:loN.cid}),'sid');
|
|
271
|
+
const ls=ro(loN,'::actor::qa_mig_state',{cid:hiN.cid}); const hs=ro(hiN,'::actor::qa_mig_state',{cid:loN.cid});
|
|
272
|
+
console.log(` DIAG lo(init): phase=${ls.Reduce('phase').Visualize()} active=${hex(la).slice(0,12)||'NIL'}`);
|
|
273
|
+
console.log(` DIAG hi(resp): phase=${hs.Reduce('phase').Visualize()} active=${hex(ha).slice(0,12)||'NIL'}`); }
|
|
274
|
+
const loSt = ro(loN, '::actor::qa_mig_state', { cid: hiN.cid });
|
|
275
|
+
const hiSt = ro(hiN, '::actor::qa_mig_state', { cid: loN.cid });
|
|
276
|
+
log('HANDSHAKE PHASES: initiator=' + loSt.Reduce('phase').Visualize() + ' responder=' + hiSt.Reduce('phase').Visualize());
|
|
277
|
+
ok(loSt.Reduce('phase').Visualize() === 'active', 'handshake: initiator reaches ACTIVE');
|
|
278
|
+
ok(hiSt.Reduce('phase').Visualize() === 'active', 'handshake: responder reaches ACTIVE');
|
|
279
|
+
ok(T(loSt.Reduce('initiator').Visualize()) && !T(hiSt.Reduce('initiator').Visualize()), 'handshake: election roles correct (lower=initiator, higher=responder)');
|
|
280
|
+
const loEp = getBin(loSt, 'epoch'), hiEp = getBin(hiSt, 'epoch');
|
|
281
|
+
ok(loEp.length === 32 && hex(loEp) === hex(hiEp), 'handshake: both sides on the same 32-byte epoch');
|
|
282
|
+
// both pins set at active (contact_e2e_epoch + contact_e2e_seen)
|
|
283
|
+
const loPin = ro(loN, '::actor::qa_mig_pin', { cid: hiN.cid });
|
|
284
|
+
const hiPin = ro(hiN, '::actor::qa_mig_pin', { cid: loN.cid });
|
|
285
|
+
ok(T(loPin.Reduce('pinned').Visualize()) && T(hiPin.Reduce('pinned').Visualize()), 'handshake: contact_e2e_epoch pinned BOTH sides');
|
|
286
|
+
ok(T(loPin.Reduce('seen').Visualize()) && T(hiPin.Reduce('seen').Visualize()), 'handshake: contact_e2e_seen set BOTH sides');
|
|
287
|
+
ok(hex(getBin(loPin, 'epoch')) === hex(loEp), 'handshake: epoch pin == FSM epoch');
|
|
288
|
+
// the bilateral rotation: both sides share the SAME fresh active session, == the pinned session
|
|
289
|
+
const loAct = getBin(ro(loN, '::actor::qa_e2e_active', { cid: hiN.cid }), 'sid');
|
|
290
|
+
const hiAct = getBin(ro(hiN, '::actor::qa_e2e_active', { cid: loN.cid }), 'sid');
|
|
291
|
+
ok(loAct.length > 0 && hex(loAct) === hex(hiAct), 'handshake: both sides share the SAME active (fresh, rotated) session_id');
|
|
292
|
+
ok(hex(getBin(loPin, 'session_id')) === hex(loAct), 'handshake: epoch pin session_id == active session_id (the exactly-once rotation)');
|
|
293
|
+
// ★ Q1=A test-only demonstration (endorsed by FleetCoordinator + MigrationReview): the rotated
|
|
294
|
+
// session carries APP DATA end-to-end. Post-active, encrypt_to(peer) rides m_sessions[peer] —
|
|
295
|
+
// the MIGRATED session (commit_rotation promoted it in) — which is the SAME call the daemon uses
|
|
296
|
+
// for its app-e2e send (e2e.mm:281,290). Proves double-ratchet app delivery at the core-gate
|
|
297
|
+
// level; the owner's real-node test covers the daemon-delivery-in-logs side. Zero prod surface.
|
|
298
|
+
const hiBundle = getBin(ro(hiN, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
299
|
+
const loIk = getBin(ro(loN, '::actor::qa_e2e_ik', {}), 'ik');
|
|
300
|
+
const appPt = Buffer.from('post-migration app message over the rotated double-ratchet');
|
|
301
|
+
const appEnv = await mutate(loN, '::actor::qa_e2e_first_send', { cid: hiN.cid, pt: binv(loN, appPt), peer: binv(loN, hiBundle) });
|
|
302
|
+
ok(hex(getBin(appEnv, 'session_id')) === hex(loAct), 'app-data(§5.6/A): initiator app send rides the MIGRATED (pinned) session_id (not a stale/old session)');
|
|
303
|
+
const appRec = await mutate(hiN, '::actor::qa_e2e_recv', { from: loN.cid, ik: binv(hiN, loIk), olm_type: +appEnv.Reduce('olm_type').Visualize(), ciphertext: binv(hiN, getBin(appEnv, 'ciphertext')) });
|
|
304
|
+
ok(T(appRec.Reduce('ok').Visualize()) && hex(getBin(appRec, 'plaintext')) === hex(appPt), 'app-data(§5.6/A): responder decrypts the app message on the migrated session (double-ratchet delivers app data e2e)');
|
|
305
|
+
|
|
306
|
+
// ═══ INCREMENT B — the REAL app-e2e RECEIVE path end-to-end (send_message → e2e box →
|
|
307
|
+
// handle_receive_e2e_message → on_message_received), over the MIGRATED (epoch-pinned) session. ═══
|
|
308
|
+
await mutate(hiN, '::actor::qa_recv_reset', {});
|
|
309
|
+
const bMsg = 'increment-B: app text delivered over the migrated e2e session, REAL handlers';
|
|
310
|
+
const bSend = await mutate(loN, '::a2a_messaging::send_message', { contact: hiN.name, text: bMsg });
|
|
311
|
+
ok(bSend.Reduce('route').Visualize() === 'e2e', 'app-e2e(B) send: send_message routed "e2e" (cored over the migrated session as receive_e2e_message_tx, not boxed legacy)');
|
|
312
|
+
await sleep(4000); // the e2e box relays over the broker
|
|
313
|
+
ok(ro(hiN, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === bMsg, 'app-e2e(B) recv: handle_receive_e2e_message decrypted + DELIVERED the plaintext to on_message_received');
|
|
314
|
+
const hiActX = getBin(ro(hiN, '::actor::qa_e2e_active', { cid: loN.cid }), 'sid');
|
|
315
|
+
ok(hex(hiActX) === hex(hiAct) && hex(hiActX) === hex(loAct), 'app-e2e(B) recv: active session(loN) == the migrated session on BOTH sides (non-circular #1867 cross-check)');
|
|
316
|
+
// File analogue: send_file → receive_e2e_file → on_file_received.
|
|
317
|
+
await mutate(hiN, '::actor::qa_recv_reset', {});
|
|
318
|
+
const bFile = Buffer.from('increment-B file bytes riding the migrated e2e session');
|
|
319
|
+
const bfSend = await mutate(loN, '::a2a_messaging::send_file', { contact: hiN.name, filename: 'b.bin', mime: 'application/octet-stream', data: binv(loN, bFile) });
|
|
320
|
+
ok(bfSend.Reduce('route').Visualize() === 'e2e', 'app-e2e(B) send_file: routed "e2e" (cored over the migrated session as receive_e2e_file_tx)');
|
|
321
|
+
await sleep(4000);
|
|
322
|
+
{ const fr = ro(hiN, '::actor::qa_recv_last', {});
|
|
323
|
+
ok(fr.Reduce('filename').Visualize() === 'b.bin' && +fr.Reduce('flen').Visualize() === bFile.length, 'app-e2e(B) recv_file: handle_receive_e2e_file decrypted + delivered the file to on_file_received'); }
|
|
324
|
+
// §5.7 RECEIVE-SIDE DOWNGRADE REFUSAL over the epoch-pinned pair: a LEGACY plaintext from loN is
|
|
325
|
+
// DROPPED by hiN (never delivered) — the receive-direction confidentiality property.
|
|
326
|
+
await mutate(hiN, '::actor::qa_recv_reset', {});
|
|
327
|
+
await mutate(loN, '::actor::qa_send_legacy', { contact: hiN.name, text: 'DOWNGRADE ATTEMPT: legacy plaintext to a migrated peer' });
|
|
328
|
+
await sleep(4000);
|
|
329
|
+
ok(ro(hiN, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === '', 'downgrade-refusal(§5.7): legacy plaintext from an EPOCH-pinned contact is DROPPED (not delivered, no receipt)');
|
|
330
|
+
// Phase D §5.6 flush-on-active: app sends queued during the initiator's commit window (route
|
|
331
|
+
// "migrating" → mig_deferred) flush FIFO on active, preserving per-contact order, queue ends
|
|
332
|
+
// empty. loN is epoch-pinned to hiN (active), so pins-before-flush holds; inject a 3-msg queue
|
|
333
|
+
// then drive the flush (the real drain over flush_mig_deferred_actions).
|
|
334
|
+
await mutate(loN, '::actor::qa_mig_inject_deferred', { cid: hiN.cid });
|
|
335
|
+
ok(ro(loN, '::actor::qa_mig_deferred_ids', { cid: hiN.cid }).Reduce('count').Visualize() === '3', 'flush(§5.6): mig_deferred filled (3 queued app sends)');
|
|
336
|
+
const flushed = await mutate(loN, '::actor::qa_mig_flush', { cid: hiN.cid });
|
|
337
|
+
ok(flushed.Reduce('flushed').Visualize() === '3' && flushed.Reduce('order').Visualize() === 'w0,w1,w2,', 'flush(§5.6): drained FIFO in order (w0,w1,w2) over e2e');
|
|
338
|
+
ok(ro(loN, '::actor::qa_mig_deferred_ids', { cid: hiN.cid }).Reduce('count').Visualize() === '0', 'flush(§5.6): mig_deferred empty after flush (queue drained in one pass)');
|
|
339
|
+
// §5.4 trigger GATE — the criterion-1 boundary (old peers must NEVER get an offer). Tested via
|
|
340
|
+
// the pure predicate mig_should_trigger (no send). ISOLATED cap advertise on loN (does not touch
|
|
341
|
+
// the full-suite test_actor). synthetic peer cids.
|
|
342
|
+
const CAP = 'core.e2e.migrate', synA = 'aa'.repeat(32), synB = 'bb'.repeat(32), synC = 'cc'.repeat(32);
|
|
343
|
+
const fires = (cid) => T(ro(loN, '::actor::qa_mig_should_trigger', { cid }).Reduce('fire').Visualize());
|
|
344
|
+
await mutate(loN, '::actor::qa_learn_peer', { cid: synA, pv: 9, caps: [] });
|
|
345
|
+
ok(!fires(synA), 'trigger(§5.4): NO offer before we advertise cap_e2e_migrate (self-advertise gate, fail-closed)');
|
|
346
|
+
await mutate(loN, '::actor::qa_init_caps', { advertise: [CAP] });
|
|
347
|
+
ok(fires(synA), 'trigger(§5.4): 0.9 peer via pv>=9 → offer fires (pv self-heal leg)');
|
|
348
|
+
await mutate(loN, '::actor::qa_learn_peer', { cid: synB, pv: 8, caps: [CAP] });
|
|
349
|
+
ok(fires(synB), 'trigger(§5.4): 0.9 peer via advertised caps → offer fires (caps leg)');
|
|
350
|
+
await mutate(loN, '::actor::qa_learn_peer', { cid: synC, pv: 8, caps: [] });
|
|
351
|
+
ok(!fires(synC), 'trigger(§5.4): OLD peer (pv<9, no cap) → NO offer (CRITERION 1)');
|
|
352
|
+
ok(!fires(hiN.cid), 'trigger(§5.4): already-migrated contact → NO offer (fires-once / in-flight gate)');
|
|
353
|
+
// §5.8 old/old → ZERO migration traffic: the trigger is fail-closed on BOTH ends. hiN never
|
|
354
|
+
// advertised cap_e2e_migrate (old self), so it stays dormant even toward a 0.9-capable peer;
|
|
355
|
+
// combined with the old-PEER gate above (synC), a genuinely old/old pair generates no offers.
|
|
356
|
+
const firesHi = (cid) => T(ro(hiN, '::actor::qa_mig_should_trigger', { cid }).Reduce('fire').Visualize());
|
|
357
|
+
await mutate(hiN, '::actor::qa_learn_peer', { cid: synA, pv: 9, caps: [CAP] });
|
|
358
|
+
ok(!firesHi(synA), 'trigger(§5.8 old/old): a self that never advertised cap_e2e_migrate stays DORMANT even toward a 0.9 peer (old-self fail-closed) — old/old → zero mig traffic');
|
|
359
|
+
// §5.6 recovery sweep: re-drive a stalled migration + $attempts cap → $migration_stalled. (Runs
|
|
360
|
+
// LAST — qa_mig_force_offered overwrites loN's contact_migration[hiN].) hiN is a registered contact
|
|
361
|
+
// so the re-drive's send does not abort; hiN is active so it no-ops the stray re-offer.
|
|
362
|
+
await mutate(loN, '::actor::qa_mig_force_offered', { cid: hiN.cid });
|
|
363
|
+
const sw1 = await mutate(loN, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
364
|
+
ok(sw1.Reduce('redriven').Visualize() === '1' && sw1.Reduce('stalled').Visualize() === '0', 'sweep(§5.6): re-drives an offered migration (byte-identical retransmit), bumps $attempts');
|
|
365
|
+
await mutate(loN, '::actor::qa_mig_set_attempts', { cid: hiN.cid, n: 30 });
|
|
366
|
+
const sw2 = await mutate(loN, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
367
|
+
ok(sw2.Reduce('stalled').Visualize() === '1' && sw2.Reduce('redriven').Visualize() === '0', 'sweep(§5.6): $attempts cap → $migration_stalled (state kept, no re-drive)');
|
|
368
|
+
// §5.4-5 rotation detector: my published bundle rotated since the snapshot → a byte-identical
|
|
369
|
+
// resend would carry a stale fp under the same nonce → SUPERSEDE (fresh offer/epoch) instead.
|
|
370
|
+
await mutate(loN, '::actor::qa_mig_force_offered', { cid: hiN.cid });
|
|
371
|
+
await mutate(loN, '::actor::qa_mig_corrupt_fp', { cid: hiN.cid });
|
|
372
|
+
const sw3 = await mutate(loN, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
373
|
+
ok(sw3.Reduce('superseded').Visualize() === '1' && sw3.Reduce('redriven').Visualize() === '0', 'sweep(§5.4-5): local bundle-fp rotation → SUPERSEDE (fresh offer/epoch), not stale byte-identical resend');
|
|
374
|
+
// Phase D §5.6: at active (epoch pinned + peer bundle present) the app-data route is E2E-only
|
|
375
|
+
// on BOTH sides — box is now unreachable for this cid's app data (barrier post-commit).
|
|
376
|
+
ok(ro(loN, '::actor::qa_e2e_route', { cid: hiN.cid }).Reduce('route').Visualize() === 'e2e', 'route(§5.6): initiator app-data route == "e2e" at active (box unreachable)');
|
|
377
|
+
ok(ro(hiN, '::actor::qa_e2e_route', { cid: loN.cid }).Reduce('route').Visualize() === 'e2e', 'route(§5.6): responder app-data route == "e2e" at active (box unreachable)');
|
|
378
|
+
// §5.8 downgrade-attempt-after-active: the epoch pin is authoritative over caps — a peer whose
|
|
379
|
+
// caps are stripped (a downgrade attempt) STILL routes e2e; the pins are untouched.
|
|
380
|
+
await mutate(loN, '::actor::qa_learn_peer', { cid: hiN.cid, pv: 9, caps: [] });
|
|
381
|
+
ok(ro(loN, '::actor::qa_e2e_route', { cid: hiN.cid }).Reduce('route').Visualize() === 'e2e', 'downgrade-after-active(§5.8): route STAYS e2e after a caps-strip (epoch pin is authoritative, not caps)');
|
|
382
|
+
{ const dp = ro(loN, '::actor::qa_mig_pin', { cid: hiN.cid });
|
|
383
|
+
ok(T(dp.Reduce('pinned').Visualize()) && hex(getBin(dp, 'session_id')) === hex(loAct), 'downgrade-after-active(§5.8): epoch pin UNTOUCHED by the downgrade attempt (same session_id)'); }
|
|
384
|
+
|
|
385
|
+
// Exhaustive phase handling: a duplicate offer redelivered when the pair is already ACTIVE
|
|
386
|
+
// must be an idempotent NO-OP (not restart the FSM), per §5.6 / MigrationReview C.3 watch-item.
|
|
387
|
+
await mutate(loN, '::actor::qa_mig_resend_offer', { peer: hiN.cid });
|
|
388
|
+
await sleep(4000);
|
|
389
|
+
const hiSt2 = ro(hiN, '::actor::qa_mig_state', { cid: loN.cid });
|
|
390
|
+
ok(hiSt2.Reduce('phase').Visualize() === 'active', 'idempotency: duplicate offer at ACTIVE is a no-op (FSM not restarted)');
|
|
391
|
+
ok(hex(getBin(hiSt2, 'epoch')) === hex(hiEp), 'idempotency: duplicate offer does NOT change the epoch');
|
|
392
|
+
const hiAct2 = getBin(ro(hiN, '::actor::qa_e2e_active', { cid: loN.cid }), 'sid');
|
|
393
|
+
ok(hex(hiAct2) === hex(hiAct), 'idempotency: duplicate offer does NOT disturb the active session');
|
|
394
|
+
|
|
395
|
+
// ═══ INCREMENT B — SEEN-not-epoch send/receive CONSISTENCY (already-E2E pre-migration pair). ═══
|
|
396
|
+
// The SEND gate boxes app as receive_e2e_message_tx for EVERY e2e-capable (seen) contact, not only
|
|
397
|
+
// epoch-pinned/migrated ones. So the RECEIVE accept-gate must accept seen-not-epoch peers too (else
|
|
398
|
+
// an already-E2E pair's app is silently DROPPED). And §5.7 downgrade-refusal stays EPOCH-only: a
|
|
399
|
+
// seen-not-epoch peer's LEGACY plaintext is STILL accepted (seen is advertisement-strength).
|
|
400
|
+
console.log('=== mig: INCREMENT B — seen-not-epoch already-E2E pair (send/receive consistency) ===');
|
|
401
|
+
const E3 = await mkNode('mig-gate-E3', 'E3');
|
|
402
|
+
const E4 = await mkNode('mig-gate-E4', 'E4'); await sleep(1000);
|
|
403
|
+
const einv = await mutate(E3, '::a2a_messaging::generate_invite', { name: 'E4' });
|
|
404
|
+
await mutate(E4, '::a2a_messaging::add_contact', { invite: binv(E4, Buffer.from(einv.Reduce('invite').GetBinary())), name: 'E3' });
|
|
405
|
+
await sleep(6000); // invite redeem round-trips (establishes contacts + peer_ads incl. e2e bundle)
|
|
406
|
+
// Mark BOTH directions e2e-SEEN (cap_e2e) WITHOUT migrating — an already-E2E, non-epoch pair.
|
|
407
|
+
await mutate(E3, '::actor::qa_learn_peer', { cid: E4.cid, pv: 9, caps: ['core.e2e'] });
|
|
408
|
+
await mutate(E4, '::actor::qa_learn_peer', { cid: E3.cid, pv: 9, caps: ['core.e2e'] });
|
|
409
|
+
ok(ro(E3, '::actor::qa_e2e_route', { cid: E4.cid }).Reduce('route').Visualize() === 'e2e', 'seen-not-epoch: app-data route == "e2e" for a seen (no-epoch) contact');
|
|
410
|
+
await mutate(E4, '::actor::qa_recv_reset', {});
|
|
411
|
+
const sMsg = 'seen-not-epoch: app over an already-E2E session, no migration in play';
|
|
412
|
+
const sSend = await mutate(E3, '::a2a_messaging::send_message', { contact: 'E4', text: sMsg });
|
|
413
|
+
ok(sSend.Reduce('route').Visualize() === 'e2e', 'seen-not-epoch: send_message boxed as receive_e2e_message_tx (send-side consistency)');
|
|
414
|
+
await sleep(4000);
|
|
415
|
+
ok(ro(E4, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === sMsg, 'seen-not-epoch: receive ACCEPTS + decrypts + delivers (accept-gate = e2e_pinned/seen, closes the latent drop)');
|
|
416
|
+
// Legacy plaintext from the SAME seen-not-epoch peer is STILL delivered (downgrade-refusal is epoch-only).
|
|
417
|
+
await mutate(E4, '::actor::qa_recv_reset', {});
|
|
418
|
+
await mutate(E3, '::actor::qa_send_legacy', { contact: 'E4', text: 'seen-not-epoch legacy — still delivered' });
|
|
419
|
+
await sleep(4000);
|
|
420
|
+
ok(ro(E4, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === 'seen-not-epoch legacy — still delivered', 'seen-not-epoch: LEGACY plaintext STILL accepted (downgrade-refusal is EPOCH-only, not seen)');
|
|
421
|
+
|
|
422
|
+
// ═══ INCREMENT C — ACCEPT-GATE REJECT: an e2e box from a peer THIS side has not seen (and is not
|
|
423
|
+
// a committed initiator for) is REJECTED before decode — no delivery, no session mutation. ═══
|
|
424
|
+
console.log('=== mig: INCREMENT C — accept-gate reject (non-e2e peer → dropped, never decoded) ===');
|
|
425
|
+
const E5 = await mkNode('mig-gate-E5', 'E5');
|
|
426
|
+
const E6 = await mkNode('mig-gate-E6', 'E6'); await sleep(1000);
|
|
427
|
+
const e5inv = await mutate(E5, '::a2a_messaging::generate_invite', { name: 'E6' });
|
|
428
|
+
await mutate(E6, '::a2a_messaging::add_contact', { invite: binv(E6, Buffer.from(e5inv.Reduce('invite').GetBinary())), name: 'E5' });
|
|
429
|
+
await sleep(6000);
|
|
430
|
+
// ONLY the sender marks the peer seen (so it routes e2e); the RECEIVER does NOT → accept-gate FALSE.
|
|
431
|
+
await mutate(E5, '::actor::qa_learn_peer', { cid: E6.cid, pv: 9, caps: ['core.e2e'] });
|
|
432
|
+
ok(ro(E5, '::actor::qa_e2e_route', { cid: E6.cid }).Reduce('route').Visualize() === 'e2e', 'accept-reject: sender routes e2e (it has seen the peer)');
|
|
433
|
+
await mutate(E6, '::actor::qa_recv_reset', {});
|
|
434
|
+
const rSend = await mutate(E5, '::a2a_messaging::send_message', { contact: 'E6', text: 'must be rejected — receiver has not seen this peer' });
|
|
435
|
+
ok(rSend.Reduce('route').Visualize() === 'e2e', 'accept-reject: sender boxed the app as receive_e2e_message_tx');
|
|
436
|
+
await sleep(4000);
|
|
437
|
+
ok(ro(E6, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === '', 'accept-reject: receiver DROPS the e2e box (not e2e_pinned, not committed → reject BEFORE decode, no delivery/session mutation)');
|
|
438
|
+
|
|
439
|
+
// decode_migration_envelope guard matrix — the point-1 divergence guard (recv_authenticated's
|
|
440
|
+
// S1/S2 is REAL) + decode_migration_envelope binding gates + the forgery-abort vs replay-reject
|
|
441
|
+
// split. P (sender) stages a rotation to M (receiver) and encrypts a known plaintext; M then
|
|
442
|
+
// decodes it (happy) and rejects tampered variants.
|
|
443
|
+
console.log('=== mig: decode_migration_envelope GUARD (divergence + binding + forgery/replay split) ===');
|
|
444
|
+
const P = await mkNode('mig-guard-P', 'P');
|
|
445
|
+
const M = await mkNode('mig-guard-M', 'M'); await sleep(800);
|
|
446
|
+
const pAd = getBin(ro(P, '::actor::qa_produce_ad', {}), 'ad');
|
|
447
|
+
const mBundle = getBin(ro(M, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
448
|
+
await mutate(P, '::actor::qa_e2e_stage_out', { cid: M.cid, peer: binv(P, mBundle) });
|
|
449
|
+
const marker = Buffer.from('guard-roundtrip-marker');
|
|
450
|
+
const enc1 = await mutate(P, '::actor::qa_mig_enc_full', { cid: M.cid, pt: binv(P, marker) });
|
|
451
|
+
const gEnv1 = getBin(enc1, 'env'), emsig1 = getBin(enc1, 'emsig');
|
|
452
|
+
const enc2 = await mutate(P, '::actor::qa_mig_enc_full', { cid: M.cid, pt: binv(P, Buffer.from('guard-second')) });
|
|
453
|
+
const emsig2 = getBin(enc2, 'emsig'); // a VALID emsig over a DIFFERENT envelope (foreign-emsig case)
|
|
454
|
+
const dec = (t) => mutate(M, '::actor::qa_mig_decode', {
|
|
455
|
+
from: t.from ?? P.cid, to: t.to ?? M.cid, ad: binv(M, t.ad ?? pAd),
|
|
456
|
+
env: binv(M, t.env ?? gEnv1), emsig: binv(M, t.emsig ?? emsig1), pv_override: t.pv_override ?? -1 });
|
|
457
|
+
const expectAbort = async (label, targ) => { try { await dec(targ); ok(false, label + ' (expected ABORT, got success)'); } catch { ok(true, label); } };
|
|
458
|
+
// Forgery → ABORT (these never mutate M's session — atomicity), run before the happy establish.
|
|
459
|
+
await expectAbort('guard: bad wire_pv (S2) → abort', { pv_override: 99 });
|
|
460
|
+
await expectAbort('guard: foreign/tampered emsig (S1) → abort', { emsig: emsig2 });
|
|
461
|
+
await expectAbort('guard: wrong $to recipient (S1) → abort', { to: P.cid });
|
|
462
|
+
await expectAbort('guard: AD.cid ≠ box sender (relay re-box, binding) → abort', { from: M.cid });
|
|
463
|
+
// Happy round-trip → establishes M's inbound session + returns the RIGHT plaintext.
|
|
464
|
+
const hap = await dec({});
|
|
465
|
+
ok(T(hap.Reduce('ok').Visualize()) && hex(getBin(hap, 'plaintext')) === hex(marker), 'guard: happy path decodes to the RIGHT plaintext (round-trip)');
|
|
466
|
+
// Replay the SAME pre-key → session_matches → replayed() → !ok (reject, NOT abort): the split.
|
|
467
|
+
const rep = await dec({});
|
|
468
|
+
ok(!T(rep.Reduce('ok').Visualize()) && rep.Reduce('code').Visualize() === 'replayed_handshake', 'guard: replay (valid emsig, replayed ciphertext) → !ok reject NOT abort (forgery/replay split)');
|
|
469
|
+
|
|
470
|
+
// ═══ §5.8 simultaneous upgrades (crossing offers) → deterministic collapse ═══
|
|
471
|
+
// Both peers trigger an offer at (nearly) the same time. The election (LOWER cid initiates)
|
|
472
|
+
// collapses the crossing proposals to exactly ONE migration: the HIGHER-cid peer, on receiving the
|
|
473
|
+
// lower's offer, abandons its own `offered` state and ACKs (GATE 4 collapse); the LOWER-cid peer
|
|
474
|
+
// treats the higher's offer as a SOLICITATION and re-emits its authoritative offer. They converge —
|
|
475
|
+
// lower=initiator, higher=responder, one shared epoch + session — regardless of arrival order.
|
|
476
|
+
// (The offers cross while both are still `offered`, well before any 4-leg handshake completes, so
|
|
477
|
+
// the lower reaches `active` on the lower's own nonce; a higher-cid peer never becomes initiator.)
|
|
478
|
+
console.log('=== mig: §5.8 simultaneous upgrades (crossing offers) → deterministic collapse ===');
|
|
479
|
+
const SU1 = await mkNode('mig-gate-SU1', 'SU1');
|
|
480
|
+
const SU2 = await mkNode('mig-gate-SU2', 'SU2'); await sleep(1000);
|
|
481
|
+
{ const invS = await mutate(SU1, '::a2a_messaging::generate_invite', { name: 'SU2' });
|
|
482
|
+
await mutate(SU2, '::a2a_messaging::add_contact', { invite: binv(SU2, Buffer.from(invS.Reduce('invite').GetBinary())), name: 'SU1' });
|
|
483
|
+
await sleep(6000);
|
|
484
|
+
const [lo, hi] = SU1.cid < SU2.cid ? [SU1, SU2] : [SU2, SU1];
|
|
485
|
+
// BOTH trigger, back-to-back (crossing offers in flight before either handshake can complete).
|
|
486
|
+
await mutate(lo, '::actor::qa_mig_trigger_offer', { peer: hi.cid });
|
|
487
|
+
await mutate(hi, '::actor::qa_mig_trigger_offer', { peer: lo.cid });
|
|
488
|
+
await sleep(20000); // crossing offers + solicitation re-emit + ack → commit → confirm settle
|
|
489
|
+
const ls = ro(lo, '::actor::qa_mig_state', { cid: hi.cid });
|
|
490
|
+
const hs = ro(hi, '::actor::qa_mig_state', { cid: lo.cid });
|
|
491
|
+
console.log(` DIAG collapse: lo phase=${ls.Reduce('phase').Visualize()} init=${ls.Reduce('initiator').Visualize()} / hi phase=${hs.Reduce('phase').Visualize()} init=${hs.Reduce('initiator').Visualize()}`);
|
|
492
|
+
ok(ls.Reduce('phase').Visualize() === 'active' && hs.Reduce('phase').Visualize() === 'active', '★ collapse: BOTH peers converge to active (crossing offers collapsed to ONE migration)');
|
|
493
|
+
ok(T(ls.Reduce('initiator').Visualize()) && !T(hs.Reduce('initiator').Visualize()), '★ collapse: deterministic roles — the LOWER cid initiated, the HIGHER responded (election, not arrival order)');
|
|
494
|
+
const le = getBin(ls, 'epoch'), he = getBin(hs, 'epoch');
|
|
495
|
+
ok(le.length === 32 && hex(le) === hex(he), 'collapse: both settle on ONE shared epoch (no split-brain / no double migration)');
|
|
496
|
+
const la = getBin(ro(lo, '::actor::qa_e2e_active', { cid: hi.cid }), 'sid');
|
|
497
|
+
const ha = getBin(ro(hi, '::actor::qa_e2e_active', { cid: lo.cid }), 'sid');
|
|
498
|
+
ok(la.length > 0 && hex(la) === hex(ha), 'collapse: both share ONE migrated session (exactly-once rotation)');
|
|
499
|
+
const lp = ro(lo, '::actor::qa_mig_pin', { cid: hi.cid }), hp = ro(hi, '::actor::qa_mig_pin', { cid: lo.cid });
|
|
500
|
+
ok(T(lp.Reduce('pinned').Visualize()) && T(hp.Reduce('pinned').Visualize()) && hex(getBin(lp, 'session_id')) === hex(la), 'collapse: both epoch-pinned to the shared migrated session'); }
|
|
501
|
+
|
|
502
|
+
// ═══ §5.8 higher-cid-only evidence → solicitation → converge (lower initiates) ═══
|
|
503
|
+
// Only the HIGHER-cid peer sees the 0.9 evidence first and offers. Its offer functions as a
|
|
504
|
+
// SOLICITATION: the lower-cid peer (the elected initiator) does NOT ack it — it emits its OWN
|
|
505
|
+
// authoritative offer, which the higher acks. Converges lower=initiator; no unelected (higher)
|
|
506
|
+
// proposer can win, even as first mover.
|
|
507
|
+
console.log('=== mig: §5.8 higher-cid-only evidence → solicitation → converge (lower initiates) ===');
|
|
508
|
+
const HO1 = await mkNode('mig-gate-HO1', 'HO1');
|
|
509
|
+
const HO2 = await mkNode('mig-gate-HO2', 'HO2'); await sleep(1000);
|
|
510
|
+
{ const invH = await mutate(HO1, '::a2a_messaging::generate_invite', { name: 'HO2' });
|
|
511
|
+
await mutate(HO2, '::a2a_messaging::add_contact', { invite: binv(HO2, Buffer.from(invH.Reduce('invite').GetBinary())), name: 'HO1' });
|
|
512
|
+
await sleep(6000);
|
|
513
|
+
const [lo, hi] = HO1.cid < HO2.cid ? [HO1, HO2] : [HO2, HO1];
|
|
514
|
+
// ONLY the HIGHER cid triggers (it saw evidence first) — its offer solicits the lower.
|
|
515
|
+
await mutate(hi, '::actor::qa_mig_trigger_offer', { peer: lo.cid });
|
|
516
|
+
await sleep(18000); // solicitation → lower's authoritative offer → ack → commit → confirm
|
|
517
|
+
const ls = ro(lo, '::actor::qa_mig_state', { cid: hi.cid });
|
|
518
|
+
const hs = ro(hi, '::actor::qa_mig_state', { cid: lo.cid });
|
|
519
|
+
console.log(` DIAG solicit: lo phase=${ls.Reduce('phase').Visualize()} init=${ls.Reduce('initiator').Visualize()} / hi phase=${hs.Reduce('phase').Visualize()} init=${hs.Reduce('initiator').Visualize()}`);
|
|
520
|
+
ok(ls.Reduce('phase').Visualize() === 'active' && hs.Reduce('phase').Visualize() === 'active', '★ solicitation: a higher-cid-only offer still drives BOTH to active (the lower emitted the authoritative offer)');
|
|
521
|
+
ok(T(ls.Reduce('initiator').Visualize()) && !T(hs.Reduce('initiator').Visualize()), '★ solicitation: the LOWER cid initiated despite the HIGHER offering FIRST (election beats first-mover)');
|
|
522
|
+
const le = getBin(ls, 'epoch'), he = getBin(hs, 'epoch');
|
|
523
|
+
ok(le.length === 32 && hex(le) === hex(he), 'solicitation: both settle on the same epoch');
|
|
524
|
+
const la = getBin(ro(lo, '::actor::qa_e2e_active', { cid: hi.cid }), 'sid');
|
|
525
|
+
const ha = getBin(ro(hi, '::actor::qa_e2e_active', { cid: lo.cid }), 'sid');
|
|
526
|
+
ok(la.length > 0 && hex(la) === hex(ha), 'solicitation: both share ONE migrated session'); }
|
|
527
|
+
|
|
528
|
+
// ═══ §5.8 already-E2E pair → FULL-FSM handshake rotates ONCE (finding b) ═══
|
|
529
|
+
// The C.3 handshake proves box-only pairs (no prior session). This proves a pair that ALREADY has a
|
|
530
|
+
// LIVE e2e session runs the WHOLE migration FSM (offer→ack→commit→confirm) through the REAL handlers
|
|
531
|
+
// and rotates EXACTLY ONCE onto a fresh migrated session that REPLACES the live one — the live
|
|
532
|
+
// session is preserved (staged-not-installed) until commit_rotation promotes the rotation.
|
|
533
|
+
console.log('=== mig: §5.8 already-E2E pair → FULL-FSM handshake rotates once (finding b) ===');
|
|
534
|
+
const AE1 = await mkNode('mig-gate-AE1', 'AE1');
|
|
535
|
+
const AE2 = await mkNode('mig-gate-AE2', 'AE2'); await sleep(1000);
|
|
536
|
+
{ const invA = await mutate(AE1, '::a2a_messaging::generate_invite', { name: 'AE2' });
|
|
537
|
+
await mutate(AE2, '::a2a_messaging::add_contact', { invite: binv(AE2, Buffer.from(invA.Reduce('invite').GetBinary())), name: 'AE1' });
|
|
538
|
+
await sleep(6000);
|
|
539
|
+
const [lo, hi] = AE1.cid < AE2.cid ? [AE1, AE2] : [AE2, AE1];
|
|
540
|
+
// Establish a PRE-MIGRATION live e2e session lo→hi (the "already-E2E" precondition).
|
|
541
|
+
const hiBundle = getBin(ro(hi, '::actor::qa_e2e_bundle', {}), 'bundle');
|
|
542
|
+
const loIk = getBin(ro(lo, '::actor::qa_e2e_ik', {}), 'ik');
|
|
543
|
+
const lenv = await mutate(lo, '::actor::qa_e2e_first_send', { cid: hi.cid, pt: binv(lo, Buffer.from('pre-migration live traffic')), peer: binv(lo, hiBundle) });
|
|
544
|
+
await mutate(hi, '::actor::qa_e2e_recv', { from: lo.cid, ik: binv(hi, loIk), olm_type: +lenv.Reduce('olm_type').Visualize(), ciphertext: binv(hi, getBin(lenv, 'ciphertext')) });
|
|
545
|
+
const liveLo = getBin(ro(lo, '::actor::qa_e2e_active', { cid: hi.cid }), 'sid');
|
|
546
|
+
const liveHi = getBin(ro(hi, '::actor::qa_e2e_active', { cid: lo.cid }), 'sid');
|
|
547
|
+
ok(liveLo.length > 0 && hex(liveLo) === hex(liveHi), 'already-E2E setup: both peers share a LIVE pre-migration session');
|
|
548
|
+
// Now run the FULL migration FSM through the real handlers.
|
|
549
|
+
await mutate(lo, '::actor::qa_mig_trigger_offer', { peer: hi.cid });
|
|
550
|
+
await sleep(12000); // offer → ack → commit → confirm
|
|
551
|
+
const ls = ro(lo, '::actor::qa_mig_state', { cid: hi.cid });
|
|
552
|
+
const hs = ro(hi, '::actor::qa_mig_state', { cid: lo.cid });
|
|
553
|
+
console.log(` DIAG already-E2E: lo phase=${ls.Reduce('phase').Visualize()} / hi phase=${hs.Reduce('phase').Visualize()}`);
|
|
554
|
+
ok(ls.Reduce('phase').Visualize() === 'active' && hs.Reduce('phase').Visualize() === 'active', '★ already-E2E: the full FSM reaches active on an ALREADY-live pair (offer→ack→commit→confirm through real handlers)');
|
|
555
|
+
const migLo = getBin(ro(lo, '::actor::qa_e2e_active', { cid: hi.cid }), 'sid');
|
|
556
|
+
const migHi = getBin(ro(hi, '::actor::qa_e2e_active', { cid: lo.cid }), 'sid');
|
|
557
|
+
ok(migLo.length > 0 && hex(migLo) === hex(migHi), 'already-E2E: both converge on ONE fresh migrated session');
|
|
558
|
+
ok(hex(migLo) !== hex(liveLo), '★ already-E2E: rotated ONCE — the migrated session REPLACED the pre-migration live session (distinct session_id, both peers)');
|
|
559
|
+
const lp = ro(lo, '::actor::qa_mig_pin', { cid: hi.cid }), hp = ro(hi, '::actor::qa_mig_pin', { cid: lo.cid });
|
|
560
|
+
ok(T(lp.Reduce('pinned').Visualize()) && T(hp.Reduce('pinned').Visualize()) && hex(getBin(lp, 'session_id')) === hex(migLo), 'already-E2E: both epoch-pinned to the migrated (not the old live) session'); }
|
|
561
|
+
|
|
562
|
+
// ═══ §5.8 late/replayed HIGHER-cid offer at an ACTIVE lower node → NO-OP (solicitation phase-guard) ═══
|
|
563
|
+
// A completed migration must not be restarted by a stale higher-cid offer. Before the guard, the
|
|
564
|
+
// GATE4 solicitation path re-emitted `offered` unconditionally — diverging contact_migration from the
|
|
565
|
+
// still-set epoch pin. The guard NO-OPs a higher-cid solicitation once the lower node is committed/
|
|
566
|
+
// active (MigrationReview: exhaustive-phase invariant; only a genuinely new nonce/epoch drives §5.6).
|
|
567
|
+
console.log('=== mig: §5.8 late higher-cid offer at an ACTIVE lower node → NO-OP (solicitation phase-guard) ===');
|
|
568
|
+
const LG1 = await mkNode('mig-gate-LG1', 'LG1');
|
|
569
|
+
const LG2 = await mkNode('mig-gate-LG2', 'LG2'); await sleep(1000);
|
|
570
|
+
{ const invL = await mutate(LG1, '::a2a_messaging::generate_invite', { name: 'LG2' });
|
|
571
|
+
await mutate(LG2, '::a2a_messaging::add_contact', { invite: binv(LG2, Buffer.from(invL.Reduce('invite').GetBinary())), name: 'LG1' });
|
|
572
|
+
await sleep(6000);
|
|
573
|
+
const [lo, hi] = LG1.cid < LG2.cid ? [LG1, LG2] : [LG2, LG1];
|
|
574
|
+
await mutate(lo, '::actor::qa_mig_trigger_offer', { peer: hi.cid });
|
|
575
|
+
await sleep(12000); // offer → ack → commit → confirm → active
|
|
576
|
+
ok(ro(lo, '::actor::qa_mig_state', { cid: hi.cid }).Reduce('phase').Visualize() === 'active', 'setup: the lower node migrated to active');
|
|
577
|
+
const pin0 = getBin(ro(lo, '::actor::qa_mig_pin', { cid: hi.cid }), 'session_id');
|
|
578
|
+
const act0 = getBin(ro(lo, '::actor::qa_e2e_active', { cid: hi.cid }), 'sid');
|
|
579
|
+
// Inject a LATE / stale HIGHER-cid offer at the now-active lower node.
|
|
580
|
+
await mutate(hi, '::actor::qa_mig_trigger_offer', { peer: lo.cid });
|
|
581
|
+
await sleep(5000);
|
|
582
|
+
ok(ro(lo, '::actor::qa_mig_state', { cid: hi.cid }).Reduce('phase').Visualize() === 'active', '★ solicitation-guard: the active lower node NO-OPs the late higher-cid offer (stays active — NOT reset to offered)');
|
|
583
|
+
ok(hex(getBin(ro(lo, '::actor::qa_mig_pin', { cid: hi.cid }), 'session_id')) === hex(pin0) && pin0.length > 0, 'solicitation-guard: epoch pin unchanged (contact_migration and the pin stay consistent)');
|
|
584
|
+
ok(hex(getBin(ro(lo, '::actor::qa_e2e_active', { cid: hi.cid }), 'sid')) === hex(act0), 'solicitation-guard: active session unchanged (no rotation restarted by the stale offer)'); }
|
|
585
|
+
|
|
586
|
+
// ═══ advertise_migrate — mid-session cap-add: enables migration WITHOUT a restart (production tx) ═══
|
|
587
|
+
// A restart to add the migrate cap re-keys the Olm ratchet (loses the pre-migration e2e session, so the
|
|
588
|
+
// migration can't rotate it). This tx appends cap_e2e_migrate to self_caps in place — the next outbound
|
|
589
|
+
// message's self_cap_ids piggyback carries it, the peer re-learns, and mig_should_trigger fires, with
|
|
590
|
+
// the live session intact. It's what the OWNER-TEST-GUIDE staged flow uses to turn migration on for the
|
|
591
|
+
// rotation baseline, and a genuine production capability (enable migration mid-session).
|
|
592
|
+
console.log('=== mig: advertise_migrate — mid-session cap-add enables migration (no restart) ===');
|
|
593
|
+
const AM = await mkNode('mig-gate-AM', 'AM'); await sleep(500);
|
|
594
|
+
{ const r1 = await mutate(AM, '::a2a_messaging::advertise_migrate', {});
|
|
595
|
+
ok(!T(r1.Reduce('was_advertising').Visualize()) && T(r1.Reduce('advertising').Visualize()), '★ advertise_migrate: was NOT advertising cap_e2e_migrate → now IS (mid-session self_caps append; the next msg re-learns on the peer → migration can trigger, live session preserved)');
|
|
596
|
+
const r2 = await mutate(AM, '::a2a_messaging::advertise_migrate', {});
|
|
597
|
+
ok(T(r2.Reduce('was_advertising').Visualize()) && T(r2.Reduce('advertising').Visualize()), 'advertise_migrate: idempotent (already advertising → no duplicate self_caps entry)'); }
|
|
598
|
+
|
|
599
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
600
|
+
// ★ THE PRODUCTION-GAP FIX: an already-e2e-pinned pair auto-migrates via the REAL paths (NOT the manual
|
|
601
|
+
// qa_mig_trigger_offer). mig_trigger_actions used to have ONE call site — the PLAINTEXT receive handler —
|
|
602
|
+
// so a seen-pinned pair (caps learned at invite, zero plaintext app traffic) NEVER auto-triggered. These
|
|
603
|
+
// rows exercise the four additive trigger paths end-to-end over the broker: B (advertise_migrate), C (the
|
|
604
|
+
// sweep as the idle reconciler); D (inbound e2e) lives in migapp.mjs (needs the live-session harness).
|
|
605
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
606
|
+
|
|
607
|
+
// ── (B) NATURAL trigger via advertise_migrate → proactive offer to the eligible LEGACY e2e contact → ACTIVE.
|
|
608
|
+
// core 0.10 (B2): a pair established through a real invite handshake presents a v2 bundle-carrying AD →
|
|
609
|
+
// core marks it BORN-DR → migration is reserved STRICTLY for pre-existing legacy sessions, so a born-DR
|
|
610
|
+
// pair MUST NOT get a proactive offer. This row asserts BOTH: (i) born-DR no-op (advertise offers 0), and
|
|
611
|
+
// (ii) after we demote the pair to LEGACY (clear the born-DR flag — as if it had registered from a v1 AD),
|
|
612
|
+
// advertise_migrate DOES proactively offer it and drives BOTH sides to ACTIVE + epoch-pin.
|
|
613
|
+
console.log('=== mig: ★ NATURAL trigger (B) — advertise_migrate proactively offers an eligible LEGACY e2e contact → ACTIVE; born-DR pair is a no-op (B2) ===');
|
|
614
|
+
{ const NI = await mkNode('mig-gate-NI', 'NI');
|
|
615
|
+
const NR = await mkNode('mig-gate-NR', 'NR'); await sleep(1000);
|
|
616
|
+
const inv = await mutate(NI, '::a2a_messaging::generate_invite', { name: 'NR' });
|
|
617
|
+
await mutate(NR, '::a2a_messaging::add_contact', { invite: binv(NR, Buffer.from(inv.Reduce('invite').GetBinary())), name: 'NI' });
|
|
618
|
+
await sleep(6000);
|
|
619
|
+
const [lo, hi] = NI.cid < NR.cid ? [NI, NR] : [NR, NI]; // lower cid = elected initiator (deterministic)
|
|
620
|
+
// The already-e2e pair: the peer is KNOWN-0.9 to the initiator (its caps advertise cap_e2e_migrate),
|
|
621
|
+
// but NO plaintext app traffic ever flows — so the legacy plaintext trigger can never fire.
|
|
622
|
+
await mutate(lo, '::actor::qa_learn_peer', { cid: hi.cid, pv: 9, caps: [CAP] });
|
|
623
|
+
ok(!T(ro(lo, '::actor::qa_mig_state', { cid: hi.cid }).Reduce('present').Visualize()), 'B-path setup: no migration exists before advertise_migrate (the gap: this pair never auto-triggered)');
|
|
624
|
+
// (B2 INVARIANT) the handshake made this pair BORN-DR → advertise_migrate must offer NOTHING.
|
|
625
|
+
ok(T(ro(lo, '::actor::qa_mig_born_dr', { cid: hi.cid }).Reduce('born_dr').Visualize()), 'B-path(B2): the invite handshake marked the pair BORN-DR (v2 AD at first contact)');
|
|
626
|
+
const amrBorn = await mutate(lo, '::a2a_messaging::advertise_migrate', {}); // enable the cap while still born-DR
|
|
627
|
+
ok(T(amrBorn.Reduce('advertising').Visualize()) && amrBorn.Reduce('offers_initiated').Visualize() === '0', '★ B-path(B2): advertise_migrate enabled the cap but proactively offered NOTHING to the born-DR pair (migration reserved for legacy)');
|
|
628
|
+
ok(!T(ro(lo, '::actor::qa_mig_state', { cid: hi.cid }).Reduce('present').Visualize()), 'B-path(B2): no migration was created for the born-DR contact');
|
|
629
|
+
// Demote to a genuinely PRE-EXISTING LEGACY session (v1-AD-era contact), then re-run the proactive trigger.
|
|
630
|
+
await mutate(lo, '::actor::qa_mig_clear_born_dr', { cid: hi.cid });
|
|
631
|
+
const amr = await mutate(lo, '::a2a_messaging::advertise_migrate', {}); // idempotent cap-add; re-scans → now offers the legacy pair
|
|
632
|
+
ok(T(amr.Reduce('advertising').Visualize()) && amr.Reduce('offers_initiated').Visualize() === '1', '★ B-path: advertise_migrate proactively offered the 1 eligible LEGACY e2e contact (mig_offer_eligible_actions)');
|
|
633
|
+
await sleep(10000); // offer → ack → commit → confirm over the broker
|
|
634
|
+
const lSt = ro(lo, '::actor::qa_mig_state', { cid: hi.cid }), hSt = ro(hi, '::actor::qa_mig_state', { cid: lo.cid });
|
|
635
|
+
console.log(` DIAG B init=${lSt.Reduce('phase').Visualize()} resp=${hSt.Reduce('phase').Visualize()}`);
|
|
636
|
+
ok(lSt.Reduce('phase').Visualize() === 'active', '★ B-path: advertise_migrate proactive offer drove the initiator to ACTIVE (legacy e2e pair, no manual trigger)');
|
|
637
|
+
ok(hSt.Reduce('phase').Visualize() === 'active', '★ B-path: responder reached ACTIVE (real advertise/receive handshake, not qa_mig_trigger_offer)');
|
|
638
|
+
ok(T(ro(lo, '::actor::qa_mig_pin', { cid: hi.cid }).Reduce('pinned').Visualize()) && T(ro(hi, '::actor::qa_mig_pin', { cid: lo.cid }).Reduce('pinned').Visualize()), 'B-path: contact_e2e_epoch pinned BOTH sides (the rotation landed)'); }
|
|
639
|
+
|
|
640
|
+
// ── (C) NATURAL trigger via the SWEEP as the idle reconciler: default-cap boot + a pre-existing eligible
|
|
641
|
+
// e2e contact + NO inbound traffic → the sweep INITIATES the migration (MR2 ruling). + dormancy + idempotency.
|
|
642
|
+
console.log('=== mig: ★ NATURAL trigger (C) — sweep_e2e_migrations INITIATES for an idle eligible LEGACY contact → ACTIVE; born-DR pair is a no-op (B2) ===');
|
|
643
|
+
{ const CS = await mkNode('mig-gate-CS', 'CS');
|
|
644
|
+
const CR = await mkNode('mig-gate-CR', 'CR'); await sleep(1000);
|
|
645
|
+
const inv = await mutate(CS, '::a2a_messaging::generate_invite', { name: 'CR' });
|
|
646
|
+
await mutate(CR, '::a2a_messaging::add_contact', { invite: binv(CR, Buffer.from(inv.Reduce('invite').GetBinary())), name: 'CS' });
|
|
647
|
+
await sleep(6000);
|
|
648
|
+
const [lo, hi] = CS.cid < CR.cid ? [CS, CR] : [CR, CS];
|
|
649
|
+
await mutate(lo, '::actor::qa_learn_peer', { cid: hi.cid, pv: 9, caps: [CAP] });
|
|
650
|
+
// DORMANCY: before the cap is advertised, the sweep INITIATES NOTHING (self-advertise fail-closed gate).
|
|
651
|
+
const sweep0 = await mutate(lo, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
652
|
+
ok(sweep0.Reduce('initiated').Visualize() === '0' && !T(ro(lo, '::actor::qa_mig_state', { cid: hi.cid }).Reduce('present').Visualize()), 'C-path DORMANCY: sweep initiates nothing before cap_e2e_migrate is advertised (pre-Phase-F it is inert) — no migration created');
|
|
653
|
+
await mutate(lo, '::actor::qa_init_caps', { advertise: [CAP] }); // default-cap boot state (no restart-offer)
|
|
654
|
+
// (B2 INVARIANT) the invite handshake made this pair BORN-DR → even with the cap advertised the sweep is a no-op.
|
|
655
|
+
ok(T(ro(lo, '::actor::qa_mig_born_dr', { cid: hi.cid }).Reduce('born_dr').Visualize()), 'C-path(B2): the invite handshake marked the pair BORN-DR (v2 AD at first contact)');
|
|
656
|
+
const sweepBorn = await mutate(lo, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
657
|
+
ok(sweepBorn.Reduce('initiated').Visualize() === '0' && !T(ro(lo, '::actor::qa_mig_state', { cid: hi.cid }).Reduce('present').Visualize()), '★ C-path(B2): sweep INITIATES NOTHING for the born-DR pair even with the cap advertised (migration reserved for legacy)');
|
|
658
|
+
// Demote to a genuinely PRE-EXISTING LEGACY session (v1-AD-era contact), then re-run the sweep reconciler.
|
|
659
|
+
await mutate(lo, '::actor::qa_mig_clear_born_dr', { cid: hi.cid });
|
|
660
|
+
const sweep1 = await mutate(lo, '::a2a_messaging::sweep_e2e_migrations', {}); // THE natural trigger
|
|
661
|
+
ok(sweep1.Reduce('initiated').Visualize() === '1', '★ C-path: sweep INITIATED 1 migration for the idle eligible pre-existing LEGACY e2e contact (proactive offer — the default-cap-boot reconciler)');
|
|
662
|
+
await sleep(10000);
|
|
663
|
+
const lSt = ro(lo, '::actor::qa_mig_state', { cid: hi.cid }), hSt = ro(hi, '::actor::qa_mig_state', { cid: lo.cid });
|
|
664
|
+
console.log(` DIAG C init=${lSt.Reduce('phase').Visualize()} resp=${hSt.Reduce('phase').Visualize()}`);
|
|
665
|
+
ok(lSt.Reduce('phase').Visualize() === 'active', '★ C-path: sweep-initiated migration drove the initiator to ACTIVE (idle pre-existing e2e pair, NO inbound traffic)');
|
|
666
|
+
ok(hSt.Reduce('phase').Visualize() === 'active', '★ C-path: responder reached ACTIVE (sweep is the eventually-consistent every-eligible-pair reconciler)');
|
|
667
|
+
// IDEMPOTENCY: a second sweep does NOT re-offer an already-migrated (active) contact (contact_migration!=NIL gate).
|
|
668
|
+
const epoch0 = hex(getBin(lSt, 'epoch'));
|
|
669
|
+
const sweep2 = await mutate(lo, '::a2a_messaging::sweep_e2e_migrations', {});
|
|
670
|
+
const lSt2 = ro(lo, '::actor::qa_mig_state', { cid: hi.cid });
|
|
671
|
+
ok(sweep2.Reduce('initiated').Visualize() === '0' && lSt2.Reduce('phase').Visualize() === 'active' && hex(getBin(lSt2, 'epoch')) === epoch0, '★ C-path IDEMPOTENCY: a second sweep does NOT re-offer the ACTIVE contact (phase + epoch unchanged; the never-cleared contact_migration gate)'); }
|
|
672
|
+
|
|
673
|
+
// ═══ 2b GENERIC CAPABILITY RE-ADVERTISEMENT — persisted per-contact ACK state ═══
|
|
674
|
+
console.log('=== mig: 2b generic capability advertise/ack reconciliation ===');
|
|
675
|
+
{
|
|
676
|
+
let CA = await mkNode('mig-adv-A', 'CAA');
|
|
677
|
+
const CB = await mkNode('mig-adv-B', 'CBB'); await sleep(1000);
|
|
678
|
+
const cinv = await mutate(CA, '::a2a_messaging::generate_invite', { name: 'CBB' });
|
|
679
|
+
await mutate(CB, '::a2a_messaging::add_contact', { invite: binv(CB, Buffer.from(cinv.Reduce('invite').GetBinary())), name: 'CAA' });
|
|
680
|
+
await sleep(6000); // invite redeem round-trips (CA gains a contact)
|
|
681
|
+
|
|
682
|
+
// Stable-era baseline: both packets are already connected and ACK the old cap
|
|
683
|
+
// lists. Persist A, destroy the live packet, and restore the same identity.
|
|
684
|
+
await mutate(CA, '::actor::qa_init_caps', { advertise: ['core.e2e'] });
|
|
685
|
+
await mutate(CB, '::actor::qa_init_caps', { advertise: ['core.e2e', 'core.receipts.receive'] });
|
|
686
|
+
await mutate(CA, '::a2a_messaging::reconcile_advertise', {});
|
|
687
|
+
await mutate(CB, '::a2a_messaging::reconcile_advertise', {});
|
|
688
|
+
await sleep(2500);
|
|
689
|
+
const persistedA = Buffer.from(ro(CA, '::actor::export_state', {}).Serialize());
|
|
690
|
+
wrapper.packet_manager.remove_packet(CA.cid);
|
|
691
|
+
await sleep(300);
|
|
692
|
+
CA = await mkNode('mig-adv-A', 'CAA');
|
|
693
|
+
await mutate(CA, '::actor::import_state', CA.pw.packet.ParseValue(new Uint8Array(persistedA)));
|
|
694
|
+
await mutate(CA, '::a2a_messaging::commit_e2e_restore', {});
|
|
695
|
+
// "Upgraded code" defines A+notifications+receipts at one-time init.
|
|
696
|
+
await mutate(CA, '::actor::qa_init_caps', { advertise: ['core.e2e', 'core.notifications', 'core.receipts.emit'] });
|
|
697
|
+
|
|
698
|
+
// First reconcile sends a generic capability snapshot. The receiver replaces its
|
|
699
|
+
// cached list, ACKs the fingerprint, and the sender persists that per-contact ACK.
|
|
700
|
+
const r1 = await mutate(CA, '::a2a_messaging::reconcile_advertise', {});
|
|
701
|
+
ok(T(r1.Reduce('changed').Visualize()), '2b: persisted global fingerprint notices the code-defined capability list');
|
|
702
|
+
ok(+r1.Reduce('capability_advertised').Visualize() === 1, '2b: first reconcile sends one generic capability snapshot');
|
|
703
|
+
await sleep(2500);
|
|
704
|
+
const learned1 = ro(CB, '::actor::qa_cap_state', { cid: CA.cid });
|
|
705
|
+
ok(learned1.Reduce('caps').Visualize().includes('core.receipts.emit'), '2b: existing peer learns newly-added receipts capability without app traffic');
|
|
706
|
+
ok(learned1.Reduce('caps').Visualize().includes('core.notifications'), '2b: existing peer learns newly-added notifications capability without app traffic');
|
|
707
|
+
ok(T(ro(CA, '::actor::qa_receipt_gate', { cid: CB.cid }).Reduce('enabled').Visualize()), '2b: restored upgraded sender has its receipt gate enabled from reconciled peer capabilities');
|
|
708
|
+
const acked1 = ro(CA, '::actor::qa_cap_state', { cid: CB.cid });
|
|
709
|
+
ok(acked1.Reduce('confirmed').Visualize() === acked1.Reduce('current').Visualize(), '2b: ACK persists the peer-confirmed current fingerprint');
|
|
710
|
+
ok(T(acked1.Reduce('confirmed_persisted').Visualize()), '2b: per-contact ACK fingerprint is present in export_core_state');
|
|
711
|
+
|
|
712
|
+
// ACK-confirmed peers are not spammed on the next boot/cadence reconciliation.
|
|
713
|
+
const r2 = await mutate(CA, '::a2a_messaging::reconcile_advertise', {});
|
|
714
|
+
ok(!T(r2.Reduce('changed').Visualize()), '2b: second reconcile is idempotent (unchanged pv+caps → not changed)');
|
|
715
|
+
ok(+r2.Reduce('capability_advertised').Visualize() === 0, '2b: ACK-confirmed contact is not re-spammed');
|
|
716
|
+
|
|
717
|
+
// Model a lost ACK/offline attempt by removing only the confirmed ledger entry:
|
|
718
|
+
// global previous-list state is still unchanged, but the per-contact retry must fire.
|
|
719
|
+
await mutate(CA, '::actor::qa_clear_cap_ack', { cid: CB.cid });
|
|
720
|
+
const retry = await mutate(CA, '::a2a_messaging::reconcile_advertise', {});
|
|
721
|
+
ok(!T(retry.Reduce('changed').Visualize()) && +retry.Reduce('capability_advertised').Visualize() === 1, '2b: missing per-contact ACK retries even when global fingerprint is unchanged');
|
|
722
|
+
await sleep(2500);
|
|
723
|
+
const retryDone = await mutate(CA, '::a2a_messaging::reconcile_advertise', {});
|
|
724
|
+
ok(+retryDone.Reduce('capability_advertised').Visualize() === 0, '2b: retry converges after ACK and does not re-spam');
|
|
725
|
+
|
|
726
|
+
// Removal is an exact replacement, not the old non-empty-only merge behavior.
|
|
727
|
+
// The monotonic E2E pin must survive even if a later snapshot removes core.e2e.
|
|
728
|
+
await mutate(CA, '::actor::qa_init_caps', { advertise: ['core.notifications'] });
|
|
729
|
+
const r3 = await mutate(CA, '::a2a_messaging::reconcile_advertise', {});
|
|
730
|
+
ok(T(r3.Reduce('changed').Visualize()) && +r3.Reduce('capability_advertised').Visualize() === 1, '2b: changed capability set re-advertises exactly once');
|
|
731
|
+
await sleep(2500);
|
|
732
|
+
const learned2 = ro(CB, '::actor::qa_cap_state', { cid: CA.cid });
|
|
733
|
+
const caps2 = learned2.Reduce('caps').Visualize();
|
|
734
|
+
ok(caps2.includes('core.notifications') && !caps2.includes('core.receipts.emit') && !caps2.includes('core.e2e'), '2b: receiver exact-replaces additions and removals');
|
|
735
|
+
ok(T(learned2.Reduce('pinned').Visualize()), '2b: capability removal cannot clear the monotonic E2E downgrade pin');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ═══ RELOAD PORT (design §3 acceptance) — page-reload DR message-loss, now GREEN ═══
|
|
739
|
+
// Ports ours-control-plane test/dr-reload-session-loss (commit 86c0c93) onto the core:
|
|
740
|
+
// two born-DR peers hold a LIVE e2e session; A is RELOADED — export the whole $core →
|
|
741
|
+
// remove the packet → recreate from the SAME seed → import_state → commit_e2e_restore —
|
|
742
|
+
// then B, still on the pre-reload session, sends in the GAP (before A sends anything).
|
|
743
|
+
// Pre-b608 the session was NOT in the export, so the recreated packet started a fresh
|
|
744
|
+
// ratchet and B's in-gap message was LOST (RED). Persist-primary restores the SAME
|
|
745
|
+
// session, so it decrypts (GREEN). Non-vacuous: we assert the freshly-recreated packet
|
|
746
|
+
// has NO session, and that commit restores the SAME session id (not a fresh ratchet).
|
|
747
|
+
console.log('\n=== mig: RELOAD port — DR session survives packet recreate, in-gap message decrypts ===');
|
|
748
|
+
{
|
|
749
|
+
const RA = await mkNode('mig-reload-A', 'RLA');
|
|
750
|
+
const RB = await mkNode('mig-reload-B', 'RLB'); await sleep(1000);
|
|
751
|
+
const rinv = await mutate(RA, '::a2a_messaging::generate_invite', { name: 'RLB' });
|
|
752
|
+
await mutate(RB, '::a2a_messaging::add_contact', { invite: binv(RB, Buffer.from(rinv.Reduce('invite').GetBinary())), name: 'RLA' });
|
|
753
|
+
await sleep(6000); // invite redeem round-trips (contacts + peer_ads incl. e2e bundle)
|
|
754
|
+
await mutate(RA, '::actor::qa_learn_peer', { cid: RB.cid, pv: 9, caps: ['core.e2e'] });
|
|
755
|
+
await mutate(RB, '::actor::qa_learn_peer', { cid: RA.cid, pv: 9, caps: ['core.e2e'] });
|
|
756
|
+
ok(ro(RA, '::actor::qa_e2e_route', { cid: RB.cid }).Reduce('route').Visualize() === 'e2e', 'reload: the pair is on the e2e route (double ratchet)');
|
|
757
|
+
|
|
758
|
+
// Establish + advance the live session BOTH ways.
|
|
759
|
+
await mutate(RB, '::actor::qa_recv_reset', {});
|
|
760
|
+
await mutate(RA, '::a2a_messaging::send_message', { contact: 'RLB', text: 'reload-a1' });
|
|
761
|
+
await sleep(3000);
|
|
762
|
+
ok(ro(RB, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === 'reload-a1', 'reload: pre-reload A→B delivered (live e2e session established)');
|
|
763
|
+
await mutate(RA, '::actor::qa_recv_reset', {});
|
|
764
|
+
await mutate(RB, '::a2a_messaging::send_message', { contact: 'RLA', text: 'reload-b1' });
|
|
765
|
+
await sleep(3000);
|
|
766
|
+
ok(ro(RA, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === 'reload-b1', 'reload: pre-reload B→A delivered (ratchet advanced both ways)');
|
|
767
|
+
|
|
768
|
+
const sidBefore = hex(getBin(ro(RA, '::actor::qa_e2e_active', { cid: RB.cid }), 'sid'));
|
|
769
|
+
ok(sidBefore.length > 0, 'reload: A holds a live e2e session id before the reload');
|
|
770
|
+
|
|
771
|
+
// Persist the WHOLE $core (includes $e2e_sessions), exactly as a host does.
|
|
772
|
+
const savedCore = Buffer.from(ro(RA, '::actor::export_state', {}).Serialize());
|
|
773
|
+
|
|
774
|
+
// PAGE RELOAD: the old packet (+ its in-memory session) is gone; recreate from the SAME seed.
|
|
775
|
+
wrapper.packet_manager.remove_packet(RA.cid);
|
|
776
|
+
await sleep(800);
|
|
777
|
+
const RA2 = await mkNode('mig-reload-A', 'RLA'); await sleep(500);
|
|
778
|
+
ok(RA2.cid === RA.cid, 'reload: recreated packet keeps the same identity (same container id, so the broker routes here)');
|
|
779
|
+
|
|
780
|
+
// Non-vacuity: the freshly recreated packet has NO session — the reload really lost it.
|
|
781
|
+
const sidFresh = hex(getBin(ro(RA2, '::actor::qa_e2e_active', { cid: RB.cid }), 'sid'));
|
|
782
|
+
ok(sidFresh.length === 0, 'reload: the freshly recreated packet has NO e2e session (confirms the reload discards it)');
|
|
783
|
+
|
|
784
|
+
// Restore: import_state stages the persisted sessions, commit_e2e_restore installs them.
|
|
785
|
+
await mutate(RA2, '::actor::import_state', RA2.pw.packet.ParseValue(new Uint8Array(savedCore)));
|
|
786
|
+
const cr = await mutate(RA2, '::a2a_messaging::commit_e2e_restore', {});
|
|
787
|
+
ok(cr.Reduce('status').Visualize() === 'ok', 'reload: commit_e2e_restore installed the persisted sessions');
|
|
788
|
+
const sidAfter = hex(getBin(ro(RA2, '::actor::qa_e2e_active', { cid: RB.cid }), 'sid'));
|
|
789
|
+
ok(sidAfter === sidBefore, 'reload: the SAME e2e session id is restored (not a fresh ratchet)');
|
|
790
|
+
|
|
791
|
+
// THE GAP: B — still on the pre-reload session — sends BEFORE A2 sends anything.
|
|
792
|
+
await mutate(RA2, '::actor::qa_recv_reset', {});
|
|
793
|
+
await mutate(RB, '::a2a_messaging::send_message', { contact: 'RLA', text: 'reload-in-gap' });
|
|
794
|
+
await sleep(4000);
|
|
795
|
+
ok(ro(RA2, '::actor::qa_recv_last', {}).Reduce('text').Visualize() === 'reload-in-gap',
|
|
796
|
+
'reload: the reloaded packet DECRYPTS B\'s in-gap message on the restored session (page-reload message-loss FIXED)');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
console.log('\n================ MIG ================');
|
|
800
|
+
if (scorecard.length === 0) console.log('MIG: ALL GREEN');
|
|
801
|
+
else { console.log(`${scorecard.length} FAILURE(S):`); scorecard.forEach((s) => console.log(' ' + s)); }
|
|
802
|
+
await sleep(300);
|
|
803
|
+
process.exit(scorecard.length === 0 ? 0 : 1);
|
|
804
|
+
}
|
|
805
|
+
main().catch((e) => { log('DRIVER ERR:', e.stack ?? e.message); process.exit(1); });
|