Beta-tester safety: preview caps on the level override, and close a race in stop()

Two things needed before testers get level overrides.

1. The override decided what a position could SPEND as well as what it could
   SEE. A tester at Scintilla overridden to Corona would have had 150,000 real
   network impressions instead of 2,500, and a 200,000-impression grant pool to
   hand to real members — real inventory, spent for real. Ad allowances and
   split-test launches are now metered on trueLevel, and grant pools are capped
   to small preview amounts (5,000 impressions / 25 copy / 2 pages / 5 ad
   batches) when a position is overridden: enough to exercise the whole flow,
   trivial to lose.

2. suite-traffic.stop() read the ledger, made TWO network round trips to the ad
   network, then wrote back the object it had read seconds earlier — silently
   discarding any campaign another member launched in that window, with the
   impressions already spent on the network. It now re-reads after the awaits
   and mutates the fresh copy.

Worth recording that this was the ONLY such race: Node's single thread makes a
synchronous read-modify-write atomic, so the meter, grants and split ledgers
were never at risk. The danger was only ever the await gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 16:01:53 -05:00
parent d5385b0512
commit 343f1edd57
3 changed files with 49 additions and 20 deletions
+19 -4
View File
@@ -189,9 +189,9 @@ async function stop(memberId, adId) {
const all = readLedger();
const mk = monthKey();
const rows = ((all[mk] || {})[String(memberId)]) || [];
const row = rows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!row) throw new Error('That banner is not one of yours from this month.');
if (row.stopped) throw new Error('That banner is already stopped.');
const pre = rows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!pre) throw new Error('That banner is not one of yours from this month.');
if (pre.stopped) throw new Error('That banner is already stopped.');
let served = 0, unserved = 0;
try {
@@ -205,6 +205,21 @@ async function stop(memberId, adId) {
await callNas({ action: 'deactivate', ad_id: Number(adId) });
// RE-READ before writing. Everything above this point took two network round
// trips to the ad network, and the ledger object read at the top of this
// function is now seconds stale. Writing it back would silently discard any
// campaign another member launched in the meantime — impressions spent on the
// network with no record that they were. Node's single thread makes the block
// below atomic; the danger was only ever the await gap.
const fresh = readLedger();
const freshRows = ((fresh[mk] || {})[String(memberId)]) || [];
const row2 = freshRows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!row2) throw new Error('That banner is no longer in this month\'s ledger.');
if (row2.stopped) return { adId: Number(adId), served: row2.served || 0, refunded: row2.refunded || 0 };
const all2 = fresh;
const row = row2;
// Charge only what actually served; the rest returns to the allowance.
// `bought` preserves the original order size so the member's history still
// shows what they launched, not just what it ended up costing them.
@@ -220,7 +235,7 @@ async function stop(memberId, adId) {
row.served = served;
row.refunded = unserved;
row.impressions = served; // what this campaign counts against the month
writeLedger(all);
writeLedger(all2);
return { adId: Number(adId), served: served, refunded: unserved };
}