@bongos/core 1.19.732 → 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.
Files changed (37) hide show
  1. package/.bongos-core.json +68 -38
  2. package/config/branding.neutral.json +3 -2
  3. package/docs/adr/0161-publish-on-merge.md +3 -0
  4. package/docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md +141 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/modules-contract.md +22 -0
  8. package/modules/agents/lib/spend-ceiling.js +170 -0
  9. package/modules/agents/migrations/agents_002_payer.sql +87 -0
  10. package/modules/agents/module.json +2 -1
  11. package/modules/agents/routes/agents.js +62 -1
  12. package/modules/agents/spawn.js +21 -3
  13. package/modules/grading/dead-feature-claim.js +121 -0
  14. package/modules/grading/grader-score.js +32 -2
  15. package/modules/grading/grader-workers/worker-verdict.js +12 -2
  16. package/modules/provisioning/provisioning.js +17 -1
  17. package/modules/provisioning/tests/provisioning.mjs +8 -8
  18. package/modules/ui-design/kit/serve.js +1 -0
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/scripts/gds/bump-version.js +14 -5
  22. package/scripts/gds/exec-path-guard.js +3 -3
  23. package/src/branding.js +13 -0
  24. package/src/module-api.js +1 -1
  25. package/src/module-loader/loader.js +6 -0
  26. package/src/module-loader/manifest-schema.js +30 -0
  27. package/src/modules.js +107 -2
  28. package/tests/agents_spend_guard.mjs +272 -0
  29. package/tests/agents_write_routes.mjs +51 -1
  30. package/tests/bump_version.mjs +78 -1
  31. package/tests/currency_label.mjs +9 -4
  32. package/tests/dead_feature_claim.mjs +176 -0
  33. package/tests/grade_server_authoritative.mjs +57 -0
  34. package/tests/provision_settings_apply.mjs +11 -5
  35. package/tests/provisioning_settings.mjs +11 -7
  36. package/tests/provisioning_settings_apply.mjs +4 -4
  37. 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;
@@ -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);
@@ -0,0 +1,121 @@
1
+ // modules/grading/dead-feature-claim.js — does a panel finding say, in its own
2
+ // words, that the shipped code does not run at all? (task 1002665)
3
+ //
4
+ // WHY. On the task 1002648 ship the Quality worker found that the new PATCH
5
+ // needs_migration path threw at runtime — a TASK_SETTER_COLUMNS allowlist gap —
6
+ // and wrote "the feature is entirely non-operational". It then filed that at
7
+ // severity `nit`, the grade PASSed, and the dead feature merged to main. The
8
+ // finding TEXT was right; the SEVERITY was miscalibrated, and severity is the
9
+ // only channel a reader's attention follows.
10
+ //
11
+ // Two things had to be true for that to merge, and this module addresses the
12
+ // first. (1) Nothing re-read the finding's own claim against its own severity.
13
+ // (2) Severity has no vote in the gate at all: `passed` in grader-score.js is
14
+ // `bothPass && !lowConfidence && …`, computed from the rubric SCORES — a major
15
+ // or blocker issue does not by itself stop a passing grade. So a severity floor
16
+ // alone would not have gated this ship, which is why grader-score.js pairs this
17
+ // classifier with a server-side guard rather than relying on the raised severity
18
+ // to do the work. The row that asked for this proposed the floor on its own; the
19
+ // floor on its own is decoration.
20
+ //
21
+ // SCOPE, deliberately narrow. This matches only ABSOLUTE claims about the
22
+ // submitted change: always-throws, never-runs, non-operational. It does NOT
23
+ // match conditional defects ("would throw on a null id") — those are ordinary
24
+ // findings the scores already judge — and it does not match the word
25
+ // "unreachable", which the originating row suggested: a reviewer writes "the old
26
+ // branch is now unreachable" to PRAISE a dead-code deletion, and that reading is
27
+ // at least as common as the damning one. A false positive here costs a builder a
28
+ // manual-confirm round, so the phrase list stays at claims that have no innocent
29
+ // reading, and the suppressors below drop the two framings that do.
30
+
31
+ 'use strict';
32
+
33
+ // Absolute claims of runtime-deadness. Each must be unambiguous ON ITS OWN — if
34
+ // a phrase has an innocent reading in ordinary review prose, it does not belong
35
+ // here, it belongs in the scores.
36
+ const DEAD_CLAIM_PATTERNS = [
37
+ /\bnon-?operational\b/i,
38
+ /\b(?:always|unconditionally) throws?\b/i,
39
+ /\bthrows? (?:at runtime|on every (?:call|invocation|request))\b/i,
40
+ /\bnever (?:executes|runs|fires|gets called)\b/i,
41
+ /\b(?:can|will) never (?:run|execute|be (?:called|reached))\b/i,
42
+ /\bdoes ?n[o']?t work at all\b/i,
43
+ /\b(?:completely|entirely|totally) (?:broken|dead|inoperable)\b/i,
44
+ /\bdead on arrival\b/i,
45
+ ];
46
+
47
+ // A claim about code the change REMOVED or FIXED, or a hypothetical, is the
48
+ // opposite of a dead-feature report — it is the reviewer describing what the
49
+ // diff cured. These read within a short window BEFORE the matched phrase,
50
+ // because that is where the framing sits in English ("this used to always
51
+ // throw", "no longer non-operational", "would always throw if the id were null").
52
+ const SUPPRESSORS = [
53
+ /\bused to\b/i,
54
+ /\bno longer\b/i,
55
+ /\bpreviously\b/i,
56
+ /\bbefore this (?:change|diff|fix|patch)\b/i,
57
+ /\bwas\b/i,
58
+ /\bwould\b/i,
59
+ /\bif\b/i,
60
+ /\bprevents?\b/i,
61
+ /\bavoids?\b/i,
62
+ /\bfix(?:e[sd])?\b/i,
63
+ ];
64
+
65
+ // How far back a suppressor counts. One clause, not one paragraph: read too far
66
+ // and an unrelated "fixed" earlier in a multi-sentence summary silences a real
67
+ // claim; read too little and "this would always throw" trips.
68
+ const SUPPRESSOR_WINDOW = 40;
69
+
70
+ // Does `text` assert that the submitted code does not run at all? Returns the
71
+ // matched phrase (truthy, and useful in the audit trail) or null. Pure.
72
+ function deadFeatureClaim(text) {
73
+ const s = typeof text === 'string' ? text : '';
74
+ if (!s) return null;
75
+ for (const re of DEAD_CLAIM_PATTERNS) {
76
+ const m = re.exec(s);
77
+ if (!m) continue;
78
+ const before = s.slice(Math.max(0, m.index - SUPPRESSOR_WINDOW), m.index);
79
+ if (SUPPRESSORS.some((sup) => sup.test(before))) continue;
80
+ return m[0];
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // The same question asked of one issues[] entry. Only `summary` carries the
86
+ // worker's prose — `severity`/`file` are enumerated fields — so this reads that
87
+ // and nothing else, which keeps a file PATH containing an unlucky word (say,
88
+ // `always-throws.test.js`) from raising a severity.
89
+ function issueClaimsRuntimeDead(issue) {
90
+ if (!issue || typeof issue !== 'object') return null;
91
+ return deadFeatureClaim(issue.summary);
92
+ }
93
+
94
+ // Severities in ascending order of gravity; anything at or above `major` needs no
95
+ // floor, and an unrecognised severity is treated as below it (fail-closed: an
96
+ // unknown label must not buy an exemption).
97
+ const AT_LEAST_MAJOR = new Set(['major', 'blocker']);
98
+
99
+ // Raise a runtime-dead finding to `major`, preserving what the worker reported.
100
+ // Returns a NEW array when anything changed, the input array otherwise, so a
101
+ // caller can cheaply tell whether the floor fired.
102
+ function applyDeadFeatureFloor(issues) {
103
+ if (!Array.isArray(issues)) return issues;
104
+ let changed = false;
105
+ const out = issues.map((it) => {
106
+ const claim = issueClaimsRuntimeDead(it);
107
+ if (!claim) return it;
108
+ if (AT_LEAST_MAJOR.has(String(it.severity || '').toLowerCase())) return it;
109
+ changed = true;
110
+ return {
111
+ ...it,
112
+ severity: 'major',
113
+ severity_reported: it.severity,
114
+ severity_floor: 'runtime_dead',
115
+ severity_floor_claim: claim,
116
+ };
117
+ });
118
+ return changed ? out : issues;
119
+ }
120
+
121
+ module.exports = { deadFeatureClaim, issueClaimsRuntimeDead, applyDeadFeatureFloor };
@@ -14,6 +14,7 @@ const fs = require('node:fs');
14
14
  const { DEFAULT_GRADER_MODEL, FALLTHROUGH_CONFIDENCE, LOW_CONFIDENCE, THRESHOLD, VERSION, permissionPathCheck, routeRankCheck } = require('./grader-config.js');
15
15
  const { TRIVIAL_MAX_FILES, TRIVIAL_MAX_LINES, clamp, median, r1 } = require('./grader-model.js');
16
16
  const { extractJson } = require('./grader-prompt.js');
17
+ const { issueClaimsRuntimeDead } = require('./dead-feature-claim.js');
17
18
 
18
19
  function isNum(n) {
19
20
  return Number.isFinite(n);
@@ -480,6 +481,35 @@ function serverSideGuards(gradeResult, { committedFiles, builderRank, diffStats,
480
481
  implausiblePassWaived = 'panel_evidence';
481
482
  }
482
483
 
484
+ // --- Guard 2b: a finding that says the change does not run (task 1002665) ---
485
+ // On the task 1002648 ship the Quality worker wrote that the new code path was
486
+ // "entirely non-operational", filed it at `nit`, and the grade PASSed — the
487
+ // dead feature merged to main and was caught only because the builder happened
488
+ // to read the finding prose. Severity alone cannot fix that, because severity
489
+ // has no vote here: `passed` is computed from the rubric SCORES, so a major or
490
+ // blocker issue does not stop a passing grade by itself. This is the vote.
491
+ //
492
+ // Re-derived server-side from the submitted summaries rather than trusting the
493
+ // client's `severity_floor` marker — same posture as Guard 1, and the worker's
494
+ // floor is the honest-panel half of the same rule.
495
+ //
496
+ // It downgrades to CONCERNS, not fail: the classifier reads a model's prose, so
497
+ // a false positive should cost a human glance through manual-confirm rather
498
+ // than a hard FAIL and a paid re-grade. That is still enough to close the hole
499
+ // — 1002648 merged automatically, and a concerns verdict does not.
500
+ const deadClaims = (base.issues || [])
501
+ .map((it) => ({ it, claim: issueClaimsRuntimeDead(it) }))
502
+ .filter((x) => x.claim);
503
+ if (clientPassed && deadClaims.length) {
504
+ const { it, claim } = deadClaims[0];
505
+ const where = it.file ? ` in ${it.file}` : '';
506
+ guardsTripped.push({ guard: 'runtime_dead_finding', severity: 'major',
507
+ summary: `a ${it.severity_reported || it.severity || '?'}-severity finding${where} asserts the change does not run ("${claim}") — a passing grade over a self-reported dead feature must be human-confirmed` });
508
+ extraIssues.push({ severity: 'major', file: it.file || '(grade)', worker: 'server-runtime-dead', summary:
509
+ `[server runtime-dead] the panel's own words say this does not run ("${claim}"); severity was reported as ${it.severity_reported || it.severity || '?'}` });
510
+ if (downgradeTo !== 'fail') downgradeTo = 'concerns';
511
+ }
512
+
483
513
  // --- Guard 3: bound the credit/achievement signals (SR-18) ---
484
514
  // The net-negative "Subtractor" bonus + shipper badges key off client-supplied
485
515
  // signals.diff_stats + committed_files the server can't re-derive. We can't
@@ -517,7 +547,7 @@ function serverSideGuards(gradeResult, { committedFiles, builderRank, diffStats,
517
547
  signals: {
518
548
  ...(base.signals || {}),
519
549
  server_guard: {
520
- applied: ['route_rank', 'permission_path', 'permission_path_unprovable', 'implausible_pass', 'forged_net_negative'],
550
+ applied: ['route_rank', 'permission_path', 'permission_path_unprovable', 'implausible_pass', 'runtime_dead_finding', 'forged_net_negative'],
521
551
  ok: true,
522
552
  tripped: [],
523
553
  // task 1002658 / ADR 0164: the audit trail records WHY a zero-issue
@@ -556,7 +586,7 @@ function serverSideGuards(gradeResult, { committedFiles, builderRank, diffStats,
556
586
  // body already ran; this is the authoritative post-hoc adjustment).
557
587
  confidence: forwardedConfidence,
558
588
  server_guard: {
559
- applied: ['route_rank', 'permission_path', 'permission_path_unprovable', 'implausible_pass', 'forged_net_negative'],
589
+ applied: ['route_rank', 'permission_path', 'permission_path_unprovable', 'implausible_pass', 'runtime_dead_finding', 'forged_net_negative'],
560
590
  ok: guardsTripped.length === 0,
561
591
  downgraded_to: downgradeTo,
562
592
  client_passed: clientPassed,
@@ -9,6 +9,8 @@
9
9
  // (the audit's explicit warning). Behavior-neutral: the objects produced here
10
10
  // are identical (same keys, same order, same values) to the inlined copies.
11
11
 
12
+ const { applyDeadFeatureFloor } = require('../dead-feature-claim.js');
13
+
12
14
  // The { verdict:'fail', scores:{}, confidence:0, ... } shape every error
13
15
  // short-circuit returns. `issues` defaults to [] (the subprocess_error and
14
16
  // parse_error paths) and is the parsed issues array on the schema_error path
@@ -19,13 +21,21 @@ function failResult({ notesMd, error, issues = [] }) {
19
21
 
20
22
  // The three identical extractions every worker does after the parse guard:
21
23
  // confidence (null-tolerant), the bounded issues array, and the subagent notes.
24
+ //
25
+ // The issues array also passes the runtime-dead floor (task 1002665): a worker
26
+ // that writes "the feature is entirely non-operational" and files it at `nit`
27
+ // has contradicted itself, and on the task 1002648 ship that contradiction is
28
+ // what let a dead feature merge. Raising it HERE, at the one place all four
29
+ // workers parse their own output, means the worker's own downstream logic —
30
+ // applySeverityGatePolicy below, which asks whether a fail is backed by a
31
+ // major/blocker — sees the corrected severity rather than the reported one.
22
32
  function parseCommon(parsed) {
23
33
  const conf = parsed.confidence == null
24
34
  ? null
25
35
  : Number.isFinite(Number(parsed.confidence)) ? Number(parsed.confidence) : null;
26
- const issues = Array.isArray(parsed.issues)
36
+ const issues = applyDeadFeatureFloor(Array.isArray(parsed.issues)
27
37
  ? parsed.issues.filter((i) => i && typeof i === 'object').slice(0, 50)
28
- : [];
38
+ : []);
29
39
  const notesFromSubagent = typeof parsed.notes_md === 'string' ? parsed.notes_md : '';
30
40
  return { conf, issues, notesFromSubagent };
31
41
  }