@bongos/core 1.19.886 → 1.19.888

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.886",
6
- "core_contract": "1.19.886",
7
- "source_commit": "316898e40a03efcd36d7449d595c288afc1cd8c1",
5
+ "core_version": "1.19.888",
6
+ "core_contract": "1.19.888",
7
+ "source_commit": "7ccf0b2f4cc7f7bafb93762cbd5cfb9ed37cf546",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T20:38:23.641Z",
9
+ "built_at": "2026-09-19T20:57:53.725Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 497,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2264,
14
+ "functional_verbatim": 2266,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2787,
20
- "tree_sha256": "b969fc24c4a56c1e7b132aadc9f0855fddb1542325d65e5fb6f39063957ed493",
19
+ "file_count": 2789,
20
+ "tree_sha256": "42c331016e53b14ca5416a325790e2846ea0ee2c6a24177222b823a1d2977b9c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2052,7 +2052,7 @@
2052
2052
  {
2053
2053
  "path": "docs/api/openapi.json",
2054
2054
  "mode": "0000644",
2055
- "sha256": "19cb42bb52d123f89bf24d9e4b8667955f0fc682b2481790f0c48d8d1ed09e79"
2055
+ "sha256": "37732622e53cde0e4ee6bb0133ef6bade2a862f7d6337c40723b94fbf6ae6c22"
2056
2056
  },
2057
2057
  {
2058
2058
  "path": "docs/architecture.md",
@@ -2607,7 +2607,7 @@
2607
2607
  {
2608
2608
  "path": "docs/module-api-changelog.md",
2609
2609
  "mode": "0000644",
2610
- "sha256": "064deae02dc6a35a8bf1fe1d0788d1de4f93361970a8631d72f90e8e8dfbb8d7"
2610
+ "sha256": "7f60dfa2c479fecbd5a81de040689b9ff6f9d2aa5a907f9a7fcf86b77b476fea"
2611
2611
  },
2612
2612
  {
2613
2613
  "path": "docs/modules-contract.md",
@@ -3732,7 +3732,7 @@
3732
3732
  {
3733
3733
  "path": "modules/agents/lib/validate.js",
3734
3734
  "mode": "0000644",
3735
- "sha256": "3ff56e388e9ae4cccb09fb072793b5544f771aeae51e8477f8a221f0c78b9515"
3735
+ "sha256": "e9b3c905539a323d74a55f98455204bd46daca2daf49c476324e85684ba7971f"
3736
3736
  },
3737
3737
  {
3738
3738
  "path": "modules/agents/migrations/agents_001_definitions.sql",
@@ -3754,6 +3754,11 @@
3754
3754
  "mode": "0000644",
3755
3755
  "sha256": "31a5ac35536cecd264bc989907f4cf094e050db0b8de85e7e3e7831cf7c25a9d"
3756
3756
  },
3757
+ {
3758
+ "path": "modules/agents/migrations/agents_005_cron_trigger.sql",
3759
+ "mode": "0000644",
3760
+ "sha256": "5d422998b1d53ee015f4a494ccede1adcb1eb90bd501bd28c1d88f9840b87367"
3761
+ },
3757
3762
  {
3758
3763
  "path": "modules/agents/module.json",
3759
3764
  "mode": "0000644",
@@ -6312,7 +6317,7 @@
6312
6317
  {
6313
6318
  "path": "modules/provisioning/paid-shape-gate.js",
6314
6319
  "mode": "0000644",
6315
- "sha256": "a72f2961ba04739648b8622d86a85a75aef613d5d7c50fbb2f43ae8cf2c4adae"
6320
+ "sha256": "7c08e627e228bec400e9e2dc204418a6995a4668eb6c1817ae59f20f77cbe71e"
6316
6321
  },
6317
6322
  {
6318
6323
  "path": "modules/provisioning/pollers/catalog-backfill.js",
@@ -6367,7 +6372,7 @@
6367
6372
  {
6368
6373
  "path": "modules/provisioning/routes/provisioning.js",
6369
6374
  "mode": "0000644",
6370
- "sha256": "e9eb66469fc14adc526f97815ef61f4145cf17bbf36bdb638a2ed4db897149b7"
6375
+ "sha256": "63919aad35ff1f47c4981e5e5c1f1c08d79eb0de9588e0c3ba2c5ed488167211"
6371
6376
  },
6372
6377
  {
6373
6378
  "path": "modules/provisioning/seams.js",
@@ -7692,12 +7697,12 @@
7692
7697
  {
7693
7698
  "path": "package-lock.json",
7694
7699
  "mode": "0000644",
7695
- "sha256": "adef509b54c9513decbeb3c56a0e87925b07dd5d491b459c3f73a7ddd9646f55"
7700
+ "sha256": "d3b1b9f84e1713ff7815c19a9bd02b6c4de4dd3b44a5ede68ff56fbd5ad0cfcd"
7696
7701
  },
7697
7702
  {
7698
7703
  "path": "package.json",
7699
7704
  "mode": "0000644",
7700
- "sha256": "f72673c4e4af6a3f16ba47c8dcd601ae2f9cb797626cf411fd291b474f5a7500"
7705
+ "sha256": "4cd9682e13a9a2e1e358c7bd500286378e7b5b8ceb1bb284a925b91ab8de2a08"
7701
7706
  },
7702
7707
  {
7703
7708
  "path": "public-docs/index.html",
@@ -9702,7 +9707,7 @@
9702
9707
  {
9703
9708
  "path": "src/module-api.js",
9704
9709
  "mode": "0000644",
9705
- "sha256": "522d02581614267d50279ebe88ace2bf5d35f1365149d35e5e98dde9f21d8243"
9710
+ "sha256": "ae3867dc81f944026ce67bae25ca927232b84accc4ed03d4ef45798526c72c46"
9706
9711
  },
9707
9712
  {
9708
9713
  "path": "src/module-loader/catalog.js",
@@ -9844,6 +9849,11 @@
9844
9849
  "mode": "0000644",
9845
9850
  "sha256": "eea8f9e0bdf2c84fabc81e16c30e4026a7a336c1e705d614e68b805957b9b74a"
9846
9851
  },
9852
+ {
9853
+ "path": "tests/agents_cron_trigger.mjs",
9854
+ "mode": "0000644",
9855
+ "sha256": "be78fa37657214c39a4880d2e1b44a1647c8b509d63262ca9a4a334431d7bdde"
9856
+ },
9847
9857
  {
9848
9858
  "path": "tests/agents_event_router.mjs",
9849
9859
  "mode": "0000644",
@@ -9877,7 +9887,7 @@
9877
9887
  {
9878
9888
  "path": "tests/agents_validate.mjs",
9879
9889
  "mode": "0000644",
9880
- "sha256": "9376185bc6e11571b050dcf61a993faf780cea66aa4c0844d114b1cb738043e2"
9890
+ "sha256": "164059d69fabbd5dd70a29b29c524b5359f0346b71091e73a5e4fafa5dbc0804"
9881
9891
  },
9882
9892
  {
9883
9893
  "path": "tests/agents_write_routes.mjs",
@@ -12727,17 +12737,17 @@
12727
12737
  {
12728
12738
  "path": "tests/provisioning_module_selection.mjs",
12729
12739
  "mode": "0000644",
12730
- "sha256": "dffa4b500d3afe770764e562236ee97b6d581ed3db301ee3f984c5caa6f6f35e"
12740
+ "sha256": "540e9b5b158a5623525924403bcef444f87fe0364591cd9c6b9e0a55f4f6295c"
12731
12741
  },
12732
12742
  {
12733
12743
  "path": "tests/provisioning_onboard_mode.mjs",
12734
12744
  "mode": "0000644",
12735
- "sha256": "f8a714c4b4a77e6eaa00f832b47beef19b5de916d544af2b839455c301be2bc8"
12745
+ "sha256": "f7a01aa6ed190c1ad544d91312909b081446a7fc2b6ef339f3ba8fa4d59f0306"
12736
12746
  },
12737
12747
  {
12738
12748
  "path": "tests/provisioning_paid_shape_gate.mjs",
12739
12749
  "mode": "0000644",
12740
- "sha256": "1cf30338eb8dafc77f2fea1e2b5ec417fe64a6576ececcca18936fe4e871c763"
12750
+ "sha256": "19961880545fe8f6c9eaf79095aa0d08f7862409109d581cac828f0f20b1be69"
12741
12751
  },
12742
12752
  {
12743
12753
  "path": "tests/provisioning_recommendations.mjs",
@@ -12672,7 +12672,7 @@
12672
12672
  "provisioning"
12673
12673
  ],
12674
12674
  "summary": "POST /provisioning/instances",
12675
- "description": "POST /provisioning/instances — request a new instance. rank: any authenticated builder (own resource); a `dedicated` (metered) shape also needs provisioning.fleet.manage (../paid-shape-gate.js), and a full shared box answers 409 box_full up front (../capacity-gate.js). ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409. Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated), domain?, tier?, onboard_mode? (greenfield|adopt) }. onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner branches its repo scaffold on it. 'adopt' means target_ref is a repo the owner ALREADY has, so the runner must clone + layer onto its history instead of pushing a fresh tree at `main` (which an existing repo rejects as unrelated history). Absent → 'greenfield', the safe default.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
12675
+ "description": "POST /provisioning/instances — request a new instance. rank: any authenticated builder (own resource) for the free `co-tenant` shape. Every other shape also needs provisioning.fleet.manage — `dedicated` because it bills, `standalone` because it runs the caller's own repo on the platform's box — and `control-plane` is refused outright, since the platform enrolls its own row (../paid-shape-gate.js, task 1003370 / audit B4). A full shared box answers 409 box_full up front (../capacity-gate.js). ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409. Body: { slug (required), target_ref?, hosting_shape? (co-tenant free; others gated), domain?, tier?, onboard_mode? (greenfield|adopt) }. onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner branches its repo scaffold on it. 'adopt' means target_ref is a repo the owner ALREADY has, so the runner must clone + layer onto its history instead of pushing a fresh tree at `main` (which an existing repo rejects as unrelated history). Absent → 'greenfield', the safe default.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
12676
12676
  "x-rank": "any-builder",
12677
12677
  "x-source": "modules/provisioning/routes/provisioning.js",
12678
12678
  "requestBody": {
@@ -2246,5 +2246,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2246
2246
  landed since 1.19.884 with no explicit bump. run 35464802278. (task 1002620)
2247
2247
  1.19.886 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2248
2248
  landed since 1.19.885 with no explicit bump. run 35467975408. (task 1002620)
2249
+ 1.19.887 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2250
+ landed since 1.19.886 with no explicit bump. run 35468511941. (task 1002620)
2251
+ 1.19.888 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2252
+ landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
2249
2253
  ---------------------------------------------------------------------------
2250
2254
  ```
@@ -48,8 +48,31 @@
48
48
  const EVENT_CATALOG = Object.freeze(['task.shipped', 'idea.graded.full']);
49
49
 
50
50
  // Mirrors agents_definitions_trigger_type_chk. 'event' consumes the catalog above;
51
- // 'on-demand' is the historian shape (a builder asks, the agent answers).
52
- const TRIGGER_TYPES = Object.freeze(['event', 'on-demand']);
51
+ // 'on-demand' is the historian shape (a builder asks, the agent answers); 'cron'
52
+ // fires on a cadence and consumes CADENCES below (task 1004008, agents_005).
53
+ const TRIGGER_TYPES = Object.freeze(['event', 'on-demand', 'cron']);
54
+
55
+ // The cadence vocabulary a 'cron' trigger may declare, in trigger_spec.cadence.
56
+ //
57
+ // DUPLICATED FROM scripts/gds/routine-schedule.js ON PURPOSE, for the reason
58
+ // RANK_ORDER below is duplicated: a module may require only src/module-api.js
59
+ // (ADR 0083), so it cannot reach into scripts/. The two copies are kept honest by
60
+ // tests/agents_cron_trigger.mjs, which reads routine-schedule.js's own CADENCES +
61
+ // isUniformDivisor and asserts this file agrees — a drifting copy fails there
62
+ // rather than at some future tick.
63
+ //
64
+ // N MUST DIVIDE 60 for the sub-hourly form. A cron step restarts each hour, so
65
+ // only a divisor yields a uniform period: `*/15` fires :00 :15 :30 :45 always,
66
+ // but `*/7` fires :00…:56 then waits 4 — a "7min" cadence that is not one.
67
+ const CADENCES = Object.freeze(['hourly', 'daily', 'weekly', 'monthly']);
68
+ const SUBHOURLY_RE = /^([1-9]\d*)min$/;
69
+ function isCadence(name) {
70
+ if (CADENCES.includes(name)) return true;
71
+ const m = SUBHOURLY_RE.exec(String(name == null ? '' : name));
72
+ if (!m) return false;
73
+ const n = Number(m[1]);
74
+ return Number.isInteger(n) && n > 0 && n < 60 && 60 % n === 0;
75
+ }
53
76
 
54
77
  // Mirrors agents_definitions_model_tier_chk. No 'main' tier by design — an agent
55
78
  // is a subagent, never the main session.
@@ -222,6 +245,29 @@ function validateAgentDefinition(obj, opts = {}) {
222
245
  }
223
246
  }
224
247
 
248
+ // --- the cadence, fail-closed, the same shape as the event catalog above ----
249
+ // agents_005's CHECK pins that a cron definition HAS a non-empty cadence; the
250
+ // vocabulary is pinned here, because the sub-hourly rule is a predicate and a
251
+ // CHECK spelling it out would be a second copy free to drift.
252
+ //
253
+ // The reach is deliberately the EVENT BLOCK'S reach, no more: an UNWIRED
254
+ // cadence is refused wherever it appears, including on a non-cron definition
255
+ // where it is dead config. A VALID cadence on a non-cron definition is ACCEPTED,
256
+ // exactly as a wired `event:` on an on-demand definition is — harmless, unread,
257
+ // and not this validator's call to police. Saying otherwise in a comment would
258
+ // describe a guard that is not here.
259
+ if (spec) {
260
+ const cadence = safeGet(spec, 'cadence');
261
+ const hasCadenceKey = cadence !== undefined && cadence !== null;
262
+ if (triggerType === 'cron' && !isNonEmptyString(cadence)) {
263
+ E('trigger_cadence_missing', 'trigger_spec.cadence',
264
+ `trigger_spec.cadence: required for a 'cron' trigger — a definition with no cadence can never fire. Named cadences: ${CADENCES.join(', ')}; or a sub-hourly '<N>min' where N divides 60.`);
265
+ } else if (hasCadenceKey && !isCadence(cadence)) {
266
+ E('trigger_cadence_unwired', 'trigger_spec.cadence',
267
+ `trigger_spec.cadence: ${JSON.stringify(cadence)} is not a cadence. Named cadences: ${CADENCES.join(', ')}; or a sub-hourly '<N>min' where N divides 60 (so the declared period and the realized one are the same claim).`);
268
+ }
269
+ }
270
+
225
271
  // --- model tier -----------------------------------------------------------
226
272
  const rawTier = safeGet(obj, 'model_tier');
227
273
  const modelTier = rawTier === undefined || rawTier === null ? 'default' : rawTier;
@@ -355,6 +401,7 @@ function validateAgentDefinition(obj, opts = {}) {
355
401
  module.exports = {
356
402
  validateAgentDefinition,
357
403
  EVENT_CATALOG,
404
+ CADENCES, isCadence,
358
405
  TRIGGER_TYPES,
359
406
  MODEL_TIERS,
360
407
  RANK_ORDER,
@@ -0,0 +1,78 @@
1
+ -- agents_005_cron_trigger.sql — make 'cron' a trigger type that can exist (task 1004008).
2
+ --
3
+ -- Tasks 1002498 (the cron-router poller) and 1002501 (the cron roster) both ask
4
+ -- for a timer whose each tick SELECTS DUE CRON AGENTS. There are none and there
5
+ -- can be none: agents_001 pinned trigger_type to ('event','on-demand') on BOTH
6
+ -- agents_definitions and agents_runs, and lib/validate.js mirrors it. Build the
7
+ -- poller against that and you get a timer that is correct, tested, and selects
8
+ -- from an empty set by construction — green tests over an unreachable branch,
9
+ -- which is a false claim of coverage rather than coverage. This migration is the
10
+ -- enabling half; the poller is 1002498's and is deliberately NOT built here.
11
+ --
12
+ -- WIDENING A FAIL-CLOSED ENUM IS THE POINT, NOT A WORKAROUND. agents_001's header
13
+ -- calls these enums fail-closed on purpose: "an unwired trigger name is refused,
14
+ -- never silently stored". That rule is kept — 'cron' stops being unwired in the
15
+ -- same change that makes it storable, because lib/validate.js is widened and
16
+ -- constrained alongside. What is still true after this: a trigger name outside
17
+ -- the three is refused at both the validator and the constraint.
18
+ --
19
+ -- BOTH TABLES, AND WHY THE LEDGER MATTERS AS MUCH AS THE DEFINITION. agents_001
20
+ -- constrains trigger_type on agents_runs too. Widen only the definition and a
21
+ -- cron agent could be stored and enabled, and then fail at the moment it fires,
22
+ -- when the ledger insert violates its own CHECK — the worst shape available: a
23
+ -- definition that looks armed and cannot record a single run.
24
+ --
25
+ -- A CRON DEFINITION MUST SAY WHEN. agents_001 already refuses an event definition
26
+ -- with no event name ("a definition that can never fire"). A cron definition with
27
+ -- no cadence is the same defect wearing a different key, so it gets the same
28
+ -- treatment rather than being left for the poller to interpret at tick time.
29
+ -- The cadence VOCABULARY is validated in lib/validate.js, not here: the named set
30
+ -- ('hourly','daily','weekly','monthly') plus the open-ended sub-hourly form
31
+ -- ('15min') is a predicate, and a CHECK that tried to spell the sub-hourly rule —
32
+ -- N must divide 60 — would be a second, drifting copy of it. The constraint
33
+ -- pins the SHAPE (a non-empty cadence key is present); the validator pins the
34
+ -- VALUE. That is the same division agents_001 draws for `event`.
35
+ --
36
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
37
+ -- object is agents_-prefixed, there is no down-migration, and the filename is
38
+ -- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run. No existing row
39
+ -- changes — widening a CHECK can only make rows MORE legal, so nothing that
40
+ -- validates today stops validating.
41
+
42
+ BEGIN;
43
+
44
+ DO $$
45
+ BEGIN
46
+ -- agents_definitions: the three WIRED trigger types.
47
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_trigger_type_chk') THEN
48
+ ALTER TABLE agents_definitions DROP CONSTRAINT agents_definitions_trigger_type_chk;
49
+ END IF;
50
+ ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_trigger_type_chk
51
+ CHECK (trigger_type IN ('event', 'on-demand', 'cron'));
52
+
53
+ -- agents_runs: the same three, so a cron fire can be RECORDED and not only run.
54
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_runs_trigger_type_chk') THEN
55
+ ALTER TABLE agents_runs DROP CONSTRAINT agents_runs_trigger_type_chk;
56
+ END IF;
57
+ ALTER TABLE agents_runs ADD CONSTRAINT agents_runs_trigger_type_chk
58
+ CHECK (trigger_type IN ('event', 'on-demand', 'cron'));
59
+
60
+ -- The twin of agents_definitions_trigger_spec_event_chk: a cron definition
61
+ -- carries a non-empty cadence, or it is a definition that can never fire.
62
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_trigger_spec_cadence_chk') THEN
63
+ ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_trigger_spec_cadence_chk
64
+ CHECK (trigger_type <> 'cron'
65
+ OR (trigger_spec ? 'cadence' AND length(trigger_spec ->> 'cadence') > 0));
66
+ END IF;
67
+ END $$;
68
+
69
+ -- agents_001 indexes (trigger_type, trigger_spec->>'event') for the event
70
+ -- router's "who wants this event" read. The cron router's read is a different
71
+ -- question — "which cron agents are due" — and it is answered by scanning the
72
+ -- cron rows, which are few. Adding a cadence index now would be indexing for a
73
+ -- query no committed code makes; 1002498 adds one if its poller needs it.
74
+
75
+ COMMENT ON CONSTRAINT agents_definitions_trigger_type_chk ON agents_definitions IS
76
+ 'The WIRED trigger vocabulary (task 1004008 added cron). Mirrored by TRIGGER_TYPES in modules/agents/lib/validate.js — widen both together or the two disagree about what can exist.';
77
+
78
+ COMMIT;
@@ -1,21 +1,54 @@
1
- // modules/provisioning/paid-shape-gate.js — who may ask for METERED infrastructure
2
- // (task 1003682).
1
+ // modules/provisioning/paid-shape-gate.js — who may ask for which hosting SHAPE
2
+ // (task 1003682, widened by task 1003370 / audit B4).
3
+ //
4
+ // THE FILENAME IS NARROWER THAN THE FILE, DELIBERATELY. This started as the gate over
5
+ // METERED shapes alone. Task 1003370 widened it to every shape that is not the free
6
+ // front door, and the name was kept because ADR 0285 links to it by path and four
7
+ // sibling files cite it by name as the precedent for living in their own file. A rename
8
+ // would have broken those links to buy a better noun; this paragraph buys the same
9
+ // clarity for free.
3
10
  //
4
11
  // The create route is open to any authenticated builder, and it must stay that way:
5
- // standing up a project is the platform's front door, not a privilege. But one of the
6
- // hosting shapes it accepts is not free. `dedicated` is the only shape whose runner leg
7
- // allocates a real droplet (`scripts/gds/provision.js` — billed monthly, defaulting to
8
- // s-2vcpu-4gb when no tier is named); every other shape co-tenants on infrastructure the
9
- // platform already pays for, which is why `costEstimateUsd` returns 0 for all of them.
12
+ // standing up a project is the platform's front door, not a privilege. But only ONE of
13
+ // the shapes it accepts is actually that front door. The others each hand the caller
14
+ // something the platform pays for or trusts:
15
+ //
16
+ // co-tenant — the front door. Co-tenants on infrastructure the platform already
17
+ // runs; `costEstimateUsd` is 0. No authority required, ever.
18
+ // dedicated — allocates a real droplet (`scripts/gds/provision.js`), billed
19
+ // monthly, defaulting to s-2vcpu-4gb when no tier is named.
20
+ // standalone — runs the caller's OWN repo on the platform's box. The unit's
21
+ // WorkingDirectory is that checkout (`scripts/gds/provision-units.js`)
22
+ // and the module loader discovers modules from `resolveInstanceRoot()`,
23
+ // which is process.cwd() (`src/module-loader/loader.js`). So the
24
+ // caller names a repo and the platform require()s its `modules/` —
25
+ // arbitrary code execution on the control plane's own machine. Task
26
+ // 1003369 gave each instance its own uid, which bounds the blast
27
+ // radius to that account; it does not make "any Xenos may run code
28
+ // here" an acceptable default.
29
+ // control-plane — the platform's OWN row (ADR 0293 D2, task 1003159), enrolled by
30
+ // `scripts/gds/enroll-control-plane.js` so the owner's deploy door has
31
+ // something to point at. Never a thing a builder asks for. See below.
10
32
  //
11
33
  // So the authority question belongs to the SHAPE, not the route. Gating the whole route
12
34
  // would make ordinary project creation require a rank — the wrong trade entirely, and the
13
35
  // reason this is a middleware over one field rather than a floor on the endpoint.
14
36
  //
37
+ // AN ALLOW-LIST, NOT A DENY-LIST — the control-plane-guard.js rule, for the same reason.
38
+ // The set named below is the set that needs NO authority. Every other shape, including
39
+ // one added after this file was last read, requires the permission by omission. The
40
+ // previous shape of this gate listed the one shape that was gated and admitted the rest,
41
+ // and that is exactly how `standalone` and `control-plane` stayed open: `standalone`
42
+ // because it was reasoned about as "$0, therefore harmless" (true of the invoice, false
43
+ // of the trust), and `control-plane` because it did not exist yet when the gate was
44
+ // written. Permitted-by-omission is the failure mode this inversion removes.
45
+ //
15
46
  // The atom is `provisioning.fleet.manage` (floor: metic), deliberately the same one that
16
47
  // already guards the fleet cost-ledger and force-teardown. Spending on the fleet and
17
- // managing the fleet are one authority, and before this the route that SPENDS money was
18
- // less gated than the route that stops it.
48
+ // managing the fleet are one authority, and before task 1003682 the route that SPENDS
49
+ // money was less gated than the route that stops it. Task 1003370 adds no new atom: the
50
+ // question "may this caller commit the platform's own resources" is the same question
51
+ // whether the resource is a droplet or the control plane's CPU.
19
52
  //
20
53
  // Split into its own file rather than inlined beside the route for two reasons, both
21
54
  // mechanical: `routes/provisioning.js` sits at its 1500-line fitness ratchet (the same
@@ -25,33 +58,75 @@
25
58
  // write route" failure on an unrelated endpoint.
26
59
  'use strict';
27
60
 
28
- // The shapes that cost money. Kept as a set of one rather than a bare `=== 'dedicated'`
29
- // so a second metered shape is a one-line change here instead of a hunt through gates;
30
- // `tests/provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES so adding one without
31
- // revisiting this file fails loudly.
32
- const METERED_SHAPES = Object.freeze(new Set(['dedicated']));
61
+ const provisioning = require('./provisioning.js');
62
+
63
+ // The shapes any authenticated builder may request with no permission at all. This is
64
+ // the whole allow-list, and it is deliberately one entry long: co-tenant is the front
65
+ // door, and the front door is the only thing that should be free. `tests/
66
+ // provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against this set so a new shape
67
+ // cannot be added without someone deciding, in writing, which side of the line it is on.
68
+ const OPEN_SHAPES = Object.freeze(new Set(['co-tenant']));
33
69
 
34
- // isMeteredShape — does this hosting shape allocate infrastructure someone is billed for?
35
- // Matches the exact wire value: the route's own validator refuses anything not in
36
- // HOSTING_SHAPES, so a case or whitespace variant is rejected as a bad shape rather than
37
- // admitted here as a cheap one.
38
- function isMeteredShape(shape) {
39
- return METERED_SHAPES.has(shape);
70
+ // Shapes that are not requestable through this route AT ALL — no permission admits them,
71
+ // because there is no rank at which "ask the API for a second control plane" is a
72
+ // coherent request. The control-plane row is the platform describing ITSELF; a builder
73
+ // -created duplicate is not a privilege escalation in the permission sense but something
74
+ // worse: `core-upgrade.js` selects the self row with
75
+ // `WHERE hosting_shape = 'control-plane' LIMIT 1`, so a second row makes "which machine
76
+ // is the control plane" a question with a caller-influenced answer. Refused by name, at
77
+ // the door, for the same reason control-plane-guard.js refuses by name.
78
+ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
79
+
80
+ // shapeAuthority — what does this wire value demand? PURE. Returns one of:
81
+ // 'open' → admit with no permission
82
+ // 'unrequestable' → refuse outright, whatever the caller holds
83
+ // 'permission' → require provisioning.fleet.manage
84
+ // 'defer' → not a shape this gate recognises; let the route's own validator
85
+ // answer it (a 400 `bad_hosting_shape` naming the valid set is a far
86
+ // more useful reply to a typo than a 403 about a permission the
87
+ // caller would not have needed anyway).
88
+ //
89
+ // 'defer' is safe despite looking like a hole: the validator runs immediately after this
90
+ // middleware and refuses anything outside HOSTING_SHAPES, and `createInstance` coerces an
91
+ // unrecognised shape to 'co-tenant' rather than storing it. There is no path by which a
92
+ // deferred value becomes a privileged row.
93
+ //
94
+ // Matches the exact wire value — a case or whitespace variant of a privileged shape is
95
+ // not in HOSTING_SHAPES, so it defers here and is then refused as a bad shape, never
96
+ // admitted as a cheap one.
97
+ function shapeAuthority(rawShape) {
98
+ const shape = rawShape === undefined || rawShape === null || rawShape === ''
99
+ ? 'co-tenant' // an absent shape is the documented default, and it is the open one
100
+ : rawShape;
101
+ if (typeof shape !== 'string') return 'defer';
102
+ if (!provisioning.HOSTING_SHAPES.includes(shape)) return 'defer';
103
+ if (UNREQUESTABLE_SHAPES.has(shape)) return 'unrequestable';
104
+ if (OPEN_SHAPES.has(shape)) return 'open';
105
+ return 'permission';
40
106
  }
41
107
 
42
- // requireAuthorityForMeteredShape — the create route's paid-shape gate. Mount it AFTER
43
- // requireBuilder and BEFORE the handler, so a refusal leaves no row behind for the runner
44
- // to drain later. `requirePermission` is injected (rather than required here) so the gate
45
- // is exercisable without standing up the auth stack.
46
- function requireAuthorityForMeteredShape(requirePermission) {
108
+ // requireAuthorityForShape — the create route's shape gate. Mount it AFTER requireBuilder
109
+ // and BEFORE the handler, so a refusal leaves no row behind for the runner to drain later.
110
+ // `requirePermission` is injected (rather than required here) so the gate is exercisable
111
+ // without standing up the auth stack.
112
+ function requireAuthorityForShape(requirePermission) {
47
113
  const gate = requirePermission('provisioning.fleet.manage');
48
- return function meteredShapeGate(req, res, next) {
49
- if (!isMeteredShape(req.body && req.body.hosting_shape)) return next();
114
+ return function shapeGate(req, res, next) {
115
+ const verdict = shapeAuthority(req.body && req.body.hosting_shape);
116
+ if (verdict === 'open' || verdict === 'defer') return next();
117
+ if (verdict === 'unrequestable') {
118
+ return res.fail('shape_not_requestable', {
119
+ status: 403,
120
+ message: 'The control plane enrolls its own row; it cannot be requested through this route.',
121
+ details: { hosting_shape: req.body.hosting_shape, requestable: [...provisioning.HOSTING_SHAPES].filter((s) => !UNREQUESTABLE_SHAPES.has(s)) },
122
+ });
123
+ }
50
124
  return gate(req, res, next);
51
125
  };
52
126
  }
53
127
 
54
- // Only the gate is exported. METERED_SHAPES and isMeteredShape are deliberately private:
55
- // nothing outside this file needs to ask "is this shape metered" separately from being
56
- // gated on it, and an export nobody consumes is one more thing to keep true.
57
- module.exports = { requireAuthorityForMeteredShape };
128
+ // Only the gate is exported. OPEN_SHAPES, UNREQUESTABLE_SHAPES and shapeAuthority are
129
+ // deliberately private: nothing outside this file needs to ask "what does this shape
130
+ // demand" separately from being gated on it, and an export nobody consumes is one more
131
+ // thing to keep true.
132
+ module.exports = { requireAuthorityForShape };
@@ -12,9 +12,10 @@
12
12
  //
13
13
  // own-scoped (requireBuilder — the caller's OWN instances):
14
14
  // POST /provisioning/instances — request a new instance
15
- // (a `dedicated` shape ALSO needs
16
- // provisioning.fleet.manage — it is the
17
- // only shape that bills; ../paid-shape-gate.js.
15
+ // (only `co-tenant` is free; every other
16
+ // shape ALSO needs provisioning.fleet.manage,
17
+ // and `control-plane` is not requestable at
18
+ // all; ../paid-shape-gate.js.
18
19
  // An `adopt` ALSO passes the credential
19
20
  // preflight — ../credential-preflight.js,
20
21
  // task 1002909; greenfield is exempt; a full box → 409, ../capacity-gate.js)
@@ -50,7 +51,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
50
51
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
51
52
  const { recommendationsForDetails } = require('../recommendations');
52
53
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
53
- const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
54
+ const { requireAuthorityForShape } = require('../paid-shape-gate');
54
55
  const { requireRoomOnBox } = require('../capacity-gate'); // a full box refused up front (task 1003938)
55
56
  const { adoptCredentialFault } = require('../credential-preflight');
56
57
  const catalogBridge = require('../catalog-bridge');
@@ -280,11 +281,14 @@ module.exports = function provisioningRoutes() {
280
281
  });
281
282
 
282
283
  // POST /provisioning/instances — request a new instance. rank: any authenticated
283
- // builder (own resource); a `dedicated` (metered) shape also needs provisioning.fleet.manage
284
- // (../paid-shape-gate.js), and a full shared box answers 409 box_full up front (../capacity-gate.js).
284
+ // builder (own resource) for the free `co-tenant` shape. Every other shape also needs
285
+ // provisioning.fleet.manage — `dedicated` because it bills, `standalone` because it runs
286
+ // the caller's own repo on the platform's box — and `control-plane` is refused outright,
287
+ // since the platform enrolls its own row (../paid-shape-gate.js, task 1003370 / audit B4).
288
+ // A full shared box answers 409 box_full up front (../capacity-gate.js).
285
289
  // ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME
286
290
  // owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
287
- // Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated),
291
+ // Body: { slug (required), target_ref?, hosting_shape? (co-tenant free; others gated),
288
292
  // domain?, tier?, onboard_mode? (greenfield|adopt) }.
289
293
  //
290
294
  // onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner
@@ -292,7 +296,7 @@ module.exports = function provisioningRoutes() {
292
296
  // ALREADY has, so the runner must clone + layer onto its history instead of pushing a
293
297
  // fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
294
298
  // 'greenfield', the safe default.
295
- router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForMeteredShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
299
+ router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
296
300
  if (validateOrRespond(req, res, {
297
301
  slug: { required: true, type: 'string', maxLength: 40 },
298
302
  target_ref: { type: 'string', maxLength: 200 },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.886",
3
+ "version": "1.19.888",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.886",
9
+ "version": "1.19.888",
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.886",
3
+ "version": "1.19.888",
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",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.886'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.888'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,133 @@
1
+ // tests/agents_cron_trigger.mjs — 'cron' is a trigger type that can EXIST
2
+ // (task 1004008, goal 1000095).
3
+ //
4
+ // Tasks 1002498 (cron-router poller) and 1002501 (cron roster) both select "due
5
+ // cron agents" from a set that was empty by construction: agents_001 pinned
6
+ // trigger_type to ('event','on-demand') on BOTH agents_definitions and
7
+ // agents_runs, and lib/validate.js mirrored it. A poller built against that is
8
+ // correct, tested, and unreachable — green tests over a branch nothing can enter.
9
+ //
10
+ // What this locks:
11
+ // * the THREE places that must agree (validator, definitions CHECK, runs CHECK)
12
+ // — the ledger constraint is the one that is easy to forget, and forgetting it
13
+ // yields the worst shape available: an agent that looks armed, fires, and
14
+ // then violates a CHECK when it tries to record the run.
15
+ // * the cadence VOCABULARY, including the sub-hourly divisor rule.
16
+ // * that validate.js's duplicated CADENCES really match scripts/gds/
17
+ // routine-schedule.js's, which is the copy this one was made from.
18
+ // * that widening the enum did not loosen it — an unwired name is still refused.
19
+
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const v = require('../modules/agents/lib/validate.js');
30
+ const schedule = require('../scripts/gds/routine-schedule.js');
31
+
32
+ const sql = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'agents', 'migrations', f), 'utf8');
33
+ const mig001 = sql('agents_001_definitions.sql');
34
+ const mig005 = sql('agents_005_cron_trigger.sql');
35
+
36
+ const def = (over = {}) => ({ name: 'ship-digest', persona: 'x'.repeat(40), ...over });
37
+ const cron = (cadence) => def({ trigger_type: 'cron', trigger_spec: { cadence } });
38
+
39
+ // ---- 1. the validator ------------------------------------------------------
40
+
41
+ test("'cron' is a wired trigger type", () => {
42
+ assert.ok(v.TRIGGER_TYPES.includes('cron'));
43
+ // Widening is not loosening: the enum is still closed.
44
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'webhook', trigger_spec: {} })).ok, false);
45
+ assert.deepEqual([...v.TRIGGER_TYPES].sort(), ['cron', 'event', 'on-demand']);
46
+ });
47
+
48
+ test('a cron definition must say WHEN, or it can never fire', () => {
49
+ const r = v.validateAgentDefinition(def({ trigger_type: 'cron', trigger_spec: {} }));
50
+ assert.equal(r.ok, false);
51
+ assert.ok(r.errors.some((e) => e.code === 'trigger_cadence_missing'),
52
+ 'the missing cadence is named as its own error, not folded into a generic spec failure');
53
+ });
54
+
55
+ test('the cadence vocabulary: named, and sub-hourly where N divides 60', () => {
56
+ for (const good of ['hourly', 'daily', 'weekly', 'monthly', '15min', '5min', '30min', '1min']) {
57
+ assert.equal(v.validateAgentDefinition(cron(good)).ok, true, `${good} is a cadence`);
58
+ }
59
+ // 7 and 45 do not divide 60: `*/7` fires :00…:56 then waits 4, so the declared
60
+ // period and the realized period would be different claims.
61
+ for (const bad of ['7min', '45min', '0min', '60min', 'fortnightly', 'often', '']) {
62
+ assert.equal(v.validateAgentDefinition(cron(bad)).ok, false, `${bad} is not a cadence`);
63
+ }
64
+ });
65
+
66
+ test('an accepted cron definition keeps its cadence through normalization', () => {
67
+ // The bug class task 1004007 documents one axis over: validate accepted a field
68
+ // and then DROPPED it, so a definition looked configured and was not.
69
+ const r = v.validateAgentDefinition(cron('daily'));
70
+ assert.equal(r.ok, true);
71
+ assert.equal(r.value.trigger_spec.cadence, 'daily', 'the cadence survives into the normalized value');
72
+ assert.equal(r.value.trigger_type, 'cron');
73
+ });
74
+
75
+ test('the event path is untouched', () => {
76
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: { event: 'task.shipped' } })).ok, true);
77
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: {} })).ok, false);
78
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: { event: 'nope' } })).ok, false);
79
+ });
80
+
81
+ test('a VALID cadence on a non-cron definition is accepted, as a wired event is', () => {
82
+ // Pinned because the reach is a judgement call and the comment beside the code
83
+ // claims exactly this. An UNWIRED cadence is still refused wherever it appears.
84
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'on-demand', trigger_spec: { cadence: 'daily' } })).ok, true);
85
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'on-demand', trigger_spec: { cadence: '7min' } })).ok, false);
86
+ });
87
+
88
+ // ---- 2. the copy that is free to drift -------------------------------------
89
+
90
+ test('validate.js CADENCES agrees with routine-schedule.js, the copy it came from', () => {
91
+ // A module may require only src/module-api.js (ADR 0083), so validate.js cannot
92
+ // import this list — it holds a duplicate. This test is the thing that keeps the
93
+ // duplicate honest; without it the two are one edit away from disagreeing about
94
+ // what a cadence is, and nothing would say so until a tick.
95
+ assert.deepEqual([...v.CADENCES].sort(), [...schedule.CADENCES].sort());
96
+ for (const n of [1, 5, 7, 15, 30, 45, 60, 0]) {
97
+ assert.equal(v.isCadence(`${n}min`), schedule.isCadence(`${n}min`),
98
+ `the two copies agree on ${n}min`);
99
+ }
100
+ });
101
+
102
+ // ---- 3. both CHECK constraints ---------------------------------------------
103
+
104
+ test('BOTH tables widen — the ledger as well as the definition', () => {
105
+ // agents_001 constrains trigger_type on agents_runs too. Widen only the
106
+ // definition and a cron agent stores, enables, fires, and THEN fails when the
107
+ // run insert violates its own CHECK.
108
+ for (const c of ['agents_definitions_trigger_type_chk', 'agents_runs_trigger_type_chk']) {
109
+ assert.ok(mig001.includes(c), `${c} exists in agents_001 — this test is about widening it`);
110
+ assert.match(mig005, new RegExp(`ADD CONSTRAINT ${c}[\\s\\S]{0,200}?'cron'`),
111
+ `${c} is re-added including 'cron'`);
112
+ assert.match(mig005, new RegExp(`DROP CONSTRAINT ${c}`),
113
+ `${c} is dropped before being re-added — ALTER cannot widen a CHECK in place`);
114
+ }
115
+ });
116
+
117
+ test('the migration pins the SHAPE of a cron spec, leaving the vocabulary to the validator', () => {
118
+ assert.match(mig005, /agents_definitions_trigger_spec_cadence_chk/);
119
+ assert.match(mig005, /trigger_type <> 'cron'/, 'the guard only binds cron rows');
120
+ assert.match(mig005, /trigger_spec \? 'cadence'/, 'and requires the key');
121
+ // The sub-hourly divisor rule must NOT be spelled in SQL — that would be a
122
+ // second copy of isCadence(), free to drift from the one the validator uses.
123
+ assert.ok(!/% *60|divid/i.test(mig005.replace(/^--.*$/gm, '')),
124
+ 'no arithmetic cadence rule in the executable SQL — the predicate lives in one place');
125
+ });
126
+
127
+ test('the migration is additive, namespaced and idempotent (ADR 0083 §5)', () => {
128
+ assert.match(mig005, /IF (NOT )?EXISTS/, 'guarded so a re-run is safe');
129
+ assert.ok(!/\bDROP TABLE\b|\bDROP COLUMN\b/i.test(mig005), 'nothing is destroyed');
130
+ for (const stmt of mig005.match(/(CREATE|ALTER)\s+TABLE\s+(\w+)/gi) || []) {
131
+ assert.match(stmt, /\s(agents_\w+)$/, `${stmt} touches only agents_-namespaced tables`);
132
+ }
133
+ });
@@ -164,12 +164,20 @@ await test('an on-demand definition carrying a WIRED event is accepted', () => {
164
164
 
165
165
  // --- THE TRIGGER TYPE -------------------------------------------------------
166
166
 
167
- await test('TRIGGER_TYPES is the v1 pair the DB CHECK also pins', () => {
168
- assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand']);
167
+ await test('TRIGGER_TYPES is the wired set the DB CHECK also pins', () => {
168
+ // Was the v1 PAIR; task 1004008 added 'cron' so tasks 1002498/1002501 select
169
+ // from a set that can be non-empty. Kept as an exact list on purpose: widening
170
+ // this vocabulary must be a deliberate edit here AND in agents_005's CHECK on
171
+ // both agents_definitions and agents_runs (tests/agents_cron_trigger.mjs pins
172
+ // that the three agree).
173
+ assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand', 'cron']);
169
174
  });
170
175
 
171
176
  await test('an unknown trigger type is refused', () => {
172
- for (const bad of ['cron', 'webhook', 'Event', 'on demand', '', null, undefined, 7, {}]) {
177
+ // 'cron' was this list's first entry until task 1004008 wired it. 'webhook' and
178
+ // 'schedule' are the plausible-but-unwired names now — the case exists to prove
179
+ // an unwired name is refused, so it needs names that are actually unwired.
180
+ for (const bad of ['webhook', 'schedule', 'Event', 'on demand', '', null, undefined, 7, {}]) {
173
181
  const r = validateAgentDefinition({ ...OK_DEF, trigger_type: bad }, OPTS);
174
182
  assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
175
183
  assert.ok(hasCode(r, 'trigger_type_unknown'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
@@ -471,7 +479,7 @@ await test('a hostile getter cannot escape the validator', () => {
471
479
 
472
480
  await test('EVERY error carries a code, a field and a human message', () => {
473
481
  const r = validateAgentDefinition(
474
- { name: 'BAD NAME', trigger_type: 'cron', trigger_spec: 'nope', scope_modules: ['government'] },
482
+ { name: 'BAD NAME', trigger_type: 'webhook', trigger_spec: 'nope', scope_modules: ['government'] },
475
483
  { authorRank: 'xenos', allowedModules: ['agents'] },
476
484
  );
477
485
  assert.equal(r.ok, false);
@@ -485,7 +493,7 @@ await test('EVERY error carries a code, a field and a human message', () => {
485
493
 
486
494
  await test('validation reports ALL faults at once, not just the first', () => {
487
495
  // An author fixing a definition file should not have to re-run four times.
488
- const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'cron' }, OPTS);
496
+ const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'webhook' }, OPTS);
489
497
  for (const code of ['name_invalid', 'persona_missing', 'trigger_type_unknown']) {
490
498
  assert.ok(hasCode(r, code), `${code} missing from ${JSON.stringify(codes(r))}`);
491
499
  }
@@ -235,7 +235,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
235
235
 
236
236
  // Act — the body the wizard files
237
237
  const res = await post('/provisioning/instances', {
238
- slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['agents', 'discord'],
238
+ slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: ['agents', 'discord'],
239
239
  });
240
240
 
241
241
  // Assert
@@ -245,7 +245,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
245
245
 
246
246
  await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
247
247
  lastCreateArgs = null; existingRow = null;
248
- const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', type: 'game' });
248
+ const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game' });
249
249
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
250
250
  assert.equal(lastCreateArgs.modules, undefined, 'nothing is invented on the owner\'s behalf');
251
251
  // …and the receipt already carries the set they will actually get.
@@ -255,7 +255,7 @@ await ta('POST /instances: an omitted modules key is still a valid create — sk
255
255
 
256
256
  await ta('POST /instances: an explicit empty selection is accepted as an answer', async () => {
257
257
  lastCreateArgs = null; existingRow = null;
258
- const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: [] });
258
+ const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: [] });
259
259
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
260
260
  assert.deepEqual(lastCreateArgs.modules, []);
261
261
  });
@@ -266,7 +266,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
266
266
  // prevent, and the reason badProjectDetail was written beside this.
267
267
  existingRow = null;
268
268
  const res = await post('/provisioning/instances', {
269
- slug: 'mercury', hosting_shape: 'standalone', modules: ['discrod'],
269
+ slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discrod'],
270
270
  });
271
271
  assert.equal(res.status, 400, res.raw);
272
272
  assert.equal(res.body.error.code, 'bad_modules');
@@ -277,7 +277,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
277
277
  await ta('POST /instances: naming an always-on module is refused with the reason, not the generic miss', async () => {
278
278
  existingRow = null;
279
279
  const res = await post('/provisioning/instances', {
280
- slug: 'mercury', hosting_shape: 'standalone', modules: ['memory'],
280
+ slug: 'mercury', hosting_shape: 'co-tenant', modules: ['memory'],
281
281
  });
282
282
  assert.equal(res.status, 400, res.raw);
283
283
  assert.equal(res.body.error.code, 'bad_modules');
@@ -286,11 +286,11 @@ await ta('POST /instances: naming an always-on module is refused with the reason
286
286
 
287
287
  await ta('POST /instances: modules must be an array of strings', async () => {
288
288
  existingRow = null;
289
- const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', modules: 'discord' });
289
+ const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: 'discord' });
290
290
  assert.equal(notArray.status, 400, notArray.raw);
291
291
  assert.equal(notArray.body.error.code, 'validation_failed');
292
292
 
293
- const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', modules: [7] });
293
+ const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: [7] });
294
294
  assert.equal(notStrings.status, 400, notStrings.raw);
295
295
  assert.equal(notStrings.body.error.code, 'validation_failed');
296
296
  });
@@ -301,7 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
301
301
  // to hunt for one.
302
302
  existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
303
303
  const res = await post('/provisioning/instances', {
304
- slug: 'mercury', hosting_shape: 'standalone', modules: ['discord'],
304
+ slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discord'],
305
305
  });
306
306
  assert.equal(res.status, 200, res.raw);
307
307
  assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
@@ -589,7 +589,7 @@ await ta('POST /instances: a null answer at CREATION is accepted and stores noth
589
589
  lastCreateArgs = null; existingRow = null;
590
590
 
591
591
  const res = await post('/provisioning/instances', {
592
- slug: 'mercury', hosting_shape: 'standalone', type: 'game', detail: { team_shape: null },
592
+ slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', detail: { team_shape: null },
593
593
  });
594
594
 
595
595
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
@@ -150,7 +150,7 @@ function post(path, body) {
150
150
  await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
151
151
  lastCreateArgs = null;
152
152
  const res = await post('/provisioning/instances', {
153
- slug: 'demo', hosting_shape: 'standalone', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
153
+ slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
154
154
  });
155
155
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
156
156
  assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
@@ -158,7 +158,7 @@ await ta('POST /instances: onboard_mode=adopt is threaded through to createInsta
158
158
 
159
159
  await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
160
160
  lastCreateArgs = null;
161
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone' });
161
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant' });
162
162
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
163
163
  assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
164
164
  });
@@ -167,7 +167,7 @@ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently co
167
167
  // Coercing here would be worse than refusing: the caller believes it asked for one thing
168
168
  // and the runner does another. The route names the valid set so the client can fix itself.
169
169
  const res = await post('/provisioning/instances', {
170
- slug: 'demo', hosting_shape: 'standalone', target_ref: 'a/b', onboard_mode: 'brownfield',
170
+ slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'a/b', onboard_mode: 'brownfield',
171
171
  });
172
172
  assert.equal(res.status, 400);
173
173
  assert.equal(res.body.error.code, 'bad_onboard_mode');
@@ -204,8 +204,8 @@ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no r
204
204
  // Without target_ref the runner would fall back to greenfield behavior on a row labelled
205
205
  // adopt. Refuse at the door rather than store a lie.
206
206
  for (const body of [
207
- { slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt' },
208
- { slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt', target_ref: ' ' },
207
+ { slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt' },
208
+ { slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt', target_ref: ' ' },
209
209
  ]) {
210
210
  const res = await post('/provisioning/instances', body);
211
211
  assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
@@ -10,11 +10,26 @@
10
10
  // SPENDS was less gated than force-teardown, which stops spending and sits behind
11
11
  // provisioning.fleet.manage.
12
12
  //
13
- // The fix gates the SHAPE, not the route: every $0 shape (standalone, co-tenant) stays open
14
- // to any authenticated builder, because ordinary project creation must not require a rank.
15
- // Only `dedicated` demands provisioning.fleet.manage — deliberately the same atom that
16
- // already guards the fleet cost-ledger and force-teardown, since spending on the fleet and
17
- // managing the fleet are one authority.
13
+ // The fix gates the SHAPE, not the route: ordinary project creation must not require a rank,
14
+ // so `co-tenant` — the front door — stays open to any authenticated builder. Everything else
15
+ // demands provisioning.fleet.manage, deliberately the same atom that already guards the fleet
16
+ // cost-ledger and force-teardown, since spending on the fleet and managing the fleet are one
17
+ // authority.
18
+ //
19
+ // WIDENED BY TASK 1003370 (audit B4), which is why this file's name is narrower than its
20
+ // subject. The original gate named the one shape it refused and admitted the rest, and two
21
+ // shapes stayed open underneath that reasoning:
22
+ //
23
+ // standalone — judged harmless because it costs $0. True of the invoice; false of the
24
+ // trust. The unit's WorkingDirectory is the caller's own checkout and the
25
+ // loader discovers modules from process.cwd(), so the platform require()s
26
+ // a repo the caller named.
27
+ // control-plane — did not exist when the gate was written, and was permitted by omission
28
+ // the moment task 1003159 added it. A probe returned 201 and a second
29
+ // control-plane row, while this file's own comment asserted it could not.
30
+ //
31
+ // So the set below is now the set that needs NO authority, and a shape added later is gated
32
+ // by omission instead of admitted by it.
18
33
  //
19
34
  // DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
20
35
  // beyond a loopback listener.
@@ -93,15 +108,7 @@ function post(path, body) {
93
108
  });
94
109
  }
95
110
 
96
- // ── the $0 shapes stay open — this is the half a careless fix would break ─────────
97
-
98
- await ta('standalone: a builder with NO fleet permission still creates (the live wizard path)', async () => {
99
- held = [];
100
- lastCreateArgs = null;
101
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
102
- assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
103
- assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
104
- });
111
+ // ── the front door stays open — this is the half a careless fix would break ───────
105
112
 
106
113
  await ta('co-tenant (and an absent shape, which defaults to it) stays open with no permission', async () => {
107
114
  for (const body of [
@@ -114,6 +121,61 @@ await ta('co-tenant (and an absent shape, which defaults to it) stays open with
114
121
  }
115
122
  });
116
123
 
124
+ // ── standalone runs the caller's OWN code on the platform's box (task 1003370) ────
125
+
126
+ await ta('standalone: REFUSED for a builder without provisioning.fleet.manage', async () => {
127
+ // Task 1003682 left this shape open, reasoning that a $0 shape hands the caller
128
+ // nothing. That is true of the invoice and false of the trust: the systemd unit's
129
+ // WorkingDirectory is the caller's own checkout and the module loader discovers
130
+ // `modules/` from process.cwd(), so a standalone request is "please require() this
131
+ // repo on your control plane". The uid split (task 1003369) bounds the blast radius;
132
+ // it does not make the default acceptable.
133
+ held = [];
134
+ lastCreateArgs = null;
135
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
136
+ assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
137
+ assert.equal(res.body.error.code, 'permission_forbidden');
138
+ assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
139
+ assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
140
+ });
141
+
142
+ await ta('standalone: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
143
+ held = ['provisioning.fleet.manage'];
144
+ lastCreateArgs = null;
145
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
146
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
147
+ assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
148
+ });
149
+
150
+ // ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
151
+
152
+ await ta('control-plane: refused for an unprivileged builder, and the refusal is not a permission', async () => {
153
+ // Until task 1003370 this returned 201 and created a SECOND control-plane row. The
154
+ // previous version of this file asserted, in a comment, that the shape "is not
155
+ // creatable through the create route at all" — a claim nothing tested, and which a
156
+ // probe falsified. The comment was true of intent and false of the code.
157
+ held = [];
158
+ lastCreateArgs = null;
159
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'control-plane', no_address: true });
160
+ assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
161
+ assert.equal(res.body.error.code, 'shape_not_requestable');
162
+ assert.equal(lastCreateArgs, null, 'no row is created');
163
+ });
164
+
165
+ await ta('control-plane: still refused for a builder holding EVERY permission', async () => {
166
+ // The distinguishing property. A permission gate would admit an Archon here, and
167
+ // `core-upgrade.js` picks the self row with `WHERE hosting_shape = 'control-plane'
168
+ // LIMIT 1` — so a second row makes "which machine is the control plane" a question
169
+ // with a caller-influenced answer. No rank makes that a coherent request; the self
170
+ // row is enrolled by scripts/gds/enroll-control-plane.js.
171
+ held = ['provisioning.fleet.manage'];
172
+ lastCreateArgs = null;
173
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'control-plane', no_address: true });
174
+ assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
175
+ assert.equal(res.body.error.code, 'shape_not_requestable');
176
+ assert.equal(lastCreateArgs, null, 'no row is created');
177
+ });
178
+
117
179
  // ── dedicated is the metered one ──────────────────────────────────────────────────
118
180
 
119
181
  await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
@@ -161,20 +223,35 @@ await ta('a case-variant shape does not slip past the gate INTO a dedicated row'
161
223
  }
162
224
  });
163
225
 
164
- await ta('HOSTING_SHAPES carries the shapes the gate reasons about, and control-plane is NOT metered', () => {
226
+ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision about its authority', () => {
165
227
  // This tripwire fired for the first time at task 1003159, which added a fourth
166
228
  // shape — 'control-plane', the platform's own box, enrolled so the owner's deploy
167
- // door has a row to point at (ADR 0293 D2). Its question was "is the new shape
168
- // also metered?", and the answer is NO, stated here rather than implied by
169
- // absence: the control plane is infrastructure the project already runs and pays
170
- // for, not a droplet allocated per request, so nothing about standing it up is
171
- // billed through this gate. It is also not creatable through the create route at
172
- // all — the self row is enrolled by the platform, not asked for by a builder.
229
+ // door has a row to point at (ADR 0293 D2). Its question then was "is the new
230
+ // shape also metered?" and the answer recorded was no — correct, and the wrong
231
+ // question. Nobody asked whether it was REQUESTABLE, and it was: a Xenos could
232
+ // POST for a second control plane until task 1003370.
233
+ //
234
+ // So the question this list now forces is the one that generalises. The gate is an
235
+ // allow-list: `co-tenant` needs nothing, `control-plane` is refused outright, and
236
+ // EVERY other shape — including one added after this file was last read — demands
237
+ // provisioning.fleet.manage by omission. A fifth shape therefore arrives already
238
+ // gated, and this assertion is the prompt to confirm that is what was wanted
239
+ // rather than the thing that has to catch the hole.
173
240
  assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
174
241
  ['co-tenant', 'control-plane', 'dedicated', 'standalone']);
175
- // The assumption the tripwire actually guards is that exactly ONE shape is
176
- // metered. A fifth shape that IS billed must widen the gate itself, in
177
- // paid-shape-gate.js, and not merely this list.
242
+ });
243
+
244
+ await ta('an unknown shape is answered by the validator, not by the gate', () => {
245
+ // The gate DEFERS anything outside HOSTING_SHAPES so a typo gets `bad_hosting_shape`
246
+ // naming the valid set, rather than a 403 about a permission the caller would not
247
+ // have needed. Deferring is safe: the validator refuses it microseconds later, and
248
+ // createInstance coerces an unrecognised shape to co-tenant rather than storing it,
249
+ // so no deferred value can become a privileged row.
250
+ return post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenamt', no_address: true })
251
+ .then((res) => {
252
+ assert.equal(res.status, 400, `expected 400, got ${res.status} ${res.raw}`);
253
+ assert.equal(res.body.error.code, 'bad_hosting_shape');
254
+ });
178
255
  });
179
256
 
180
257
  server.close();