@bongos/core 1.19.753 → 1.19.755

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.753",
6
- "core_contract": "1.19.753",
7
- "source_commit": "80dc32c8218722e226e51da2ebb020cec6d9d0c2",
5
+ "core_version": "1.19.755",
6
+ "core_contract": "1.19.755",
7
+ "source_commit": "1cc8ad5ec0be2921b0e5cf12464689c86b5c36a6",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-15T23:33:23.711Z",
9
+ "built_at": "2026-09-16T02:11:51.276Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 482,
12
+ "docs_redacted": 483,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2110,
14
+ "functional_verbatim": 2112,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2617,
20
- "tree_sha256": "878000807a14e178ce864325e1330e8c14b05bd4799fab37d3cecde128cde500",
19
+ "file_count": 2620,
20
+ "tree_sha256": "4960c3809b0d1bd4756c52793ef2167cfc1b70844cd2250f3e35723cd275bc50",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1939,10 +1939,15 @@
1939
1939
  "mode": "0000644",
1940
1940
  "sha256": "df40904ac989d54ea6bea561649a35a98ec7dc1ba96ea65395cb314ffe2059a9"
1941
1941
  },
1942
+ {
1943
+ "path": "docs/adr/0287-a-backlog-task-that-nothing-gates-promotes-itself.md",
1944
+ "mode": "0000644",
1945
+ "sha256": "3990568d9257c263d629527bd4bfb143cbf211c5a61544bf8db527c9e3c11249"
1946
+ },
1942
1947
  {
1943
1948
  "path": "docs/adr/README.md",
1944
1949
  "mode": "0000644",
1945
- "sha256": "e7f895a2f6dc062acd2afcf2c477ab8e60f1cbc93a836d8759a47d204a55a450"
1950
+ "sha256": "6e19a62c805b92eda9a318fe7d67cac22927a80d4ad6bdd681256f0cd06281cd"
1946
1951
  },
1947
1952
  {
1948
1953
  "path": "docs/api-reference.md",
@@ -2507,7 +2512,7 @@
2507
2512
  {
2508
2513
  "path": "docs/module-api-changelog.md",
2509
2514
  "mode": "0000644",
2510
- "sha256": "ccb51e74c6ede2d1dc04f3877491a2b2a8c266494c9624b710fd3af463ac49b8"
2515
+ "sha256": "744adc56bff99972f3104823c66db347b9aaef9aee42d06f12bff8da8e559a7d"
2511
2516
  },
2512
2517
  {
2513
2518
  "path": "docs/modules-contract.md",
@@ -5467,7 +5472,7 @@
5467
5472
  {
5468
5473
  "path": "modules/lifecycle/db-tasks.js",
5469
5474
  "mode": "0000644",
5470
- "sha256": "df48a62844d96e93bfa1e3c20031b4c4fbb1efda3e943b1374c3015cc1df9951"
5475
+ "sha256": "2ec7c2bec4ef96b5ded094c69104fc30e53b7659cb2b2444ed4491df3f3836dc"
5471
5476
  },
5472
5477
  {
5473
5478
  "path": "modules/lifecycle/db-versions.js",
@@ -5627,7 +5632,7 @@
5627
5632
  {
5628
5633
  "path": "modules/lifecycle/publish-reconciler.js",
5629
5634
  "mode": "0000644",
5630
- "sha256": "9f3e3ae52f9fa49a1f5638d92a4b985d0c23fe0433d917d017aa78876c41ca43"
5635
+ "sha256": "dce7913d2635c40ce9557e7cb554aa152dbbe4c40e88676faa3f5e5b34bc26ae"
5631
5636
  },
5632
5637
  {
5633
5638
  "path": "modules/lifecycle/rot.js",
@@ -7562,12 +7567,12 @@
7562
7567
  {
7563
7568
  "path": "package-lock.json",
7564
7569
  "mode": "0000644",
7565
- "sha256": "cf5907822b5a511411433a626f4f1651d58505115c4a5ba39a391195e120e2ad"
7570
+ "sha256": "d2721993cfbc4a656aab1c93434a55dd7a440a3bb63048893ed6b43c2b1bbeec"
7566
7571
  },
7567
7572
  {
7568
7573
  "path": "package.json",
7569
7574
  "mode": "0000644",
7570
- "sha256": "159c9db7924dccba4d3a83a4139e958190bc1eab91e974b696846d5418ad0c20"
7575
+ "sha256": "23815bafaed5c20104d344967253d871d91b0df5524e5fba825119a316a0784a"
7571
7576
  },
7572
7577
  {
7573
7578
  "path": "public-docs/index.html",
@@ -9337,7 +9342,7 @@
9337
9342
  {
9338
9343
  "path": "src/bongos/serve-internal.js",
9339
9344
  "mode": "0000644",
9340
- "sha256": "61b13299ffdd7f036d5bc3d364135bd8ca4d3470223001b8ff6cc45d858d6107"
9345
+ "sha256": "9edf07966de8d137ba6d987557bb4a9fefeedd19e640f10302dbfd340e0343f9"
9341
9346
  },
9342
9347
  {
9343
9348
  "path": "src/bongos/uptime-poller.js",
@@ -9367,7 +9372,7 @@
9367
9372
  {
9368
9373
  "path": "src/module-api.js",
9369
9374
  "mode": "0000644",
9370
- "sha256": "a15e5c285579017bee5b7b08b63efc00124fa10e2e562dafadf6b2b456f0820c"
9375
+ "sha256": "690fb3d76f0d06fcc41490f58b4a273c42ecfd65c8e9a2acb7cff0e85885f5a4"
9371
9376
  },
9372
9377
  {
9373
9378
  "path": "src/module-loader/catalog.js",
@@ -11614,6 +11619,11 @@
11614
11619
  "mode": "0000644",
11615
11620
  "sha256": "40c396fda3a618bf9fa2d2ae3d8d30b50e62c4210199d70b5e29cb82a046d037"
11616
11621
  },
11622
+ {
11623
+ "path": "tests/module_asset_subdomain.mjs",
11624
+ "mode": "0000644",
11625
+ "sha256": "420e17095b054f87be386913077966d456ae687b030b476e1bc33b15ae35ed9f"
11626
+ },
11617
11627
  {
11618
11628
  "path": "tests/module_catalog.mjs",
11619
11629
  "mode": "0000644",
@@ -11994,6 +12004,11 @@
11994
12004
  "mode": "0000644",
11995
12005
  "sha256": "5444df365780c33d61ca9a18de08ed89ed2ff74d8af198d51edbaed19d6010c2"
11996
12006
  },
12007
+ {
12008
+ "path": "tests/promotion_sweep.mjs",
12009
+ "mode": "0000644",
12010
+ "sha256": "43246276a75ec8e7d01610b4f6c6988434144338804614b37b062c7a43a8beca"
12011
+ },
11997
12012
  {
11998
12013
  "path": "tests/prose_edits.mjs",
11999
12014
  "mode": "0000644",
@@ -0,0 +1,35 @@
1
+ # 0287 — A backlog task that nothing gates promotes itself
2
+
3
+ **Status:** accepted · 2026-09-15 · [task 1003932](https://cloudbongos.com/builders#/task/1003932) (owner decision, same session)
4
+
5
+ ## Context
6
+
7
+ Auto-promotion has been reactive-only since [migration 163](../../migrations/163_polymorphic_dependencies.sql). All three paths are `AFTER UPDATE` triggers that fire at the **dependency** end of an edge — a task ships, a criterion is satisfied, a goal is achieved — and then re-check `task_is_fully_unblocked` for that node's dependents.
8
+
9
+ Two states are unreachable by any of them, and neither is rare:
10
+
11
+ 1. **No gating edge at all.** `createTask` inserts at `status='backlog'`. A task with no edges is nobody's dependent, so no trigger ever evaluates it.
12
+ 2. **An edge added after the dependency shipped.** The ship event already happened; creating the edge later re-evaluates nothing.
13
+
14
+ Measured on the live database, 2026-09-15: **155 backlog tasks were promotable and parked** — 33 of 41 in goal 1000106 alone, the rest spread over ten other goals. 117 more were correctly waiting on unfinished work. The owner's framing was blunt: *"nothing should be owner held backlog, are things not auto promoting?"*
15
+
16
+ This is the same failure `runCriterionSweep` was built for on the criterion axis ([ADR 0183](0183-criteria-close-themselves.md)): a reactive rule cannot drain rows whose event has already passed, and nothing will ever fire for them again.
17
+
18
+ ## Decision
19
+
20
+ **A backlog task with no unmet dependency and no open blocker becomes `ready` on its own**, via a `runPromotionSweep` on the publish-reconciler tick, beside the criterion sweep it copies.
21
+
22
+ The owner chose the widest rule: the sweep covers **spikes** (which migration 163 excluded on purpose) and the cascade's **artist-review** tasks (which `cascade.js` files at `backlog` deliberately). One rule, no carve-outs. Promoting only makes a task claimable; it compels nobody to take it, and `/builder-sequence` still halts on a `judgement_kind` spike.
23
+
24
+ **It ships disarmed.** The sweep reports what it would promote until `BONGOS_PROMOTION_SWEEP_ARMED=1`. The owner asked to see the list before ~155 rows change status at once, and a mass status change with no preview is exactly the kind of thing that is easy to do and tedious to unpick.
25
+
26
+ **The open-blocker guard lives in the sweep's own SQL**, not in `task_is_fully_unblocked`. [Migration 176](../../migrations/176_promotion_respects_open_blockers.sql) folded that clause into the predicate for V4.R15; [migration 178](../../migrations/178_reward_gate_auto_assign_revert_autopromote.sql) then replaced the body with one it described as "byte-identical to the 163 body" and dropped it. Production was verified during this task: the live predicate has no blocker clause, and one task already sits at `ready` carrying an open blocker. Restoring it is [task 1003934](https://cloudbongos.com/builders#/task/1003934). A sweep that promotes in bulk must not inherit a single point of failure that has already been reverted once, so it re-checks, and keeps re-checking even after 1003934 lands.
27
+
28
+ ## Consequences
29
+
30
+ - **`backlog` stops being a resting state.** It becomes the brief gap between filing and being claimable. `/backlog-review` — "the state a human must say go on" — loses most of its purpose; it is not deleted here, but the next session to touch it should expect little left to walk.
31
+ - **The claimable feed grows by roughly 155 rows once armed**, across eleven goals. Nothing is force-assigned; the queue simply stops lying about what is available.
32
+ - **The `UPDATE` repeats the `WHERE`** rather than trusting the ids the `SELECT` returned, so a row that stops qualifying between the two statements is not promoted.
33
+ - **Idempotent**: it reads only `status='backlog'` rows, and an empty candidate set issues no write at all.
34
+ - Rejected — **promoting at creation time** (`createTask` inserting `ready` when a task has no edges): it would break `cascade.js`'s tested invariant that a cascade-filed review lands at `backlog`, and it fixes nothing already parked. A sweep handles both the backlog drain and the self-heal, which is why the criterion axis chose one too.
35
+ - Rejected — **scoping the first run to one goal**: it proves the mechanism on 37 rows while leaving the same defect live in ten other goals, and needs a second task to finish.
@@ -378,3 +378,4 @@ This keeps the decision history honest and traceable.
378
378
  | 0284 | [**The control plane carries no metrics stack** ([task 1003876](https://cloudbongos.com/builders#/task/1003876) · goal 1000106). Supersedes ADR 0127. Grafana (187 MB, the box's largest process) + Prometheus (110 MB, 2.5 GB data) were removed from the `s-1vcpu-2gb` control plane every provisioned project shares; they scraped none of those projects and nothing in the core read them. Owner chose remove over move-to-own-droplet. The `/metrics` exporter stays in every instance, so a scraper can return from a box that is not the control plane. Leftovers (DNS record, co-hosting-box socat forwarders, instance `infra/` copies) filed as a follow-up.](0284-the-control-plane-carries-no-metrics-stack.md) | infra / Cloud Bongos |
379
379
  | 0285 | [**A shared box holds about twelve projects per GB, and memory is the wall** ([task 1003877](https://cloudbongos.com/builders#/task/1003877) · goal 1000106, a spike measured read-only on the live control plane at the owner's call). A project costs ~80 MB warm (70 MB Node + one busy Postgres backend); idle projects look like ~25 MB only because swap holds their cold pages, and swap is not capacity. Fixed base ~750 MB on a control plane. Ceiling per tier `floor((usable − fixed) / 80)`: 15 on today's `s-1vcpu-2gb` (11 live), 39 on `s-2vcpu-4gb`; resize at 12. RAM binds first, Postgres connections (pool default 10 × N vs 97) second; CPU, ports, units, disk and the backup window do not bind. Keep the three shapes, add placement ("standalone until the box is full, then the next box"); a shared slot costs box price ÷ ceiling ($0.62–0.80/mo), `dedicated` 8–40× that.](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) | infra / Cloud Bongos |
380
380
  | 0286 | [**A gated project's manifest tells a stranger how to sign in, and nothing else** ([task 1003652](https://cloudbongos.com/builders#/task/1003652) · goal 1000106, from idea 1000845; closes ADR 0192's deferred item). `GET /instance` stays exempt from the member door, but on a gated project a caller with no live session gets `{ schema, name, brand: { identity.productName, project }, auth (incl. idp), admission }` — no modules, ranks, repo coordinates, dev-box flag or branding strings. Members get the full manifest; a failed lookup answers as a stranger; gated responses are `private, no-store` + `Vary: Cookie, Authorization`. Schema id stays `instance-manifest/v1` (a subset; `bongos login` refuses any other). Owner's call: sign-in basics only.](0286-a-gated-project-s-manifest-tells-a-stranger-how-to-sign-in-and-nothing-else.md) | security / Cloud Bongos |
381
+ | 0287 | [**A backlog task that nothing gates promotes itself** ([task 1003932](https://cloudbongos.com/builders#/task/1003932), owner decision 2026-09-15). Every promotion path in migration 163 is an `AFTER UPDATE` trigger at the DEPENDENCY end of an edge, so two states are unreachable by all of them: a task with no gating edge is nobody's dependent and is never evaluated, and an edge added AFTER its dependency shipped missed the only event that would have fired. Measured live: **155 backlog tasks promotable and parked**, 33 of 41 in one goal. Same failure `runCriterionSweep` exists for on the criterion axis (ADR 0183) — a reactive rule cannot drain rows whose event has passed. **Decision: `runPromotionSweep` joins the publish-reconciler tick** and promotes any backlog task with no unmet dependency and no open blocker. The owner chose the widest rule — spikes (excluded by 163 on purpose) and cascade-filed artist reviews included, no carve-outs; promoting only makes a task claimable, it compels nobody. **It ships DISARMED**, reporting what it would promote until `BONGOS_PROMOTION_SWEEP_ARMED=1`, because ~155 rows changing status at once deserves a preview. The **open-blocker guard is re-checked in the sweep's own SQL**: migration 176 folded that clause into `task_is_fully_unblocked` and migration 178 dropped it while claiming to restore "the 163 body" — verified against production, where one task already sits at `ready` with an open blocker (its fix is task 1003934). A bulk promoter must not inherit a single point of failure that has been reverted once. Consequence the owner accepted: **`backlog` stops being a resting state** and `/backlog-review` loses most of its purpose. Rejected: promoting at creation (breaks `cascade.js`'s tested backlog invariant and fixes nothing already parked); scoping the first run to one goal (leaves the defect live in ten others).](0287-a-backlog-task-that-nothing-gates-promotes-itself.md) | lifecycle / work tracking |
@@ -1980,5 +1980,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1980
1980
  landed since 1.19.751 with no explicit bump. run 35034524356. (task 1002620)
1981
1981
  1.19.753 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1982
1982
  landed since 1.19.752 with no explicit bump. run 35036151976. (task 1002620)
1983
+ 1.19.754 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1984
+ landed since 1.19.753 with no explicit bump. run 35039836268. (task 1002620)
1985
+ 1.19.755 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1986
+ landed since 1.19.754 with no explicit bump. run 35047010813. (task 1002620)
1983
1987
  ---------------------------------------------------------------------------
1984
1988
  ```
@@ -1037,6 +1037,66 @@ async function updateTaskStatus(id, status) {
1037
1037
  await pool.query(`UPDATE tasks SET status = $2 WHERE id = $1`, [id, status]);
1038
1038
  }
1039
1039
 
1040
+ // The UNSCOPED half of auto-promotion (task 1003932) — the backlog drain that
1041
+ // `runCriterionSweep` already is for criteria.
1042
+ //
1043
+ // Every promotion path migration 163 defines is an AFTER UPDATE trigger at the
1044
+ // DEPENDENCY end of an edge: a task ships, a criterion is satisfied, a goal is
1045
+ // achieved. Two states are therefore unreachable by any trigger, and neither is
1046
+ // rare: a task with NO gating edge is nobody's dependent, so nothing ever
1047
+ // evaluates it; and an edge added AFTER its dependency shipped missed the only
1048
+ // event that would have. Measured on the live DB 2026-09-15: 155 backlog tasks
1049
+ // project-wide were promotable and parked, 33 of 41 in one goal alone.
1050
+ //
1051
+ // TWO GUARDS, BOTH IN SQL, and the second is not redundant. `task_blockers` is
1052
+ // re-checked here rather than trusted to `task_is_fully_unblocked`, because
1053
+ // migration 176 folded that clause into the predicate and migration 178 replaced
1054
+ // the body with one it called "byte-identical to the 163 body" — dropping it.
1055
+ // Verified against production: the live predicate has no blocker clause (its own
1056
+ // fix is task 1003934). A sweep that promotes in bulk must not inherit a single
1057
+ // point of failure that has already been reverted once.
1058
+ //
1059
+ // The UPDATE repeats the WHERE rather than trusting the ids it just read, so a
1060
+ // row that stops qualifying between the two statements is not promoted anyway.
1061
+ const PROMOTABLE_BACKLOG_WHERE = `
1062
+ WHERE t.status = 'backlog'
1063
+ AND task_is_fully_unblocked(t.id)
1064
+ AND NOT EXISTS (
1065
+ SELECT 1
1066
+ FROM task_blockers tb
1067
+ JOIN blockers b ON b.id = tb.blocker_id
1068
+ WHERE tb.task_id = t.id
1069
+ AND b.status = 'open')`;
1070
+
1071
+ // Runaway guard, not an expected limit (the runSweep precedent).
1072
+ const PROMOTION_SWEEP_LIMIT = 500;
1073
+
1074
+ async function promoteUnblockedBacklogTasks(exec, { armed = false, limit = PROMOTION_SWEEP_LIMIT } = {}) {
1075
+ const client = exec || pool;
1076
+ const { rows: candidates } = await client.query(
1077
+ `SELECT t.id, t.title, t.goal_id
1078
+ FROM tasks t
1079
+ ${PROMOTABLE_BACKLOG_WHERE}
1080
+ ORDER BY t.id
1081
+ LIMIT $1`,
1082
+ [limit]
1083
+ );
1084
+ // Disarmed is the DEFAULT: this sweep can move hundreds of rows at once, so it
1085
+ // reports first and promotes only once explicitly armed.
1086
+ if (!armed || candidates.length === 0) return { armed, candidates, promoted: [] };
1087
+ const { rows: promoted } = await client.query(
1088
+ `UPDATE tasks t
1089
+ SET status = 'ready',
1090
+ promoted_at = COALESCE(t.promoted_at, now()),
1091
+ updated_at = now()
1092
+ ${PROMOTABLE_BACKLOG_WHERE}
1093
+ AND t.id = ANY($1::bigint[])
1094
+ RETURNING t.id, t.title, t.goal_id`,
1095
+ [candidates.map((r) => r.id)]
1096
+ );
1097
+ return { armed, candidates, promoted };
1098
+ }
1099
+
1040
1100
  // The cascade-filed ARTIST REVIEW rows that are still open (ADR 0241, task
1041
1101
  // 1003575) — the state the deploy gate reads. Narrow projection on purpose: the
1042
1102
  // caller needs an id and a filing date to name what holds a deploy and to apply
@@ -1111,6 +1171,7 @@ module.exports = {
1111
1171
  listClaimableTasks,
1112
1172
  listOpenArtistReviews,
1113
1173
  listTasks,
1174
+ promoteUnblockedBacklogTasks,
1114
1175
  rankCrossDisciplineRecommendations,
1115
1176
  rewardGateAssignment,
1116
1177
  rewardGateError,
@@ -886,6 +886,42 @@ async function runCriterionSweep(deps = {}) {
886
886
  }
887
887
  }
888
888
 
889
+ // runPromotionSweep — the backlog drain (task 1003932). Same shape and same
890
+ // reason as runCriterionSweep above: the three migration-163 promotion triggers
891
+ // only fire at the DEPENDENCY end of an edge, so a backlog task with no edges is
892
+ // never evaluated by anything and a late-added edge missed its only event.
893
+ //
894
+ // DISARMED BY DEFAULT. It can move hundreds of rows in one tick (155 were
895
+ // promotable on 2026-09-15), so it reports what it WOULD promote until
896
+ // BONGOS_PROMOTION_SWEEP_ARMED=1 says otherwise. Pure DB like runCostSweep — no
897
+ // push credential — so it runs on hosts where getPublishStatus 503s.
898
+ async function runPromotionSweep(deps = {}) {
899
+ const { logFn, errFn } = loggers(deps);
900
+ // Injected deps.tasksDb is the test seam; the sibling module is production.
901
+ const tasksDb = (deps.tasksDb && typeof deps.tasksDb.promoteUnblockedBacklogTasks === 'function')
902
+ ? deps.tasksDb
903
+ : require('./db-tasks.js');
904
+ const exec = deps.pool || seams.pool;
905
+ const armed = typeof deps.promotionArmed === 'boolean'
906
+ ? deps.promotionArmed
907
+ : process.env.BONGOS_PROMOTION_SWEEP_ARMED === '1';
908
+ try {
909
+ const out = await tasksDb.promoteUnblockedBacklogTasks(exec, { armed });
910
+ for (const t of out.promoted) {
911
+ logFn(`[publish-reconciler] task ${t.id} backlog→ready — every dependency is done and no blocker is open`);
912
+ }
913
+ if (!out.armed && out.candidates.length) {
914
+ const ids = out.candidates.map((t) => t.id).join(', ');
915
+ logFn(`[publish-reconciler] promotion sweep (report-only): ${out.candidates.length} task(s) would promote — ${ids}`);
916
+ logFn('[publish-reconciler] set BONGOS_PROMOTION_SWEEP_ARMED=1 to let it promote them');
917
+ }
918
+ return out;
919
+ } catch (e) {
920
+ errFn(`[publish-reconciler] promotion sweep (non-blocking): ${e && e.message}`);
921
+ return { skipped: 'error', error: e && e.message };
922
+ }
923
+ }
924
+
889
925
  // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
890
926
  // by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
891
927
  // default whenever a push credential is configured; the route's getPublishStatus
@@ -905,6 +941,7 @@ function start(deps = {}) {
905
941
  runSweep(deps).catch(crashHandler('sweep'));
906
942
  runCostSweep(deps).catch(crashHandler('cost sweep'));
907
943
  runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
944
+ runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
908
945
  };
909
946
 
910
947
  firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
@@ -941,6 +978,7 @@ module.exports = {
941
978
  stop,
942
979
  runSweep,
943
980
  runCostSweep,
981
+ runPromotionSweep,
944
982
  runCriterionSweep,
945
983
  couldBeStaleGenerated,
946
984
  describeTarget,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.753",
3
+ "version": "1.19.755",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.753",
9
+ "version": "1.19.755",
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.753",
3
+ "version": "1.19.755",
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",
@@ -826,6 +826,20 @@ function mountInternalSurfaces(app) {
826
826
  app.use((req, res, next) => {
827
827
  if (!isBuildersHost(req)) return next();
828
828
  if (req.path.startsWith('/api/') || req.path === '/healthz') return next();
829
+ // Module browser assets are addressed at the ROOT on every host — the URL the
830
+ // loader's hallWidgetScripts projection emits is an absolute /modules/<key>/…
831
+ // and injectModuleHallWidgets puts that same tag in the hall index on BOTH
832
+ // hosts (task 1002848). Without this line the shim rewrites it to
833
+ // /builders/modules/… before the MODULE_ASSET_RE handler below can match, so
834
+ // the tag is injected on the subdomain and the asset 404s there — injection
835
+ // works on both hosts, serving works only on the apex. The handler keeps all
836
+ // its own defences (the isModuleEnabled gate, the traversal check); this only
837
+ // lets the request reach it.
838
+ //
839
+ // The general hazard: ANY apex-rooted route registered BELOW this shim is
840
+ // eaten the same way. Today that is this one and the /docs family (idea 529
841
+ // → task 1002861); /downloads/* dodges it by being registered ABOVE instead.
842
+ if (req.path.startsWith('/modules/')) return next();
829
843
  if (req.path === '/builders' || req.path.startsWith('/builders/')) return next();
830
844
  // Prefix with /builders so the handlers below match. req.url carries the
831
845
  // query string; preserve it. '/' → '/builders/', '/watch' → '/builders/watch'.
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.753'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.755'; // 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,155 @@
1
+ // tests/module_asset_subdomain.mjs — module browser assets must route identically
2
+ // on the apex and on builders.<apex> (task 1002848).
3
+ //
4
+ // THE BUG. A module-contributed hall widget's <script src="/modules/<key>/public/
5
+ // hall-widget.js"> is injected into the hall index on BOTH hosts by
6
+ // injectModuleHallWidgets, but only SERVED on the apex. The builders-subdomain
7
+ // rewrite shim in serve-internal.js runs as app.use middleware, ahead of the
8
+ // MODULE_ASSET_RE route, and rewrites every path on that host onto the /builders/*
9
+ // tree — exempting only /api/, /healthz and paths already under /builders. So
10
+ // /modules/demo/public/hall-widget.js became /builders/modules/... , stopped
11
+ // matching ^/modules/, and fell through to a 404.
12
+ //
13
+ // WHY THIS TEST HAS TO EXIST. The seam is a live no-op today: no module ships a
14
+ // hall-widget.js, so nothing on either host currently requests one, and a
15
+ // regression here would be completely invisible until the first module that does
16
+ // ship one — at which point it would look like a bug in that module. The test
17
+ // therefore drives a module asset that DOES exist (hall-ui's own public/ dir) and
18
+ // asserts the two hosts agree, which is the property the fix is really about.
19
+ //
20
+ // serve-internal is DB-free (the pg pool connects lazily), so we mount it on a
21
+ // throwaway express app and hit it over loopback — same pattern as
22
+ // tests/docs_openapi_route.mjs.
23
+ //
24
+ // USE node:http, NOT fetch. `Host` is a forbidden header name in the fetch spec,
25
+ // so `fetch(url, { headers: { host } })` SILENTLY drops the override and sends the
26
+ // real one (127.0.0.1:<port>). A host-routing test written with fetch therefore
27
+ // compares the apex against itself, passes, and keeps passing with the fix
28
+ // reverted — which is exactly what the first draft of this file did until the
29
+ // mutation probe caught it. node:http honours an explicit Host in options.headers.
30
+ //
31
+ // Run: node --test tests/module_asset_subdomain.mjs
32
+
33
+ import { test } from 'node:test';
34
+ import assert from 'node:assert/strict';
35
+ import { createRequire } from 'node:module';
36
+ import http from 'node:http';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ // Any host starting with "builders." is the subdomain; anything else is the apex.
41
+ const APEX = 'example.com';
42
+ const SUBDOMAIN = 'builders.example.com';
43
+
44
+ // A file the hall-ui module really ships, so a 200 proves the request reached the
45
+ // MODULE_ASSET_RE handler AND the handler found bytes — not merely that it matched.
46
+ const REAL_ASSET = '/modules/hall-ui/public/atlas.css';
47
+
48
+ async function withServer(fn) {
49
+ const express = require('express');
50
+ const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
51
+ const app = express();
52
+ mountInternalSurfaces(app);
53
+ const server = await new Promise((resolve) => {
54
+ const s = app.listen(0, () => resolve(s));
55
+ });
56
+ try {
57
+ await fn(server.address().port);
58
+ } finally {
59
+ await new Promise((r) => server.close(r));
60
+ }
61
+ }
62
+
63
+ // One loopback request with a REAL Host header. Never follows redirects — a 301 is
64
+ // itself a routing decision we want to see.
65
+ function get(port, path, host) {
66
+ return new Promise((resolve, reject) => {
67
+ const req = http.request({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => {
68
+ let body = '';
69
+ res.setEncoding('utf8');
70
+ res.on('data', (c) => { body += c; });
71
+ res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
72
+ });
73
+ req.on('error', reject);
74
+ req.end();
75
+ });
76
+ }
77
+
78
+ // The harness is only meaningful if the Host header actually reaches the app, and
79
+ // the fetch trap above proves that is not self-evident. Assert it directly, so a
80
+ // future change that breaks host propagation fails HERE with a clear reason rather
81
+ // than turning every test below into a silent apex-vs-apex tautology.
82
+ test('the harness really varies the Host — without this the rest is vacuous', async () => {
83
+ await withServer(async (port) => {
84
+ const { isBuildersHost } = require('../src/bongos/serve-internal.js');
85
+ assert.equal(isBuildersHost({ headers: { host: SUBDOMAIN } }), true);
86
+ assert.equal(isBuildersHost({ headers: { host: APEX } }), false);
87
+ // and end-to-end: both hosts 301 /builders/, but to DIFFERENT places — the apex
88
+ // sends you to the subdomain, the subdomain strips the redundant prefix. Equal
89
+ // targets would mean the Host never reached the app.
90
+ const sub = await get(port, '/builders/', SUBDOMAIN);
91
+ const apex = await get(port, '/builders/', APEX);
92
+ assert.notEqual(sub.headers.location, apex.headers.location,
93
+ 'the two hosts must route differently, or the Host header is being dropped');
94
+ });
95
+ });
96
+
97
+ test('a real module asset serves on the apex AND on builders.<apex>', async () => {
98
+ await withServer(async (port) => {
99
+ const apex = await get(port, REAL_ASSET, APEX);
100
+ const sub = await get(port, REAL_ASSET, SUBDOMAIN);
101
+ assert.equal(apex.status, 200, 'precondition: the asset serves on the apex');
102
+ assert.equal(sub.status, 200,
103
+ 'the subdomain must serve it too — the rewrite shim used to turn this into /builders/modules/... and 404');
104
+ });
105
+ });
106
+
107
+ test('the two hosts return the same body, not just the same status', async () => {
108
+ await withServer(async (port) => {
109
+ const apex = await get(port, REAL_ASSET, APEX);
110
+ const sub = await get(port, REAL_ASSET, SUBDOMAIN);
111
+ assert.ok(apex.body.length > 0, 'precondition: the asset is not empty');
112
+ assert.equal(sub.body, apex.body, 'the subdomain must serve the same bytes, not a rewritten page');
113
+ });
114
+ });
115
+
116
+ test('an unknown module 404s on BOTH hosts — the exemption grants no new reach', async () => {
117
+ await withServer(async (port) => {
118
+ const p = '/modules/no-such-module/public/x.css';
119
+ assert.equal((await get(port, p, APEX)).status, 404);
120
+ assert.equal((await get(port, p, SUBDOMAIN)).status, 404,
121
+ 'the shim exemption must not turn an unknown module into anything but a 404');
122
+ });
123
+ });
124
+
125
+ test('a missing file inside a REAL module 404s on both hosts', async () => {
126
+ await withServer(async (port) => {
127
+ const p = '/modules/hall-ui/public/definitely-not-here.css';
128
+ assert.equal((await get(port, p, APEX)).status, 404);
129
+ assert.equal((await get(port, p, SUBDOMAIN)).status, 404);
130
+ });
131
+ });
132
+
133
+ test('path traversal out of the module dir is refused on the subdomain too', async () => {
134
+ await withServer(async (port) => {
135
+ // The handler normalizes and rejects a rel path escaping modules/<key>/public.
136
+ // Worth pinning on the SUBDOMAIN specifically: that request now reaches the
137
+ // handler where it previously never did, so this is newly-exercised ground.
138
+ for (const p of ['/modules/hall-ui/public/../../../package.json',
139
+ '/modules/hall-ui/public/..%2f..%2f..%2fpackage.json']) {
140
+ const r = await get(port, p, SUBDOMAIN);
141
+ assert.notEqual(r.status, 200, `traversal must not serve a file outside the module dir (${p})`);
142
+ assert.ok(!/"name":\s*"@bongos/.test(r.body), 'package.json bytes must never appear in the body');
143
+ }
144
+ });
145
+ });
146
+
147
+ test('the exemption is scoped to /modules/ — a lookalike path is still rewritten', async () => {
148
+ await withServer(async (port) => {
149
+ // If the exemption had been written as a bare `includes('modules')` or without
150
+ // the trailing slash, this path would escape the shim too.
151
+ const r = await get(port, '/moduleslike', SUBDOMAIN);
152
+ assert.notEqual(r.status, 200,
153
+ 'a path merely starting with "modules" but outside /modules/ is not a module asset');
154
+ });
155
+ });
@@ -0,0 +1,210 @@
1
+ // tests/promotion_sweep.mjs
2
+ //
3
+ // The backlog promotion sweep (task 1003932) — the UNSCOPED half of
4
+ // auto-promotion, the exact shape runCriterionSweep already has for criteria.
5
+ //
6
+ // Why it exists: every promotion path in migration 163 is an AFTER UPDATE
7
+ // trigger at the DEPENDENCY end of an edge (a task ships, a criterion is
8
+ // satisfied, a goal is achieved). A task with NO gating edge is nobody's
9
+ // dependent, so no trigger ever evaluates it and it cannot leave 'backlog' —
10
+ // measured 2026-09-15: 33 of 41 backlog tasks in goal 1000106, and 155
11
+ // project-wide. A task whose edge was added AFTER its dependency shipped is
12
+ // stuck the same way: the ship event already happened.
13
+ //
14
+ // HOW THESE TESTS PROVE A GUARD, and where that proof stops. CI runs a DB-free
15
+ // unit gate (scripts/gds/run-unit-tests.js excludes anything needing Postgres,
16
+ // and no workflow provides a Postgres service), so a fixture-in-a-real-database
17
+ // test would run nowhere. Instead the fake pool below answers the way the
18
+ // database would GIVEN THE GUARDS THE STATEMENT ACTUALLY CARRIES: a blocked row
19
+ // is withheld only because the SQL contains the blocker clause. Drop the clause
20
+ // and that row starts coming back, and the "is NOT promoted" cases go red.
21
+ //
22
+ // The residual gap, stated rather than papered over: a guard NEUTERED while
23
+ // keeping its wording (`AND b.status = 'open' AND FALSE`) still reads as present
24
+ // here. Closing that needs Postgres. What stands in for it is a live read-only
25
+ // verification recorded in the task's ship notes — against production, the
26
+ // sweep's own candidate query returned 155 rows, of which 0 carried an open
27
+ // blocker and 0 were not fully unblocked, while 2 backlog rows were excluded by
28
+ // the blocker clause alone.
29
+ //
30
+ // Run: node tests/promotion_sweep.mjs
31
+
32
+ import { strict as assert } from 'node:assert';
33
+ import { createRequire } from 'node:module';
34
+ import { readFileSync } from 'node:fs';
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const dbTasks = require('../modules/lifecycle/db-tasks.js');
38
+ const reconciler = require('../modules/lifecycle/publish-reconciler.js');
39
+
40
+ let passed = 0;
41
+ let failed = 0;
42
+ async function test(name, fn) {
43
+ try {
44
+ await fn();
45
+ passed++;
46
+ console.log(` ok ${name}`);
47
+ } catch (err) {
48
+ failed++;
49
+ console.log(` FAIL ${name}`);
50
+ console.log(` ${err.message}`);
51
+ }
52
+ }
53
+
54
+ // The two shapes that are stuck today, plus one row per guard the sweep must
55
+ // respect. `why` is the reason the row must NOT be promoted.
56
+ const QUALIFIES = [
57
+ { id: 1003930, title: 'no dependency edges at all', goal_id: 1000106 },
58
+ { id: 1002348, title: 'edge added after the dependency shipped', goal_id: 1000106 },
59
+ ];
60
+ const EXCLUDED = [
61
+ { id: 2000001, title: 'a dependency has not shipped', goal_id: 1000106, why: 'unmetDep' },
62
+ { id: 2000002, title: 'carries an open blocker', goal_id: 1000106, why: 'openBlocker' },
63
+ { id: 2000003, title: 'already out of backlog', goal_id: 1000106, why: 'notBacklog' },
64
+ ];
65
+ const FIXTURE = [...QUALIFIES, ...EXCLUDED];
66
+
67
+ // Answer as the database would, given the guards this statement carries.
68
+ function admittedBy(sql, rows) {
69
+ const backlogGuard = /status\s*=\s*'backlog'/i.test(sql);
70
+ const depGuard = /task_is_fully_unblocked\s*\(/i.test(sql);
71
+ const blockerGuard = /task_blockers/i.test(sql) && /b\.status\s*=\s*'open'/i.test(sql);
72
+ return rows.filter((r) => {
73
+ if (r.why === 'notBacklog' && backlogGuard) return false;
74
+ if (r.why === 'unmetDep' && depGuard) return false;
75
+ if (r.why === 'openBlocker' && blockerGuard) return false;
76
+ return true;
77
+ });
78
+ }
79
+
80
+ function mkPool({ rows = FIXTURE } = {}) {
81
+ const calls = [];
82
+ return {
83
+ calls,
84
+ async query(sql, params) {
85
+ const text = String(sql);
86
+ calls.push({ sql: text, params });
87
+ if (/^\s*UPDATE/i.test(text)) {
88
+ // The UPDATE re-checks the guards AND narrows to the ids it was handed.
89
+ const ids = Array.isArray(params && params[0]) ? params[0] : null;
90
+ const scoped = ids ? rows.filter((r) => ids.includes(r.id)) : rows;
91
+ return { rows: admittedBy(text, scoped) };
92
+ }
93
+ return { rows: admittedBy(text, rows) };
94
+ },
95
+ };
96
+ }
97
+
98
+ const updatesIn = (pool) => pool.calls.filter((c) => /^\s*UPDATE/i.test(c.sql));
99
+ const idsOf = (rows) => rows.map((r) => r.id);
100
+
101
+ // ---------- what gets promoted, and what does not ----------
102
+
103
+ await test('report-only by default: it names candidates and writes NOTHING', async () => {
104
+ const pool = mkPool();
105
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, {});
106
+ assert.equal(out.armed, false, 'default is report-only — arming is explicit');
107
+ assert.deepEqual(idsOf(out.candidates), idsOf(QUALIFIES));
108
+ assert.deepEqual(out.promoted, [], 'nothing is promoted while disarmed');
109
+ assert.equal(updatesIn(pool).length, 0, 'a disarmed sweep must never issue an UPDATE');
110
+ });
111
+
112
+ await test('armed: it promotes exactly the qualifying rows', async () => {
113
+ const pool = mkPool();
114
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
115
+ assert.equal(out.armed, true);
116
+ assert.deepEqual(idsOf(out.promoted), idsOf(QUALIFIES));
117
+ const update = updatesIn(pool)[0];
118
+ assert.ok(update, 'an armed sweep issues the UPDATE');
119
+ assert.match(update.sql, /status\s*=\s*'ready'/i, "it moves the task to 'ready'");
120
+ assert.match(update.sql, /promoted_at\s*=\s*COALESCE\(/i,
121
+ 'and stamps promoted_at without overwriting an existing one (mirrors the 163 triggers)');
122
+ });
123
+
124
+ await test('a task whose dependency has not shipped is NOT promoted', async () => {
125
+ const pool = mkPool();
126
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
127
+ assert.ok(!idsOf(out.candidates).includes(2000001), 'never a candidate');
128
+ assert.ok(!idsOf(out.promoted).includes(2000001), 'and never promoted');
129
+ });
130
+
131
+ await test('a task carrying an open blocker is NOT promoted', async () => {
132
+ const pool = mkPool();
133
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
134
+ assert.ok(!idsOf(out.candidates).includes(2000002), 'never a candidate');
135
+ assert.ok(!idsOf(out.promoted).includes(2000002), 'and never promoted');
136
+ });
137
+
138
+ await test('a task already out of backlog is NOT touched', async () => {
139
+ const pool = mkPool();
140
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
141
+ assert.ok(!idsOf(out.promoted).includes(2000003));
142
+ });
143
+
144
+ await test('the UPDATE re-checks the guards rather than trusting the ids it read', async () => {
145
+ const pool = mkPool();
146
+ await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
147
+ const update = updatesIn(pool)[0].sql;
148
+ assert.match(update, /status\s*=\s*'backlog'/i, 'backlog-only, restated in the write');
149
+ assert.match(update, /task_is_fully_unblocked\s*\(/i, 'dependency doneness, restated');
150
+ assert.match(update, /task_blockers/i, 'open-blocker guard, restated');
151
+ assert.match(update, /id = ANY\(/i, 'and narrowed to the candidates it just read');
152
+ });
153
+
154
+ await test('the guards are in the read as well as the write', async () => {
155
+ const pool = mkPool();
156
+ await dbTasks.promoteUnblockedBacklogTasks(pool, {});
157
+ const select = pool.calls.find((c) => /^\s*SELECT/i.test(c.sql)).sql;
158
+ assert.match(select, /status\s*=\s*'backlog'/i);
159
+ assert.match(select, /task_is_fully_unblocked\s*\(/i);
160
+ // The shared predicate LOST its blocker clause when migration 178 replaced
161
+ // migration 176's body (verified against production, task 1003934), so the
162
+ // sweep carries its own rather than inheriting a single point of failure that
163
+ // has already been reverted once.
164
+ assert.match(select, /task_blockers/i);
165
+ assert.match(select, /b\.status\s*=\s*'open'/i);
166
+ });
167
+
168
+ await test('nothing to do: no candidates means no UPDATE, even armed', async () => {
169
+ const pool = mkPool({ rows: EXCLUDED });
170
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
171
+ assert.deepEqual(out.candidates, [], 'every fixture row is excluded by a guard');
172
+ assert.deepEqual(out.promoted, []);
173
+ assert.equal(updatesIn(pool).length, 0,
174
+ 'an empty candidate set must not issue a write (the idempotent second run)');
175
+ });
176
+
177
+ // ---------- the sweep + its wiring ----------
178
+
179
+ await test('runPromotionSweep reports through the injected logger and never throws', async () => {
180
+ const lines = [];
181
+ const out = await reconciler.runPromotionSweep({
182
+ pool: mkPool(),
183
+ promotionArmed: false,
184
+ log: (m) => lines.push(m),
185
+ error: (m) => lines.push(`ERR ${m}`),
186
+ });
187
+ assert.equal(out.armed, false);
188
+ assert.match(lines.join('\n'), /1003930/, 'it names the tasks it would promote');
189
+ assert.ok(!lines.some((l) => l.startsWith('ERR')), 'a clean run logs no error');
190
+ });
191
+
192
+ await test('a throwing pool is reported, not propagated (a sweep can never crash the tick)', async () => {
193
+ const errs = [];
194
+ const boom = { query: async () => { throw new Error('db exploded'); } };
195
+ const out = await reconciler.runPromotionSweep({ pool: boom, log: () => {}, error: (m) => errs.push(m) });
196
+ assert.equal(out.skipped, 'error', 'the failure is returned, not thrown');
197
+ assert.match(errs.join('\n'), /db exploded/, 'and it says what happened');
198
+ });
199
+
200
+ await test('the sweep runs on the tick, fire-and-forget like its siblings', async () => {
201
+ const src = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8')
202
+ .replace(/\r\n/g, '\n');
203
+ assert.match(src, /runPromotionSweep\(deps\)\.catch\(/, 'wired into tick() the way runCriterionSweep is');
204
+ });
205
+
206
+ // ---------- Summary ----------
207
+
208
+ console.log('');
209
+ console.log(`promotion-sweep tests: ${passed} passed, ${failed} failed`);
210
+ if (failed > 0) process.exitCode = 1;