@bongos/core 1.19.178 → 1.19.180
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 +33 -13
- package/docs/adr/0173-legacy-multi-ship-shortfall-not-backfilled.md +53 -0
- package/docs/adr/0174-government-terminology-ranks-not-roles.md +57 -0
- package/docs/adr/README.md +2 -0
- package/modules/ideas/inbox.js +65 -0
- package/modules/ideas/migrations/ideas_002_idea_tasks.sql +47 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_task_lineage.mjs +126 -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.180",
|
|
6
|
+
"core_contract": "1.19.180",
|
|
7
|
+
"source_commit": "0e35108a1b162d8346e1262bf6453f991bd41de9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T21:08:29.177Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 308,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1276,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1605,
|
|
20
|
+
"tree_sha256": "31a11588f3d36ea469e0fc02f4e295ffe55eac4b68f3a9979a49d44acddbaddb",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1344,10 +1344,20 @@
|
|
|
1344
1344
|
"mode": "0000644",
|
|
1345
1345
|
"sha256": "d06219f51351c1dd73f7ba556c855e678a2e55e79dacd06419c212a6a24fcc64"
|
|
1346
1346
|
},
|
|
1347
|
+
{
|
|
1348
|
+
"path": "docs/adr/0173-legacy-multi-ship-shortfall-not-backfilled.md",
|
|
1349
|
+
"mode": "0000644",
|
|
1350
|
+
"sha256": "1c3c44c292b01e57399631ad604faa0b60ccfd841aa7385f5f4ab2cfdca4157d"
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
"path": "docs/adr/0174-government-terminology-ranks-not-roles.md",
|
|
1354
|
+
"mode": "0000644",
|
|
1355
|
+
"sha256": "c645ce8202fa8e8faa59d3aa00a8167477b5223817b760a733b38864d7625c1d"
|
|
1356
|
+
},
|
|
1347
1357
|
{
|
|
1348
1358
|
"path": "docs/adr/README.md",
|
|
1349
1359
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1360
|
+
"sha256": "79b6d2e5dee626f509ace71bcd133e707ae26b7cbe96a64eb93ebf868c3aabf3"
|
|
1351
1361
|
},
|
|
1352
1362
|
{
|
|
1353
1363
|
"path": "docs/api-reference.md",
|
|
@@ -3467,13 +3477,18 @@
|
|
|
3467
3477
|
{
|
|
3468
3478
|
"path": "modules/ideas/inbox.js",
|
|
3469
3479
|
"mode": "0000644",
|
|
3470
|
-
"sha256": "
|
|
3480
|
+
"sha256": "082e2598eec5645a84182d0b56f6e2ba9917d54c0af81bea3196d531099d4249"
|
|
3471
3481
|
},
|
|
3472
3482
|
{
|
|
3473
3483
|
"path": "modules/ideas/migrations/ideas_001_grades.sql",
|
|
3474
3484
|
"mode": "0000644",
|
|
3475
3485
|
"sha256": "4bdd4ebf495d19562fdb8faf508df3a4f577cb0fcc8a879a7eeb1dffd3699caa"
|
|
3476
3486
|
},
|
|
3487
|
+
{
|
|
3488
|
+
"path": "modules/ideas/migrations/ideas_002_idea_tasks.sql",
|
|
3489
|
+
"mode": "0000644",
|
|
3490
|
+
"sha256": "3c30ba66b8426d6f304c8135764108f25c4c9aeb8a571347dd7c22ff7a68fad2"
|
|
3491
|
+
},
|
|
3477
3492
|
{
|
|
3478
3493
|
"path": "modules/ideas/module.json",
|
|
3479
3494
|
"mode": "0000644",
|
|
@@ -4227,12 +4242,12 @@
|
|
|
4227
4242
|
{
|
|
4228
4243
|
"path": "package-lock.json",
|
|
4229
4244
|
"mode": "0000644",
|
|
4230
|
-
"sha256": "
|
|
4245
|
+
"sha256": "f9e97249b5dcb223b1c8910bfe85a239d9948cbf721ec303989a553d601b48dd"
|
|
4231
4246
|
},
|
|
4232
4247
|
{
|
|
4233
4248
|
"path": "package.json",
|
|
4234
4249
|
"mode": "0000644",
|
|
4235
|
-
"sha256": "
|
|
4250
|
+
"sha256": "395d13208f43926545b37afc8dab7d3a833809a036fba5a268247c198a8dfc66"
|
|
4236
4251
|
},
|
|
4237
4252
|
{
|
|
4238
4253
|
"path": "public-docs/index.html",
|
|
@@ -5837,7 +5852,7 @@
|
|
|
5837
5852
|
{
|
|
5838
5853
|
"path": "src/module-api.js",
|
|
5839
5854
|
"mode": "0000644",
|
|
5840
|
-
"sha256": "
|
|
5855
|
+
"sha256": "20924c6aa2d3ab24769ca059983064cab32c103fc979a6749a80021fcf7321d7"
|
|
5841
5856
|
},
|
|
5842
5857
|
{
|
|
5843
5858
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6979,6 +6994,11 @@
|
|
|
6979
6994
|
"mode": "0000644",
|
|
6980
6995
|
"sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
|
|
6981
6996
|
},
|
|
6997
|
+
{
|
|
6998
|
+
"path": "tests/idea_task_lineage.mjs",
|
|
6999
|
+
"mode": "0000644",
|
|
7000
|
+
"sha256": "cd782459f1e4e6ca6df8a0349521aedcc6a867128240a78d2f00e15f0fd5f259"
|
|
7001
|
+
},
|
|
6982
7002
|
{
|
|
6983
7003
|
"path": "tests/idea_templates.mjs",
|
|
6984
7004
|
"mode": "0000644",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# 0173 — The legacy multi-ship reward shortfall is not backfilled; it closes as a transition cost
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-08-16
|
|
5
|
+
- **Task:** [#1002520](https://cloudbongos.com/builders#/task/1002520) (goal 1000037 — pay-on-land incentive alignment)
|
|
6
|
+
- **Related:** ADR 0054 (cost-plus reward — `round(true_cost_usd × 1.20)`), ADR 0120 (pay-on-land), ADR 0146 (cost-plus-only), core migration `<redacted>.sql` (the `reward_basis` watermark)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
Before `core_196` landed, the cost-plus session reward booked **one row per session**. A session that shipped more than once — the normal shape, since a builder ships, opens a new worktree, and claims again — was therefore paid at the level of its *first* ship and never topped up as the session kept spending. `core_196` fixed this forward with watermark accounting: uniqueness moved from `(session_id)` to `(session_id, reward_basis)`, where `reward_basis` is the cumulative total owed at the moment of the row, so each later ship books the difference.
|
|
11
|
+
|
|
12
|
+
That migration added `reward_basis integer NOT NULL DEFAULT 0`, which left every pre-existing row at `0`. Its own comment names the consequence and defers it:
|
|
13
|
+
|
|
14
|
+
> Existing rows therefore land on `reward_basis = 0` … The application refuses to top those up: doing so would be an uncontrolled retroactive payout across every historical session. Reconstructing what past sessions are owed is a deliberate, owner-signed-off backfill, tracked separately.
|
|
15
|
+
|
|
16
|
+
Task 1002520 was that tracked backfill. Its title asserted that `reward_basis = 0` identifies the underpaid population, and it carried two open questions: whether that marker over-selects, and what the total owed actually is. Both are now answered, measured against the live `cloudbongos.com` database on 2026-08-16.
|
|
17
|
+
|
|
18
|
+
**The marker over-selects by roughly 60%.** `reward_basis = 0` means "booked before top-ups existed", not "underpaid" — a single-ship legacy session was paid its full worth at the time and is owed nothing.
|
|
19
|
+
|
|
20
|
+
| population | sessions | credits already paid |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| legacy rows (`reward_basis = 0`) | 478 | 23,477 |
|
|
23
|
+
| — single-ship (paid in full, owed nothing) | 287 | 12,669 |
|
|
24
|
+
| — multi-ship (the real candidate set) | 191 | 10,808 |
|
|
25
|
+
|
|
26
|
+
**The reconstructable shortfall is 7,697 credits.** Each candidate's stored `session_records.model_usage` was re-priced through the locked `PRICES` table (`priceModelUsage`, including the `fable` family added the same day in task [#1002883](https://cloudbongos.com/builders#/task/1002883)) at `round(true_cost_usd × 1.20)`, minus what was actually paid:
|
|
27
|
+
|
|
28
|
+
- 119 of the 191 have priceable `model_usage` → **7,697 credits owed**
|
|
29
|
+
- 72 have **empty** `model_usage` → not reconstructable server-side at all; their transcripts exist only on builders' machines (the population task [#1002448](https://cloudbongos.com/builders#/task/1002448) covers)
|
|
30
|
+
- 16 were already paid at or above their worth — 1,131 credits of excess that no proposal proposed to claw back
|
|
31
|
+
- 0 unknown model ids, so the pricing carries no fallback error
|
|
32
|
+
|
|
33
|
+
Per builder: example-owner 5,296 (69%), jaxri 2,319, scootermack 82.
|
|
34
|
+
|
|
35
|
+
## Decision
|
|
36
|
+
|
|
37
|
+
**Do not backfill.** The legacy multi-ship shortfall closes as a one-time transition cost, and task 1002520 closes with no credits minted.
|
|
38
|
+
|
|
39
|
+
The owner was presented with four options — mint the 7,697 now, reconstruct the missing 72 first and mint once, substitute a flat reviewed per-session amount, or decline — and chose to decline (2026-08-16, blocker [#1000113](https://cloudbongos.com/builders#/blocker/1000113)).
|
|
40
|
+
|
|
41
|
+
## Rationale
|
|
42
|
+
|
|
43
|
+
1. **It is the same shape of decision as the ADR 0146 clawback, and answering it the same way keeps the ledger coherent.** That go-live reversed 343 credits of already-booked per-task estimate and did not re-pay it as an estimate; builders re-earn under the new rule instead. Declining here treats the pre-`core_196` underpayment symmetrically: the accounting changed, the boundary is where the change landed, and neither side is reopened.
|
|
44
|
+
2. **The reconstruction is structurally incomplete, and paying only the reconstructable part is arbitrary.** 72 of 191 candidate sessions have no usage data at all. Paying the 119 would settle the population that happens to have left a server-side trace and silently skip 38% of it — a distinction with no relationship to who was underpaid or by how much.
|
|
45
|
+
3. **The measured figure is not symmetric with how overpayment is handled.** 16 sessions were paid *above* their worth by 1,131 credits, and no version of this backfill proposed recovering that. A payout that corrects errors in one direction only is a policy choice, and the owner declined to adopt it.
|
|
46
|
+
4. **The forward fix is already live and is the thing that mattered.** `core_196`'s watermark accounting means no session underpays this way again. The backfill was always remediation of a closed window, not a defect that continues to cost builders.
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
- Task 1002520 ships with no artifact beyond this ADR and mints nothing. Blocker 1000113 resolves as decided.
|
|
51
|
+
- **Three builders are permanently short by the amounts above** — example-owner 5,296, jaxri 2,319, scootermack 82, plus whatever the 72 unreconstructable sessions were worth. This is a real cost borne by real builders, recorded here rather than left implicit, and the owner absorbs the largest share of it.
|
|
52
|
+
- Task [#1002448](https://cloudbongos.com/builders#/task/1002448) is **unaffected and still owed.** It is a different failure — the Windows `encodeProjectPath` bug meant those sessions were never *recorded*, so they were paid nothing at all rather than paid at a stale watermark. That population still has recoverable transcripts and its own owner sign-off step. Do not read this ADR as closing it.
|
|
53
|
+
- `reward_basis = 0` remains in the schema as the "booked before top-ups existed" marker. This ADR is the answer to anyone who later reads that column as an unpaid-work queue: it is not one, it over-selects by 60%, and the underpayment it partly marks was deliberately not settled.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 0174 — Governance is renamed government, its positions are ranks, and "role" is reserved for the three crafts
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-08-16
|
|
5
|
+
- **Task:** [#1003002](https://cloudbongos.com/builders#/task/1003002) (R01 of goal 1000068 — government terminology)
|
|
6
|
+
- **Amends:** ADR 0151 and ADR 0172 (the editable-rank-roles one) — **vocabulary only**. Every decision either ADR made survives unchanged.
|
|
7
|
+
- **Related:** ADR 0016 (trust boundary — server-enforced, uncached, per request), 0043 (git/ssh trust boundary + the rank floor on protected paths), 0086 (goals + scope walls), 0157 (Archon is rank and identity only)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The word **role** currently names two unrelated things in this codebase, and a third thing is about to claim it.
|
|
12
|
+
|
|
13
|
+
1. **The permission system's positions.** `governance_roles` holds the four ladder tiers — Newcomer/`xenos`, Contributor/`thetes`, Trusted/`metic`, Owner/`archon` — plus any hand-composed permission bundles an Owner creates. Its key column is `role_key`. `governance_builder_roles` assigns them; `governance_role_permissions` grants to them. Throughout the module and its ADRs these are called *rank-roles*, which is itself a tell: the name needs a qualifier because the noun was already doing other work.
|
|
14
|
+
|
|
15
|
+
2. **The three crafts.** `tasks.discipline` and `builders.preferred_disciplines` carry `engineer` / `artist` / `ideator` (plus a `ui` value scoped for deletion, and `unclassified`) — migrations 014, 086, 176. Goal 1000066 is actively renaming these to **roles**: its criteria are literally `three-roles-only` and `users-hold-roles`, and its design treats Artist, Ideator, and Builder as the roles a session runs as.
|
|
16
|
+
|
|
17
|
+
3. **Discord's server roles.** `modules/discord/discord-role-sync.js` mirrors a builder's rank onto a Discord role badge. This is Discord's own noun for its own concept, reached through its API.
|
|
18
|
+
|
|
19
|
+
So goal 1000066 was on a collision course with the permission module: both would call their central entity a "role", in the same codebase, resolved on the same requests. The owner settled it by directive on 2026-08-16:
|
|
20
|
+
|
|
21
|
+
> *"Governance should be called government. Ranks are the positions in government. The name roles should be reserved for the three core roles of Cloud Bongos. Artist, Ideator, and Builder."*
|
|
22
|
+
|
|
23
|
+
Two facts made the rename cheap enough to do at full depth rather than as a label pass. The rank tiers' grants are ordinary `governance_role_permissions` rows (ADR 0172 established this when it made them editable), so nothing needs remodelling — only renaming. And the substantive authority work is already done and settled: ADR 0151 built the permission model, ADR 0157 fixed what Archon means, ADR 0172 made the tiers editable. There is no pending decision this rename would have to be sequenced around.
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
**1. Governance becomes government, at full depth.** `modules/governance/` → `modules/government/`; the kernel module key `governance` → `government`; `config/governance.neutral.json` and `config/governance.json` → `config/government.neutral.json` and `config/government.json`; the API from `/api/bongos/governance/*` to `/api/bongos/government/*`; the permission keys `governance.manage` → `government.manage` (~50 call sites) and `page.view.governance` → `page.view.government`. Half-measures were considered and rejected: a labels-only pass leaves the UI and the code disagreeing, which is the exact condition that produced this collision.
|
|
28
|
+
|
|
29
|
+
**2. The positions in the government are ranks. The entity IS the rank — there is no second noun.** `governance_roles` → `government_ranks`, `governance_builder_roles` → `government_builder_ranks`, `governance_role_permissions` → `government_rank_permissions`, and the `role_key` column → `rank_key`. "Position" is the word for explaining what a rank *is* in prose; it is not a code term, because introducing it as one would recreate the qualifier problem that "rank-role" already demonstrates. `rank-roles.js` → `ranks.js`, and its exports `planRankRoleSync` / `isSeededRankRole` → `planRankSync` / `isSeededRank`.
|
|
30
|
+
|
|
31
|
+
**3. The kernel port `governance.roleSync` becomes `government.rankSync`.** This is a cross-module seam, not an internal name: it is declared in `modules/governance/module.json` under `provides`, and resolved through `seams.resolveOptional` by `modules/lifecycle/db.js` and `src/bongos/db.js` on the rank-write path. It is also a transaction participant — a failed rotation rolls the rank write back — so the rename must land atomically across the provider and both consumers, never as a partial sweep.
|
|
32
|
+
|
|
33
|
+
**4. Hand-composed permission bundles stay in the one table, as custom ranks.** They keep living in `government_ranks`, distinguished exactly as today by `seeded_rank` and `is_system`.
|
|
34
|
+
|
|
35
|
+
This was the closest call in the directive, and it was decided against the alternative deliberately. The objection is real: a custom bundle is *not* a position on the ladder, it is assigned by hand rather than derived from `builders.rank`, and ADR 0172 §4 already treats the two kinds as different enough that it widened one and refused to widen the other. Splitting them into a second concept — "appointments" was the candidate — would make that existing asymmetry structural instead of implicit. The owner chose the single table anyway, because it keeps this migration a pure rename with no restructuring of a store that every authenticated request reads. Recorded here so the next reader inherits the trade-off rather than rediscovering it: if the Government tab reads wrong once the labels land, splitting is a follow-up goal, not a defect in this one.
|
|
36
|
+
|
|
37
|
+
**5. "Role" means a craft — Artist, Ideator, or Builder — and nothing else.** Enforced, not merely documented: a rule in `scripts/gds/fitness.js` fails the CI `unit` check if "role" reappears on the government side, or if "governance" reappears outside the historical record. The rule ships with its allowlist and a one-line reason per entry, because a check that passes through blanket suppression reads as evidence while proving nothing.
|
|
38
|
+
|
|
39
|
+
**6. Discord's server roles keep the word.** `modules/discord/discord-role-sync.js` and everything it touches are out of scope. That noun belongs to Discord's API; renaming our side of the boundary would make our code describe their system incorrectly. The fitness rule from Decision 5 scopes to the government surface, not to `modules/discord/`.
|
|
40
|
+
|
|
41
|
+
**Explicitly unchanged.** The ladder slugs `xenos`/`thetes`/`metic`/`archon` and their labels Newcomer/Contributor/Trusted/Owner. `builders.rank`. `requireRank`. Every floor, grant, and assignment semantic. ADR 0043's out-of-band enforcement, which reads the rank ladder and never consults this database. This ADR moves no authority; it renames the things that hold it.
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
**The scope wall has to be widened before the folder moves, not after.** The kernel module key is load-bearing in a way a folder name usually is not: `MODULE_GLOBS` maps keys to file globs, and every goal's `scope_modules` array names those keys — including goal 1000068's own. Move the folder first and the goal's wall stops matching the files its remaining tasks need to touch, blocking the work mid-goal. Hence R02 adds `government` alongside `governance` and migrates the stored arrays before R04 moves anything.
|
|
46
|
+
|
|
47
|
+
**The database rename and the constant rename must land in the same deploy.** `government_rank_permissions.permission_key` stores `governance.manage` as text, and `catalog.js` holds the same string as a code constant. If the rows change while the old code is still serving, every `governance.manage` check fails closed — and because that key is what gates the Government tab, the failure mode is the Owner locked out of the surface they would use to investigate. R03 and R05 are sequenced accordingly, and R03 takes a backup first.
|
|
48
|
+
|
|
49
|
+
**Two ADRs currently claim the number 0172**, so "ADR 0172" no longer identifies a decision on its own. This ADR amends `<redacted>.md`; the other is `<redacted>.md`. Filed as idea 1000753 with a proposed guard (fail CI on a duplicate ADR number); deliberately not fixed here, because renumbering an ADR mid-rename would put two independent reference churns in the same diff.
|
|
50
|
+
|
|
51
|
+
**ADR 0151 and ADR 0172 get amendment notes, not rewrites.** Their bodies stay in the old vocabulary. An ADR records what was decided and why at a point in time; editing the prose to match today's words would quietly erase the evidence that the words changed — which is the one thing a reader hitting "rank-role" in an old session log will need.
|
|
52
|
+
|
|
53
|
+
**Goal 1000066 can now proceed without ambiguity, and needs to.** This ADR frees the word; 1000066 spends it by renaming `tasks.discipline` and `builders.preferred_disciplines` to roles. Neither half is sufficient alone: until 1000066 lands, "role" is unclaimed rather than correctly claimed, and the fitness rule from Decision 5 only proves the government side is clean. The two goals are independent in their file sets and can run in parallel.
|
|
54
|
+
|
|
55
|
+
**Rejected: renaming the ladder slugs to match.** `xenos`/`thetes`/`metic`/`archon` are referenced by `builders.rank`, `requireRank`, the protected-path enforcers, the Discord role map, and years of session logs. The directive did not ask for it, and bundling it here would turn a bounded rename into a trust-boundary migration.
|
|
56
|
+
|
|
57
|
+
**Rejected: a compatibility shim kept indefinitely.** The old `/governance/*` route paths answer for exactly one release, logging each hit, and R05 records the removal release in this ADR's lineage. A permanent alias would mean both vocabularies stay correct forever, which is the state this ADR exists to end.
|
package/docs/adr/README.md
CHANGED
|
@@ -243,8 +243,10 @@ This keeps the decision history honest and traceable.
|
|
|
243
243
|
| 0168 | [**The scaffold vendors the hub's RUNNING core (`npm pack` of the installed package); registry-default deferred to the go-public step** ([task 1002706](https://cloudbongos.com/builders#/task/1002706); gap 13 of the project-creation audit). Two collided facts: children were born pinned to a frozen `file:vendor/bongos-core-1.19.2.tgz` (installedCoreTarball reuses the hub's own file: pin — task 1002275 — and the hub's pin sat at 1.19.2), and the task-1002621 registry cutover (hub pin now `"@bongos/core": "1.19.56"`, verified on the box) made installedCoreTarball return null — dropping the scaffold into the package-core.js source rebuild that ABORTS on a consumer instance (empty core set → empty child repo, the 1002275 failure resurrected). Fix: a three-rung ladder — file:-pin reuse → NEW `installedCorePackDir()` + `npm pack` of the hub's installed core (child pins the SAME, CURRENT core, stays file:-pinned, owner needs no token) → source rebuild. The brief's "make registry the default now that the package is public" was verified FALSE (both npm scopes 404 anonymously; ADR 0134 keeps them private until go-public) — a registry-default child would be born UNINSTALLABLE for a tokenless owner. Registry-default is deferred to the go-public step (idea 1000700); the gap-13 "flip the box env" ops leg is explicitly a no-op until then; child freshness rides this ladder + the hub-pin lane (task 1002708). Rejected: registry-default now, dropping NPM_TOKEN, vendoring from the npm cacache.](<redacted>.md) | provisioning / distribution |
|
|
244
244
|
| 0167 | [**The /create gate trust link defaults to the AGPL-3.0 license text, not the npm package page** ([task 1002705](https://cloudbongos.com/builders#/task/1002705); gap 1 of the project-creation audit). The gate's "open source" link pointed at a nonexistent repo (404); the task brief's suggested default — the npm package page — is ALSO dead for the gate's actual audience (signed-out visitors): `@cloudbongos/core` and `@bongos/core` both 404 anonymously (private scopes, ADR 0134), and the ADR 0099 public mirror stays dormant until ~2026-11. The license text is the one live, instance-neutral artifact behind the claim today. The browsable-code upgrade is the owner's filed choice (idea 1000700): public repo now (forfeits the ADR 0065 §4 redaction window) / public npm package / wait for the mirror. Rejected: the npm link (verified dead), flipping the repo public (not a builder's call), plain unlinked text (less information, same cost).](<redacted>.md) | public landing / governance |
|
|
245
245
|
| 0169 | [**`bongos serve`: the `bongos dev` boot path, on a real host** ([task 1002398](https://cloudbongos.com/builders#/task/1002398), goal 1000052). [ADR 0149](<redacted>.md) gave an owner one command to stand an instance up on their laptop; "now put it on a server" had no equally good answer — Docker serves the *vanilla* brand on `:3000`, `bongos onboard` provisions a whole droplet through the control plane, and [`instance-deploy.md`](../recipes/instance-deploy.md) left the systemd unit as a hand-written prose step. The six steps (deps → Postgres → createdb → migrate → env → server → health) were already right for a host; only four *decisions inside them* were laptop-shaped. So: a `--server` **mode** on the same script (`bongos serve`), never a second boot path, so the two can't drift. (1) **Supervision** — a systemd unit (`Restart=always`, `WantedBy=multi-user.target`) instead of a pid file that dies on reboot, falling back to the pid-managed process where systemd is absent rather than refusing. (2) **Bind address, derived from the origin** — `https://…` means a proxy terminates TLS, so bind `127.0.0.1`; plain `http://` on a public host means nothing is in front, so bind `0.0.0.0`. The common misconfiguration (app publicly reachable on its raw port behind a proxy) becomes impossible by default. (3) **Peer-auth bootstrap** — a fresh `apt-get install postgresql` cluster has only the `postgres` role, so a deploy user's `createdb` fails with `role "deploy" does not exist`; that reads like a broken install and is really an unbootstrapped cluster, so we create the login role as the superuser. (4) **Health means the PUBLIC origin** — the loopback probe proves the app; DNS/proxy/TLS are where a host actually fails, so it probes the advertised origin too and names the missing layer (advisory — DNS may legitimately not be wired). Secrets go to a root-owned `0600` `EnvironmentFile`, never `Environment=` (`systemctl show` leaks those, and the laptop path's `buildEnv()` spreads the caller's whole shell). Every decision is a pure injectable in `dev-lib.js`, proved in `tests/dev_lib.mjs` with no host. Rejected: a separate `deploy.sh` (drift — the point is the *same* six steps), Docker-on-the-host (makes brand+port a compose override), always binding `0.0.0.0` (exposes the raw port), refusing without systemd (breaks containers/BSD).](<redacted>.md) | onboarding / deploy |
|
|
246
|
+
| 0173 | [**The legacy multi-ship reward shortfall is NOT backfilled — it closes as a one-time transition cost** ([task 1002520](https://cloudbongos.com/builders#/task/1002520), goal 1000037). Before `core_196` the cost-plus reward booked ONE row per session, so a multi-ship session (the normal shape — ship, new worktree, claim again) was paid at its first ship's level and never topped up; `core_196`'s watermark accounting `(session_id, reward_basis)` fixed that forward and deferred the retroactive half to an "owner-signed-off backfill, tracked separately". This is that decision, and it is **decline**. Measured live 2026-08-16, both of the task's open questions answered: (1) the task's own premise is wrong — `reward_basis = 0` is the column's DEFAULT, so it means "booked before top-ups existed", not "underpaid", and **over-selects by ~60%** (478 legacy sessions, but 287 are single-ship and were paid in full; only 191 are candidates); (2) re-pricing each candidate's stored `session_records.model_usage` at `round(true_cost_usd × 1.20)` gives **7,697 credits owed** across 119 sessions — example-owner 5,296 (69%), jaxri 2,319, scootermack 82 — with **72 candidates unreconstructable** (empty `model_usage`) and 16 already OVER-paid by 1,131 that nobody proposed clawing back. Declined because it mirrors the ADR 0146 clawback's own logic (the accounting changed; neither side of the boundary reopens), because paying only the 119 that happened to leave a server-side trace is arbitrary against the 38% that did not, and because correcting shortfalls while ignoring overpayment is a policy choice the owner declined to adopt. Consequence recorded plainly: three builders stay permanently short. **Does NOT close task 1002448** — that is the Windows `encodeProjectPath` population, never *recorded* rather than paid at a stale watermark, still recoverable and still owed.](<redacted>.md) | economy / rewards |
|
|
246
247
|
| 0172 | [**The sub-Archon rank roles are owner-editable; the Archon role is not; the substrate keys are non-grantable to everything** ([task 1002961](https://cloudbongos.com/builders#/task/1002961), goal 1000030). Amends ADR 0151 §2's "the four seeded rank-roles are immutable through this API" wall — the GRANT half only. Their grants were always ordinary `governance_role_permissions` rows (the `governance_002` seed writes them); the read-only rendering was route policy, not structure. Three walls now, where there was one: the **Archon** rank-role stays frozen as the LOCKOUT GUARD (it is the only role holding `governance.manage` by seed, so freezing it means no click sequence can revoke the permission needed to reach this tab); the three **sub-Archon** rank-roles become grant-editable, the seven **in-app** Archon powers included (owner's explicit choice, escalation consequence stated); and `substrate` becomes a declared catalog axis — `deploy.push_main`/`path.protected.modify`/`infra.ssh` are non-grantable to EVERY role forever, because ADR 0043 enforces them out of band, so a grant row confers nothing and the toggle would be the UI lying. Grantability goes per-role (`isGrantableTo(key, role)`); `isGrantable(key)` survives as the narrow custom-role question config.js strips against. **Custom roles are deliberately NOT widened** — a rank-role moves only by a rank write, a custom role is assignable by anyone holding `governance.manage`. Assignment stays rank-derived (R95b) untouched. Adds `POST /governance/roles/:roleKey/reset` (one transaction, filtered through `isGrantableTo`, refused on a custom role) because a drifted ~60-row rank has no way back by clicking. Rejected: substrate keys behind a warning (a toggle that succeeds and delegates nothing), an editable Archon role behind a confirm (a confirm is not a guard; real recovery is hand-written SQL on the droplet).](<redacted>.md) | governance / permissions |
|
|
247
248
|
| 0171 | [**Profile ↔ cross-project rollup: the join runs in-process on the hub; consent is fail-closed account state; the hide flag lives on the account, enforced in the read path** ([task 1002277](https://cloudbongos.com/builders#/task/1002277), R01 of goal 1000045). The C1 cross-project profile reads the platform-identity rollup through the module's **declared-but-never-registered `platform-identity.hub` port**: the module registers a real provider and core `GET /builders/:id/profile` `resolveOptional`s it beside `reward` — zero network, `null` (clean degrade) on every non-hub instance. Federated instances **link out** instead of inlining (amends profile-page spec Decision 1: an instance-side copy is either stale-on-hide or a per-view hub call). Consent: `profile_state` on `platform_identity_accounts`, **fail-closed** — `active` iff touched by a hub-session `/sso/authorize` (device-poll/check-in create `provisional`; backfill = all provisional, lazy upgrade; no core-`builders` join — type-unsafe vs `system:*` ids and a module-migration boundary breach), so the P03 owner decision can only ever widen, never claw back. The C2 hide flag is `hide_stats` on the account (NOT per-instance `behavior_prefs`), enforced **inside** the module's read functions — `listScoutingRollup` itself gains the `active AND NOT hide_stats` predicate so the live Metic-floor `/people`+`/scouting` surfaces honor it by construction. Hub profile responses go `no-store` (hide must beat the 60s cache). Publishing is gated on hardening the self-reported rollup (membership-row requirement, rank-vocab validation, disciplines caps, check-in→rollup sequencing). Found + filed: the `LATERAL unnest` fan-out inflating rollup sums (task 1002911), the unreserved `u` slug (task 1002912), the missing platform T&C artifact (task 1002913), criterion amendments (task 1002914). Rejected: instance→hub per-view pull (always-on dependency), hub→instance mirroring (stale-on-hide), storing the flag in builder-settings, backfilling `active` from hub `builders` rows.](<redacted>.md) | platform identity / profiles / privacy |
|
|
248
249
|
| 0170 | [**The featured projection tells time honestly: `created_at` in, `updated_at` out, owner from the project's own archon** ([task 1002778](https://cloudbongos.com/builders#/task/1002778), goal 1000061). The hub card's metadata pass added `created_at` to the public `/projects/featured` projection, labelled **Listed** — it is the moment the hub catalogued the row, never when the project began, and the C6 card test bans the word "Created". Two recorded deviations: (1) the task brief said `updated_at` was ready to add, but the column is bumped by every client re-registration AND the Archon's feature toggle, so as a public "last updated" it would report curation noise as project activity — it may sort, never project, and the test pins the SELECT list itself (`SELECT *` banned: the route is CORS-open rank:public and a star would leak `owner_github_id`/`status`). (2) The owner fact reads the project's OWN leaderboard archon row (login + avatar, type-checked and capped) from the payload project-stats already fetches — zero extra requests, the 2-fetch pin intact, the advisory `owner_github_id` column still unread per the design contract. Multi-archon: top-credits archon is named; a fallen-off-the-page archon renders as no owner shown, never a wrong one. Rejected: projecting `updated_at` as asked (it lies), reading `owner_github_id` (advisory, contract-banned), a third per-origin fetch for activity recency (moves the test pin for a fact nobody asked for yet).](<redacted>.md) | platform identity / public surface |
|
|
250
|
+
| 0174 | [**Governance is renamed government, its positions are ranks, and "role" is reserved for the three crafts** ([task 1003002](https://cloudbongos.com/builders#/task/1003002), R01 of goal 1000068). Owner directive, 2026-08-16. The word **role** named two unrelated things: the permission system's ladder tiers (`governance_roles`, the "rank-roles") and the three crafts (`tasks.discipline` = engineer/artist/ideator) — and goal 1000066 was actively claiming it for the crafts (`three-roles-only`, `users-hold-roles`), putting the two on a collision course. Resolved at full depth rather than as a label pass, because a labels-only fix leaves the UI and the code disagreeing, which is the condition that produced the collision. `modules/governance/` → `modules/government/`; the kernel module key, the config files, `/api/bongos/governance/*` → `/government/*`, and the permission keys `governance.manage` → `government.manage` (~50 sites) + `page.view.governance` → `page.view.government`. **The entity IS the rank — no second noun**: `governance_roles` → `government_ranks`, `role_key` → `rank_key`, `rank-roles.js` → `ranks.js`; "position" stays prose-only, since introducing it as a code term would recreate the qualifier problem "rank-role" already demonstrates. The cross-module kernel port `governance.roleSync` → `government.rankSync` must land atomically across its provider and both consumers (`modules/lifecycle/db.js`, `src/bongos/db.js`) because it is a transaction participant on the rank-write path. Hand-composed bundles stay in the one table as **custom ranks** — the owner's call over splitting them into a separate "appointments" concept, with the objection recorded (a custom bundle is not a ladder position, and ADR 0172 §4 already treats the two kinds as different). Enforced by a `fitness.js` rule, allowlist shipped with reasons. **Discord's server roles keep the word** — that noun belongs to Discord's API. Amends ADR 0151 + ADR 0172 (the editable-rank-roles one) in **vocabulary only**; ladder slugs, `builders.rank`, `requireRank`, and every floor/grant/assignment semantic are untouched. Rejected: renaming the ladder slugs (turns a bounded rename into a trust-boundary migration), a permanent compat shim (both vocabularies stay correct forever).](<redacted>.md) | government / permissions |
|
|
249
251
|
|
|
250
252
|
> ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -355,9 +355,69 @@ async function updateIdea(id, { status, kind, promoted_to_task_id, reviewed_at,
|
|
|
355
355
|
params
|
|
356
356
|
);
|
|
357
357
|
if (rows.length === 0) throw { code: 'IDEA_NOT_FOUND' };
|
|
358
|
+
// Idea → task lineage (task 1002997 / BV1.R173): when a promote sets a task
|
|
359
|
+
// link, record it in the many-tasks side table too, so stream 1 (IC4) can
|
|
360
|
+
// later pay "5 per task shipped under this idea". This is ADDITIVE to the
|
|
361
|
+
// existing single-FK promoted_to_task_id (unchanged) — best-effort so it can
|
|
362
|
+
// never fail a triage verdict that already committed on the line above.
|
|
363
|
+
if (promoted_to_task_id !== undefined && promoted_to_task_id !== null) {
|
|
364
|
+
try { await linkIdeaTask(id, promoted_to_task_id, 'promote'); }
|
|
365
|
+
catch (e) { /* the promote succeeded; lineage is a ledger convenience, not a gate */ }
|
|
366
|
+
}
|
|
358
367
|
return rows[0];
|
|
359
368
|
}
|
|
360
369
|
|
|
370
|
+
// Record that a task descends from an idea (task 1002997 / BV1.R173). The
|
|
371
|
+
// one-idea-many-tasks lineage stream 1 reads. Keyed by task_id (a task has at
|
|
372
|
+
// most one source idea), idempotent: re-linking the same (idea, task) is a
|
|
373
|
+
// no-op; re-pointing a task to a different idea updates its source.
|
|
374
|
+
//
|
|
375
|
+
// TWO ORIGINS, ONE WIRED TODAY. `origin='promote'` is called live below, from
|
|
376
|
+
// updateIdea, on every triage promote. `origin='develop'` is the SEAM for R146
|
|
377
|
+
// (develop a spark into a Full Idea → work): that transition does not exist yet,
|
|
378
|
+
// so this task deliberately does NOT invent a call site for it — wiring a
|
|
379
|
+
// not-yet-built path would be fiction. R146 calls this with origin='develop'
|
|
380
|
+
// when it lands. The value is in the CHECK constraint and accepted here so R146
|
|
381
|
+
// is a one-line call, not a schema change. (This scope was corrected on the task
|
|
382
|
+
// after a grader flag: R173 provides the mechanism; R146 owns the develop call.)
|
|
383
|
+
async function linkIdeaTask(ideaId, taskId, origin = 'promote') {
|
|
384
|
+
await pool.query(
|
|
385
|
+
`INSERT INTO ideas_task_lineage (task_id, idea_id, origin)
|
|
386
|
+
VALUES ($1, $2, $3)
|
|
387
|
+
ON CONFLICT (task_id) DO UPDATE
|
|
388
|
+
SET idea_id = EXCLUDED.idea_id, origin = EXCLUDED.origin`,
|
|
389
|
+
[taskId, ideaId, origin]
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Count tasks that have SHIPPED under an idea (task 1002997 / BV1.R173). This is
|
|
394
|
+
// the query stream 1 (IC4) pays against: `WHERE idea_id = N` hits the index, and
|
|
395
|
+
// the tasks join filters to shipped. Returns a plain integer.
|
|
396
|
+
async function countShippedTasksForIdea(ideaId) {
|
|
397
|
+
const { rows } = await pool.query(
|
|
398
|
+
`SELECT COUNT(*)::int AS n
|
|
399
|
+
FROM ideas_task_lineage l
|
|
400
|
+
JOIN tasks t ON t.id = l.task_id
|
|
401
|
+
WHERE l.idea_id = $1 AND t.status = 'shipped'`,
|
|
402
|
+
[ideaId]
|
|
403
|
+
);
|
|
404
|
+
return rows[0] ? rows[0].n : 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// The source idea of a task, or null (task 1002997 / BV1.R173). The reverse
|
|
408
|
+
// lookup stream 1's ship hook (IC4) uses: given a task that just shipped, which
|
|
409
|
+
// idea — and which author — does it pay?
|
|
410
|
+
async function sourceIdeaForTask(taskId) {
|
|
411
|
+
const { rows } = await pool.query(
|
|
412
|
+
`SELECT l.idea_id, l.origin, i.captured_by
|
|
413
|
+
FROM ideas_task_lineage l
|
|
414
|
+
JOIN idea_inbox i ON i.id = l.idea_id
|
|
415
|
+
WHERE l.task_id = $1`,
|
|
416
|
+
[taskId]
|
|
417
|
+
);
|
|
418
|
+
return rows[0] || null;
|
|
419
|
+
}
|
|
420
|
+
|
|
361
421
|
// -------------------------------------------------------------------------
|
|
362
422
|
// Grade writes (task 1002916 / BV1.R140)
|
|
363
423
|
//
|
|
@@ -676,6 +736,11 @@ module.exports = {
|
|
|
676
736
|
updateIdea,
|
|
677
737
|
setIdeaGrade,
|
|
678
738
|
setIdeaCompleteness,
|
|
739
|
+
// idea → task lineage (task 1002997 / BV1.R173) — the one-idea-many-tasks
|
|
740
|
+
// link stream 1 (IC4) pays against
|
|
741
|
+
linkIdeaTask,
|
|
742
|
+
countShippedTasksForIdea,
|
|
743
|
+
sourceIdeaForTask,
|
|
679
744
|
// criterion-proposal ratification (V3.R31 / #252)
|
|
680
745
|
ratifyCriterionProposal,
|
|
681
746
|
parseProposalBody,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- ideas_002_idea_tasks.sql — idea → task lineage (task 1002997, BV1.R173, goal 1000067).
|
|
2
|
+
--
|
|
3
|
+
-- WHY A JOIN TABLE, NOT A COLUMN. Stream 1 of the ideator credit lane (IC4,
|
|
4
|
+
-- ADR 0172) pays "5 credits per task shipped UNDER the idea" — one idea can
|
|
5
|
+
-- descend into MANY tasks, especially once a Full Idea becomes a goal with many
|
|
6
|
+
-- tasks (R149 triage recommends exactly that). idea_inbox.promoted_to_task_id is
|
|
7
|
+
-- a single FK (one idea → one task) and stays as-is for back-compat; this table
|
|
8
|
+
-- is the one-idea-many-tasks lineage the credit count reads.
|
|
9
|
+
--
|
|
10
|
+
-- WHY IT LIVES HERE, NAMESPACED. The many-tasks link could be a column on the
|
|
11
|
+
-- `tasks` table — but `tasks` is the PROTECTED `lifecycle` module, and this
|
|
12
|
+
-- goal's scope wall is `ideas` + `economy`. A module migration also may not
|
|
13
|
+
-- ALTER a table outside its own `ideas_` namespace (ADR 0083 §Decision #5,
|
|
14
|
+
-- fitness Check 11). So the lineage is an IDEAS-OWNED side table keyed by task
|
|
15
|
+
-- id — the same shape that let ideas_001_grades add a grade without touching
|
|
16
|
+
-- idea_inbox. It references both idea_inbox and tasks (FKs to existing tables
|
|
17
|
+
-- are reads, not schema changes to them), so it stays inside the wall.
|
|
18
|
+
--
|
|
19
|
+
-- Module migrations are ADDITIVE and NAMESPACED: every object is ideas_-prefixed,
|
|
20
|
+
-- no down-migration, idempotent (safe to re-run).
|
|
21
|
+
|
|
22
|
+
BEGIN;
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS ideas_task_lineage (
|
|
25
|
+
-- The task, PRIMARY KEY: a task descends from AT MOST ONE idea (its source).
|
|
26
|
+
-- That keeps stream 1's "5 per shipped task" unambiguous — a task cannot pay
|
|
27
|
+
-- two ideas — and makes the credit lookup a point read. CASCADE: if the task
|
|
28
|
+
-- is deleted the lineage row is meaningless.
|
|
29
|
+
task_id bigint PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
|
30
|
+
|
|
31
|
+
-- The idea this task descends from. NOT unique — one idea → many tasks is the
|
|
32
|
+
-- whole point. CASCADE with the idea: no orphan lineage.
|
|
33
|
+
idea_id bigint NOT NULL REFERENCES idea_inbox(id) ON DELETE CASCADE,
|
|
34
|
+
|
|
35
|
+
-- HOW the task came from the idea, for audit + so IC4 can weigh them
|
|
36
|
+
-- differently later if wanted. 'promote' = a triage promote (the existing
|
|
37
|
+
-- path); 'develop' = a spark developed into a Full Idea then into work (R146).
|
|
38
|
+
origin text NOT NULL DEFAULT 'promote'
|
|
39
|
+
CHECK (origin IN ('promote', 'develop')),
|
|
40
|
+
|
|
41
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- The credit count is "tasks shipped under idea N" → WHERE idea_id = N. Index it.
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_ideas_task_lineage_idea ON ideas_task_lineage (idea_id);
|
|
46
|
+
|
|
47
|
+
COMMIT;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.180",
|
|
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.180",
|
|
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.180",
|
|
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
|
@@ -783,8 +783,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
783
783
|
// landed since 1.19.176 with no explicit bump. run 31971532260. (task 1002620)
|
|
784
784
|
// 1.19.178 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
785
785
|
// landed since 1.19.177 with no explicit bump. run 31971769062. (task 1002620)
|
|
786
|
+
// 1.19.179 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
787
|
+
// landed since 1.19.178 with no explicit bump. run 31972209764. (task 1002620)
|
|
788
|
+
// 1.19.180 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
789
|
+
// landed since 1.19.179 with no explicit bump. run 31972568511. (task 1002620)
|
|
786
790
|
// ---------------------------------------------------------------------------
|
|
787
|
-
const CORE_VERSION = '1.19.
|
|
791
|
+
const CORE_VERSION = '1.19.180'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
788
792
|
|
|
789
793
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
790
794
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// tests/idea_task_lineage.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002997 (BV1.R173, goal 1000067) — idea → task lineage.
|
|
4
|
+
//
|
|
5
|
+
// The done-when as assertions: an idea can be traced to every task descended
|
|
6
|
+
// from it; a promote sets the link (additive to the single-FK
|
|
7
|
+
// promoted_to_task_id, which is unchanged); counting "tasks shipped under idea
|
|
8
|
+
// N" is one query; the reverse lookup names the source idea + author for a
|
|
9
|
+
// task that just shipped (what stream 1's ship hook reads).
|
|
10
|
+
//
|
|
11
|
+
// DB-free the tests/idea_grades.mjs way: pool.query patched on the doorway
|
|
12
|
+
// before inbox.js loads. The stub models ideas_task_lineage as an in-memory
|
|
13
|
+
// map keyed by task_id, so the ON CONFLICT (task_id) upsert and the joins
|
|
14
|
+
// behave like Postgres.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/idea_task_lineage.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const api = require('../src/module-api.js');
|
|
23
|
+
|
|
24
|
+
// in-memory stand-ins for the two tables the lineage queries touch
|
|
25
|
+
let lineage = new Map(); // task_id -> { idea_id, origin }
|
|
26
|
+
let tasks = new Map(); // task_id -> { status }
|
|
27
|
+
let ideaInbox = new Map(); // idea_id -> { captured_by }
|
|
28
|
+
let lastUpdate = null;
|
|
29
|
+
|
|
30
|
+
api.pool.query = async (sql, params = []) => {
|
|
31
|
+
// linkIdeaTask: INSERT ... ON CONFLICT (task_id) DO UPDATE
|
|
32
|
+
if (/INSERT INTO ideas_task_lineage/i.test(sql)) {
|
|
33
|
+
const [taskId, ideaId, origin] = params;
|
|
34
|
+
lineage.set(String(taskId), { idea_id: String(ideaId), origin });
|
|
35
|
+
return { rowCount: 1, rows: [] };
|
|
36
|
+
}
|
|
37
|
+
// countShippedTasksForIdea: JOIN tasks, WHERE idea_id=$1 AND status='shipped'
|
|
38
|
+
if (/COUNT\(\*\)::int AS n/i.test(sql)) {
|
|
39
|
+
const ideaId = String(params[0]);
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const [taskId, row] of lineage) {
|
|
42
|
+
if (row.idea_id === ideaId && (tasks.get(taskId) || {}).status === 'shipped') n++;
|
|
43
|
+
}
|
|
44
|
+
return { rows: [{ n }] };
|
|
45
|
+
}
|
|
46
|
+
// sourceIdeaForTask: JOIN idea_inbox, WHERE task_id=$1
|
|
47
|
+
if (/l\.idea_id, l\.origin, i\.captured_by/i.test(sql)) {
|
|
48
|
+
const taskId = String(params[0]);
|
|
49
|
+
const row = lineage.get(taskId);
|
|
50
|
+
if (!row) return { rows: [] };
|
|
51
|
+
return { rows: [{ idea_id: row.idea_id, origin: row.origin, captured_by: (ideaInbox.get(row.idea_id) || {}).captured_by ?? null }] };
|
|
52
|
+
}
|
|
53
|
+
// updateIdea's CTE UPDATE ... RETURNING — record it, return a plausible row
|
|
54
|
+
if (/UPDATE idea_inbox/i.test(sql)) {
|
|
55
|
+
lastUpdate = { sql, params };
|
|
56
|
+
return { rows: [{ id: params[0], status: 'promoted', promoted_to_task_id: params[1] ?? null, grade: 'quick' }] };
|
|
57
|
+
}
|
|
58
|
+
// getIdea and anything else
|
|
59
|
+
return { rows: [{ id: params[0], status: 'open', grade: 'quick' }] };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
63
|
+
|
|
64
|
+
let passed = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
async function test(name, fn) {
|
|
67
|
+
lineage = new Map(); tasks = new Map(); ideaInbox = new Map(); lastUpdate = null;
|
|
68
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
69
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await test('linkIdeaTask records one idea → many tasks', async () => {
|
|
73
|
+
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
74
|
+
await inbox.linkIdeaTask(5, 101, 'promote');
|
|
75
|
+
await inbox.linkIdeaTask(5, 102, 'develop');
|
|
76
|
+
assert.equal(lineage.size, 3);
|
|
77
|
+
for (const [, row] of lineage) assert.equal(row.idea_id, '5', 'all three trace to idea 5');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('re-linking the same task is idempotent; re-pointing updates the source', async () => {
|
|
81
|
+
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
82
|
+
await inbox.linkIdeaTask(5, 100, 'promote'); // same → no growth
|
|
83
|
+
assert.equal(lineage.size, 1);
|
|
84
|
+
await inbox.linkIdeaTask(9, 100, 'develop'); // re-point task 100 to idea 9
|
|
85
|
+
assert.equal(lineage.size, 1, 'still one row keyed by task_id');
|
|
86
|
+
assert.equal(lineage.get('100').idea_id, '9', 'source updated');
|
|
87
|
+
assert.equal(lineage.get('100').origin, 'develop');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('countShippedTasksForIdea counts only shipped descendants', async () => {
|
|
91
|
+
await inbox.linkIdeaTask(5, 100); await inbox.linkIdeaTask(5, 101); await inbox.linkIdeaTask(5, 102);
|
|
92
|
+
tasks.set('100', { status: 'shipped' });
|
|
93
|
+
tasks.set('101', { status: 'shipped' });
|
|
94
|
+
tasks.set('102', { status: 'ready' }); // not shipped yet
|
|
95
|
+
await inbox.linkIdeaTask(9, 200); tasks.set('200', { status: 'shipped' }); // a different idea
|
|
96
|
+
assert.equal(await inbox.countShippedTasksForIdea(5), 2, 'two of idea 5\'s three tasks are shipped');
|
|
97
|
+
assert.equal(await inbox.countShippedTasksForIdea(9), 1);
|
|
98
|
+
assert.equal(await inbox.countShippedTasksForIdea(404), 0, 'an idea with no tasks counts 0');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('sourceIdeaForTask names the idea + author, or null', async () => {
|
|
102
|
+
ideaInbox.set('5', { captured_by: '42' });
|
|
103
|
+
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
104
|
+
const src = await inbox.sourceIdeaForTask(100);
|
|
105
|
+
assert.equal(src.idea_id, '5');
|
|
106
|
+
assert.equal(src.captured_by, '42', 'the author stream 1 pays');
|
|
107
|
+
assert.equal(src.origin, 'promote');
|
|
108
|
+
assert.equal(await inbox.sourceIdeaForTask(999), null, 'a task with no source idea is null');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('a promote through updateIdea sets the lineage AND the legacy single FK', async () => {
|
|
112
|
+
await inbox.updateIdea(5, { status: 'promoted', promoted_to_task_id: 100 });
|
|
113
|
+
// legacy single FK still written (the UPDATE carried it)
|
|
114
|
+
assert.ok(lastUpdate && lastUpdate.params.includes(100), 'promoted_to_task_id still set on idea_inbox');
|
|
115
|
+
// and the many-tasks lineage recorded it too
|
|
116
|
+
assert.equal(lineage.get('100')?.idea_id, '5', 'lineage row added on promote');
|
|
117
|
+
assert.equal(lineage.get('100')?.origin, 'promote');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('a non-promote update does not touch lineage', async () => {
|
|
121
|
+
await inbox.updateIdea(5, { status: 'discarded', triage_note: 'stale' });
|
|
122
|
+
assert.equal(lineage.size, 0, 'discard writes no lineage row');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
console.log(`\nidea_task_lineage: ${passed} passed, ${failed} failed`);
|
|
126
|
+
if (failed > 0) process.exit(1);
|