@bongos/core 1.19.842 → 1.19.844
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 +70 -40
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_247_discipline_required.sql +58 -0
- package/modules/agents/lib/spend-ceiling.js +126 -37
- package/modules/agents/migrations/agents_004_spend_cap.sql +53 -0
- package/modules/agents/pollers/event-router.js +29 -2
- package/modules/agents/routes/agents.js +38 -1
- package/modules/agents/spawn.js +73 -1
- package/modules/copy-desk/routes/copy-desk.js +6 -0
- package/modules/economy/cost-classify.js +11 -0
- package/modules/economy/cost.js +21 -3
- package/modules/economy/reward.js +7 -0
- package/modules/hall-ui/public/goals.states.json +2 -2
- package/modules/hall-ui/public/idea.states.json +1 -1
- package/modules/hall-ui/public/ideas.states.json +1 -1
- package/modules/hall-ui/public/index.states.json +1 -1
- package/modules/hall-ui/public/pair.states.json +1 -1
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/hall-ui/public/work.states.json +1 -1
- package/modules/ideas/inbox.js +4 -2
- package/modules/lifecycle/db-tasks.js +28 -2
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/goals.js +17 -4
- package/modules/lifecycle/routes/task-write-routes.js +25 -6
- package/modules/lifecycle/task-classifier.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-disciplines.js +144 -0
- package/scripts/gds/fitness-ratchets.js +59 -2
- package/scripts/gds/init.js +6 -1
- package/scripts/gds/triage.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/agents_spend_guard.mjs +203 -2
- package/tests/hall_states_regex_escapes.mjs +98 -0
- package/tests/knip_ratchet_ambient.mjs +101 -0
- package/tests/rank_floor_wiring.mjs +4 -0
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_discipline_required.mjs +163 -0
- package/tests/task_goal_required.mjs +4 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- agents_004_spend_cap.sql — the PER-AGENT monthly spend cap (task 1002499).
|
|
2
|
+
--
|
|
3
|
+
-- agents_002 recorded who PAYS for a fire, and modules/agents/lib/spend-ceiling.js
|
|
4
|
+
-- bounds what a payer may spend in a month. Both ask "how much has this WALLET
|
|
5
|
+
-- spent", and neither can answer "how much has this AGENT spent" — so under a
|
|
6
|
+
-- single project payer one runaway definition consumes the whole instance ceiling
|
|
7
|
+
-- and every other agent is refused for a bill it did not run up. The owner's
|
|
8
|
+
-- decision (2026-07-05, recorded on task 1002499) is ~$5/month per agent, layered
|
|
9
|
+
-- under the ~$30/month global autonomy brake: a per-agent cap bounds the blast
|
|
10
|
+
-- radius of ONE bad definition, and the global brake bounds the fleet.
|
|
11
|
+
--
|
|
12
|
+
-- NULL MEANS "USE THE INSTANCE DEFAULT", NOT "UNCAPPED". The default lives in
|
|
13
|
+
-- lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5) so a definition
|
|
14
|
+
-- that never thought about money still has a ceiling, and an instance can move
|
|
15
|
+
-- every unset agent with one env var. Storing the default on each row instead
|
|
16
|
+
-- would freeze today's number into every definition ever synced — the same trap
|
|
17
|
+
-- agents_002's header names for deriving payer_kind, in the other direction.
|
|
18
|
+
-- Nothing may read this column as "no cap": the CHECK below forbids a zero or a
|
|
19
|
+
-- negative, so the only two states are a positive number and "ask the default".
|
|
20
|
+
--
|
|
21
|
+
-- WHY NO NEW INDEX. The read this backs — SUM(cost_usd) for one agent this month
|
|
22
|
+
-- — is already served by agents_runs_agent_idx (agent_name, created_at DESC),
|
|
23
|
+
-- which agents_001 created with the comment "the read behind a per-agent spend
|
|
24
|
+
-- rollup". This migration is the rollup that index was built for.
|
|
25
|
+
--
|
|
26
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
27
|
+
-- object is agents_-prefixed, there is no down-migration, and the filename is
|
|
28
|
+
-- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run.
|
|
29
|
+
|
|
30
|
+
BEGIN;
|
|
31
|
+
|
|
32
|
+
-- The per-agent monthly ceiling in USD. NULL = fall back to the instance default.
|
|
33
|
+
-- numeric(10,4) mirrors agents_runs.cost_usd, so a cap and the spend it bounds are
|
|
34
|
+
-- the same kind of number and a comparison cannot lose a fraction of a cent.
|
|
35
|
+
ALTER TABLE agents_definitions ADD COLUMN IF NOT EXISTS spend_cap_usd numeric(10, 4);
|
|
36
|
+
|
|
37
|
+
DO $$
|
|
38
|
+
BEGIN
|
|
39
|
+
-- A stored cap must be a real ceiling. Zero would read as "this agent may never
|
|
40
|
+
-- fire", which is what `enabled = false` already says and says better; a
|
|
41
|
+
-- negative is meaningless. Both are refused at the schema rather than left for
|
|
42
|
+
-- the guard to interpret, because a guard that has to decide what 0 means is a
|
|
43
|
+
-- guard with two possible answers.
|
|
44
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_spend_cap_positive_chk') THEN
|
|
45
|
+
ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_spend_cap_positive_chk
|
|
46
|
+
CHECK (spend_cap_usd IS NULL OR spend_cap_usd > 0);
|
|
47
|
+
END IF;
|
|
48
|
+
END $$;
|
|
49
|
+
|
|
50
|
+
COMMENT ON COLUMN agents_definitions.spend_cap_usd IS
|
|
51
|
+
'Per-agent monthly spend ceiling in USD (task 1002499). NULL = use the instance default from lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5). Never "uncapped" — a positive value is the only storable cap.';
|
|
52
|
+
|
|
53
|
+
COMMIT;
|
|
@@ -113,8 +113,13 @@ const FANOUT_MAX = envInt('BONGOS_AGENTS_EVENT_FANOUT_MAX', DEFAULT_FANOUT_MAX);
|
|
|
113
113
|
// `enabled = false` by agents_definitions_flagged_not_armed_chk, so the WHERE
|
|
114
114
|
// clause below already excludes it and a second check here would only be a
|
|
115
115
|
// second place to get it wrong.
|
|
116
|
+
// `spend_cap_usd` joins the list for step (g2) — the per-agent ceiling reads the
|
|
117
|
+
// definition's own cap (task 1002499). It has to be selected here or every agent
|
|
118
|
+
// silently takes the instance default, which is the quiet-wrong-answer shape this
|
|
119
|
+
// spelled-out list otherwise protects against: a cap set on a definition would be
|
|
120
|
+
// stored, shown by the API, and ignored by the only code that enforces it.
|
|
116
121
|
const DEF_COLUMNS = `
|
|
117
|
-
id, name, persona, trigger_type, trigger_spec, model_tier
|
|
122
|
+
id, name, persona, trigger_type, trigger_spec, model_tier, spend_cap_usd
|
|
118
123
|
`;
|
|
119
124
|
|
|
120
125
|
// ---------------------------------------------------------------------------
|
|
@@ -590,10 +595,32 @@ async function fanOut(payload, deps = {}) {
|
|
|
590
595
|
return report;
|
|
591
596
|
}
|
|
592
597
|
|
|
598
|
+
// (g2) THE PER-AGENT CEILING (task 1002499), once PER AGENT rather than once
|
|
599
|
+
// for the fan-out. That is the whole difference from (g) above: the payer
|
|
600
|
+
// ceiling asks a question about the wallet, so one answer covers every agent
|
|
601
|
+
// sharing it, while this one asks about each definition and so has as many
|
|
602
|
+
// answers as there are agents. An agent over its share is skipped with its own
|
|
603
|
+
// ledger row; its siblings still fire, which is the behaviour a per-agent cap
|
|
604
|
+
// exists to produce — one runaway definition must not silence the fleet.
|
|
605
|
+
const stillAdmitted = [];
|
|
606
|
+
for (const a of admitted) {
|
|
607
|
+
const agentRoom = await ceiling.checkAgent(pool, a.definition);
|
|
608
|
+
if (agentRoom.ok) { stillAdmitted.push(a); continue; }
|
|
609
|
+
const reason = agentRoom.reason === 'agent_spend_cap_reached'
|
|
610
|
+
? `agent '${a.definition && a.definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
|
|
611
|
+
: `this agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`;
|
|
612
|
+
await ledgerNoGo(deps, { definition: a.definition, taskId, reason, spendPayer });
|
|
613
|
+
report.skipped += 1;
|
|
614
|
+
}
|
|
615
|
+
if (!stillAdmitted.length) {
|
|
616
|
+
report.reason = 'agent_spend_ceiling';
|
|
617
|
+
return report;
|
|
618
|
+
}
|
|
619
|
+
|
|
593
620
|
// (h) FIRE. Sequentially, so N agents on one ship are N model calls in a row
|
|
594
621
|
// rather than N at once — the spend ceiling's own blind spot is fires already
|
|
595
622
|
// in flight, and serialising is what keeps that blind spot one fire wide.
|
|
596
|
-
for (const a of
|
|
623
|
+
for (const a of stillAdmitted) {
|
|
597
624
|
const fired = await fireOne(a, { task, taskId, spendPayer }, deps);
|
|
598
625
|
if (fired && fired.ran) report.fired += 1; else report.skipped += 1;
|
|
599
626
|
}
|
|
@@ -122,7 +122,7 @@ const COLUMNS = `
|
|
|
122
122
|
id, name, title, trigger_type, trigger_spec, model_tier,
|
|
123
123
|
scope_modules, scope_paths, scope_violation,
|
|
124
124
|
source, provenance, author_rank,
|
|
125
|
-
source_path, last_synced_at, enabled, created_at, updated_at
|
|
125
|
+
source_path, last_synced_at, enabled, spend_cap_usd, created_at, updated_at
|
|
126
126
|
`;
|
|
127
127
|
|
|
128
128
|
// PURE. Row → wire shape, so the projection is unit-testable without a database.
|
|
@@ -168,6 +168,14 @@ function serializeDefinition(row, { includePersona = false } = {}) {
|
|
|
168
168
|
// is the one disarm the registry can explain; anything else is simply off.
|
|
169
169
|
disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
|
|
170
170
|
last_synced_at: row.last_synced_at || null,
|
|
171
|
+
// The agent's own monthly ceiling (task 1002499), or null when it takes the
|
|
172
|
+
// instance default. Served as a NUMBER, not the pg NUMERIC string, so a caller
|
|
173
|
+
// comparing it against a spend does not silently compare strings. Null here
|
|
174
|
+
// never means "uncapped" — agents_004's CHECK makes a positive value the only
|
|
175
|
+
// storable cap, and an unset one resolves to BONGOS_AGENT_SPEND_CAP_USD.
|
|
176
|
+
spend_cap_usd: row.spend_cap_usd === null || row.spend_cap_usd === undefined
|
|
177
|
+
? null
|
|
178
|
+
: Number(row.spend_cap_usd),
|
|
171
179
|
created_at: row.created_at,
|
|
172
180
|
updated_at: row.updated_at,
|
|
173
181
|
};
|
|
@@ -605,6 +613,35 @@ module.exports = function agentsRoutes() {
|
|
|
605
613
|
);
|
|
606
614
|
if (!rows.length) return res.fail('agent_not_found', 404);
|
|
607
615
|
const definition = rows[0];
|
|
616
|
+
|
|
617
|
+
// THE PER-AGENT CEILING (task 1002499), after the payer's and before the
|
|
618
|
+
// gate. It cannot run any earlier: it needs the definition, both for its own
|
|
619
|
+
// spend_cap_usd and for the name it sums by.
|
|
620
|
+
//
|
|
621
|
+
// A THIRD refusal with a THIRD code, for the same reason the first two are
|
|
622
|
+
// separate: 429 says "too fast", `spend_cap_reached` says "this wallet's
|
|
623
|
+
// month is gone", and this one says "this wallet has room, but THIS AGENT
|
|
624
|
+
// has had its share". Collapsing them would tell an operator staring at a
|
|
625
|
+
// refused fire to go raise the wrong number — and under a single project
|
|
626
|
+
// payer the wrong number is the one that refuses every OTHER agent too.
|
|
627
|
+
const agentRoom = await spendCeiling.checkAgent(api.pool, definition);
|
|
628
|
+
if (!agentRoom.ok) {
|
|
629
|
+
return res.fail('agent_spend_cap_reached', {
|
|
630
|
+
status: 402,
|
|
631
|
+
message: agentRoom.reason === 'agent_spend_cap_reached'
|
|
632
|
+
? `agent '${definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
|
|
633
|
+
: `this fire is refused because the agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`,
|
|
634
|
+
details: {
|
|
635
|
+
scope: 'agent-spend-per-agent',
|
|
636
|
+
reason: agentRoom.reason,
|
|
637
|
+
agent: definition.name,
|
|
638
|
+
cap_usd: agentRoom.capUsd,
|
|
639
|
+
// null whenever the refusal was a failure to READ, never a claimed sum.
|
|
640
|
+
spent_usd: agentRoom.spentUsd,
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
608
645
|
const gate = gateFor(definition);
|
|
609
646
|
const trigger = { type: 'on-demand', ref: `on-demand:${req.builder.github_login || req.builder.id}` };
|
|
610
647
|
|
package/modules/agents/spawn.js
CHANGED
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
const api = require('../../src/module-api');
|
|
37
37
|
const { payerFor } = require('./lib/spend-ceiling');
|
|
38
38
|
|
|
39
|
+
// Namespaced + structured, like the module's other two log sites (routes/agents.js,
|
|
40
|
+
// pollers/event-router.js). A bare console.warn here would be the module's only
|
|
41
|
+
// unstructured line, and the fitness console ratchet counts it.
|
|
42
|
+
const log = api.logger('agents');
|
|
43
|
+
|
|
39
44
|
// tier → concrete model id. The mapping is BRANDING, not a constant here: an
|
|
40
45
|
// instance retunes its own tiers, and the DB constraint already limits the
|
|
41
46
|
// vocabulary to these two.
|
|
@@ -139,6 +144,68 @@ async function insertRun(pool, row) {
|
|
|
139
144
|
return r.rows[0].id;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
// The cost_log SOURCE TAG for one agent's spend (task 1002499). The `agent:`
|
|
148
|
+
// family is what modules/economy/cost.js's autonomous MTD brake matches on, beside
|
|
149
|
+
// `cron:`, so the prefix is a contract and not a label — see that function's header.
|
|
150
|
+
// The name is already schema-constrained to ^[a-z][a-z0-9-]*$ (agents_001), so
|
|
151
|
+
// nothing here has to sanitise it, but the tag is built in ONE place so a future
|
|
152
|
+
// rename cannot leave the brake matching a prefix nobody writes any more.
|
|
153
|
+
function costSourceTag(agentName) {
|
|
154
|
+
return `agent:${agentName}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Mirror one completed fire's cost into cost_log, so the project-wide brake can
|
|
158
|
+
// see it (task 1002499).
|
|
159
|
+
//
|
|
160
|
+
// WHY A MIRROR AND NOT A MOVE. agents_runs.cost_usd stays the ACCOUNTING — it is
|
|
161
|
+
// what the per-payer and per-agent ceilings sum, it carries the payer, and it is
|
|
162
|
+
// the row the schema's invariants are written about. cost_log is the project-wide
|
|
163
|
+
// ROLL-UP: the autonomy spend-brake and the cost dashboard's by_source breakdown
|
|
164
|
+
// both read it, and neither knows agents_runs exists. Before this, agent spend
|
|
165
|
+
// appeared in exactly one of those two places, which is why the brake was blind
|
|
166
|
+
// to it. The mirror is a projection of the ledger, never a second ledger: it is
|
|
167
|
+
// written once, after the cost is final, keyed so a retry cannot double-post.
|
|
168
|
+
//
|
|
169
|
+
// IDEMPOTENT BY CONSTRUCTION. logCost is insert-once on (source, source_ref), and
|
|
170
|
+
// source_ref is the run id — the one value that is unique per fire and already
|
|
171
|
+
// exists. A re-run of this function for the same fire is a no-op at the database,
|
|
172
|
+
// not a second row, which matters because the alternative to a stable key here is
|
|
173
|
+
// double-counting money.
|
|
174
|
+
//
|
|
175
|
+
// BEST-EFFORT, DELIBERATELY. A fire that ran and was ledgered in agents_runs has
|
|
176
|
+
// already happened and already cost money; failing it now because the roll-up
|
|
177
|
+
// could not be written would discard a completed fire's result to fix a reporting
|
|
178
|
+
// row. The direction of that trade is the opposite of the ceilings' — they fail
|
|
179
|
+
// CLOSED because they decide whether to spend, this fails OPEN because the spend
|
|
180
|
+
// is already past. What it must never do is stay silent: an unwritten mirror
|
|
181
|
+
// means the brake is under-counting, so the failure is logged with the run id.
|
|
182
|
+
async function mirrorCostToLedger({ runId, definition, costUsd, builderId = null }, deps = {}) {
|
|
183
|
+
if (!(typeof costUsd === 'number' && costUsd > 0)) return false;
|
|
184
|
+
const name = definition && definition.name;
|
|
185
|
+
if (!runId || typeof name !== 'string' || !name) return false;
|
|
186
|
+
const economy = deps.economy
|
|
187
|
+
|| (typeof api.resolveOptional === 'function' ? api.resolveOptional('reward', null) : null);
|
|
188
|
+
if (!economy || typeof economy.logCost !== 'function') return false;
|
|
189
|
+
try {
|
|
190
|
+
await economy.logCost({
|
|
191
|
+
amountUsd: costUsd,
|
|
192
|
+
category: 'api',
|
|
193
|
+
source: costSourceTag(name),
|
|
194
|
+
sourceRef: `agent-run-${runId}`,
|
|
195
|
+
// Who ASKED, when anyone did. Not who PAYS — payer_kind lives on the
|
|
196
|
+
// agents_runs row and is that question's answer; cost_log's builder_id is
|
|
197
|
+
// per-builder attribution, and an event fire legitimately has neither.
|
|
198
|
+
builderId,
|
|
199
|
+
description: `agent ${name} — fire ${runId}`.slice(0, 1000),
|
|
200
|
+
});
|
|
201
|
+
return true;
|
|
202
|
+
} catch (e) {
|
|
203
|
+
(deps.log || log).warn({ run_id: runId, agent: name, err: (e && e.message) || String(e) },
|
|
204
|
+
'cost mirror failed — agents_runs still has the cost, but the autonomous MTD brake is under-counting by it');
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
142
209
|
async function finishRun(pool, id, patch) {
|
|
143
210
|
const cols = Object.keys(patch);
|
|
144
211
|
const set = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
|
|
@@ -218,6 +285,11 @@ async function spawnAgent({
|
|
|
218
285
|
outputRef: ok ? (deps.outputRef || (res && res.output_ref) || null) : null,
|
|
219
286
|
});
|
|
220
287
|
await finishRun(pool, runId, patch);
|
|
288
|
+
// AFTER the ledger write, never before: the mirror projects a cost that is
|
|
289
|
+
// already recorded, so a row in cost_log with no agents_runs row behind it
|
|
290
|
+
// must not be possible. A failed or zero-cost fire mirrors nothing — the
|
|
291
|
+
// guard is inside mirrorCostToLedger so both call shapes share it.
|
|
292
|
+
await mirrorCostToLedger({ runId, definition, costUsd, builderId: requestedByBuilderId }, deps);
|
|
221
293
|
return { ok, ran: true, runId, model, cost_usd: costUsd, duration_ms: durationMs };
|
|
222
294
|
} catch (e) {
|
|
223
295
|
const durationMs = Math.max(0, now() - started);
|
|
@@ -228,4 +300,4 @@ async function spawnAgent({
|
|
|
228
300
|
}
|
|
229
301
|
}
|
|
230
302
|
|
|
231
|
-
module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt };
|
|
303
|
+
module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt, mirrorCostToLedger, costSourceTag };
|
|
@@ -429,6 +429,12 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
429
429
|
// opinion mint claimable work.
|
|
430
430
|
status: 'backlog',
|
|
431
431
|
kind: 'feature',
|
|
432
|
+
// task 1002988: named explicitly rather than inferred. A copy patch is
|
|
433
|
+
// reader-facing wording, which is artist work — cascade.js already treats
|
|
434
|
+
// `source: 'copy-proposal'` as an artist surface (hasCopyOrVisualWork), so
|
|
435
|
+
// inferring it from the patch text would risk the two disagreeing about
|
|
436
|
+
// the same row.
|
|
437
|
+
discipline: 'artist',
|
|
432
438
|
priority: 4,
|
|
433
439
|
touches: [patch.file],
|
|
434
440
|
source: 'copy-proposal',
|
|
@@ -46,6 +46,17 @@ const COST_WRITE_SURFACES = [
|
|
|
46
46
|
file: 'scripts/gds/session-cost.js',
|
|
47
47
|
builder_id: 'the authed builder; category=api, source_ref=<session-id>, task_id=null. Idempotent per session.',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
// task 1002499: an agent fire mirrors its cost here so the autonomous MTD
|
|
51
|
+
// spend-brake can see it. agents_runs.cost_usd stays the accounting; this is
|
|
52
|
+
// the project-wide roll-up, written once per fire after the cost is final and
|
|
53
|
+
// keyed source_ref=agent-run-<id> so a retry cannot double-post. It reaches
|
|
54
|
+
// logCost through the economy PORT (ADR 0083) — still one writer, which is the
|
|
55
|
+
// property this list exists to state.
|
|
56
|
+
surface: 'modules/agents/spawn.js (fire mirror) → reward port → db.logCost() (source=agent:<name>)',
|
|
57
|
+
file: 'modules/agents/spawn.js',
|
|
58
|
+
builder_id: 'whoever REQUESTED the fire, or null for an event fire, which nobody asked for. Not the PAYER — that is payer_kind on the agents_runs row, a different question.',
|
|
59
|
+
},
|
|
49
60
|
{
|
|
50
61
|
surface: 'migrations / direct DB',
|
|
51
62
|
file: '—',
|
package/modules/economy/cost.js
CHANGED
|
@@ -55,16 +55,34 @@ async function getBuilderCostStatus(builderId) {
|
|
|
55
55
|
return classifyCostStatus(row.spend_mtd_usd, row.budget_usd);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date
|
|
59
|
-
// `cron:%`-sourced rows (System 1 / task 1344 tags cron-launched sessions). The
|
|
58
|
+
// System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date. The
|
|
60
59
|
// autonomy precheck's hard spend-brake compares this against a ceiling so a
|
|
61
60
|
// runaway autonomous fleet self-throttles. Returns a plain number (USD).
|
|
61
|
+
//
|
|
62
|
+
// TWO SOURCE FAMILIES, because there are two ways this project spends money with
|
|
63
|
+
// nobody watching (task 1002499):
|
|
64
|
+
// cron:% — cron-launched SESSIONS (System 1 / task 1344).
|
|
65
|
+
// agent:% — agent fires (modules/agents). Each one is a `claude -p` the server
|
|
66
|
+
// spawns on its own, and POST /agents/:name/invoke answers 202
|
|
67
|
+
// without waiting, so the caller is not present for the spend either.
|
|
68
|
+
//
|
|
69
|
+
// The agent family was the hole this closes. Agent fires recorded their cost in
|
|
70
|
+
// `agents_runs.cost_usd` and nowhere else, so the brake — which reads cost_log —
|
|
71
|
+
// could not see a dollar of it: the per-payer and per-agent ceilings in
|
|
72
|
+
// modules/agents/lib/spend-ceiling.js bounded each wallet and each definition,
|
|
73
|
+
// while the fleet-wide brake they sit under was measuring only half the fleet.
|
|
74
|
+
//
|
|
75
|
+
// THE PREFIX MATCH IS THE CONTRACT, not a convenience. `agent:<name>` is written
|
|
76
|
+
// by exactly one place (the mirror in modules/agents/spawn.js) and the shape is
|
|
77
|
+
// asserted there, so widening the brake to a family rather than a list means a
|
|
78
|
+
// new agent is inside the ceiling the day it is authored, with nothing to
|
|
79
|
+
// remember to add here.
|
|
62
80
|
async function autonomousApiSpendMtd() {
|
|
63
81
|
const { rows } = await pool.query(
|
|
64
82
|
`SELECT COALESCE(SUM(amount_usd), 0) AS usd
|
|
65
83
|
FROM cost_log
|
|
66
84
|
WHERE category = 'api'
|
|
67
|
-
AND source LIKE 'cron:%'
|
|
85
|
+
AND (source LIKE 'cron:%' OR source LIKE 'agent:%')
|
|
68
86
|
AND recorded_at >= date_trunc('month', now())`
|
|
69
87
|
);
|
|
70
88
|
return Number(rows[0]?.usd) || 0;
|
|
@@ -67,6 +67,13 @@ module.exports = {
|
|
|
67
67
|
setBuilderMonthlyBudget: (...a) => cost.setBuilderMonthlyBudget(...a),
|
|
68
68
|
reconcileSessionCosts: (...a) => cost.reconcileSessionCosts(...a),
|
|
69
69
|
autonomousApiSpendMtd: (...a) => cost.autonomousApiSpendMtd(...a),
|
|
70
|
+
// The one WRITE the agents module needs (task 1002499). cost_log is economy's
|
|
71
|
+
// table and modules may only cross the wall through a port (ADR 0083 / 0093 §2),
|
|
72
|
+
// so an agent fire mirrors its cost through here rather than by importing cost.js
|
|
73
|
+
// or writing the INSERT itself. It is the same logCost every other writer uses —
|
|
74
|
+
// not an agent-shaped variant — so there is still exactly one writer, which is
|
|
75
|
+
// the property modules/economy/cost-classify.js's COST_WRITE_SURFACES enumerates.
|
|
76
|
+
logCost: (...a) => cost.logCost(...a),
|
|
70
77
|
leaderboard: (...a) => credits.leaderboard(...a),
|
|
71
78
|
getLeaderboardWithAchievements: (...a) => achievements.getLeaderboardWithAchievements(...a),
|
|
72
79
|
listAchievements: (...a) => achievements.listAchievements(...a),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#idea-body", "#idea-h"] } },
|
|
11
11
|
"missing": { "auth": true, "url": "/idea/999999", "actions": [["wait", 600]], "expect": { "visible": ["#idea-h"] } }
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
8
|
"_console": "The 501 is the harness, not the page: the meter re-scores through POST /inbox/score on every keystroke and scripts/hall-preview/server.js answers every write with preview_readonly by design. Declared rather than hidden.",
|
|
9
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "\\b501\\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
|
|
10
10
|
"states": {
|
|
11
11
|
"compose": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#fi-form", "#fi-fields", "#fi-queue"] } },
|
|
12
12
|
"develop": { "auth": true, "url": "/ideas?develop=1000884", "actions": [["wait", 700]], "expect": { "visible": ["#fi-develop"] } }
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"_feeds": "As work.states.json: the kit's stub is not the hall's feed set, so Home renders its skeletons and empty states here. The populated dashboard is the hall-preview harness.",
|
|
8
8
|
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard"],
|
|
9
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
10
|
"_1003441": "The `leaderboard` state left with the view: the Roll is the /roster page now and #leaderboard REDIRECTS there, so there is no #view-leaderboard on this page to make visible. The redirect is proven in tests/hall_nav.mjs (it asserts location.replace('/roster') fires on the index and nowhere else) rather than by a render state, because a state whose page navigates away measures the destination, not this page. `standing-karma` replaces the coverage: task 2178 made each Standing rail section addressable and none had a measured state.",
|
|
11
11
|
"states": {
|
|
12
12
|
"home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing"] } },
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"request": { "auth": true, "url": "/pair?code=K7QD-4M2X", "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-approve-btn"] } },
|
|
11
11
|
"nocode": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-scroll .pair-status--err"] } }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/", "/downloads/devbox/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed", "Password field"],
|
|
9
9
|
"states": {
|
|
10
10
|
"account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips", "#privacy-rows"] } },
|
|
11
11
|
"access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#task-grade", "#task-sessions", "#copy-prompt-btn"] } },
|
|
11
11
|
"from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"_feeds": "The kit's stub carries a small fixture set, not the hall's task feeds, so the board renders its EMPTY and SKELETON states here — which is the point: an empty queue is a state the floors must hold in. The feed 404s are declared, never hidden.",
|
|
8
8
|
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/versions"],
|
|
9
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
10
|
"states": {
|
|
11
11
|
"ready": { "auth": true, "expect": { "visible": ["#claim-scroll"], "hidden": ["#wip-scroll", "#backlog-scroll", "#history-scroll"] } },
|
|
12
12
|
"in-progress": { "auth": true, "url": "/builders/work#in-progress", "actions": [["wait", 400]], "expect": { "visible": ["#wip-scroll"] } },
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -405,9 +405,11 @@ async function routeIdeaToGoal({
|
|
|
405
405
|
// own floor and takes the HIGHER of the two, so this can raise the bar and
|
|
406
406
|
// never lower it. Deliberately NOT passed at all: `creditsReward` (server
|
|
407
407
|
// default — createTask auto-assigns the capped suggestion) and
|
|
408
|
-
// `discipline`
|
|
409
|
-
//
|
|
408
|
+
// `discipline` — NO LONGER 'unclassified' (task 1002988). createTask refuses a
|
|
409
|
+
// craft it cannot infer; refusing a filer is the wrong trade for an INBOX, so we
|
|
410
|
+
// ask the same question and fall back to 'engineer' (free to re-triage; a lost capture is not).
|
|
410
411
|
const task = await lifecycle.createTask({
|
|
412
|
+
discipline: (lifecycle.resolveWriteDiscipline?.({ title, description: bodyMd, touches: declaredTouches })?.discipline) || 'engineer',
|
|
411
413
|
versionId: goal.version_id,
|
|
412
414
|
title,
|
|
413
415
|
description: bodyMd,
|
|
@@ -18,6 +18,7 @@ const { matchProtected } = api.permissionPathCheck;
|
|
|
18
18
|
const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
|
|
19
19
|
const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
20
20
|
const { executorFor } = require('./db-executor.js');
|
|
21
|
+
const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
|
|
21
22
|
|
|
22
23
|
// -------------------------------------------------------------------------
|
|
23
24
|
// Tasks
|
|
@@ -123,7 +124,13 @@ async function createTask({
|
|
|
123
124
|
sourceRef = null,
|
|
124
125
|
valueSummary = null,
|
|
125
126
|
kind = 'unclassified',
|
|
126
|
-
|
|
127
|
+
// task 1002988 — NOT defaulted to 'unclassified' any more. This is the single
|
|
128
|
+
// chokepoint every create vector passes through, including the ones that never
|
|
129
|
+
// touch an HTTP route (idea promotion, copy-desk, newcomer-restock, kickoff-seed,
|
|
130
|
+
// cascade), so resolving here is what makes "no row is born role-less" true of
|
|
131
|
+
// the whole path rather than of the two routes. Resolved just below; `undefined`
|
|
132
|
+
// means "work it out from the text".
|
|
133
|
+
discipline = undefined,
|
|
127
134
|
parallelSafe = null,
|
|
128
135
|
newcomerFriendly = false,
|
|
129
136
|
needsMigration = false,
|
|
@@ -198,6 +205,25 @@ async function createTask({
|
|
|
198
205
|
}
|
|
199
206
|
goalIdEffective = await generalGoalIdForVersion(versionId);
|
|
200
207
|
}
|
|
208
|
+
|
|
209
|
+
// task 1002988 (owner decision 2026-09-17): every row is born with a craft.
|
|
210
|
+
// An absent or 'unclassified' discipline is INFERRED from title + description +
|
|
211
|
+
// touches; a task whose text says nothing about its craft is REFUSED rather than
|
|
212
|
+
// filed under a word that means "nobody looked". Throwing here rather than
|
|
213
|
+
// silently defaulting is the point — 1,189 role-less rows accumulated precisely
|
|
214
|
+
// because the default was quiet. A caller that cannot tolerate a refusal passes
|
|
215
|
+
// an explicit discipline, which is then visible at its call site instead of
|
|
216
|
+
// hidden in this signature.
|
|
217
|
+
const resolvedDiscipline = resolveWriteDiscipline({ discipline, title, description, touches });
|
|
218
|
+
if (resolvedDiscipline.error) {
|
|
219
|
+
const err = new Error(resolvedDiscipline.unclassifiable
|
|
220
|
+
? `Cannot infer a discipline for "${String(title).slice(0, 80)}" from its title and description. Pass discipline explicitly: ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
|
|
221
|
+
: `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')} (got ${JSON.stringify(discipline)}).`);
|
|
222
|
+
err.code = 'BAD_DISCIPLINE';
|
|
223
|
+
err.unclassifiable = resolvedDiscipline.unclassifiable === true;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
const disciplineEffective = resolvedDiscipline.discipline;
|
|
201
227
|
// task 1498 / ADR 0084: every task carries a deliberate rank floor. Derive it,
|
|
202
228
|
// then let an explicit requiresRank override only UPWARD (highestRank) — a
|
|
203
229
|
// requested rank below the derived floor is clamped up to the floor
|
|
@@ -247,7 +273,7 @@ async function createTask({
|
|
|
247
273
|
sourceRef,
|
|
248
274
|
valueSummary,
|
|
249
275
|
kind,
|
|
250
|
-
|
|
276
|
+
disciplineEffective,
|
|
251
277
|
parallelSafe,
|
|
252
278
|
newcomerFriendly === true,
|
|
253
279
|
needsMigration === true,
|
|
@@ -37,6 +37,12 @@ module.exports = {
|
|
|
37
37
|
// (consumer: core cascade-dispatch.js, the dormant System-3 lib).
|
|
38
38
|
get VOTABLE_TASK_STATUSES() { return classifier.VOTABLE_TASK_STATUSES; },
|
|
39
39
|
classifyTaskKind: (...a) => classifier.classifyTaskKind(...a),
|
|
40
|
+
// task 1002988: createTask now REFUSES a task whose craft it cannot work out, so
|
|
41
|
+
// a consumer that must not fail (idea capture, copy-desk) needs to ask the same
|
|
42
|
+
// question first and name its own fallback. Exposed on the port because a module
|
|
43
|
+
// may not require another module — the boundary rule — and a second copy of this
|
|
44
|
+
// heuristic living in modules/ideas is exactly the drift this port exists to stop.
|
|
45
|
+
resolveWriteDiscipline: (...a) => classifier.resolveWriteDiscipline(...a),
|
|
40
46
|
// TASK_KINDS: the kind vocabulary, as an ordered array (consumer: GET /me,
|
|
41
47
|
// which hands it to the hall so a filter can be BUILT from it instead of
|
|
42
48
|
// retyped — task 1003453). VALID_KINDS is a Set and Sets do not survive
|
|
@@ -31,7 +31,7 @@ const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edi
|
|
|
31
31
|
const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
|
|
32
32
|
const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
|
|
33
33
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
34
|
-
const { classifyTaskKind, VALID_KINDS,
|
|
34
|
+
const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
35
35
|
const { validateOrRespond, parseId, LIMITS } = api;
|
|
36
36
|
const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
|
|
37
37
|
// goalScopeCheck — the PURE scope wall (BV1.R59 / ADR 0086 §3), reached through
|
|
@@ -1388,10 +1388,23 @@ module.exports = function buildGoalsRouter() {
|
|
|
1388
1388
|
const result = classifyTaskKind(body.title, body.description || '');
|
|
1389
1389
|
kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
|
|
1390
1390
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1391
|
+
// task 1002988 — the SECOND create path. It carried the same
|
|
1392
|
+
// `body.discipline ?? 'unclassified'` line as POST /tasks, so a fix applied
|
|
1393
|
+
// only there would have left this one minting the rows the task exists to
|
|
1394
|
+
// remove. Both now share resolveWriteDiscipline().
|
|
1395
|
+
const resolvedDiscipline = resolveWriteDiscipline({
|
|
1396
|
+
discipline: body.discipline, title: body.title, description: body.description, touches: body.touches,
|
|
1397
|
+
});
|
|
1398
|
+
if (resolvedDiscipline.error) {
|
|
1399
|
+
return res.fail('bad_discipline', {
|
|
1400
|
+
status: 400,
|
|
1401
|
+
message: resolvedDiscipline.unclassifiable
|
|
1402
|
+
? `discipline was not supplied and could not be inferred from the title and description. Pass one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
|
|
1403
|
+
: `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
|
|
1404
|
+
details: { valid: Array.from(WRITABLE_DISCIPLINES) },
|
|
1405
|
+
});
|
|
1394
1406
|
}
|
|
1407
|
+
const discipline = resolvedDiscipline.discipline;
|
|
1395
1408
|
|
|
1396
1409
|
// Create. version = the goal's (not client-supplied); status 'ready' so the
|
|
1397
1410
|
// author can claim it immediately; requiresRank OMITTED → auto-derived
|
|
@@ -22,7 +22,7 @@ const db = require('../db');
|
|
|
22
22
|
const hierarchy = require('../hierarchy');
|
|
23
23
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
24
24
|
const { buildGoalAdvisory } = require('../goal-advisory');
|
|
25
|
-
const { classifyTaskKind, VALID_KINDS,
|
|
25
|
+
const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
26
26
|
const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
27
27
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
28
28
|
const { badKind, detectsMigration } = require('./task-route-helpers.js');
|
|
@@ -141,10 +141,22 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
141
141
|
const result = classifyTaskKind(body.title, body.description || '');
|
|
142
142
|
kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
// task 1002988: a create must never mint a role-less row. An omitted (or
|
|
145
|
+
// explicitly 'unclassified') discipline is INFERRED from the text; only a
|
|
146
|
+
// genuinely unclassifiable task is refused.
|
|
147
|
+
const resolved = resolveWriteDiscipline({
|
|
148
|
+
discipline: body.discipline, title: body.title, description: body.description, touches: body.touches,
|
|
149
|
+
});
|
|
150
|
+
if (resolved.error) {
|
|
151
|
+
return res.fail('bad_discipline', {
|
|
152
|
+
status: 400,
|
|
153
|
+
message: resolved.unclassifiable
|
|
154
|
+
? `discipline was not supplied and could not be inferred from the title and description. Pass one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
|
|
155
|
+
: `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
|
|
156
|
+
details: { valid: Array.from(WRITABLE_DISCIPLINES), inferred: false },
|
|
157
|
+
});
|
|
147
158
|
}
|
|
159
|
+
const discipline = resolved.discipline;
|
|
148
160
|
// task 1498 / ADR 0084: an explicit requires_rank must be a live rank. The
|
|
149
161
|
// floor auto-derives from touches + security_sensitive; createTask clamps
|
|
150
162
|
// any explicit value UP to that floor (never below), so this only rejects a
|
|
@@ -496,8 +508,15 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
496
508
|
if (hasKind && !VALID_KINDS.has(body.kind)) {
|
|
497
509
|
return badKind(res);
|
|
498
510
|
}
|
|
499
|
-
|
|
500
|
-
|
|
511
|
+
// task 1002988: a PATCH may not push a row BACK to 'unclassified' — that is a
|
|
512
|
+
// triage state, not a craft, and letting an edit re-create one would reopen the
|
|
513
|
+
// hole the create path above just closed.
|
|
514
|
+
if (hasDiscipline && !WRITABLE_DISCIPLINES.has(body.discipline)) {
|
|
515
|
+
return res.fail('bad_discipline', {
|
|
516
|
+
status: 400,
|
|
517
|
+
message: `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
|
|
518
|
+
details: { valid: Array.from(WRITABLE_DISCIPLINES) },
|
|
519
|
+
});
|
|
501
520
|
}
|
|
502
521
|
if (hasNewcomerOrXenos) {
|
|
503
522
|
// V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
|