@bongos/core 1.19.921 → 1.19.922

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.921",
6
- "core_contract": "1.19.921",
7
- "source_commit": "b57a8de976b2d06f0400384b4c8bd5d1f0d69508",
5
+ "core_version": "1.19.922",
6
+ "core_contract": "1.19.922",
7
+ "source_commit": "b57f79a73cefa42840ff10d77aab958af1fe4ed4",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-20T20:00:16.702Z",
9
+ "built_at": "2026-09-20T20:40:52.111Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 509,
12
+ "docs_redacted": 510,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2305,
14
+ "functional_verbatim": 2309,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2840,
20
- "tree_sha256": "9ea93825644aa4fadb711161dbfbf72e4a032f44e888e27c23fbf66d1a695b32",
19
+ "file_count": 2845,
20
+ "tree_sha256": "36516d370c40955c53b7b0b70845d49e03d34aaecf7d5e58a54bf78ed454f694",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2094,10 +2094,15 @@
2094
2094
  "mode": "0000644",
2095
2095
  "sha256": "e582b4ece7a5b0acf4392d8b3d4942d55b52dc487d7c6a7c3a159203485ec633"
2096
2096
  },
2097
+ {
2098
+ "path": "docs/adr/0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md",
2099
+ "mode": "0000644",
2100
+ "sha256": "3a743808de440eebd5fcdd74a4c88e41acc82cc02ce5632cb392b2aa0c05b48e"
2101
+ },
2097
2102
  {
2098
2103
  "path": "docs/adr/README.md",
2099
2104
  "mode": "0000644",
2100
- "sha256": "7f11cc3e7cf17031aae449f13724fe1c42350516e5294732b6db1eb6e3917673"
2105
+ "sha256": "caece332e048f6ee15517740255c4d715efc025c82756ac095ccde7814b60daa"
2101
2106
  },
2102
2107
  {
2103
2108
  "path": "docs/api-reference.md",
@@ -2667,7 +2672,7 @@
2667
2672
  {
2668
2673
  "path": "docs/module-api-changelog.md",
2669
2674
  "mode": "0000644",
2670
- "sha256": "5693553f86384a3e66f0e6ba77ba1e6f83130a440d0a1f17179173b98696a026"
2675
+ "sha256": "d657d8b2fc7eb2bb4379a22a41d95b835ac655848626120a2218ab7603df5313"
2671
2676
  },
2672
2677
  {
2673
2678
  "path": "docs/modules-contract.md",
@@ -4229,15 +4234,20 @@
4229
4234
  "mode": "0000644",
4230
4235
  "sha256": "736eed2c89863d6eb28072627a40ac4f8945832cfb7b1d1aba10e6cc65483901"
4231
4236
  },
4237
+ {
4238
+ "path": "modules/economy/idea-brightness.js",
4239
+ "mode": "0000644",
4240
+ "sha256": "745da28c27ebcd50b349c3fe027f7e3f14888f9e012e0c9443656de249444ba1"
4241
+ },
4232
4242
  {
4233
4243
  "path": "modules/economy/idea-size-historical.js",
4234
4244
  "mode": "0000644",
4235
- "sha256": "f99f62eb4eaeda10b32284dbe27f87e64d5b6b82c9eb4aa442057d0de13be761"
4245
+ "sha256": "215eefac80aaf3624a8ff5d5284f29388a2e1b492424dd152b3d9bbe074e3a01"
4236
4246
  },
4237
4247
  {
4238
4248
  "path": "modules/economy/idea-size.js",
4239
4249
  "mode": "0000644",
4240
- "sha256": "d1fa5fda6b34bd2a8cf8d563c780bc4b12f1cdfb955e13f51799a589609ac1c0"
4250
+ "sha256": "8818bea53d2ce90fe0bc98b53bfed1af11c4eeb541942b96c4cf50a162791634"
4241
4251
  },
4242
4252
  {
4243
4253
  "path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
@@ -5522,7 +5532,7 @@
5522
5532
  {
5523
5533
  "path": "modules/lifecycle/cascade.js",
5524
5534
  "mode": "0000644",
5525
- "sha256": "fd8aeec221eede847aa3b0047c01721ac7e2076e95675a71a117eff3bf3bbc55"
5535
+ "sha256": "176f7463d6f3c7bf2297760d0934f88528ee180dfb0c9e49a4347a449af2820d"
5526
5536
  },
5527
5537
  {
5528
5538
  "path": "modules/lifecycle/check-rollup.js",
@@ -5764,6 +5774,11 @@
5764
5774
  "mode": "0000644",
5765
5775
  "sha256": "e98efbb1537f0d99adb6b6b66c1fdccfd4bbac16b97362d479af903eb9de492c"
5766
5776
  },
5777
+ {
5778
+ "path": "modules/lifecycle/migrations/lifecycle_010_removal_targets.sql",
5779
+ "mode": "0000644",
5780
+ "sha256": "e3e29e791eeb94ab1380ae2e2a13eafaea413faf0d0324a4e2007ab2b3002fca"
5781
+ },
5767
5782
  {
5768
5783
  "path": "modules/lifecycle/mingle.js",
5769
5784
  "mode": "0000644",
@@ -5772,7 +5787,7 @@
5772
5787
  {
5773
5788
  "path": "modules/lifecycle/module.json",
5774
5789
  "mode": "0000644",
5775
- "sha256": "01f96d398408491bc4f1d9357c23c259f16e52184569849a0caf775f5b38836b"
5790
+ "sha256": "36a03e5d8a4fc1129439356ba6daaf284d2cd2ca3d98d35f990e3215ff11198a"
5776
5791
  },
5777
5792
  {
5778
5793
  "path": "modules/lifecycle/pg-constraint-fail.js",
@@ -5804,6 +5819,11 @@
5804
5819
  "mode": "0000644",
5805
5820
  "sha256": "8270ce55255998a4f0bbf574a54df0ba2597013c727b4afb1ce2abad9c9d4d2a"
5806
5821
  },
5822
+ {
5823
+ "path": "modules/lifecycle/removal-targets.js",
5824
+ "mode": "0000644",
5825
+ "sha256": "e80304e7b43fe86852763e2f859bc05c1b88bb50a3eea407342ae6d46b8f9eda"
5826
+ },
5807
5827
  {
5808
5828
  "path": "modules/lifecycle/rot.js",
5809
5829
  "mode": "0000644",
@@ -5872,7 +5892,7 @@
5872
5892
  {
5873
5893
  "path": "modules/lifecycle/routes/lifecycle.js",
5874
5894
  "mode": "0000644",
5875
- "sha256": "f4b080286870b6cd52cd59c5cb7c5a5127c3b0a90df50b7d87495d684767203b"
5895
+ "sha256": "330e42fad79812a05a90256fe31931df2156093b80a648d7c838ecac136c91e4"
5876
5896
  },
5877
5897
  {
5878
5898
  "path": "modules/lifecycle/routes/mingle.js",
@@ -5892,7 +5912,7 @@
5892
5912
  {
5893
5913
  "path": "modules/lifecycle/routes/removal-proposals.js",
5894
5914
  "mode": "0000644",
5895
- "sha256": "329f130d3556dc05981ce6b4b6ce4d55984c6ca42fd0f421fd70cba63eb277ac"
5915
+ "sha256": "e0a0e14a8818c011334e8f61a50c8dd7ad11b4387acfd6ab9e18e570f54ec6c4"
5896
5916
  },
5897
5917
  {
5898
5918
  "path": "modules/lifecycle/routes/rot.js",
@@ -7882,12 +7902,12 @@
7882
7902
  {
7883
7903
  "path": "package-lock.json",
7884
7904
  "mode": "0000644",
7885
- "sha256": "90c995ff72567f0389795d8aabe1e76d05d0ac47b604081c0f03725a0a66737a"
7905
+ "sha256": "0169c3fafa67ebf63c9ae35566e8104bcf210ae80973c8e1837874109fe85354"
7886
7906
  },
7887
7907
  {
7888
7908
  "path": "package.json",
7889
7909
  "mode": "0000644",
7890
- "sha256": "f406c6c8801548af676175664eb13a14e0f80ee58a8106eca0ed075baab77136"
7910
+ "sha256": "b62dd828c8e8b167dfc4bca7d54fdec8c76b13f89a44e61365d48962abf77ba9"
7891
7911
  },
7892
7912
  {
7893
7913
  "path": "public-docs/index.html",
@@ -8832,7 +8852,7 @@
8832
8852
  {
8833
8853
  "path": "scripts/gds/provision-core-upgrade.js",
8834
8854
  "mode": "0000644",
8835
- "sha256": "5fad0beb0a3d4b322918a99765cb922fcce1de4196c19cb08a0d3bc91c3d149e"
8855
+ "sha256": "1fd6e38184bb0943fdc22e314b9e6cad0b5ebeae7f7370992adbd3b4f2ad5a9e"
8836
8856
  },
8837
8857
  {
8838
8858
  "path": "scripts/gds/provision-disconnect.js",
@@ -9887,7 +9907,7 @@
9887
9907
  {
9888
9908
  "path": "src/module-api.js",
9889
9909
  "mode": "0000644",
9890
- "sha256": "6e48d3680bf84d97e45001deee8c64b2a5a31eed9ce9b68515b13d4a42086203"
9910
+ "sha256": "86c69951f0059e5374245ef798c5cd24cd82d836a4ccf706e80f186de986b473"
9891
9911
  },
9892
9912
  {
9893
9913
  "path": "src/module-loader/catalog.js",
@@ -10802,7 +10822,7 @@
10802
10822
  {
10803
10823
  "path": "tests/core_upgrade_runner.mjs",
10804
10824
  "mode": "0000644",
10805
- "sha256": "a8fc77b7a32e4377b4b327bd485cbc17428421ef3fc871770fdaeee3e02e48f4"
10825
+ "sha256": "5d0b4fa1ec48d40ebc801902b5279475533ae2dc8819e587bb95bc17783cba06"
10806
10826
  },
10807
10827
  {
10808
10828
  "path": "tests/cosmos_token_contrast.mjs",
@@ -11894,6 +11914,11 @@
11894
11914
  "mode": "0000644",
11895
11915
  "sha256": "0d8f07c64f4d17c8c894b2d6395281882efd4e186c5714fe9aab61e2808d4314"
11896
11916
  },
11917
+ {
11918
+ "path": "tests/idea_brightness.mjs",
11919
+ "mode": "0000644",
11920
+ "sha256": "d67d60b1236ccedd01591356f5a5a9a72e4f1efd895ddf5af00c744219a2b3b6"
11921
+ },
11897
11922
  {
11898
11923
  "path": "tests/idea_capture_grades.mjs",
11899
11924
  "mode": "0000644",
@@ -0,0 +1,163 @@
1
+ # ADR 0321 — Brightness is a payout-weighted share, and a removal's targets are rows
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-20
5
+ - **Task:** [task 1004099](https://cloudbongos.com/builders#/task/1004099) (BV2.R16, goal 1000095 — BONGOS-V2)
6
+ - **Spec:** `docs/specs/<redacted>.md` decisions 4 and 20
7
+ - **Under:** [ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md), [ADR 0319](0319-a-removal-verdict-is-a-row-beside-the-question.md)
8
+
9
+ ## Context
10
+
11
+ An idea's **size** is its total ideator payout from its descent and never falls
12
+ ([ADR 0312](0312-provenance-is-a-second-ideas-owned-edge-table.md) and
13
+ `modules/economy/idea-size.js`). **Brightness** is the other channel: how much of that
14
+ work is still alive. The pair is what lets a thought whose version the project later tore
15
+ out draw *large and dark* — visibly different from a thought that never caused anything
16
+ and draws *small and dark*. One number cannot express both, which is why there are two.
17
+
18
+ Two things had to be settled before it could be computed.
19
+
20
+ **First, what a share is a share OF.** The spec says graded, not binary (decision 20): a
21
+ descent of five things with three alive is partly lit, and goes out only when nothing
22
+ survives.
23
+
24
+ **Second, and the real blocker: the removal facts are stored in the wrong direction.**
25
+ ADR 0319 gave a confirmed removal a row — `lifecycle_removal_verdicts`, keyed on the
26
+ proposal, carrying the verdict and `ship_task_id`. It also said, in the migration and in
27
+ its own text, that the list of tasks whose work was removed was deliberately absent, and
28
+ that *"brightness designs its own storage for targets when it needs one."* That deferral
29
+ is now due.
30
+
31
+ The gap is not cosmetic. `ship_task_id` is the task whose merge **deleted** the files.
32
+ Brightness needs the previously-shipped tasks that **stopped counting**. Those are
33
+ different rows, almost always under different ideas — a later, unrelated piece of work
34
+ tears out an earlier one. Reading the ship as the removed task dims the wrong star, and
35
+ dims it for the wrong idea, while looking like a working query.
36
+
37
+ The attribution itself already exists. `modules/lifecycle/removal-derivation.js` blames
38
+ each deleted path back to the task that introduced it, and the cascade renders the result
39
+ as prose in the proposal's body. `removal-proposals.js` refuses to parse that prose back
40
+ and is right to: *"a body is written for a person to read, and a reader that silently
41
+ depends on its line shape breaks the day somebody improves the wording."*
42
+
43
+ ## Decision
44
+
45
+ ### D1 — The targets are rows, written at proposal time
46
+
47
+ `modules/lifecycle/migrations/lifecycle_010_removal_targets.sql` adds
48
+ `lifecycle_removal_targets (proposal_task_id, removed_task_id, paths)`, written by
49
+ `recordRemovalTargets` from the same `ctx.removals` objects the proposal body renders.
50
+ **One derivation, two renderings** — the prose and the rows cannot disagree, and
51
+ `removal-proposals.js` keeps its refusal intact because something else now carries the
52
+ fact.
53
+
54
+ It must be written at **proposal** time rather than at verdict time. The attribution comes
55
+ from GitHub commit-history reads, capped at eight paths per ship
56
+ (`MAX_ATTRIBUTED_PATHS`) and running on the server's shared push credential — the same
57
+ credential the publish path depends on. Re-deriving when a person clicks Confirm, days
58
+ later, would spend that budget again against a history that has moved on.
59
+
60
+ This is **not** the cascade runner fanning out, which [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md)
61
+ forbids: the removal rule still files exactly one task. This records a side fact *about*
62
+ that one task — the same shape as the verdict row its answer writes.
63
+
64
+ `ON CONFLICT DO NOTHING`, never `DO UPDATE`. A second walk can legitimately find *fewer*
65
+ paths, because the cap and the rate limit make an exhausted page yield no attribution at
66
+ all; overwriting would let a degraded re-read erase a good first one. For the same
67
+ reason the writer runs on the cascade's `skipped` row as well as its `created` one, so a
68
+ re-ship **repairs** an attribution that a derivation outage missed.
69
+
70
+ ### D2 — Brightness is weighted by payout, not by task count
71
+
72
+ The share is `live / credits`, where each descent task is weighted by what it actually
73
+ earned the idea.
74
+
75
+ Counting tasks instead would make deleting a one-line fix dim a star as hard as deleting
76
+ the feature it belongs to. Weighting by payout makes brightness fall in proportion to how
77
+ much of the idea's *worth* died — and since size is drawn from the same quantity, the two
78
+ channels become a share of one number rather than two unrelated measurements sitting side
79
+ by side. It also makes "large and dead" exact: full size, zero brightness.
80
+
81
+ The basis is **paid + estimated**, matching size. A historical idea's descent was never
82
+ paid for (`idea-size-historical.js` prices those hops); counting only banked credits would
83
+ mean removing an estimated hop dims nothing while the size drawn beside it still counts
84
+ that hop. To support this, `priceDescentForIdea` and `ideaSizes` now return a per-task
85
+ `byTask` breakdown that **sums exactly to `credits`** — assembled from the priced descent
86
+ plus any ledger payment for a task the descent no longer reaches, because `idea-size.js`
87
+ deliberately keeps those in `credits` so size cannot fall, and dropping them here would
88
+ make the parts sum to less than the whole and silently overstate brightness.
89
+
90
+ ### D3 — An idea that earned nothing is LIT, not dark
91
+
92
+ Zero over zero is not zero. A freshly-filed thought has had nothing removed from it, so
93
+ `share: 1` with `hasWork: false`. Returning 0 would make it pixel-identical to the thought
94
+ whose version was torn out — which is precisely the distinction BV2.R19's proof exists to
95
+ check, and would quietly defeat the reason size and brightness are two channels.
96
+ `hasWork` is what lets the five states (BV2.R17) tell a protostar from a remnant without
97
+ re-deriving it.
98
+
99
+ ### D4 — "Could not look" is not "nothing was removed"
100
+
101
+ When the removal facts cannot be read — the lifecycle module is off, the port is
102
+ unregistered — `share` is `null` and `attributable` is `false`, never a confident 1. Same
103
+ posture `idea-size.js` already takes with `historical: null` when the descent walk is
104
+ unavailable: an established absence and an unreadable one are different answers, and only
105
+ one of them may be drawn as a plain full-brightness star.
106
+
107
+ A **confirmed removal that names no target** is the residual case D1 cannot close: it is
108
+ genuinely gone, and brightness does not know which idea to dim. It happens for proposals
109
+ filed before `lifecycle_010`, for attributions where every deletion predates
110
+ `claims.published_head_sha`, and for a derivation outage. Guessing in either direction is
111
+ worse than not dimming — so nothing dims, and `countUnattributedConfirmed()` exists so a
112
+ surface can say *"the sky may read brighter than the truth by up to N confirmed
113
+ removals"* instead of being silently optimistic.
114
+
115
+ ### D5 — Brightness lives in `economy`, behind a port
116
+
117
+ It is a share of the same payouts size is built from, so it sits beside size in
118
+ `modules/economy/idea-brightness.js`; putting it in `lifecycle` would mean that module
119
+ doing money arithmetic. That makes the removal facts a cross-module read, so they come
120
+ through a kernel-mediated port — `lifecycle.removals` — and never a `require`
121
+ ([ADR 0083](<redacted>.md) rule 1), exactly as economy already
122
+ reaches `ideas.descent`.
123
+
124
+ Economy resolves it with `resolveOptional` and **does not declare it in `consumes`**.
125
+ `consumes` means *required*, and `verifyEnabledPorts` refuses to boot when an enabled
126
+ module consumes an unprovided port — declaring it would turn "lifecycle off" into
127
+ "instance will not start". The same non-declaration, for the same reason, as
128
+ `ideas.descent`.
129
+
130
+ ## Consequences
131
+
132
+ - Brightness is computable per idea and falls only when a **person** confirms a removal.
133
+ A dismissed or unanswered proposal dims nothing, which is what stops refactors, renames
134
+ and moves from dimming the sky continuously — ADR 0313's central finding.
135
+ - **No credit is ever clawed back.** `modules/economy/idea-brightness.js` contains no
136
+ writer and is unreachable from any ship path. `shipped` stays terminal, the ledger is
137
+ never rewritten, and size does not move. Only the picture changes.
138
+ - `ideaSizes` grew a field. Callers that spread its result now carry a `Map`; nothing
139
+ serialises it to JSON today, and a surface that wants to must project it rather than
140
+ pass it through.
141
+ - BV2.R17 (the five states) can resolve protostar-vs-remnant from `hasWork` and `share`
142
+ without re-deriving either, and BV2.R18 (the sky) has `removedTasks` to explain *why* a
143
+ star dimmed rather than only that it did.
144
+
145
+ ## Rejected
146
+
147
+ - **Parsing the proposal body.** The attribution is right there as
148
+ ` - task <id> — <title>`. It is also documentation prose that a copy edit may reword at
149
+ any time, and ADR 0319 already refused this for that reason. A reader that depends on a
150
+ human-facing line shape fails silently and dims nothing.
151
+ - **Re-deriving targets at verdict time.** Spends the capped GitHub budget a second time,
152
+ against a history that has moved, on the interactive path where a person is waiting.
153
+ - **Treating `ship_task_id` as the removed task.** The wrong direction; dims the ship's
154
+ own idea instead of the one whose work died. Named here because the column is right
155
+ there on the verdict row and is the obvious thing to reach for.
156
+ - **Counting tasks rather than payouts.** Simpler, and makes a one-line fix weigh the same
157
+ as a feature.
158
+ - **Clamping the share at both ends.** The bottom is clamped so an anomaly renders as a
159
+ legible "nothing survives" rather than a negative; the top is not, and `live` / `removed`
160
+ are reported unclamped, so an impossible figure stays visible instead of being hidden by
161
+ the thing that claims to prevent it — `idea-size.js`'s argument against clamping size.
162
+ - **Storing the removed task's title beside the row.** It lives on `tasks` and can be
163
+ joined for; a copy goes stale the first time a title is edited.
@@ -412,3 +412,4 @@ This keeps the decision history honest and traceable.
412
412
  | 0320 | [**An empty diff is not an ungraded diff: grade the claim instead** ([task 1004011](https://cloudbongos.com/builders#/task/1004011), goal 1000118 — BONGOS-V2; scoped out of task 1003950 shape 2). `ship.js --allow-empty` closes a task that produced no committed artifact, and it used to do two things at once: set `skipGrade`, so the subagent grader never ran, and therefore route the close through `POST /tasks/:id/confirm`, gated on `task.confirm.grade_bypass` — one of [ADR 0157](0157-archon-is-rank-and-identity-only.md)'s four trust-boundary keys, so a Metic could not close an artifact-free task at all. **Both halves were wrong.** The permission was the wrong question: `grade_bypass` means *ratify past a grade that FAILED*, a genuine override of an existing verdict, and a no-artifact close has no verdict to override — charging the same key for "it was judged and rejected" and "there was nothing to judge" conflates an override with an absence. And nothing was being checked, which is the more serious half: the assertions in the handoff notes ("the premise was already fixed", "this was a pure ops verification", "the row was updated directly") went into the ledger unread, on exactly the ships where the record is the ONLY evidence that exists. A permission gate is not scrutiny — an Archon clicking confirm is not someone checking whether the claim is true. **Decision:** on `--allow-empty` the grader is not skipped, it is RE-POINTED — it grades the notes + value summary against the task description, asking whether the task is genuinely artifact-free and whether the evidence supports that. A pass is a real passing grade confirming through the ordinary rank-open path; a fail is answered with better evidence, not a key. Three sub-decisions carry it: the rubric axes are **re-asked, not replaced** (`functional_fidelity` → is this genuinely artifact-free, `code_quality` → is the evidence specific and checkable), because the gate arithmetic and `/grade-audit`, the aggregates and the cost model all read those columns and a third pair of names would mean teaching every one of them a synonym; **two generalist shots, not the four-lens panel** (Hacker/Quality/Efficiency are lenses over code — over three paragraphs of prose they are four readings of the same short text at four times the cost — so the lenses go, recorded in `signals.lenses_dropped` per the #513 trivial-diff precedent; but the panel's UNANIMOUS gate was also the cross-check that makes injection hard, and the first cut kept a single shot and lost it, so the species asks the same question twice and collapses by **unanimity, not median** — under a median the mean of a fooled 10 and a suspicious 3 is a passing 6.5 and the second reading launders the first; a split is recorded as `signals.shot_dissent`; the collapse covers ALL THREE gate axes including `confidence`, which the second grading round caught missing — it is the one axis living in `signals` rather than `rubric_scores`, and the server derives `lowConfidence` purely from the submitted value, so an honest 0.2 "I cannot judge this" averaged with a confident 0.9 would have cleared the 0.4 gate at 0.55); and the species marker lives in **`signals.no_artifact_grade`, not `grader_kind`**, because the server's `validateAndScore` hardcodes `task_grades.grader_kind` to `'subagent'` for everything on that route. **D4: every builder-controlled field is scrubbed and fenced** (`grader-workers/fence.js`, SR-15 / #995) — the panel workers have fenced the diff, title, description and value summary since #995, and this species is the worse case on both sides: the notes are not context around an artifact the model can independently check, they ARE the evidence, and a successful injection does not flip one of four workers, it closes a task past the very scrutiny this ADR added to replace the Archon-only gate. The first implementation shipped it unfenced and the grader caught it as a blocker. The no-follow rule is restated AFTER the artifact too, since the notes may run to 24,000 characters and a rule stated once at the top is a long way behind by the end of an adversarial payload. The prompt renders no changed-files or diff section at all, so the absence cannot read as the finding. No diff hash is stored — hashing the empty diff makes it a reuse key shared by every empty-diff ship of the task, handing the next close a pass this one earned. Unchanged: `--skip-grade` still reaches the Archon gate (the task 1003950 escalation split still chooses the refusal), and below the protected-path floor the server's Guard 1b (task 1002646) still fails a zero-`committed_files` pass closed, correctly — this moves the Metic-and-above case, which is the case that was filed. **Drops task 1004010**, the alternative shape — a new lower-floored permission atom for the same close — by owner decision of 2026-09-20: it answers "who may skip the check" when the better question is "why is there no check", and it permanently widens the permission surface, the same class of change the owner declined three days earlier for `version.create` ([task 1003853](https://cloudbongos.com/builders#/task/1003853), [ADR 0250](0250-strict-versioning-the-version-boundary-is-the-scope-gate.md) §6a). Also rejected: lowering the floor on the existing key (treats the gate as the point; nothing would read the notes); grading the notes with the full panel; a new pair of rubric axes for prose (honest about the measurement, but moves the gate arithmetic and every consumer of it for a naming gain).](0320-an-empty-diff-is-not-an-ungraded-diff.md) | grading / permissions |
413
413
  | 0319 | [**A removal verdict is a row beside the question, because both answers close it the same way** ([task 1004095](https://cloudbongos.com/builders#/task/1004095), goal 1000095 — BONGOS-V2). [ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md) settled that a ship deleting an earlier shipped task’s files files a QUESTION and a person answers it; task 1004090 built the question and nothing could answer it. Building the answer surfaced the property that decides the design: a proposal is a question, not work, so CONFIRMING and DISMISSING both leave nothing to build and both end the task at `abandoned` — meaning a status alone records neither, and brightness could not tell the star it must dim from the one it must not. ADR 0313 had already rejected the obvious alternative (a `removed` status; `shipped` is terminal and much of the ledger leans on it — a removal is a fact recorded BESIDE work, not a new terminal state). Decision: `lifecycle_removal_verdicts` (migration `lifecycle_009`), PRIMARY KEY on `proposal_task_id` so one proposal holds one answer ever — a second decide is REFUSED (409), never upserted and never an idempotent 200, because overwriting destroys the record that a person answered differently; the row is locked `FOR UPDATE` first, so simultaneous Confirm and Dismiss cannot both read “undecided”, and `ALREADY_DECIDED` is checked BEFORE `PROPOSAL_CLOSED` because an answered proposal is also abandoned and the useful message is the one naming the earlier verdict. The verdict and the status change commit in ONE transaction. What this does NOT ship is the target list: the affected tasks and paths stay PROSE in the proposal body (the owner’s 2026-09-20 decision, since [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) forbids widening the runner to fan out), and nothing here parses it — brightness designs that storage. What brightness does get is structured: the verdict plus `ship_task_id`, denormalised out of the `source_ref` that encodes it as a string nothing can join on. Surface: spec decision 15’s “existing awaiting-nod surface” is the CARD, not the QUEUE — [ADR 0299](0299-the-nod-queue-stays-scoped-to-routed-filings.md) keeps that queue scoped to routed filings and a removal proposal has no idea lineage at all, so it gets its own slice on the same card at the same floor. The PERMISSION is reused (`task.promote`, Metic+, read and write both) and the VERB is not: no existing route means “that work is genuinely gone”, and `/tasks/:id/abandon` would close the question without recording which answer was given. Top-level `/removal-proposals` rather than `/tasks/removal-proposals`, because the manifest mounts `tasks` first and its `/tasks/:id` swallows a later file’s literal segment. Rot is INHERITED: `backlog` is rottable and `abandoned` is not, so an unanswered proposal rots and an answered one leaves the feed with no code written for either — pinned against the rot module’s own exported vocabulary. Rejected: the status as the record; parsing the body at confirm time; widening the nod queue; separate `/confirm` and `/dismiss` routes; upserting a second verdict; a `LIKE ‘prefix%’` match (a rule key may contain an underscore, which is a LIKE wildcard).](0319-a-removal-verdict-is-a-row-beside-the-question.md) | ideas / lifecycle |
414
414
  | 0318 | [**Listing a file for task affinity is not claiming to own it** ([task 1004110](https://cloudbongos.com/builders#/task/1004110), goal 1000095 — BONGOS-V2). The case [ADR 0314](0314-module-protection-is-per-path-not-per-module.md) named and left open, closed by neither of the two routes it predicted. `builder-settings` derived `protected` — Archon-only to scope a goal to — on exactly one glob, `src/bongos/routes/me.js`, caught by the blanket `src/bongos/routes/` prefix. **That glob was never an ownership claim:** `MODULE_GLOBS` listed it for task AFFINITY and said in the same comment that the `GET /me` aggregator stays a thin core facade, and `isProtected()` cannot tell the two apart. **Both predicted routes were measured and rejected.** Narrowing the blanket prefix: all eighteen files under `src/bongos/routes/` are protected by that prefix AND NOTHING ELSE, so narrowing unwalls `auth.js`, `builders.js`, `backup.js` and `core-update.js` together — now pinned as a tripwire test rather than left as a caution. Moving `me.js` into `modules/builder-settings/`: the aggregator resolves TEN module ports (`lifecycle`, `reward`, `onboarding`, `government`, `specialities`, `grade`, `box`, `art` ×2, `discord`) and `builder-settings` is one of them, so hosting it inside one module means resolving nine foreign ports from the inside — the `core -> module` edge [ADR 0083](0083-modular-architecture-strangler-build-plan.md) rule 1 forbids. Decision: **ownership follows what the file is** — `me.js` moves to `kernel`, which already owns `src/bongos/routes/security.js`. This is 0314 one step on: owning the command is not owning the authority, and **being listed next to a module is not owning it either**. **Rejected: a general affinity tier** in `MODULE_GLOBS` mirroring `isCommandPath` — the audit does not support it. Three other modules (`kernel`, `devsecops`, `methodology`) are also protected only by paths outside their own directory, but in each the foreign path genuinely IS the domain; `builder-settings` was the only module protected by a path its own source called core, so a new tier would be a new place for a hole to hide, built for a population of one. `protectedModules()` goes 9 -> 8. **The wall that matters does not move:** a sub-Metic builder touching `/me` is still refused by `checkPermissionPaths`, asserted directly rather than assumed. The sixteen still-unowned route files remain [task 1004116](https://cloudbongos.com/builders#/task/1004116), whose option (a) this de-risks.](0318-listing-a-file-for-task-affinity-is-not-owning-it.md) | permissions / module scope |
415
+ | 0321 | [**Brightness is a payout-weighted share, and a removal's targets are rows** ([task 1004099](https://cloudbongos.com/builders#/task/1004099), goal 1000095 — BONGOS-V2). Size is an idea's total ideator payout and never falls; brightness is how much of that work is still alive, and the pair is what lets a thought whose version was torn out draw LARGE AND DARK rather than merging with one that never mattered. Two things had to be settled. **The removal facts were stored in the wrong direction**: [ADR 0319](0319-a-removal-verdict-is-a-row-beside-the-question.md) gave a confirmed removal a row carrying the verdict and `ship_task_id` — the task whose merge DELETED the files — and deliberately left out the tasks whose work was removed, saying brightness would design its own storage when it needed one. Those are different rows under different ideas, so reading one as the other dims the wrong star while looking like a working query. `<redacted>` stores `(proposal_task_id, removed_task_id, paths)` at PROPOSAL time, from the SAME derivation objects the proposal body renders as prose — one derivation, two renderings, so they cannot disagree and `removal-proposals.js` keeps its refusal to parse a body back. Proposal time and not verdict time because the attribution comes from capped, rate-limited GitHub commit-history reads that only exist while the cascade runs. NOT a runner fan-out ([ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md)): the rule still files one task; this records a side fact about it. `ON CONFLICT DO NOTHING`, never `DO UPDATE`, since a degraded second walk finds FEWER paths and must not erase a good first read — and the writer runs on the cascade's SKIPPED row too, so a re-ship repairs what a derivation outage missed. **The share is weighted by PAYOUT, not by task count**, or deleting a one-line fix dims a star as hard as deleting the feature; weighting by what each hop earned also makes 'large and dead' exact, and makes the two channels a share of ONE number rather than two unrelated measurements. Basis is paid + estimated to match size, which required `priceDescentForIdea`/`ideaSizes` to return a per-task `byTask` summing EXACTLY to `credits` — including ledger payments for tasks the descent no longer reaches, which `idea-size.js` keeps in `credits` so size cannot fall and which would otherwise make the parts sum to less than the whole. Three edges, each silent: an idea that earned nothing is `share: 1, hasWork: false`, because zero over zero is not zero and a protostar is not a remnant (the BV2.R19 distinction); an unreadable removal port gives `share: null, attributable: false`, because 'could not look' is not 'nothing was removed'; and a CONFIRMED removal naming no target dims nothing at all — brightness cannot know which idea — so `countUnattributedConfirmed()` exists to say the gap out loud instead of quietly brightening the sky. Brightness lives in `economy` beside size and reaches lifecycle through the new `lifecycle.removals` port, resolved OPTIONALLY and NOT declared in `consumes` (declaring it would turn 'lifecycle off' into 'instance will not start' — the `ideas.descent` precedent). **No credit is clawed back and nothing here writes**: ADR 0313 stands, `shipped` is terminal, only the picture changes. Rejected: parsing the proposal body (ADR 0319 already refused it — a human-facing line shape fails silently when reworded); re-deriving at verdict time; treating `ship_task_id` as the removed task; counting tasks rather than payouts; clamping the share at the top, which would hide the anomaly it claims to prevent.](0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md) | ideas / economy |
@@ -2316,5 +2316,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2316
2316
  landed since 1.19.919 with no explicit bump. run 35531720944. (task 1002620)
2317
2317
  1.19.921 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2318
2318
  landed since 1.19.920 with no explicit bump. run 35534076467. (task 1002620)
2319
+ 1.19.922 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2320
+ landed since 1.19.921 with no explicit bump. run 35536293721. (task 1002620)
2319
2321
  ---------------------------------------------------------------------------
2320
2322
  ```
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/idea-brightness.js — BRIGHTNESS: the live share of an idea's
4
+ // descent (task 1004099 / BV2.R16, goal 1000095; spec
5
+ // docs/specs/bongos-v2-ideator-surface.md decisions 4 and 20; ADR 0321, under
6
+ // ADR 0313 + 0319).
7
+ //
8
+ // WHAT BRIGHTNESS IS. Size is an idea's total ideator payout from its descent and
9
+ // NEVER falls (./idea-size.js). Brightness is how much of that payout is still
10
+ // alive: the same sum, restricted to the descent tasks the project has not since
11
+ // confirmed as removed, over the whole. Two channels, two facts — and the pair is
12
+ // the entire point. A thought that caused a version the project later tore out
13
+ // draws LARGE AND DARK, which is visibly different from a thought that never
14
+ // caused anything and draws small and dark. One number could not say that.
15
+ //
16
+ // GRADED, NOT BINARY (spec decision 20). A descent of five things with three
17
+ // still alive is partly lit. It goes out only when nothing survives. So this is a
18
+ // share in [0, 1] and not a flag — an idea dims progressively as pieces of its
19
+ // work are removed.
20
+ //
21
+ // WEIGHTED BY PAYOUT, NOT BY COUNT, and that is the decision in this file.
22
+ // Counting tasks would make removing a one-line fix dim a star as much as
23
+ // removing the feature it belongs to. Weighting by what each hop actually earned
24
+ // means brightness falls in proportion to how much of the idea's WORTH died,
25
+ // which is the same quantity size is drawn from — so the two channels are a
26
+ // share of one number rather than two unrelated measurements that happen to sit
27
+ // side by side. It also makes "large and dead" exact: full size, zero brightness.
28
+ //
29
+ // THE BASIS IS PAID + ESTIMATED, the same as size. A historical idea's descent
30
+ // was never paid for (./idea-size-historical.js prices those hops), and if
31
+ // brightness counted only banked credits, removing an estimated hop would dim
32
+ // nothing while the size drawn beside it still counted that hop. The share would
33
+ // then be a fraction of a different number than the one on screen.
34
+ //
35
+ // BRIGHTNESS NEVER CLAWS BACK CREDITS, and nothing here could. This module only
36
+ // READS — no writer, no ledger row, no status change. ADR 0313 settles that
37
+ // `shipped` is terminal and the ledger is never rewritten when the project
38
+ // changes its mind: only the picture changes. Size is computed from credit_log
39
+ // and is untouched by anything in this file.
40
+ //
41
+ // WHAT COUNTS AS REMOVED IS A PERSON'S ANSWER, never a derived one. A ship that
42
+ // deletes an earlier shipped task's files files a QUESTION (the
43
+ // `removal-proposed-on-ship` cascade), someone confirms or dismisses it, and only
44
+ // 'confirmed' reaches this read. Refactors, renames and file moves all look like
45
+ // deletions to a diff, so a derived-only signal would dim the sky continuously —
46
+ // ADR 0313's central finding, and the reason `dismissed` exists at all.
47
+ //
48
+ // MODULE BOUNDARY: the removal facts live in the LIFECYCLE module and are reached
49
+ // through the kernel-mediated `lifecycle.removals` port, never a cross-module
50
+ // require (ADR 0083 rule 1) — the same way ./idea-size.js reaches `ideas.descent`.
51
+
52
+ const api = require('../../src/module-api');
53
+ const { resolveOptional } = api;
54
+ const { ideaSizes } = require('./idea-size');
55
+
56
+ // A positive integer id as it arrives off a row or a caller, where it may be a
57
+ // bigint serialised to a string. Never coerced with Number(); the guard
58
+ // ./idea-size.js uses, for the same precision reason.
59
+ function idOf(v) {
60
+ return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
61
+ }
62
+
63
+ // ideaBrightnesses — brightness for MANY ideas, keyed by idea id (string).
64
+ //
65
+ // Returns Map<ideaIdString, {
66
+ // share number in [0,1] — the live fraction of the descent's payout,
67
+ // or null when it could not be established (see below)
68
+ // live credits still alive
69
+ // removed credits whose work is confirmed gone
70
+ // credits the total the two divide (equals size's `credits`)
71
+ // removedTasks [{ taskId, credits, proposalTaskId, paths }] — WHAT went out,
72
+ // so a surface can say why a star dimmed instead of only that it
73
+ // did
74
+ // hasWork false when the idea has earned nothing at all
75
+ // attributable false when the removal facts could not be read
76
+ // }>
77
+ //
78
+ // EVERY ID ASKED FOR IS PRESENT. Absent and dark are different answers, and an
79
+ // idea that caused nothing yet is a real state the sky has to draw.
80
+ //
81
+ // AN IDEA THAT EARNED NOTHING IS `share: 1`, `hasWork: false` — LIT, NOT DARK,
82
+ // and this is the sharpest edge in the file. Zero over zero is not zero. A
83
+ // freshly-filed thought has had nothing removed from it; it is a protostar, not a
84
+ // remnant. Returning 0 would light-for-light merge it with the thought whose
85
+ // version was torn out, which is exactly the distinction BV2.R19's proof exists
86
+ // to check. `hasWork` is what lets the five states (BV2.R17) tell the two apart
87
+ // without re-deriving it.
88
+ //
89
+ // WHEN THE REMOVAL FACTS CANNOT BE READ — the lifecycle module is off, or the
90
+ // port is unregistered — `share` is NULL and `attributable` is false, rather than
91
+ // 1. "Nothing was removed" and "we could not look" are different answers and only
92
+ // one of them may be drawn as a plain full-brightness star. Same posture
93
+ // ./idea-size.js takes with `historical: null` when the descent walk is
94
+ // unavailable.
95
+ //
96
+ // ideaIds — array of ids (bigint-safe strings or numbers)
97
+ // client — optional tx client; falls back to the pool inside the reads
98
+ // descentByIdea — the caller's already-walked descent, passed straight through
99
+ // to ideaSizes so a page that has one costs no extra walk
100
+ // sizes — an already-computed ideaSizes() result, when the caller has
101
+ // one. Brightness needs the same per-task figures size does,
102
+ // so a surface drawing both should compute sizes once and hand
103
+ // them here rather than paying for the whole read twice.
104
+ async function ideaBrightnesses(ideaIds, { client = null, descentByIdea = null, sizes = null } = {}) {
105
+ const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
106
+ const out = new Map();
107
+ if (ids.length === 0) return out;
108
+
109
+ const sized = sizes || await ideaSizes(ids, { client, descentByIdea });
110
+
111
+ // The per-task breakdown. `ideaSizes` carries the totals; `byTask` is what
112
+ // lets a share be taken of them, and it is absent only when the descent could
113
+ // not be walked at all — in which case there is nothing to attribute against
114
+ // and the idea reads unattributable below.
115
+ const perTask = new Map();
116
+ for (const id of ids) {
117
+ const s = sized.get(id);
118
+ perTask.set(id, (s && s.byTask instanceof Map) ? s.byTask : null);
119
+ }
120
+
121
+ // Every descent task across every idea asked for, in ONE port call. A lookup
122
+ // per idea would be a round trip per star on a page of them.
123
+ const allTaskIds = new Set();
124
+ for (const byTask of perTask.values()) {
125
+ if (!byTask) continue;
126
+ for (const taskId of byTask.keys()) allTaskIds.add(taskId);
127
+ }
128
+
129
+ const port = resolveOptional && resolveOptional('lifecycle.removals');
130
+ let confirmed = null;
131
+ if (port && typeof port.confirmedRemovalsForTasks === 'function' && allTaskIds.size > 0) {
132
+ confirmed = await port.confirmedRemovalsForTasks([...allTaskIds], { client });
133
+ } else if (port && typeof port.confirmedRemovalsForTasks === 'function') {
134
+ // The port is there and there is simply nothing to ask about. That is an
135
+ // established answer — no removals — not an unreadable one.
136
+ confirmed = new Map();
137
+ }
138
+
139
+ for (const id of ids) {
140
+ const s = sized.get(id) || { credits: 0 };
141
+ const byTask = perTask.get(id);
142
+ const credits = Number(s.credits) || 0;
143
+
144
+ if (!confirmed || !byTask) {
145
+ out.set(id, {
146
+ share: null, live: null, removed: null, credits,
147
+ removedTasks: [], hasWork: credits > 0, attributable: false,
148
+ });
149
+ continue;
150
+ }
151
+
152
+ let removed = 0;
153
+ const removedTasks = [];
154
+ for (const [taskId, taskCredits] of byTask) {
155
+ const hit = confirmed.get(taskId);
156
+ if (!hit) continue;
157
+ const c = Number(taskCredits) || 0;
158
+ removed += c;
159
+ removedTasks.push({
160
+ taskId,
161
+ credits: c,
162
+ proposalTaskId: hit.proposalTaskId,
163
+ paths: hit.paths,
164
+ });
165
+ }
166
+
167
+ // The live half comes off the TOTAL minus what died, not off a second sum
168
+ // over the surviving tasks. The two differ: `credits` includes payouts for
169
+ // tasks the descent no longer reaches (a re-parented task, a deleted edge),
170
+ // which ./idea-size.js keeps on purpose so size cannot fall. Summing the
171
+ // survivors would quietly drop those and make a re-parenting dim a star.
172
+ const live = credits - removed;
173
+ // Clamped at the bottom only, and never at the top. `removed` is a subset of
174
+ // the same figures `credits` was built from so it cannot exceed it — but if
175
+ // it ever did, a negative brightness is a rendering bug where a zero is a
176
+ // legible "nothing survives". The anomaly is still visible in `live` and
177
+ // `removed`, which are reported unclamped.
178
+ const share = credits > 0 ? Math.max(0, live) / credits : 1;
179
+
180
+ out.set(id, {
181
+ share,
182
+ live,
183
+ removed,
184
+ credits,
185
+ // Largest loss first: a surface explaining why a star dimmed wants the
186
+ // piece that mattered, not the first one the descent happened to yield.
187
+ removedTasks: removedTasks.sort((a, b) => b.credits - a.credits),
188
+ hasWork: credits > 0,
189
+ attributable: true,
190
+ });
191
+ }
192
+ return out;
193
+ }
194
+
195
+ // ideaBrightness — the same answer for one idea. A thin wrapper on purpose: two
196
+ // implementations of "how lit is this thought" is two answers, and this is the
197
+ // shape a single-idea panel wants. Mirrors ./idea-size.js's ideaSize.
198
+ async function ideaBrightness(ideaId, opts = {}) {
199
+ const key = idOf(ideaId);
200
+ const unknown = {
201
+ share: null, live: null, removed: null, credits: 0,
202
+ removedTasks: [], hasWork: false, attributable: false,
203
+ };
204
+ if (!key) return unknown;
205
+ const all = await ideaBrightnesses([key], opts);
206
+ return all.get(key) || unknown;
207
+ }
208
+
209
+ module.exports = {
210
+ ideaBrightness,
211
+ ideaBrightnesses,
212
+ };