@bongos/core 1.19.733 → 1.19.734

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/lib/spend-ceiling.js — the per-payer MONTHLY SPEND CEILING
4
+ // (task 1003884).
5
+ //
6
+ // WHY THIS IS NOT fire-budget.js, and must never become it. That file is a
7
+ // sliding-window ceiling on how OFTEN a builder may fire, and its own header says
8
+ // what it is not: "A CEILING, NOT A BUDGET. This bounds the blast radius of a
9
+ // loop; it does not decide what agents may cost... Do not grow this into a budget
10
+ // — a ceiling that silently became the accounting would be a second, wrong answer
11
+ // to 'what did agents cost'." This file is the other half it deferred to: it reads
12
+ // the REAL accounting, `agents_runs.cost_usd`, which the grade port bills through
13
+ // the shared LLM cost cache, and refuses a fire when the payer has spent their
14
+ // month.
15
+ //
16
+ // The two bound different things and neither substitutes for the other:
17
+ //
18
+ // fire-budget 10 fires/hour/BUILDER bounds a runaway loop, in real time
19
+ // spend-ceiling $N/month/PAYER bounds the BILL, off the real ledger
20
+ //
21
+ // A rate ceiling cannot bound a bill (ten fires an hour is ~$650/month/builder),
22
+ // and a spend ceiling cannot bound a loop (it reads spend that has already landed).
23
+ // Both run, in that order, on every fire.
24
+ //
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // IT FAILS CLOSED, AND THAT IS THE WHOLE POINT.
27
+ //
28
+ // The prior art is task 1001393, which was filed because the autonomy spend-brake
29
+ // was structurally BLIND: gatherSignals() read month-to-date spend from a database
30
+ // that was unreachable from where the spender ran, so the precheck FAILED OPEN and
31
+ // the brake never tripped once. A spend guard that answers "I don't know" with
32
+ // "go ahead" is not a guard. So every one of these refuses:
33
+ //
34
+ // • no cap configured, or a cap that is not a positive finite number
35
+ // — an unbounded ceiling is the defect above, wearing a cap's name;
36
+ // • no payer to bill — an unattributable spend is what the ledger exists to
37
+ // prevent, the same fail-closed reading fire-budget gives an unidentified
38
+ // caller;
39
+ // • the ledger read THREW — the exact failure 1001393 named. "The database is
40
+ // unreachable" is the moment a ceiling matters most, not least;
41
+ // • the payer has spent at or past the cap.
42
+ //
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // THE KNOWN OVERSHOOT, stated rather than hidden. A fire is checked BEFORE it
45
+ // runs and its cost lands AFTER it finishes (a pending run carries cost_usd NULL),
46
+ // so fires already in flight are invisible to this read. The overshoot is
47
+ // therefore bounded by concurrent-fires × ~$0.90 — and what bounds the concurrent
48
+ // count is fire-budget. That is the second reason both exist: each one's blind
49
+ // spot is the other's window.
50
+ //
51
+ // Requires nothing (ADR 0083): the pool is passed in, the clock is injected, and
52
+ // the cap comes from env — so the whole file is testable without a database.
53
+
54
+ // The default is deliberately SMALL. A default cap is the number that applies to
55
+ // an instance whose owner never thought about this, and for them the right answer
56
+ // is "enough to try it, not enough to hurt". At the historian exemplar's ~$0.90 a
57
+ // fire that is roughly 27 fires a month. An instance that means to spend more says
58
+ // so with one env var; an instance that meant to spend nothing was never going to
59
+ // be saved by a generous default.
60
+ const DEFAULT_CAP_USD = 25;
61
+
62
+ // Calendar month, UTC. It matches the window agents_001's spend index was built
63
+ // for ("What did agents cost this month?") and the $/month cap fire-budget's
64
+ // header defers to — and a calendar month is the unit a bill arrives in, which is
65
+ // the unit an owner reasons about.
66
+ function monthStartUtc(at) {
67
+ const d = new Date(at);
68
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
69
+ }
70
+
71
+ function envNumber(name, fallback) {
72
+ const raw = process.env[name];
73
+ if (raw === undefined || raw === '') return fallback;
74
+ const n = Number(raw);
75
+ return Number.isFinite(n) && n > 0 ? n : fallback;
76
+ }
77
+
78
+ // WHO PAYS for one fire, given the project's setting and who asked. PURE.
79
+ //
80
+ // Two settings map to a payer directly; `none` — and anything unset, misspelled,
81
+ // or from a newer core — attributes to the PROJECT, because that is who the money
82
+ // actually came from. `none` is not a third kind of payer, it is the owner not
83
+ // having said, and its real consequence lands at config load: src/modules.js
84
+ // refuses to enable a metered module on an OPEN-door instance whose owner has not
85
+ // said. On a closed-door instance the module keeps working and the project's own
86
+ // ceiling bounds it, which is today's behaviour made explicit rather than changed.
87
+ //
88
+ // AN EVENT FIRE HAS NO CALLER, so `builder` cannot apply to one: nobody asked for
89
+ // it and there is nobody to bill. It falls to the project, which is the only
90
+ // honest answer — and never to "no payer", which would make an event fire the one
91
+ // unbounded path through this file.
92
+ function payerFor(spendPayer, requestedByBuilderId) {
93
+ if (spendPayer === 'builder' && requestedByBuilderId !== null && requestedByBuilderId !== undefined) {
94
+ return { payer_kind: 'builder', payer_builder_id: requestedByBuilderId };
95
+ }
96
+ return { payer_kind: 'project', payer_builder_id: null };
97
+ }
98
+
99
+ function createSpendCeiling({
100
+ capUsd = envNumber('BONGOS_AGENTS_SPEND_CAP_USD', DEFAULT_CAP_USD),
101
+ now = () => Date.now(),
102
+ } = {}) {
103
+ return {
104
+ capUsd,
105
+
106
+ /**
107
+ * May this payer spend right now?
108
+ *
109
+ * Unlike fire-budget.check this records NOTHING: the ledger row the spawn
110
+ * writes is the record, and a ceiling that also kept its own tally would be
111
+ * the second, disagreeing answer this module is careful not to have.
112
+ *
113
+ * @returns {{ok: boolean, spentUsd: number|null, capUsd: number, remainingUsd: number, reason: string|null}}
114
+ */
115
+ async check(pool, payer) {
116
+ const cap = Number(capUsd);
117
+ const deny = (reason) => ({ ok: false, spentUsd: null, capUsd: cap, remainingUsd: 0, reason });
118
+
119
+ if (!Number.isFinite(cap) || cap <= 0) return deny('no_spend_cap_configured');
120
+ const kind = payer && payer.payer_kind;
121
+ if (kind !== 'project' && kind !== 'builder') return deny('no_payer');
122
+ const builderId = payer.payer_builder_id ?? null;
123
+ if (kind === 'builder' && builderId === null) return deny('no_payer');
124
+ if (!pool || typeof pool.query !== 'function') return deny('ledger_unreadable');
125
+
126
+ const since = monthStartUtc(now());
127
+ let spent;
128
+ try {
129
+ // Summed BY PAYER, so a builder-paid instance gives each builder their own
130
+ // month and a project-paid one sums the instance. Rows written before
131
+ // agents_002 carry no payer_kind and match neither, which is the
132
+ // conservative direction: nobody is charged for spend that was not
133
+ // attributed to them.
134
+ const { rows } = await pool.query(
135
+ `SELECT COALESCE(SUM(cost_usd), 0) AS spent
136
+ FROM agents_runs
137
+ WHERE cost_usd IS NOT NULL
138
+ AND created_at >= $1
139
+ AND payer_kind = $2
140
+ AND payer_builder_id IS NOT DISTINCT FROM $3`,
141
+ [since, kind, builderId],
142
+ );
143
+ const raw = rows && rows[0] ? rows[0].spent : undefined;
144
+ // NOT `Number(raw)` alone. Number(null) is 0, so a sum that came back as
145
+ // SQL NULL — a shape COALESCE should make impossible, and would therefore
146
+ // only appear if this query or that table changed under us — would read
147
+ // as "this payer has spent nothing" and admit the fire. That is the
148
+ // fail-OPEN direction wearing a plausible number, which is the whole
149
+ // failure mode task 1001393 was filed about, so an absent value is
150
+ // rejected BEFORE it can be coerced into a permissive one.
151
+ spent = (raw === null || raw === undefined) ? Number.NaN : Number(raw);
152
+ } catch (_) {
153
+ // THE 1001393 CASE. An unreadable ledger is the one this file exists for.
154
+ return deny('ledger_unreadable');
155
+ }
156
+ // A sum that is not a number is the same "I don't know" as a throw, and gets
157
+ // the same answer. NUMERIC comes back as a string from pg, so this catches a
158
+ // genuinely unparseable value, not the ordinary one.
159
+ if (!Number.isFinite(spent) || spent < 0) return deny('ledger_unreadable');
160
+
161
+ const remaining = cap - spent;
162
+ if (remaining <= 0) {
163
+ return { ok: false, spentUsd: spent, capUsd: cap, remainingUsd: 0, reason: 'spend_cap_reached' };
164
+ }
165
+ return { ok: true, spentUsd: spent, capUsd: cap, remainingUsd: remaining, reason: null };
166
+ },
167
+ };
168
+ }
169
+
170
+ module.exports = { createSpendCeiling, payerFor, monthStartUtc, DEFAULT_CAP_USD };
@@ -0,0 +1,87 @@
1
+ -- agents_002_payer.sql — WHO PAYS for a fire (task 1003884).
2
+ --
3
+ -- agents_001 recorded what a fire COST (`cost_usd`, billed through the shared LLM
4
+ -- cost cache) and who ASKED for it (`requested_by_builder_id`). It did not record
5
+ -- who is on the HOOK for it, and those are not the same question: with
6
+ -- `spend_payer: project` the instance pays for a fire a stranger requested, and
7
+ -- with `spend_payer: builder` the requester does. Without the answer stored, a
8
+ -- per-payer ceiling has nothing to sum and a change to the setting would silently
9
+ -- re-interpret every historical row.
10
+ --
11
+ -- WHY IT IS STORED AND NOT DERIVED. `payer_kind` is derivable TODAY from the
12
+ -- project's setting plus `requested_by_builder_id` — and that is exactly the trap.
13
+ -- The setting is a live knob an owner may flip; deriving would make last month's
14
+ -- bill change when they do, and a ledger whose past moves is not a ledger. The
15
+ -- run records the answer that was true when it fired, the same reason `model`
16
+ -- stores the concrete model id rather than the tier that resolved it.
17
+ --
18
+ -- THE VOCABULARY IS TWO WORDS, AND 'none' IS NOT ONE OF THEM. The project setting
19
+ -- has a third value (`none` — the owner has never said), but a RUN always has a
20
+ -- real payer: `none` attributes to the project, because that is who the money
21
+ -- actually came from. `none` does its work at config load instead, where
22
+ -- src/modules.js refuses to ENABLE a module that meters spend on an instance whose
23
+ -- join door is `open` and whose owner has not said who pays.
24
+ --
25
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
26
+ -- object is agents_-prefixed, there is no down-migration, and the filename is
27
+ -- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run.
28
+
29
+ BEGIN;
30
+
31
+ -- 'project' (whoever runs the instance) | 'builder' (the caller who spent it).
32
+ ALTER TABLE agents_runs ADD COLUMN IF NOT EXISTS payer_kind text;
33
+ -- WHICH builder, when the payer is one. ON DELETE SET NULL matches
34
+ -- requested_by_builder_id: deleting a person must not erase what the instance
35
+ -- spent, and `payer_kind` survives to say the bill had an owner.
36
+ ALTER TABLE agents_runs ADD COLUMN IF NOT EXISTS payer_builder_id bigint
37
+ REFERENCES builders(id) ON DELETE SET NULL;
38
+
39
+ -- Idempotent constraint adds: ADD CONSTRAINT has no IF NOT EXISTS, so each is
40
+ -- guarded by its own catalog lookup rather than by a re-runnable DDL form.
41
+ DO $$
42
+ BEGIN
43
+ -- The closed vocabulary, for the same fail-closed reason every other enum in
44
+ -- agents_001 is text + CHECK: an unwired payer name is refused, never stored.
45
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_runs_payer_kind_chk') THEN
46
+ ALTER TABLE agents_runs ADD CONSTRAINT agents_runs_payer_kind_chk
47
+ CHECK (payer_kind IS NULL OR payer_kind IN ('project', 'builder'));
48
+ END IF;
49
+
50
+ -- A builder payer must NAME the builder; a project payer must name none. Either
51
+ -- half missing would leave a row that says it is attributed while being
52
+ -- unsummable — the shape a per-payer ceiling silently reads as zero.
53
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_runs_payer_ref_chk') THEN
54
+ ALTER TABLE agents_runs ADD CONSTRAINT agents_runs_payer_ref_chk
55
+ CHECK (
56
+ payer_kind IS NULL
57
+ OR (payer_kind = 'builder' AND payer_builder_id IS NOT NULL)
58
+ OR (payer_kind = 'project' AND payer_builder_id IS NULL)
59
+ );
60
+ END IF;
61
+
62
+ -- THE INVARIANT THE TASK ASKS FOR: a run that cost money says who pays. A
63
+ -- no-go costs nothing and needs no payer (agents_001 already forbids it a
64
+ -- cost), and a pending run has no cost yet.
65
+ --
66
+ -- NOT VALID, deliberately. The constraint binds every INSERT and UPDATE from
67
+ -- here on, but does not scan rows written before this column existed — a
68
+ -- self-hosted instance that already ran agents has history that predates the
69
+ -- concept, and failing its migration to punish it for that would be the wrong
70
+ -- trade. Those rows stay honestly un-attributed; the ceiling sums by payer and
71
+ -- so counts none of them, which is the conservative direction (it never credits
72
+ -- a payer with spend that was someone else's).
73
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_runs_cost_needs_payer_chk') THEN
74
+ ALTER TABLE agents_runs ADD CONSTRAINT agents_runs_cost_needs_payer_chk
75
+ CHECK (cost_usd IS NULL OR cost_usd = 0 OR payer_kind IS NOT NULL) NOT VALID;
76
+ END IF;
77
+ END $$;
78
+
79
+ -- "What has this payer spent this month?" — the ceiling's only read, and it is on
80
+ -- the invoke hot path. Partial on cost_usd for the same reason
81
+ -- agents_runs_spend_idx is: a row that cost nothing contributes nothing to a
82
+ -- spend sum and would only dilute the scan.
83
+ CREATE INDEX IF NOT EXISTS agents_runs_payer_spend_idx
84
+ ON agents_runs (payer_kind, payer_builder_id, created_at DESC)
85
+ WHERE cost_usd IS NOT NULL;
86
+
87
+ COMMIT;
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "key": "agents",
3
3
  "title": "Agents (declarative agent registry)",
4
- "description": "Declarative agent definitions (.claude/agents/*.md) reconciled at deploy into a DB-backed registry (agents_definitions) plus a per-fire ledger (agents_runs) — BONGOS-V1 goal 1000038, Phase 1. Files-are-source: agents-sync upserts file-sourced definitions and never clobbers an instance-authored db row, and stamps author_rank from the author's LIVE DB rank because a committed file cannot grant authority (ADR 0016). Spawns consume the EXISTING `grade` port (runSubagentCached), which is fixed read-only, so v1 agents are observe-and-report only — no write principals — and every fire bills through the shared LLM cost cache. default:false: shipped is not enabled, and an instance opts in per ADR 0083.",
4
+ "description": "Declarative agent definitions (.claude/agents/*.md) reconciled at deploy into a DB-backed registry (agents_definitions) plus a per-fire ledger (agents_runs) — BONGOS-V1 goal 1000038, Phase 1. Files-are-source: agents-sync upserts file-sourced definitions and never clobbers an instance-authored db row, and stamps author_rank from the author's LIVE DB rank because a committed file cannot grant authority (ADR 0016). Spawns consume the EXISTING `grade` port (runSubagentCached), which is fixed read-only, so v1 agents are observe-and-report only — no write principals — and every fire bills through the shared LLM cost cache. default:false: shipped is not enabled, and an instance opts in per ADR 0083. spend.requiresPayer (task 1003884): a fire spends real money for whoever called it (~$0.90, and POST /agents/:name/invoke answers 202 without waiting), so the core refuses to ENABLE this module on an instance whose join door is `open` with no payer configured — an account created seconds ago would otherwise spend the instance's money with nobody to bill. Every run then records its payer beside cost_usd (agents_002_payer.sql) and a per-payer monthly ceiling fails closed when it is exhausted OR unreadable (lib/spend-ceiling.js) — distinct from lib/fire-budget.js, which stays a rate ceiling.",
5
5
  "coreVersion": "^1.19.0",
6
6
  "default": false,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": { "routes": ["agents"], "migrations": true },
9
+ "spend": { "requiresPayer": true },
9
10
  "provides": [],
10
11
  "consumes": ["grade"]
11
12
  }
@@ -81,6 +81,7 @@ const authoring = require('../lib/authoring');
81
81
  const { gateFor } = require('../lib/gate');
82
82
  const { createAnswerHold } = require('../lib/answer-hold');
83
83
  const { createFireBudget } = require('../lib/fire-budget');
84
+ const { createSpendCeiling, payerFor } = require('../lib/spend-ceiling');
84
85
  const { spawnAgent } = require('../spawn');
85
86
 
86
87
  const log = api.logger('agents');
@@ -90,6 +91,28 @@ const log = api.logger('agents');
90
91
  // per-router for the opposite reason — it is a delivery buffer, and a split one
91
92
  // only means an answer is collected where it was produced.
92
93
  const fireBudget = createFireBudget();
94
+ // ONE INSTANCE, module-level, for the reason fire-budget's header gives about two
95
+ // call sites each building their own: two ceilings look identical in review and
96
+ // are not one ceiling. This one holds no state — it reads the ledger every time —
97
+ // so the singleton is about the CAP being one number, not about a shared bucket.
98
+ const spendCeiling = createSpendCeiling();
99
+
100
+ // The project's `spend_payer` policy, read through branding (src/branding.js maps
101
+ // <PREFIX>_SPEND_PAYER onto project.spendPayer). Read per request rather than
102
+ // memoized here because branding already memoizes for the process — a second copy
103
+ // would only add a way for the two to disagree.
104
+ function projectSpendPayer() {
105
+ try {
106
+ const pack = api.branding ? api.branding() : null;
107
+ return (pack && pack.project && pack.project.spendPayer) || null;
108
+ } catch (_) {
109
+ // An unreadable pack is NOT an unbounded fire: payerFor treats a null policy
110
+ // as the project paying, and the ceiling then bounds the project. The
111
+ // fail-closed reading of an unreadable posture is src/modules.js's job, and it
112
+ // makes it once, at config load.
113
+ return null;
114
+ }
115
+ }
93
116
 
94
117
  // The columns the serializer needs. Spelled out rather than `SELECT *` so that a
95
118
  // column added later (an author's email, a raw credential) cannot reach a
@@ -541,6 +564,42 @@ module.exports = function agentsRoutes() {
541
564
  });
542
565
  }
543
566
  try {
567
+ // THE SPEND CEILING, after the rate ceiling and before anything is read or
568
+ // written. The two are deliberately separate refusals with separate codes:
569
+ // 429 says "too fast, try later", 402 says "this month's money is gone",
570
+ // and an operator who cannot tell those apart cannot tell a runaway loop
571
+ // from a budget that is simply too small. See ../lib/spend-ceiling.js.
572
+ //
573
+ // It FAILS CLOSED — including when the ledger cannot be read, which is the
574
+ // exact defect task 1001393 was filed about.
575
+ //
576
+ // INSIDE the try, unlike the synchronous rate ceiling above it. This is the
577
+ // first `await` in the handler, and express 4 does not catch a rejected
578
+ // async handler — an unhandled rejection is a process-level crash that takes
579
+ // every other in-flight request with it, which is the same hazard the
580
+ // detached `done` promise below is careful about. check() is written not to
581
+ // throw; the try is what makes that a property of the route rather than a
582
+ // promise the ceiling has to keep forever.
583
+ const spendPayer = projectSpendPayer();
584
+ const payer = payerFor(spendPayer, req.builder.id);
585
+ const room = await spendCeiling.check(api.pool, payer);
586
+ if (!room.ok) {
587
+ return res.fail('spend_cap_reached', {
588
+ status: 402,
589
+ message: room.reason === 'spend_cap_reached'
590
+ ? `agent spend for this ${payer.payer_kind} has reached its $${room.capUsd} monthly cap — each fire spends real money`
591
+ : `agent fires are refused because the spend ceiling could not be established (${room.reason}) — a ceiling that cannot be read must not be assumed`,
592
+ details: {
593
+ scope: 'agent-spend',
594
+ reason: room.reason,
595
+ payer_kind: payer.payer_kind,
596
+ cap_usd: room.capUsd,
597
+ // null whenever the refusal was a failure to READ the ledger, which
598
+ // is the honest answer: a number here would claim a sum we do not have.
599
+ spent_usd: room.spentUsd,
600
+ },
601
+ });
602
+ }
544
603
  const { rows } = await api.pool.query(
545
604
  `SELECT ${COLUMNS}, persona FROM agents_definitions WHERE name = $1`, [name]
546
605
  );
@@ -554,7 +613,7 @@ module.exports = function agentsRoutes() {
554
613
  // call — the reason it can stay synchronous while the go path cannot.
555
614
  if (gate.decision !== 'go') {
556
615
  const skipped = await spawnAgent({
557
- definition, trigger, gate, requestedByBuilderId: req.builder.id,
616
+ definition, trigger, gate, requestedByBuilderId: req.builder.id, spendPayer,
558
617
  }, {});
559
618
  return res.fail('agent_refused', {
560
619
  status: 409,
@@ -572,6 +631,7 @@ module.exports = function agentsRoutes() {
572
631
  trigger,
573
632
  gate,
574
633
  requestedByBuilderId: req.builder.id,
634
+ spendPayer,
575
635
  }, {
576
636
  // Wrap the port's runner to keep the reply, which spawnAgent does not
577
637
  // return — it records what a fire DID, not what it said.
@@ -669,3 +729,4 @@ module.exports.serializeRun = serializeRun;
669
729
  // The module-level ceiling, exported so a test can reset it between cases —
670
730
  // a singleton otherwise carries one case's spend into the next.
671
731
  module.exports.fireBudget = fireBudget;
732
+ module.exports.spendCeiling = spendCeiling;
@@ -34,6 +34,7 @@
34
34
  // happened, and "the agent didn't run" then has two causes nobody can tell apart.
35
35
 
36
36
  const api = require('../../src/module-api');
37
+ const { payerFor } = require('./lib/spend-ceiling');
37
38
 
38
39
  // tier → concrete model id. The mapping is BRANDING, not a constant here: an
39
40
  // instance retunes its own tiers, and the DB constraint already limits the
@@ -53,8 +54,20 @@ function resolveModel(tier, brandingModels) {
53
54
 
54
55
  // The insert shape for one fire, decided BEFORE anything spawns. Pure, so the
55
56
  // schema's invariants can be tested without a database.
56
- function plannedRow({ definition = {}, trigger = {}, gate = {}, model = null, requestedByBuilderId = null } = {}) {
57
+ function plannedRow({
58
+ definition = {}, trigger = {}, gate = {}, model = null, requestedByBuilderId = null,
59
+ // The project's `spend_payer` policy as it stands AT THIS FIRE. Passed in rather
60
+ // than read here so this stays pure, and stamped into the row rather than
61
+ // derived later so a flip of the setting cannot rewrite what last month cost
62
+ // (agents_002_payer.sql says why at length).
63
+ spendPayer = null,
64
+ } = {}) {
57
65
  const go = gate.decision === 'go';
66
+ // WHO PAYS — recorded on EVERY row, including a no-go. A refusal costs nothing
67
+ // and the schema forbids it a cost, but it is still a fire somebody would have
68
+ // been billed for, and a ledger that attributes only the expensive half cannot
69
+ // answer "what would this have cost us".
70
+ const payer = payerFor(spendPayer, requestedByBuilderId);
58
71
  return {
59
72
  definition_id: definition.id ?? null,
60
73
  agent_name: definition.name || null,
@@ -71,6 +84,11 @@ function plannedRow({ definition = {}, trigger = {}, gate = {}, model = null, re
71
84
  // never be re-read through today's tier mapping.
72
85
  model: go ? model : null,
73
86
  requested_by_builder_id: requestedByBuilderId ?? null,
87
+ // Distinct from requested_by_builder_id above, and the distinction is the
88
+ // point: that field is who ASKED, these are who is on the HOOK. They are the
89
+ // same person only when the project put the bill on its builders.
90
+ payer_kind: payer.payer_kind,
91
+ payer_builder_id: payer.payer_builder_id,
74
92
  };
75
93
  }
76
94
 
@@ -135,14 +153,14 @@ async function finishRun(pool, id, patch) {
135
153
  // agent's bad day.
136
154
  async function spawnAgent({
137
155
  definition, input = null, trigger = {}, gate = { decision: 'go' },
138
- requestedByBuilderId = null, cache = null,
156
+ requestedByBuilderId = null, cache = null, spendPayer = null,
139
157
  } = {}, deps = {}) {
140
158
  const pool = deps.pool || api.pool;
141
159
  const now = deps.now || (() => Date.now());
142
160
  const brandingModels = deps.brandingModels || (api.branding && api.branding().models) || {};
143
161
  const model = resolveModel(definition && definition.model_tier, brandingModels);
144
162
 
145
- const row = plannedRow({ definition, trigger, gate, model, requestedByBuilderId });
163
+ const row = plannedRow({ definition, trigger, gate, model, requestedByBuilderId, spendPayer });
146
164
  let runId = null;
147
165
  try {
148
166
  runId = await insertRun(pool, row);
@@ -173,12 +173,28 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
173
173
  // and coherent because strict holds only the pin: the worst case is a project whose
174
174
  // deploy waits on a review nobody has done, which is visible and owner-releasable,
175
175
  // never a stranded builder.
176
+ // `spend_payer` is the project's answer to "who pays when a module spends money
177
+ // on a caller's behalf?" (task 1003884). `project` puts the bill on whoever runs
178
+ // the instance; `builder` on the caller who spent it; `none` is no payer at all.
179
+ //
180
+ // It is the one setting that can REFUSE A MODULE. src/modules.js will not enable a
181
+ // module whose manifest declares `spend.requiresPayer` while this project's join
182
+ // door is `open` and this key is `none` — an account created seconds ago could
183
+ // otherwise spend the instance's money with nobody to bill. The refusal is at
184
+ // config load, so the module's routes never mount at all.
185
+ //
186
+ // Its default IS today's behaviour, unlike `artist_gate` above: no project has
187
+ // ever named a payer, and `none` is what every existing row already means. That
188
+ // costs nothing today because the only module declaring metered spend ships
189
+ // `default: false` — an owner who turns it on is the first person this key ever
190
+ // stops, and the warning tells them which two words fix it.
176
191
  const SETTINGS_VOCAB = Object.freeze({
177
192
  platform_visibility: ['public', 'gated'],
178
193
  joinability: ['open', 'apply', 'invite_only'],
179
194
  visibility: ['public', 'private', 'stealth'],
180
195
  join_grant: ['view', 'apply', 'full'],
181
196
  artist_gate: ['off', 'advisory', 'strict'],
197
+ spend_payer: ['none', 'project', 'builder'],
182
198
  });
183
199
  // Default = today's behavior on every axis, including the join door: ADR 0182's
184
200
  // migration gives the hub column the same `public` default, "existing rows keep
@@ -186,7 +202,7 @@ const SETTINGS_VOCAB = Object.freeze({
186
202
  // owner's ruling rather than the status quo — see the note above it.
187
203
  const DEFAULT_SETTINGS = Object.freeze({
188
204
  platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full',
189
- artist_gate: 'strict',
205
+ artist_gate: 'strict', spend_payer: 'none',
190
206
  });
191
207
 
192
208
  // The project's PLANET (task 1003347, direction record §16): how the platform
@@ -263,27 +263,27 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
263
263
  console.log('\nPer-project settings (task 1003040):');
264
264
 
265
265
  t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
266
- assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
267
- assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
268
- assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
266
+ assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
267
+ assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
268
+ assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
269
269
  assert.deepEqual(
270
270
  P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
271
- { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' },
271
+ { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
272
272
  'a stored key overlays only itself'
273
273
  );
274
274
  assert.deepEqual(
275
275
  P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted', join_grant: 'everything' } }),
276
- { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' },
276
+ { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
277
277
  'off-vocabulary values fall back to the default; unknown keys never leak out'
278
278
  );
279
279
  assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
280
- { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'a non-object settings value reads as defaults');
280
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'a non-object settings value reads as defaults');
281
281
  // the join door reads back like any other policy key (task 1002324)
282
282
  assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
283
- { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict' }, 'a stored door overlays only itself');
283
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'a stored door overlays only itself');
284
284
  // what a public join grants is a policy key like any other (task 1002331)
285
285
  assert.deepEqual(P.effectiveSettings({ settings: { join_grant: 'view' } }),
286
- { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view', artist_gate: 'strict' }, 'the grant overlays only itself');
286
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view', artist_gate: 'strict', spend_payer: 'none' }, 'the grant overlays only itself');
287
287
  });
288
288
 
289
289
  t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
@@ -189,6 +189,7 @@ const STUB_VOCAB = {
189
189
  visibility: ['public', 'private', 'stealth'],
190
190
  join_grant: ['view', 'apply', 'full'],
191
191
  artist_gate: ['off', 'advisory', 'strict'],
192
+ spend_payer: ['none', 'project', 'builder'],
192
193
  planet_template: null, // the template set is long; shape is checked, not membership
193
194
  planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
194
195
  card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.733",
3
+ "version": "1.19.734",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.733",
9
+ "version": "1.19.734",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.733",
3
+ "version": "1.19.734",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -138,11 +138,20 @@ function isReleaseCommit(subject) {
138
138
  // exactly the distinction the constant was written to make.
139
139
  //
140
140
  // BOTH failure directions are safe, which is what makes this acceptable on a
141
- // release-critical lane:
142
- // commit written, publish then failed → version looks taken, next run bumps
143
- // past it. A gap in the version sequence; harmless.
144
- // published, commit push then failed → version looks free, the publish
145
- // retries and npm refuses with EPUBLISHCONFLICT. Loud, and nothing wrong ships.
141
+ // release-critical lane — but only since task 1003914 reordered publish.yml so
142
+ // the bump commit is PUSHED AFTER a successful publish, not before it:
143
+ // pack or publish failed → the bump commit was never pushed (it only ever
144
+ // existed on the discarded runner), so no version looks taken and the next
145
+ // run recomputes and retries the SAME version. No gap, nothing burned.
146
+ // • published, push then failed → version looks free, so the next run bumps to
147
+ // it and npm refuses the duplicate. publish.yml treats that refusal as
148
+ // success and falls through to the record step, which commits the release
149
+ // line main was missing. Self-healing, and nothing wrong ships.
150
+ //
151
+ // Before that reorder the first case was the lossy one: the push landed first,
152
+ // so a failed publish left main declaring a version the registry never had, the
153
+ // cascade terminator no-opped every following run, and only a fresh work merge
154
+ // moved past it. That is how 1.19.716-723 were minted and lost (2026-09-13).
146
155
  function laneReleasedVersions(subjects = []) {
147
156
  const out = new Set();
148
157
  for (const subject of subjects) {
@@ -91,7 +91,7 @@ const EXEMPTIONS = [
91
91
  + 're-opens for review.',
92
92
  },
93
93
  {
94
- at: 'src/module-loader/loader.js:190',
94
+ at: 'src/module-loader/loader.js:196',
95
95
  why: 'The plugin loader, and the second site the audit named (B50 "motivating sites"). It '
96
96
  + 'require()s a discovered module\'s route file, so the specifier is a runtime path BY '
97
97
  + 'DESIGN — a plugin system whose module paths are compile-time constants is not a plugin '
@@ -103,8 +103,8 @@ const EXEMPTIONS = [
103
103
  + 'absorbed into this check.',
104
104
  },
105
105
  {
106
- at: 'src/module-loader/loader.js:293',
107
- why: 'The poller twin of the route require at :190 — same DEFAULT_ROOTS bound, same plugin-by-'
106
+ at: 'src/module-loader/loader.js:299',
107
+ why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
108
108
  + 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
109
109
  + 'change to either call re-opens only that one.',
110
110
  },
package/src/branding.js CHANGED
@@ -77,6 +77,12 @@ const ENV_OVERRIDES = [
77
77
  // that has been strict since it stood up.
78
78
  ['ARTIST_GATE', ['project', 'artistGate']],
79
79
  ['ARTIST_GATE_SINCE', ['project', 'artistGateSince']],
80
+ // Who pays when a module spends money for whoever called it (task 1003884) —
81
+ // none | project | builder. src/modules.js reads it through this pack at config
82
+ // load and REFUSES to enable a module that declares metered spend while the join
83
+ // door is `open` and this is `none`. Like its neighbours it is memoized for the
84
+ // process, which is why the platform restarts the instance to apply a change.
85
+ ['SPEND_PAYER', ['project', 'spendPayer']],
80
86
  ];
81
87
 
82
88
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -265,6 +271,13 @@ function clientBranding(b = branding()) {
265
271
  // DEFAULT, which for this one key is 'strict' rather than today's behaviour
266
272
  // — the owner's ruling, ADR 0241 §4.
267
273
  artistGate: b.project?.artistGate || 'strict',
274
+ // Who pays for metered module spend (task 1003884). Published for the same
275
+ // read-back reason as its neighbours — and for one of its own: this is the
276
+ // key that can REFUSE a module, so an owner looking at a module that will
277
+ // not turn on needs the instance's own answer to "what did it restart
278
+ // with?", not the platform's record of what they saved. The fallback is the
279
+ // platform default, which for this key IS today's behaviour.
280
+ spendPayer: b.project?.spendPayer || 'none',
268
281
  },
269
282
  };
270
283
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.733'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.734'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');