@bongos/core 1.19.177 → 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 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.177",
6
- "core_contract": "1.19.177",
7
- "source_commit": "ec902233a41d6187d97c1b0761ca59b57ae3b180",
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-16T20:47:16.225Z",
9
+ "built_at": "2026-08-16T21:01:16.700Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 306,
12
+ "docs_redacted": 307,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1273,
14
+ "functional_verbatim": 1276,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1600,
20
- "tree_sha256": "07b51c6ba546cfe6dc3f252283ffdbf5fa06230b8a86baeb82b82311d5e11dee",
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": "6956721fd794123ff5c51909fcee4bd03e1f1c455765ed8805fcf0fd94d92986"
1355
+ "sha256": "6699594ef0076aa00dbf458b2491fe41f2f2da39c3c19305b3e3c5da47e0a522"
1351
1356
  },
1352
1357
  {
1353
1358
  "path": "docs/api-reference.md",
@@ -2832,7 +2837,7 @@
2832
2837
  {
2833
2838
  "path": "modules/economy/credits.js",
2834
2839
  "mode": "0000644",
2835
- "sha256": "38526b1d7a095fc48d062ddd645df074f381d2ef0e0aac6593ff306601659904"
2840
+ "sha256": "b6f8c6fcabe6cb9f5eb83e936d129509004c86969d5e0d16a9653ff06b88f7ac"
2836
2841
  },
2837
2842
  {
2838
2843
  "path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
@@ -2847,7 +2852,7 @@
2847
2852
  {
2848
2853
  "path": "modules/economy/reward.js",
2849
2854
  "mode": "0000644",
2850
- "sha256": "2c0e17de0497d642dd28be0d810dd24bfcfc263fd805612f2f0cc05bc330f1d8"
2855
+ "sha256": "2a354daf06e344a613c8ad7df12ed6e534a9920f49b9f62b54e29b64ead59546"
2851
2856
  },
2852
2857
  {
2853
2858
  "path": "modules/economy/routes/cost.js",
@@ -3467,17 +3472,22 @@
3467
3472
  {
3468
3473
  "path": "modules/ideas/inbox.js",
3469
3474
  "mode": "0000644",
3470
- "sha256": "5f4b91ab9eb47ca4c34b7163a2e46934c648654c6909f9623385daf3429c9a48"
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",
3480
- "sha256": "7aa38c4dc690735cea2c1ab1ea61b9fa34281ec6f3505ed091990949f741b77d"
3490
+ "sha256": "64116b6abe3b2e13e3e6697b5c53e12534de698376410e2a14e319d4836481be"
3481
3491
  },
3482
3492
  {
3483
3493
  "path": "modules/ideas/routes/blockers.js",
@@ -3487,7 +3497,7 @@
3487
3497
  {
3488
3498
  "path": "modules/ideas/routes/inbox.js",
3489
3499
  "mode": "0000644",
3490
- "sha256": "8604c627617cc90fa3197dcf3776ce708546d754b9acc2d45c16dfea97a478cb"
3500
+ "sha256": "d6307869ae1b4388aa33244a1807f74ca2442122dea597e2f1ea5b7fe4533495"
3491
3501
  },
3492
3502
  {
3493
3503
  "path": "modules/ideas/templates.js",
@@ -4227,12 +4237,12 @@
4227
4237
  {
4228
4238
  "path": "package-lock.json",
4229
4239
  "mode": "0000644",
4230
- "sha256": "bd31fbe4175062649acbd10eba6caca643e13941a4d8a366bd5ba2625e7ac92d"
4240
+ "sha256": "f38fd02d66fd26b996d7be16700d41174d6b8c1806f749785c0b22b0cd2da412"
4231
4241
  },
4232
4242
  {
4233
4243
  "path": "package.json",
4234
4244
  "mode": "0000644",
4235
- "sha256": "f5fe7936f9d6739c7268697daab41d5520a0da60bc2b63099d8bd7145e977091"
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": "5904881edeeaeedb873a2968c36a01b3c2faf6e79112bd56653f28692aa5267b"
5850
+ "sha256": "40087cb50f30e260e252072f490d8c38a7a4e19aa92d92d31ba16b347b168212"
5841
5851
  },
5842
5852
  {
5843
5853
  "path": "src/module-loader/catalog.js",
@@ -6964,6 +6974,11 @@
6964
6974
  "mode": "0000644",
6965
6975
  "sha256": "95b53f1acfa61f723741b4d33b5e882525903ccc549ae01f3094a788251a60ec"
6966
6976
  },
6977
+ {
6978
+ "path": "tests/idea_full_pass_credit.mjs",
6979
+ "mode": "0000644",
6980
+ "sha256": "1ae98ae22202e6a6dbf42a48e0133654eeda32eec36b697c59f6b9d630936817"
6981
+ },
6967
6982
  {
6968
6983
  "path": "tests/idea_grades.mjs",
6969
6984
  "mode": "0000644",
@@ -6974,6 +6989,11 @@
6974
6989
  "mode": "0000644",
6975
6990
  "sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
6976
6991
  },
6992
+ {
6993
+ "path": "tests/idea_task_lineage.mjs",
6994
+ "mode": "0000644",
6995
+ "sha256": "cd782459f1e4e6ca6df8a0349521aedcc6a867128240a78d2f00e15f0fd5f259"
6996
+ },
6977
6997
  {
6978
6998
  "path": "tests/idea_templates.mjs",
6979
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.
@@ -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 |
@@ -107,6 +107,17 @@ const KIND_MULTIPLIERS = Object.freeze({
107
107
  });
108
108
  const IDEA_BONUS_FRACTION = 0.25; // 25% slice to the idea's original capturer
109
109
 
110
+ // =========================================================================
111
+ // The IDEATOR credit lane — stream 2 (ADR 0172 / goal 1000067 IC2).
112
+ // =========================================================================
113
+ // A Full Idea that clears the completeness bar pays its author a flat credit,
114
+ // ONCE. This is the ideator lane, NOT the builder per-task estimate — it gates
115
+ // on config.ideatorCredit, never perTaskCredit, so cost-plus-only does not
116
+ // suppress it (that is the whole point of ADR 0172). The other two streams
117
+ // (per-task-ship IC4, governance-pass IC5) will be siblings of this function.
118
+ const IDEA_FULL_PASS_CREDIT = 5; // owner: "gives 5 credits"
119
+ const IDEA_FULL_PASS_BAR = 60; // completeness 0-100; ADR 0172 proposed default
120
+
110
121
  // =========================================================================
111
122
  // Session token reward (flat cost-plus) — task 1005 (ADR 0054).
112
123
  // =========================================================================
@@ -321,6 +332,59 @@ async function maybeAwardIdeaPromotionBonus(client, taskId, shipperBuilderId, mu
321
332
  };
322
333
  }
323
334
 
335
+ // Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
336
+ // its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
337
+ // of the reward port — the caller passes its `client` (a pool works too, since
338
+ // the write is a single idempotent statement).
339
+ //
340
+ // client — pg client or pool
341
+ // ideaId, authorId — the idea and its captured_by builder
342
+ // completenessScore — the R142 score (0-100) or null (unscorable)
343
+ // config — resolved reward policy; gates on config.ideatorCredit
344
+ //
345
+ // Returns { credited, reason }:
346
+ // { credited: 5, reason: 'paid' } — first pass, booked
347
+ // { credited: 0, reason: 'already_paid' } — a prior row for this idea exists
348
+ // { credited: 0, reason: 'below_bar' } — null or < IDEA_FULL_PASS_BAR
349
+ // { credited: 0, reason: 'ideator_lane_off' } — instance opted the lane out
350
+ //
351
+ // IDEMPOTENCY is the reason string: `idea.credit.full_pass:<ideaId>` is unique
352
+ // per idea, and the INSERT ... WHERE NOT EXISTS is a single atomic statement, so
353
+ // a re-grade / re-capture / concurrent request can never double-pay — no unique
354
+ // constraint or extra column on the core credit_log table needed (which a module
355
+ // migration may not add anyway). task_id is NULL: an idea credit has no task.
356
+ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessScore, config = rewardConfig() } = {}) {
357
+ if (config && config.ideatorCredit === false) {
358
+ return { credited: 0, reason: 'ideator_lane_off' };
359
+ }
360
+ if (completenessScore == null || !Number.isFinite(Number(completenessScore)) || Number(completenessScore) < IDEA_FULL_PASS_BAR) {
361
+ return { credited: 0, reason: 'below_bar' };
362
+ }
363
+ if (authorId == null || ideaId == null) {
364
+ return { credited: 0, reason: 'missing_author_or_idea' };
365
+ }
366
+ const reason = `idea.credit.full_pass:${ideaId}`;
367
+ const description = `Full Idea #${ideaId} passed (completeness ${Math.round(Number(completenessScore))}/100) → ${IDEA_FULL_PASS_CREDIT}c to its author (ideator lane, ADR 0172)`;
368
+ // Idempotency predicate is `builder_id = author AND reason = <per-idea key>`.
369
+ // The `builder_id` term is NOT for correctness — the reason string is already
370
+ // globally unique per idea — it exists to hit the existing
371
+ // `idx_credit_log_builder (builder_id, recorded_at)` index: the NOT EXISTS
372
+ // narrows to the author's handful of credit rows (index scan) instead of a
373
+ // sequential scan of a credit_log that grows with total platform activity.
374
+ // credit_log is a CORE table, so a module migration cannot add an index on
375
+ // `reason` (the additive-namespace rule) — reusing the builder index is the
376
+ // in-scope fix. (R143/IC2 grader finding.)
377
+ const { rowCount } = await client.query(
378
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
379
+ SELECT $1, NULL, $2, $3, $4
380
+ WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $3)`,
381
+ [authorId, IDEA_FULL_PASS_CREDIT, reason, description]
382
+ );
383
+ return rowCount > 0
384
+ ? { credited: IDEA_FULL_PASS_CREDIT, reason: 'paid' }
385
+ : { credited: 0, reason: 'already_paid' };
386
+ }
387
+
324
388
  // Award the flat cost-plus session token reward, idempotent per session_id
325
389
  // (task 1005, ADR 0054). Called inside the same transaction as the session
326
390
  // upsert (see upsertSessionRecordWithReward).
@@ -531,6 +595,9 @@ module.exports = {
531
595
  applyKindMultiplier,
532
596
  insertMultipliedConfirmCredit,
533
597
  maybeAwardIdeaPromotionBonus,
598
+ awardIdeaFullPassCredit,
599
+ IDEA_FULL_PASS_CREDIT,
600
+ IDEA_FULL_PASS_BAR,
534
601
  awardSessionTokenReward,
535
602
  NET_NEGATIVE_BONUS,
536
603
  SUBTRACTOR_THRESHOLD,
@@ -24,6 +24,8 @@ module.exports = {
24
24
  // credit allocation — transaction-participant: caller passes its tx `client`
25
25
  insertMultipliedConfirmCredit: (...a) => credits.insertMultipliedConfirmCredit(...a),
26
26
  maybeAwardIdeaPromotionBonus: (...a) => credits.maybeAwardIdeaPromotionBonus(...a),
27
+ // ADR 0172 IC2 — the ideator credit lane (stream 2): a passing Full Idea pays its author, once.
28
+ awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
27
29
  awardNetNegativeBonus: (...a) => credits.awardNetNegativeBonus(...a),
28
30
  awardSessionTokenReward: (...a) => credits.awardSessionTokenReward(...a),
29
31
  // gamification hooks the lifecycle fires at claim/ship
@@ -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;
@@ -1,11 +1,22 @@
1
1
  {
2
2
  "key": "ideas",
3
3
  "title": "Ideas",
4
- "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
4
+ "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue \u2014 the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 \u00a71); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 \u00a75 \u2014 retiring the temporary `captureIdea` doorway leak).",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
- "maintenance": { "status": "core-maintained" },
8
- "contributes": { "routes": ["inbox", "blockers"] },
9
- "provides": ["ideas.capture"],
10
- "consumes": []
7
+ "maintenance": {
8
+ "status": "core-maintained"
9
+ },
10
+ "contributes": {
11
+ "routes": [
12
+ "inbox",
13
+ "blockers"
14
+ ]
15
+ },
16
+ "provides": [
17
+ "ideas.capture"
18
+ ],
19
+ "consumes": [
20
+ "reward"
21
+ ]
11
22
  }
@@ -120,6 +120,32 @@ module.exports = function buildInboxRouter() {
120
120
  idea.template_key = row.template_key;
121
121
  idea.field_values = row.field_values;
122
122
  idea.completeness_score = row.completeness_score;
123
+
124
+ // Stream 2 (ADR 0172 / IC2): a Full Idea that clears the completeness
125
+ // bar pays its author, once, on the IDEATOR credit lane — a separate
126
+ // reward from the builder estimate, so cost-plus-only does NOT
127
+ // suppress it. Resolved through the economy `reward` port (this
128
+ // module declares `reward` in its consumes), never a direct import.
129
+ // FAIL-OPEN, inside the same guard: a credit hiccup must never lose
130
+ // the capture — the idea is already stored. The port method is
131
+ // idempotent (reason = idea.credit.full_pass:<id>), so this cannot
132
+ // double-pay on a re-capture. The pool is the tx client — the write
133
+ // is one atomic idempotent statement.
134
+ try {
135
+ const reward = api.resolveOptional && api.resolveOptional('reward');
136
+ if (reward && reward.awardIdeaFullPassCredit) {
137
+ const r = await reward.awardIdeaFullPassCredit(api.pool, {
138
+ ideaId: idea.id,
139
+ authorId: req.builder.id,
140
+ completenessScore: row.completeness_score,
141
+ });
142
+ if (r && r.credited > 0) idea.ideator_credit = r.credited;
143
+ }
144
+ } catch (creditErr) {
145
+ console.error('[gds] POST /inbox full-idea pass-credit failed (idea captured + graded, credit skipped):', creditErr);
146
+ // no warning surfaced — the idea and its grade are intact; the credit
147
+ // is the only thing missed, and a reconcile/re-grade path can pay it.
148
+ }
123
149
  } catch (gradeErr) {
124
150
  console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
125
151
  grade_warning = 'captured_but_ungraded';
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.177",
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.177",
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.177",
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
@@ -781,8 +781,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
781
781
  // landed since 1.19.175 with no explicit bump. run 31971125373. (task 1002620)
782
782
  // 1.19.177 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
783
783
  // landed since 1.19.176 with no explicit bump. run 31971532260. (task 1002620)
784
+ // 1.19.178 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
784
788
  // ---------------------------------------------------------------------------
785
- const CORE_VERSION = '1.19.177'; // CI auto-patch carrier (ADR 0161); see changelog above
789
+ const CORE_VERSION = '1.19.179'; // CI auto-patch carrier (ADR 0161); see changelog above
786
790
 
787
791
  // A namespaced logger so a module's log lines are attributable + consistent.
788
792
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,113 @@
1
+ // tests/idea_full_pass_credit.mjs
2
+ //
3
+ // task 1002996 (BV1.R172 / IC2, goal 1000067) — stream 2 of the ideator credit
4
+ // lane: a Full Idea with a passing completeness score pays its author 5 credits,
5
+ // once. ADR 0172.
6
+ //
7
+ // The done-when as assertions: a passing Full Idea books exactly 5; a thin /
8
+ // null-scored one books 0; re-running never double-pays; cost-plus-only does NOT
9
+ // suppress it (it gates on ideatorCredit, never perTaskCredit); an instance that
10
+ // opts the ideator lane out pays nothing.
11
+ //
12
+ // The credit fn (awardIdeaFullPassCredit) is exercised over a stub client that
13
+ // records statements and simulates the WHERE-NOT-EXISTS idempotency, so the
14
+ // idempotency logic is proven without a DB. Pure module import.
15
+ //
16
+ // Run: node tests/idea_full_pass_credit.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 credits = require('../modules/economy/credits.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ async function test(name, fn) {
27
+ try { await fn(); passed++; console.log(` ok ${name}`); }
28
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
29
+ }
30
+
31
+ // A stub client whose credit_log "table" is an in-memory set of reasons, so the
32
+ // INSERT ... WHERE NOT EXISTS (SELECT 1 ... WHERE reason=$3) behaves exactly like
33
+ // Postgres: the second insert of the same reason is a no-op (rowCount 0).
34
+ function stubClient() {
35
+ const rows = [];
36
+ return {
37
+ rows,
38
+ async query(sql, params) {
39
+ // our fn issues exactly one INSERT ... SELECT ... WHERE NOT EXISTS, whose
40
+ // subquery filters on builder_id AND reason (the index-friendly predicate).
41
+ // Mirror both terms so the stub matches the real query.
42
+ const builderId = params[0];
43
+ const reason = params[2];
44
+ assert.match(sql, /builder_id = \$1 AND reason = \$3/, 'idempotency check must scope by builder_id to hit the index');
45
+ const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
46
+ if (exists) return { rowCount: 0, rows: [] };
47
+ rows.push({ builder_id: builderId, delta: params[1], reason, description: params[3] });
48
+ return { rowCount: 1, rows: [] };
49
+ },
50
+ };
51
+ }
52
+
53
+ const PASS = 72; // clears the 60 bar
54
+ const THIN = 40; // below the bar
55
+
56
+ await test('a passing Full Idea books exactly 5 credits to its author', async () => {
57
+ const c = stubClient();
58
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } });
59
+ assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
60
+ assert.equal(r.reason, 'paid');
61
+ assert.equal(c.rows.length, 1);
62
+ assert.equal(c.rows[0].delta, 5);
63
+ assert.equal(c.rows[0].builder_id, 3);
64
+ assert.equal(c.rows[0].reason, 'idea.credit.full_pass:9');
65
+ });
66
+
67
+ await test('re-running pays nothing — idempotent per idea', async () => {
68
+ const c = stubClient();
69
+ const opts = { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } };
70
+ const first = await credits.awardIdeaFullPassCredit(c, opts);
71
+ const second = await credits.awardIdeaFullPassCredit(c, opts);
72
+ const third = await credits.awardIdeaFullPassCredit(c, opts);
73
+ assert.equal(first.credited, 5);
74
+ assert.equal(second.credited, 0);
75
+ assert.equal(second.reason, 'already_paid');
76
+ assert.equal(third.credited, 0);
77
+ assert.equal(c.rows.length, 1, 'exactly one credit_log row ever, across three calls');
78
+ });
79
+
80
+ await test('a below-bar or null score books 0 — scoring gates the pay, not capture', async () => {
81
+ const c = stubClient();
82
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 1, authorId: 3, completenessScore: THIN, config: { ideatorCredit: true } })).reason, 'below_bar');
83
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 2, authorId: 3, completenessScore: null, config: { ideatorCredit: true } })).reason, 'below_bar');
84
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 3, authorId: 3, completenessScore: undefined, config: { ideatorCredit: true } })).reason, 'below_bar');
85
+ assert.equal(c.rows.length, 0, 'nothing written for any sub-bar score');
86
+ });
87
+
88
+ await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardless of perTaskCredit', async () => {
89
+ // the live cloudbongos.com policy: builder estimate off, ideator lane on
90
+ const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
91
+ assert.equal(policy.perTaskCredit, false);
92
+ assert.equal(policy.ideatorCredit, true);
93
+ const c = stubClient();
94
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 5, authorId: 3, completenessScore: PASS, config: policy });
95
+ assert.equal(r.credited, 5, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
96
+ });
97
+
98
+ await test('an instance that opts the ideator lane out pays nothing', async () => {
99
+ const c = stubClient();
100
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: 3, completenessScore: PASS, config: { ideatorCredit: false } });
101
+ assert.equal(r.credited, 0);
102
+ assert.equal(r.reason, 'ideator_lane_off');
103
+ assert.equal(c.rows.length, 0);
104
+ });
105
+
106
+ await test('a missing author or idea is a safe 0, never a throw', async () => {
107
+ const c = stubClient();
108
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
109
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: null, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
110
+ });
111
+
112
+ console.log(`\nidea_full_pass_credit: ${passed} passed, ${failed} failed`);
113
+ if (failed > 0) process.exit(1);
@@ -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);