@bongos/core 1.19.895 → 1.19.897

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.895",
6
- "core_contract": "1.19.895",
7
- "source_commit": "e1f8d85b302ea3a742b16cc5ec06911132ae7f78",
5
+ "core_version": "1.19.897",
6
+ "core_contract": "1.19.897",
7
+ "source_commit": "bee6c40d5b1e4600a59cace9b3068cfb1626de0d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T23:04:46.322Z",
9
+ "built_at": "2026-09-20T02:58:37.233Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 498,
12
+ "docs_redacted": 500,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2274,
14
+ "functional_verbatim": 2277,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2798,
20
- "tree_sha256": "b3d7ed781a5cfdc09c1f4cb1d361b1630e18adb02f1d9abf09c70b8867d6b496",
19
+ "file_count": 2803,
20
+ "tree_sha256": "649ca49d792d2d5a87fa5ae51bad0373270f0551dc7d8f82929bfa9112592e21",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2044,10 +2044,15 @@
2044
2044
  "mode": "0000644",
2045
2045
  "sha256": "9c8eff827f7c676cb8fb73475383812746d31c9ca35e2ec4ccb3845c48d2cd63"
2046
2046
  },
2047
+ {
2048
+ "path": "docs/adr/0311-an-idea-is-a-permanent-origin-tag.md",
2049
+ "mode": "0000644",
2050
+ "sha256": "6046995188cef5e8652787eca38ccc6a69393f0985891764d6573f3379bbe6ab"
2051
+ },
2047
2052
  {
2048
2053
  "path": "docs/adr/README.md",
2049
2054
  "mode": "0000644",
2050
- "sha256": "ca64ff12d272ebf2a1b7f6752193ad464c6b7c03118c6f9555a58d8b4a0f6e0c"
2055
+ "sha256": "16fdd3b239a9a161d70e3432c35705590d0103f798f2e5f00db53cffcbf5cb81"
2051
2056
  },
2052
2057
  {
2053
2058
  "path": "docs/api-reference.md",
@@ -2114,6 +2119,11 @@
2114
2119
  "mode": "0000644",
2115
2120
  "sha256": "67391accbb08e35e37df89e225476bae1b2a8957534a92ba63f49f3f53fcfdeb"
2116
2121
  },
2122
+ {
2123
+ "path": "docs/design/ideator-surface-direction.md",
2124
+ "mode": "0000644",
2125
+ "sha256": "0ca62887b7345136ed669d2a6c279cd59c0cea2dce9cc91a3af1680ce0a31449"
2126
+ },
2117
2127
  {
2118
2128
  "path": "docs/design/landing-page-direction-v2.md",
2119
2129
  "mode": "0000644",
@@ -2612,7 +2622,7 @@
2612
2622
  {
2613
2623
  "path": "docs/module-api-changelog.md",
2614
2624
  "mode": "0000644",
2615
- "sha256": "14add7d8f56069acae77405671c2b1b4e7dadfd03eb4cd36ab08cbcd9f6ed9a9"
2625
+ "sha256": "a14d4e32aa8cd8793cdc535db0aa0047d7381de2e95cd6dd629c15b3ba0089b5"
2616
2626
  },
2617
2627
  {
2618
2628
  "path": "docs/modules-contract.md",
@@ -3709,6 +3719,11 @@
3709
3719
  "mode": "0000644",
3710
3720
  "sha256": "ae3e405c7e59fbc13e23ae2a4ba945176f2369a150c997d9b8134e49991f273b"
3711
3721
  },
3722
+ {
3723
+ "path": "migrations/core_250_cascade_requests_do_not_auto_promote.sql",
3724
+ "mode": "0000644",
3725
+ "sha256": "599449a48b3850eb457a34630a7bd5d95d354602f1a9e9b887593183a457cd21"
3726
+ },
3712
3727
  {
3713
3728
  "path": "modules/agents/CLAUDE.md",
3714
3729
  "mode": "0000644",
@@ -5477,7 +5492,7 @@
5477
5492
  {
5478
5493
  "path": "modules/lifecycle/db-tasks.js",
5479
5494
  "mode": "0000644",
5480
- "sha256": "5d17541d837a06903be832814a74218c9fee8b9119dd870c88273743be5a5efa"
5495
+ "sha256": "ba8a41a33d3612f41509d50c65533f169b4008bbc8a01df783dbd03fe872afa4"
5481
5496
  },
5482
5497
  {
5483
5498
  "path": "modules/lifecycle/db-versions.js",
@@ -7717,12 +7732,12 @@
7717
7732
  {
7718
7733
  "path": "package-lock.json",
7719
7734
  "mode": "0000644",
7720
- "sha256": "9ae4e1d11cdbeca421ec3a967b7130085744c14df5901d501bd8129974f65c20"
7735
+ "sha256": "ea53354ce229c7ec74a1909c0d0153363ee9f18969b0916c6dc832e36d2e9e8f"
7721
7736
  },
7722
7737
  {
7723
7738
  "path": "package.json",
7724
7739
  "mode": "0000644",
7725
- "sha256": "adcd6bd6b27ed6e43e143613beb81d9d063244bf5ccdf948ec3b59a89bd15401"
7740
+ "sha256": "6cec6fa876274d23ee1da877b77841482b5b2558ddebac5adad801535deffa0d"
7726
7741
  },
7727
7742
  {
7728
7743
  "path": "public-docs/index.html",
@@ -8347,7 +8362,7 @@
8347
8362
  {
8348
8363
  "path": "scripts/gds/gds-literal-scan.js",
8349
8364
  "mode": "0000644",
8350
- "sha256": "30a05f9d8d476e83740f0630e17d1accc3e29d67cebfc74d19abfeae6608e514"
8365
+ "sha256": "c7722e0ea9f0b6f1bc6cd2f791fd79f148861bd9c52eaf910859efebc6a13e94"
8351
8366
  },
8352
8367
  {
8353
8368
  "path": "scripts/gds/gen-api-client.js",
@@ -8944,6 +8959,11 @@
8944
8959
  "mode": "0000644",
8945
8960
  "sha256": "fe6f67019515067a6147506ad5a29fcb38df06f66239b879ed9acf7d16f1b06a"
8946
8961
  },
8962
+ {
8963
+ "path": "scripts/gds/seed-bv2-ideator-surface-tasks.js",
8964
+ "mode": "0000644",
8965
+ "sha256": "1e74a2b1582922bbab8be30e62f8276f9d22e69d42210783940abcfcb618c603"
8966
+ },
8947
8967
  {
8948
8968
  "path": "scripts/gds/seed-gds-v4-tasks.js",
8949
8969
  "mode": "0000644",
@@ -9727,7 +9747,7 @@
9727
9747
  {
9728
9748
  "path": "src/module-api.js",
9729
9749
  "mode": "0000644",
9730
- "sha256": "ae29e03c67edd61e903c64c4583c3febdfdea3ac75422e7b3075125f0b79f4e4"
9750
+ "sha256": "dc291210b4d1b471b7e946fc451381204d9eece987e0d0df49f364e41a240594"
9731
9751
  },
9732
9752
  {
9733
9753
  "path": "src/module-loader/catalog.js",
@@ -11027,7 +11047,7 @@
11027
11047
  {
11028
11048
  "path": "tests/fitness_gds_ratchet.mjs",
11029
11049
  "mode": "0000644",
11030
- "sha256": "10b3d6db55dd8d20de69fe8757575cf965ca8a14e91512c2f7b71b62ba3af855"
11050
+ "sha256": "230e532b54a69a3ee2c009b3a018a6cb920c92fb402364427cbdc244434d4c1b"
11031
11051
  },
11032
11052
  {
11033
11053
  "path": "tests/fitness_ratchets.mjs",
@@ -12672,7 +12692,12 @@
12672
12692
  {
12673
12693
  "path": "tests/promotion_sweep.mjs",
12674
12694
  "mode": "0000644",
12675
- "sha256": "43246276a75ec8e7d01610b4f6c6988434144338804614b37b062c7a43a8beca"
12695
+ "sha256": "c0c88682ef2b43ae582cfe41ac39e0213b50aaf0cae2e9cbd314007a7aa64b89"
12696
+ },
12697
+ {
12698
+ "path": "tests/promotion_triggers_skip_cascade.mjs",
12699
+ "mode": "0000644",
12700
+ "sha256": "ce1de49cfe554f3e5fa4ae9a784a8ba41d537571c9ae6307f72c2710e693f6aa"
12676
12701
  },
12677
12702
  {
12678
12703
  "path": "tests/prose_edits.mjs",
@@ -13722,7 +13747,7 @@
13722
13747
  {
13723
13748
  "path": "tests/task_unblocked_open_blocker.mjs",
13724
13749
  "mode": "0000644",
13725
- "sha256": "57a776dc56011bfe792a7a30ab29598bfc1b5b52f26ea0ef74faae5fbba26f6d"
13750
+ "sha256": "503692cc227e43292c133abba0fe17307fedd4b32be56e457a690b7be88c1586"
13726
13751
  },
13727
13752
  {
13728
13753
  "path": "tests/task_visual_route_gate.mjs",
@@ -0,0 +1,52 @@
1
+ # ADR 0311 — An idea is a permanent origin tag, and the payout follows its descent
2
+
3
+ **Status:** accepted · **Date:** 2026-09-19 · **Task:** [task 1004076](https://cloudbongos.com/builders#/task/1004076) (goal 1000095 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0262](0262-a-bug-never-lands-in-the-inbox.md) + the landing matrix it governs (`modules/ideas/routing.js`) · [ADR 0175](0175-the-board-room-full-idea-ratification.md) (the Board Room, unchanged by this) · [ADR 0054](0054-session-token-reward-cost-plus.md) (the credit the payout rides on) · [ADR 0015](0015-task-dependencies-and-auto-promotion.md) (why spikes do not auto-promote) · [ADR 0086](0086-goal-scoped-work-hierarchy.md) (goals, which a Full Idea may now become)
6
+
7
+ ## Context
8
+
9
+ An idea, today, is a **container for work**. It sits in `idea_inbox` until it names a goal, and then it becomes a task and leaves the ideas world entirely. The better-aimed the thought, the faster it disappears from the person who had it — and the ideas page, which shows only what is left, is named after the leftovers.
10
+
11
+ This was reported as a small confusion and turned out to be structural. On 2026-09-19 the owner filed a thought from the hall, it routed into a goal as a task, the confirmation line was cleared by the next keystroke, and five minutes later they could not find it. The signal existed; its half-life was about four seconds. The routing decision that caused it was made by a `<select>` nobody notices.
12
+
13
+ Three facts found while mapping the live mechanism shaped everything below:
14
+
15
+ - **`karma_log` carries no task key and no idea key**, deliberately (`migrations/040_builder_karma.sql`). "The karma a thought caused" is not a quantity this system can compute.
16
+ - **Task parentage is capped at one level and refuses cross-version parents** (`modules/lifecycle/hierarchy.js`), by design — ship rollup and claim safety lean on it.
17
+ - **Nothing anywhere records that shipped work was later removed.** `shipped` is terminal, there is no reverted state, and `tasks.merged_into_task_id` is CHECK-constrained to abandoned rows, so it can only describe work cut before it landed.
18
+
19
+ ## The decision
20
+
21
+ **An idea stops being a container and becomes a permanent origin tag** — never edited, never editing, and the only thing that can answer where a piece of work came from. It may become a task, a criterion or a goal depending on scope; the tag itself never changes.
22
+
23
+ **The tag survives every fork, and the payout follows it.** The ideator's credit split (`modules/economy/credits.js` `awardIdeaTaskShipCredit`) resolves only the earliest lineage link today, so a task split off later pays nobody. It is extended to follow the whole descent, **decaying with depth** — full share at the direct task, halving each hop, so an infinite descent is worth at most twice the direct task. This is what gives the tag teeth: if it does not survive a fork, *someone does not get paid*. Descendant **work** pays the origin; a separate idea that merely cites the origin does not, or early filers farm the project.
24
+
25
+ **Size and brightness are two independent channels.** Size is the sum of those payouts and **never falls**; brightness is how much of that work is **still alive**. A thought that caused a great deal of work the project later tore out renders **large and dead**. A single number would have made that indistinguishable from a thought that never mattered, which is the failure the split exists to prevent.
26
+
27
+ **Karma is rejected as the basis for size** — see Context. Credits are already idea-attributed at ship; karma is not, and is left as it is.
28
+
29
+ **The task hierarchy is not widened.** Provenance is a different relation from work decomposition and belongs with `ideas_task_lineage`, which is already many-ideas-to-one-task and carries none of the hierarchy's invariants.
30
+
31
+ **Removal is proposed by the system and confirmed by a person.** `modules/lifecycle/cascade.js` already implements ship-generates-work and declares an empty rule set; the first rule files a **question** when a ship deletes an earlier shipped task's work. A derived-only signal dims the sky for refactors, renames and file moves; a declared-only one rots because nobody remembers. An unanswered proposal rots on the existing clock; confirming sits at the nod's floor on the nod's existing surface.
32
+
33
+ **A ratified Full Idea becomes the structure, not another point inside it** — a goal, or a criterion inside one. The Full Idea carries a **recommendation** and the board decides which, because scope is the thing you learn last. The five questions are replaced with the owner's set and **versioned**, so ideas already filed keep scoring against the questions they were actually asked. The Board Room flow is unchanged.
34
+
35
+ **Two settings belong to the project, not the platform**, and share one home: who may ignite a thought directly into claimable work, and the decay rate.
36
+
37
+ **Existing ideas get their lineage backfilled, not their payouts.** Size for a pre-existing idea is computed from the descent it has, so the picture is meaningful on day one without spending credits twice — and because that number is then not a sum of payouts actually made, it must be **marked as historical** rather than quietly presented as one.
38
+
39
+ ## Consequences
40
+
41
+ - **The hard part is now load-bearing rather than optional.** Before this, a lineage tag that died at the first fork produced a worse picture. After it, it produces a wrong ledger. That is deliberate: it puts the effort where the value is.
42
+ - **Ideator payouts become open-ended in principle**, bounded in practice by the decay. A project that dislikes the shape turns the dial rather than arguing with the platform.
43
+ - **The visible record will be dominated by large dead rings in a project that churns.** Accepted: failure is fine, and a record that only ever grows brighter is not a record.
44
+ - **Three tasks were dropped** as this direction invalidated them — a text-only ancestry trail ([task 1004054](https://cloudbongos.com/builders#/task/1004054)), a `held` inbox status ([task 1002832](https://cloudbongos.com/builders#/task/1002832)), and backfilled Full Idea answers on old goals ([task 1003678](https://cloudbongos.com/builders#/task/1003678)) — and one was amended ([task 1003852](https://cloudbongos.com/builders#/task/1003852)), whose title asserted the exact exclusivity this reverses.
45
+ - **Working area 6 now carries ten criteria.** A consequence of the owner's instruction to add here rather than open a new goal.
46
+
47
+ ## Does not decide
48
+
49
+ - The **exact decay rate** beyond its default of halving — it is a per-instance dial, and what a given project should set it to is that project's call.
50
+ - **What precisely counts as removed** — the shape is propose-then-confirm; the derivation is [task 1004081](https://cloudbongos.com/builders#/task/1004081)'s spike to settle.
51
+ - **How provenance edges are physically recorded** — [task 1004080](https://cloudbongos.com/builders#/task/1004080)'s spike, constrained only by "not by widening the task hierarchy".
52
+ - **How any of this is drawn.** The agreed visual direction is `docs/design/ideator-surface-direction.md` and the mockups beside it; the criteria deliberately name the *model* — the states, the channels, the rules — and leave the picture free to change.
@@ -402,3 +402,4 @@ This keeps the decision history honest and traceable.
402
402
  | 0308 | [**An eleventh working area: Autobongos** ([task 1003900](https://cloudbongos.com/builders#/task/1003900), goal 1000119). Amends [ADR 0264](0264-the-ten-working-areas.md), which nine days earlier collapsed 38 goals into **ten** areas held until 5,000 builders. Area 11 is Autobongos ([goal 1000119](https://cloudbongos.com/builders#/goal/1000119), owner Lars). **Area 6 was the obvious home and was rejected**: area 6 is about a human's experience of the platform, and this area has no human in it — which limits to spend, which fences hold unsupervised, how it recovers with nobody at the keyboard. Those get less attention next to work with a user in front of it, not better answers. It **reopens** goal 1000028 (*Autonomy groundwork*), archived in the 38→10 cut with no successor and carried as `succeeds_goal_id`, so the lineage is in the ledger rather than the prose — and ADR 0264 §3 had already left "fold, keep as an eleventh, or close" as a live owner question. Admitting it cost something recorded: BONGOS-V2 is `building`, so its goal set is closed (ADR 0250) and `POST /goals` refuses `version_not_planning` — an Archon reopened it with a stored `admit_reason`, which is the cut holding well enough that adding to it left evidence. Does not decide: the ten-area discipline stands (one exception, written reason, 5,000-builder horizon unchanged), 0264 §3's other two open rows, or a second goal for the area.](0308-an-eleventh-working-area.md) | methodology / goals |
403
403
  | 0309 | [**Module enablement is a database row, above the committed pack and below env** ([task 1002857](https://cloudbongos.com/builders#/task/1002857), goal 1000091). The hall Modules-tab toggle ([ADR 0107](0107-module-upstreaming-contribution-to-core-catalog.md) §9) wrote `config/modules.json` and reloaded the cache, so it looked like it worked — but that file is the committed instance pack and the pull-deploy runs `git reset --hard origin/main`, so the toggle reverted on the next deploy, **silently**. Enablement moves to `module_enablement_overrides` (migration `core_248`), read as one precedence layer: neutral pack → instance pack → **DB override** → env → default. **Above the pack** because the pack is what a deploy restores, so an override below it would be erased by the event it exists to survive; **below env** because `<PREFIX>_MODULE_<KEY>` is the incident knob and must not need a reachable database. **Auto-committing to `main` stays rejected** ([ADR 0016](<redacted>.md)): durability belongs in the DB, not in an HTTP handler that writes to the default branch. Two open design points settled: the **scope key** is the resolved instance pack path (the only axis two instances sharing one database differ on, so one sibling's toggle cannot flip the other's module), and the DB is read into a **snapshot** refreshed at boot and on write rather than inline, because `loadModules()` is synchronous and making it async would ripple into every consumer of a module flag. An unreachable database is a **no-op** — no row means "no opinion", not "off". Does not decide: whether a toggle should also restart the process so remounting is immediate.](0309-durable-module-enablement-db-override.md) | modules / ops |
404
404
  | 0310 | [**A speciality OFFERS skills; the adopter turns them on, one at a time, having been shown what each does** ([task 1003978](https://cloudbongos.com/builders#/task/1003978), goal 1000095). Completes [ADR 0296](0296-a-role-speciality-is-data-over-three-existing-surfaces.md), which defined a speciality as knowledge + skills + an interaction contract and shipped two of the three: `grep -i skill` across the module's schema, domain and routes returns only the header sentences describing a thing that is not there — prose asserting behaviour the code does not have, and the feature's own definition at that. Task 1003978 withheld an implementation until the authority question was answered, and the answer is the strong one: **naming a skill GRANTS NOTHING**. Skills are materialised by `claude-materialize.js` and permission is rank-gated server-side with no caching ([ADR 0016](<redacted>.md)); a speciality offers a menu, and a menu is not an entitlement. **Two layers, because one layer is the blanket the owner ruled against**: the catalog holds what a speciality OFFERS, the already-per-builder `specialities_adoptions` row holds what THIS builder turned on — so two economist ideators share a speciality's knowledge and contract and need not share one enabled skill. **The default is nothing enabled, and that is a position about LEARNING rather than a safe default**: the owner's ruling is that adoption must *walk through what comes with each* because *people need to learn the system*. A settings page of unchecked boxes satisfies the letter and defeats the intent, so the bar is a user test — a person who has finished adopting can say what each skill does. Sharing needs no new mechanism: `owner_kind` / `visibility` / `offered` / `sellable` already ship the private→project→public ladder, and skills ride it. Builds nothing; the three-task order (schema, walkthrough, injection) is filed, and the four documentation sites that claimed a shipped ingredient now say decided-not-built.](0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md) | specialities / methodology |
405
+ | 0311 | [**An idea is a permanent origin tag, and the payout follows its descent** ([task 1004076](https://cloudbongos.com/builders#/task/1004076), goal 1000095 — BONGOS-V2). An idea today is a CONTAINER for work: it leaves `idea_inbox` when it names a goal and lives on as a task, so the better-aimed the thought the faster it vanishes from whoever had it. Reported as a small confusion — a thought filed from the hall routed into a goal, the confirmation line cleared on the next keystroke, and five minutes later the owner could not find it. The idea becomes an **immutable origin tag** instead, the only thing that can answer where a piece of work came from, and **the ideator payout follows its whole descent** rather than stopping at the earliest lineage link (`awardIdeaTaskShipCredit`), decaying by depth — full at the direct task, halving each hop — so an infinite descent is worth at most twice the direct task. That is what gives the tag teeth: if it does not survive a fork, someone does not get paid. **Size and brightness are two channels, not one number**: size is the sum of those payouts and never falls, brightness is how much of that work is still alive, so a thought whose work the project tore out renders LARGE AND DEAD rather than looking like one that never mattered. **Karma was rejected as the basis** — `karma_log` carries no task key and no idea key, deliberately ([`migrations/040_builder_karma.sql`](../../migrations/040_builder_karma.sql)), so "the karma a thought caused" is not computable; credits already are idea-attributed. **The task hierarchy is NOT widened** to carry the descent — it is capped at one level and refuses cross-version parents because ship rollup and claim safety lean on it, and provenance is a different relation from work decomposition. **Nothing records that shipped work was later removed** (`shipped` is terminal, `merged_into_task_id` is CHECK-constrained to abandoned rows), so the first `cascade.js` rule PROPOSES a removal and a person confirms it — derived-only would dim for refactors and renames, declared-only would rot. A ratified **Full Idea becomes a goal or a criterion**, carrying a recommendation the board rules on, because scope is the thing you learn last; the five questions are replaced and VERSIONED so prior filings keep their own. Dropped three tasks this invalidated and amended a fourth whose title asserted the exact exclusivity it reverses. Does not decide: the decay rate beyond its default, what precisely counts as removed, how the edges are physically recorded, or how any of it is drawn.](0311-an-idea-is-a-permanent-origin-tag.md) | ideas / economy |
@@ -0,0 +1,77 @@
1
+ # Design direction — the ideator surface
2
+
3
+ > **Status:** agreed 2026-09-19 with the area owner. Planning session meta task 1004076; the decisions live on idea 1001072; the scope is `docs/specs/<redacted>.md`.
4
+ >
5
+ > This file is the **visual** half. The spec is the build; the criteria on goal 1000095 are the done-when. Where this file and the spec disagree about behaviour, the spec wins; where a criterion and either of them disagree, the criterion wins.
6
+
7
+ ## The problem this answers
8
+
9
+ An idea, today, is a container for work. When it succeeds — when it names a goal and becomes a task — it leaves the ideas world entirely and lives on as a task. So the better aimed the thought, the faster it disappears from the person who had it. The ideas page is named after the leftovers.
10
+
11
+ The session that produced this direction started from a real confusion: a filed thought silently became a task under a goal, the confirmation line vanished on the next keystroke, and the owner could not find what they had written five minutes earlier.
12
+
13
+ ## The shift
14
+
15
+ **An idea stops being a container and becomes a permanent origin tag** — never edited, never editing, and the only thing that can answer where a piece of work came from. It can become a task, a criterion or a goal depending on scope, but the tag itself never changes.
16
+
17
+ Two flows, not one:
18
+
19
+ - **New ideas file fast.** One line, one gesture. Aiming is optional. Nothing about filing gets slower.
20
+ - **Past ideas are explored as a descent** — one thought at the top, everything that grew from it falling away below.
21
+
22
+ ## The world
23
+
24
+ The project's own design system (`DESIGN.md`, "Cloud Bongos — The Chrome World") already supplies the vocabulary: a pure black void, hairline structure, and one warm salmon accent whose single law is that it **lights only what is pressable or live**. That law is why this direction reads as a sky without inventing anything: a living thing glows, a dead thing does not, and the accent's rarity is what makes it believed.
25
+
26
+ ### Gravity
27
+
28
+ The project's open goals are gravity wells. A thought released near one is caught by it and becomes work inside it; a thought nothing catches floats in open space.
29
+
30
+ This is **not new machinery**. It is the capture-time routing that already runs, drawn instead of hidden inside a `<select>`. The invisible dropdown is the direct cause of the confusion that started the session, so making the catch visible is the fix, not a decoration on top of it.
31
+
32
+ A goal's **criteria are the rings inside its well**, closing as each is satisfied.
33
+
34
+ ### The five objects
35
+
36
+ Dim and dark are different states and must be different objects, not one dot at two opacities.
37
+
38
+ | Object | What it means |
39
+ |---|---|
40
+ | **star** | caused work that is still alive. it dims as pieces of that work are removed |
41
+ | **protostar** | filed, not yet ignited. inside a well it awaits a nod; in open space nothing has claimed it |
42
+ | **satellite** | folded into another thought. it orbits that one now, and its value counts there |
43
+ | **remnant** | it burned, then the project removed it. it keeps every bit of its size |
44
+ | **cold body** | declined at triage. it never ignited, so it has no size at all |
45
+
46
+ Position carries a distinction the object alone cannot: a protostar **inside a well** has a home and is waiting for a nod; a protostar in **open space** has no home at all. That makes the nod queue something a person can see rather than a list someone must remember to open.
47
+
48
+ ### Size and brightness are two channels
49
+
50
+ - **Size** is what a thought caused — the ideator's total payout from its descent — and it **never falls**.
51
+ - **Brightness** is how much of that work is **still alive**.
52
+
53
+ Two facts, two encodings. A thought that caused a great deal of work which the project later tore out renders **large and dead**: a big hollow ring. A single number would have made that look identical to a thought that never mattered, which is the failure this split exists to prevent.
54
+
55
+ Karma is not the basis. `karma_log` carries no task key and no idea key and never has, so "the karma a thought caused" is not a quantity the system can compute. Credits are already idea-attributed at ship, so they are.
56
+
57
+ Size is long-tailed, so it is **compressed for drawing** while the true figure is shown in the panel.
58
+
59
+ ## Four surfaces
60
+
61
+ The mockup sources are committed at [`mocks/ideator-surface/`](mocks/ideator-surface/) — see that folder's README. They are the agreed shape, not a pixel contract.
62
+
63
+ 1. **File** — the fast path. One line, an optional aim, and a receipt that does not evaporate. Filing never gets slower than it is today.
64
+ 2. **The room** — the plain, everyday overview: what you filed, what it became, its size and whether it is still burning. This is the page a person opens daily, and it does the real work.
65
+ 3. **The descent** — one thought at the top and everything it caused falling away below, through rewrites, splits and merges, including the branches that went out.
66
+ 4. **The sky** — the ambient view. It is drawn from numbers the other three already produce, it arrives last, and **it is never the only path to anything it shows**.
67
+
68
+ ## What this direction refuses
69
+
70
+ - **A galaxy is not trivial, and it must never be load-bearing.** Every fact the sky shows is reachable without it.
71
+ - **Filing does not get slower to serve the picture.** A spark's whole value is that it costs nothing to have; the sky redraws in the background.
72
+ - **The sky will be dominated by large dead rings in a project that churns.** That is honest, and it is the accepted trade — failure is fine, and a bright sky should be earned.
73
+ - **Brightness never claws back credits.** The ledger is not rewritten when the project changes its mind; only the picture changes.
74
+
75
+ ## Provenance
76
+
77
+ Owner-led ideation, 2026-09-19, following the Ideator pack's stance (the human drives, Claude is the sounding board). Every decision above is the owner's, recorded as it was made on idea 1001072. The clickable mockup lives on claude.ai and is linked from that idea; the committed sources beside this file are what survives if that link does not.
@@ -2264,5 +2264,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2264
2264
  landed since 1.19.893 with no explicit bump. run 35473999345. (task 1002620)
2265
2265
  1.19.895 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2266
2266
  landed since 1.19.894 with no explicit bump. run 35475074265. (task 1002620)
2267
+ 1.19.896 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2268
+ landed since 1.19.895 with no explicit bump. run 35478072367. (task 1002620)
2269
+ 1.19.897 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2270
+ landed since 1.19.896 with no explicit bump. run 35485346707. (task 1002620)
2267
2271
  ---------------------------------------------------------------------------
2268
2272
  ```
@@ -0,0 +1,126 @@
1
+ -- core_250 a cascade REQUEST is never auto-promoted out of backlog
2
+ -- Applied under task 1004077.
3
+ --
4
+ -- WHY. 'backlog' means two different things and only one of them is drainable
5
+ -- by a machine:
6
+ --
7
+ -- waiting on a TRIGGER a dependency has not shipped -- mechanically
8
+ -- unblockable, and what these triggers are for
9
+ -- waiting on a PERSON a generated REQUEST nobody has triaged
10
+ --
11
+ -- modules/lifecycle/cascade.js files every task it generates at 'backlog' and
12
+ -- never 'ready' (one of the four properties tests/cascade.mjs pins), and writes
13
+ -- a preamble onto each one telling the reader it "is a REQUEST, filed at
14
+ -- backlog, and it goes through the board's ordinary triage". Nothing enforced
15
+ -- that downstream, so the board's ordinary triage was skipped by a machine.
16
+ --
17
+ -- WHAT IT COST. modules/hall-ui/public/studio.js reads the artist's queue as
18
+ -- `?discipline=artist&status=backlog`. A cascade task carries no dependencies,
19
+ -- so task_is_fully_unblocked() is trivially true and it qualified for promotion
20
+ -- the instant it existed. Task 1004037 was filed at 19:53:57 and promoted at
21
+ -- 19:54:54 -- 57 seconds, no person involved. Every artist review was swept out
22
+ -- of the artist's room about a minute after arriving and reappeared in
23
+ -- /builder-start as ordinary claimable work; all four open on 2026-09-19 had
24
+ -- been drained. One was then claimed into a git worktree and judged by a grader
25
+ -- panel whose four lenses explicitly do not read a sentence (ADR 0298): an
26
+ -- artist review carried out without an artist, while the studio sat empty.
27
+ --
28
+ -- WHY HERE AS WELL AS IN THE SWEEP. The report-only sweep in
29
+ -- modules/lifecycle/db-tasks.js (PROMOTABLE_BACKLOG_WHERE) is fixed in the same
30
+ -- task, but it is not the only promoter. Each trigger below matches a
31
+ -- dependency edge that may be anchored at the task OR AT ITS GOAL
32
+ -- (`d.from_kind = 'goal' AND d.from_id = t.goal_id`), and a cascade review
33
+ -- INHERITS the reviewed task's goal (cascade.js property 3). So a goal-level
34
+ -- edge promotes a cascade row that has no dependencies of its own. Fixing only
35
+ -- the sweep would leave that path open and the defect would return
36
+ -- intermittently, which is worse than leaving it alone.
37
+ --
38
+ -- SHAPE. `AND t.source IS DISTINCT FROM 'cascade'` sits beside the existing
39
+ -- `AND t.kind <> 'spike'`, which is the established precedent in these same
40
+ -- functions for "this sort of row does not auto-promote". IS DISTINCT FROM, not
41
+ -- <>, because source is nullable and `NULL <> 'cascade'` is NULL, which would
42
+ -- silently stop promoting every task that has no source at all.
43
+ --
44
+ -- NOT task_is_fully_unblocked(). That predicate answers "are this task's
45
+ -- dependencies satisfied", it is shared with the claim gate, and teaching it to
46
+ -- also mean "has a human triaged this" would change what a refused claim means.
47
+ --
48
+ -- Forward-safe: CREATE OR REPLACE only, no DROP, no column change, and the
49
+ -- bodies are 163's verbatim apart from the one added line. A rolled-back code
50
+ -- pin runs fine against these -- the clause narrows what promotes and an older
51
+ -- release neither reads nor writes `tasks.source` on this path.
52
+
53
+ -- (a) a task shipped
54
+ CREATE OR REPLACE FUNCTION promote_dependents_on_ship()
55
+ RETURNS TRIGGER AS $$
56
+ BEGIN
57
+ IF NEW.status = 'shipped' AND (OLD.status IS DISTINCT FROM 'shipped') THEN
58
+ UPDATE tasks t
59
+ SET status = 'ready', promoted_at = COALESCE(t.promoted_at, now()), updated_at = now()
60
+ WHERE t.status = 'backlog'
61
+ AND t.kind <> 'spike'
62
+ AND t.source IS DISTINCT FROM 'cascade'
63
+ AND EXISTS (
64
+ SELECT 1 FROM dependencies d
65
+ WHERE d.to_kind = 'task' AND d.to_id = NEW.id
66
+ AND ( (d.from_kind = 'task' AND d.from_id = t.id)
67
+ OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
68
+ OR (d.from_kind = 'criterion' AND d.from_id IN (
69
+ SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)) )
70
+ )
71
+ AND task_is_fully_unblocked(t.id);
72
+ END IF;
73
+ RETURN NEW;
74
+ END;
75
+ $$ LANGUAGE plpgsql;
76
+
77
+ -- (b) a criterion satisfied
78
+ CREATE OR REPLACE FUNCTION promote_dependents_on_criterion_satisfy()
79
+ RETURNS TRIGGER AS $$
80
+ BEGIN
81
+ IF NEW.satisfied = true AND (OLD.satisfied IS DISTINCT FROM true) THEN
82
+ UPDATE tasks t
83
+ SET status = 'ready', promoted_at = COALESCE(t.promoted_at, now()), updated_at = now()
84
+ WHERE t.status = 'backlog'
85
+ AND t.kind <> 'spike'
86
+ AND t.source IS DISTINCT FROM 'cascade'
87
+ AND EXISTS (
88
+ SELECT 1 FROM dependencies d
89
+ WHERE d.to_kind = 'criterion' AND d.to_id = NEW.id
90
+ AND ( (d.from_kind = 'task' AND d.from_id = t.id)
91
+ OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
92
+ OR (d.from_kind = 'criterion' AND d.from_id IN (
93
+ SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)) )
94
+ )
95
+ AND task_is_fully_unblocked(t.id);
96
+ END IF;
97
+ RETURN NEW;
98
+ END;
99
+ $$ LANGUAGE plpgsql;
100
+
101
+ -- (c) a goal achieved
102
+ CREATE OR REPLACE FUNCTION promote_dependents_on_goal_achieve()
103
+ RETURNS TRIGGER AS $$
104
+ BEGIN
105
+ IF NEW.status = 'achieved' AND (OLD.status IS DISTINCT FROM 'achieved') THEN
106
+ UPDATE tasks t
107
+ SET status = 'ready', promoted_at = COALESCE(t.promoted_at, now()), updated_at = now()
108
+ WHERE t.status = 'backlog'
109
+ AND t.kind <> 'spike'
110
+ AND t.source IS DISTINCT FROM 'cascade'
111
+ AND EXISTS (
112
+ SELECT 1 FROM dependencies d
113
+ WHERE d.to_kind = 'goal' AND d.to_id = NEW.id
114
+ AND ( (d.from_kind = 'task' AND d.from_id = t.id)
115
+ OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
116
+ OR (d.from_kind = 'criterion' AND d.from_id IN (
117
+ SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)) )
118
+ )
119
+ AND task_is_fully_unblocked(t.id);
120
+ END IF;
121
+ RETURN NEW;
122
+ END;
123
+ $$ LANGUAGE plpgsql;
124
+
125
+ -- The triggers themselves are unchanged; 163 created them and CREATE OR REPLACE
126
+ -- above swaps the bodies underneath them in place.
@@ -1129,8 +1129,36 @@ async function updateTaskStatus(id, status) {
1129
1129
  //
1130
1130
  // The UPDATE repeats the WHERE rather than trusting the ids it just read, so a
1131
1131
  // row that stops qualifying between the two statements is not promoted anyway.
1132
+ // 'backlog' MEANS TWO DIFFERENT THINGS, and this sweep may only drain one of
1133
+ // them (task 1004077). A task waiting on a TRIGGER — a dependency that has not
1134
+ // shipped — is mechanically unblockable, and draining those is what this sweep
1135
+ // was built for. A task waiting on a PERSON is a generated REQUEST nobody has
1136
+ // triaged, and "no unmet dependency" says nothing about whether a human has
1137
+ // looked at it. The system already knows the two are different: /backlog-review
1138
+ // exists to "separate rows waiting on a PERSON from rows waiting on a TRIGGER",
1139
+ // and it separates them by hand because nothing in the data did.
1140
+ //
1141
+ // Every cascade row is the second kind. cascade.js files at 'backlog' and never
1142
+ // 'ready' — one of the four properties tests/cascade.mjs pins — and the preamble
1143
+ // it writes onto every generated task says so to the reader: "it is a REQUEST,
1144
+ // filed at backlog, and it goes through the board's ordinary triage".
1145
+ //
1146
+ // WHAT IT COST. A cascade task carries dependencies: [], so it was trivially
1147
+ // "fully unblocked" and qualified the instant it existed. Measured on task
1148
+ // 1004037: filed 19:53:57, promoted 19:54:54 — 57 seconds, by this sweep, with
1149
+ // no person involved. studio.js:476 reads the artist's queue as
1150
+ // `?discipline=artist&status=backlog`, so every artist review was swept out of
1151
+ // the artist's room about a minute after arriving and reappeared in
1152
+ // /builder-start as ordinary claimable work. All four reviews open on
1153
+ // 2026-09-19 had been drained. One was then claimed into a worktree and graded
1154
+ // by a panel whose four lenses do not read a sentence (ADR 0298) — an artist
1155
+ // review performed without an artist, while the studio sat empty.
1156
+ //
1157
+ // The exclusion is by SOURCE, not by discipline: engineer-scoping-on-full-idea
1158
+ // is equally a request, and the preamble above is written on both.
1132
1159
  const PROMOTABLE_BACKLOG_WHERE = `
1133
1160
  WHERE t.status = 'backlog'
1161
+ AND t.source IS DISTINCT FROM 'cascade'
1134
1162
  AND task_is_fully_unblocked(t.id)
1135
1163
  AND NOT EXISTS (
1136
1164
  SELECT 1
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.895",
3
+ "version": "1.19.897",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.895",
9
+ "version": "1.19.897",
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.895",
3
+ "version": "1.19.897",
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",
@@ -88,6 +88,25 @@ const GDS_RULE_FILES = new Set([
88
88
  // legal on any line in any counted file.
89
89
  const GDS_VERSION_ID_RE = /GDS-V\d+/g;
90
90
 
91
+ // An opaque encoded payload is not vocabulary. A committed design mock carries
92
+ // its own content as a base64'd bundle (docs/design/mocks/ideator-surface/
93
+ // ideator-surface.html embeds one in a <script type="__bundler/manifest">), and
94
+ // a three-letter run turns up inside any large base64 blob by chance — five did,
95
+ // which is enough to red the ratchet on a change that introduced no literal at
96
+ // all. The blob cannot be edited to remove them, and the page cannot be
97
+ // committed without it: the manifest IS the page content.
98
+ //
99
+ // So: an unbroken base64-shaped run of 200+ characters is stripped before
100
+ // matching, exactly the way a version id is. The threshold is what makes this
101
+ // safe. The acronym is a WORD wherever it is vocabulary, so it always sits
102
+ // against a boundary — whitespace, punctuation, a case change, an underscore.
103
+ // No line of prose or code reaches 200 unbroken base64 characters, and the
104
+ // blobs that do carry nothing to rename. This is deliberately narrower than a
105
+ // path exclusion on the mocks tree: that tree still counts, so a real literal
106
+ // in a mock's own comment (docs/design/mocks/landing/v1.html:345) is still
107
+ // caught.
108
+ const GDS_ENCODED_BLOB_RE = /[A-Za-z0-9+/=]{200,}/g;
109
+
91
110
  // The literal itself: case-sensitive, so the lowercase path/env spellings that
92
111
  // criterion C2 owns (`scripts/gds/`, `/api/gds`, `gds_session`) are counted
93
112
  // only where they appear in their SHOUTING form. Case-sensitivity is the point
@@ -105,7 +124,9 @@ function isExcludedPath(relPath) {
105
124
  // (the checkErrorEnvelope precedent: a gate whose detector drifts from its
106
125
  // sweep is worse than no gate).
107
126
  function countInText(text) {
108
- const stripped = text.replace(GDS_VERSION_ID_RE, '');
127
+ const stripped = text
128
+ .replace(GDS_ENCODED_BLOB_RE, '')
129
+ .replace(GDS_VERSION_ID_RE, '');
109
130
  return (stripped.match(GDS_LITERAL_RE) || []).length;
110
131
  }
111
132
 
@@ -0,0 +1,399 @@
1
+ // Seed the tasks for the ideator surface on goal 1000095 (BONGOS-V2, Working area 6).
2
+ // Source of truth: docs/specs/bongos-v2-ideator-surface.md (status: final).
3
+ // Visual direction: docs/design/ideator-surface-direction.md. Decisions: idea 1001072.
4
+ // Planning session 2026-09-19, meta task 1004076.
5
+ //
6
+ // Idempotent on (source, source_ref). Re-running resolves to the existing rows.
7
+ // criterion_ids are SLUGS, never positional "Cn" — goal 1000095 is one of several
8
+ // goals on this version, so a positional ref resolves against the wrong scope and
9
+ // POST /tasks now refuses it outright (learning 1000188, task 1003108).
10
+ //
11
+ // Ends by diffing the criterion links it planned against what the goal actually
12
+ // carries, and exits non-zero on any mismatch (the seeding contract's self-check).
13
+ //
14
+ // Run: node scripts/gds/seed-bv2-ideator-surface-tasks.js
15
+ const os = require('os');
16
+ const { expectedCriterionCounts, verifySeedCriteriaLinks } = require('./seed-verify-criteria');
17
+ const sess = require(os.homedir() + '/.config/cloudbongos/gds-session.json');
18
+ const BASE = sess.api_base.replace(/\/$/, '');
19
+ const TOKEN = sess.token;
20
+ const VER = 'BONGOS-V2';
21
+ const GOAL = 1000095;
22
+ const SRC = 'planning-session';
23
+ const REF_PREFIX = 'bv2-ideator-surface';
24
+ const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
25
+
26
+ const CRIT = {
27
+ lost: 'ideator-nothing-is-lost',
28
+ fork: 'ideator-tag-survives-the-fork',
29
+ sky: 'ideator-sky-tells-the-truth',
30
+ full: 'ideator-full-idea-shapes-the-space',
31
+ };
32
+
33
+ const SPEC = 'Spec: `docs/specs/bongos-v2-ideator-surface.md` (final). Design of record: idea 1001072 + `docs/design/ideator-surface-direction.md`.';
34
+
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+ async function api(method, path, body, _tries = 0) {
37
+ await sleep(400); // base throttle to stay under the per-IP rate limit
38
+ const res = await fetch(`${BASE}/api/bongos${path}`, {
39
+ method, headers: H, body: body ? JSON.stringify(body) : undefined,
40
+ });
41
+ const text = await res.text();
42
+ let json; try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 300) }; }
43
+ if (res.status === 429 && _tries < 8) {
44
+ const wait = ((json.error && json.error.details && json.error.details.retry_after_seconds) || 30) + 1;
45
+ console.log(` …rate-limited, waiting ${wait}s`);
46
+ await sleep(wait * 1000);
47
+ return api(method, path, body, _tries + 1);
48
+ }
49
+ return { status: res.status, ok: res.ok, json };
50
+ }
51
+
52
+ // TOPOLOGICAL ORDER. The BV2.R## prefix is assigned from this array's index, so
53
+ // for any task A depending on B, B sits earlier and carries the lower number.
54
+ // Nothing else on BONGOS-V2 uses a rank prefix; these 27 carry one so the batch
55
+ // reads in its planned order inside each priority bucket.
56
+ const TASKS = [
57
+ // ---- T1: the two spikes. Neither auto-promotes (kind='spike', ADR 0015).
58
+ {
59
+ ref: 'R07', kind: 'spike', discipline: 'engineer', priority: 5, crit: [CRIT.fork],
60
+ title: 'Spike: how provenance is recorded without widening the task hierarchy',
61
+ desc: `Output an ADR naming the edge shape, where it is written, and who writes it when work is split or rewritten.
62
+
63
+ **The constraint that makes this a spike.** \`modules/lifecycle/hierarchy.js\` caps task parentage at ONE level (a parent may not itself have a parent; a task with children may not become a child) and refuses cross-version trees. Both are deliberate — ship rollup and claim safety lean on them. A descent of the shape idea → task → rewrite → split → removal is four levels and crosses a version boundary, so it cannot ride on that relation, and **widening it is explicitly out of scope** (spec decision 8).
64
+
65
+ Provenance is a different relation from work decomposition. The expected home is \`ideas_task_lineage\`, which is already many-ideas-to-one-task and carries none of those invariants — but confirm that before assuming it.
66
+
67
+ **Also settle:** who writes the edge. Parentage today is a manual explicit act, so an edge that depends on someone remembering will have holes. ${SPEC}`,
68
+ },
69
+ {
70
+ ref: 'R14', kind: 'spike', discipline: 'engineer', priority: 5, crit: [CRIT.sky],
71
+ title: 'Spike: what counts as removed, and who says so',
72
+ desc: `Output an ADR settling the removal signal: its source, and whether it is derived or declared.
73
+
74
+ **Nothing records this today.** \`shipped\` is terminal (\`modules/lifecycle/rot.js\` says so in as many words), there is no reverted status, and \`tasks.merged_into_task_id\` is CHECK-constrained to abandoned rows so it can only ever describe work that was cut before it landed. This signal is what lets brightness fall at all.
75
+
76
+ **The owner's decision (spec decision 13) is the shape, not the detail:** the system PROPOSES and a person CONFIRMS. \`modules/lifecycle/cascade.js\` already implements ship-generates-work and ships with an EMPTY rule set, so the mechanism exists and only the rule is new. A derived-only signal dims the sky for refactors, renames and file moves; a declared-only one rots because nobody remembers.
77
+
78
+ Nearest existing signal to build the derivation on: \`modules/economy/credits.js\` \`netNegativeBonusAmount\`, which already reads \`lines_removed > lines_added\` from a ship's own diff — the only place the system counts deletion. ${SPEC}`,
79
+ },
80
+
81
+ // ---- Zero-dep surfaces and settings.
82
+ {
83
+ ref: 'R01', kind: 'feature', discipline: 'ui', priority: 4, crit: [CRIT.lost],
84
+ title: 'The receipt persists',
85
+ desc: `A filing's outcome is stated and survives the next keystroke; the last several outcomes stay reachable on the surface that filed them.
86
+
87
+ **This is the bug that started the session.** The hall already prints "Filed as task N under <goal>" on a routed capture (\`modules/hall-ui/public/ideas.js\`, the \`#fq-result\` line) — and then clears it the moment the composer is touched again. The signal exists; its half-life is about four seconds.
88
+
89
+ Done-when: a person files a thought, keeps working, and can still see what became of it without navigating anywhere. ${SPEC}`,
90
+ },
91
+ {
92
+ ref: 'R02', kind: 'feature', discipline: 'ui', priority: 4, crit: [CRIT.lost],
93
+ title: 'Gravity is visible before release',
94
+ desc: `The goal that will catch a thought is shown while composing, and letting it float is an explicit choice rather than an empty dropdown.
95
+
96
+ **No new routing.** \`modules/ideas/routing.js\` \`decideLanding\` already decides this at capture time, and \`GET /goals/suggestions\` already feeds the composer's goal select. This task makes the decision legible at the moment it is made — the invisible dropdown is the direct cause of the confusion this whole scope came from (spec decision 2).
97
+
98
+ Done-when: before releasing a thought, a person can see which goal will catch it, change it, or deliberately let it float. ${SPEC}`,
99
+ },
100
+ {
101
+ ref: 'R05', kind: 'feature', discipline: 'engineer', priority: 3, crit: [CRIT.lost],
102
+ title: 'The per-instance dials: the ignite floor and the decay rate',
103
+ desc: `Both configured rather than constant, sharing one home. Defaults preserve today's behaviour and halving respectively.
104
+
105
+ **Two dials, one surface** (spec decisions 3 and 16). The ignite floor — who may convert a quick idea directly into claimable work — is today the \`METIC_PLUS\` constant in \`modules/ideas/routing.js\`. The decay rate — how fast an ideator's share falls per hop of the descent — is new in R11. The owner's ruling is that both belong to the project, not to the platform: a project of one wants every thought claimable, a project of fifty wants a nod.
106
+
107
+ They are scoped together deliberately so a second dial does not grow its own separate home. Do not widen either default as a side effect. ${SPEC}`,
108
+ },
109
+ {
110
+ ref: 'R20', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.full],
111
+ title: 'The new five questions as a versioned template',
112
+ desc: `\`base-v2\` ships beside \`base-v1\`; ideas already filed keep scoring against the questions they were actually asked.
113
+
114
+ **The owner's question set, in order:** what made you have the idea; scope as best as you can here; what should an engineer or artist know prior to building; how is this long term justified in the project; what does a successful implementation of the idea look like.
115
+
116
+ **This is cheaper than it looks.** \`modules/ideas/templates.js\` already registers templates by key and every idea stores its \`template_key\`, so this is a second entry in the registry, NOT a migration. Renaming a label is free; only renaming a key would be a data migration, and this does not need to.
117
+
118
+ One field survives outright (what made you have the idea ≈ \`why_had\`) and one roughly (the long-term justification ≈ \`fit\`). Three are new and one is dropped. ${SPEC}`,
119
+ },
120
+
121
+ // ---- The provenance spine.
122
+ {
123
+ ref: 'R08', kind: 'feature', discipline: 'engineer', priority: 5, crit: [CRIT.fork], deps: ['R07'],
124
+ title: 'Provenance edges on the idea lineage ledger',
125
+ desc: `task→task descent edges recorded, to arbitrary depth and across version boundaries. The task hierarchy's one-level cap and its cross-version refusal are untouched.
126
+
127
+ Build to whatever R07's ADR settled. The write side that exists today is \`modules/ideas/inbox.js\` \`linkIdeaTask\`, an upsert on \`(task_id, idea_id)\`; \`ideas_task_lineage\` has been many-ideas-to-one-task since \`ideas_006\`.
128
+
129
+ **The property that matters:** the tag must survive a rewrite, a split and a merge. If it only survives one hop the result is a link, not a story — and, after R11, someone does not get paid. ${SPEC}`,
130
+ },
131
+ {
132
+ ref: 'R04', kind: 'refactor', discipline: 'ui', priority: 3, crit: [CRIT.lost], deps: ['R01'],
133
+ title: 'Fold the Full Idea form and the spark queue into the new surface',
134
+ desc: `Developing a thought is a step taken AFTER a one-line filing, never a gate before it. No dead links from \`/idea/:id\`, the command palette, or the nav.
135
+
136
+ **What the ideas page actually is today:** three stacked sections — the spark queue ("waiting to be developed"), the quick composer, and the five-field Full Idea composer with its live completeness meter. All three need a home. The Board Room does NOT — it is reached from the left nav and is not behind this page.
137
+
138
+ **The risk this task carries** (spec decision 18): a spark's whole value is that it costs nothing to have. Folding a five-question form onto the same surface is the most likely way to slow the one-line path. R06a exists to catch that; do not treat it as someone else's problem. ${SPEC}`,
139
+ },
140
+ {
141
+ ref: 'R11', kind: 'feature', discipline: 'engineer', priority: 5, crit: [CRIT.fork], deps: ['R08'],
142
+ title: 'The ideator payout follows the descent, decayed by depth',
143
+ desc: `A descendant's ship pays the origin at the decayed rate. Idempotent. Existing first-hop behaviour preserved exactly.
144
+
145
+ **The seam exists.** \`modules/economy/credits.js\` \`awardIdeaTaskShipCredit\` already resolves a shipped task back to its source idea and splits the payout — but only via the EARLIEST lineage link, one hop. A task split off later, or a child of a child, pays nobody.
146
+
147
+ **Decay** (spec decision 16): full share at the direct task, halving at each hop, so an infinite descent is worth at most twice the direct task. The rate is a dial (R05), not a constant.
148
+
149
+ **The boundary that stops farming** (spec decision 7): descendant WORK pays the origin; a separate idea that merely cites the origin does not. The descent displays more than the payout counts. ${SPEC}`,
150
+ },
151
+ {
152
+ ref: 'R09', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.fork], deps: ['R08'],
153
+ title: 'The descent reads to arbitrary depth and across versions',
154
+ desc: `One query, not per-hop round trips.
155
+
156
+ **Three findings inherited from abandoned task 1004054**, whose predecessor was built, graded and lost. They are free design requirements, not someone else's mess:
157
+
158
+ 1. **Test at the RENDERED layer, not just the data layer.** The prior attempt tested only the server-side trail builder, which is exactly why finding 2 went uncaught.
159
+ 2. **A merged idea's triage note lives on the MERGED row, not the target row.** The prior attempt read it from the target, so the headline scenario was the broken one.
160
+ 3. **Collapse the hops into one query or a CTE.** The prior attempt ran 4–6 serial round trips per read.
161
+
162
+ Today's reader is \`modules/ideas/inbox.js\` \`listIdeaTrailForBuilder\`, which returns \`lineage_tasks[]\` — one hop only. ${SPEC}`,
163
+ },
164
+ {
165
+ ref: 'R15', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.sky], deps: ['R14'],
166
+ title: 'The cascade rule that proposes a removal',
167
+ desc: `A ship that deletes an earlier shipped task's work files a QUESTION, not a verdict. \`shipped\` stays terminal and no credit is ever clawed back.
168
+
169
+ Build to R14's ADR. \`modules/lifecycle/cascade.js\` already implements ship-generates-work and declares an EMPTY rule set — this is the first rule, so expect the mechanism to be sound and unexercised.
170
+
171
+ **Do not make this a verdict.** The owner chose propose-then-confirm precisely because refactors, renames and file moves all look like deletions to a diff. A rule that marks work removed on its own evidence will dim the sky for reasons that are not real. ${SPEC}`,
172
+ },
173
+ {
174
+ ref: 'R21', kind: 'feature', discipline: 'engineer', priority: 3, crit: [CRIT.full], deps: ['R20'],
175
+ title: 'Substance floors for the new Full Idea fields',
176
+ desc: `Floors chosen deliberately for each new field, because the completeness score feeds credits.
177
+
178
+ \`modules/ideas/completeness.js\` scores against the per-field \`substanceMin\` declared in the template; \`base-v1\` used a flat 40 characters for all five. The new fields are not interchangeable — "scope as best as you can here" and "what made you have the idea" do not deserve the same floor.
179
+
180
+ **The never-invent-a-field rule gets stronger here, not weaker** (the Ideator pack). The new set is deliberately resistant to ghost-writing: nobody but the filer can answer what made them have the idea. A floor that pushes an agent to pad a thin answer destroys exactly that property. A short answer scores short — that is the scorer working. ${SPEC}`,
181
+ },
182
+ {
183
+ ref: 'R22', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.full], deps: ['R20'],
184
+ title: 'A Full Idea carries a recommendation: goal or criterion',
185
+ desc: `The recommendation is carried from filing through to the board item and shown to voters.
186
+
187
+ **The ideator does not decide** (spec decision 9). They recommend; the board decides. The reasoning the owner accepted is that scope is the thing you learn last — the person who had the thought writes it, the people reading it judge how big it turned out to be.
188
+
189
+ The board item payload is \`boardWindowPayload\` in \`modules/ideas/routes/inbox.js\`; the section labels the Board Room renders are a hand-mirror of the template's field keys (\`modules/hall-ui/public/board-room.js\`, test-pinned) — so a template change and a board change move together. **The Board Room's own flow is unchanged.** ${SPEC}`,
190
+ },
191
+ {
192
+ ref: 'R12', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.fork], deps: ['R11'],
193
+ title: 'Size is the sum of those payouts',
194
+ desc: `Computable from the ledger for any idea. Compressed for drawing, with the true figure available.
195
+
196
+ **Size is credits, not karma** (spec decision 5). \`karma_log\` carries no task key and no idea key — \`migrations/040_builder_karma.sql\` states that this is deliberate — so "the karma a thought caused" is not a quantity that can be computed. Credits are already idea-attributed at ship, so they can.
197
+
198
+ Size never falls. That is what makes it independent of brightness, and what lets a thought whose work was torn out still render large. Sums are long-tailed, so compress for drawing and show the real number in the panel. ${SPEC}`,
199
+ },
200
+ {
201
+ ref: 'R10', kind: 'feature', discipline: 'ui', priority: 4, crit: [CRIT.fork], deps: ['R09'],
202
+ title: 'The descent surface on the idea detail page',
203
+ desc: `One thought at the top, everything it caused falling away below — including the branches that went out.
204
+
205
+ \`/idea/:id\` (\`modules/hall-ui/public/idea.js\`) is the canonical per-idea record and stays; this is its new section. It already renders a one-line "became task N" outcome, which this replaces with the full story.
206
+
207
+ Done-when: a person can trace a thing shipped months later back to the line that caused it, through rewrites, splits and merges. See \`docs/design/mocks/ideator-surface/Descent.dc.html\` for the agreed shape — it is a shape, not a pixel contract. ${SPEC}`,
208
+ },
209
+ {
210
+ ref: 'R15a', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.sky], deps: ['R15'],
211
+ title: 'Confirming or dismissing a removal proposal',
212
+ desc: `On the existing awaiting-nod surface, at the same floor as the nod (Metic+, \`task.promote\`). No new permission and no new page.
213
+
214
+ An unanswered proposal **rots on the normal clock** (spec decision 14) rather than accumulating — \`modules/lifecycle/rot.js\` and the \`GET /inbox/rotting\` feed already exist and this rides them.
215
+
216
+ The surface is \`modules/hall-ui/public/goal-inbox.js\`, which already renders the awaiting-nod queue and the rot feed side by side. Confirming is what makes brightness fall; dismissing is what stops a refactor from dimming the sky. ${SPEC}`,
217
+ },
218
+ {
219
+ ref: 'R23', kind: 'feature', discipline: 'engineer', priority: 3, crit: [CRIT.full], deps: ['R22'],
220
+ title: 'Ratify a Full Idea into a goal',
221
+ desc: `The goal half beside the existing criterion half. One transaction. The five answers land on the goal.
222
+
223
+ **The criterion half already exists** and is the model to follow: \`modules/ideas/inbox.js\` \`ratifyCriterionProposal\` writes \`done_when_criteria\` + the idea status + karma + the audit row in a single transaction, and \`POST /inbox/:id/ratify\` fronts it.
224
+
225
+ **Nothing today turns a filed thought into a goal** — goals come from a separate planning act — so this is the genuinely new path of the four in this criterion. \`POST /goals\` validates \`scope_modules\` against the live roster and escalates to Archon for a protected scope; a ratification path has to respect both. ${SPEC}`,
226
+ },
227
+ {
228
+ ref: 'R12a', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.fork], deps: ['R12'],
229
+ title: 'Backfill lineage for existing ideas, without backfilling money',
230
+ desc: `Pre-existing ideas carry a size computed from the descent they actually have. No credits are paid retroactively.
231
+
232
+ **And the consequence is surfaced, not hidden** (spec decision 17). For those ideas size is NOT a sum of payouts actually made, so it needs a historical marker or the number quietly lies about what it means. The owner chose this deliberately: it makes the sky meaningful on day one without spending credits again on work already paid for once.
233
+
234
+ Done-when: every idea filed before this shipped has a size, and anywhere that size is shown makes clear which kind of number it is. ${SPEC}`,
235
+ },
236
+ {
237
+ ref: 'R03', kind: 'feature', discipline: 'ui', priority: 3, crit: [CRIT.lost], deps: ['R12'],
238
+ title: 'The room',
239
+ desc: `A plain overview of one's own thinking: what was filed, what it became, its size, and whether it still burns.
240
+
241
+ **This is the page a person opens daily**, and it does the real work — the sky is ambient, the room is useful. It is deliberately unglamorous: rows, a size, a state.
242
+
243
+ **It is the author's own view. The profile keeps a compact public trail** (spec decision 19) — the two are not the same surface. \`modules/hall-ui/public/profile.js\` already renders an idea trail from \`GET /inbox/by-builder/:id\`; that stays as what someone else sees on you.
244
+
245
+ See \`docs/design/mocks/ideator-surface/Room.dc.html\` for the agreed shape. ${SPEC}`,
246
+ },
247
+ {
248
+ ref: 'R16', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.sky], deps: ['R15a', 'R12'],
249
+ title: 'Brightness — the live share of a descent',
250
+ desc: `Computable per idea, and it falls when a removal is confirmed.
251
+
252
+ **Graded, not binary** (spec decision 20): a thought whose descent is five things with three still alive is partly lit, and goes out only when nothing survives. That is what makes a star visibly dim as the project removes pieces of what it caused.
253
+
254
+ **Brightness never claws back credits.** The ledger is not rewritten when the project changes its mind — only the picture changes. Size, which R12 owns, does not move. ${SPEC}`,
255
+ },
256
+ {
257
+ ref: 'R06', kind: 'verify', discipline: 'engineer', priority: 3, crit: [CRIT.lost], deps: ['R01', 'R03'],
258
+ title: 'Proof: a thought filed from the hall is traceable an hour later without asking anyone',
259
+ desc: `**The live demonstration that \`ideator-nothing-is-lost\` is met.**
260
+
261
+ A person files a thought, leaves, comes back later, and finds what became of it unaided — no asking, no database, no one else's help. If they filed it aimed, they can see which goal caught it; if they let it float, they can see that nothing has claimed it yet.
262
+
263
+ This is the exact scenario that failed on 2026-09-19 and produced this whole scope: a thought was filed, it silently became a task under a goal, and the person who filed it could not find it five minutes later. The proof is that this can no longer happen. ${SPEC}`,
264
+ },
265
+ {
266
+ ref: 'R06a', kind: 'verify', discipline: 'engineer', priority: 3, crit: [CRIT.lost], deps: ['R04'],
267
+ title: 'Proof: the one-line path did not get slower',
268
+ desc: `**The guard on spec decision 18**, which is the decision in this scope carrying the most risk.
269
+
270
+ Filing a spark takes the same gestures and the same time it does today, with the Full Idea form present on the same surface. Measure it against today's path, not against an impression.
271
+
272
+ A spark's whole value is that it costs nothing to have. If the fast path picks up an extra step, a scroll, or a decision, this scope will have built something beautiful that people stop using — and they will go back to firing one-liners into Discord. A green build is not the evidence here; a timed comparison is. ${SPEC}`,
273
+ },
274
+ {
275
+ ref: 'R13', kind: 'verify', discipline: 'engineer', priority: 3, crit: [CRIT.fork], deps: ['R10', 'R12'],
276
+ title: 'Proof: a task split twice pays its origin at the decayed rate and appears in the descent',
277
+ desc: `**The live demonstration that \`ideator-tag-survives-the-fork\` is met.**
278
+
279
+ A real thought becomes a real task; that task is split; one of its children is split again; each ships. The origin is paid at the decayed rate at every hop, and the whole chain reads as one descent.
280
+
281
+ This is the criterion everything else stands on. If the tag dies at the first fork, the descent is a link, the size is wrong, and the sky is decoration over a number that does not mean anything. Demonstrate it on real work, not a fixture. ${SPEC}`,
282
+ },
283
+ {
284
+ ref: 'R17', kind: 'feature', discipline: 'engineer', priority: 4, crit: [CRIT.sky], deps: ['R16'],
285
+ title: 'The five states resolve from ledger facts',
286
+ desc: `star / protostar / satellite / remnant / cold body — each DERIVED, none a UI guess.
287
+
288
+ - **star** — caused work that is still alive
289
+ - **protostar** — filed, not yet ignited. Include the well-vs-open-space distinction: caught by a goal and awaiting a nod is a different state from claimed by nothing, and position is what carries it (spec decision, direction doc)
290
+ - **satellite** — folded into another thought; its value counts there
291
+ - **remnant** — it burned, then the project removed it; it keeps its size
292
+ - **cold body** — declined at triage; it never ignited, so it has no size at all
293
+
294
+ Done-when: every state is computed from the ledger, and no surface has to infer one. ${SPEC}`,
295
+ },
296
+ {
297
+ ref: 'R24', kind: 'verify', discipline: 'engineer', priority: 3, crit: [CRIT.full], deps: ['R23'],
298
+ title: 'Proof: a Full Idea ratified as a goal produces a goal carrying its five answers',
299
+ desc: `**The live demonstration that \`ideator-full-idea-shapes-the-space\` is met.**
300
+
301
+ A Full Idea is written through the new five questions, carries a recommendation, goes to the Board Room unchanged, is ratified as a GOAL, and the resulting goal carries those five answers natively — scope from "scope as best as you can here", done-when from "what does a successful implementation look like".
302
+
303
+ Run it end to end through the real Board Room. The point of this criterion is that an ideator's best work becomes the structure other people's thinking lands in; a goal that arrives without its answers has not demonstrated that. ${SPEC}`,
304
+ },
305
+ {
306
+ ref: 'R18', kind: 'feature', discipline: 'ui', priority: 3, crit: [CRIT.sky], deps: ['R17', 'R03'],
307
+ title: 'The sky',
308
+ desc: `The ambient view over the five states: size compressed, brightness live, goals as gravity wells and their criteria as the rings inside them.
309
+
310
+ **It arrives last on purpose.** By this point it draws numbers and states the rest of the work has already produced, which is what keeps it cheap. A galaxy is not trivial, and this scope only earns it because nothing else depends on it.
311
+
312
+ **It is never the only path to anything it shows** (spec decision 11). Every fact in the sky is reachable from the room or the descent.
313
+
314
+ See \`docs/design/mocks/ideator-surface/Sky.dc.html\`, and \`docs/design/ideator-surface-direction.md\` for why the project's own design system — a black void and one accent that lights only what is live — already supplies this. ${SPEC}`,
315
+ },
316
+ {
317
+ ref: 'R19', kind: 'verify', discipline: 'engineer', priority: 3, crit: [CRIT.sky], deps: ['R18'],
318
+ title: 'Proof: a large dead thought renders large and dead',
319
+ desc: `**The live demonstration that \`ideator-sky-tells-the-truth\` is met**, and the single scenario this criterion exists for.
320
+
321
+ A thought that caused substantial work which the project has since removed shows at **full size and zero brightness**, and is distinguishable at a glance from a thought that never caused anything at all.
322
+
323
+ That distinction is the whole reason size and brightness are two channels instead of one number. If those two render alike, the criterion is not met however green the build is. ${SPEC}`,
324
+ },
325
+ ];
326
+
327
+ async function main() {
328
+ const ids = {};
329
+ const criterionIdListsPerTask = [];
330
+
331
+ // BASELINE. verifySeedCriteriaLinks unions the plan's slugs with EVERY criterion
332
+ // the goal carries, so a criterion this seed does not touch is compared against an
333
+ // expected zero. Goal 1000095 already held six criteria with live task links before
334
+ // this seed existed, so an unbaselined check would report six false mismatches and
335
+ // exit(1) on a correct run. Snapshot the untouched criteria's real counts HERE,
336
+ // before anything is written, and fold them into the plan — the check then still
337
+ // catches any drift on the four this seed owns, which is what it is for.
338
+ const OURS = new Set(Object.values(CRIT));
339
+ const baseline = {};
340
+ const pre = await api('GET', `/goals/${GOAL}`);
341
+ if (!pre.ok || !pre.json || !Array.isArray(pre.json.criteria)) {
342
+ console.error(`Could not read goal ${GOAL} to baseline its existing criteria (status ${pre.status}). Refusing to seed blind.`);
343
+ process.exit(1);
344
+ }
345
+ for (const row of pre.json.criteria) {
346
+ if (!OURS.has(row.criterion_id)) baseline[row.criterion_id] = Number(row.linked_tasks) || 0;
347
+ }
348
+ console.log(`Baselined ${Object.keys(baseline).length} pre-existing criteria on goal ${GOAL}.`);
349
+
350
+ console.log(`Seeding ${TASKS.length} tasks into goal ${GOAL} (${VER})…\n`);
351
+
352
+ for (let i = 0; i < TASKS.length; i++) {
353
+ const t = TASKS[i];
354
+ const rank = String(i + 1).padStart(2, '0');
355
+ const title = `BV2.R${rank} — ${t.title}`;
356
+ const r = await api('POST', '/tasks', {
357
+ version_id: VER,
358
+ goal_id: GOAL,
359
+ title,
360
+ description: t.desc,
361
+ kind: t.kind,
362
+ discipline: t.discipline,
363
+ priority: t.priority,
364
+ criterion_ids: t.crit,
365
+ source: SRC,
366
+ source_ref: `${REF_PREFIX}-${t.ref}`,
367
+ });
368
+ if (!r.ok) {
369
+ console.log(` ${t.ref} FAILED (${r.status}): ${JSON.stringify(r.json).slice(0, 300)}`);
370
+ continue;
371
+ }
372
+ ids[t.ref] = r.json.task.id;
373
+ criterionIdListsPerTask.push(t.crit);
374
+ console.log(` ${t.ref} → ${r.json.task.id} ${title.slice(0, 74)}`);
375
+ }
376
+
377
+ console.log('\nWiring dependencies…');
378
+ for (const t of TASKS) {
379
+ if (!t.deps || !t.deps.length) continue;
380
+ const id = ids[t.ref];
381
+ if (!id) { console.log(` ${t.ref}: skipped, task was not created`); continue; }
382
+ const depIds = t.deps.map((d) => ids[d]).filter(Boolean);
383
+ if (depIds.length !== t.deps.length) {
384
+ console.log(` ${t.ref}: WARNING — ${t.deps.length - depIds.length} dep(s) unresolved`);
385
+ }
386
+ if (!depIds.length) continue;
387
+ const r = await api('POST', `/tasks/${id}/dependencies`, { depends_on_task_ids: depIds });
388
+ console.log(` ${t.ref} depends on ${t.deps.join(', ')} → ${r.ok ? 'OK' : `FAILED ${r.status} ${JSON.stringify(r.json).slice(0, 180)}`}`);
389
+ }
390
+
391
+ console.log('');
392
+ await verifySeedCriteriaLinks({
393
+ goalId: GOAL,
394
+ expectedCounts: { ...baseline, ...expectedCriterionCounts(criterionIdListsPerTask) },
395
+ fetchGoal: (gid) => api('GET', `/goals/${gid}`),
396
+ });
397
+ }
398
+
399
+ main().catch((e) => { console.error(e); process.exit(1); });
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.895'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.897'; // 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');
@@ -38,6 +38,24 @@ test('a version id is NOT counted — it is a row key, not vocabulary', () => {
38
38
  assert.equal(scan.countInText(`${LITERAL}-V3 and the ${LITERAL} acronym`), 1);
39
39
  });
40
40
 
41
+ test('an encoded blob is NOT counted — a chance run inside base64 is not vocabulary', () => {
42
+ // A committed design mock embeds its own page content as one base64 payload,
43
+ // and three letters turn up inside a large blob by chance. Five did, which
44
+ // was enough to red the ratchet on a change that introduced no literal at all.
45
+ const blob = `AAAA${LITERAL}BBBB`.repeat(40); // > 200 unbroken base64 chars
46
+ assert.ok(blob.length > 200, `the fixture must exceed the threshold, got ${blob.length}`);
47
+ assert.equal(scan.countInText(blob), 0);
48
+
49
+ // …and the strip must not blind the detector to a REAL literal beside it:
50
+ // the same failure mode the version-id case above guards against.
51
+ assert.equal(scan.countInText(`the ${LITERAL} acronym, then ${blob}`), 1);
52
+
53
+ // A SHORT base64-ish run is still counted. The threshold is what keeps this
54
+ // safe, so a change that lowers it has to fail here rather than silently
55
+ // blinding the gate to ordinary identifiers.
56
+ assert.equal(scan.countInText(`AAAA${LITERAL}BBBB`), 1);
57
+ });
58
+
41
59
  test('the frozen record is excluded — an ADR or session log may say the old name', () => {
42
60
  for (const p of [
43
61
  'docs/adr/0064-rename.md',
@@ -61,6 +61,11 @@ const EXCLUDED = [
61
61
  { id: 2000001, title: 'a dependency has not shipped', goal_id: 1000106, why: 'unmetDep' },
62
62
  { id: 2000002, title: 'carries an open blocker', goal_id: 1000106, why: 'openBlocker' },
63
63
  { id: 2000003, title: 'already out of backlog', goal_id: 1000106, why: 'notBacklog' },
64
+ // task 1004077. A cascade-filed REQUEST waits on a PERSON, not on a trigger,
65
+ // so this sweep must never drain it. It is the row most likely to qualify by
66
+ // accident: cascade.js files with no dependencies at all, so every other
67
+ // guard here passes it.
68
+ { id: 2000004, title: 'a cascade request nobody has triaged', goal_id: 1000106, why: 'cascade' },
64
69
  ];
65
70
  const FIXTURE = [...QUALIFIES, ...EXCLUDED];
66
71
 
@@ -69,10 +74,15 @@ function admittedBy(sql, rows) {
69
74
  const backlogGuard = /status\s*=\s*'backlog'/i.test(sql);
70
75
  const depGuard = /task_is_fully_unblocked\s*\(/i.test(sql);
71
76
  const blockerGuard = /task_blockers/i.test(sql) && /b\.status\s*=\s*'open'/i.test(sql);
77
+ // IS DISTINCT FROM, not <>: source is nullable, and `NULL <> 'cascade'` is
78
+ // NULL, which would stop promoting every sourceless task instead. A <>
79
+ // spelling must NOT satisfy this guard.
80
+ const cascadeGuard = /source\s+IS\s+DISTINCT\s+FROM\s+'cascade'/i.test(sql);
72
81
  return rows.filter((r) => {
73
82
  if (r.why === 'notBacklog' && backlogGuard) return false;
74
83
  if (r.why === 'unmetDep' && depGuard) return false;
75
84
  if (r.why === 'openBlocker' && blockerGuard) return false;
85
+ if (r.why === 'cascade' && cascadeGuard) return false;
76
86
  return true;
77
87
  });
78
88
  }
@@ -135,6 +145,36 @@ await test('a task carrying an open blocker is NOT promoted', async () => {
135
145
  assert.ok(!idsOf(out.promoted).includes(2000002), 'and never promoted');
136
146
  });
137
147
 
148
+ await test('a cascade REQUEST is NOT promoted — it waits on a person, not a trigger', async () => {
149
+ // task 1004077. This is the regression the whole task exists for. cascade.js
150
+ // files every generated task at 'backlog' and never 'ready', and tells its
151
+ // reader it "goes through the board's ordinary triage" — but a cascade row
152
+ // carries no dependencies, so before this guard it was trivially "fully
153
+ // unblocked" and this sweep promoted it within a minute of filing. That
154
+ // emptied the studio, whose queue is `?discipline=artist&status=backlog`:
155
+ // measured on task 1004037, filed 19:53:57 and promoted 19:54:54.
156
+ const pool = mkPool();
157
+ const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
158
+ assert.ok(!idsOf(out.candidates).includes(2000004),
159
+ 'a cascade request became a promotion candidate — the source guard is gone from the SELECT, '
160
+ + 'and every artist review will be swept out of the studio about a minute after it is filed');
161
+ assert.ok(!idsOf(out.promoted).includes(2000004), 'and never promoted');
162
+ });
163
+
164
+ await test('the cascade guard is in the SQL as IS DISTINCT FROM, not <>', async () => {
165
+ // Narrow on purpose. `t.source <> 'cascade'` reads as equivalent and is not:
166
+ // source is nullable and `NULL <> 'cascade'` is NULL, so the <> spelling
167
+ // stops promoting every task with no source at all — a much larger, much
168
+ // quieter bug than the one being fixed.
169
+ const pool = mkPool();
170
+ await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
171
+ const select = pool.calls.find((c) => /^\s*SELECT/i.test(c.sql)).sql;
172
+ assert.match(select, /source\s+IS\s+DISTINCT\s+FROM\s+'cascade'/i,
173
+ 'the SELECT must exclude cascade rows with IS DISTINCT FROM');
174
+ assert.doesNotMatch(select, /source\s*<>\s*'cascade'/i,
175
+ 'the <> spelling silently withholds every sourceless task instead');
176
+ });
177
+
138
178
  await test('a task already out of backlog is NOT touched', async () => {
139
179
  const pool = mkPool();
140
180
  const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
@@ -0,0 +1,117 @@
1
+ // tests/promotion_triggers_skip_cascade.mjs
2
+ //
3
+ // The SECOND promoter (task 1004077). Static shape guard for core_250, in the
4
+ // style of tests/task_unblocked_open_blocker.mjs's guard for core_245 — and for
5
+ // the same reason: CI runs a DB-free unit gate, so a migration that has not
6
+ // been applied anywhere is otherwise asserted by nothing at all.
7
+ //
8
+ // WHY THE TRIGGERS NEED THEIR OWN GUARD. tests/promotion_sweep.mjs covers the
9
+ // JS sweep. The sweep is not the only thing that promotes. Each of the three
10
+ // migration-163 trigger functions matches a dependency edge that may be
11
+ // anchored at the task OR AT ITS GOAL (`d.from_kind = 'goal' AND d.from_id =
12
+ // t.goal_id`), and a cascade review INHERITS the reviewed task's goal
13
+ // (cascade.js property 3). So a goal-level edge promotes a cascade row that has
14
+ // no dependencies of its own. Fixing only the sweep would have left that path
15
+ // open and made the defect come back intermittently — which is worse than not
16
+ // fixing it, because an intermittent drain is much harder to see than a
17
+ // constant one.
18
+ //
19
+ // What this proves and where the proof stops: that the SQL in the migration
20
+ // file carries the guard, and that no later migration has silently redefined
21
+ // these functions without it. It does not prove the migration applied. That
22
+ // needs Postgres and is called out in the ship notes rather than papered over.
23
+ //
24
+ // Run: node tests/promotion_triggers_skip_cascade.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { readFileSync, readdirSync } from 'node:fs';
28
+ import { dirname, join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { makeRunner } from './helpers.mjs';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const repoRoot = join(__dirname, '..');
34
+ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
35
+
36
+ const MIG = 'core_250_cascade_requests_do_not_auto_promote.sql';
37
+
38
+ // The three promotion triggers, by the state transition each reacts to.
39
+ const TRIGGERS = [
40
+ 'promote_dependents_on_ship',
41
+ 'promote_dependents_on_criterion_satisfy',
42
+ 'promote_dependents_on_goal_achieve',
43
+ ];
44
+
45
+ // Comments carry the rationale; strip them before asserting on the executable
46
+ // body so prose can never stand in for the real SQL.
47
+ const body = read('migrations', MIG).replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
48
+
49
+ // The body of one function, so a guard present in ONE cannot satisfy an
50
+ // assertion about another — the defect this whole task is about was a guard
51
+ // that existed in one place and not the other.
52
+ function fnBody(name) {
53
+ const start = body.indexOf(`CREATE OR REPLACE FUNCTION ${name}()`);
54
+ assert.notEqual(start, -1, `${MIG} does not redefine ${name}`);
55
+ const after = body.slice(start + 1);
56
+ const next = after.indexOf('CREATE OR REPLACE FUNCTION');
57
+ return next === -1 ? after : after.slice(0, next);
58
+ }
59
+
60
+ const { test, summary } = makeRunner();
61
+
62
+ await test('core_250 redefines all three promotion triggers', () => {
63
+ for (const name of TRIGGERS) {
64
+ assert.match(body, new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`), `${name} is redefined`);
65
+ }
66
+ assert.doesNotMatch(body, /DROP\s+FUNCTION/i, 'CREATE OR REPLACE only — a DROP would take the triggers with it');
67
+ });
68
+
69
+ await test('each trigger refuses to promote a cascade row', () => {
70
+ for (const name of TRIGGERS) {
71
+ assert.match(fnBody(name), /t\.source IS DISTINCT FROM 'cascade'/,
72
+ `${name} promotes cascade requests — an artist review reached by a goal-level edge is still `
73
+ + 'swept out of the studio, and the sweep-only fix does not cover it');
74
+ }
75
+ });
76
+
77
+ await test('the guard is IS DISTINCT FROM, never <>', () => {
78
+ // Narrow on purpose, and not a style note. tasks.source is nullable, so
79
+ // `NULL <> 'cascade'` is NULL and the <> spelling stops promoting every task
80
+ // with no source at all — a much larger and much quieter bug than the one
81
+ // being fixed here.
82
+ for (const name of TRIGGERS) {
83
+ assert.doesNotMatch(fnBody(name), /t\.source\s*<>\s*'cascade'/,
84
+ `${name} uses <>, which withholds every sourceless task instead of every cascade one`);
85
+ }
86
+ });
87
+
88
+ await test('nothing else was weakened while adding the guard', () => {
89
+ // core_250 restates whole function bodies, so it inherits only what it
90
+ // restates — the exact mechanism by which the open-blocker clause was lost
91
+ // twice (see tests/task_unblocked_open_blocker.mjs). Assert the guards that
92
+ // were already there survived the retype.
93
+ for (const name of TRIGGERS) {
94
+ const fn = fnBody(name);
95
+ assert.match(fn, /t\.status = 'backlog'/, `${name} still promotes only from backlog`);
96
+ assert.match(fn, /t\.kind <> 'spike'/, `${name} still skips spikes`);
97
+ assert.match(fn, /task_is_fully_unblocked\(t\.id\)/, `${name} still re-checks the shared predicate`);
98
+ assert.match(fn, /status = 'ready'/, `${name} still promotes to ready`);
99
+ }
100
+ });
101
+
102
+ await test('core_250 is the LAST definition of each trigger in migrations/', () => {
103
+ // The drift tripwire, and the assertion that would have caught migration 177
104
+ // on the day it landed. A later whole-body redefinition inherits only what it
105
+ // restates, so this fails the moment someone redefines one of these functions
106
+ // without carrying the cascade guard forward.
107
+ const files = readdirSync(join(repoRoot, 'migrations')).filter((f) => f.endsWith('.sql')).sort();
108
+ for (const name of TRIGGERS) {
109
+ const defines = new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`);
110
+ const defs = files.filter((f) => defines.test(read('migrations', f)));
111
+ assert.ok(defs.includes(MIG), `${MIG} defines ${name}`);
112
+ assert.equal(defs[defs.length - 1], MIG,
113
+ `a later migration redefines ${name} and must restate the cascade guard: ${defs.join(', ')}`);
114
+ }
115
+ });
116
+
117
+ summary('promotion-trigger cascade guard');
@@ -113,10 +113,22 @@ await test('every OTHER gate still spells "open" the same way', () => {
113
113
  // predicate is fixed that guard is belt-and-braces — and it stays, because a
114
114
  // sweep that mass-promotes must not inherit a single point of failure from a
115
115
  // predicate that has already been reverted twice.
116
+ // Asserted as the TWO things this test names, not as one verbatim copy of the
117
+ // whole clause. The literal pin that used to stand here broke on task
118
+ // 1004077, which ADDED a guard (`t.source IS DISTINCT FROM 'cascade'`) — a
119
+ // strictly stronger predicate that this test then reported as a regression.
120
+ // A pin that reds when the clause gets SAFER is testing the string, not the
121
+ // rule. Both halves are still asserted independently, so neither can be
122
+ // dropped quietly.
123
+ const sweepClause = (code('modules', 'lifecycle', 'db-tasks.js')
124
+ .match(/PROMOTABLE_BACKLOG_WHERE = `([^`]*)`/) || [])[1];
125
+ assert.ok(sweepClause, 'PROMOTABLE_BACKLOG_WHERE is no longer a single template literal');
126
+ assert.match(sweepClause, /task_is_fully_unblocked\(t\.id\)/,
127
+ 'the sweep still calls the shared predicate');
116
128
  assert.match(
117
- code('modules', 'lifecycle', 'db-tasks.js'),
118
- /PROMOTABLE_BACKLOG_WHERE = ` WHERE t\.status = 'backlog' AND task_is_fully_unblocked\(t\.id\) AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id WHERE tb\.task_id = t\.id AND b\.status = 'open'\)`/,
119
- 'the sweep still calls the predicate AND keeps its own open-blocker guard'
129
+ sweepClause,
130
+ /NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id WHERE tb\.task_id = t\.id AND b\.status = 'open'\)/,
131
+ 'and keeps its OWN open-blocker guard rather than inheriting the predicate as a single point of failure'
120
132
  );
121
133
  });
122
134