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
+14 -6
View File
@@ -47,11 +47,19 @@ function tools() {
});
}
function poolFor(tool, level) {
// A tester on a level override can SEE every tier, but must not be able to
// hand out a real Corona pool — 200,000 network impressions given to real
// members is real inventory, spent for real. Preview pools are big enough to
// exercise the whole flow and small enough that losing them costs nothing.
const PREVIEW_POOL = { traffic: 5000, copy: 25, page: 2, textad: 5 };
function poolFor(tool, level, preview) {
const t = POOLS[tool];
if (!t) return 0;
const lv = Math.max(1, Math.min(8, Number(level) || 1));
return t.pool[lv - 1] || 0;
const base = t.pool[lv - 1] || 0;
if (preview) return Math.min(base, PREVIEW_POOL[tool] || 0);
return base;
}
function rows(mk) {
@@ -86,10 +94,10 @@ function receivedDetail(memberId) {
return out;
}
function status(memberId, level) {
function status(memberId, level, preview) {
const t = {};
Object.keys(POOLS).forEach(function (k) {
const pool = poolFor(k, level);
const pool = poolFor(k, level, preview);
const given = givenBy(memberId, k);
t[k] = {
label: POOLS[k].label, unit: POOLS[k].unit, step: POOLS[k].step, max: POOLS[k].max,
@@ -97,7 +105,7 @@ function status(memberId, level) {
};
});
return {
level: Number(level) || 0, canGrant: Number(level) >= MIN_LEVEL,
level: Number(level) || 0, canGrant: Number(level) >= MIN_LEVEL, preview: !!preview,
tools: t, resets: monthKey(),
history: rows().filter(function (r) { return Number(r.by) === Number(memberId); }).slice(-40).reverse(),
received: receivedDetail(memberId)
@@ -119,7 +127,7 @@ function grant(opts) {
if (n <= 0) throw new Error('Choose how much to give.');
if (n > POOLS[tool].max) throw new Error('The most you can give one person at a time is ' + POOLS[tool].max.toLocaleString() + '.');
const pool = poolFor(tool, level);
const pool = poolFor(tool, level, opts.preview);
const given = givenBy(by, tool);
if (given + n > pool) {
throw new Error('That is more than you have left to give this month — ' +