Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chatexpiry.mjs

21.7 KiB, 1 run

created by r2519314175:269, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_chatexpiry.mjs — the algebra a chat's lifetime rests on.
2//
3// A chat is throw-away: untouched for the operator's few days it goes to the
4// trash on its own, and the trash destroys it some weeks later. Both clocks run
5// in the browser, on every device, with no server to arbitrate. So the whole
6// feature is a claim about a MERGE, and this file is where that claim is put
7// under load — two devices, driven directly, with parcels handed between them
8// in whatever order the case wants.
9//
10// It runs under Node with no browser: `www/js/trash.js` is a classic script
11// whose store half touches only `localStorage`, `window` and `fetch`, all three
12// of which are shimmed below. The panel half is left unbuilt (no `document`
13// nodes are asked for), which is deliberate — this file is about the record,
14// and the panel is checked in the browser by verify_chatlife.mjs.
15//
16// WHAT IS ACTUALLY ASSERTED, and why each one is here:
17//
18// 1. A chat untouched past the window is trashed; one touched inside it is
19// not. The boundary, from both sides, because a check that only pushed a
20// chat far past the deadline would pass against code that trashed
21// everything.
22//
23// 2. THE OPERATOR SETTING MOVES THE BOUNDARY. The same chat, at the same
24// instant, under two different policies, comes out on opposite sides. This
25// is the check that fails if the knob is read once at boot, or read from
26// the wrong route, or ignored in favour of a constant.
27//
28// 3. TWO DEVICES CONVERGE RATHER THAN DUPLICATING. Two devices expire the
29// same chat days apart and must produce the SAME RECORD, byte for byte —
30// not two records, and not one record whose retention clock restarted. It
31// is asserted on the serialised parcel because that is what the push
32// comparison reads: a merge that differed by a field would push for ever.
33//
34// 4. AN AUTOMATIC ACT NEVER OUTRANKS A HUMAN ONE. A device that has been away
35// comes back with a deadline that has passed and must not bury a restore
36// somebody made by hand while it was gone. This is the property that made
37// `expire` take the deadline as an argument instead of stamping the clock,
38// and it is the one whose absence loses work.
39//
40// 5. A LOWERED RETENTION DOES NOT REACH BACK. The term is pinned on the
41// record when it is trashed, so moving the knob governs what happens next
42// and never brings forward a destruction date the panel has already shown
43// somebody.
44//
45// 6. THE TOMBSTONE TERM OUTLIVES A STALE PEER, including a peer working to a
46// retention this device has never been set to — which it learns from the
47// record the peer sends.
48//
49// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` patches
50// www/js/trash.js in memory before it is evaluated, and the run is then expected
51// to FAIL. A break whose anchor does not appear exactly once aborts, so a break
52// that has rotted cannot report a quiet pass.
53//
54// node dev/verify_chatexpiry.mjs --break stampsnow # 3, 4: expire() stamps Date.now()
55// node dev/verify_chatexpiry.mjs --break repushes # 3: expire() re-stamps an item already trashed
56// node dev/verify_chatexpiry.mjs --break buriesback # 4: expire() ignores a restore
57// node dev/verify_chatexpiry.mjs --break livepolicy # 5: retention read live, not pinned
58// node dev/verify_chatexpiry.mjs --break shorttomb # 6: the old seven-day tombstone term
59// node dev/verify_chatexpiry.mjs --break ignoreknob # 2: the expiry window is a constant
60// node dev/verify_chatexpiry.mjs # and then, clean
61//
62// No world and no server: it opens no port and touches no scratch directory.
63import { readFileSync } from 'fs';
64import vm from 'node:vm';
65
66const SRC = new URL('../www/js/trash.js', import.meta.url);
67
68const BREAK = (() => {
69 const i = process.argv.indexOf('--break');
70 return i > 0 ? String(process.argv[i + 1] || '') : '';
71})();
72
73const DAY = 24 * 3600 * 1000;
74
75const ok = [], bad = [];
76const check = (name, pass, detail) => {
77 (pass ? ok : bad).push(name);
78 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
79};
80
81// ── The breaks ───────────────────────────────────────────────────────
82//
83// Every one of these is a shape the code could plausibly have had. Two of them
84// are shapes an earlier draft DID have.
85const BREAKS = {
86 // The obvious implementation: expiry is a trashing, so call the trashing
87 // function. It is wrong because the stamp then says when this device
88 // noticed rather than when the deadline passed, and two devices that
89 // noticed at different times disagree for ever.
90 stampsnow: {
91 find: '\t\tr.at = when;',
92 with: '\t\tr.at = Math.max(Date.now(), r.back + 1);',
93 },
94 // The guard against re-expiring something already in the trash is dropped.
95 // Every sweep then rewrites the record and pushes the destruction date out,
96 // so a trashed chat is never actually destroyed.
97 repushes: {
98 find: '\t\tif (r && r.at > r.back) return false;\t\t// already in the trash',
99 with: '',
100 },
101 // The guard against outranking a restore is dropped.
102 buriesback: {
103 find: '\t\tif (r && when <= r.back) return false;\t\t// a restore outranks this deadline',
104 with: '',
105 },
106 // The retention is read live from the policy instead of off the record —
107 // which is what the file did before the figure was settable, and what makes
108 // lowering the knob destroy things early.
109 livepolicy: {
110 find: '\t\t\t\tif (now >= r.at + r.r * DAY) {',
111 with: '\t\t\t\tif (now >= r.at + retainDays() * DAY) {',
112 },
113 // The tombstone and restore-record term goes back to the flat seven days it
114 // was, which is shorter than the retention it has to outlive.
115 shorttomb: {
116 find: '\t\ttombTtlMs: function () { return (load().high + GRACE_DAYS) * DAY; },',
117 with: '\t\ttombTtlMs: function () { return 7 * DAY; },',
118 },
119 // The expiry window ignores the operator entirely.
120 ignoreknob: {
121 find: '\t\tchatExpireMs: function () { return load().expire * DAY; },',
122 with: '\t\tchatExpireMs: function () { return 3 * DAY; },',
123 },
124};
125
126if (BREAK && !BREAKS[BREAK]) {
127 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
128 process.exit(2);
129}
130
131/// The module source, damaged if asked, or a hard stop.
132function source() {
133 let src = readFileSync(SRC, 'utf8');
134 if (!BREAK) return src;
135 const spec = BREAKS[BREAK];
136 const n = src.split(spec.find).length - 1;
137 if (n !== 1) {
138 console.error(`break '${BREAK}': the anchor appears ${n} times in trash.js, `
139 + 'so nothing was broken and the run below would prove nothing.');
140 process.exit(2);
141 }
142 return src.replace(spec.find, spec.with);
143}
144
145// ── One simulated device ─────────────────────────────────────────────
146//
147// A fresh evaluation of trash.js against its own `localStorage` and its own
148// clock. Two of these is two devices; there is no shared state between them
149// except the parcels this file hands over, which is the point.
150function device(label) {
151 const store = new Map();
152 // A REAL vm context, in which `window` IS the global object — not a plain
153 // object passed in under that name. It matters: trash.js publishes with
154 // `window.DaimondTrash = …` and then reads `DaimondPolicy` as a BARE
155 // identifier, which is only the same thing when `window` is the global. A
156 // shim that got this wrong reported the shipped defaults for every policy
157 // read and three checks below failed against correct code.
158 const ctx = {
159 localStorage: {
160 getItem: (k) => (store.has(k) ? store.get(k) : null),
161 setItem: (k, v) => store.set(k, String(v)),
162 removeItem: (k) => store.delete(k),
163 },
164 // No gateway in this harness. `refresh()` catches the rejection and
165 // leaves the cached figures in place, which is the offline path.
166 fetch: () => Promise.reject(new Error('no gateway in this harness')),
167 // Just enough of a document for the PANEL half of the file to attach its
168 // delegated listeners and then find nothing. It draws nothing here: every
169 // render path returns early on a missing `#trash-list`, and none is
170 // called. A fuller shim would be a second implementation of the browser
171 // to keep in step, which is what this file most wants to avoid.
172 document: {
173 addEventListener: () => {},
174 getElementById: () => null,
175 querySelectorAll: () => [],
176 },
177 setTimeout, clearTimeout,
178 console: { debug: () => {}, warn: () => {}, log: () => {} },
179 Date, JSON, Math, Object, String, Number, Promise, isFinite, parseInt,
180 // The cross-tab `storage` listener. One device here is one tab, so
181 // nothing ever fires it; it exists because the module attaches it at load.
182 addEventListener: () => {},
183 };
184 ctx.window = ctx;
185 ctx.globalThis = ctx;
186 vm.createContext(ctx);
187 vm.runInContext(source(), ctx, { filename: 'trash.js[' + label + ']' });
188 if (!ctx.DaimondTrash || !ctx.DaimondPolicy) {
189 console.error(`device ${label}: trash.js did not publish its two modules`);
190 process.exit(2);
191 }
192 // Asserted rather than assumed: if the bare-global lookup ever stops
193 // working, every policy read below silently falls back to the shipped
194 // figures and this file's checks become vacuous.
195 ctx.DaimondPolicy.set(11, 22);
196 if (ctx.DaimondTrash.retainMs() !== 22 * DAY) {
197 console.error(`device ${label}: the trash store cannot see DaimondPolicy — `
198 + 'every check in this file would be measuring the shipped defaults.');
199 process.exit(2);
200 }
201 ctx.DaimondPolicy.reset(); store.clear();
202 return { label, trash: ctx.DaimondTrash, policy: ctx.DaimondPolicy, store };
203}
204
205/// Hand one device's state to the other, exactly as a parcel does.
206const send = (from, to) => to.trash.adopt(from.trash.snapshot());
207
208/// The bytes a push would compare. Two devices that have converged produce the
209/// same string; two that have not, do not.
210const bytes = (d) => JSON.stringify(d.trash.snapshot());
211
212/// A chat's deadline, as `chatDueAt` in daimond.js computes it.
213const dueOf = (d, touchedAt) => touchedAt + d.policy.chatExpireMs();
214
215const CHAT = 'c-abc';
216const T0 = Date.parse('2026-06-01T09:00:00Z'); // when the chat was last touched
217
218// ── 1. The boundary, from both sides ─────────────────────────────────
219{
220 const d = device('A');
221 d.policy.set(3, 30);
222 const due = dueOf(d, T0);
223 check('the window is the operator figure, in days', due - T0 === 3 * DAY,
224 `${(due - T0) / DAY} days`);
225
226 // Inside the window: a device looking on the last day must NOT trash it.
227 // The caller is what decides, so the caller's rule is what is exercised:
228 // `now >= due`.
229 const insideNow = T0 + 3 * DAY - 1000;
230 check('a chat touched inside the window is not yet due', insideNow < due);
231
232 // Past it: trashed, and trashed AT THE DEADLINE.
233 const movedOut = d.trash.expire(CHAT, 'chat', due);
234 check('a chat untouched past the window is moved to the trash', movedOut === true);
235 check('and it is IN the trash, not merely recorded', d.trash.has(CHAT) === true);
236 check('and the record says the clock did it, not a person', d.trash.isAuto(CHAT) === true);
237 const rec = d.trash.raw()[CHAT];
238 check('the stamp written is the DEADLINE, not the moment it was noticed',
239 rec.at === due, `at=${rec.at} due=${due}`);
240}
241
242// ── 2. The operator setting moves the boundary ───────────────────────
243//
244// The SAME chat and the SAME instant, judged under two policies. If the window
245// were a constant this pair could not disagree.
246{
247 const strict = device('strict'); strict.policy.set(1, 30);
248 const loose = device('loose'); loose.policy.set(30, 30);
249 const now = T0 + 5 * DAY;
250
251 const dueStrict = dueOf(strict, T0), dueLoose = dueOf(loose, T0);
252 check('a one-day policy has this chat overdue at five days', now >= dueStrict);
253 check('a thirty-day policy does not', now < dueLoose);
254 check('the two policies genuinely disagree about the same chat',
255 (now >= dueStrict) !== (now >= dueLoose),
256 `strict due ${(dueStrict - T0) / DAY}d, loose due ${(dueLoose - T0) / DAY}d`);
257
258 strict.trash.expire(CHAT, 'chat', dueStrict);
259 check('the chat is trashed under the short policy', strict.trash.has(CHAT) === true);
260 check('and is untouched under the long one', loose.trash.has(CHAT) === false);
261}
262
263// ── 3. Two devices converge rather than duplicating ──────────────────
264//
265// A and B expire the same chat nine days apart. Both compute the deadline from
266// the SAME synced `updatedAt`, so both must write the same record — and the
267// serialised parcels must be identical, because that string is what the push
268// compares against the last one sent.
269{
270 const A = device('A'), B = device('B');
271 A.policy.set(3, 30); B.policy.set(3, 30);
272 const due = dueOf(A, T0);
273
274 A.trash.expire(CHAT, 'chat', due); // A notices on the day
275 B.trash.expire(CHAT, 'chat', dueOf(B, T0)); // B, nine days later, same deadline
276
277 check('both devices hold the chat as trashed',
278 A.trash.has(CHAT) && B.trash.has(CHAT));
279 check('and their records are byte-identical BEFORE any parcel is exchanged',
280 bytes(A) === bytes(B), `\n A: ${bytes(A)}\n B: ${bytes(B)}`);
281
282 // The merge is then a no-op in both directions, which is what stops the two
283 // devices telling each other about the same trashing for ever.
284 const aMoved = send(B, A), bMoved = send(A, B);
285 check('adopting the other device\'s parcel moves neither of them',
286 aMoved === false && bMoved === false, `A moved:${aMoved} B moved:${bMoved}`);
287 check('and they are still identical afterwards', bytes(A) === bytes(B));
288
289 // ONE record, not two. The trash is keyed by id, so a duplicate would show
290 // as a second entry — which is exactly what a scheme keyed by anything else
291 // would have produced.
292 check('there is exactly one trash record for the chat',
293 Object.keys(A.trash.raw()).length === 1,
294 Object.keys(A.trash.raw()).join(', '));
295
296 // And the destruction date has not drifted: a device that restamped would
297 // have pushed it nine days out on whichever side noticed second.
298 check('the destruction date is the same on both, and is measured from the deadline',
299 A.trash.dueAt(CHAT) === B.trash.dueAt(CHAT)
300 && A.trash.dueAt(CHAT) === due + 30 * DAY,
301 `A ${A.trash.dueAt(CHAT)} B ${B.trash.dueAt(CHAT)} want ${due + 30 * DAY}`);
302}
303
304// ── 3b. The clock does not keep rewriting a record it has already written ──
305//
306// The sweep runs hourly for the life of the app, so `expire` is offered the
307// same chat again and again after it is already in the trash. Two things must
308// not happen, and neither is caught by the convergence checks above, because
309// there both offers carried the SAME deadline.
310{
311 const d = device('A');
312 d.policy.set(3, 30);
313
314 // (a) A HUMAN DELETION IS NOT RELABELLED, and its date is not moved.
315 // Somebody deletes a chat by hand today that they last touched a fortnight
316 // ago. Its computed deadline is therefore a fortnight in the PAST, so an
317 // `expire` that did not check would drag `at` backwards — bringing the
318 // destruction date forward by a fortnight and telling the panel a clock did
319 // what the person did.
320 const deletedNow = Date.now();
321 d.trash.put(CHAT, 'chat');
322 const byHand = d.trash.raw()[CHAT].at;
323 check('a chat deleted by hand is stamped now, not at some past deadline',
324 Math.abs(byHand - deletedNow) < 5000);
325 const staleDue = T0 + d.policy.chatExpireMs(); // a fortnight ago
326 const relabelled = d.trash.expire(CHAT, 'chat', staleDue);
327 check('the sweep does not relabel a hand-deleted chat as expired',
328 relabelled === false && d.trash.isAuto(CHAT) === false);
329 check('and it does not drag the destruction date backwards',
330 d.trash.raw()[CHAT].at === byHand,
331 `at moved from ${byHand} to ${d.trash.raw()[CHAT].at}`);
332
333 // (b) AN ALREADY-EXPIRED CHAT IS NOT PUSHED OUT. A parcel can carry a
334 // fresher `updatedAt` for a chat that is already in the trash — the other
335 // device worked on it before it heard about the trashing — which makes the
336 // computed deadline LATER than the stamp on the record. Rewriting it would
337 // restart the retention clock, and the hourly sweep would restart it again
338 // every time the chat was touched anywhere. Nothing would ever be destroyed.
339 const e = device('B');
340 e.policy.set(3, 30);
341 const due = T0 + e.policy.chatExpireMs();
342 e.trash.expire(CHAT, 'chat', due);
343 const settled = e.trash.dueAt(CHAT);
344 const later = due + 10 * DAY;
345 const pushed = e.trash.expire(CHAT, 'chat', later);
346 check('a later deadline does not move a chat already in the trash',
347 pushed === false, 'the record was rewritten');
348 check('so the retention clock does not restart',
349 e.trash.dueAt(CHAT) === settled,
350 `destruction date moved by ${(e.trash.dueAt(CHAT) - settled) / DAY} days`);
351}
352
353// ── 4. An automatic act never outranks a human one ───────────────────
354//
355// The case that loses work if it is got wrong. The chat expires on both
356// devices; the user restores it on A; B has been switched off throughout and
357// comes back holding a deadline that passed a fortnight ago.
358{
359 const A = device('A'), B = device('B');
360 A.policy.set(3, 30); B.policy.set(3, 30);
361 const due = dueOf(A, T0);
362
363 A.trash.expire(CHAT, 'chat', due);
364 B.trash.expire(CHAT, 'chat', due);
365 A.trash.back(CHAT); // the user presses Restore on A
366 check('the restore takes it out of the trash on A', A.trash.has(CHAT) === false);
367
368 // B boots, sees its own stale deadline, and tries again. It must decline.
369 const buried = B.trash.expire(CHAT, 'chat', due);
370 check('B re-expiring on its stale record changes nothing on B',
371 buried === false || B.trash.raw()[CHAT].at === due);
372
373 send(A, B);
374 check('once the parcel arrives, B agrees the chat is restored',
375 B.trash.has(CHAT) === false);
376
377 // And now the sharp end: B tries once more, with the restore in hand.
378 const again = B.trash.expire(CHAT, 'chat', due);
379 check('an expiry whose deadline predates the restore is REFUSED',
380 again === false, 'the automatic act overruled the person');
381 check('so the chat is still out of the trash on B', B.trash.has(CHAT) === false);
382 send(B, A);
383 check('and B cannot push the trashing back onto A either',
384 A.trash.has(CHAT) === false);
385
386 // The way back in is a NEW deadline, which is what a restore earns by
387 // stamping the chat: the window starts again from the restore.
388 const restoredAt = A.trash.raw()[CHAT].back;
389 const nextDue = restoredAt + A.policy.chatExpireMs();
390 check('a deadline EARNED after the restore is accepted',
391 A.trash.expire(CHAT, 'chat', nextDue) === true);
392 check('so a restored chat rejoins the cycle rather than becoming immortal',
393 A.trash.has(CHAT) === true);
394}
395
396// ── 5. A lowered retention does not reach back ───────────────────────
397{
398 const d = device('A');
399 d.policy.set(3, 90); // a generous operator
400 const due = dueOf(d, T0);
401 d.trash.expire(CHAT, 'chat', due);
402 const promised = d.trash.dueAt(CHAT);
403 check('the panel is promised ninety days', promised === due + 90 * DAY,
404 `${(promised - due) / DAY} days`);
405
406 d.policy.set(3, 7); // the operator changes their mind
407 check('lowering the knob does not move a date already promised',
408 d.trash.dueAt(CHAT) === promised,
409 `was ${(promised - due) / DAY}d, now ${(d.trash.dueAt(CHAT) - due) / DAY}d`);
410 check('and the new figure does govern the NEXT thing trashed',
411 d.policy.trashRetainMs() === 7 * DAY);
412
413 // The sweep has to agree with the tile. A sweep reading the live policy
414 // would destroy this on day seven, eighty-three days before the date its
415 // owner was shown.
416 const early = due + 8 * DAY;
417 const realNow = Date.now;
418 Date.now = () => early;
419 try {
420 const dueNow = d.trash.sweep().map((x) => x.id);
421 check('and the sweep does not destroy it eighty-three days early',
422 dueNow.indexOf(CHAT) === -1, `swept: ${dueNow.join(', ') || 'nothing'}`);
423 } finally { Date.now = realNow; }
424}
425
426// ── 6. The tombstone term outlives a stale peer ──────────────────────
427{
428 const d = device('A');
429 d.policy.set(3, 30);
430 check('the tombstone term is longer than the retention it must outlive',
431 d.policy.tombTtlMs() > d.policy.trashRetainMs(),
432 `tomb ${d.policy.tombTtlMs() / DAY}d vs retain ${d.policy.trashRetainMs() / DAY}d`);
433 // The concrete failure it exists to stop: destroy by hand on day one, be
434 // away for the peer's whole retention, and the tombstone must still be in
435 // the parcel when the peer returns.
436 const peerStillHoldingUntil = 30 * DAY;
437 check('a tombstone laid the day after a trashing survives until the peer\'s own verdict',
438 d.policy.tombTtlMs() >= peerStillHoldingUntil,
439 `${d.policy.tombTtlMs() / DAY}d`);
440
441 // And a peer working to a LONGER retention than this device has ever been
442 // set to teaches it, because the term rides on the record.
443 const far = device('far');
444 far.policy.set(3, 365);
445 far.trash.put(CHAT, 'chat');
446 const before = d.policy.tombTtlMs();
447 send(far, d);
448 check('adopting a peer\'s record raises this device\'s tombstone term to cover it',
449 d.policy.tombTtlMs() >= 365 * DAY,
450 `was ${before / DAY}d, now ${d.policy.tombTtlMs() / DAY}d`);
451 check('and it does not fall again when the operator lowers the figure here',
452 (d.policy.set(3, 5), d.policy.tombTtlMs() >= 365 * DAY),
453 `${d.policy.tombTtlMs() / DAY}d`);
454}
455
456// ── Report ───────────────────────────────────────────────────────────
457console.log(`\n${ok.length} ok, ${bad.length} failed`);
458if (bad.length) {
459 console.log('failed: ' + bad.join('; '));
460 if (BREAK) console.log(`\n(expected: --break ${BREAK} is meant to fail)`);
461 process.exit(1);
462}
463if (BREAK) {
464 console.log(`\nBREAK '${BREAK}' PASSED EVERYTHING — the checks above do not `
465 + 'actually test what they claim to.');
466 process.exit(1);
467}
468process.exit(0);