@bongos/core 1.19.525 → 1.19.526

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.525",
6
- "core_contract": "1.19.525",
7
- "source_commit": "05d381d80b50bdf77128d21d9dbe8b99006aff91",
5
+ "core_version": "1.19.526",
6
+ "core_contract": "1.19.526",
7
+ "source_commit": "cc35e48eb6199ca46a1e5f5b6cc20fc9cfd6db6f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-04T00:50:39.702Z",
9
+ "built_at": "2026-09-04T01:15:34.422Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 432,
12
+ "docs_redacted": 433,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1969,
14
+ "functional_verbatim": 1971,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2425,
20
- "tree_sha256": "cd090a2014e8d8d8f09333158a652163b425ad11fef0c4c578802dbd63f91ddf",
19
+ "file_count": 2428,
20
+ "tree_sha256": "412b4c1a07121e150e646b60c72b020a57acab43ae48d35a0f35cde0c328f224",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1714,10 +1714,15 @@
1714
1714
  "mode": "0000644",
1715
1715
  "sha256": "30cf04604592423e7520fc3e0e7ea8bd020747bcf1b8e712ae339e1e9a5eb324"
1716
1716
  },
1717
+ {
1718
+ "path": "docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md",
1719
+ "mode": "0000644",
1720
+ "sha256": "4c6f98c2362a5b61e294f1b3b9b055d44a9e47743ef8a71e5cb0f40ddd187e98"
1721
+ },
1717
1722
  {
1718
1723
  "path": "docs/adr/README.md",
1719
1724
  "mode": "0000644",
1720
- "sha256": "880547e3bd6629f52c07a17e1d38b70c7f8f80f3bc3984735f13d02effb56b2f"
1725
+ "sha256": "b03da1a3085e208b68ab5728e97b0e06b7cfb399e74d776ecc32c29074cd2a79"
1721
1726
  },
1722
1727
  {
1723
1728
  "path": "docs/api-reference.md",
@@ -2602,7 +2607,7 @@
2602
2607
  {
2603
2608
  "path": "docs/module-api-changelog.md",
2604
2609
  "mode": "0000644",
2605
- "sha256": "5b4103ec2cd85e4ab6dd6f2906c069b02f90248d37e7bc0b5de3a4408648667f"
2610
+ "sha256": "645d63e339e5392d92422641d77d4942713e1aa5fd4d06b8d8714fdc76e18759"
2606
2611
  },
2607
2612
  {
2608
2613
  "path": "docs/modules-contract.md",
@@ -5244,6 +5249,11 @@
5244
5249
  "mode": "0000644",
5245
5250
  "sha256": "38bbb1b0f0992c655cd48f95476fbbdac11381bb929999b6da0f68fbbf802b16"
5246
5251
  },
5252
+ {
5253
+ "path": "modules/lifecycle/cascade.js",
5254
+ "mode": "0000644",
5255
+ "sha256": "dcfbe3bf4cb7fc2731991d94cfac509ccd25f56f3c14c81aada1680c907bb0fb"
5256
+ },
5247
5257
  {
5248
5258
  "path": "modules/lifecycle/claim-eligibility.js",
5249
5259
  "mode": "0000644",
@@ -5532,7 +5542,7 @@
5532
5542
  {
5533
5543
  "path": "modules/lifecycle/routes/lifecycle.js",
5534
5544
  "mode": "0000644",
5535
- "sha256": "637c442875fbe297e3aba54f4b6b16f9d06b740782e3506f65c58f34b67ea81d"
5545
+ "sha256": "006868d0c47d0b134ce0a1773c61fc92b19dd15a6aae7517477ec5cc620d7ace"
5536
5546
  },
5537
5547
  {
5538
5548
  "path": "modules/lifecycle/routes/mingle.js",
@@ -5609,6 +5619,11 @@
5609
5619
  "mode": "0000644",
5610
5620
  "sha256": "f5133f1a27404712a7cce9e9fa43ff016ba2342571fd8e109228e46486941c47"
5611
5621
  },
5622
+ {
5623
+ "path": "modules/lifecycle/tests/cascade.mjs",
5624
+ "mode": "0000644",
5625
+ "sha256": "e932d203a4306a7d8dfe16301a6481a31f34b431a6eaa44c055c3d1a2a67c4b0"
5626
+ },
5612
5627
  {
5613
5628
  "path": "modules/lifecycle/version-close.js",
5614
5629
  "mode": "0000644",
@@ -7302,12 +7317,12 @@
7302
7317
  {
7303
7318
  "path": "package-lock.json",
7304
7319
  "mode": "0000644",
7305
- "sha256": "a874a97765e31f2c3f6c022d648a27336ae780b1506d065c8f96ea6ddbc017cc"
7320
+ "sha256": "ffc21421efa292da210129a95802bcff3a22555742015ec7898127f73806d87e"
7306
7321
  },
7307
7322
  {
7308
7323
  "path": "package.json",
7309
7324
  "mode": "0000644",
7310
- "sha256": "4713013d945cec19710bc535aef5a8908151dadc624afb2253378958a0de7d8e"
7325
+ "sha256": "b5027375f7ecd4aed2e7e4764dab95828a9648a81860f5a9a459ddf55e56ddd0"
7311
7326
  },
7312
7327
  {
7313
7328
  "path": "public-docs/index.html",
@@ -8977,7 +8992,7 @@
8977
8992
  {
8978
8993
  "path": "src/module-api.js",
8979
8994
  "mode": "0000644",
8980
- "sha256": "122875c28651704555d439dbfec3e148dd9fe3b230e5adaa82b921787ecf508d"
8995
+ "sha256": "f3ff5d629ae2e24b8e9358d2da8059ce156643f666246e2f264c6cec6c8906cc"
8981
8996
  },
8982
8997
  {
8983
8998
  "path": "src/module-loader/catalog.js",
@@ -0,0 +1,107 @@
1
+ # 0242 — A cross-role cascade is a declaration table on the event that already exists
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-04
5
+ - **Tasks:** [#1003549](https://cloudbongos.com/builders#/task/1003549) (goal 1000074, the artist's loop) — the split [#1003115](https://cloudbongos.com/builders#/task/1003115) (R04) invited in its own brief: *"if that generalisation is bigger than this task, split it — a cascade mechanism is its own task and possibly its own goal."* Encoded as a real dependency edge by [#1003264](https://cloudbongos.com/builders#/task/1003264).
6
+ - **Related:** 0241 (the artist gate — why a cascade may never fail a ship), 0083 (the module system's one-way rule and the kernel seams), 0233 (a proposal is a task carrying a patch — the same `(source, source_ref)` idempotency), 0086 (goals), 0120 (pay on land)
7
+
8
+ ## Context
9
+
10
+ R04 needs "every ship with copy or visuals generates one [review task]" — automatic on ship, never
11
+ hand-filed. Its brief said plainly that the infrastructure for that **did not exist**, and named the
12
+ spark queue as the prototype to extend. Reading both surfaces closely shows the prototype is the
13
+ wrong half of the problem:
14
+
15
+ - The **spark queue** (goal 1000062 / [task 1002921](https://cloudbongos.com/builders#/task/1002921))
16
+ returns open Big Idea Sparks not yet developed.
17
+ - The **copy-desk flag queue** (R02 / [task 1003113](https://cloudbongos.com/builders#/task/1003113))
18
+ returns surfaces anyone flagged as needing touch-up.
19
+
20
+ Both are **PULL**: a filtered listing of rows another role had already created. They are how one role
21
+ *finds* work another role left. Nothing anywhere **generated** work on a state transition, which is
22
+ the **PUSH** R04 actually asks for. (`cascade-dispatch.js` is model-cost routing and unrelated —
23
+ named here only because its name has sent more than one reader down the wrong path.)
24
+
25
+ ## Decision
26
+
27
+ ### 1. No new bus — `task.shipped` already exists
28
+
29
+ The kernel seam registry's own header uses this exact scenario as its worked example: *"the lifecycle
30
+ emits `task.shipped` and the discord module may want to react."* The event is emitted post-commit by
31
+ both ship paths (the publish reconciler and the ship routes), and already has two consumers —
32
+ discord's ship broadcast and sessions' deferred reward.
33
+
34
+ So a cascade is **one more listener on the event that already says a ship happened**, registered in
35
+ `routes/lifecycle.js` beside the kernel port, for the reason that file already documents: the route
36
+ mount is the one boot hook that runs both in the live server and whenever a test rebuilds the router.
37
+ Building a second notification path would have been a parallel spine to keep in sync with this one.
38
+
39
+ ### 2. A declaration table, not a rules engine
40
+
41
+ A cascade is one frozen entry: `{ key, discipline, when(task), title(task), body(task) }`. There is
42
+ no condition language, no stored rules, no per-project configuration, no UI, and no priority
43
+ ordering. Adding a cascade means appending an entry; the runner does not change.
44
+
45
+ This is the [ADR 0237](<redacted>.md)
46
+ / [ADR 0239](<redacted>.md) shape applied to a
47
+ different problem, and for the same reason: the generalisations worth building are the ones two or
48
+ three real entries have shown they share. With one consumer in existence, a rules engine would be
49
+ inventing the requirements it then satisfied.
50
+
51
+ ### 3. `CASCADES` ships EMPTY, and R04 writes the first rule
52
+
53
+ The mechanism lands with no production cascade. Its four properties are proved against the tests'
54
+ own declarations rather than against one example — which is the stronger proof, because a mechanism
55
+ pinned to its first consumer's shape tends to fit only that shape.
56
+
57
+ This is deliberate and is asserted, so the emptiness is a decision on the record rather than
58
+ something nobody noticed. The artist review cascade is R04's content: this task built the thing R04
59
+ said was missing, and stopped there.
60
+
61
+ ### 4. Four properties, all of them load-bearing
62
+
63
+ - **It can never fail the ship it reacts to.** The event fires post-commit, both emitters already
64
+ wrap it, listener errors are isolated by the seam, and `runCascadesForShip` catches everything and
65
+ returns a report. Every failure shape — a predicate that throws, a malformed rule, a database that
66
+ is down, a junk payload — is a logged report and nothing else. This is not defensive habit: ADR
67
+ 0241 settled that an artist's review may hold a **deploy** and never a builder's **ship**, and a
68
+ generator that could fail a ship would smuggle that gate back in through the back door. Verified
69
+ against a real failure, not a stub: with no database reachable, emitting `task.shipped` logs one
70
+ error line and resolves.
71
+ - **It is idempotent, on machinery that already exists.** Generated tasks carry `source: 'cascade'`
72
+ and `source_ref: cascade-<key>-<taskId>`, so `createTask`'s `(source, source_ref)` read — the same
73
+ one a copy proposal uses (ADR 0233) — makes a reconciler re-sweep, a re-ship, or both emitters
74
+ firing a no-op. The runner checks first, so the ordinary case is a read.
75
+ - **It says where it came from.** Every generated task inherits the source task's `goal_id` and
76
+ `version_id`, carries the originating id in its ref, and opens with the source task and the rule
77
+ key. NOT `parent_task_id`: that column models a **subtask**, and cascaded work is not part of the
78
+ ship that triggered it — it is separate work, for a different role, that outlives its trigger.
79
+ - **It files at `backlog`, never `ready`.** Generated work is a request and goes through ordinary
80
+ triage, like a copy proposal or a capture. Auto-filing claimable work would let a rule nobody
81
+ re-read mint claims.
82
+
83
+ ## Consequences
84
+
85
+ - **R04 is unblocked**, and narrowed: it appends one entry to `CASCADES` and writes the review task's
86
+ content and the ADR 0241 gate. It does not build a generation mechanism.
87
+ - **A rule key is permanent.** It is half the `source_ref`, so renaming one re-files every task that
88
+ rule ever generated. Stated in the file and pinned by the duplicate-key check.
89
+ - **The mechanism is discipline-agnostic.** Nothing about it is artist-specific; an ideator or
90
+ engineer cascade is the same entry with a different `discipline`. That is the generalisation the
91
+ brief asked for, and the reason this is not simply part of R04.
92
+ - **Accepted limit:** cascades are declared in code, not configured per project. A project that
93
+ wants a cascade the platform does not declare cannot have one. That is the right trade while the
94
+ count is zero, and the point at which it stops being right is when the second and third entries
95
+ disagree about what they need.
96
+
97
+ ## Rejected
98
+
99
+ - **Extending the spark queue.** The brief's own suggestion, and the wrong half: the queue is a
100
+ read-side filter over rows that already exist. Nothing in it generates anything.
101
+ - **A new event or a poller.** `task.shipped` exists, is post-commit, and has two consumers already.
102
+ - **A rules engine, or per-project cascade configuration.** See §2 — both are generalisations ahead
103
+ of their evidence.
104
+ - **Filing generated work at `ready`.** See §4.
105
+ - **`parent_task_id` as the link.** See §4 — it would claim a subtask relationship that is not true.
106
+ - **Running the cascade inside the ship transaction.** It would make a generation failure a ship
107
+ failure, which is exactly the coupling ADR 0241 rules out.
@@ -329,5 +329,6 @@ This keeps the decision history honest and traceable.
329
329
  | 0239 | [**A recommendation is a table that carries its own reason; governance is a preview and invites are a seam** ([task 1002336](https://cloudbongos.com/builders#/task/1002336) · goal 1000046, criteria `proj-module-select` / `proj-governance-reco` / `proj-invite-builders`). R19 ([ADR 0237](<redacted>.md)) declared the per-type starter bundle and said outright it is "a declaration and not a rule engine — R17/R22 own the layer that reasons"; this is that layer, and the goal pins it **rule-based**, with the AI/embeddings pass deferred ([task 1003500](https://cloudbongos.com/builders#/task/1003500), idea 1000552). **Two inputs, and the free-text description is not one of them:** the DECLARED `type` (a column) and `team_shape` (the one closed-answer key the R15 interview collects, [task 1002334](https://cloudbongos.com/builders#/task/1002334)) — inferring from prose is the deferred layer ADR 0237 §7 already rejected once. The engine is three frozen tables (`TEAM_SHAPE_RULES` add/remove ONE opt-in module; `GOVERNANCE_RULES` name a preset; `INVITE_RULES` emit a seek) and **every row carries its own `reason` sentence**, shipped with the effect rather than reconstructed by whichever surface renders it — a recommendation an owner cannot be told the reason for is one they cannot disagree with. `bundle.adjustments` is a **DELTA**: a rule whose condition matched but whose module the bundle already carried records NOTHING, because "we switched discord on because you said community" over a module a game would have had anyway is a claim the owner can check and find false. **Governance is a named preview, never a setting** — the governance module is planned and unbuilt (goal 1000030), so a preset is a name plus one sentence of what it feels like (`owner-only` / `trusted-team` / `open-contribution`) and every result carries `applied: false`, one flag instead of the same fact hardcoded in R25, R18, the wizard and the project page. **Invites are a SEAM**: `suggested` is always `[]` because ranking builders means querying the leaderboard in the sibling `economy` module, which the one-way rule ([ADR 0083](<redacted>.md)) forbids importing — the engine emits `seeking: { kind: 'top-builders', limit }` (or `null` = suggest nobody, a real answer) and R28 ([task 1002347](https://cloudbongos.com/builders#/task/1002347)) turns it into names. **Skipping is an answer:** every table states a default and each is the NARROWEST option (widening later costs one setting; a role granted to strangers is not recovered by setting it back), an off-vocabulary `team_shape` reads as no answer exactly as `normalizeProjectDetail` drops it, and the route answers 200-with-defaults rather than 400 — refusing would make the supported path ([ADR 0182](<redacted>.md) D4) an error. `tailored: boolean` is the machine-readable half of `proj-detail-interview`'s details-vs-skip split that R18 ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) proves. Served at public `GET /provisioning/recommendations?type=&team_shape=` (same absent gate as `starter-bundles`; no database, pinned by a `pool`-throws stub), returning ADR 0237 §5's bundle shape plus `adjustments` so a recommendation and R20's preselect can never name different modules. Rejected: reading the description, filling `suggested` from `GET /public/leaderboard`, a weighted scoring function, a reason per matched condition, and a 400 on an unknown shape.](<redacted>.md) | provisioning / project creation |
330
330
  | 0240 | [**A skipped module picker stores nothing, and the bundle is resolved on read** ([task 1002339](https://cloudbongos.com/builders#/task/1002339) · goal 1000046, criterion `proj-module-select`). [ADR 0237](<redacted>.md) declared the starter bundles and closed with "where a chosen selection lands is R20's decision". It lands in a new **nullable** `modules jsonb` column on `provisioning_instances` (`provisioning_014`), read through `starter-bundles.effectiveModules(row)` and **three-valued**: `NULL` = nobody answered (the picker was skipped, or the caller does not ask) and the declared type's bundle is resolved AT READ TIME; `[]` = the owner switched them all off, a real answer; `["dev-box",…]` = what they picked. `NOT NULL DEFAULT '[]'` was rejected because it collapses the first two forever — the `PROJECT_DETAIL_VOCAB` rule ("absent stays absent, skipped stays skipped") applied to a list. **Skipping is safe because the preselect and the fallback are the same function**, `bundleForProjectType`, called twice — not two lists kept in agreement — so `bongos onboard` and any API caller inherit the bundle by omitting the field, and the canonical sequence in `onboard-plan.js` grows **no new step** (the picker refines `type`, an answer already in it). NOT `detail` (its vocabulary is closed + scalar by construction, and [ADR 0237](<redacted>.md) §2 ruled the interview must not ask this) and NOT `settings` (a setting is a policy the RUNNING instance obeys — web.env, a settings-apply intent, a restart). The picker's catalog copy (`OPTIONAL_MODULES`) is **declared beside the bundles**, because `module.json` descriptions are written for builders, `GET /modules` needs a session the signed-out wizard has not got, and `GET /public/modules` serves booleans — pinned the ADR 0237 way, so a new `default: false` module cannot ship without owner-facing copy. `GET /provisioning/starter-bundles` gains `optional` so the preselect and the catalog arrive in **one** read. The route **refuses** an unknown / always-on / hub-only key (`400 bad_modules`) rather than dropping it silently (`badProjectDetail`'s rule), and a re-request reports `modules_ignored`. The column is a DECLARATION, not yet an input to the control-plane runner. Rejected: storing the preselect on a skip (it writes down an answer nobody gave), a new `onboardPlan` step, and rendering the always-on core as sixteen disabled toggles (a disabled control reads as a choice the owner failed to make).](<redacted>.md) | provisioning / project creation |
331
331
  | 0241 | [**The artist gate holds the DEPLOY, not the ship, and it reads a state rather than a judgement** ([task 1003116](https://cloudbongos.com/builders#/task/1003116) · goal 1000074, criterion `artist-blocking-is-per-project` — R05, the DECISION task the goal placed before R04 so no code would be wired to an unsettled gate). Goal 1000074 carries two rules on a collision course: the owner's "blocking strictness is PER PROJECT, not global", and [ADR 0162](<redacted>.md), which retired review gates on `main` after a code-owner requirement stranded two graded-PASS ships in a day. **0162 is usually cited by a paraphrase — "a verdict is advisory, never self-executing" — and the paraphrase is wrong as a blanket claim:** 0162's own table of compensating controls marks three of four as *fails closed*, the subagent grader among them. What it actually retired was a gate GitHub could only express as ACCOUNTS — identity-pinned against [ADR 0016](<redacted>.md), and stranding with no escape hatch. So the surviving test is: **a gate is admissible when it is evaluated automatically from readable state, consistently for every builder, with a named escape hatch; inadmissible when a particular person must act and it stalls when they do not.** Decision: (1) the gate reads a **STATE** — "is there an unresolved artist review task?" — never an opinion, so the artist's judgement is what they write INTO the task and stays advisory in exactly 0162's sense, while what the system reads is ordinary GDS status; (2) strictness is a project setting `artist_gate ∈ off | advisory | strict` and at `strict` it holds the **DEPLOY, not the ship** (owner ruling) — the builder's work is graded, merged, credited and landed on CI alone exactly as today, so 0162's strand class cannot recur, and the gate attaches to the boundary the artist's ABI mandate is actually about (users see the deployed instance, not `main`; [ADR 0137](<redacted>.md) already splits land/release/deploy); (3) the escape hatch is the **project OWNER only** — not the builder, not any Metic+, not the artist — via the existing `POST /tasks/:id/override-request`, because a person can be both artist and builder and could pass their own work; a self-release is permitted but never quiet (required rationale, audit-logged as a self-override) so a solo instance cannot deadlock; (4) the default is **`strict`** (owner ruling, against the author's `advisory` recommendation), which is safe only because §2 holds the deploy — the worst case is a pin that waits, and this instance is included once R04 ships; a project with no artist must still be able to deploy, and the refusal must name what holds it and which control releases it; (5) tightening is **not retroactive** — re-gating history would strand the next deploy behind reviews for work nobody remembers and make the setting frightening to raise; R02's flag queue is the right tool for already-shipped copy. Rejected: blocking the ship (recreates 0162's incident exactly, with credits unpaid — [ADR 0120](<redacted>.md)); advisory at every level (configures nothing); any Metic+ as the hatch (makes `strict` indistinguishable from `advisory`); making the artist's VERDICT the gate (the one thing 0162 genuinely forbids); retroactive tightening; a global constant (ruled out by the owner's constraint by name).](<redacted>.md) | goal 1000074 / the artist's loop |
332
+ | 0242 | [**A cross-role cascade is a declaration table on the event that already exists** ([task 1003549](https://cloudbongos.com/builders#/task/1003549) · goal 1000074, criterion `artist-review-task-on-ship` — the split R04 invited in its own brief and [ADR 0241](<redacted>.md)'s dependency). R04 needs "every ship with copy or visuals GENERATES one", and its brief said the infrastructure did not exist, naming the spark queue as the prototype. Reading both surfaces shows the prototype is the wrong half: the spark queue ([task 1002921](https://cloudbongos.com/builders#/task/1002921)) and the copy-desk flag queue ([task 1003113](https://cloudbongos.com/builders#/task/1003113)) are both **PULL** — filtered listings of rows another role already created — and nothing anywhere generated work on a state transition, which is the **PUSH** R04 asks for. (`cascade-dispatch.js` is model-cost routing; named only because its name has misled more than one reader.) **Decision: no new bus.** The kernel seam registry's own header uses this exact scenario as its worked example, `task.shipped` is already emitted post-commit by both ship paths and already has two consumers (discord's broadcast, sessions' deferred reward) — so a cascade is ONE MORE LISTENER on it, registered in `routes/lifecycle.js` beside the port because that mount is the one boot hook tests also run. A cascade is a frozen entry `{ key, discipline, when(task), title(task), body(task) }` — **a declaration table, not a rules engine**: no condition language, no stored rules, no per-project config, no priority ordering; adding one appends an entry and the runner does not change (the [ADR 0237](<redacted>.md)/[0239](<redacted>.md) shape, for their reason — the generalisations worth building are the ones two or three real entries have shown they share). **`CASCADES` ships EMPTY and R04 writes the first rule**, asserted so the emptiness is a decision on the record; the mechanism is proved against the tests' own declarations rather than against its first consumer's shape. Four load-bearing properties, all pinned: (1) **it can never fail the ship it reacts to** — post-commit event, isolated listeners, and a runner that catches everything and returns a report, because ADR 0241 settled that an artist may hold a DEPLOY and never a builder's SHIP and a generator that could fail a ship would smuggle that gate back in; verified against a real failure (no database reachable → one log line, resolves); (2) **idempotent on machinery that already exists** — `source: 'cascade'` + `source_ref: cascade-<key>-<taskId>` through `createTask`'s `(source, source_ref)` read, the same one a copy proposal uses ([ADR 0233](<redacted>.md)), so a reconciler re-sweep or a double emit is a no-op; (3) **it says where it came from** — inherits `goal_id`/`version_id`, carries the origin id in the ref and the prose, but NOT `parent_task_id`, which models a SUBTASK, and cascaded work is not part of the ship that triggered it; (4) **files at `backlog`, never `ready`** — generated work is a request, and auto-filing claimable work would let a rule nobody re-read mint claims. A rule key is PERMANENT (half the source_ref; renaming re-files everything it ever generated), pinned by a duplicate-key check. The mechanism is discipline-agnostic — an ideator or engineer cascade is the same entry with a different `discipline`, which is the generalisation that made this its own task. Accepted limit: cascades are declared in code, not configured per project. Rejected: extending the spark queue (a read-side filter that generates nothing), a new event or poller, a rules engine or per-project config, filing at `ready`, `parent_task_id` as the link, and running the cascade inside the ship transaction (which would make a generation failure a ship failure — the exact coupling ADR 0241 rules out).](<redacted>.md) | goal 1000074 / the artist's loop |
332
333
 
333
334
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -1487,5 +1487,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1487
1487
  landed since 1.19.523 with no explicit bump. run 33818285201. (task 1002620)
1488
1488
  1.19.525 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1489
1489
  landed since 1.19.524 with no explicit bump. run 33823218193. (task 1002620)
1490
+ 1.19.526 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1491
+ landed since 1.19.525 with no explicit bump. run 33824700579. (task 1002620)
1490
1492
  ---------------------------------------------------------------------------
1491
1493
  ```
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/cascade.js — a ship in one role GENERATES work in another
4
+ // (task 1003549, goal 1000074's the artist's loop; the split R04 asked for).
5
+ //
6
+ // WHAT WAS MISSING, precisely — the gap is PUSH vs PULL. Two surfaces already
7
+ // let one role take work from another, and both are LISTINGS of rows somebody
8
+ // had already created: the spark queue (goal 1000062 / task 1002921) returns
9
+ // open sparks awaiting development, and the copy-desk flag queue (R02 / task
10
+ // 1003113) returns surfaces anyone flagged. A role could READ what another
11
+ // produced; nothing GENERATED work on a state transition. R04's requirement is
12
+ // the other direction — "every ship with copy or visuals generates one [review
13
+ // task]", automatic, never hand-filed — and there was no mechanism for it.
14
+ //
15
+ // WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a DECLARATION TABLE plus
16
+ // a runner, not a rules engine. A cascade is one frozen entry saying: when a
17
+ // shipped task looks like THIS, file THAT task, for THAT discipline. There is no
18
+ // condition language, no stored rules, no per-project configuration and no UI —
19
+ // every one of those is a thing to add when a second and third cascade exist and
20
+ // have shown what they have in common. Today `CASCADES` ships EMPTY: the
21
+ // mechanism, its idempotency and its non-blocking property are proved by the
22
+ // tests' own declarations, and R04 (task 1003115) writes the first production
23
+ // entry. A mechanism whose first real rule is written by the task that needs it
24
+ // is honest; one that ships a rule nobody asked for is a guess.
25
+ //
26
+ // THE FOUR PROPERTIES THAT MAKE IT SAFE, each of which the tests pin:
27
+ //
28
+ // 1. IT NEVER BLOCKS THE SHIP IT REACTS TO. The runner is driven by the
29
+ // `task.shipped` kernel event, which fires POST-COMMIT and whose emitters
30
+ // already wrap it in try/catch; on top of that `runCascadesForShip` catches
31
+ // everything and returns a report. A cascade that throws, or a database
32
+ // that is down, changes nothing about the ship — the work is landed, graded
33
+ // and paid before this code runs at all. This is not a nicety: ADR 0241
34
+ // settled that an artist's review may hold a DEPLOY and never a builder's
35
+ // ship, and a generator that could fail a ship would smuggle that gate back
36
+ // in through the back door.
37
+ //
38
+ // 2. IT IS IDEMPOTENT, using machinery that already exists. Each generated
39
+ // task carries `source: 'cascade'` and `source_ref: cascade-<key>-<id>`,
40
+ // and `createTask`'s (source, source_ref) idempotency read returns the
41
+ // original row instead of minting a second one. So a reconciler re-sweep, a
42
+ // re-ship, or both emitters firing for the same task are no-ops. The
43
+ // runner ALSO checks first, so the common case is a read rather than a
44
+ // write that gets rejected.
45
+ //
46
+ // 3. IT SAYS WHERE IT CAME FROM. Every generated task inherits the source
47
+ // task's `goal_id` and `version_id`, carries the originating task's id
48
+ // inside its `source_ref`, and opens its description with that id and the
49
+ // rule key. A task that appears on the board with no author has to be able
50
+ // to explain itself, or the next person deletes it. NOT `parent_task_id`:
51
+ // that column models a SUBTASK, and cascaded work is not a part of the ship
52
+ // that triggered it — it is separate work, for a different role, that
53
+ // outlives its trigger. The attribution belongs in the ref and the prose.
54
+ //
55
+ // 4. IT FILES AT `backlog`, NEVER `ready`. Generated work is a REQUEST, and
56
+ // it goes through the board's ordinary triage like a copy proposal (ADR
57
+ // 0233 §6) or any capture. Auto-filing claimable work would let a rule
58
+ // nobody re-read mint claims.
59
+ //
60
+ // PURE ABOVE `runCascadesForShip`: the declaration shape, the matching and the
61
+ // task spec are all synchronous functions over plain objects, so a cascade can
62
+ // be pinned without a database.
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // The declaration
66
+ // ---------------------------------------------------------------------------
67
+
68
+ // A cascade rule:
69
+ // key string, stable + unique. It is half the source_ref, so RENAMING
70
+ // a key re-files every task the rule ever generated. Treat it as
71
+ // permanent.
72
+ // discipline the role the generated work is FOR ('artist', 'ideator',
73
+ // 'engineer'). This is the whole point of the mechanism: the
74
+ // discipline of the generated task is not the discipline of the
75
+ // ship that caused it.
76
+ // when(task) a predicate over the shipped task row. Must be pure and total —
77
+ // a throw is caught and treated as "no match", because a broken
78
+ // predicate must not stop the other rules.
79
+ // title(task) / body(task) the generated task's title and description.
80
+ // kind optional task kind; defaults to 'chore'.
81
+ //
82
+ // Production rules live here. It is empty by design — see the header.
83
+ const CASCADES = Object.freeze([]);
84
+
85
+ // The (source, source_ref) pair the generated task is keyed on. `source` is a
86
+ // constant so every cascade-generated row is findable as a class; the ref pins
87
+ // it to one rule and one originating ship.
88
+ const CASCADE_SOURCE = 'cascade';
89
+
90
+ function cascadeSourceRef(ruleKey, sourceTaskId) {
91
+ return `cascade-${ruleKey}-${sourceTaskId}`;
92
+ }
93
+
94
+ // Validate a declaration at the moment it is used rather than trusting it. A
95
+ // malformed rule is a wiring bug that should be loud in a test, not a silent
96
+ // missing task in production six weeks later.
97
+ function assertRuleShape(rule) {
98
+ const problems = [];
99
+ if (!rule || typeof rule !== 'object') return ['rule is not an object'];
100
+ if (typeof rule.key !== 'string' || !rule.key) problems.push('key must be a non-empty string');
101
+ if (typeof rule.discipline !== 'string' || !rule.discipline) problems.push('discipline must be a non-empty string');
102
+ if (typeof rule.when !== 'function') problems.push('when must be a function');
103
+ if (typeof rule.title !== 'function') problems.push('title must be a function');
104
+ if (typeof rule.body !== 'function') problems.push('body must be a function');
105
+ return problems;
106
+ }
107
+
108
+ // Every rule in a table, checked, with duplicate keys reported — two rules
109
+ // sharing a key would collide on source_ref and silently suppress one another.
110
+ function validateCascades(rules = CASCADES) {
111
+ const errors = [];
112
+ const seen = new Set();
113
+ rules.forEach((rule, i) => {
114
+ for (const p of assertRuleShape(rule)) errors.push(`cascade[${i}]: ${p}`);
115
+ const key = rule && rule.key;
116
+ if (typeof key === 'string' && key) {
117
+ if (seen.has(key)) errors.push(`cascade[${i}]: duplicate key "${key}" — keys are half the source_ref and must be unique`);
118
+ seen.add(key);
119
+ }
120
+ });
121
+ return errors;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Matching + the generated task spec
126
+ // ---------------------------------------------------------------------------
127
+
128
+ // The task rows a shipped task cascades into. Pure: no database, no clock, no
129
+ // side effect — hand it a task-shaped object and a rule table and it returns
130
+ // the specs that would be filed.
131
+ //
132
+ // A rule whose `when` THROWS is treated as no-match and reported in `errors`,
133
+ // never propagated: one broken predicate must not cost the other rules their
134
+ // tasks, and it certainly must not reach the ship.
135
+ function cascadesFor(task, rules = CASCADES) {
136
+ const matched = [];
137
+ const errors = [];
138
+ if (!task || task.id == null) return { matched, errors: ['no task'] };
139
+ for (const rule of rules) {
140
+ const shape = assertRuleShape(rule);
141
+ if (shape.length) { errors.push(`cascade "${rule && rule.key}": ${shape.join('; ')}`); continue; }
142
+ let hit = false;
143
+ try {
144
+ hit = rule.when(task) === true;
145
+ } catch (e) {
146
+ errors.push(`cascade "${rule.key}": when() threw (${e && e.message}) — treated as no match`);
147
+ continue;
148
+ }
149
+ if (!hit) continue;
150
+ try {
151
+ matched.push({ rule, spec: cascadeTaskSpec(rule, task) });
152
+ } catch (e) {
153
+ errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
154
+ }
155
+ }
156
+ return { matched, errors };
157
+ }
158
+
159
+ // The createTask payload for one matched rule. The attribution preamble is
160
+ // prepended here rather than left to each rule, so no rule can forget it.
161
+ function cascadeTaskSpec(rule, task) {
162
+ const body = String(rule.body(task) || '').trim();
163
+ const preamble =
164
+ `Filed automatically when task ${task.id} shipped — the "${rule.key}" cascade `
165
+ + `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
166
+ + `and it goes through the board's ordinary triage. If the rule that made it is wrong, fix `
167
+ + `the rule rather than only this task.`;
168
+ return {
169
+ versionId: task.version_id,
170
+ goalId: task.goal_id != null ? Number(task.goal_id) : null,
171
+ title: String(rule.title(task) || '').trim(),
172
+ description: `${preamble}\n\n${body}`,
173
+ discipline: rule.discipline,
174
+ kind: rule.kind || 'chore',
175
+ status: 'backlog',
176
+ source: CASCADE_SOURCE,
177
+ sourceRef: cascadeSourceRef(rule.key, task.id),
178
+ };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // The runner — the only part that touches a database
183
+ // ---------------------------------------------------------------------------
184
+
185
+ // React to one shipped task. Returns a report, NEVER throws and never rejects:
186
+ // property 1 in the header. `deps` is injected so the whole runner is testable
187
+ // with two stubs and no database.
188
+ //
189
+ // deps.getTask(id) -> the full task row (the event payload is
190
+ // the ship RETURNING row and carries no
191
+ // goal_id / version_id / discipline)
192
+ // deps.findTaskBySourceRef({...}) -> an existing row or null
193
+ // deps.createTask(spec) -> the created row
194
+ // deps.log -> optional { info, error }
195
+ async function runCascadesForShip(shipped, deps = {}) {
196
+ const report = { created: [], skipped: [], errors: [] };
197
+ const log = deps.log || null;
198
+ try {
199
+ const id = shipped && (shipped.id ?? shipped.task_id);
200
+ if (id == null) { report.errors.push('cascade: ship payload carried no task id'); return report; }
201
+
202
+ const task = deps.getTask ? await deps.getTask(id) : null;
203
+ if (!task) { report.errors.push(`cascade: task ${id} not readable after ship`); return report; }
204
+
205
+ const { matched, errors } = cascadesFor(task, deps.rules || CASCADES);
206
+ report.errors.push(...errors);
207
+
208
+ for (const { rule, spec } of matched) {
209
+ try {
210
+ const existing = deps.findTaskBySourceRef
211
+ ? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
212
+ : null;
213
+ if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
214
+ const created = await deps.createTask(spec);
215
+ report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
216
+ log?.info?.(`[cascade] task ${id} shipped → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
217
+ } catch (e) {
218
+ // One rule failing costs that rule its task and nothing else.
219
+ report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
220
+ }
221
+ }
222
+ } catch (e) {
223
+ report.errors.push(`cascade: ${e && e.message}`);
224
+ }
225
+ if (report.errors.length) log?.error?.(`[cascade] ${report.errors.length} problem(s): ${report.errors.join(' | ')}`);
226
+ return report;
227
+ }
228
+
229
+ module.exports = {
230
+ CASCADES,
231
+ CASCADE_SOURCE,
232
+ cascadeSourceRef,
233
+ assertRuleShape,
234
+ validateCascades,
235
+ cascadesFor,
236
+ cascadeTaskSpec,
237
+ runCascadesForShip,
238
+ };
@@ -26,11 +26,36 @@
26
26
  const express = require('express');
27
27
  const api = require('../../../src/module-api');
28
28
  const lifecycle = require('../lifecycle');
29
+ const db = require('../db');
30
+ const cascade = require('../cascade');
31
+
32
+ // task 1003549 — the cross-role cascade listener. `task.shipped` already exists
33
+ // as a kernel event (emitted post-commit by the publish reconciler and the ship
34
+ // routes, consumed today by discord's broadcast and sessions' deferred reward),
35
+ // so a ship generating work in ANOTHER role needs no new bus: it is one more
36
+ // listener on the event that already says a ship happened. Registered here for
37
+ // the same reason the port is — `mountModuleRoutes` is the one boot hook that
38
+ // runs in the live server AND whenever a test rebuilds the router.
39
+ //
40
+ // The handle is kept so a re-mount replaces the listener instead of stacking a
41
+ // second one (the discord `_shipListenerOff` precedent); a doubled listener
42
+ // would be harmless anyway, since filing is idempotent on (source, source_ref),
43
+ // but "harmless because something downstream catches it" is not a reason to
44
+ // leave it. Nothing here can affect the ship: the emitters fire post-commit and
45
+ // already wrap the emit, and `runCascadesForShip` resolves rather than throws.
46
+ let _cascadeListenerOff = null;
29
47
 
30
48
  function registerSeams() {
31
49
  if (!api.hasProvider('lifecycle')) {
32
50
  api.registerProvider('lifecycle', lifecycle);
33
51
  }
52
+ if (_cascadeListenerOff) _cascadeListenerOff();
53
+ _cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
54
+ getTask: (id) => db.getTask(id),
55
+ findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
56
+ createTask: (spec) => db.createTask(spec),
57
+ log: api.logger ? api.logger('lifecycle') : null,
58
+ }));
34
59
  }
35
60
 
36
61
  module.exports = function buildLifecycleRouter() {
@@ -0,0 +1,197 @@
1
+ // modules/lifecycle/tests/cascade.mjs — the cross-role cascade (task 1003549 /
2
+ // goal 1000074, the artist's loop).
3
+ //
4
+ // The mechanism's whole value is that a ship in one role can generate work in
5
+ // another WITHOUT ever putting that generation in the ship's way. So the four
6
+ // properties the header of cascade.js promises are what these pin, and three of
7
+ // them are about what must NOT happen:
8
+ //
9
+ // 1. it never blocks the ship — every failure shape resolves to a report
10
+ // 2. it is idempotent — the same ship twice files one task
11
+ // 3. it says where it came from
12
+ // 4. it files at backlog, never ready
13
+ //
14
+ // The production table ships EMPTY (R04 writes the first rule), so the rules
15
+ // below are the test's OWN declarations — which is also the point: a mechanism
16
+ // proved by a rule its consumer has not written yet is proved against the
17
+ // contract rather than against one example.
18
+ //
19
+ // DB-free: the runner takes its three database calls as injected functions.
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import path from 'node:path';
23
+ import url from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
27
+ const require_ = createRequire(import.meta.url);
28
+ const cascade = require_(path.join(HERE, '..', 'cascade.js'));
29
+
30
+ let passed = 0;
31
+ let failed = 0;
32
+ async function test(name, fn) {
33
+ try { await fn(); passed++; console.log(` ok ${name}`); }
34
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
35
+ }
36
+
37
+ console.log('\nlifecycle: a ship in one role generates work in another');
38
+
39
+ const shipped = (over = {}) => ({
40
+ id: '9001',
41
+ version_id: 'BONGOS-V1',
42
+ goal_id: '1000074',
43
+ title: 'R09 · Reword the hall empty states',
44
+ discipline: 'engineer',
45
+ kind: 'feature',
46
+ status: 'shipped',
47
+ ...over,
48
+ });
49
+
50
+ // A rule of the shape R04 will write: an engineer's ship becomes an artist's task.
51
+ const REVIEW_RULE = {
52
+ key: 'test-artist-review',
53
+ discipline: 'artist',
54
+ when: (t) => t.discipline === 'engineer',
55
+ title: (t) => `Review the copy that shipped in task ${t.id}`,
56
+ body: (t) => `Look at what ${t.title} put in front of a person.`,
57
+ };
58
+
59
+ const stubs = (over = {}) => {
60
+ const created = [];
61
+ return {
62
+ created,
63
+ deps: {
64
+ rules: [REVIEW_RULE],
65
+ getTask: async () => shipped(),
66
+ findTaskBySourceRef: async () => null,
67
+ createTask: async (spec) => { created.push(spec); return { id: String(9100 + created.length) }; },
68
+ ...over,
69
+ },
70
+ };
71
+ };
72
+
73
+ await test('an engineer ship files a task FOR THE ARTIST — the push the queues never had', async () => {
74
+ const { created, deps } = stubs();
75
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
76
+
77
+ assert.equal(report.errors.length, 0, report.errors.join(' | '));
78
+ assert.equal(created.length, 1);
79
+ const spec = created[0];
80
+ assert.equal(spec.discipline, 'artist', 'the generated work is for a DIFFERENT role than the ship');
81
+ assert.equal(report.created[0].discipline, 'artist');
82
+ assert.match(spec.title, /9001/);
83
+ });
84
+
85
+ await test('the generated task files at backlog and inherits its goal and version', async () => {
86
+ const { created, deps } = stubs();
87
+ await cascade.runCascadesForShip({ id: '9001' }, deps);
88
+ const spec = created[0];
89
+ assert.equal(spec.status, 'backlog', 'generated work is a REQUEST — auto-filing claimable work mints claims nobody asked for');
90
+ assert.equal(spec.goalId, 1000074);
91
+ assert.equal(spec.versionId, 'BONGOS-V1');
92
+ });
93
+
94
+ await test('it says where it came from, in the ref and in the prose', async () => {
95
+ const { created, deps } = stubs();
96
+ await cascade.runCascadesForShip({ id: '9001' }, deps);
97
+ const spec = created[0];
98
+ assert.equal(spec.source, 'cascade');
99
+ assert.equal(spec.sourceRef, 'cascade-test-artist-review-9001');
100
+ assert.match(spec.description, /task 9001 shipped/);
101
+ assert.match(spec.description, /test-artist-review/);
102
+ assert.match(spec.description, /Look at what/, 'the rule\'s own body survives the preamble');
103
+ // NOT a subtask: cascaded work outlives its trigger and belongs to another role.
104
+ assert.equal(spec.parentTaskId, undefined);
105
+ });
106
+
107
+ await test('the same ship twice files ONE task — a reconciler re-sweep is a no-op', async () => {
108
+ const { created, deps } = stubs();
109
+ await cascade.runCascadesForShip({ id: '9001' }, deps);
110
+ assert.equal(created.length, 1);
111
+
112
+ // Second pass: the row now exists under the same (source, source_ref).
113
+ deps.findTaskBySourceRef = async ({ source, sourceRef }) => (
114
+ source === 'cascade' && sourceRef === 'cascade-test-artist-review-9001' ? { id: '9101' } : null
115
+ );
116
+ const again = await cascade.runCascadesForShip({ id: '9001' }, deps);
117
+ assert.equal(created.length, 1, 'no second row');
118
+ assert.equal(again.created.length, 0);
119
+ assert.deepEqual(again.skipped[0], { key: 'test-artist-review', taskId: '9101', reason: 'already filed' });
120
+ });
121
+
122
+ await test('a rule that does not match files nothing, quietly', async () => {
123
+ const { created, deps } = stubs({ getTask: async () => shipped({ discipline: 'artist' }) });
124
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
125
+ assert.equal(created.length, 0);
126
+ assert.equal(report.errors.length, 0, 'a non-match is not a problem');
127
+ });
128
+
129
+ // --- property 1: nothing here may reach the ship ----------------------------
130
+
131
+ await test('NOTHING a cascade does can fail the ship — every failure shape resolves', async () => {
132
+ const shapes = {
133
+ 'when() throws': stubs({ rules: [{ ...REVIEW_RULE, when: () => { throw new Error('boom'); } }] }),
134
+ 'title() throws': stubs({ rules: [{ ...REVIEW_RULE, title: () => { throw new Error('boom'); } }] }),
135
+ 'createTask rejects': stubs({ createTask: async () => { throw new Error('db down'); } }),
136
+ 'findTaskBySourceRef rejects': stubs({ findTaskBySourceRef: async () => { throw new Error('db down'); } }),
137
+ 'getTask rejects': stubs({ getTask: async () => { throw new Error('db down'); } }),
138
+ 'getTask returns nothing': stubs({ getTask: async () => null }),
139
+ 'a malformed rule': stubs({ rules: [{ key: 'broken' }] }),
140
+ };
141
+ for (const [name, { created, deps }] of Object.entries(shapes)) {
142
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
143
+ assert.ok(report && Array.isArray(report.errors), `${name}: returned a report`);
144
+ assert.ok(report.errors.length > 0, `${name}: and said what went wrong`);
145
+ assert.equal(created.length, 0, `${name}: filed nothing`);
146
+ }
147
+
148
+ // The payload itself being junk is the same story.
149
+ for (const junk of [null, undefined, {}, { id: null }]) {
150
+ const { deps } = stubs();
151
+ const report = await cascade.runCascadesForShip(junk, deps);
152
+ assert.ok(report.errors.length > 0, `${JSON.stringify(junk)}: reported rather than threw`);
153
+ }
154
+ });
155
+
156
+ await test('one broken rule costs only its own task', async () => {
157
+ const good = { ...REVIEW_RULE, key: 'good-one' };
158
+ const bad = { ...REVIEW_RULE, key: 'bad-one', when: () => { throw new Error('boom'); } };
159
+ const { created, deps } = stubs({ rules: [bad, good] });
160
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
161
+ assert.equal(created.length, 1, 'the healthy rule still filed');
162
+ assert.equal(created[0].sourceRef, 'cascade-good-one-9001');
163
+ assert.equal(report.errors.length, 1);
164
+ assert.match(report.errors[0], /bad-one/);
165
+ });
166
+
167
+ // --- the declaration itself --------------------------------------------------
168
+
169
+ await test('the production table is valid, and a duplicate key is refused', () => {
170
+ assert.deepEqual(cascade.validateCascades(), [], 'the shipped CASCADES table must be well-formed');
171
+ assert.ok(Array.isArray(cascade.CASCADES));
172
+ assert.equal(Object.isFrozen(cascade.CASCADES), true);
173
+
174
+ // Two rules sharing a key would collide on source_ref and silently suppress
175
+ // one another — the failure that is invisible in production.
176
+ const errs = cascade.validateCascades([REVIEW_RULE, { ...REVIEW_RULE }]);
177
+ assert.equal(errs.length, 1);
178
+ assert.match(errs[0], /duplicate key/);
179
+
180
+ // A rule missing any required field is named, field by field.
181
+ const shape = cascade.assertRuleShape({ key: 'x' });
182
+ assert.ok(shape.some((p) => /discipline/.test(p)));
183
+ assert.ok(shape.some((p) => /when/.test(p)));
184
+ assert.ok(shape.some((p) => /title/.test(p)));
185
+ assert.ok(shape.some((p) => /body/.test(p)));
186
+ });
187
+
188
+ await test('the production table ships EMPTY, deliberately — R04 writes the first rule', () => {
189
+ // If this fails because a rule was added, that is fine: replace the assertion
190
+ // with one that pins the new rule's key and discipline. It exists so the
191
+ // emptiness is a DECISION on the record (see cascade.js's header) rather than
192
+ // something nobody noticed.
193
+ assert.equal(cascade.CASCADES.length, 0);
194
+ });
195
+
196
+ console.log(`\n${passed} passed, ${failed} failed`);
197
+ process.exit(failed ? 1 : 0);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.525",
3
+ "version": "1.19.526",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.525",
9
+ "version": "1.19.526",
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.525",
3
+ "version": "1.19.526",
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
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.525'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.526'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');