Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_telemetry.mjs

59.5 KiB, 1 run

created by r2519314175:729, 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_telemetry.mjs — beta telemetry carries numbers, and never a word of anybody's.
2//
3// The promise this file defends is the one Daimond is sold on: your content does
4// not reach our server. A beta tester agrees to usage and failure counts; they do
5// not agree to a chat fragment arriving inside a stack trace, or a Diamond's name
6// arriving inside a "what were you doing" box. So the design has no string field
7// at all, and this proves that AT THE NETWORK rather than by reading the code.
8//
9// ── What is proved, and what could fake it ──────────────────────────
10//
11// A negative check is the easiest kind to fake, and this file's most important
12// check is negative. Three things guard against a green for the wrong reason:
13//
14// 1. THE MARKERS ARE PROVED PRESENT FIRST. A distinctive string is typed into
15// a chat, given to a Diamond as its name, and used as a file path -- and
16// each is then read back out of the running app before anything is asserted
17// about the wire. "No marker on the wire" is worthless if the marker was
18// never in the app.
19//
20// 2. THE BATCH IS PROVED TO HAVE LEFT. "No content in the telemetry request"
21// is trivially true when there is no telemetry request. The consented pass
22// asserts a batch went, and asserts the codes in it are exactly the ones
23// emitted after consent.
24//
25// 3. THE CHECK IS PROVED RED IN THE SAME RUN. Two further passes serve a
26// DELIBERATELY BROKEN `telemetry.js` -- one that appends an on-screen chat
27// message to the outgoing body, and one that consents to itself at load --
28// and this file fails unless the leak check fires on the first and the
29// before-consent check fires on the second. Each break is SERVED AT
30// `js/telemetry.js` in place of the file on disk, so nothing is edited and
31// anybody can re-run it -- see `serveModule`, and the incident in its
32// comment for why it is a route and no longer an init script.
33//
34// ── Running it ──────────────────────────────────────────────────────
35//
36// bash dev/world.sh 5 --up
37// eval "$(bash dev/world.sh 5 --env)"
38// node dev/verify_telemetry.mjs
39// bash dev/world.sh 5 --down
40//
41// Headless. The gateway does NOT need to be running: this is a check on what
42// leaves the browser, and a batch that reaches a closed port has still left.
43import fs from 'node:fs';
44import path from 'node:path';
45import { createRequire } from 'node:module';
46import { fileURLToPath } from 'node:url';
47
48import { open, chat, transcript, scratch, APP } from './harness.mjs';
49
50const HERE = path.dirname(fileURLToPath(import.meta.url));
51const ROOT = path.join(HERE, '..');
52const SRC_JS = path.join(ROOT, 'www/js/telemetry.js');
53const SRC_RS = path.join(ROOT, 'gateway/src/handlers/telemetry.rs');
54
55// The two break passes at the end prove the negatives fire, on every run. These
56// flags do the same thing the other way round -- they break the build the MAIN
57// passes drive -- so the headline checks can be SEEN red rather than only
58// inferred red from a later pass going green:
59//
60// node dev/verify_telemetry.mjs --break=note # the no-content check must fail
61// node dev/verify_telemetry.mjs --break=consent # the before-consent check must fail
62// node dev/verify_telemetry.mjs --break=narrow # the build-ordinal checks must fail
63// node dev/verify_telemetry.mjs --break=halfway # the queued-batch check must fail
64// node dev/verify_telemetry.mjs --break=eager # the withdrawal-survives-a-reload check must fail
65// node dev/verify_telemetry.mjs --break=forget # the consent-survives-a-reload check must fail
66// node dev/verify_telemetry.mjs --break=inert # the something-emits check must fail
67// node dev/verify_telemetry.mjs --break=undisclosed # the policy check must fail
68const BREAK = (process.argv.find((a) => a.startsWith('--break=')) || '').split('=')[1] || '';
69
70const ok = [], bad = [];
71const check = (name, pass, detail) => {
72 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
73 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
74};
75
76// Distinctive enough that nothing else in the app or the suite could produce
77// them, so a hit is a leak and never a coincidence.
78const MARK = {
79 chat: 'ZQXJ7731-what-I-typed-in-confidence',
80 diamond: 'ZQXJ7731-Diamond-Name',
81 file: 'zqxj7731-private-note.md',
82 body: 'ZQXJ7731-the-contents-of-my-file',
83};
84const MARKERS = Object.values(MARK);
85
86// ── The wire, read as a stranger would read it ──────────────────────
87
88/// Is this request going to Daimond's own origin?
89///
90/// The model provider is deliberately excluded. A prompt reaching the provider
91/// the user chose is the product working; a prompt reaching OUR origin is the
92/// promise broken, and those are different facts that must not be conflated.
93const appOrigin = new URL(APP).origin;
94const toApp = (r) => { try { return new URL(r.url).origin === appOrigin; } catch { return false; } };
95const toTelemetry = (r) => { try { return toApp(r) && new URL(r.url).pathname === '/api/telemetry'; }
96 catch { return false; } };
97
98/// Every place a marker turned up in what left the browser for our origin.
99///
100/// Both the address and the body: a payload smuggled in a query string is still
101/// a payload, which is why the URL is searched too.
102function leaks(requests, only) {
103 const out = [];
104 for (const r of requests.filter(only)) {
105 const hay = r.url + '\n' + (r.body || '');
106 for (const m of MARKERS) {
107 if (hay.indexOf(m) !== -1) out.push(`${m} in ${new URL(r.url).pathname}`);
108 }
109 }
110 return out;
111}
112
113/// Is this parsed body numbers all the way down, with no key outside the
114/// declared set? Stated here rather than borrowed from the module under test,
115/// so a module that redefined its own rules could not redefine the check.
116const KEYS = ['v', 'b', 'l', 'w', 't', 'd', 'e'];
117function shapeFaults(body) {
118 const faults = [];
119 let j;
120 try { j = JSON.parse(body); } catch (e) { return ['the body is not JSON']; }
121 if (!j || typeof j !== 'object' || Array.isArray(j)) return ['the body is not an object'];
122 const isInt = (x) => typeof x === 'number' && Number.isInteger(x) && x >= 0;
123 for (const k of Object.keys(j)) {
124 if (KEYS.indexOf(k) === -1) { faults.push(`the field "${k}" is not one of ${KEYS.join(',')}`); continue; }
125 if (k === 'e') {
126 if (!Array.isArray(j.e)) { faults.push('"e" is not a list'); continue; }
127 j.e.forEach((row, i) => {
128 if (!Array.isArray(row) || row.length !== 3 || !row.every(isInt)) {
129 faults.push(`event ${i} is not three whole numbers: ${JSON.stringify(row)}`);
130 }
131 });
132 } else if (!isInt(j[k])) {
133 faults.push(`"${k}" is ${JSON.stringify(j[k])}, which is not a whole number`);
134 }
135 }
136 return faults;
137}
138
139/// The event codes a captured batch carried.
140function codesIn(requests) {
141 const seen = new Set();
142 for (const r of requests.filter(toTelemetry)) {
143 try { (JSON.parse(r.body).e || []).forEach((row) => seen.add(row[0])); } catch (e) { /* not ours */ }
144 }
145 return seen;
146}
147
148// ── The breaks, served rather than written to disk ──────────────────
149
150/// A `telemetry.js` that appends the user's own on-screen words to the batch.
151///
152/// This is the leak in its most plausible form -- a "just a little context"
153/// field -- and it is placed AFTER the module's own integer check, because a
154/// break the module catches for us would prove nothing about the wire.
155function breakWithNote(src) {
156 const anchor = 'body: JSON.stringify(body),';
157 if (src.indexOf(anchor) === -1) throw new Error('the break anchor is gone from telemetry.js');
158 return src.replace(anchor,
159 'body: JSON.stringify(Object.assign({}, body, { note: ' +
160 '((document.getElementById("chat-output") || {}).textContent || "").slice(0, 400) })),');
161}
162
163/// A `telemetry.js` that consents to itself at load, which is what a release
164/// that "forgot the check" would look like.
165function breakWithSelfConsent(src) {
166 return src + '\ntry { window.DaimondTelemetry.consent({ wave: 1 }); } catch (e) {}\n';
167}
168
169/// A `telemetry.js` that judges the build ordinal as though it were a count.
170///
171/// This is the defect exactly as it stood: one ceiling for every field, so an
172/// eight-hex-digit build id beginning 8-f -- half of all builds -- is floored to
173/// zero on the last step before the wire. It is the break the ordinal checks
174/// below are proved red against.
175const NARROW_ANCHOR = 'var MAX_BUILD = 4294967295;';
176function breakWithNarrowCeiling(src) {
177 const n = src.split(NARROW_ANCHOR).length - 1;
178 if (n !== 1) {
179 console.error(`break 'narrow': the anchor appears ${n} times in telemetry.js, `
180 + 'so nothing was broken and the run below would prove nothing.');
181 process.exit(2);
182 }
183 return src.replace(NARROW_ANCHOR, 'var MAX_BUILD = MAX_N;');
184}
185
186/// How long a session waits for the module's own flush timer, when the timer is
187/// the thing under test. The shipped interval is a minute, which is right for a
188/// tester's battery and wrong for a check: a withdrawal that is only asserted
189/// against a flush nobody waited for is a withdrawal nobody has seen work.
190const FAST_FLUSH = 1200;
191
192/// The same module with its flush interval shortened, and nothing else changed.
193///
194/// Used only by the sessions in section 1c, which have to watch a queued batch
195/// either go or not go. The anchor is asserted, so a renamed constant stops the
196/// run rather than quietly leaving the sessions waiting on a timer that never
197/// comes round -- which would make "no batch left" true for the wrong reason.
198const FLUSH_ANCHOR = 'var FLUSH_MS = 60000;';
199function withFastFlush(src) {
200 const n = src.split(FLUSH_ANCHOR).length - 1;
201 if (n !== 1) {
202 console.error(`the flush anchor appears ${n} times in telemetry.js, so a `
203 + 'withdrawal session would wait on the shipped minute and prove nothing.');
204 process.exit(2);
205 }
206 return src.replace(FLUSH_ANCHOR, `var FLUSH_MS = ${FAST_FLUSH};`);
207}
208
209/// A `withdraw()` that drops the recorder and leaves its timer running.
210///
211/// THE PLAUSIBLE DEFECT, and the reason the withdrawal check is proved at the
212/// network rather than against `armed()`. `rec = null` alone reads exactly like
213/// a working withdrawal from outside -- `armed()` false, `emit()` refusing --
214/// while the closure's own timer still holds the buffer and sends it a minute
215/// later. That is the shape the stub in the incident had, and a check that
216/// asked the module how it felt would pass on it.
217const CLOSE_ANCHOR = ` close: function () {
218 if (timer) { clearTimeout(timer); timer = null; }
219 buf.length = 0;
220 dropped = 0;
221 },`;
222function breakWithHalfWithdrawal(src) {
223 if (src.split(CLOSE_ANCHOR).length - 1 !== 1) {
224 console.error('break \'halfway\': the close() anchor is gone from telemetry.js, '
225 + 'so nothing was broken and the run below would prove nothing.');
226 process.exit(2);
227 }
228 return src.replace(CLOSE_ANCHOR,
229 ' close: function () { /* halfway: the recorder goes, its timer does not */ },');
230}
231
232/// A `resume()` that re-arms without consulting what was written down.
233///
234/// The dangerous half of the reload pair, and the one that reads as harmless
235/// while it is being written: the gateway says this account is in the beta, so
236/// why ask twice? Because "in the beta" is membership and not agreement, and a
237/// tester who withdrew is still a member. This is what a withdrawal undone by
238/// the next visit looks like from the inside.
239const EAGER_ANCHOR = ' if (remembered() !== account) return false;';
240function breakWithEagerResume(src) {
241 if (src.split(EAGER_ANCHOR).length - 1 !== 1) {
242 console.error('break \'eager\': the resume() anchor is gone from telemetry.js.');
243 process.exit(2);
244 }
245 return src.replace(EAGER_ANCHOR, ' /* eager: what was written down is not consulted */');
246}
247
248/// A `consent()` that arms without writing the agreement down.
249///
250/// The harmless-looking half: everything works, for one sitting. This is the
251/// state the module was in by design until the gateway could answer for the
252/// membership, and the check it fires is the one that says so.
253const FORGET_ANCHOR = ' if (account) remember(account);';
254function breakWithForgetfulConsent(src) {
255 if (src.split(FORGET_ANCHOR).length - 1 !== 1) {
256 console.error('break \'forget\': the consent() anchor is gone from telemetry.js.');
257 process.exit(2);
258 }
259 return src.replace(FORGET_ANCHOR, ' /* forget: the agreement is not written down */');
260}
261
262/// Which patch each break applies to the module the page loads.
263///
264/// `inert` and `undisclosed` patch nothing, because what they simulate is not a
265/// change to this module: they are the two states the app must never ship in --
266/// a loaded client that nothing emits to, and one the published policy does not
267/// describe. They replace `grant` and `load`, which simulated the state the tree
268/// WAS in (nothing granted consent, nothing loaded the client) and became dead
269/// levers the moment it was built: a flag that can no longer make a check fail
270/// is a flag that says the check is proved when it is not.
271const PATCHES = {
272 note: breakWithNote,
273 consent: breakWithSelfConsent,
274 narrow: breakWithNarrowCeiling,
275 halfway: breakWithHalfWithdrawal,
276 eager: breakWithEagerResume,
277 forget: breakWithForgetfulConsent,
278 inert: (s) => s,
279 undisclosed: (s) => s,
280};
281if (BREAK && !PATCHES[BREAK]) {
282 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(PATCHES).join(', ')}`);
283 process.exit(2);
284}
285/// The module as this run drives it: patched under `--break`, otherwise as it is.
286const asDriven = (src) => (BREAK ? PATCHES[BREAK](src) : src);
287
288/// Serve `src` AS `js/telemetry.js`, in place of the file on disk.
289///
290/// IT USED TO BE `addInitScript`, which ran the module before the page's own
291/// scripts -- correct while `index.html` did not load it, and quietly wrong the
292/// moment it did: the page's copy then re-ran the IIFE and replaced
293/// `window.DaimondTelemetry` with an UNPATCHED module. Every break went missing
294/// and every timer session waited 1.2 seconds on a sixty-second flush, so six
295/// checks turned red at once and the leak check -- the most important negative
296/// in this file -- stopped firing on a build that leaks.
297///
298/// Serving it at its own address cannot drift that way again: whatever the page
299/// loads is what this run patched, because it is the same request.
300async function serveModule(page, src) {
301 await page.route('**/js/telemetry.js', (r) => r.fulfill({
302 status: 200, contentType: 'application/javascript', body: src,
303 }));
304}
305
306// ── One browser session ─────────────────────────────────────────────
307
308/// # Arguments
309/// * `label` - Names the profile and the session.
310/// * `patch` - What to do to `telemetry.js` before the page loads it.
311/// * `giveConsent` - Mint a recorder, or leave the session unconsented.
312/// * `buildId` - Serve this build id from `build.json` instead of the real one,
313/// so a property about build ids can be asserted rather than whatever the
314/// day's build happens to be.
315/// * `quick` - Skip the marker fixture. Only for a session that is not about
316/// content leaking; the leak checks need the markers proved present first.
317async function runSession({ label, patch, giveConsent, buildId = '', quick = false }) {
318 const src = patch(fs.readFileSync(SRC_JS, 'utf8'));
319 const requests = [];
320 const profile = scratch('telemetry-' + label);
321 fs.rmSync(profile, { recursive: true, force: true });
322
323 // Both hooks have to be in place BEFORE the first navigation: the module is
324 // injected the way `index.html` will one day carry it, and the capture must
325 // not miss a request made during boot.
326 const s = await open({
327 name: 'tele-' + label,
328 profile,
329 defaults: false,
330 route: async (page) => {
331 if (buildId) {
332 // The updater reads the same file. It only reloads a HIDDEN tab
333 // (`apply` in js/updater.js), and this one is not hidden, so a
334 // substituted id moves the chip and nothing else.
335 await page.route('**/build.json', (r) => r.fulfill({
336 status: 200, contentType: 'application/json',
337 body: JSON.stringify({ build: buildId }),
338 }));
339 }
340 await serveModule(page, src);
341 page.on('request', (r) => {
342 requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' });
343 });
344 },
345 });
346 const { page } = s;
347
348 const present = { module: false, chat: false, diamond: false, file: false };
349 try {
350 present.module = await page.evaluate(() => !!window.DaimondTelemetry);
351
352 if (quick) {
353 const after = giveConsent ? await page.evaluate(() => {
354 const T = window.DaimondTelemetry;
355 const granted = T.consent({ wave: 3 });
356 T.emit('app.open', 830);
357 return { granted: granted, armed: T.armed(), wave: T.wave() };
358 }) : null;
359 const sent = await page.evaluate(() => window.DaimondTelemetry.flush());
360 await page.waitForTimeout(800);
361 return { requests, present, before: null, after, sent };
362 }
363
364 // A Diamond carrying the marker as its NAME, and a file carrying it as
365 // its PATH and its CONTENT. Through the real wasm, into the real store.
366 const built = await page.evaluate(async (m) => {
367 const mod = await import('/pkg/oxedyne_daimond.js');
368 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
369 const id = await app.create_diamond(m.diamond);
370 await app.run_tool('file_write', JSON.stringify({ path: m.file, content: m.body }));
371 const names = JSON.parse(await app.list_diamonds()).map((d) => d.name);
372 const listing = String(await app.run_tool('file_list', JSON.stringify({ path: '.' })));
373 return { id, names, listing };
374 }, MARK);
375 present.diamond = built.names.indexOf(MARK.diamond) !== -1;
376 present.file = built.listing.indexOf(MARK.file) !== -1;
377
378 // And the marker typed into a chat, sent, and answered.
379 await chat(s, MARK.chat);
380 present.chat = (await transcript(s)).indexOf(MARK.chat) !== -1;
381
382 // Events emitted BEFORE consent. Nothing should keep them, and nothing
383 // should send them when consent arrives later.
384 const before = await page.evaluate(() => ({
385 emitted: window.DaimondTelemetry.emit('panel.open', 5),
386 armed: window.DaimondTelemetry.armed(),
387 flushed: false,
388 }));
389 await page.evaluate(() => window.DaimondTelemetry.emit('tool.run', 2));
390 const flushedBefore = await page.evaluate(() => window.DaimondTelemetry.flush());
391 before.flushed = flushedBefore;
392
393 let after = null;
394 if (giveConsent) {
395 after = await page.evaluate(() => {
396 const T = window.DaimondTelemetry;
397 const granted = T.consent({ wave: 3 });
398 T.emit('app.open', 830);
399 T.emit('onboard.step', 6);
400 T.emit('chat.new', 1);
401 T.emit('turn.send', 1);
402 T.emit('error.thrown', 1);
403 return { granted: granted, armed: T.armed(), wave: T.wave() };
404 });
405 }
406 // A flush either way: the unconsented session must still send nothing.
407 const sent = await page.evaluate(() => window.DaimondTelemetry.flush());
408 await page.waitForTimeout(800);
409
410 return { requests, present, before, after, sent };
411 } finally {
412 try { await s.browser.close(); } catch (e) { /* ignore */ }
413 }
414}
415
416/// A consenting session that is left to the module's OWN flush timer.
417///
418/// Everything else in this file makes a batch go by calling `flush()`. That
419/// cannot answer the question section 1c asks, which is whether a batch already
420/// sitting in the buffer goes when nobody asks it to -- so this one emits, does
421/// `act`, and then waits while the timer comes round twice.
422///
423/// # Arguments
424/// * `label` - Names the profile and the session.
425/// * `act` - JavaScript run in the page after consent: the withdrawal under
426/// test, or nothing at all for the control.
427async function timerSession({ label, act }) {
428 // `asDriven` first, so a `--break` run drives the same module the Node-side
429 // checks above read. A session on the unpatched file under a break flag is a
430 // session answering about a different program.
431 const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8')));
432 const requests = [];
433 const profile = scratch('telemetry-' + label);
434 fs.rmSync(profile, { recursive: true, force: true });
435 const s = await open({
436 name: 'tele-' + label,
437 profile,
438 defaults: false,
439 route: async (page) => {
440 await serveModule(page, src);
441 page.on('request', (r) => {
442 requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' });
443 });
444 },
445 });
446 try {
447 const armed = await s.page.evaluate(() => {
448 const T = window.DaimondTelemetry;
449 T.consent({ wave: 3 });
450 T.emit('app.open', 830);
451 T.emit('panel.open', 2);
452 return T.armed();
453 });
454 if (act) await s.page.evaluate(act);
455 await s.page.waitForTimeout(FAST_FLUSH * 3);
456 return { armed, batches: requests.filter(toTelemetry).length };
457 } finally {
458 try { await s.browser.close(); } catch (e) { /* ignore */ }
459 }
460}
461
462/// Does taking consent back actually stop a batch that is already queued?
463///
464/// Answered at the network, in two sessions that differ by one line: the control
465/// consents and waits, and must SEND -- without that, "nothing was sent" is a
466/// statement about a timer that never fired.
467///
468/// The withdrawal is not a guessed name. Anything the module exports whose name
469/// says it takes consent back is called; if it exports nothing of the kind, that
470/// is itself the answer, and no session is run for it.
471/// A session that agrees, optionally withdraws, and then RELOADS.
472///
473/// The reload is the point. Consent that does not survive one covers a tester's
474/// first sitting and no other; a withdrawal that does not survive one is undone
475/// by the next visit, which is worse than never having offered it. Both are
476/// answered by the same session with one flag between them.
477///
478/// The boot path is simulated the way `rearm()` in `www/js/passcode.js` drives
479/// it -- `resume()` with the wave and account the gateway would answer on this
480/// boot -- because the gateway is not part of a world and the property under
481/// test is the browser's, not the server's.
482///
483/// # Arguments
484/// * `label` - Names the profile and the session.
485/// * `withdrawFirst` - Take consent back before reloading.
486async function reloadSession({ label, withdrawFirst }) {
487 const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8')));
488 const requests = [];
489 const profile = scratch('telemetry-' + label);
490 fs.rmSync(profile, { recursive: true, force: true });
491 const s = await open({
492 name: 'tele-' + label,
493 profile,
494 defaults: false,
495 route: async (page) => {
496 await serveModule(page, src);
497 page.on('request', (r) => {
498 requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' });
499 });
500 },
501 });
502 try {
503 await s.page.evaluate((take) => {
504 const T = window.DaimondTelemetry;
505 T.consent({ wave: 3, account: 'acct-under-test' });
506 T.emit('app.open', 830);
507 if (take) T.withdraw();
508 }, !!withdrawFirst);
509 // Everything before this line is discarded with the page; what is being
510 // asked is what the NEXT visit does -- so the count starts HERE. Counting
511 // from the top of the session would let a batch sent before the reload
512 // stand in for one sent after it, and the consent half of the pair would
513 // then pass on a build that never resumed at all.
514 const beforeReload = requests.length;
515 await s.page.reload({ waitUntil: 'domcontentloaded' });
516 const back = await s.page.evaluate(() => {
517 const T = window.DaimondTelemetry;
518 const resumed = T.resume({ wave: 3, account: 'acct-under-test' });
519 // `panel.open` and nothing else, because a code is what makes the
520 // answer unambiguous. The dying page flushes on `pagehide` -- that is
521 // `app.close`, added with the emit sites -- and its request starts
522 // during the reload, so COUNTING batches would let the old page's
523 // last gasp stand in for the new page's recorder. Code 4 can only
524 // have come from a recorder this visit restored.
525 T.emit('panel.open', 2);
526 return { resumed: resumed, armed: T.armed(), agreed: T.agreed('acct-under-test') };
527 });
528 await s.page.waitForTimeout(FAST_FLUSH * 3);
529 const after = requests.slice(beforeReload);
530 return Object.assign(back, {
531 batches: after.filter(toTelemetry).length,
532 carried: codesIn(after).has(4) });
533 } finally {
534 try { await s.browser.close(); } catch (e) { /* ignore */ }
535 }
536}
537
538/// A session that consents FIRST and is then used, with this file emitting
539/// nothing at all.
540///
541/// Every other session here calls `emit()` by hand, which proves the transport
542/// and proves nothing whatever about the app: a build with the call sites
543/// deleted passes all of them. Here the only thing that can put an event in the
544/// buffer is `www/js/daimond.js` doing its own work, so what comes back is what
545/// the app actually reports when somebody uses it.
546async function realSession(label) {
547 const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8')));
548 const requests = [];
549 const profile = scratch('telemetry-' + label);
550 fs.rmSync(profile, { recursive: true, force: true });
551 const s = await open({
552 name: 'tele-' + label,
553 profile,
554 defaults: false,
555 route: async (page) => {
556 await serveModule(page, src);
557 page.on('request', (r) => {
558 requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' });
559 });
560 },
561 });
562 try {
563 // Consent before anything is done, so everything after it is inside the
564 // agreement. Nothing else is emitted from here.
565 const armed = await s.page.evaluate(() =>
566 window.DaimondTelemetry.consent({ wave: 3, account: 'acct-under-test' }));
567 // A real turn, through the real composer, answered by the mock provider.
568 await chat(s, 'Make a file called notes.md with one line in it.');
569 await s.page.waitForTimeout(FAST_FLUSH * 3);
570 return { armed, requests, codes: codesIn(requests) };
571 } finally {
572 try { await s.browser.close(); } catch (e) { /* ignore */ }
573 }
574}
575
576/// The consent card, driven the way a person drives it.
577///
578/// The gateway is not part of a world, so the redemption reply is stubbed --
579/// with the shape `gateway/src/handlers/passcode.rs` actually answers, `wave`
580/// and `account_id` included. Everything else is the app: the real card, the
581/// real strings, the real module.
582///
583/// # Arguments
584/// * `answer` - `'yes'`, `'no'`, or `'escape'` for the person who closes it.
585async function cardSession(answer) {
586 const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8')));
587 const requests = [];
588 const profile = scratch('telemetry-card-' + answer);
589 fs.rmSync(profile, { recursive: true, force: true });
590 const s = await open({
591 name: 'tele-card-' + answer, profile, defaults: false,
592 route: async (page) => {
593 await serveModule(page, src);
594 page.on('request', (r) => {
595 requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' });
596 });
597 },
598 });
599 try {
600 const shown = await s.page.evaluate(async () => {
601 window.DaimondGateway.state = () => ({ authed: false, refused: 'beta_only', refusal: '' });
602 window.DaimondGateway.redeemPasscode = async () => ({
603 created: true, pro: true, wave: 3, handle: 'quiet-harbour-41',
604 authed: true, account: 'acct-under-test' });
605 window.DaimondPasscode.show();
606 await new Promise((r) => setTimeout(r, 200));
607 document.querySelector('.beta-input').value = 'a1b2-c3d4-e5f6';
608 // The Redeem button is the last in the entry card's row, whatever it
609 // is called in the locale under test.
610 const row = document.querySelectorAll('.beta-box .beta-row button');
611 row[row.length - 1].click();
612 await new Promise((r) => setTimeout(r, 500));
613 const box = document.querySelector('.beta-consent');
614 if (!box) return { asked: false };
615 const link = box.querySelector('a.legal-link');
616 const btns = box.querySelectorAll('.beta-row button');
617 return {
618 asked: true,
619 answers: btns.length,
620 // Neither answer may be the bigger one. Read off the rendered
621 // boxes rather than off the class names, because a class that
622 // stopped meaning what it says is exactly how a "real choice"
623 // quietly becomes a nudge.
624 sameSize: btns.length === 2
625 && Math.abs(btns[0].getBoundingClientRect().height
626 - btns[1].getBoundingClientRect().height) < 2,
627 href: link ? link.getAttribute('href') : '',
628 // NAMED APART from the reading taken after the answer. Both were
629 // called `armed`, and the merge below silently overwrote this one
630 // with that one -- so the check that nothing is armed while the
631 // question is still up was reading the state AFTER yes was
632 // pressed, and failed on a working app. A check that reports the
633 // wrong moment is worse than no check: it sends somebody hunting
634 // through the app for a defect that is in the test.
635 armedBefore: window.DaimondTelemetry.armed(),
636 };
637 });
638 if (shown.asked) {
639 await s.page.evaluate((how) => {
640 const btns = document.querySelectorAll('.beta-consent .beta-row button');
641 if (how === 'no') btns[0].click();
642 if (how === 'yes') btns[1].click();
643 }, answer);
644 if (answer === 'escape') {
645 // The way out that is not a button: the card's own key handler,
646 // which is what a person who wants no part of this presses.
647 await s.page.keyboard.press('Escape');
648 }
649 }
650 await s.page.waitForTimeout(FAST_FLUSH * 2);
651 const state = await s.page.evaluate(() => ({
652 armed: window.DaimondTelemetry.armed(),
653 agreed: window.DaimondTelemetry.agreed('acct-under-test'),
654 cardUp: !!document.querySelector('.beta-scrim'),
655 }));
656 return Object.assign(shown, state, { batches: requests.filter(toTelemetry).length });
657 } finally {
658 try { await s.browser.close(); } catch (e) { /* ignore */ }
659 }
660}
661
662function withdrawalNames() {
663 return Object.keys(client)
664 .filter((k) => /withdraw|revoke/i.test(k) && typeof client[k] === 'function');
665}
666
667async function withdrawal() {
668 const names = withdrawalNames();
669 const control = await timerSession({ label: 'wd-control', act: '' });
670 const after = names.length
671 ? await timerSession({ label: 'wd-stop',
672 act: `window.DaimondTelemetry.${names[0]}();` })
673 : null;
674 return { names, control: control.batches, after: after ? after.batches : -1 };
675}
676
677// ┌───────────────────────────────────────────────────────────────────┐
678// │ 1. The two copies of the vocabulary agree │
679// └───────────────────────────────────────────────────────────────────┘
680
681// The module AS THIS RUN DRIVES IT. Under `--break` the page is served a
682// patched copy, so a Node-side check that read the file on disk would be
683// checking a different module from the one in the browser -- and would go green
684// while the browser went red, which is the shape of a check that proves nothing.
685const nodeCopy = (() => {
686 if (!BREAK) return SRC_JS;
687 const p = scratch('telemetry-driven-' + BREAK + '.js');
688 fs.writeFileSync(p, asDriven(fs.readFileSync(SRC_JS, 'utf8')));
689 return p;
690})();
691const client = createRequire(import.meta.url)(nodeCopy);
692
693/// The gateway's copy, read out of the Rust: variant → code, and variant → name.
694function rustVocabulary() {
695 const rs = fs.readFileSync(SRC_RS, 'utf8');
696 const codes = {}, names = {};
697 for (const m of rs.matchAll(/^\s{4}([A-Z][A-Za-z]*)\s*=\s*(\d+),$/gm)) codes[m[1]] = Number(m[2]);
698 for (const m of rs.matchAll(/Self::([A-Za-z]+)\s*=>\s*"([a-z.]+)",/g)) names[m[1]] = m[2];
699 const out = {};
700 for (const v of Object.keys(codes)) if (names[v]) out[codes[v]] = names[v];
701 return out;
702}
703
704const rust = rustVocabulary();
705const js = {};
706client.EVENTS.forEach((e) => { js[e.code] = e.name; });
707
708check('the gateway\'s event list was actually read', Object.keys(rust).length > 0,
709 `${Object.keys(rust).length} events found in telemetry.rs`);
710check('the client\'s event list was actually read', Object.keys(js).length > 0,
711 `${Object.keys(js).length} events found in telemetry.js`);
712// Both directions, so neither copy can be a superset of the other unnoticed.
713const missingInRust = Object.keys(js).filter((c) => rust[c] !== js[c]);
714const missingInJs = Object.keys(rust).filter((c) => js[c] !== rust[c]);
715check('every event the client can send, the gateway knows by the same name',
716 missingInRust.length === 0, missingInRust.map((c) => `${c}=${js[c]}`).join(', '));
717check('and the gateway knows no event the client does not',
718 missingInJs.length === 0, missingInJs.map((c) => `${c}=${rust[c]}`).join(', '));
719check('every event says what its number means and what question it answers',
720 client.EVENTS.every((e) => e.n && e.n.length > 3 && e.asks && e.asks.length > 20),
721 client.EVENTS.filter((e) => !e.asks || e.asks.length <= 20).map((e) => e.name).join(', '));
722// The declaration is a user-facing document; a code that moved would silently
723// re-label every batch already collected.
724check('no two events share a code or a name',
725 new Set(client.EVENTS.map((e) => e.code)).size === client.EVENTS.length
726 && new Set(client.EVENTS.map((e) => e.name)).size === client.EVENTS.length);
727
728// ── 1b. And the TOOL table is the registry's, not somebody's memory ──
729//
730// `TOOLS` turns a tool's name into the integer that goes on the wire, and a name
731// that is not in it becomes 0 -- 'other'. So a tool missing from the list is not
732// a gap in the data: it is a WRONG NUMBER, reported under a heading that says
733// something else was run. Half the table went missing once and was corrected by
734// hand; twelve names went missing again -- ask, the Social pair, the spreadsheet
735// and document tools, the links, runs and verify -- and by 2026-08-28 a third of
736// the tool surface reported as 'other', which is exactly the evidence a decision
737// about what to sell in a tool pack would rest on.
738//
739// READ OUT OF THE REGISTRY, `Tool::name` in `src/tools.rs`, because the two ways
740// this was kept true before both depended on somebody remembering. A variant
741// added there adds an arm here, and this goes red the same day.
742//
743// ONE DIRECTION ONLY, deliberately. Every registry name must be in the table; a
744// name in the table that the registry no longer has is NOT a failure -- a tool
745// that is removed keeps its position for ever, or every number already gathered
746// under the ones after it changes meaning.
747const SRC_TOOLS = path.join(ROOT, 'src/tools.rs');
748
749/// Every tool's wire name, read off `Tool::name`'s match arms.
750///
751/// Two shapes of arm, because one name is a constant: `Tool::SocialSend =>
752/// SOCIAL_SEND_TOOL`, which is resolved by finding the constant's own value. An
753/// arm whose right-hand side is neither is reported rather than skipped, or a
754/// third shape would quietly shrink the list this checks against.
755function registryToolNames() {
756 const rs = fs.readFileSync(SRC_TOOLS, 'utf8');
757 // FROM THE FIRST ARM, not from the signature: several enums in that file have a
758 // `name(&self)`, and the first of them is a build system's. The arms end at the
759 // match's own closing brace.
760 const first = rs.match(/Tool::FileRead\s*=>\s*"file_read",/);
761 if (!first) return { names: [], odd: ['Tool::name\'s arms were not found at all'] };
762 const arms = rs.slice(first.index, rs.indexOf('\n }', first.index));
763 const out = [], odd = [];
764 for (const m of arms.matchAll(/Tool::[A-Za-z]+\s*=>\s*([^,\n]+),/g)) {
765 const rhs = m[1].trim();
766 if (rhs.startsWith('"')) { out.push(rhs.slice(1, -1)); continue; }
767 const c = rs.match(new RegExp('const ' + rhs + ': &str = "([a-z_]+)"'));
768 if (c) out.push(c[1]); else odd.push(rhs);
769 }
770 return { names: out, odd };
771}
772
773const registry = registryToolNames();
774check('the tool registry was actually read out of src/tools.rs',
775 registry.names.length > 20 && registry.odd.length === 0,
776 `${registry.names.length} tools, unreadable arms: ${registry.odd.join(', ') || 'none'}`);
777const unnamed = registry.names.filter((n) => client.TOOLS.indexOf(n) === -1);
778check('EVERY TOOL THE REGISTRY HAS IS NAMED IN THE CLIENT\'S TABLE',
779 unnamed.length === 0,
780 unnamed.length ? `${unnamed.length} would report as 'other': ${unnamed.join(', ')}` : '');
781// 'other' is position 0 and is not a tool; nothing else may repeat, or two tools
782// share a number and the operator cannot tell which ran.
783check('and no tool shares a number with another',
784 new Set(client.TOOLS).size === client.TOOLS.length,
785 client.TOOLS.filter((n, i) => client.TOOLS.indexOf(n) !== i).join(', '));
786// ┌───────────────────────────────────────────────────────────────────┐
787// │ 1c. The consent moment, and the three things that must come with │
788// │ it. Written as implications, so they hold today and bite on │
789// │ the day somebody builds it │
790// └───────────────────────────────────────────────────────────────────┘
791//
792// AUDITED 2026-08-14, AND NONE OF IT HAS MOVED. Nothing in the shipped app asks
793// a beta tester whether they will send usage counts. Passcode redemption is
794// built (`www/js/passcode.js`) and carries no such line; `www/js/legal.js` says
795// so in its own header, naming "the beta passcode's consent line" as the one
796// caller of `link()` that is not built; the published Privacy Policy enumerates
797// what the gateway holds without telemetry among it and states "We use no
798// analytics, advertising or cross-site tracking cookies".
799//
800// This section used to assert those two absences flat -- nothing grants consent,
801// nothing loads the module -- which is a check that goes red on the FIRST
802// correct step towards building the feature and says nothing about whether the
803// step was safe. What it should have been asserting is what has to be true
804// ALONGSIDE a grant, and the most important of those was never written down at
805// all:
806//
807// A GRANTED RECORDER CANNOT BE STOPPED FROM OUTSIDE `telemetry.js`.
808//
809// Measured, not reasoned: with the flush interval shortened and a consenting
810// session left to its own timer, replacing `window.DaimondTelemetry` wholesale
811// with a stub -- the most a module that may not edit `telemetry.js` can do --
812// stops nothing. `armed()` reads FALSE while the queued batch goes anyway
813// ({"v":1,"b":2536699389,...,"e":[[1,1,830],[4,1,2]]} left a stubbed page on
814// 2026-08-14). A settings pane built on that would show a tester "off" while
815// their events were in flight, which is the exact lie this whole file exists to
816// make impossible. The recorder holds its buffer, its timer and its `fetch` in
817// one closure and exports no way back out: `consent()` mints, and nothing
818// un-mints. A withdrawal belongs in that closure, beside `consent()`, and
819// nowhere else.
820//
821// So the three implications below. All three now BITE rather than hold
822// vacuously -- consent is granted from `www/js/passcode.js`, the client is
823// loaded by `index.html`, and the policy carries the section it links to -- and
824// each is proved red by a break: `halfway`, `eager` and `forget` on the first,
825// `inert` on the second, `undisclosed` on the third. The vacuous branches are
826// kept because they are what a build that UNWIRES this would take, and a check
827// that cannot describe that state would simply go quiet.
828
829/// The files under `www/js` -- `telemetry.js` itself excepted, since a module
830/// mentioning its own name proves nothing -- that match a pattern.
831function scanFor(what) {
832 return fs.readdirSync(path.join(ROOT, 'www/js'))
833 .filter((f) => f.endsWith('.js') && f !== 'telemetry.js')
834 .filter((f) => what.test(fs.readFileSync(path.join(ROOT, 'www/js', f), 'utf8')));
835}
836const callers = scanFor(/DaimondTelemetry\s*\.\s*consent/);
837const emitters = BREAK === 'inert' ? [] : scanFor(/DaimondTelemetry\s*\.\s*emit/);
838
839const indexHtml = fs.readFileSync(path.join(ROOT, 'www/index.html'), 'utf8');
840// Paired with its own presence check: an absence proved against a file that was
841// never read is the way both halves of this feature stayed invisible.
842check('index.html was actually read, and does load the sibling modules',
843 indexHtml.indexOf('js/chunks.js') !== -1, `${indexHtml.length} bytes`);
844const loaded = /<script[^>]+telemetry\.js/.test(indexHtml);
845
846// What the published policy would have to carry before the client may be loaded.
847// An id and not a sentence: prose is edited and an anchor is not, and this is
848// the anchor the consent line links to.
849const POLICY = path.join(ROOT, 'www/guide/legal/privacy.html');
850const policyHtml = fs.readFileSync(POLICY, 'utf8');
851check('the policy the consent line would link to was actually read',
852 policyHtml.indexOf('id="cookies"') !== -1, `${policyHtml.length} bytes`);
853const disclosed = BREAK !== 'undisclosed' && /id="beta-telemetry"/.test(policyHtml);
854
855// ── 1. Consent granted ⟹ consent can be taken back ──────────────────
856//
857// The property that matters most, and the only one worth a live session: not
858// "checked at startup" but checked against a batch that is already queued.
859// STANDING FROM THE MOMENT THE MODULE EXPORTS ONE, rather than from the moment
860// something calls `consent()`. Stopping a queued batch is a property of the
861// module, and a property nobody has watched hold is a property that stops
862// holding: waiting for a caller would have left it unwatched over exactly the
863// stretch of work where the withdrawal is written.
864if (callers.length === 0 && withdrawalNames().length === 0) {
865 check('nothing grants consent, so nothing can be left unable to withdraw it',
866 true, 'vacuous: nothing grants consent and the module offers no way back');
867} else {
868 const wd = await withdrawal();
869 check('a consenting session that is left alone DOES send, so the check below is not vacuous',
870 wd.control > 0, `${wd.control} batch(es) from the control session`);
871 check('the module exports a way to take consent back', wd.names.length > 0,
872 wd.names.length ? wd.names.join(', ')
873 : `granted by ${callers.join(', ') || 'something'}, and telemetry.js exports `
874 + 'none of it — a recorder is minted by consent() and nothing un-mints it');
875 check('AND WITHDRAWING STOPS A BATCH THAT IS ALREADY QUEUED', wd.after === 0,
876 wd.after < 0 ? 'never ran: there is nothing to withdraw with'
877 : `${wd.after} batch(es) left AFTER consent was withdrawn`);
878
879 // ── And both answers survive the reload that used to lose them ──
880 //
881 // The pair is the check. A build that never resumed would pass the
882 // withdrawal half while covering one sitting per tester, and a build that
883 // resumed unconditionally would pass the consent half while sending for
884 // somebody who had said stop. Only one build passes both.
885 const kept = await reloadSession({ label: 'wd-kept', withdrawFirst: false });
886 check('an agreement survives a reload, so the test is not one sitting per tester',
887 kept.resumed === true && kept.armed === true && kept.carried === true,
888 `resumed ${kept.resumed}, armed ${kept.armed}, the new page's own event `
889 + `${kept.carried ? 'arrived' : 'DID NOT arrive'} (${kept.batches} batch(es))`);
890
891 // ── And the card itself: three ways to say no, one to say yes ──
892 //
893 // The property the whole design rests on is that silence is a NO. It is
894 // structurally true -- nothing but a pressed button calls `consent()` -- and
895 // structural truths are exactly the ones that stop being true quietly, so
896 // all three refusals are driven rather than argued.
897 const said = await cardSession('yes');
898 check('the card asks, with both answers and a way to read what is sent',
899 said.asked === true && said.answers === 2 && said.sameSize === true
900 && said.href.indexOf('#beta-telemetry') !== -1,
901 `asked ${said.asked}, ${said.answers} answer(s), same size ${said.sameSize}, link ${said.href}`);
902 check('nothing is armed while the question is still on screen',
903 said.armedBefore === false, `armed before answering: ${said.armedBefore}`);
904 check('and pressing yes is what arms it, so the refusals below are not vacuous',
905 said.agreed === true, `agreed ${said.agreed}`);
906
907 const nope = await cardSession('no');
908 check('DECLINING LEAVES THE SAME STATE AS NEVER HAVING BEEN ASKED',
909 nope.armed === false && nope.agreed === false && nope.batches === 0,
910 `armed ${nope.armed}, agreed ${nope.agreed}, ${nope.batches} batch(es)`);
911
912 const shut = await cardSession('escape');
913 check('AND CLOSING THE CARD IS A DECLINE — not a question held open',
914 shut.armed === false && shut.agreed === false && shut.batches === 0
915 && shut.cardUp === false,
916 `armed ${shut.armed}, agreed ${shut.agreed}, card still up ${shut.cardUp}, `
917 + `${shut.batches} batch(es)`);
918
919 const gone = await reloadSession({ label: 'wd-gone', withdrawFirst: true });
920 check('AND A WITHDRAWAL SURVIVES ONE TOO — reopening the app does not start it again',
921 gone.resumed === false && gone.armed === false && gone.agreed === false
922 && gone.carried === false && gone.batches === 0,
923 `resumed ${gone.resumed}, armed ${gone.armed}, agreed ${gone.agreed}, `
924 + `${gone.batches} batch(es) after reopening, event carried ${gone.carried}`);
925}
926
927// ── 2. The client is loaded ⟹ something emits ───────────────────────
928//
929// A client on the page with no call sites is a consent moment asking for
930// permission to send nothing, which is a worse state than not shipping it: the
931// tester has agreed to something and the operator has no data to show for it.
932check('telemetry.js is loaded only where something actually emits',
933 !loaded || emitters.length > 0,
934 loaded ? `loaded, and ${emitters.length} file(s) name DaimondTelemetry.emit`
935 : 'vacuous: the client is not loaded');
936
937// AND THE CALL SITES ARE DRIVEN, not counted. The check above is satisfied by a
938// file that mentions the module; this one uses the app and reads what came out.
939// A build whose emits were all deleted passes every other check in this file.
940if (emitters.length > 0) {
941 console.log('\n— a consenting session that is USED, with this file emitting nothing —');
942 const real = await realSession('real');
943 check('the session consented, so there was somewhere for an event to go', real.armed === true);
944 const sent = [...real.codes].sort((a, b) => a - b);
945 // The two the turn itself must produce. Named rather than counted: "some
946 // events arrived" would pass on a build that reported only its own startup.
947 check('A REAL TURN REPORTS ITSELF — the app emitted turn.send and turn.done',
948 real.codes.has(7) && real.codes.has(8), `codes seen: ${sent.join(',') || 'none'}`);
949 check('and nothing the app did put a word of anybody\'s content on the wire',
950 leaks(real.requests, toApp).length === 0, leaks(real.requests, toApp).join('; '));
951 const realFaults = real.requests.filter(toTelemetry).flatMap((r) => shapeFaults(r.body));
952 check('and every batch it sent is whole numbers under the declared field names',
953 realFaults.length === 0, realFaults.join('; '));
954}
955
956// ── 3. The client is loaded ⟹ the policy says so ────────────────────
957//
958// The order is the point. A build that sends usage counts under a policy that
959// says "we use no analytics" has broken a published promise, whatever the
960// dialog said.
961check('telemetry.js is loaded only where the Privacy Policy describes what it sends',
962 !loaded || disclosed,
963 loaded ? `policy section id="beta-telemetry": ${disclosed ? 'present' : 'MISSING'}`
964 : 'vacuous: the client is not loaded');
965
966// ┌───────────────────────────────────────────────────────────────────┐
967// │ 1b. Three ceilings, and two fields that are not counts │
968// └───────────────────────────────────────────────────────────────────┘
969//
970// `MAX_N` guarded every field at both ends. It is `i32::MAX`, and `b` is eight
971// hex digits of the build id read as a number -- a u32. So every build whose id
972// begins 8-f (64 of 127 sealed builds, by the transparency log) was floored to
973// zero by the client, and would have had its WHOLE BATCH refused by the gateway
974// had it not been. `t` is whole seconds since 1970 and would have stopped in
975// 2038. The checks below assert the property rather than the day's build id,
976// which is what the previous check did and why it could pass on a coin flip.
977
978/// A named integer constant out of the Rust, or NaN.
979function rustConst(name) {
980 const m = fs.readFileSync(SRC_RS, 'utf8')
981 .match(new RegExp('pub const ' + name + ':\\s*i64\\s*=\\s*([0-9_]+)\\s*;'));
982 return m ? Number(m[1].replace(/_/g, '')) : NaN;
983}
984const rustN = rustConst('MAX_N'), rustB = rustConst('MAX_BUILD'), rustT = rustConst('MAX_TIME');
985check('the gateway declares all three ceilings',
986 Number.isFinite(rustN) && Number.isFinite(rustB) && Number.isFinite(rustT),
987 `MAX_N=${rustN} MAX_BUILD=${rustB} MAX_TIME=${rustT}`);
988check('the client and the gateway agree on the count ceiling', client.MAX_N === rustN,
989 `client ${client.MAX_N}, gateway ${rustN}`);
990// The one that matters most: if these two ever differ again, the client either
991// zeroes the field or the gateway drops the batch, and both are silent.
992check('and on the build ceiling — the pair whose disagreement caused this',
993 client.MAX_BUILD === rustB, `client ${client.MAX_BUILD}, gateway ${rustB}`);
994check('and on the send-stamp ceiling', client.MAX_TIME === rustT,
995 `client ${client.MAX_TIME}, gateway ${rustT}`);
996check('the build ceiling covers every eight-hex-digit id', client.MAX_BUILD >= 0xffffffff,
997 String(client.MAX_BUILD));
998check('the send stamp outlives 2038', client.MAX_TIME > 2147483648, String(client.MAX_TIME));
999
1000// The fixture, and the proof that it is not vacuous: an ordinal BELOW the count
1001// ceiling would pass this check under the broken code too.
1002const HI_ID = 'f7bd6f814c2a';
1003const HI_ORD = client.buildOrdinal(HI_ID);
1004check('the fixture build id really is over the count ceiling, so this can fail',
1005 HI_ORD > client.MAX_N, `${HI_ID} -> ${HI_ORD}, MAX_N ${client.MAX_N}`);
1006check('an eight-hex-digit build id reads as its true ordinal',
1007 HI_ORD === parseInt(HI_ID.slice(0, 8), 16), String(HI_ORD));
1008
1009const hiBatch = client.pack(3, HI_ORD, [[1, 0, 830]], 0);
1010check('and pack() carries it whole rather than flooring it to zero',
1011 hiBatch.b === HI_ORD, `b=${hiBatch.b}, wanted ${HI_ORD}`);
1012check('and the module\'s own last gate accepts the batch carrying it',
1013 client.onlyIntegers(hiBatch) === true);
1014check('the stamp is a real clock, not a floored one',
1015 hiBatch.t > 1750000000 && hiBatch.t < client.MAX_TIME, String(hiBatch.t));
1016
1017// The other half of the property: widening two fields must not have widened the
1018// one where the capacity actually is. A count is still a count.
1019const overBatch = client.pack(3, HI_ORD, [[1, 0, client.MAX_N + 1]], client.MAX_N + 1);
1020check('a count above the count ceiling is still floored to zero',
1021 overBatch.d === 0 && overBatch.e[0][2] === 0,
1022 `d=${overBatch.d}, n=${overBatch.e[0][2]}`);
1023check('and a build id beyond eight hex digits is floored too',
1024 client.pack(3, client.MAX_BUILD + 1, [], 0).b === 0);
1025
1026// ┌───────────────────────────────────────────────────────────────────┐
1027// │ 2. Before consent, nothing leaves │
1028// └───────────────────────────────────────────────────────────────────┘
1029
1030console.log('\n— a session that never consents —');
1031const quiet = await runSession({ label: 'quiet',
1032 patch: BREAK === 'consent' ? breakWithSelfConsent : (s) => s, giveConsent: false });
1033
1034check('the module loaded into the page', quiet.present.module);
1035check('the marker really is in a chat', quiet.present.chat);
1036check('the marker really is a Diamond\'s name', quiet.present.diamond);
1037check('the marker really is a file in the workspace', quiet.present.file);
1038
1039check('no recorder exists', quiet.before.armed === false);
1040check('emitting does nothing', quiet.before.emitted === false);
1041check('and a flush has nothing to send', quiet.before.flushed === false && quiet.sent === false);
1042const quietBatches = quiet.requests.filter(toTelemetry);
1043check('nothing was sent to the telemetry endpoint at all', quietBatches.length === 0,
1044 `${quietBatches.length} request(s)`);
1045check('and no marker reached our origin by any other route',
1046 leaks(quiet.requests, toApp).length === 0, leaks(quiet.requests, toApp).join('; '));
1047
1048// ┌───────────────────────────────────────────────────────────────────┐
1049// │ 3. After consent, batches leave — carrying numbers only │
1050// └───────────────────────────────────────────────────────────────────┘
1051
1052console.log('\n— a session that consents —');
1053const live = await runSession({ label: 'live',
1054 patch: BREAK === 'note' ? breakWithNote : (s) => s, giveConsent: true });
1055
1056check('the marker really is in a chat', live.present.chat);
1057check('the marker really is a Diamond\'s name', live.present.diamond);
1058check('the marker really is a file in the workspace', live.present.file);
1059check('a beta grant mints a recorder', live.after && live.after.granted === true && live.after.armed === true);
1060check('and the wave it was granted is the wave it holds', live.after && live.after.wave === 3);
1061
1062const batches = live.requests.filter(toTelemetry);
1063check('a batch actually left the browser', batches.length > 0, `${batches.length} request(s)`);
1064if (batches.length) console.log(' the batch, verbatim: ' + batches[0].body.slice(0, 300));
1065check('it was a POST with no query string on the address',
1066 batches.every((r) => r.method === 'POST' && r.url.indexOf('?') === -1));
1067// Which build a batch came from is the first thing an operator asks of a beta
1068// report, and it is read asynchronously -- so the first flush of a session is
1069// exactly the one a race would rob of it. Proved against `build.json`, not
1070// against "not zero", so a wrong number could not pass.
1071const stampedBuild = (() => { try { return JSON.parse(batches[0].body).b; } catch (e) { return -1; } })();
1072const wantBuild = (() => {
1073 try { return parseInt(JSON.parse(fs.readFileSync(path.join(ROOT, 'www/build.json'), 'utf8')).build.slice(0, 8), 16); }
1074 catch (e) { return -2; }
1075})();
1076check('and it names the build it came from', stampedBuild === wantBuild,
1077 `sent ${stampedBuild}, build.json says ${wantBuild}`);
1078
1079// THE check. Every marker, against every body and address that went to our own
1080// origin.
1081const wire = leaks(live.requests, toTelemetry);
1082check('NO WORD OF THE USER\'S CONTENT IS IN WHAT WAS SENT', wire.length === 0, wire.join('; '));
1083check('and none of it reached our origin by any other route',
1084 leaks(live.requests, toApp).length === 0, leaks(live.requests, toApp).join('; '));
1085
1086const faults = batches.flatMap((r) => shapeFaults(r.body));
1087check('every batch is whole numbers under the declared field names',
1088 faults.length === 0, faults.join('; '));
1089
1090// The batch is the one we asked for, not an empty shell that would make the
1091// check above true for the wrong reason.
1092const got = codesIn(live.requests);
1093const want = new Set([1, 3, 6, 7, 14]);
1094check('the batch carries exactly the events emitted after consent',
1095 got.size === want.size && [...want].every((c) => got.has(c)),
1096 `sent ${[...got].sort((a, b) => a - b).join(',')}`);
1097// Emitted before the grant, and never kept: consent is not retroactive.
1098check('and nothing emitted before consent was kept and sent later',
1099 !got.has(4) && !got.has(12), `panel.open=${got.has(4)} tool.run=${got.has(12)}`);
1100
1101// ┌───────────────────────────────────────────────────────────────────┐
1102// │ 3b. And the same property AT THE NETWORK, on a build id that │
1103// │ the old ceiling would have thrown away │
1104// └───────────────────────────────────────────────────────────────────┘
1105//
1106// The check above compares against `www/build.json`, so what it proves depends
1107// on the day's build id: `138e6581` fits under the old ceiling and `9732f5fd`
1108// does not. A check that passes or fails on one hex digit is a check that will
1109// report this defect fixed roughly half the time. Here the id is chosen, so the
1110// property holds whatever has been built.
1111
1112console.log('\n— a session whose build id begins with f —');
1113const hi = await runSession({ label: 'hibuild', quick: true, giveConsent: true,
1114 buildId: HI_ID, patch: BREAK === 'narrow' ? breakWithNarrowCeiling : (s) => s });
1115
1116const hiBatches = hi.requests.filter(toTelemetry);
1117check('the high-build session sent a batch, so the check below is not vacuous',
1118 hiBatches.length > 0, `${hiBatches.length} request(s)`);
1119const hiSent = (() => { try { return JSON.parse(hiBatches[0].body).b; } catch (e) { return -1; } })();
1120check('a build id beginning with f arrives whole, not as zero',
1121 hiSent === HI_ORD, `sent ${hiSent}, wanted ${HI_ORD}`);
1122check('and the gateway would take it: it is inside the build ceiling both ends declare',
1123 hiSent > 0 && hiSent <= rustB && hiSent <= client.MAX_BUILD,
1124 `${hiSent} vs gateway ${rustB}`);
1125const hiStamp = (() => { try { return JSON.parse(hiBatches[0].body).t; } catch (e) { return -1; } })();
1126check('and the send stamp is a plausible clock rather than a floored field',
1127 hiStamp > 1750000000 && hiStamp <= rustT, String(hiStamp));
1128
1129// ┌───────────────────────────────────────────────────────────────────┐
1130// │ 4. The leak check, proved red │
1131// └───────────────────────────────────────────────────────────────────┘
1132
1133console.log('\n— the same session, with a "note" field added on purpose —');
1134const leaky = await runSession({ label: 'leaky', patch: breakWithNote, giveConsent: true });
1135
1136const leakyBatches = leaky.requests.filter(toTelemetry);
1137check('the broken build still sent a batch, so the check below is not vacuous',
1138 leakyBatches.length > 0, `${leakyBatches.length} request(s)`);
1139const caught = leaks(leaky.requests, toTelemetry);
1140check('THE LEAK CHECK FIRES on a build that adds one string field',
1141 caught.length > 0, caught.join('; '));
1142check('and it names the chat the user typed', caught.some((c) => c.indexOf(MARK.chat) === 0),
1143 caught.join('; '));
1144check('the shape check fires on it too', leakyBatches.flatMap((r) => shapeFaults(r.body)).length > 0,
1145 leakyBatches.flatMap((r) => shapeFaults(r.body)).join('; '));
1146
1147// ┌───────────────────────────────────────────────────────────────────┐
1148// │ 5. The before-consent check, proved red │
1149// └───────────────────────────────────────────────────────────────────┘
1150
1151console.log('\n— a build that consents to itself, never having been asked —');
1152const forward = await runSession({ label: 'forward', patch: breakWithSelfConsent, giveConsent: false });
1153
1154check('THE BEFORE-CONSENT CHECK FIRES on a build that arms itself',
1155 forward.requests.filter(toTelemetry).length > 0,
1156 `${forward.requests.filter(toTelemetry).length} request(s) — the quiet session had ${quietBatches.length}`);
1157
1158console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
1159process.exit(bad.length ? 1 : 0);