@bongos/core 1.19.466 → 1.19.468

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.466",
6
- "core_contract": "1.19.466",
7
- "source_commit": "5b915c14235b08df52728e3d94dc196d548791a3",
5
+ "core_version": "1.19.468",
6
+ "core_contract": "1.19.468",
7
+ "source_commit": "46a154331618a8c57ed0d178e7bfe13e180b7559",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-02T02:25:23.295Z",
9
+ "built_at": "2026-09-02T14:11:23.358Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 407,
12
+ "docs_redacted": 408,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1822,
14
+ "functional_verbatim": 1823,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2253,
20
- "tree_sha256": "4bb527a26dc80efbf88ff9d1a853737b1a657a8f300f8fbf01e76b4146834f58",
19
+ "file_count": 2255,
20
+ "tree_sha256": "96abcd9bb3bfd8f8bfc4930c27fe91aa04b9f59717743a5b059f768854c865c3",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1629,10 +1629,15 @@
1629
1629
  "mode": "0000644",
1630
1630
  "sha256": "717bdc174891ccf5bc8756b25f53eca016d4d6dfaeebec8d12bb2a6bfef56976"
1631
1631
  },
1632
+ {
1633
+ "path": "docs/adr/0225-privacy-suppresses-cross-project-surfaces-only.md",
1634
+ "mode": "0000644",
1635
+ "sha256": "f9f3a0b8ebe2d161e17e1c632bbbda71842c9476958294330420f28c6301f7db"
1636
+ },
1632
1637
  {
1633
1638
  "path": "docs/adr/README.md",
1634
1639
  "mode": "0000644",
1635
- "sha256": "ec731cb900dc5b30287a370325042a4ead509759aba5688c32d91f293047c491"
1640
+ "sha256": "7b353237a164c704ac247646b93c419b26cc5fd8e53ac81e63b553146df76c85"
1636
1641
  },
1637
1642
  {
1638
1643
  "path": "docs/api-reference.md",
@@ -2122,7 +2127,7 @@
2122
2127
  {
2123
2128
  "path": "docs/module-api-changelog.md",
2124
2129
  "mode": "0000644",
2125
- "sha256": "5e800493043ac5941fa8ed5f9efc66359a0622f79968fd7158a61651556c1cf3"
2130
+ "sha256": "b0ee40d833a5f28cba9450cc355d4b2b922def1e95fed5725ebee8796f2918a6"
2126
2131
  },
2127
2132
  {
2128
2133
  "path": "docs/modules-contract.md",
@@ -3612,7 +3617,7 @@
3612
3617
  {
3613
3618
  "path": "modules/economy/credits.js",
3614
3619
  "mode": "0000644",
3615
- "sha256": "2269a87709349cd410bad2714189788d24c1edff3e654af5b47aacb93c415cdb"
3620
+ "sha256": "06f1cc7080509864ea4f474627a7a35de8348fcf4f935a13fc62da38e2a67fa8"
3616
3621
  },
3617
3622
  {
3618
3623
  "path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
@@ -3632,7 +3637,7 @@
3632
3637
  {
3633
3638
  "path": "modules/economy/reward.js",
3634
3639
  "mode": "0000644",
3635
- "sha256": "59952daf3cb8cadc33f5249fb07552949d390ba37c71299117a2d503f83e422a"
3640
+ "sha256": "13cc932402c3cf0d2154507c6e6a462aeb98520a2214b8561ec01a6bb9245fd6"
3636
3641
  },
3637
3642
  {
3638
3643
  "path": "modules/economy/routes/cost.js",
@@ -6622,12 +6627,12 @@
6622
6627
  {
6623
6628
  "path": "package-lock.json",
6624
6629
  "mode": "0000644",
6625
- "sha256": "098654f260139d3b7b4d45ef87d7a15c3ca8a0ebb04ce36ba538e212c3dce351"
6630
+ "sha256": "775fa829d773f6c39be8c93c544e77c005304ff78a3c879778292fd5dc806ee8"
6626
6631
  },
6627
6632
  {
6628
6633
  "path": "package.json",
6629
6634
  "mode": "0000644",
6630
- "sha256": "9670afc099badbbc922edd16e4cb6a1a99d370267cc15b2232af678b6c65f7b0"
6635
+ "sha256": "0d5b71ad68cce7afe9f0d4b35f8f4105fad5f00f46e91f60659ebf064f288f62"
6631
6636
  },
6632
6637
  {
6633
6638
  "path": "public-docs/index.html",
@@ -8277,7 +8282,7 @@
8277
8282
  {
8278
8283
  "path": "src/module-api.js",
8279
8284
  "mode": "0000644",
8280
- "sha256": "400ed351948806a44d6558bbc10d35158237d80c3101415acebb6d075747c3ae"
8285
+ "sha256": "97199ab275115c07426ca83be7b0c618b3aebdb4b1e9349ab90ea989f8d625b4"
8281
8286
  },
8282
8287
  {
8283
8288
  "path": "src/module-loader/catalog.js",
@@ -9794,6 +9799,11 @@
9794
9799
  "mode": "0000644",
9795
9800
  "sha256": "63afe2afd59ff64e290234a0cc8d33a0f77b784d8c10c2d7147fe2ab3c114cd1"
9796
9801
  },
9802
+ {
9803
+ "path": "tests/idea_governance_pass_credit.mjs",
9804
+ "mode": "0000644",
9805
+ "sha256": "75af0cfce88da50c74a1c499e7308774b70eb05e31a3274246dd6b4c8fdb1b34"
9806
+ },
9797
9807
  {
9798
9808
  "path": "tests/idea_grades.mjs",
9799
9809
  "mode": "0000644",
@@ -0,0 +1,84 @@
1
+ # 0225 — Privacy suppresses cross-project surfaces only: a private builder still appears on their own project's roster
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-02
5
+ - **Tasks:** [#1003443](https://cloudbongos.com/builders#/task/1003443) (goal 1000075 — collaboration and the social hall; criterion `community-is-one-builders-page`)
6
+ - **Related:** [ADR 0171](<redacted>.md) (owns `hide_stats` + `profile_state`, fail-closed consent in the READ path) · [`docs/specs/<redacted>.md`](../specs/<redacted>.md) (the spec this amends)
7
+
8
+ ## 1. Why this ADR exists
9
+
10
+ The privacy spec is **accepted**, and for one case it defined its two flags
11
+ inconsistently. Found 2026-08-30 while scoping the Builders page:
12
+
13
+ - `hide_stats` suppresses *"everything derived from the **cross-project rollup**"* —
14
+ qualified, so a project's own in-hall leaderboard is plainly out of scope.
15
+ - `account_visibility: private` said *"absent from search, **leaderboards**, sitemap"* —
16
+ **not** qualified, so it read as covering the in-project leaderboard too.
17
+
18
+ Two readers could act on the same accepted spec and build opposite behaviour. Worse,
19
+ PV2 requires *"ONE shared predicate used by every public read: … leaderboards …"*,
20
+ while `modules/economy/credits.js`'s `leaderboard()` is a bare
21
+ `SELECT … FROM builders ORDER BY total_credits DESC` — no join to
22
+ `platform_identity_accounts`, no predicate at all. Read one way that is an unshipped
23
+ PV2 violation sitting in main; read the other way it is correct. Nothing in the
24
+ documents settled which.
25
+
26
+ ## 2. The decision
27
+
28
+ **Privacy suppresses CROSS-PROJECT surfaces only.** Owner, 2026-08-30.
29
+
30
+ A builder marked `private` is absent from `/u/<handle>`, platform search, the sitemap,
31
+ Explore-adjacent surfaces and the recruiting reach — every surface that is *about the
32
+ platform*. They still appear on the roster and leaderboard of a project they actually
33
+ build on, because **a project's record of its own work is the project's business**. The
34
+ flag governs how far a builder's identity travels, not whether the work they did for
35
+ you is yours to show.
36
+
37
+ Two things follow, and they are the point of writing this down:
38
+
39
+ - **`credits.leaderboard()` is correct as written and needs no change.** Adding a
40
+ visibility predicate there would be a regression, not a fix — which is exactly what
41
+ a future session reading PV2 literally would have done.
42
+ - **PV2's "every public read … leaderboards" means the cross-project leaderboards.**
43
+ The shared predicate still owns every surface it names; the in-project ledger was
44
+ never one of them.
45
+
46
+ ## 3. What changed
47
+
48
+ The spec was amended rather than left to be re-litigated — the ambiguity was in its
49
+ wording, so the wording is the fix (task 1003443):
50
+
51
+ - the D1 `account_visibility` row now says *cross-project* leaderboards, and says
52
+ outright that the flag does not reach a project's own roster;
53
+ - the two `active · private` matrix rows no longer say **"absent everywhere"** — the
54
+ single most misreadable phrase in the file — but "absent from every cross-project
55
+ surface";
56
+ - the `active · public` rows qualify their "leaderboards" the same way, so the matrix
57
+ cannot be read against the table;
58
+ - PV2's own ship-table row is qualified too;
59
+ - and the boundary is stated as the **third rule** under D2, beside "`hide_stats`
60
+ beats everything", because that block is where a reader looks for the rules the
61
+ matrix encodes.
62
+
63
+ ## 4. Rejected
64
+
65
+ **Making `private` hide a builder from the projects they build on.** It reads as the
66
+ more privacy-protective option and is the reason the ambiguity was worth an owner
67
+ decision rather than a coin flip. Rejected because it breaks the ledger: a project
68
+ whose roster silently omits contributors cannot show who built it, credits and karma
69
+ would be awarded to someone invisible on the surface that explains them, and the
70
+ in-hall leaderboard would disagree with the task history any builder can already read.
71
+ Privacy about *the platform* does not imply anonymity *inside a project you joined*.
72
+
73
+ **Leaving it as a dated note in the spec.** The task offered this and it is the wrong
74
+ shape: the decision resolves a stated ambiguity in an accepted spec and pins a piece
75
+ of shipped code as deliberately predicate-free. That is an ADR's job — a note in a
76
+ spec is not what a future session greps when it wonders why one leaderboard query has
77
+ no visibility join.
78
+
79
+ ## 5. What this does not decide
80
+
81
+ Whether an in-project roster may be made public to the world (a per-project
82
+ visibility question the platform owns — goal 1000058's `platformVisibility`), and
83
+ whether a builder can leave a project's roster entirely. Both are about the
84
+ *project's* surface, not the account flag this ADR scopes.
@@ -311,5 +311,6 @@ This keeps the decision history honest and traceable.
311
311
  | 0222 | [**The image family under the hero-only rule: plates and hero objects, never screens; the mark is drawn; the board is a contact sheet; the pair and the sidecar are the output contract** ([task 1003328](https://cloudbongos.com/builders#/task/1003328) · goal 1000083). The four image skills of `Leonxlnx/taste-skill` (scanned `dangerous`, ADR 0198) are built around pictures of interfaces: one reference image per section, one app screen per flow step in a phone mockup, an identity board with the brand name rendered in, and a build loop whose truth is the generated image; the panel flagged the eagerness mandate, the image-count rule and the must-invoke-any-tool directive by name, and the owner's rule (2026-08-26/27) forbids the category. Decisions: **D1** the subject is the composition's plate slots and the count is the slot count; variety survives as anchors (field, beside-bleeding, masthead, orb, backdrop; text-left image-right refused), the palette lock is the pack, cross-frame consistency is one family (material, light, lens, ground). **D2** a screen is the surface itself: the mobile skill makes portrait plates for 390 / 320, the flow is the states file rendered by the kit, the bible is `DESIGN.md`, no phone mockup, native declines to `/design`. **D3** the source of truth for UI is the owner's references and the world; `image-to-code` reads them (analysis, extraction spec, faithful translation, drift as the failure mode, the clarity checklist) and the only image it may cause is the hero plate. **D4** the mark is drawn as inline SVG, the colour system is the pack, the type specimen is the faces on a real page, the applications are the kit's renders, the image direction is a hero-object family, the nine-panel board is a contact sheet of real parts. **D5** the output contract is DESIGN.md's pair and sidecar (JPEG dark plate, WebP alpha cutout, `<file>.<ext>.json` beside each, `--plate-*` tokens on `body`) through the instance's art pipeline key, the spend logged, a white ground through the remover, alpha verified with ffmpeg, never a crop. **D6** the worked example is the grove specimen's hero object, not the front door. **D7** the pins: the four declared, each naming the sidecar, "text-free" and the art key; no eagerness / count / per-section / must-invoke directive. Rejected: per-section images for reference only; phone mockups around real shots; a generated logo as a start; the plate on the front door; a shared base skill with wrappers.](<redacted>.md) | ui-design / skills / imagery |
312
312
  | 0223 | [**The anti-pattern detector is two tiers inside the kit: mechanical floors fail, craft tells warn, a false positive is waived in the file with its reason, and nothing new is installed** ([task 1003329](https://cloudbongos.com/builders#/task/1003329) · goal 1000083). The [detector] group of impeccable's rebuild spec (about sixty rules, a CLI plus a browser bundle wired to hooks, per-project ignores, in-file waivers, "a clean scan is a floor and never proof") lands as RULES in the kit that already exists, per the task body: no second tool, no bundle, no hook. Decisions: **D1** `kit/tells.js` carries the static rules and `check-mock.js` runs them after the platform rules and taste bans; `lib.js`'s `factsInPage` collects the computed-value twins as `tells` and `render.js` prints them as `~ tell:` lines per render, recorded in `report.json`, never counted as problems. Two tiers: floors FAIL (a bounce easing; a card nested in a card) and tells WARN (the icon-heading-text grid, hero metrics, section numbering, a kicker above the h1, a side stripe, a hard offset shadow, a coloured halo, decorative blur, a default typeface, a wide measure, a long headline, the same entrance everywhere, a geometric mask on a plate, glyphs as icons, a rainbow gradient, the browser surfaces nobody themed, proportional numerals, tiny type; rendered: measure, tiny text, numerals in cells, headline lines, an overshooting curve). **D2** the world decides which tells are floors: a kicker above the h1 is a FAIL where the instance's `DESIGN.md` bans the eyebrow (a new `eyebrow` taste key), a WARN elsewhere. **D3** a false positive is waived in the file with its reason (`kit-ignore: <rule prefix> (reason)` → a WAIVE row), `--raw` runs with none, `--no-tells` runs the platform rules only, `--json` prints rows with `fails` / `warns` counts; no per-project ignore config. **D4** not ported: the purple-to-blue tell (a literal colour already fails; hue is unresolvable under the fifteen), an injected overlay, the screenshot sampler (`contrast.js --url` is that pass), a cascade resolver (the browser is the resolver), native code, every hook. **D5** the pins: a clean fixture passes every rule, every rule class trips at its tier with the case count equal to the rule count, the promotion, the waiver / raw / json paths through the real CLI, `craftTells` as warnings; the hub mock and the three specimens still pass, `/projects` and the grove specimen still render ALL CLEAN. Rejected: a second CLI; every tell as a floor (the kit as the taste and the instance as the exception); a project-wide ignore config; an edit-time hook.](0223-the-detector-is-two-tiers-in-the-kit.md) | ui-design / kit |
313
313
  | 0224 | [**Module-owned skills reach the core checkout as untracked, self-ignored copies with a manifest: the self-copy guard stays, the dev path is a flag, and the file map skips what the module documents** ([task 1003330](https://cloudbongos.com/builders#/task/1003330) · goal 1000083). `materializeClaude`'s self-copy guard is right (a core copying itself is a no-op at best), but Claude Code discovers only `.claude/skills/` at the project root, so a builder IN the core checkout could not invoke any of the nine ui-design skills written there. Decisions: **D1** `node scripts/gds/claude-materialize.js --module-skills-only [--dry-run] [--clean]` calls `materializeModuleSkillsIntoCore`: the same roster, gate and slug rule as step 1b, verbatim copies (no bongosify: the checkout runs `node scripts/gds/…`), a core-owned name kept, a disabled module's excluded; the guard untouched. **D2** untracked and self-ignored: a generated `.claude/skills/.module-skills.json` manifest and a `.gitignore` that ignores itself, the manifest and every materialised dir; each run removes what the last manifest named then lands the current set (byte-identical on repeat; a dropped declaration disappears); `--clean` stops after the removal. **D3** `gen-file-map.js` and `tests/file_map.mjs` skip materialised names by the manifest (a module skill is documented by its module's README, not by the notes sidecar). **D4** init from a checkout that carries the copies still lands them through step 1b, transformed, and never the dotfiles. **D5** pinned end to end in `tests/claude_materialize.mjs`. Rejected: dropping the guard; committing the copies; symlinks or junctions; notes for dirs not in the repository.](<redacted>.md) | materialiser / skills |
314
+ | 0225 | [**Privacy suppresses cross-project surfaces only: a private builder still appears on their own project's roster** ([task 1003443](https://cloudbongos.com/builders#/task/1003443) · goal 1000075). The accepted privacy spec defined its two flags inconsistently for one case: `hide_stats` suppresses "everything derived from the CROSS-PROJECT rollup" (qualified, so an in-project leaderboard is out of scope) while `account_visibility: private` said "absent from search, LEADERBOARDS, sitemap" (unqualified, so it read as covering the in-project one too) — and PV2 demands "ONE shared predicate used by every public read: … leaderboards …" while `modules/economy/credits.js`'s `leaderboard()` is a bare `SELECT … FROM builders` with no join and no predicate. Read one way that is an unshipped PV2 violation in main; read the other it is correct, and nothing settled which. **Owner decision (2026-08-30): privacy suppresses CROSS-PROJECT surfaces only.** A `private` builder is absent from `/u/<handle>`, platform search, the sitemap, Explore and the recruiting reach, and still appears on the roster of a project they build on — a project's record of its own work is the project's business. Two consequences are the point of recording it: `credits.leaderboard()` is **correct as written** (adding a predicate there would be a regression, not a fix — exactly what a literal PV2 reading would have done), and PV2's "leaderboards" means the cross-project ones. The spec was amended rather than annotated: the D1 row and both `active · public` matrix rows now say *cross-project*, the two `active · private` rows drop "absent everywhere" (the file's most misreadable phrase) for "absent from every cross-project surface", PV2's ship-table row is qualified, and the boundary lands as the **third rule** under D2 beside "`hide_stats` beats everything". Rejected: making `private` hide a builder from projects they build on (it reads as more protective and breaks the ledger — a roster that omits contributors cannot show who built the thing, and credits would accrue to someone invisible on the surface explaining them); and leaving it as a dated spec note (it pins shipped code as deliberately predicate-free, which is an ADR's job).](<redacted>.md) | privacy / spec |
314
315
 
315
316
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -1369,5 +1369,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1369
1369
  landed since 1.19.464 with no explicit bump. run 33581328823. (task 1002620)
1370
1370
  1.19.466 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1371
1371
  landed since 1.19.465 with no explicit bump. run 33583122775. (task 1002620)
1372
+ 1.19.467 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1373
+ landed since 1.19.466 with no explicit bump. run 33638703282. (task 1002620)
1374
+ 1.19.468 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1375
+ landed since 1.19.467 with no explicit bump. run 33640370009. (task 1002620)
1372
1376
  ---------------------------------------------------------------------------
1373
1377
  ```
@@ -119,6 +119,11 @@ const IDEA_FULL_PASS_CREDIT = 5; // owner: "gives 5 credits"
119
119
  const IDEA_FULL_PASS_BAR = 60; // completeness 0-100; ADR 0172 proposed default
120
120
  const IDEA_TASK_SHIP_BASE = 5; // stream 1: base credit per shipped task under an idea
121
121
  const QUICK_IDEA_FACTOR = 0.6; // a quick/spark idea's grade factor (ADR 0172 default)
122
+ // Stream 3 (IC5): a board-ratified Full Idea pays a share of a 100-credit
123
+ // ceiling, in proportion to how complete the idea was. Owner: "30-100 credits
124
+ // ... paid as a percent on the 100 credits for its fullness."
125
+ const IDEA_GOVERNANCE_PASS_MAX = 100; // owner: "up to 100 credits"
126
+ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on the award fn
122
127
 
123
128
  // =========================================================================
124
129
  // Session token reward (flat cost-plus) — task 1005 (ADR 0054).
@@ -577,6 +582,114 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
577
582
  : { credited: 0, reason: 'already_paid', ideaId: Number(ideaId), authorId: Number(authorId) };
578
583
  }
579
584
 
585
+ // Stream 3 (ADR 0172 / goal 1000067 IC5): a Full Idea that PASSES the Board
586
+ // Room's ratification vote pays a share of a 100-credit ceiling, scaled by the
587
+ // idea's R142 completeness — "70%-complete + passes = 70". The ideator lane, so
588
+ // it gates on config.ideatorCredit, never perTaskCredit.
589
+ //
590
+ // THE BOARD CALLS THIS THROUGH THE `reward` PORT, INSIDE ITS CLOSE TRANSACTION
591
+ // (docs/specs/board-room-pass-event-contract.md, Decision 1) — not off the
592
+ // `board.item.passed` seam event, because emitAsync swallows listener errors,
593
+ // which is right for a Discord post and disqualifying for a payment. If this
594
+ // write fails the close rolls back and the item stays open, so the vote is
595
+ // retried rather than the author silently unpaid.
596
+ //
597
+ // RE-DRIVABLE BY DESIGN (contract Decision 5): the durable fact is the
598
+ // `board_items` row with outcome='passed', not a delivered event. Nothing here
599
+ // reads board state or requires a live listener, and the reason key makes a
600
+ // blanket re-run safe, so a missed award can be reconciled by sweeping the
601
+ // passed items and calling this again. No such sweep is scheduled in v1 — the
602
+ // requirement is only that the shape permits one.
603
+ //
604
+ // client — pg client / pool (transaction participant)
605
+ // ideaId, authorId — the ratified idea and its captured_by builder
606
+ // completenessScore — the R142 score (0-100)
607
+ // subjectType — the board subject, when the caller knows it; only
608
+ // 'full_idea' pays (contract Decision 2). Omitted means
609
+ // "already filtered", which is how the board calls it.
610
+ // config — resolved reward policy; gates on config.ideatorCredit
611
+ //
612
+ // Returns { credited, reason }:
613
+ // { credited: N, reason: 'paid' } — first pass for this idea
614
+ // { credited: 0, reason: 'already_paid' } — a prior row for this idea
615
+ // { credited: 0, reason: 'not_a_full_idea' } — an amendment pays nothing
616
+ // { credited: 0, reason: 'ideator_lane_off' } — instance opted the lane out
617
+ // { credited: 0, reason: 'missing_author_or_idea' }
618
+ // { credited: 0, reason: 'no_completeness_score' }
619
+ //
620
+ // NO `below_bar` RETURN, deliberately (contract Decision 4): ADR 0175 §5 makes
621
+ // the completeness bar the gate on OPENING a vote window at all, so an idea
622
+ // below the bar can never reach a pass. Re-checking it here would imply the
623
+ // board might send something it cannot.
624
+ async function awardIdeaGovernancePassCredit(client, {
625
+ ideaId, authorId, completenessScore, subjectType, config = rewardConfig(),
626
+ } = {}) {
627
+ // SUBJECT TYPE FIRST, ahead of the lane check that opens both sibling streams:
628
+ // a constitutional amendment traverses the same close path and is not an
629
+ // ideator payment at all, so whether the ideator lane is on is irrelevant to
630
+ // it. A re-drive sweep reading these reasons gets the truthful one.
631
+ if (subjectType != null && subjectType !== 'full_idea') {
632
+ return { credited: 0, reason: 'not_a_full_idea' };
633
+ }
634
+ if (config && config.ideatorCredit === false) {
635
+ return { credited: 0, reason: 'ideator_lane_off' };
636
+ }
637
+ if (ideaId == null) {
638
+ return { credited: 0, reason: 'missing_author_or_idea' };
639
+ }
640
+ if (completenessScore == null || !Number.isFinite(Number(completenessScore))) {
641
+ return { credited: 0, reason: 'no_completeness_score' };
642
+ }
643
+ // The parties come from the DB, not the call site (the R147 rule): there is
644
+ // exactly one place that decides who earns from an idea. `authorId` is a
645
+ // fallback for a caller holding an idea this lookup cannot see.
646
+ const parties = await ideaCreditParties(client, ideaId);
647
+ const resolvedAuthor = (parties && parties.authorId != null) ? parties.authorId : authorId;
648
+ if (resolvedAuthor == null) {
649
+ return { credited: 0, reason: 'missing_author_or_idea' };
650
+ }
651
+ // CLAMPED to [0,1] before scaling, so a malformed score can never pay out
652
+ // more than the ceiling (the stream-1 precedent). The floor then applies:
653
+ // it is UNREACHABLE under default config — ADR 0175 §5 gates window-opening
654
+ // on IDEA_FULL_PASS_BAR (60), so a real payout lands in [60,100] and max()
655
+ // never binds — but it ships because it is the owner's stated intent and goes
656
+ // live the moment either constant moves.
657
+ const pct = Math.max(0, Math.min(1, Number(completenessScore) / 100));
658
+ const delta = Math.max(
659
+ IDEA_GOVERNANCE_PASS_FLOOR,
660
+ Math.round(pct * IDEA_GOVERNANCE_PASS_MAX)
661
+ );
662
+ // Split with whoever DEVELOPED the spark, the same 70/30 as streams 1 and 2
663
+ // (ADR 0185) — the contract predates that split and names the author alone;
664
+ // mirroring the sibling streams, as it asks, means splitting here too.
665
+ const payees = splitIdeaCredit(delta, {
666
+ authorId: resolvedAuthor,
667
+ developedBy: parties ? parties.developedBy : null,
668
+ });
669
+ if (payees.length === 0) {
670
+ return { credited: 0, reason: 'zero_after_scaling' };
671
+ }
672
+ // ONCE PER IDEA, FOREVER. A resubmission that passes again, a re-close, a
673
+ // replay, or a concurrent request all resolve to this same key and book
674
+ // nothing further. Builder-scoped so the NOT EXISTS hits
675
+ // idx_credit_log_builder (credit_log is a CORE table — a module migration may
676
+ // not add an index on `reason`), and because a split books one row per payee
677
+ // under ONE reason, a reason-only predicate would refuse the second payee.
678
+ // task_id is NULL: an idea credit has no task.
679
+ const reason = `idea.credit.governance_pass:${ideaId}`;
680
+ const scorePct = Math.round(Number(completenessScore));
681
+ const { credited, payees: booked } = await bookIdeaSplit(client, {
682
+ payees,
683
+ reason,
684
+ describe: (p) => `Full Idea #${ideaId} ratified by the board (completeness ${scorePct}/100) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 3, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
685
+ });
686
+ return {
687
+ credited,
688
+ reason: credited > 0 ? 'paid' : 'already_paid',
689
+ payees: booked,
690
+ };
691
+ }
692
+
580
693
  // Award the flat cost-plus session token reward, idempotent per session_id
581
694
  // (task 1005, ADR 0054). Called inside the same transaction as the session
582
695
  // upsert (see upsertSessionRecordWithReward).
@@ -824,6 +937,7 @@ module.exports = {
824
937
  maybeAwardIdeaPromotionBonus,
825
938
  awardIdeaFullPassCredit,
826
939
  awardIdeaTaskShipCredit,
940
+ awardIdeaGovernancePassCredit,
827
941
  // the spark-handoff split (task 1002923 / BV1.R147, ADR 0185)
828
942
  splitIdeaCredit,
829
943
  IDEA_DEVELOPER_SHARE,
@@ -831,6 +945,8 @@ module.exports = {
831
945
  IDEA_FULL_PASS_BAR,
832
946
  IDEA_TASK_SHIP_BASE,
833
947
  QUICK_IDEA_FACTOR,
948
+ IDEA_GOVERNANCE_PASS_MAX,
949
+ IDEA_GOVERNANCE_PASS_FLOOR,
834
950
  awardSessionTokenReward,
835
951
  NET_NEGATIVE_BONUS,
836
952
  SUBTRACTOR_THRESHOLD,
@@ -28,6 +28,11 @@ module.exports = {
28
28
  awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
29
29
  // ADR 0172 IC4 — stream 1: pay the idea's author per shipped task, grade-scaled (replaces the 25% slice at ship).
30
30
  awardIdeaTaskShipCredit: (...a) => credits.awardIdeaTaskShipCredit(...a),
31
+ // ADR 0172 IC5 — stream 3: a board-ratified Full Idea pays up to 100, scaled by completeness.
32
+ // The board's close transaction resolves this port and calls this with its own
33
+ // `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
34
+ // money crosses the wall; the `board.item.passed` event never carries a payment.
35
+ awardIdeaGovernancePassCredit: (...a) => credits.awardIdeaGovernancePassCredit(...a),
31
36
  awardNetNegativeBonus: (...a) => credits.awardNetNegativeBonus(...a),
32
37
  awardSessionTokenReward: (...a) => credits.awardSessionTokenReward(...a),
33
38
  // gamification hooks the lifecycle fires at claim/ship
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.466",
3
+ "version": "1.19.468",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.466",
9
+ "version": "1.19.468",
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.466",
3
+ "version": "1.19.468",
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
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.466'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.468'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,243 @@
1
+ // tests/idea_governance_pass_credit.mjs
2
+ //
3
+ // task 1002999 (BV1.R175 / IC5, goal 1000067) — stream 3 of the ideator credit
4
+ // lane: a Full Idea that PASSES the Board Room's ratification vote pays a share
5
+ // of a 100-credit ceiling, scaled by its R142 completeness. ADR 0172 §3, and
6
+ // docs/specs/board-room-pass-event-contract.md (the agreement with the board).
7
+ //
8
+ // The done-when as assertions: a ratified Full Idea books round(completeness%)
9
+ // once; a constitutional amendment books nothing; re-running never double-pays;
10
+ // cost-plus-only does NOT suppress it (it gates on ideatorCredit, never
11
+ // perTaskCredit); an instance that opts the ideator lane out pays nothing.
12
+ //
13
+ // The credit fn is exercised over a stub client that records statements and
14
+ // simulates the WHERE-NOT-EXISTS idempotency, so the idempotency logic is proven
15
+ // without a DB — the tests/idea_full_pass_credit.mjs harness, unchanged.
16
+ //
17
+ // Run: node tests/idea_governance_pass_credit.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const credits = require('../modules/economy/credits.js');
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ async function test(name, fn) {
28
+ try { await fn(); passed++; console.log(` ok ${name}`); }
29
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
30
+ }
31
+
32
+ // A stub client whose credit_log "table" is an in-memory set of (builder, reason)
33
+ // pairs, so INSERT ... WHERE NOT EXISTS behaves exactly like Postgres: a second
34
+ // insert for the same pair is a no-op (rowCount 0). `developedBy` defaults to
35
+ // null — an idea nobody else developed — and is set explicitly by the split test.
36
+ function stubClient({ authorId = null, developedBy = null } = {}) {
37
+ const rows = [];
38
+ return {
39
+ rows,
40
+ async query(sql, params) {
41
+ // The parties lookup — who authored this idea, and who developed it.
42
+ if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
43
+ return { rows: authorId == null ? [] : [{ captured_by: authorId, developed_by: developedBy }] };
44
+ }
45
+ // The idempotency predicate must scope by builder_id AND reason: a split
46
+ // books two rows under ONE reason, so a reason-only predicate would
47
+ // silently refuse the second payee (and credit_log is a CORE table, so a
48
+ // module migration cannot add an index on `reason` anyway).
49
+ const builderId = params[0];
50
+ const reason = params[3];
51
+ assert.match(sql, /builder_id = \$1 AND reason = \$4/, 'idempotency check must scope by builder_id AND reason');
52
+ const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
53
+ if (exists) return { rowCount: 0, rows: [] };
54
+ rows.push({ builder_id: builderId, task_id: params[1], delta: params[2], reason, description: params[4] });
55
+ return { rowCount: 1, rows: [] };
56
+ },
57
+ };
58
+ }
59
+
60
+ const ON = { ideatorCredit: true };
61
+
62
+ await test('a ratified Full Idea pays its completeness percent of the 100 ceiling', async () => {
63
+ const c = stubClient({ authorId: 3 });
64
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 9, authorId: 3, completenessScore: 70, config: ON });
65
+ assert.equal(r.credited, 70, 'the owner\'s worked example: 70%-complete + passes = 70');
66
+ assert.equal(r.reason, 'paid');
67
+ assert.equal(c.rows.length, 1);
68
+ assert.equal(c.rows[0].delta, 70);
69
+ assert.equal(c.rows[0].builder_id, 3);
70
+ assert.equal(c.rows[0].reason, 'idea.credit.governance_pass:9');
71
+ assert.equal(c.rows[0].task_id, null, 'an idea credit has no task');
72
+ });
73
+
74
+ await test('a fully-complete idea pays the whole 100-credit ceiling, never more', async () => {
75
+ const c = stubClient({ authorId: 3 });
76
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 1, authorId: 3, completenessScore: 100, config: ON });
77
+ assert.equal(r.credited, credits.IDEA_GOVERNANCE_PASS_MAX);
78
+ assert.equal(r.credited, 100);
79
+ });
80
+
81
+ await test('a malformed over-100 score is clamped to the ceiling, not paid out', async () => {
82
+ const c = stubClient({ authorId: 3 });
83
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 2, authorId: 3, completenessScore: 420, config: ON });
84
+ assert.equal(r.credited, 100, 'the ceiling holds even when the input is nonsense');
85
+ });
86
+
87
+ await test('re-running pays nothing — once per idea, forever', async () => {
88
+ const c = stubClient({ authorId: 3 });
89
+ const opts = { ideaId: 9, authorId: 3, completenessScore: 80, config: ON };
90
+ const first = await credits.awardIdeaGovernancePassCredit(c, opts);
91
+ const second = await credits.awardIdeaGovernancePassCredit(c, opts);
92
+ const third = await credits.awardIdeaGovernancePassCredit(c, opts);
93
+ assert.equal(first.credited, 80);
94
+ assert.equal(second.credited, 0);
95
+ assert.equal(second.reason, 'already_paid');
96
+ assert.equal(third.credited, 0);
97
+ assert.equal(c.rows.length, 1, 'exactly one credit_log row ever, across three calls');
98
+ });
99
+
100
+ await test('a constitutional amendment books nothing — subject_type is load-bearing', async () => {
101
+ const c = stubClient({ authorId: 3 });
102
+ const r = await credits.awardIdeaGovernancePassCredit(c, {
103
+ ideaId: null, authorId: 3, completenessScore: null,
104
+ subjectType: 'constitutional_amendment', config: ON,
105
+ });
106
+ assert.equal(r.credited, 0);
107
+ assert.equal(r.reason, 'not_a_full_idea');
108
+ assert.equal(c.rows.length, 0, 'an amendment traverses the same close path and must pay nobody');
109
+ });
110
+
111
+ await test('subject_type is checked BEFORE the lane flag, so a sweep gets the truthful reason', async () => {
112
+ const c = stubClient({ authorId: 3 });
113
+ const r = await credits.awardIdeaGovernancePassCredit(c, {
114
+ ideaId: 5, authorId: 3, completenessScore: 90,
115
+ subjectType: 'constitutional_amendment', config: { ideatorCredit: false },
116
+ });
117
+ assert.equal(r.reason, 'not_a_full_idea', 'an amendment is not an ideator payment at all');
118
+ });
119
+
120
+ await test('an explicit full_idea subject pays exactly as an omitted one does', async () => {
121
+ const c = stubClient({ authorId: 3 });
122
+ const r = await credits.awardIdeaGovernancePassCredit(c, {
123
+ ideaId: 7, authorId: 3, completenessScore: 65, subjectType: 'full_idea', config: ON,
124
+ });
125
+ assert.equal(r.credited, 65, 'the board omits subjectType because it has already filtered; a sweep passes it');
126
+ });
127
+
128
+ await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardless of perTaskCredit', async () => {
129
+ // the live cloudbongos.com policy: builder estimate off, ideator lane on
130
+ const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
131
+ assert.equal(policy.perTaskCredit, false);
132
+ assert.equal(policy.ideatorCredit, true);
133
+ const c = stubClient({ authorId: 3 });
134
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 5, authorId: 3, completenessScore: 75, config: policy });
135
+ assert.equal(r.credited, 75, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
136
+ });
137
+
138
+ await test('an instance that opts the ideator lane out pays nothing', async () => {
139
+ const c = stubClient({ authorId: 3 });
140
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 6, authorId: 3, completenessScore: 90, config: { ideatorCredit: false } });
141
+ assert.equal(r.credited, 0);
142
+ assert.equal(r.reason, 'ideator_lane_off');
143
+ assert.equal(c.rows.length, 0);
144
+ });
145
+
146
+ await test('an unscorable idea books 0 — and it is NOT reported as below a bar', async () => {
147
+ // There is deliberately no `below_bar` return: ADR 0175 §5 makes the
148
+ // completeness bar the gate on OPENING a window, so a sub-bar idea can never
149
+ // reach a pass. A null score means the score is MISSING, not low.
150
+ const c = stubClient({ authorId: 3 });
151
+ assert.equal((await credits.awardIdeaGovernancePassCredit(c, { ideaId: 1, authorId: 3, completenessScore: null, config: ON })).reason, 'no_completeness_score');
152
+ assert.equal((await credits.awardIdeaGovernancePassCredit(c, { ideaId: 2, authorId: 3, completenessScore: undefined, config: ON })).reason, 'no_completeness_score');
153
+ assert.equal((await credits.awardIdeaGovernancePassCredit(c, { ideaId: 3, authorId: 3, completenessScore: 'x', config: ON })).reason, 'no_completeness_score');
154
+ assert.equal(c.rows.length, 0, 'nothing written when there is no score to scale by');
155
+ });
156
+
157
+ await test('a missing author or idea is a safe 0, never a throw', async () => {
158
+ const noIdea = stubClient({ authorId: 3 });
159
+ assert.equal((await credits.awardIdeaGovernancePassCredit(noIdea, { ideaId: null, authorId: 3, completenessScore: 90, config: ON })).reason, 'missing_author_or_idea');
160
+ // An idea the lookup cannot see AND no caller-supplied author → still a safe 0.
161
+ const unknown = stubClient(); // lookup returns no row
162
+ assert.equal((await credits.awardIdeaGovernancePassCredit(unknown, { ideaId: 6, authorId: null, completenessScore: 90, config: ON })).reason, 'missing_author_or_idea');
163
+ assert.equal(unknown.rows.length, 0);
164
+ });
165
+
166
+ await test('the DB is authoritative for the author; the argument is only a fallback', async () => {
167
+ const known = stubClient({ authorId: 3 });
168
+ const r = await credits.awardIdeaGovernancePassCredit(known, { ideaId: 6, authorId: null, completenessScore: 90, config: ON });
169
+ assert.equal(r.credited, 90);
170
+ assert.equal(known.rows[0].builder_id, 3, 'paid the author the DB names');
171
+
172
+ // ...and when the lookup finds nothing, the passed author is still honoured,
173
+ // so a caller holding an idea this module cannot see is not silently unpaid.
174
+ const invisible = stubClient();
175
+ const f = await credits.awardIdeaGovernancePassCredit(invisible, { ideaId: 6, authorId: 7, completenessScore: 90, config: ON });
176
+ assert.equal(f.credited, 90);
177
+ assert.equal(invisible.rows[0].builder_id, 7);
178
+ });
179
+
180
+ await test('a developed spark splits 70/30 with its developer, conserving the total', async () => {
181
+ // ADR 0185, the same rule streams 1 and 2 apply. The contract for this stream
182
+ // predates the split and names the author alone; mirroring the sibling
183
+ // streams, as it asks, means splitting here too.
184
+ const c = stubClient({ authorId: 3, developedBy: 8 });
185
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 4, authorId: 3, completenessScore: 70, config: ON });
186
+ assert.equal(r.credited, 70, 'the total is conserved across the two payees');
187
+ assert.equal(c.rows.length, 2);
188
+ const dev = c.rows.find((x) => x.builder_id === 8);
189
+ const author = c.rows.find((x) => x.builder_id === 3);
190
+ assert.equal(dev.delta, 49, '70% of 70, rounded');
191
+ assert.equal(author.delta, 21, 'the remainder — never independently rounded');
192
+ assert.equal(dev.reason, author.reason, 'one reason key, two rows — hence the builder-scoped predicate');
193
+ });
194
+
195
+ await test('a self-developed spark collapses to one payee, so nobody is under-paid', async () => {
196
+ const c = stubClient({ authorId: 3, developedBy: 3 });
197
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 11, authorId: 3, completenessScore: 70, config: ON });
198
+ assert.equal(r.credited, 70);
199
+ assert.equal(c.rows.length, 1, 'two rows for one builder would hit the idempotency predicate and UNDER-pay');
200
+ });
201
+
202
+ await test('the floor pays 30 on a sub-30 score — a branch unreachable under default config', async () => {
203
+ // NOT A REAL SCENARIO, and deliberately not faked into one: ADR 0175 §5 gates
204
+ // window-opening on IDEA_FULL_PASS_BAR, so a real payout lands in [60,100]
205
+ // and max() never binds. The branch is exercised by handing the function an
206
+ // input the board cannot currently send — never by lowering the bar in a
207
+ // fixture, which would look like a scenario it is not. It ships because it is
208
+ // the owner's stated intent and goes live the moment either constant moves.
209
+ const c = stubClient({ authorId: 3 });
210
+ const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 12, authorId: 3, completenessScore: 10, config: ON });
211
+ assert.equal(r.credited, credits.IDEA_GOVERNANCE_PASS_FLOOR);
212
+ assert.equal(r.credited, 30);
213
+ });
214
+
215
+ await test('the floor really is unreachable at the shipped constants — the finding, as an assertion', async () => {
216
+ const lowestPassablePayout = Math.round(credits.IDEA_FULL_PASS_BAR / 100 * credits.IDEA_GOVERNANCE_PASS_MAX);
217
+ assert.ok(
218
+ lowestPassablePayout > credits.IDEA_GOVERNANCE_PASS_FLOOR,
219
+ `bar ${credits.IDEA_FULL_PASS_BAR} pays ${lowestPassablePayout}, above the ${credits.IDEA_GOVERNANCE_PASS_FLOOR} floor — if this ever fails, the floor has become live and the branch above is a real scenario`
220
+ );
221
+ });
222
+
223
+ await test('all three ideator streams use distinct reason keys, so one idea can earn on each', async () => {
224
+ const c = stubClient({ authorId: 3 });
225
+ await credits.awardIdeaFullPassCredit(c, { ideaId: 20, authorId: 3, completenessScore: 90, config: ON });
226
+ await credits.awardIdeaGovernancePassCredit(c, { ideaId: 20, authorId: 3, completenessScore: 90, config: ON });
227
+ assert.equal(c.rows.length, 2, 'stream 2 and stream 3 both paid the same idea');
228
+ assert.equal(c.rows[0].reason, 'idea.credit.full_pass:20');
229
+ assert.equal(c.rows[1].reason, 'idea.credit.governance_pass:20');
230
+ assert.equal(c.rows[0].delta + c.rows[1].delta, 95, '5 for the grade, 90 for the ratification');
231
+ });
232
+
233
+ await test('the award rides the reward PORT, so the board can resolve it', async () => {
234
+ const reward = require('../modules/economy/reward.js');
235
+ assert.equal(typeof reward.awardIdeaGovernancePassCredit, 'function',
236
+ 'board.js duck-types on exactly this name; without it the close is a silent no-op');
237
+ const c = stubClient({ authorId: 3 });
238
+ const r = await reward.awardIdeaGovernancePassCredit(c, { ideaId: 30, authorId: 3, completenessScore: 60, config: ON });
239
+ assert.equal(r.credited, 60, 'delegated, not spread — the port reaches the live function');
240
+ });
241
+
242
+ console.log(`\nidea_governance_pass_credit: ${passed} passed, ${failed} failed`);
243
+ if (failed > 0) process.exit(1);