@bongos/core 1.19.922 → 1.19.923
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 +31 -16
- package/docs/adr/0322-a-remnant-needs-evidence-and-merged-wins.md +110 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/economy/reward.js +11 -0
- package/modules/ideas/idea-state.js +254 -0
- package/modules/ideas/routes/inbox.js +8 -0
- package/modules/ideas/trail.js +13 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/idea_state.mjs +399 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.923",
|
|
6
|
+
"core_contract": "1.19.923",
|
|
7
|
+
"source_commit": "f854693589aaf4e6736cbe02c05c3fd5eb2f2ca1",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-20T22:19:24.521Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 511,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2311,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2848,
|
|
20
|
+
"tree_sha256": "c600fca8af8dd65a3567fe7851b2ead8bc6c08f43dcb3b1b4964957c5c734e1a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2099,10 +2099,15 @@
|
|
|
2099
2099
|
"mode": "0000644",
|
|
2100
2100
|
"sha256": "3a743808de440eebd5fcdd74a4c88e41acc82cc02ce5632cb392b2aa0c05b48e"
|
|
2101
2101
|
},
|
|
2102
|
+
{
|
|
2103
|
+
"path": "docs/adr/0322-a-remnant-needs-evidence-and-merged-wins.md",
|
|
2104
|
+
"mode": "0000644",
|
|
2105
|
+
"sha256": "6a4ad58720701e760068d5ce1761733790beef0a142aa31a08782d029cc7a390"
|
|
2106
|
+
},
|
|
2102
2107
|
{
|
|
2103
2108
|
"path": "docs/adr/README.md",
|
|
2104
2109
|
"mode": "0000644",
|
|
2105
|
-
"sha256": "
|
|
2110
|
+
"sha256": "7c66ecbd1940f3cdd830f0fb4acf65c41178807a431bb8fab382e4b8a0dbc8ae"
|
|
2106
2111
|
},
|
|
2107
2112
|
{
|
|
2108
2113
|
"path": "docs/api-reference.md",
|
|
@@ -2672,7 +2677,7 @@
|
|
|
2672
2677
|
{
|
|
2673
2678
|
"path": "docs/module-api-changelog.md",
|
|
2674
2679
|
"mode": "0000644",
|
|
2675
|
-
"sha256": "
|
|
2680
|
+
"sha256": "2dc422992b2c8e66db2eb3e7492abb35d061caf0ea2e3cc1ad492d0b4b8083b4"
|
|
2676
2681
|
},
|
|
2677
2682
|
{
|
|
2678
2683
|
"path": "docs/modules-contract.md",
|
|
@@ -4272,7 +4277,7 @@
|
|
|
4272
4277
|
{
|
|
4273
4278
|
"path": "modules/economy/reward.js",
|
|
4274
4279
|
"mode": "0000644",
|
|
4275
|
-
"sha256": "
|
|
4280
|
+
"sha256": "79f225665dfc84fce1c50d01a25bbf81986c91047732d04113f24a68f766d29a"
|
|
4276
4281
|
},
|
|
4277
4282
|
{
|
|
4278
4283
|
"path": "modules/economy/routes/cost.js",
|
|
@@ -5399,6 +5404,11 @@
|
|
|
5399
5404
|
"mode": "0000644",
|
|
5400
5405
|
"sha256": "363598b5905c850967b407f20110a1d9e9d5b1090a958700cb6a4ed5cea70d80"
|
|
5401
5406
|
},
|
|
5407
|
+
{
|
|
5408
|
+
"path": "modules/ideas/idea-state.js",
|
|
5409
|
+
"mode": "0000644",
|
|
5410
|
+
"sha256": "e516f7951b91234c1f9dc6e327dc7365b6f6c9aad69bb3a85835061e2d072253"
|
|
5411
|
+
},
|
|
5402
5412
|
{
|
|
5403
5413
|
"path": "modules/ideas/inbox.js",
|
|
5404
5414
|
"mode": "0000644",
|
|
@@ -5477,7 +5487,7 @@
|
|
|
5477
5487
|
{
|
|
5478
5488
|
"path": "modules/ideas/routes/inbox.js",
|
|
5479
5489
|
"mode": "0000644",
|
|
5480
|
-
"sha256": "
|
|
5490
|
+
"sha256": "c302e5cd180ce9a6253ab30fad24b970d06f33f0916ac5d922323e536da7b213"
|
|
5481
5491
|
},
|
|
5482
5492
|
{
|
|
5483
5493
|
"path": "modules/ideas/routing.js",
|
|
@@ -5507,7 +5517,7 @@
|
|
|
5507
5517
|
{
|
|
5508
5518
|
"path": "modules/ideas/trail.js",
|
|
5509
5519
|
"mode": "0000644",
|
|
5510
|
-
"sha256": "
|
|
5520
|
+
"sha256": "5ea6bad2296406f56bcf3a00f040ce78a789f24fc0d7d5b933e1526cbbb97cd2"
|
|
5511
5521
|
},
|
|
5512
5522
|
{
|
|
5513
5523
|
"path": "modules/lifecycle/CLAUDE.md",
|
|
@@ -7902,12 +7912,12 @@
|
|
|
7902
7912
|
{
|
|
7903
7913
|
"path": "package-lock.json",
|
|
7904
7914
|
"mode": "0000644",
|
|
7905
|
-
"sha256": "
|
|
7915
|
+
"sha256": "baab771a63f97afcbcfa72e466c8e3bcdb220724bf1a0fd2046e078c88c31cc4"
|
|
7906
7916
|
},
|
|
7907
7917
|
{
|
|
7908
7918
|
"path": "package.json",
|
|
7909
7919
|
"mode": "0000644",
|
|
7910
|
-
"sha256": "
|
|
7920
|
+
"sha256": "ff37869714eeb7c99b2120171573cee5a434667baa4eb7bc76a87755f4a67214"
|
|
7911
7921
|
},
|
|
7912
7922
|
{
|
|
7913
7923
|
"path": "public-docs/index.html",
|
|
@@ -9907,7 +9917,7 @@
|
|
|
9907
9917
|
{
|
|
9908
9918
|
"path": "src/module-api.js",
|
|
9909
9919
|
"mode": "0000644",
|
|
9910
|
-
"sha256": "
|
|
9920
|
+
"sha256": "7c96d3ff9b319a6e4b6e640b01d8e8d3d61a341efb292359c35c495a938eacd5"
|
|
9911
9921
|
},
|
|
9912
9922
|
{
|
|
9913
9923
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12034,6 +12044,11 @@
|
|
|
12034
12044
|
"mode": "0000644",
|
|
12035
12045
|
"sha256": "8bfc36537ffccae702e74e8763534fa535486a2eb8bbf9ee546a2c7368d1dc6b"
|
|
12036
12046
|
},
|
|
12047
|
+
{
|
|
12048
|
+
"path": "tests/idea_state.mjs",
|
|
12049
|
+
"mode": "0000644",
|
|
12050
|
+
"sha256": "f71f7007d72689e865b1945feac66012398b38557affdc776d0a6abdc2140e65"
|
|
12051
|
+
},
|
|
12037
12052
|
{
|
|
12038
12053
|
"path": "tests/idea_task_descent.mjs",
|
|
12039
12054
|
"mode": "0000644",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# ADR 0322 — The five states are derived once, and a remnant needs evidence
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-20
|
|
5
|
+
- **Task:** [task 1004103](https://cloudbongos.com/builders#/task/1004103) (BV2.R17, goal 1000095 — BONGOS-V2)
|
|
6
|
+
- **Spec:** `docs/specs/<redacted>.md` decisions 4, 19 and 20; criterion `ideator-sky-tells-the-truth`
|
|
7
|
+
- **Design of record:** `docs/design/ideator-surface-direction.md` §"The five objects"
|
|
8
|
+
- **Under:** [ADR 0321](0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md), [ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md), [ADR 0312](0312-provenance-is-a-second-ideas-owned-edge-table.md)
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
The ideator surface draws a thought as one of five objects: **star** (it caused work that
|
|
13
|
+
is still alive), **protostar** (filed, not yet ignited), **satellite** (folded into another
|
|
14
|
+
thought), **remnant** (it burned, then the project removed it) and **cold body** (declined
|
|
15
|
+
at triage, so it never ignited and has no size at all). Dim and dark are different states
|
|
16
|
+
and must be different objects, not one dot at two opacities.
|
|
17
|
+
|
|
18
|
+
Three surfaces are about to draw them — the room (BV2.R03), the descent (R10) and the sky
|
|
19
|
+
(R20) — and the spec's own phrasing is that every state must be *computed from the ledger,
|
|
20
|
+
and no surface has to infer one*.
|
|
21
|
+
|
|
22
|
+
The facts already exist. `idea_inbox.status` carries the triage verdict (`open`,
|
|
23
|
+
`promoted`, `discarded`, `merged`; `core_195`). ADR 0321 gave economy `ideaBrightnesses()`,
|
|
24
|
+
returning `hasWork`, `share` and `attributable` per idea. `tasks.goal_id` and `tasks.status`
|
|
25
|
+
say where a thought's work sits and whether it awaits a nod. Nothing was missing; what was
|
|
26
|
+
missing was one place that reads them.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
**One resolver, `modules/ideas/idea-state.js`, and no surface decides a state.** A state
|
|
31
|
+
inferred in three renderers is three states: the sky can say a thought went out while the
|
|
32
|
+
room beside it still calls it alive, with nothing erroring and no way to tell which is
|
|
33
|
+
right. The resolver is pure — facts in, object out — the way `routing.js` is pure about a
|
|
34
|
+
landing. `attachStateToTrail` is the one async wrapper, composed on the trail route after
|
|
35
|
+
`attachSizeToTrail`.
|
|
36
|
+
|
|
37
|
+
**Brightness goes on the `reward` port.** `ideas` may not reach `credit_log` any other way
|
|
38
|
+
(ADR 0083 rule 1), and it already declares `reward` in `consumes`, so no new dependency
|
|
39
|
+
edge. Shipping size without brightness had left the module able to draw how big a thought
|
|
40
|
+
was and unable to say whether it was still alive.
|
|
41
|
+
|
|
42
|
+
**The order of the tests is the decision**, and each step could have gone the other way:
|
|
43
|
+
|
|
44
|
+
1. **Merged wins over everything.** A folded thought is a satellite even when it earned
|
|
45
|
+
money of its own — and it *keeps* that money; `trail-size.js` is emphatic that stream 1
|
|
46
|
+
books against the idea whose lineage row named the shipping task. This changes what the
|
|
47
|
+
thought **is**, not what it earned. Letting work win here draws a satellite as an
|
|
48
|
+
independent star beside the thought that absorbed it: one idea, twice in the sky.
|
|
49
|
+
2. **Work beats a discard.** The design defines a cold body as one that *"never ignited, so
|
|
50
|
+
it has no size at all"* — a stated property, and testing work before status is what
|
|
51
|
+
makes it true by construction rather than by hope. It is reachable: triage can discard a
|
|
52
|
+
duplicate whose twin already routed and paid, and `updateIdea` never touches the ledger.
|
|
53
|
+
3. **A remnant needs evidence, never the absence of an answer.** `share === 0` means the
|
|
54
|
+
project confirmed every piece of the descent gone. `share === null` means the removal
|
|
55
|
+
facts could not be read at all — ADR 0321 is explicit that those are different answers.
|
|
56
|
+
An idea with work and no readable facts is a **star** carrying `attributable: false`, so
|
|
57
|
+
a surface says "unknown" rather than "full". Reading a falsy share as death turns one
|
|
58
|
+
unmounted module into a sky of hollow rings.
|
|
59
|
+
4. **A protostar is the absence of work, not the presence of a status.** Ignition is being
|
|
60
|
+
*paid* for something, not being converted into a row, so a promoted-but-unshipped idea is
|
|
61
|
+
still unignited — which is exactly the state the nod queue exists for.
|
|
62
|
+
|
|
63
|
+
**Position is a second axis**, because the design says the object alone cannot carry it: a
|
|
64
|
+
protostar **in a well** has a home and awaits a nod; one in **open space** has nothing
|
|
65
|
+
holding it. The resolver returns `position`, `wellGoalId` and `awaitingNod` for every state
|
|
66
|
+
(a star has a position too), and the nod is asked of *every* task rather than only the one
|
|
67
|
+
that named the well — an idea that became two tasks, one promoted and one still at
|
|
68
|
+
`backlog`, still has something waiting on a person.
|
|
69
|
+
|
|
70
|
+
**`suggested_goal_id` is deliberately not a well.** It sits on the row, it names a goal, and
|
|
71
|
+
it is the wrong fact: `projection.js` records it as strictly advisory — the routing hint on
|
|
72
|
+
a filing that did **not** convert, with `tasks.goal_id` as the binding home. Reading it here
|
|
73
|
+
would draw a protostar inside a well that never caught it and put it in a nod queue nobody
|
|
74
|
+
can act on, because no task exists to promote. The binding fact is now selected on both of
|
|
75
|
+
the trail's task joins (`trail.js`), which costs no extra round trip.
|
|
76
|
+
|
|
77
|
+
**The page reads the ledger once.** `attachSizeToTrail` leaves economy's own `byTask`
|
|
78
|
+
breakdown on each row and brightness takes its share of exactly that, so the state attach
|
|
79
|
+
hands it straight back. All of the sizes or none: a partial map reads a missing entry as
|
|
80
|
+
"no work", which would draw a paid star as a protostar.
|
|
81
|
+
|
|
82
|
+
**An absent `reward` port does not empty the sky.** The states still resolve on status
|
|
83
|
+
alone, carrying `attributable: false` — the same posture `trail-size.js` takes with
|
|
84
|
+
`size: null`.
|
|
85
|
+
|
|
86
|
+
## Consequences
|
|
87
|
+
|
|
88
|
+
- `GET /inbox/by-builder/:id` now returns `state` per idea beside `size` and
|
|
89
|
+
`descent_tasks`. Additive; no existing consumer reads it yet, and R03/R10/R20 will.
|
|
90
|
+
- The five objects are spelled once (`IDEA_STATES`), so a renderer switching on hand-copied
|
|
91
|
+
strings cannot drift by a hyphen and silently draw nothing.
|
|
92
|
+
- Nothing here writes, and no credit moves. ADR 0313 stands: `shipped` is terminal, and
|
|
93
|
+
only the picture changes.
|
|
94
|
+
- `tests/idea_state.mjs` (31 cases, DB-free). The ordering, the zero-over-zero edge, the
|
|
95
|
+
well and the trail SQL are each mutation-probed.
|
|
96
|
+
|
|
97
|
+
## Rejected
|
|
98
|
+
|
|
99
|
+
- **Resolving the state in each renderer.** The cheapest thing to write and the reason the
|
|
100
|
+
done-when says "no surface has to infer one".
|
|
101
|
+
- **Deriving the state from `share` alone.** It merges a protostar with a remnant — the
|
|
102
|
+
precise distinction the two channels exist to draw, and the one BV2.R19's proof checks.
|
|
103
|
+
- **Treating an unreadable `share` as zero.** "We could not look" is not "everything was
|
|
104
|
+
removed"; ADR 0321 pays for that distinction upstream and discarding it here wastes it.
|
|
105
|
+
- **Status-first ordering** (a discarded idea is always a cold body). It contradicts the
|
|
106
|
+
object's own stated property the moment triage discards something that already paid.
|
|
107
|
+
- **Reading `suggested_goal_id` as a well.** Advisory by construction; it would populate a
|
|
108
|
+
nod queue with entries nothing can act on.
|
|
109
|
+
- **A sixth object for "unknown brightness".** The vocabulary is the owner's and is five;
|
|
110
|
+
the uncertainty is carried as a flag on the answer, not as a new thing in the sky.
|
package/docs/adr/README.md
CHANGED
|
@@ -413,3 +413,4 @@ This keeps the decision history honest and traceable.
|
|
|
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
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 |
|
|
416
|
+
| 0322 | [**The five states are derived once, and a remnant needs evidence** ([task 1004103](https://cloudbongos.com/builders#/task/1004103), goal 1000095 — BONGOS-V2). The ideator surface draws a thought as one of five objects — star, protostar, satellite, remnant, cold body — and three surfaces are about to draw them (the room, the descent, the sky). Every fact already existed: `idea_inbox.status` (`core_195`), [ADR 0321](0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md)’s `hasWork` / `share` / `attributable`, and `tasks.goal_id` + `tasks.status`. What was missing was ONE place that reads them: a state inferred in three renderers is three states, and the sky can say a thought went out while the room beside it still calls it alive, with nothing erroring. **Decision:** one pure resolver (`modules/ideas/idea-state.js`), brightness exposed on the `reward` port (ideas may not reach `credit_log` any other way — [ADR 0083](0083-modular-architecture-strangler-build-plan.md) rule 1 — and already declares `reward` in `consumes`), and the ORDER of the tests IS the decision. **MERGED wins over everything:** a folded thought keeps its own money (`trail-size.js` is emphatic) but is no longer its own point in the sky, so letting work win draws one idea twice, beside the thought that absorbed it. **WORK beats a DISCARD:** a cold body “never ignited, so it has no size at all” is a stated PROPERTY, and testing work first makes it true by construction — triage really can discard a duplicate whose twin already routed and paid, and `updateIdea` never touches the ledger. **A REMNANT needs EVIDENCE, never the absence of an answer:** `share: 0` is a confirmed removal, `share: null` is an unreadable one, and ADR 0321 pays for that distinction upstream — so an idea with work and no readable facts is a STAR carrying `attributable: false`, because reading a falsy share as death turns one unmounted module into a sky of hollow rings. **A PROTOSTAR is the absence of WORK, not of a status:** ignition is being PAID for something, so a promoted-but-unshipped idea is still unignited, which is exactly what the nod queue exists for. **Position is a second axis** (`well` / `open-space` / `awaitingNod`) because the design says the object alone cannot carry it, and the nod is asked of EVERY task, not only the one that named the well. **`suggested_goal_id` is deliberately NOT a well** — `projection.js` records it as strictly advisory, the routing hint on a filing that did not convert, so reading it would put a thought in a nod queue with no task to promote; the binding `tasks.goal_id` now rides on both of the trail’s task joins at no extra round trip. The page reads the ledger ONCE: the state attach hands economy back the `byTask` breakdown size just computed, all of them or none (a partial map reads a missing entry as “no work” and draws a paid star as a protostar). An absent `reward` port does not empty the sky — states still resolve, flagged unattributable. Nothing writes; ADR 0313 stands. Rejected: resolving in each renderer; deriving from `share` alone (it merges a protostar with a remnant, the exact BV2.R19 distinction); treating an unreadable share as zero; status-first ordering; reading the hint as a home; a sixth object for “unknown brightness” (the vocabulary is the owner’s and is five — the uncertainty is a flag on the answer, not a new thing in the sky).](0322-a-remnant-needs-evidence-and-merged-wins.md) | ideas / economy |
|
|
@@ -2318,5 +2318,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2318
2318
|
landed since 1.19.920 with no explicit bump. run 35534076467. (task 1002620)
|
|
2319
2319
|
1.19.922 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2320
2320
|
landed since 1.19.921 with no explicit bump. run 35536293721. (task 1002620)
|
|
2321
|
+
1.19.923 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2322
|
+
landed since 1.19.922 with no explicit bump. run 35541278656. (task 1002620)
|
|
2321
2323
|
---------------------------------------------------------------------------
|
|
2322
2324
|
```
|
|
@@ -21,6 +21,7 @@ const cost = require('./cost');
|
|
|
21
21
|
const streak = require('./streak');
|
|
22
22
|
const ideaSizeLib = require('./idea-size');
|
|
23
23
|
const ideaSizeHistorical = require('./idea-size-historical');
|
|
24
|
+
const ideaBrightnessLib = require('./idea-brightness');
|
|
24
25
|
|
|
25
26
|
module.exports = {
|
|
26
27
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -110,6 +111,16 @@ module.exports = {
|
|
|
110
111
|
// projection and quietly drops a field the pricing needs.
|
|
111
112
|
descentRowsFromTrail: (...a) => ideaSizeHistorical.descentRowsFromTrail(...a),
|
|
112
113
|
get IDEA_SIZE_SCALE_REF() { return ideaSizeLib.IDEA_SIZE_SCALE_REF; },
|
|
114
|
+
// BRIGHTNESS — the live share of that descent (task 1004099 / BV2.R16), on the
|
|
115
|
+
// port for the same reason size is. It is the SECOND of the two channels, and
|
|
116
|
+
// the pair is what BV2.R17 resolves the five states from: a thought that
|
|
117
|
+
// earned nothing (`hasWork: false`) and one whose work was all torn out
|
|
118
|
+
// (`share: 0`) are different objects in the sky, and only a caller holding
|
|
119
|
+
// BOTH fields can tell them apart. Shipping size without this left the ideas
|
|
120
|
+
// module able to draw how big a thought was and unable to say whether it was
|
|
121
|
+
// still alive.
|
|
122
|
+
ideaBrightness: (...a) => ideaBrightnessLib.ideaBrightness(...a),
|
|
123
|
+
ideaBrightnesses: (...a) => ideaBrightnessLib.ideaBrightnesses(...a),
|
|
113
124
|
computeStreak: (...a) => streak.computeStreak(...a),
|
|
114
125
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
115
126
|
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// modules/ideas/idea-state.js — THE FIVE STATES (task 1004103 / BV2.R17,
|
|
2
|
+
// goal 1000095, criterion `ideator-sky-tells-the-truth`; spec
|
|
3
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4, 19 and 20; the object
|
|
4
|
+
// vocabulary is docs/design/ideator-surface-direction.md §"The five objects";
|
|
5
|
+
// ADR 0322, under ADR 0321 + 0313).
|
|
6
|
+
//
|
|
7
|
+
// star / protostar / satellite / remnant / cold body. Every one of them is
|
|
8
|
+
// DERIVED here, from facts the ledger already holds, so that no surface has to
|
|
9
|
+
// infer one — which is the done-when. Three surfaces are about to draw these
|
|
10
|
+
// (the room, the descent, the sky) and a state decided three times is three
|
|
11
|
+
// states: the sky would say a thought went out while the room beside it still
|
|
12
|
+
// called it alive, with nothing erroring and no way to tell which was right.
|
|
13
|
+
//
|
|
14
|
+
// THE FACTS, AND WHERE EACH COMES FROM:
|
|
15
|
+
//
|
|
16
|
+
// idea_inbox.status 'open' | 'promoted' | 'discarded' | 'merged'
|
|
17
|
+
// — the triage verdict (core_195).
|
|
18
|
+
// brightness.hasWork has this thought been paid for anything at all
|
|
19
|
+
// brightness.share how much of that payout is still alive
|
|
20
|
+
// brightness.attributable whether the removal facts could be read
|
|
21
|
+
// — all three from economy's idea-brightness.js,
|
|
22
|
+
// reached through the `reward` port.
|
|
23
|
+
// tasks[].goal_id / .status the BINDING home and whether it awaits a nod
|
|
24
|
+
// — from the trail's lineage join.
|
|
25
|
+
//
|
|
26
|
+
// THE ORDER OF THE TESTS IS THE DESIGN, and each step below is a decision
|
|
27
|
+
// someone could reasonably have made the other way:
|
|
28
|
+
//
|
|
29
|
+
// 1. MERGED WINS OVER EVERYTHING — a merged thought is a satellite even when it
|
|
30
|
+
// earned money of its own. `modules/ideas/trail-size.js` is emphatic that a
|
|
31
|
+
// merged idea keeps its OWN size (stream 1 books against the idea whose
|
|
32
|
+
// lineage row named the shipping task), and that stays true: this changes
|
|
33
|
+
// what the thought IS, never what it earned. The design's word is "it orbits
|
|
34
|
+
// that one now" — once folded, it is no longer its own point in the sky, and
|
|
35
|
+
// drawing it as an independent star beside the thought that absorbed it would
|
|
36
|
+
// show one idea twice.
|
|
37
|
+
//
|
|
38
|
+
// 2. WORK BEATS A DISCARD. A discarded idea that nevertheless caused shipped,
|
|
39
|
+
// paid work is not a cold body — the design defines that object as "it never
|
|
40
|
+
// ignited, so it has no size at all", which is a stated PROPERTY, and testing
|
|
41
|
+
// work first is what keeps the property true by construction instead of by
|
|
42
|
+
// hope. This is reachable: triage can discard a duplicate whose twin already
|
|
43
|
+
// routed and paid, and `updateIdea` never touches the ledger.
|
|
44
|
+
//
|
|
45
|
+
// 3. REMNANT NEEDS EVIDENCE, NEVER ITS ABSENCE. `share === 0` says the project
|
|
46
|
+
// confirmed every piece of this descent gone. `share === null` says the
|
|
47
|
+
// removal facts could not be read at all (lifecycle off, port unregistered) —
|
|
48
|
+
// brightness is explicit that those are different answers, and treating the
|
|
49
|
+
// second as the first would draw every star in the instance as a hollow ring
|
|
50
|
+
// the moment a module failed to mount. An unreadable idea with work is a
|
|
51
|
+
// star, carrying `attributable: false` so a surface can say the brightness is
|
|
52
|
+
// unknown rather than full.
|
|
53
|
+
//
|
|
54
|
+
// 4. A PROTOSTAR IS THE ABSENCE OF WORK, not the presence of a status. Both an
|
|
55
|
+
// 'open' idea nobody has touched and a 'promoted' one whose task has not
|
|
56
|
+
// shipped yet are unignited, which is exactly right: ignition is being paid
|
|
57
|
+
// for something, not being converted into a row.
|
|
58
|
+
//
|
|
59
|
+
// POSITION IS A SEPARATE AXIS from the object, and it is the half the design
|
|
60
|
+
// says the object alone cannot carry: a protostar INSIDE A WELL has a home and
|
|
61
|
+
// awaits a nod; one in OPEN SPACE has nothing holding it. It is computed for
|
|
62
|
+
// every state (a star has a position too) but it is only load-bearing for a
|
|
63
|
+
// protostar, where it is the difference between "someone must act" and "nobody
|
|
64
|
+
// has claimed this".
|
|
65
|
+
//
|
|
66
|
+
// WHY `suggested_goal_id` IS NOT A WELL. It sits on the row, it names a goal,
|
|
67
|
+
// and it is the wrong fact: `projection.js` records that it is STRICTLY
|
|
68
|
+
// ADVISORY — the routing HINT on a filing that did NOT convert, with the binding
|
|
69
|
+
// home being `tasks.goal_id`. Reading it here would draw a protostar sitting
|
|
70
|
+
// inside a well that never caught it, and put it in a nod queue no one can act
|
|
71
|
+
// on because no task exists to promote.
|
|
72
|
+
//
|
|
73
|
+
// PURE. No DB, no clock, no port — the resolver takes facts and returns an
|
|
74
|
+
// answer, the way `routing.js` takes a filing and returns a landing. The one
|
|
75
|
+
// async function in the file is the trail attacher at the bottom, which fetches
|
|
76
|
+
// the brightness half and then calls the pure one. `tests/idea_state.mjs` drives
|
|
77
|
+
// every cell of the matrix DB-free because of that split.
|
|
78
|
+
|
|
79
|
+
const api = require('../../src/module-api');
|
|
80
|
+
|
|
81
|
+
// The five objects, spelled once. Exported so a renderer switches on the same
|
|
82
|
+
// vocabulary the resolver emits rather than a hand-copied list of strings that
|
|
83
|
+
// can drift by a hyphen.
|
|
84
|
+
const IDEA_STATES = Object.freeze({
|
|
85
|
+
STAR: 'star',
|
|
86
|
+
PROTOSTAR: 'protostar',
|
|
87
|
+
SATELLITE: 'satellite',
|
|
88
|
+
REMNANT: 'remnant',
|
|
89
|
+
COLD_BODY: 'cold-body',
|
|
90
|
+
});
|
|
91
|
+
const IDEA_STATE_VALUES = Object.freeze(Object.values(IDEA_STATES));
|
|
92
|
+
|
|
93
|
+
// The two positions. `null` is not a third one — it is "this thought has no
|
|
94
|
+
// task in any goal", which IS open space; the field is never null.
|
|
95
|
+
const IDEA_POSITIONS = Object.freeze({ WELL: 'well', OPEN_SPACE: 'open-space' });
|
|
96
|
+
|
|
97
|
+
// The task status that means "routed into a goal, one Metic+ promote from
|
|
98
|
+
// claimable" — the nod the landing matrix soft-lands every non-bug at
|
|
99
|
+
// (`routing.js` HARD_LAND_KINDS). Spelled here rather than imported because
|
|
100
|
+
// routing.js is about the DECISION to land and this is about reading the result
|
|
101
|
+
// of one; the coupling is the status vocabulary, which is core's.
|
|
102
|
+
const AWAITING_NOD_STATUS = 'backlog';
|
|
103
|
+
|
|
104
|
+
// A positive integer id as it arrives off a row, where a bigint may be a string.
|
|
105
|
+
// Never coerced with Number() — the guard economy's idea-size.js uses, for the
|
|
106
|
+
// same precision reason.
|
|
107
|
+
function idOf(v) {
|
|
108
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// taskFactsFromIdeaRow — the { goalId, status } pairs an idea's state depends
|
|
112
|
+
// on, taken off a trail row.
|
|
113
|
+
//
|
|
114
|
+
// It lives here, with the resolver that consumes it, for the reason economy's
|
|
115
|
+
// `descentRowsFromTrail` lives with the pricing rather than in the trail: a
|
|
116
|
+
// hand-built projection at the call site is a copy that can quietly stop
|
|
117
|
+
// carrying a field the consumer needs, and nothing errors when it does.
|
|
118
|
+
//
|
|
119
|
+
// BOTH SOURCES, because neither alone is complete. `lineage_tasks` is the full
|
|
120
|
+
// answer for anything promoted since `ideas_task_lineage` existed (one idea can
|
|
121
|
+
// become many tasks); `promoted_to_task_id` is the ONLY answer for promotions
|
|
122
|
+
// that predate it. The legacy row is added only when the lineage does not
|
|
123
|
+
// already name that task, so an idea carrying both is not counted twice.
|
|
124
|
+
function taskFactsFromIdeaRow(row) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const lineage = Array.isArray(row && row.lineage_tasks) ? row.lineage_tasks : [];
|
|
128
|
+
for (const t of lineage) {
|
|
129
|
+
if (!t) continue;
|
|
130
|
+
const taskId = idOf(t.task_id);
|
|
131
|
+
if (taskId) seen.add(taskId);
|
|
132
|
+
out.push({ taskId, goalId: idOf(t.goal_id), status: t.status || null });
|
|
133
|
+
}
|
|
134
|
+
const legacyId = idOf(row && row.promoted_to_task_id);
|
|
135
|
+
if (legacyId && !seen.has(legacyId)) {
|
|
136
|
+
out.push({
|
|
137
|
+
taskId: legacyId,
|
|
138
|
+
goalId: idOf(row.promoted_task_goal_id),
|
|
139
|
+
status: row.promoted_task_status || null,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// resolveIdeaState — the whole decision, pure.
|
|
146
|
+
//
|
|
147
|
+
// status the idea's triage status
|
|
148
|
+
// hasWork brightness.hasWork — has it been paid for anything
|
|
149
|
+
// share brightness.share — the live fraction, or null if unreadable
|
|
150
|
+
// attributable brightness.attributable — whether the removal facts were read
|
|
151
|
+
// tasks taskFactsFromIdeaRow(row)
|
|
152
|
+
//
|
|
153
|
+
// Returns { state, position, wellGoalId, awaitingNod, brightness, hasWork,
|
|
154
|
+
// attributable }
|
|
155
|
+
//
|
|
156
|
+
// `brightness` is passed straight through so a surface drawing the object and
|
|
157
|
+
// its dimness reads ONE answer: a renderer that took the state from here and the
|
|
158
|
+
// share from somewhere else could draw a full-brightness remnant.
|
|
159
|
+
//
|
|
160
|
+
// A MISSING BRIGHTNESS IS NOT ZERO WORK. When the caller has no brightness at
|
|
161
|
+
// all (the `reward` port is absent), `hasWork` arrives undefined and the idea
|
|
162
|
+
// resolves on its status alone — an unignited-looking protostar — with
|
|
163
|
+
// `attributable: false` saying the money could not be read. Defaulting `hasWork`
|
|
164
|
+
// to false would be the same claim; the flag is what stops a surface from
|
|
165
|
+
// stating it.
|
|
166
|
+
function resolveIdeaState({ status = null, hasWork = false, share = null, attributable = false, tasks = [] } = {}) {
|
|
167
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
168
|
+
// A well is a goal that actually HOLDS one of this thought's tasks.
|
|
169
|
+
const inGoal = list.find((t) => t && t.goalId);
|
|
170
|
+
const wellGoalId = inGoal ? inGoal.goalId : null;
|
|
171
|
+
const position = wellGoalId ? IDEA_POSITIONS.WELL : IDEA_POSITIONS.OPEN_SPACE;
|
|
172
|
+
// The nod is a property of a TASK, so it is asked of every task in a goal and
|
|
173
|
+
// not only of the one that named the well: an idea that became two tasks, one
|
|
174
|
+
// promoted and one still at backlog, still has something awaiting a person.
|
|
175
|
+
const awaitingNod = list.some((t) => t && t.goalId && t.status === AWAITING_NOD_STATUS);
|
|
176
|
+
|
|
177
|
+
const common = {
|
|
178
|
+
position,
|
|
179
|
+
wellGoalId,
|
|
180
|
+
awaitingNod,
|
|
181
|
+
brightness: share,
|
|
182
|
+
hasWork: hasWork === true,
|
|
183
|
+
attributable: attributable === true,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// 1. Folded into another thought — whatever else is true of it.
|
|
187
|
+
if (status === 'merged') return { state: IDEA_STATES.SATELLITE, ...common };
|
|
188
|
+
|
|
189
|
+
// 2/3. It ignited. Only CONFIRMED removal of the whole descent puts it out.
|
|
190
|
+
if (hasWork === true) {
|
|
191
|
+
const dead = attributable === true && share === 0;
|
|
192
|
+
return { state: dead ? IDEA_STATES.REMNANT : IDEA_STATES.STAR, ...common };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Declined at triage, having caused nothing.
|
|
196
|
+
if (status === 'discarded') return { state: IDEA_STATES.COLD_BODY, ...common };
|
|
197
|
+
|
|
198
|
+
// 5. Filed, not yet ignited. `position` is the half that matters here.
|
|
199
|
+
return { state: IDEA_STATES.PROTOSTAR, ...common };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// attachStateToTrail — hangs `state` on every idea of a trail page, in place,
|
|
203
|
+
// mirroring attachDescentToTrail / attachSizeToTrail so the route composes the
|
|
204
|
+
// three the same way.
|
|
205
|
+
//
|
|
206
|
+
// COMPOSED AFTER attachSizeToTrail, and that ordering saves a whole second
|
|
207
|
+
// ledger read: size leaves `idea.size` carrying economy's own `byTask`
|
|
208
|
+
// breakdown, which is exactly the input brightness takes its share of. Handing
|
|
209
|
+
// it back means one query for the page rather than two. When an idea's size is
|
|
210
|
+
// missing or carries no breakdown — the port was absent, so size is null —
|
|
211
|
+
// brightness is asked for nothing and the ideas resolve on status alone.
|
|
212
|
+
//
|
|
213
|
+
// AN ABSENT PORT DOES NOT MAKE EVERY THOUGHT DARK. The state still resolves,
|
|
214
|
+
// carrying `attributable: false`; what it must never do is silently claim the
|
|
215
|
+
// sky is empty. Same posture trail-size.js takes with `size: null`.
|
|
216
|
+
async function attachStateToTrail(ideas, { client = null } = {}) {
|
|
217
|
+
const list = Array.isArray(ideas) ? ideas : [];
|
|
218
|
+
if (list.length === 0) return list;
|
|
219
|
+
|
|
220
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
221
|
+
let brightnesses = null;
|
|
222
|
+
if (reward && typeof reward.ideaBrightnesses === 'function') {
|
|
223
|
+
const sizes = new Map();
|
|
224
|
+
for (const idea of list) {
|
|
225
|
+
if (idea && idea.size && idea.size.byTask instanceof Map) sizes.set(String(idea.id), idea.size);
|
|
226
|
+
}
|
|
227
|
+
brightnesses = await reward.ideaBrightnesses(
|
|
228
|
+
list.map((i) => i.id),
|
|
229
|
+
{ client, ...(sizes.size === list.length ? { sizes } : {}) }
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const idea of list) {
|
|
234
|
+
const b = brightnesses && brightnesses.get(String(idea.id));
|
|
235
|
+
idea.state = resolveIdeaState({
|
|
236
|
+
status: idea.status,
|
|
237
|
+
hasWork: b ? b.hasWork : false,
|
|
238
|
+
share: b ? b.share : null,
|
|
239
|
+
attributable: b ? b.attributable : false,
|
|
240
|
+
tasks: taskFactsFromIdeaRow(idea),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return list;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
IDEA_STATES,
|
|
248
|
+
IDEA_STATE_VALUES,
|
|
249
|
+
IDEA_POSITIONS,
|
|
250
|
+
AWAITING_NOD_STATUS,
|
|
251
|
+
taskFactsFromIdeaRow,
|
|
252
|
+
resolveIdeaState,
|
|
253
|
+
attachStateToTrail,
|
|
254
|
+
};
|
|
@@ -43,6 +43,7 @@ const { attachDescentToTrail } = require('../trail-descent');
|
|
|
43
43
|
// file for the same two reasons trail-descent.js is: inbox.js is at the size
|
|
44
44
|
// ratchet, and a ledger read keyed by idea is a third relation again.
|
|
45
45
|
const { attachSizeToTrail } = require('../trail-size');
|
|
46
|
+
const { attachStateToTrail } = require('../idea-state');
|
|
46
47
|
// The walk itself, published as the `ideas.descent` port below.
|
|
47
48
|
const descent = require('../descent');
|
|
48
49
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
@@ -644,6 +645,13 @@ module.exports = function buildInboxRouter() {
|
|
|
644
645
|
// the number, and the number is a ledger read that does not depend on the
|
|
645
646
|
// walk having run. One query for the whole page, through the `reward` port.
|
|
646
647
|
await attachSizeToTrail(ideas);
|
|
648
|
+
// THE STATE — which of the five objects each thought is (BV2.R17). Last of
|
|
649
|
+
// the three, because it is the only one that reads the other two: it takes
|
|
650
|
+
// its share off the `byTask` breakdown size just left on the row, so the
|
|
651
|
+
// page pays for one ledger read rather than two. Derived here and not in the
|
|
652
|
+
// renderer — three surfaces are about to draw these, and a state inferred
|
|
653
|
+
// three times is three states.
|
|
654
|
+
await attachStateToTrail(ideas);
|
|
647
655
|
return res.json({ builder_id: builderId, ideas, total, limit, offset });
|
|
648
656
|
}, { errorCode: 'idea_trail_failed' }));
|
|
649
657
|
|
package/modules/ideas/trail.js
CHANGED
|
@@ -42,6 +42,16 @@ const { skillFrictionHiddenClause } = require('./skill-friction');
|
|
|
42
42
|
// first. Both are returned: the lineage array is the complete answer, the legacy
|
|
43
43
|
// column is the only answer for promotions that predate the lineage table. The
|
|
44
44
|
// LATERAL is bounded by the page window, so this stays one query, not an N+1.
|
|
45
|
+
//
|
|
46
|
+
// `goal_id` RIDES ALONG ON BOTH TASK JOINS (task 1004103 / BV2.R17) because it
|
|
47
|
+
// is what tells a protostar in a WELL from one in OPEN SPACE — the distinction
|
|
48
|
+
// the design makes position carry, and the reason the nod queue is a thing a
|
|
49
|
+
// person can see rather than a list someone must remember to open
|
|
50
|
+
// (docs/design/ideator-surface-direction.md). It is the BINDING home for "which
|
|
51
|
+
// goal"; `idea_inbox.suggested_goal_id`, already on the row, is a HINT about a
|
|
52
|
+
// filing that did not convert and is deliberately not what ./idea-state.js
|
|
53
|
+
// reads. Two nullable columns on joins the query already makes, so this costs
|
|
54
|
+
// no round trip.
|
|
45
55
|
const IDEA_TRAIL_DEFAULT_LIMIT = 20;
|
|
46
56
|
const IDEA_TRAIL_MAX_LIMIT = 100;
|
|
47
57
|
|
|
@@ -68,6 +78,7 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
68
78
|
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
69
79
|
pt.title AS promoted_task_title,
|
|
70
80
|
pt.status AS promoted_task_status,
|
|
81
|
+
pt.goal_id AS promoted_task_goal_id,
|
|
71
82
|
mi.title AS merged_into_idea_title,
|
|
72
83
|
mi.status AS merged_into_idea_status,
|
|
73
84
|
COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
|
|
@@ -78,7 +89,8 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
78
89
|
LEFT JOIN LATERAL (
|
|
79
90
|
SELECT json_agg(json_build_object(
|
|
80
91
|
'task_id', l.task_id, 'origin', l.origin,
|
|
81
|
-
'title', lt.title, 'status', lt.status
|
|
92
|
+
'title', lt.title, 'status', lt.status,
|
|
93
|
+
'goal_id', lt.goal_id
|
|
82
94
|
) ORDER BY l.task_id) AS tasks
|
|
83
95
|
FROM ideas_task_lineage l
|
|
84
96
|
JOIN tasks lt ON lt.id = l.task_id
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.923",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.923",
|
|
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.
|
|
3
|
+
"version": "1.19.923",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.923'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// tests/idea_state.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004103 (BV2.R17, goal 1000095, criterion `ideator-sky-tells-the-truth`)
|
|
4
|
+
// — THE FIVE STATES resolve from ledger facts. Spec
|
|
5
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4, 19 and 20; the object
|
|
6
|
+
// vocabulary is docs/design/ideator-surface-direction.md §"The five objects".
|
|
7
|
+
//
|
|
8
|
+
// WHAT CAN GO SILENTLY WRONG HERE, which is what this file is aimed at:
|
|
9
|
+
//
|
|
10
|
+
// * A PROTOSTAR DRAWN AS A REMNANT. An idea that has earned nothing comes back
|
|
11
|
+
// from brightness as `share: 1, hasWork: false` — LIT. Resolve the state off
|
|
12
|
+
// the share alone and a freshly-filed thought is indistinguishable from one
|
|
13
|
+
// whose version the project tore out, which is the exact distinction the two
|
|
14
|
+
// channels exist to draw. §2.
|
|
15
|
+
// * "COULD NOT LOOK" READ AS "EVERYTHING WAS REMOVED". `share: null` means the
|
|
16
|
+
// removal facts were unreadable, not that nothing survived. A resolver that
|
|
17
|
+
// treats a falsy share as dead turns one unmounted module into a sky of
|
|
18
|
+
// hollow rings. §3.
|
|
19
|
+
// * A COLD BODY WITH MONEY ON IT. The design defines a cold body as having no
|
|
20
|
+
// size at all; triage can discard a duplicate whose twin already paid, so
|
|
21
|
+
// the property only holds if work is tested BEFORE the status. §4.
|
|
22
|
+
// * A MERGED THOUGHT DRAWN TWICE. It keeps its own size (trail-size.js is
|
|
23
|
+
// emphatic), so an order that lets work win would draw a satellite as an
|
|
24
|
+
// independent star beside the thought that absorbed it. §5.
|
|
25
|
+
// * THE WELL READ OFF THE HINT. `suggested_goal_id` names a goal and is
|
|
26
|
+
// strictly advisory — the routing hint on a filing that did NOT convert. Read
|
|
27
|
+
// it as a home and a protostar joins a nod queue nobody can act on, because
|
|
28
|
+
// no task exists to promote. §6.
|
|
29
|
+
// * THE BINDING FACT NEVER REACHING THE RESOLVER. The state can only tell a
|
|
30
|
+
// well from open space if the trail actually selects `goal_id`; drop it and
|
|
31
|
+
// every thought in the instance quietly reads as homeless. §7 reads the
|
|
32
|
+
// statement.
|
|
33
|
+
// * THE ATTACH NEVER BEING CALLED. A resolver nothing invokes passes its whole
|
|
34
|
+
// suite and reaches no surface — this module's own history (a dead cascade
|
|
35
|
+
// rule) says so. §8 drives the real attacher and §9 holds the route wiring.
|
|
36
|
+
// * THE PAGE PAYING TWICE. Brightness takes its share of the same `byTask`
|
|
37
|
+
// breakdown size already computed; not handing it back doubles the ledger
|
|
38
|
+
// reads on every trail view, with nothing rendering differently. §8.
|
|
39
|
+
//
|
|
40
|
+
// DB-free: the reward port is a stub and the resolver is pure.
|
|
41
|
+
//
|
|
42
|
+
// Run: node tests/idea_state.mjs
|
|
43
|
+
|
|
44
|
+
import { strict as assert } from 'node:assert';
|
|
45
|
+
import { createRequire } from 'node:module';
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
|
|
50
|
+
const require = createRequire(import.meta.url);
|
|
51
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
52
|
+
|
|
53
|
+
const api = require('../src/module-api.js');
|
|
54
|
+
const state = require('../modules/ideas/idea-state.js');
|
|
55
|
+
const reward = require('../modules/economy/reward.js');
|
|
56
|
+
|
|
57
|
+
const { resolveIdeaState, taskFactsFromIdeaRow, attachStateToTrail, IDEA_STATES, IDEA_POSITIONS } = state;
|
|
58
|
+
|
|
59
|
+
let passed = 0;
|
|
60
|
+
let failed = 0;
|
|
61
|
+
async function test(name, fn) {
|
|
62
|
+
try { await fn(); passed += 1; console.log(`ok ${name}`); }
|
|
63
|
+
catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A lit idea that has been paid: what brightness returns for a live descent.
|
|
67
|
+
const LIVE = { hasWork: true, share: 1, attributable: true };
|
|
68
|
+
// The same descent, every piece of it confirmed removed.
|
|
69
|
+
const DEAD = { hasWork: true, share: 0, attributable: true };
|
|
70
|
+
// A thought that has earned nothing — LIT, because nothing has been taken away.
|
|
71
|
+
const UNIGNITED = { hasWork: false, share: 1, attributable: true };
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// 1. the five objects, each from its facts
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
await test('§1 star — it caused work that is still alive', () => {
|
|
78
|
+
const s = resolveIdeaState({ status: 'promoted', ...LIVE });
|
|
79
|
+
assert.equal(s.state, IDEA_STATES.STAR);
|
|
80
|
+
assert.equal(s.brightness, 1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('§1 star — partly removed is DIM, not out', () => {
|
|
84
|
+
const s = resolveIdeaState({ status: 'promoted', hasWork: true, share: 0.4, attributable: true });
|
|
85
|
+
assert.equal(s.state, IDEA_STATES.STAR, 'graded, not binary — it goes out only when nothing survives');
|
|
86
|
+
assert.equal(s.brightness, 0.4, 'and the surface reads the dimness off the same answer');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('§1 protostar — filed, not yet ignited', () => {
|
|
90
|
+
assert.equal(resolveIdeaState({ status: 'open', ...UNIGNITED }).state, IDEA_STATES.PROTOSTAR);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await test('§1 protostar — PROMOTED but unshipped is still unignited', () => {
|
|
94
|
+
// Ignition is being paid for something, not being converted into a row. A
|
|
95
|
+
// status-first resolver calls this a star and the sky claims work that has not
|
|
96
|
+
// happened.
|
|
97
|
+
const s = resolveIdeaState({
|
|
98
|
+
status: 'promoted', ...UNIGNITED,
|
|
99
|
+
tasks: [{ taskId: '5', goalId: '9', status: 'ready' }],
|
|
100
|
+
});
|
|
101
|
+
assert.equal(s.state, IDEA_STATES.PROTOSTAR);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await test('§1 satellite — folded into another thought', () => {
|
|
105
|
+
assert.equal(resolveIdeaState({ status: 'merged', ...UNIGNITED }).state, IDEA_STATES.SATELLITE);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('§1 remnant — it burned, then the project removed it', () => {
|
|
109
|
+
const s = resolveIdeaState({ status: 'promoted', ...DEAD });
|
|
110
|
+
assert.equal(s.state, IDEA_STATES.REMNANT);
|
|
111
|
+
assert.equal(s.hasWork, true, 'and it KEEPS its size — large and dead is the whole point');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await test('§1 cold body — declined at triage, having caused nothing', () => {
|
|
115
|
+
const s = resolveIdeaState({ status: 'discarded', ...UNIGNITED });
|
|
116
|
+
assert.equal(s.state, IDEA_STATES.COLD_BODY);
|
|
117
|
+
assert.equal(s.hasWork, false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// 2. zero over zero — the sharpest edge brightness hands over
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
await test('§2 an idea that earned nothing is a PROTOSTAR, never a remnant', () => {
|
|
125
|
+
// brightness returns share 1 / hasWork false for it on purpose. The failure
|
|
126
|
+
// this pins is a resolver that asks "is the share zero" of an idea whose share
|
|
127
|
+
// was never a fraction of anything.
|
|
128
|
+
const fresh = resolveIdeaState({ status: 'open', hasWork: false, share: 1, attributable: true });
|
|
129
|
+
const torn = resolveIdeaState({ status: 'promoted', hasWork: true, share: 0, attributable: true });
|
|
130
|
+
assert.equal(fresh.state, IDEA_STATES.PROTOSTAR);
|
|
131
|
+
assert.equal(torn.state, IDEA_STATES.REMNANT);
|
|
132
|
+
assert.notEqual(fresh.state, torn.state, 'these two must never be the same object');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('§2 hasWork is what separates them, not the share', () => {
|
|
136
|
+
// Same share on both sides; only hasWork differs.
|
|
137
|
+
const a = resolveIdeaState({ status: 'open', hasWork: false, share: 0, attributable: true });
|
|
138
|
+
const b = resolveIdeaState({ status: 'open', hasWork: true, share: 0, attributable: true });
|
|
139
|
+
assert.equal(a.state, IDEA_STATES.PROTOSTAR);
|
|
140
|
+
assert.equal(b.state, IDEA_STATES.REMNANT);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// 3. a remnant needs EVIDENCE of removal, never the absence of an answer
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
await test('§3 unreadable removal facts give a STAR, flagged unattributable', () => {
|
|
148
|
+
const s = resolveIdeaState({ status: 'promoted', hasWork: true, share: null, attributable: false });
|
|
149
|
+
assert.equal(s.state, IDEA_STATES.STAR, 'we could not look — that is not evidence the work is gone');
|
|
150
|
+
assert.equal(s.attributable, false, 'and the surface is TOLD, so it can say "unknown" rather than "full"');
|
|
151
|
+
assert.equal(s.brightness, null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('§3 a share of 0 with attributable false is still not a remnant', () => {
|
|
155
|
+
// Defensive: brightness never emits this pair, and a resolver that reads the
|
|
156
|
+
// share without the flag would call an unreadable idea dead the day it did.
|
|
157
|
+
const s = resolveIdeaState({ status: 'promoted', hasWork: true, share: 0, attributable: false });
|
|
158
|
+
assert.equal(s.state, IDEA_STATES.STAR);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// 4. work beats a discard — the cold body's stated property
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
await test('§4 a DISCARDED idea that caused paid work is not a cold body', () => {
|
|
166
|
+
// Reachable: triage discards a duplicate whose twin already routed and paid,
|
|
167
|
+
// and updateIdea never touches the ledger. A cold body "has no size at all" —
|
|
168
|
+
// testing work first is what keeps that true by construction.
|
|
169
|
+
const s = resolveIdeaState({ status: 'discarded', ...LIVE });
|
|
170
|
+
assert.equal(s.state, IDEA_STATES.STAR);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await test('§4 a discarded idea whose work was then removed is a remnant', () => {
|
|
174
|
+
assert.equal(resolveIdeaState({ status: 'discarded', ...DEAD }).state, IDEA_STATES.REMNANT);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// 5. merged wins over everything — one thought, one point in the sky
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
await test('§5 a MERGED idea with money of its own is still a satellite', () => {
|
|
182
|
+
// It keeps its own size (trail-size.js), and that stays true — this changes
|
|
183
|
+
// what the thought IS, not what it earned. Letting work win here draws it as an
|
|
184
|
+
// independent star beside the thought that absorbed it.
|
|
185
|
+
const s = resolveIdeaState({ status: 'merged', ...LIVE });
|
|
186
|
+
assert.equal(s.state, IDEA_STATES.SATELLITE);
|
|
187
|
+
assert.equal(s.hasWork, true, 'and its size is reported, not erased');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await test('§5 a merged idea whose work was removed is a satellite too', () => {
|
|
191
|
+
assert.equal(resolveIdeaState({ status: 'merged', ...DEAD }).state, IDEA_STATES.SATELLITE);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// 6. position — the distinction the object alone cannot carry
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
await test('§6 a protostar in a WELL awaits a nod', () => {
|
|
199
|
+
const s = resolveIdeaState({
|
|
200
|
+
status: 'open', ...UNIGNITED,
|
|
201
|
+
tasks: [{ taskId: '5', goalId: '77', status: 'backlog' }],
|
|
202
|
+
});
|
|
203
|
+
assert.equal(s.position, IDEA_POSITIONS.WELL);
|
|
204
|
+
assert.equal(s.wellGoalId, '77');
|
|
205
|
+
assert.equal(s.awaitingNod, true, 'this is what makes the nod queue something a person can SEE');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await test('§6 a protostar in OPEN SPACE has nothing holding it', () => {
|
|
209
|
+
const s = resolveIdeaState({ status: 'open', ...UNIGNITED, tasks: [] });
|
|
210
|
+
assert.equal(s.position, IDEA_POSITIONS.OPEN_SPACE);
|
|
211
|
+
assert.equal(s.wellGoalId, null);
|
|
212
|
+
assert.equal(s.awaitingNod, false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test('§6 suggested_goal_id is a HINT and never a well', () => {
|
|
216
|
+
// It names a goal and it is strictly advisory: the routing hint on a filing
|
|
217
|
+
// that did not convert. Reading it here puts a thought in a nod queue with no
|
|
218
|
+
// task to promote.
|
|
219
|
+
const row = { id: '1', status: 'open', suggested_goal_id: '77', lineage_tasks: [] };
|
|
220
|
+
const s = resolveIdeaState({ status: row.status, ...UNIGNITED, tasks: taskFactsFromIdeaRow(row) });
|
|
221
|
+
assert.equal(s.position, IDEA_POSITIONS.OPEN_SPACE);
|
|
222
|
+
assert.equal(s.wellGoalId, null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await test('§6 a task in a goal that is past the nod is a well, not a queue', () => {
|
|
226
|
+
const s = resolveIdeaState({
|
|
227
|
+
status: 'promoted', ...LIVE,
|
|
228
|
+
tasks: [{ taskId: '5', goalId: '77', status: 'shipped' }],
|
|
229
|
+
});
|
|
230
|
+
assert.equal(s.position, IDEA_POSITIONS.WELL);
|
|
231
|
+
assert.equal(s.awaitingNod, false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('§6 the nod is asked of EVERY task, not only the one that named the well', () => {
|
|
235
|
+
// One idea, two tasks: one promoted, one still at backlog. Something still
|
|
236
|
+
// awaits a person, and a resolver that only inspected the first task misses it.
|
|
237
|
+
const s = resolveIdeaState({
|
|
238
|
+
status: 'promoted', ...LIVE,
|
|
239
|
+
tasks: [
|
|
240
|
+
{ taskId: '5', goalId: '77', status: 'ready' },
|
|
241
|
+
{ taskId: '6', goalId: '77', status: 'backlog' },
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
assert.equal(s.awaitingNod, true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('§6 a backlog task with NO goal is not a nod queue entry', () => {
|
|
248
|
+
// The nod is a promote within a goal; a goal-less backlog row has no well to
|
|
249
|
+
// be promoted inside.
|
|
250
|
+
const s = resolveIdeaState({
|
|
251
|
+
status: 'open', ...UNIGNITED,
|
|
252
|
+
tasks: [{ taskId: '5', goalId: null, status: 'backlog' }],
|
|
253
|
+
});
|
|
254
|
+
assert.equal(s.awaitingNod, false);
|
|
255
|
+
assert.equal(s.position, IDEA_POSITIONS.OPEN_SPACE);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// 7. the facts off a real trail row — and the statement that carries them
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
await test('§7 lineage tasks and the legacy column are BOTH read, and never twice', () => {
|
|
263
|
+
// lineage_tasks is the complete answer since ideas_task_lineage; the legacy
|
|
264
|
+
// promoted_to_task_id is the only answer for promotions that predate it. An
|
|
265
|
+
// idea carrying both must not count that task twice.
|
|
266
|
+
const both = taskFactsFromIdeaRow({
|
|
267
|
+
promoted_to_task_id: '5', promoted_task_status: 'ready', promoted_task_goal_id: '77',
|
|
268
|
+
lineage_tasks: [{ task_id: 5, status: 'ready', goal_id: 77 }],
|
|
269
|
+
});
|
|
270
|
+
assert.equal(both.length, 1, 'the same task named twice is one task');
|
|
271
|
+
const legacyOnly = taskFactsFromIdeaRow({
|
|
272
|
+
promoted_to_task_id: '5', promoted_task_status: 'backlog', promoted_task_goal_id: '77',
|
|
273
|
+
lineage_tasks: [],
|
|
274
|
+
});
|
|
275
|
+
assert.equal(legacyOnly.length, 1);
|
|
276
|
+
assert.equal(legacyOnly[0].goalId, '77', 'a pre-lineage promotion still has a home');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await test('§7 ids survive as strings — a bigint is never coerced', () => {
|
|
280
|
+
const facts = taskFactsFromIdeaRow({ lineage_tasks: [{ task_id: 9007199254740993, goal_id: '77', status: 'ready' }] });
|
|
281
|
+
assert.equal(facts[0].goalId, '77');
|
|
282
|
+
assert.equal(typeof facts[0].taskId, 'string');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await test('§7 the trail SELECTS goal_id on both task joins', () => {
|
|
286
|
+
// The binding fact. Without it every thought reads as homeless and the
|
|
287
|
+
// well-vs-open-space distinction silently disappears — nothing errors, the
|
|
288
|
+
// nod queue is simply empty forever.
|
|
289
|
+
const sql = fs.readFileSync(path.join(ROOT, 'modules/ideas/trail.js'), 'utf8');
|
|
290
|
+
assert.match(sql, /'goal_id',\s*lt\.goal_id/, 'the lineage LATERAL must carry the goal');
|
|
291
|
+
assert.match(sql, /pt\.goal_id\s+AS\s+promoted_task_goal_id/, 'and so must the legacy join');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// 8. the attach — including the branch where the reward port is not there
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
await test('§8 with the reward port absent, the states still resolve — flagged', () => {
|
|
299
|
+
// Run before the port is registered: a port is single-provider and cannot be
|
|
300
|
+
// unregistered, so this is the only honest place for the absent branch. An
|
|
301
|
+
// unmounted economy must not empty the sky.
|
|
302
|
+
assert.equal(api.hasProvider('reward'), false, 'nothing has registered it yet');
|
|
303
|
+
return attachStateToTrail([{ id: '7', status: 'open', lineage_tasks: [] }]).then((ideas) => {
|
|
304
|
+
assert.equal(ideas[0].state.state, IDEA_STATES.PROTOSTAR);
|
|
305
|
+
assert.equal(ideas[0].state.attributable, false, 'the money could not be read, and it says so');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// The port stub. It records what it was ASKED, which is what §8's one-read
|
|
310
|
+
// property is pinned on.
|
|
311
|
+
const asked = [];
|
|
312
|
+
api.registerProvider('reward', {
|
|
313
|
+
...reward,
|
|
314
|
+
async ideaBrightnesses(ids, opts = {}) {
|
|
315
|
+
asked.push({ ids: ids.map(String), sizes: opts.sizes || null });
|
|
316
|
+
const out = new Map();
|
|
317
|
+
for (const id of ids.map(String)) {
|
|
318
|
+
if (id === '7') out.set(id, { ...LIVE, credits: 40, removedTasks: [] });
|
|
319
|
+
if (id === '8') out.set(id, { ...DEAD, credits: 40, removedTasks: [{ taskId: '500', credits: 40 }] });
|
|
320
|
+
if (id === '9') out.set(id, { ...UNIGNITED, credits: 0, removedTasks: [] });
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await test('§8 the attach hangs one state per idea, off the real brightness', async () => {
|
|
327
|
+
const ideas = [
|
|
328
|
+
{ id: '7', status: 'promoted', lineage_tasks: [{ task_id: 500, goal_id: 77, status: 'shipped' }] },
|
|
329
|
+
{ id: '8', status: 'promoted', lineage_tasks: [{ task_id: 500, goal_id: 77, status: 'shipped' }] },
|
|
330
|
+
{ id: '9', status: 'open', lineage_tasks: [] },
|
|
331
|
+
];
|
|
332
|
+
await attachStateToTrail(ideas);
|
|
333
|
+
assert.equal(ideas[0].state.state, IDEA_STATES.STAR);
|
|
334
|
+
assert.equal(ideas[1].state.state, IDEA_STATES.REMNANT);
|
|
335
|
+
assert.equal(ideas[2].state.state, IDEA_STATES.PROTOSTAR);
|
|
336
|
+
assert.equal(ideas[2].state.position, IDEA_POSITIONS.OPEN_SPACE);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await test('§8 an already-computed size is HANDED BACK, so the page reads once', async () => {
|
|
340
|
+
// attachSizeToTrail leaves economy's own byTask breakdown on the row, and
|
|
341
|
+
// brightness takes its share of exactly that. Not passing it makes every trail
|
|
342
|
+
// view pay for the ledger twice, and nothing renders differently.
|
|
343
|
+
asked.length = 0;
|
|
344
|
+
const withSize = [
|
|
345
|
+
{ id: '7', status: 'promoted', lineage_tasks: [], size: { credits: 40, byTask: new Map([['500', 40]]) } },
|
|
346
|
+
];
|
|
347
|
+
await attachStateToTrail(withSize);
|
|
348
|
+
assert.ok(asked[0].sizes instanceof Map, 'the breakdown must travel with the request');
|
|
349
|
+
assert.equal(asked[0].sizes.get('7').credits, 40);
|
|
350
|
+
|
|
351
|
+
// And a page where the sizes are NOT all in hand asks for none of them, rather
|
|
352
|
+
// than handing over a partial map: brightness reads a missing entry as "no
|
|
353
|
+
// work", which would draw a paid star as a protostar.
|
|
354
|
+
asked.length = 0;
|
|
355
|
+
await attachStateToTrail([
|
|
356
|
+
{ id: '7', status: 'promoted', lineage_tasks: [], size: { credits: 40, byTask: new Map() } },
|
|
357
|
+
{ id: '8', status: 'promoted', lineage_tasks: [], size: null },
|
|
358
|
+
]);
|
|
359
|
+
assert.equal(asked[0].sizes, null, 'all of them or none — a partial map silently unlights a star');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await test('§8 an empty page costs nothing at all', async () => {
|
|
363
|
+
asked.length = 0;
|
|
364
|
+
assert.deepEqual(await attachStateToTrail([]), []);
|
|
365
|
+
assert.equal(asked.length, 0);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// 9. the wiring — a resolver nothing calls reaches no surface
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
await test('§9 the trail route attaches the state, AFTER the size', async () => {
|
|
373
|
+
const route = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
|
|
374
|
+
assert.match(route, /require\('\.\.\/idea-state'\)/, 'the route must import it');
|
|
375
|
+
const size = route.indexOf('await attachSizeToTrail(ideas)');
|
|
376
|
+
const stateAt = route.indexOf('await attachStateToTrail(ideas)');
|
|
377
|
+
assert.ok(size > 0 && stateAt > 0, 'both attaches must be called on the trail');
|
|
378
|
+
assert.ok(stateAt > size, 'state reads the byTask breakdown size leaves behind, so it runs second');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await test('§9 the reward port exposes brightness at all', async () => {
|
|
382
|
+
// The ideas module may only reach credit_log through this port (ADR 0083 rule
|
|
383
|
+
// 1). Shipping brightness without putting it on the port leaves the module able
|
|
384
|
+
// to draw how big a thought is and unable to say whether it is still alive.
|
|
385
|
+
assert.equal(typeof reward.ideaBrightnesses, 'function');
|
|
386
|
+
assert.equal(typeof reward.ideaBrightness, 'function');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await test('§9 the five objects are spelled ONCE, and the module exports them', async () => {
|
|
390
|
+
// A renderer switching on hand-copied strings drifts by a hyphen and draws
|
|
391
|
+
// nothing, silently.
|
|
392
|
+
assert.deepEqual(
|
|
393
|
+
[...state.IDEA_STATE_VALUES].sort(),
|
|
394
|
+
['cold-body', 'protostar', 'remnant', 'satellite', 'star']
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
399
|
+
process.exit(failed === 0 ? 0 : 1);
|