Files
rm-circle-team-router/suite-grants.js
T
martbost 343f1edd57 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>
2026-08-28 16:01:53 -05:00

152 lines
6.4 KiB
JavaScript

// Circle Suite — Team Grants (Vertex L7, Corona L8).
//
// The problem this fixes: every tool from L1 to L8 was a "me" tool — help ME
// write, help ME post, help ME measure. But past the first couple of levels the
// business is not about you. It is get two, help your two, teach them to teach.
// So the top of the ladder was scaling the wrong axis: more of your own
// capability, at double the price each rung.
//
// A grant lets a leader hand Suite capacity DOWN into their own organisation —
// impressions, generations, page builds, ad batches. Three things make it work:
//
// 1. It comes from a POOL that is a perk of the level, not a tax on the
// leader's own allowance. Making leaders choose between equipping their
// team and using their own tools would just mean nobody grants anything.
// 2. It can only go to someone inside the grantor's own organisation, checked
// against the contract. This is not a way to hand capacity to strangers.
// 3. It expires with the month, like every other allowance here. Grants are
// for unblocking someone now, not for building up a balance.
'use strict';
const fs = require('fs');
const path = require('path');
let DATA_DIR = null;
function init(opts) { DATA_DIR = opts.dataDir; }
function file() { return path.join(DATA_DIR, 'team-grants.json'); }
function readAll() { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch (e) { return {}; } }
function writeAll(v) { try { fs.writeFileSync(file(), JSON.stringify(v)); } catch (e) {} }
function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
const MIN_LEVEL = 7;
// What a leader can give away each month, by level (index = level - 1).
// Zero below Vertex — this is the thing the top of the ladder buys.
const POOLS = {
traffic: { label: 'Ad impressions', unit: 'impressions', step: 1000, max: 50000,
pool: [0, 0, 0, 0, 0, 0, 60000, 200000] },
copy: { label: 'Copy Engine generations', unit: 'generations', step: 25, max: 300,
pool: [0, 0, 0, 0, 0, 0, 300, 1000] },
page: { label: 'Page builds', unit: 'pages', step: 1, max: 20,
pool: [0, 0, 0, 0, 0, 0, 15, 50] },
textad: { label: 'Text ad batches', unit: 'batches', step: 5, max: 60,
pool: [0, 0, 0, 0, 0, 0, 60, 200] }
};
function tools() {
return Object.keys(POOLS).map(function (k) {
return { key: k, label: POOLS[k].label, unit: POOLS[k].unit, step: POOLS[k].step, max: POOLS[k].max };
});
}
// 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));
const base = t.pool[lv - 1] || 0;
if (preview) return Math.min(base, PREVIEW_POOL[tool] || 0);
return base;
}
function rows(mk) {
const all = readAll();
return all[mk || monthKey()] || [];
}
// How much of `tool` this position has RECEIVED this month.
function receivedBy(memberId, tool) {
return rows().reduce(function (s, r) {
return s + ((Number(r.to) === Number(memberId) && r.tool === tool) ? Number(r.n) || 0 : 0);
}, 0);
}
// How much of `tool` this position has GIVEN AWAY this month.
function givenBy(memberId, tool) {
return rows().reduce(function (s, r) {
return s + ((Number(r.by) === Number(memberId) && r.tool === tool) ? Number(r.n) || 0 : 0);
}, 0);
}
// Who gave it to them — so a recipient can see it came from their upline and
// not from nowhere. That is half the point: it should feel like being helped.
function receivedDetail(memberId) {
const out = {};
rows().forEach(function (r) {
if (Number(r.to) !== Number(memberId)) return;
if (!out[r.tool]) out[r.tool] = { total: 0, from: [] };
out[r.tool].total += Number(r.n) || 0;
out[r.tool].from.push({ by: Number(r.by), n: Number(r.n), at: r.at });
});
return out;
}
function status(memberId, level, preview) {
const t = {};
Object.keys(POOLS).forEach(function (k) {
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,
pool: pool, given: given, remaining: Math.max(0, pool - given)
};
});
return {
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)
};
}
// Hand capacity to someone. The caller is responsible for having verified that
// `to` is inside `by`'s organisation — that check needs the chain and lives in
// the route, not here.
function grant(opts) {
const by = Number(opts.by), to = Number(opts.to);
const tool = String(opts.tool || '');
const n = Math.floor(Number(opts.n) || 0);
const level = Number(opts.level) || 0;
if (!POOLS[tool]) throw new Error('That is not something you can grant.');
if (level < MIN_LEVEL) throw new Error('Team Grants unlock at Vertex (level 7).');
if (!to || to === by) throw new Error('Pick someone in your team other than yourself.');
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, 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 — ' +
Math.max(0, pool - given).toLocaleString() + ' ' + POOLS[tool].unit + ' remaining.');
}
const all = readAll();
const mk = monthKey();
if (!all[mk]) all[mk] = [];
const rec = { by: by, to: to, tool: tool, n: n, at: new Date().toISOString() };
all[mk].push(rec);
// Keep three months, same as every other ledger here.
const keep = Object.keys(all).sort().slice(-3);
const trimmed = {};
keep.forEach(function (k) { trimmed[k] = all[k]; });
writeAll(trimmed);
return rec;
}
module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, receivedBy, givenBy, receivedDetail };