@bongos/core 1.19.733 → 1.19.735
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.
- package/.bongos-core.json +61 -36
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0161-publish-on-merge.md +3 -0
- package/docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md +141 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +22 -0
- package/modules/agents/lib/spend-ceiling.js +170 -0
- package/modules/agents/migrations/agents_002_payer.sql +87 -0
- package/modules/agents/module.json +2 -1
- package/modules/agents/routes/agents.js +62 -1
- package/modules/agents/spawn.js +21 -3
- package/modules/provisioning/provisioning.js +17 -1
- package/modules/provisioning/tests/provisioning.mjs +8 -8
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +14 -5
- package/scripts/gds/exec-path-guard.js +3 -3
- package/scripts/gds/package-core.js +31 -10
- package/src/branding.js +13 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/loader.js +6 -0
- package/src/module-loader/manifest-schema.js +30 -0
- package/src/modules.js +107 -2
- package/tests/agents_spend_guard.mjs +272 -0
- package/tests/agents_write_routes.mjs +51 -1
- package/tests/bump_version.mjs +78 -1
- package/tests/currency_label.mjs +9 -4
- package/tests/leak_scan_fail_closed.mjs +108 -0
- package/tests/provision_settings_apply.mjs +11 -5
- package/tests/provisioning_settings.mjs +11 -7
- package/tests/provisioning_settings_apply.mjs +4 -4
- package/tests/provisioning_settings_env.mjs +5 -1
|
@@ -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;
|
package/modules/agents/spawn.js
CHANGED
|
@@ -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({
|
|
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.
|
|
3
|
+
"version": "1.19.735",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.735",
|
|
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.
|
|
3
|
+
"version": "1.19.735",
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
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:
|
|
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:
|
|
107
|
-
why: 'The poller twin of the route require at :
|
|
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
|
},
|