@bongos/core 1.19.178 → 1.19.179
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 +28 -13
- package/docs/adr/0173-legacy-multi-ship-shortfall-not-backfilled.md +53 -0
- package/docs/adr/README.md +1 -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 +3 -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.179",
|
|
6
|
+
"core_contract": "1.19.179",
|
|
7
|
+
"source_commit": "3e3923982635eedbb47c31fed40dc74640631692",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T21:01:16.700Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 307,
|
|
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": 1604,
|
|
20
|
+
"tree_sha256": "d5756378b193fb24bf46906f062f0bc598975f90a75a41d3a9f92a6e2f79834a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1344,10 +1344,15 @@
|
|
|
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
|
+
},
|
|
1347
1352
|
{
|
|
1348
1353
|
"path": "docs/adr/README.md",
|
|
1349
1354
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1355
|
+
"sha256": "6699594ef0076aa00dbf458b2491fe41f2f2da39c3c19305b3e3c5da47e0a522"
|
|
1351
1356
|
},
|
|
1352
1357
|
{
|
|
1353
1358
|
"path": "docs/api-reference.md",
|
|
@@ -3467,13 +3472,18 @@
|
|
|
3467
3472
|
{
|
|
3468
3473
|
"path": "modules/ideas/inbox.js",
|
|
3469
3474
|
"mode": "0000644",
|
|
3470
|
-
"sha256": "
|
|
3475
|
+
"sha256": "082e2598eec5645a84182d0b56f6e2ba9917d54c0af81bea3196d531099d4249"
|
|
3471
3476
|
},
|
|
3472
3477
|
{
|
|
3473
3478
|
"path": "modules/ideas/migrations/ideas_001_grades.sql",
|
|
3474
3479
|
"mode": "0000644",
|
|
3475
3480
|
"sha256": "4bdd4ebf495d19562fdb8faf508df3a4f577cb0fcc8a879a7eeb1dffd3699caa"
|
|
3476
3481
|
},
|
|
3482
|
+
{
|
|
3483
|
+
"path": "modules/ideas/migrations/ideas_002_idea_tasks.sql",
|
|
3484
|
+
"mode": "0000644",
|
|
3485
|
+
"sha256": "3c30ba66b8426d6f304c8135764108f25c4c9aeb8a571347dd7c22ff7a68fad2"
|
|
3486
|
+
},
|
|
3477
3487
|
{
|
|
3478
3488
|
"path": "modules/ideas/module.json",
|
|
3479
3489
|
"mode": "0000644",
|
|
@@ -4227,12 +4237,12 @@
|
|
|
4227
4237
|
{
|
|
4228
4238
|
"path": "package-lock.json",
|
|
4229
4239
|
"mode": "0000644",
|
|
4230
|
-
"sha256": "
|
|
4240
|
+
"sha256": "f38fd02d66fd26b996d7be16700d41174d6b8c1806f749785c0b22b0cd2da412"
|
|
4231
4241
|
},
|
|
4232
4242
|
{
|
|
4233
4243
|
"path": "package.json",
|
|
4234
4244
|
"mode": "0000644",
|
|
4235
|
-
"sha256": "
|
|
4245
|
+
"sha256": "6bb296f3e03b8a7ed59c82229a022ca09332049e657a582f5ec3a9e568d2fdef"
|
|
4236
4246
|
},
|
|
4237
4247
|
{
|
|
4238
4248
|
"path": "public-docs/index.html",
|
|
@@ -5837,7 +5847,7 @@
|
|
|
5837
5847
|
{
|
|
5838
5848
|
"path": "src/module-api.js",
|
|
5839
5849
|
"mode": "0000644",
|
|
5840
|
-
"sha256": "
|
|
5850
|
+
"sha256": "40087cb50f30e260e252072f490d8c38a7a4e19aa92d92d31ba16b347b168212"
|
|
5841
5851
|
},
|
|
5842
5852
|
{
|
|
5843
5853
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6979,6 +6989,11 @@
|
|
|
6979
6989
|
"mode": "0000644",
|
|
6980
6990
|
"sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
|
|
6981
6991
|
},
|
|
6992
|
+
{
|
|
6993
|
+
"path": "tests/idea_task_lineage.mjs",
|
|
6994
|
+
"mode": "0000644",
|
|
6995
|
+
"sha256": "cd782459f1e4e6ca6df8a0349521aedcc6a867128240a78d2f00e15f0fd5f259"
|
|
6996
|
+
},
|
|
6982
6997
|
{
|
|
6983
6998
|
"path": "tests/idea_templates.mjs",
|
|
6984
6999
|
"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.
|
package/docs/adr/README.md
CHANGED
|
@@ -243,6 +243,7 @@ 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 |
|
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.179",
|
|
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.179",
|
|
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.179",
|
|
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,10 @@ 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)
|
|
786
788
|
// ---------------------------------------------------------------------------
|
|
787
|
-
const CORE_VERSION = '1.19.
|
|
789
|
+
const CORE_VERSION = '1.19.179'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
788
790
|
|
|
789
791
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
790
792
|
// 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);
|