@bongos/core 1.19.297 → 1.19.298
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 +27 -17
- package/docs/adr/0185-spark-handoff-credit-split.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/modules/economy/credits.js +156 -31
- package/modules/ideas/routes/inbox.js +31 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/idea_credit_split.mjs +224 -0
- package/tests/idea_develop_spark.mjs +47 -10
- package/tests/idea_full_pass_credit.mjs +50 -18
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.298",
|
|
6
|
+
"core_contract": "1.19.298",
|
|
7
|
+
"source_commit": "89c91526e13db0ef8a1fbc4091e6691df74f71fc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-22T20:51:27.946Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 322,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1401,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1745,
|
|
20
|
+
"tree_sha256": "885d55252052de946b48981a842f0b6230d156fbb900f77f02c2e1daf0e0d511",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1404,10 +1404,15 @@
|
|
|
1404
1404
|
"mode": "0000644",
|
|
1405
1405
|
"sha256": "d68cae6169d7ef251fdf6c721e63a124bd3bf46979ffa51459db435426d24f8b"
|
|
1406
1406
|
},
|
|
1407
|
+
{
|
|
1408
|
+
"path": "docs/adr/0185-spark-handoff-credit-split.md",
|
|
1409
|
+
"mode": "0000644",
|
|
1410
|
+
"sha256": "51931fe5ae89b14b6c8955e6d6cd43d88a7a6ed05f8c862cdfe56a14302cc990"
|
|
1411
|
+
},
|
|
1407
1412
|
{
|
|
1408
1413
|
"path": "docs/adr/README.md",
|
|
1409
1414
|
"mode": "0000644",
|
|
1410
|
-
"sha256": "
|
|
1415
|
+
"sha256": "96f12718ea595a8fff3d3aeafc27013b4ef251724e80d077607addc167533639"
|
|
1411
1416
|
},
|
|
1412
1417
|
{
|
|
1413
1418
|
"path": "docs/api-reference.md",
|
|
@@ -1417,7 +1422,7 @@
|
|
|
1417
1422
|
{
|
|
1418
1423
|
"path": "docs/api/openapi.json",
|
|
1419
1424
|
"mode": "0000644",
|
|
1420
|
-
"sha256": "
|
|
1425
|
+
"sha256": "f77f37d52d726cf7df447b7988416e5eca802cc2da0398ac1e6e1df212ba664e"
|
|
1421
1426
|
},
|
|
1422
1427
|
{
|
|
1423
1428
|
"path": "docs/architecture.md",
|
|
@@ -2982,7 +2987,7 @@
|
|
|
2982
2987
|
{
|
|
2983
2988
|
"path": "modules/economy/credits.js",
|
|
2984
2989
|
"mode": "0000644",
|
|
2985
|
-
"sha256": "
|
|
2990
|
+
"sha256": "7c42fc848a1b7139d7ffa59cc3d81d89db84329b7beae9d5b8c5bc0afefc6f41"
|
|
2986
2991
|
},
|
|
2987
2992
|
{
|
|
2988
2993
|
"path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
|
|
@@ -3747,7 +3752,7 @@
|
|
|
3747
3752
|
{
|
|
3748
3753
|
"path": "modules/ideas/routes/inbox.js",
|
|
3749
3754
|
"mode": "0000644",
|
|
3750
|
-
"sha256": "
|
|
3755
|
+
"sha256": "44e918d0fe82a22a986f66b33e1165c1f30d3e67b04cde9019a6c293d42f1f72"
|
|
3751
3756
|
},
|
|
3752
3757
|
{
|
|
3753
3758
|
"path": "modules/ideas/routing.js",
|
|
@@ -4542,12 +4547,12 @@
|
|
|
4542
4547
|
{
|
|
4543
4548
|
"path": "package-lock.json",
|
|
4544
4549
|
"mode": "0000644",
|
|
4545
|
-
"sha256": "
|
|
4550
|
+
"sha256": "56a2a9e3416c259a9ee31664dae5bb5509baa1410c40e6e6b0fceca4b3bff209"
|
|
4546
4551
|
},
|
|
4547
4552
|
{
|
|
4548
4553
|
"path": "package.json",
|
|
4549
4554
|
"mode": "0000644",
|
|
4550
|
-
"sha256": "
|
|
4555
|
+
"sha256": "b58f5b7eddb94b3ec917dfe1694957f1da4f7154c4ad655b4aed06bafaf033ac"
|
|
4551
4556
|
},
|
|
4552
4557
|
{
|
|
4553
4558
|
"path": "public-docs/index.html",
|
|
@@ -6202,7 +6207,7 @@
|
|
|
6202
6207
|
{
|
|
6203
6208
|
"path": "src/module-api.js",
|
|
6204
6209
|
"mode": "0000644",
|
|
6205
|
-
"sha256": "
|
|
6210
|
+
"sha256": "3a94110af825d5235724b6d1552ae99b13f90f74ff9b8e82e4ada09885c8b3fd"
|
|
6206
6211
|
},
|
|
6207
6212
|
{
|
|
6208
6213
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7489,10 +7494,15 @@
|
|
|
7489
7494
|
"mode": "0000644",
|
|
7490
7495
|
"sha256": "7e2024b3f1fa79a6bdec55b134be6337d4867df0a77fedb205f32b783a3d6193"
|
|
7491
7496
|
},
|
|
7497
|
+
{
|
|
7498
|
+
"path": "tests/idea_credit_split.mjs",
|
|
7499
|
+
"mode": "0000644",
|
|
7500
|
+
"sha256": "fd96eb0dd472810854d9e0a7ee29725764cf0c3f4ad502c57d6d4a324da25ef5"
|
|
7501
|
+
},
|
|
7492
7502
|
{
|
|
7493
7503
|
"path": "tests/idea_develop_spark.mjs",
|
|
7494
7504
|
"mode": "0000644",
|
|
7495
|
-
"sha256": "
|
|
7505
|
+
"sha256": "a498e2dfc87dbb5050d414ff04ebf4178900edb771be384fe91247e452a37a02"
|
|
7496
7506
|
},
|
|
7497
7507
|
{
|
|
7498
7508
|
"path": "tests/idea_full_capture.mjs",
|
|
@@ -7502,7 +7512,7 @@
|
|
|
7502
7512
|
{
|
|
7503
7513
|
"path": "tests/idea_full_pass_credit.mjs",
|
|
7504
7514
|
"mode": "0000644",
|
|
7505
|
-
"sha256": "
|
|
7515
|
+
"sha256": "63afe2afd59ff64e290234a0cc8d33a0f77b784d8c10c2d7147fe2ab3c114cd1"
|
|
7506
7516
|
},
|
|
7507
7517
|
{
|
|
7508
7518
|
"path": "tests/idea_grades.mjs",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# 0185 — The spark-handoff credit split: 70% developer, 30% spark author
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-08-22
|
|
5
|
+
- **Tasks:** [#1002923](https://cloudbongos.com/builders#/task/1002923) (BV1.R147, goal 1000062 — three grades of idea). Completes the spark chain: [#1002920](https://cloudbongos.com/builders#/task/1002920) (the mark), [#1002921](https://cloudbongos.com/builders#/task/1002921) (the queue), [#1002922](https://cloudbongos.com/builders#/task/1002922) (the pickup)
|
|
6
|
+
- **Extends:** [ADR 0172](<redacted>.md) — the ideator credit lane. That ADR built the streams and paid `captured_by`; this one decides what happens when `captured_by` is not the only person who made the idea.
|
|
7
|
+
- **Related:** ADR 0023 (the original idea-promotion slice), 0146 (reward-mode machinery), 0054 (cost-plus)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
ADR 0172 shipped the ideator credit lane: stream 1 pays 5 credits (scaled by idea
|
|
12
|
+
grade) for every task shipped under an idea, stream 2 pays 5 credits once when a
|
|
13
|
+
Full Idea clears the completeness bar. Both paid `idea_inbox.captured_by` — the
|
|
14
|
+
person who filed the idea — because at the time that was the only person an idea
|
|
15
|
+
had.
|
|
16
|
+
|
|
17
|
+
Goal 1000062 then built the **spark handoff**. An author marks a quick idea as a
|
|
18
|
+
Big Idea Spark ("I have the spark but not the full picture, someone develop
|
|
19
|
+
this" — R144); that mark is the consent that makes cross-builder development
|
|
20
|
+
fair. The spark becomes browsable (R145), and another builder develops it into a
|
|
21
|
+
Full Idea in place, preserving the original author while being recorded as its
|
|
22
|
+
developer (R146, `ideas_grades.developed_by`).
|
|
23
|
+
|
|
24
|
+
That created a person the credit model had no answer for. Two builders now make
|
|
25
|
+
one Full Idea: one had the thought, one wrote the five fields. Both streams still
|
|
26
|
+
paid only the author.
|
|
27
|
+
|
|
28
|
+
**R146 therefore deliberately shipped paying nothing at all.** Not an oversight —
|
|
29
|
+
a refusal to guess. `awardIdeaFullPassCredit`'s idempotency predicate is
|
|
30
|
+
`(builder_id, reason)`, so a payment to the *developer* would **not** have blocked
|
|
31
|
+
a later payment to the *author*: the same idea would have paid twice. Choosing a
|
|
32
|
+
recipient at that call site was not merely premature, it was unsafe. The develop
|
|
33
|
+
route left the money alone and named this task as the owner of the decision.
|
|
34
|
+
|
|
35
|
+
The result was a real gap in the meantime: a developed Full Idea that cleared the
|
|
36
|
+
bar received stream 2 **never** — the only path that could pay it was the author
|
|
37
|
+
resubmitting their own idea.
|
|
38
|
+
|
|
39
|
+
## Decision
|
|
40
|
+
|
|
41
|
+
### 1. 70% to the developer, 30% to the spark author — on both existing streams
|
|
42
|
+
|
|
43
|
+
The owner's call, 2026-08-22. The developer takes the larger share, and the
|
|
44
|
+
reasoning is behavioural rather than sentimental: **developing someone else's
|
|
45
|
+
spark is the scarce act the queue depends on.** At an even split, developing a
|
|
46
|
+
spark pays half what authoring your own Full Idea pays for the same writing work,
|
|
47
|
+
so the rational move is always to write your own and the R145 queue stays a list
|
|
48
|
+
nobody reads. The majority share is what makes the pickup surface function.
|
|
49
|
+
|
|
50
|
+
The author is not left thin. They keep 30% of every split **and** their share of
|
|
51
|
+
stream 1 on every task shipped under the idea, for as long as the idea keeps
|
|
52
|
+
producing work. The thought keeps earning; it simply stops being the only
|
|
53
|
+
contribution the ledger recognises.
|
|
54
|
+
|
|
55
|
+
One ratio, applied to **both** streams — the immediate pass credit and the
|
|
56
|
+
trailing per-task credit — so there is a single rule to explain rather than a
|
|
57
|
+
table of cases. Stream 3 (the board-room payout, [#1002999](https://cloudbongos.com/builders#/task/1002999),
|
|
58
|
+
not yet built) inherits the same helper when it lands.
|
|
59
|
+
|
|
60
|
+
### 2. A policy constant in `credits.js`, not a table
|
|
61
|
+
|
|
62
|
+
`IDEA_DEVELOPER_SHARE = 0.70` sits beside the pass bar and the quick factor, per
|
|
63
|
+
ADR 0172's own consequence ("thresholds/factors are policy constants in
|
|
64
|
+
`credits.js`", following the ADR 0023 precedent). A share of someone's earnings
|
|
65
|
+
deserves a review and a commit, not a runtime edit. It is one constant, so a
|
|
66
|
+
project that wants a different balance changes one line — which is the recorded
|
|
67
|
+
owner philosophy for this goal: a simple base, designed to be altered.
|
|
68
|
+
|
|
69
|
+
### 3. The economy module decides who is paid — the caller no longer can
|
|
70
|
+
|
|
71
|
+
`awardIdeaFullPassCredit` now reads `captured_by` and `developed_by` from the
|
|
72
|
+
database itself (`ideaCreditParties`) instead of trusting the `authorId` its
|
|
73
|
+
caller passes; the argument survives only as a fallback for an idea the lookup
|
|
74
|
+
cannot see. `awardIdeaTaskShipCredit` already read the idea row and simply selects
|
|
75
|
+
`developed_by` alongside.
|
|
76
|
+
|
|
77
|
+
This is the structural half of the decision, and it is what makes R146's caution
|
|
78
|
+
unnecessary going forward. The reason the develop route could not safely pay was
|
|
79
|
+
that *the recipient was chosen at the call site*. With the payees derived in one
|
|
80
|
+
place, no caller can direct money at the wrong builder, and the develop route now
|
|
81
|
+
books the split like any other path — **it hands over an idea and a score, never
|
|
82
|
+
a recipient.**
|
|
83
|
+
|
|
84
|
+
### 4. Three arithmetic properties, each a real bug if missed
|
|
85
|
+
|
|
86
|
+
`splitIdeaCredit` is pure and carries them:
|
|
87
|
+
|
|
88
|
+
- **The total is conserved.** The developer's share is rounded and the author gets
|
|
89
|
+
*the remainder*. Rounding both independently pays 6 credits out of a 5-credit
|
|
90
|
+
payout (`round(3.5) + round(1.5) = 4 + 2`).
|
|
91
|
+
- **A self-developed idea collapses to one payee.** R146 deliberately permits an
|
|
92
|
+
author to develop their own spark. Emitting two rows for one builder would not
|
|
93
|
+
double-pay — it would **under**-pay, because the idempotency predicate is
|
|
94
|
+
`(builder_id, reason)` and the second insert is silently refused, so the builder
|
|
95
|
+
would receive one of their two shares. The comparison normalises with `String()`
|
|
96
|
+
because `bigint` columns arrive as strings: a type mismatch must not become a
|
|
97
|
+
phantom second payee.
|
|
98
|
+
- **Zero-value rows are dropped.** A 1-credit payout splits to 1/0; a 0-credit
|
|
99
|
+
ledger row reads like a payment that was not made.
|
|
100
|
+
|
|
101
|
+
Ids otherwise pass through **unconverted**. `builder_id` is a `bigint`, so
|
|
102
|
+
`Number()`-ing it risks precision on a large id and would change the value type
|
|
103
|
+
the pre-R147 code fed straight into the `INSERT`.
|
|
104
|
+
|
|
105
|
+
### 5. `builder_id` in the idempotency predicate is now load-bearing
|
|
106
|
+
|
|
107
|
+
It entered as an index optimisation — narrowing the `NOT EXISTS` to one builder's
|
|
108
|
+
rows via `idx_credit_log_builder` instead of scanning a growing `credit_log` (the
|
|
109
|
+
R143/IC2 grader finding). Under a split it is also **correctness**: two payees are
|
|
110
|
+
booked under one `reason`, so a reason-only predicate would refuse the second.
|
|
111
|
+
A partially-booked split therefore completes on the next call, and `credited`
|
|
112
|
+
reports only what was newly booked.
|
|
113
|
+
|
|
114
|
+
## Consequences
|
|
115
|
+
|
|
116
|
+
- **The spark queue has a reason to be picked up.** The chain R144 → R145 → R146
|
|
117
|
+
now ends in a payment rather than in attribution alone.
|
|
118
|
+
- **A developed Full Idea gets stream 2 at develop time**, closing the gap R146
|
|
119
|
+
left. It is fail-open: the development has already committed, and a credit-lane
|
|
120
|
+
hiccup must never roll back somebody's work.
|
|
121
|
+
- **One place decides who earns from an idea.** Future callers inherit the split
|
|
122
|
+
for free and cannot get the recipient wrong.
|
|
123
|
+
- **Not decided here:** stream 3's split (it ships with the Board Room, task
|
|
124
|
+
1002999) and whether `origin='develop'` in `ideas_task_lineage` belongs to
|
|
125
|
+
whoever promotes a *developed* idea into a task. ADR 0172 §5 says the lineage
|
|
126
|
+
row is "set at promotion and at develop (R146)", but R146 creates no task —
|
|
127
|
+
developing a spark produces a Full Idea, not work — so that origin is still
|
|
128
|
+
unwired, deliberately, rather than invented. It changes who stream 1 pays, so it
|
|
129
|
+
deserves its own decision.
|
package/docs/adr/README.md
CHANGED
|
@@ -258,5 +258,6 @@ This keeps the decision history honest and traceable.
|
|
|
258
258
|
| 0182 | [**Project visibility is TWO axes: a derived publish gate and a stored join door** ([task 1002320](https://cloudbongos.com/builders#/task/1002320), goal 1000046). Goal 1000046 seeded 31 tasks each citing "per R01" — this ADR — so nothing in the goal could start until it landed: it gated **29 open tasks**, the largest single chokepoint in the repo, while itself parked at `backlog` with no dependencies. The spec its planning task cites was never written, so this is the design of record. The goal's criterion says **four** states (Public/Private/Stealth/Unlisted) and R04 says **three** stored plus a derived dark-matter; both are right and conflating them is the trap. There are two INDEPENDENT axes: **publishability** (DERIVED from the identity gate — name + description, per the owner interview) decides whether a project is on the map at all, and **the join door** (STORED `visibility ∈ public|private|stealth`, owner-set) decides how it renders and who gets in. Four user-facing states = 3 × publishable + one derived state that collapses all three. The column name `visibility` is a mild lie kept for consistency with `goals.visibility` (ADR 0112) — it does NOT decide visibility, publishability does — and the ADR is the mitigation. All three stored states are VISIBLE; only the derived state is invisible, which is what makes the 3+1 split honest rather than a workaround. `private` inherits ADR 0112's exact meaning (visible-but-closed); `stealth` exposes EXISTENCE but not IDENTITY, and its redaction is **server-side in the feed** — the naive client-side version leaks to anyone opening the network tab, and the orbs page is a static site OUTSIDE this repo (R09) so it could never be enforced there. Three mechanisms touch "does this appear" and are separated: `status` (operational), `visibility` (owner), `featured` (Archon editorial) — on the map iff `status='active' AND publishable`, with `featured` only ranking among those. That is a real behaviour change: today's predicate is `featured=true AND status='active'`, so **featuring is currently load-bearing for visibility at all** and the goal's owner-control premise silently does not work without it. Visibility is owned by the INSTANCE and the hub row is a CACHE (ADR 0141 §3 + the admin-console spike's D1) — so `owner_github_id` being "advisory, not an authority" is CORRECT rather than a gap, R05 moves to an instance surface, and R08's absent-report default is fail-closed (unreported ⇒ dark matter, off the map rather than exposed by omission). Per-state join rules compose with the separate `open|closed` join policy ([task 1002817](https://cloudbongos.com/builders#/task/1002817)) by one rule: **visibility sets the ceiling, join policy may only narrow** — otherwise the two settings are a coin-flip decided by evaluation order. Finally, `stealth` COLLIDES with the existing instance-wide pre-launch password gate (`STEALTH_PASSWORD`, `src/bongos/stealth-gate.js`), which means nearly the opposite (a whole instance made unreadable vs a project publicly on the map, anonymously); per ADR 0174 the product meaning keeps the word and the infra rename is filed separately rather than decided by an ADR about visibility. Rejected: a stored 4-valued enum (makes `unlisted` compete with `private`, and lets publishable+unlisted disagree), a separate `published` boolean (can drift from the identity it summarises; derived cannot), and keeping `featured` as the visibility gate (an owner who sets public and appears nowhere has not been given control).](<redacted>.md) | platform-identity / project visibility |
|
|
259
259
|
| 0183 | [**A done-when criterion closes itself; the review queue becomes a place to look** ([task 1002069](https://cloudbongos.com/builders#/task/1002069), goal 1000072, owner request 2026-07-05). ADR 0086 §6 made `satisfied` reachable exactly one way — a Metic+ calling `POST /done-when/:id/satisfy` — even though the signal it gated on is unambiguous (**every** gating task shipped) and a human confirming it adds no information. When nobody runs `/goal-review` the result is not a careful pause but a **lying board**: this session measured goal 1000072 at `6/9 shipped, 0/5 criteria`. Now one helper, `doneWhen.autoSatisfyShippedCriteria(exec, {taskId})`, runs from **two** places for two different reasons — inside `shipTask`'s txn (scoped, **immediate**, on the txn client so a rolled-back ship cannot leave a goal achieved) and unscoped on every `publish-reconciler` tick (**backlog drain** for criteria whose tasks shipped before this existed, since nothing will ship against them again, plus **self-heal**). Idempotent by construction — it reads only `satisfied=false` rows — which is what makes an every-tick sweep safe. **The load-bearing subtlety is the delivery guard.** Auto-close needs BOTH "nothing still pending" AND **≥ 1 task actually `shipped`**; the second is not redundant, because since [task 1002651](https://cloudbongos.com/builders#/task/1002651) made `abandoned` terminal-not-pending, a criterion whose tasks were **all cut** satisfies the first clause *vacuously* and would auto-close on ZERO delivered work, cascading its goal to `achieved` (measured: `buildCriterionProgress` returns `pending_review:true` for exactly that shape). Yet `pending_review` **keeps** that wider meaning on purpose — it asks "should a human LOOK at this", and 1002651 chose that an all-abandoned criterion surfaces so someone decides whether it still means anything. So: everything that auto-closes was flagged; not everything flagged auto-closes. An earlier draft "aligned" the two predicates and broke that intent — `tests/criterion_progress.mjs` caught it; **do not re-align them**, the gap is the design. `/goal-review` is kept, demoted from gate to viewing surface; manual `POST /satisfy` stays for what automation cannot judge (a criterion with no linked tasks never auto-closes). Honest limits recorded: auto-satisfy cannot help a criterion nothing is LINKED to, and the in-txn close logs before commit (accepted — threading the summary out through `withTx` would cost ~5 lines in the over-threshold `db.js` under shrink-only ratchet policy). Rejected: sweep-only (up to 5 min to see your own goal close), hook-only (never drains the backlog), a stored `pending_review` column (second source of truth), deleting `/goal-review`.](<redacted>.md) | lifecycle / goal tier |
|
|
260
260
|
| 0184 | [**A ship must account for itself: `shipTask` requires a land proof or a declared no-artifact** ([task 1002483](https://cloudbongos.com/builders#/task/1002483), goal 1000024). `db.shipTask` is the confirmed→shipped flip and, since [ADR 0120](<redacted>.md), where the CREDIT is booked — yet its only precondition was `WHERE status = 'confirmed'`, nothing about whether code reached main. The two AUTOMATED paths each proved the land first; the MANUAL route passed nothing, so a task confirmed with no pushed code could be shipped by its claim holder **or by any Metic+ sweeping `/merge-mode`**, booking its `task.shipped` credit. It stayed untracked for two months because its tracking task was closed in June asserting task 1001547 had delivered the gate — it had not, blocker 1000047's own final update says so, and the code agreed with the blocker rather than the closure note: **a hole hidden by a record nobody checked**, which is the same failure the gate is about. Decision: the gate lives at the WRITE, not the route (a route gate leaves the invariant unenforced at the layer that moves the ledger, so the next caller reopens it silently), and `shipTask` is FAIL-CLOSED on a required `landAssurance`. TWO kinds, and the second is why this does not make things worse: `proof` (this task's commit is on main) and **`no_artifact`** — a deliberate DB-only ship, an explicitly supported shape (CLAUDE.md "Direct DB writes are work too") that [task 1002478](https://cloudbongos.com/builders#/task/1002478) documents as a dead end below Archon. A naive proof-only gate would have made 1002478 strictly worse; naming no-artifact as a KIND turns it from an absence the gate must be talked out of into a recorded answer. Both carry a reason, audited post-commit and best-effort (an audit miss must never roll back a landed ship). `ship.js` now declares its own `--db-only`/`--allow-empty` lands, without which this fix would have regressed the very flow CLAUDE.md calls supported. Rejected: (b) gate only the route — leaves the write unguarded; (c) record the residual as tolerable — defensible on blast radius and genuinely close, but it would have been the FOURTH record asserting this was handled with nothing checkable behind it. Does NOT gate confirm: a codeless confirm is still accepted, it simply cannot reach `shipped` or pay.](<redacted>.md) | lifecycle / integrity |
|
|
261
|
+
| 0185 | [**The spark-handoff credit split: 70% developer, 30% spark author** ([task 1002923](https://cloudbongos.com/builders#/task/1002923), BV1.R147, goal 1000062 — completing the chain [task 1002920](https://cloudbongos.com/builders#/task/1002920) mark → [task 1002921](https://cloudbongos.com/builders#/task/1002921) queue → [task 1002922](https://cloudbongos.com/builders#/task/1002922) pickup). [ADR 0172](<redacted>.md) built the ideator streams and paid `captured_by`, the only person an idea then had. The spark handoff created a second: R146 develops a spark IN PLACE, preserving the author while recording a `developed_by`. **R146 therefore shipped paying NOTHING on purpose** — not an oversight but a refusal to guess, because `awardIdeaFullPassCredit`'s idempotency predicate is `(builder_id, reason)`, so paying the DEVELOPER would not have blocked a later payment to the AUTHOR and one idea would have paid twice; the cost of that caution was that a developed Full Idea received stream 2 **never** unless its author resubmitted. Owner decision (2026-08-22): **70/30 favouring the developer**, on BOTH existing streams, one ratio so there is one rule to explain. The reasoning is behavioural, not sentimental — developing someone else's spark is the scarce act the queue depends on, and at an even split it pays HALF what authoring your own Full Idea pays for the same writing work, so the rational move is always to write your own and the R145 queue stays a list nobody reads. The author keeps 30% plus their share of stream 1 on every task shipped under the idea, forever. `IDEA_DEVELOPER_SHARE = 0.70` is a **policy constant in `credits.js`** beside the pass bar and quick factor (ADR 0172's own consequence, the ADR 0023 precedent): a share of someone's earnings deserves a review and a commit, not a runtime edit. **The structural half is what retires R146's caution:** the economy module now derives the payees itself (`ideaCreditParties` reads `captured_by` + `developed_by`), so the caller can no longer choose a recipient — the develop route hands over an idea and a score, never a payee, and books the split like any other path (fail-open; the development has already committed). Three arithmetic properties, each a real bug if missed: the total is **conserved** (developer rounded, author gets the REMAINDER — rounding both pays 6 out of a 5-credit payout); a **self-developed** idea collapses to ONE payee, because two rows for one builder would silently UNDER-pay under the per-builder idempotency predicate, not double-pay (the comparison normalises via `String()` since `bigint` arrives as a string, so a type mismatch cannot become a phantom payee); and **zero-value rows are dropped**. Ids otherwise pass through unconverted — `Number()`-ing a `bigint` risks precision. `builder_id` in the idempotency predicate, which entered as an index optimisation (the R143/IC2 grader finding), is now **load-bearing for correctness**: two payees share one `reason`, so a reason-only predicate would refuse the second, and a partially-booked split completes on the next call. **Not decided here:** stream 3's split (ships with the Board Room, task 1002999), and whether `origin='develop'` in `ideas_task_lineage` belongs to whoever promotes a DEVELOPED idea into a task — ADR 0172 §5 says the row is "set at promotion and at develop (R146)", but R146 creates no task, so that origin stays deliberately unwired rather than invented; it changes who stream 1 pays.](<redacted>.md) | economy / ideator lane |
|
|
261
262
|
|
|
262
263
|
> ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
|
package/docs/api/openapi.json
CHANGED
|
@@ -6531,7 +6531,7 @@
|
|
|
6531
6531
|
"inbox"
|
|
6532
6532
|
],
|
|
6533
6533
|
"summary": "POST /inbox/:id/develop",
|
|
6534
|
-
"description": "POST /inbox/:id/develop — DEVELOP A SPARK INTO A FULL IDEA (task 1002922 / BV1.R146, goal 1000062). The pickup half of the handoff R144 and R145 set up: the author consented by marking the spark, the queue made it findable, and now another builder fills in the five template fields and the thought becomes a Full Idea. OPEN TO ANY BUILDER, INCLUDING THE AUTHOR. Cross-builder development is the entire point, so there is no author check here — the mirror image of R144, where the gate was the whole feature. The consent was already given and is already recorded (this row is a spark BECAUSE its author said so), and re-asking for it would make the queue unpickable. Self-development is likewise legal: an author may mark a thought and come back to it themselves. ONLY A SPARK CAN BE DEVELOPED, and \"only once\" is enforced by the DATABASE, not by this handler. developSpark's UPDATE carries `AND grade = 'spark'`, so two builders picking the same spark off the queue at the same instant cannot both succeed — the second matches zero rows. The pre-read below exists ONLY to tell a 404 apart from a 409 and to give a useful message; it is not the guard, and it deliberately does not try to be.
|
|
6534
|
+
"description": "POST /inbox/:id/develop — DEVELOP A SPARK INTO A FULL IDEA (task 1002922 / BV1.R146, goal 1000062). The pickup half of the handoff R144 and R145 set up: the author consented by marking the spark, the queue made it findable, and now another builder fills in the five template fields and the thought becomes a Full Idea. OPEN TO ANY BUILDER, INCLUDING THE AUTHOR. Cross-builder development is the entire point, so there is no author check here — the mirror image of R144, where the gate was the whole feature. The consent was already given and is already recorded (this row is a spark BECAUSE its author said so), and re-asking for it would make the queue unpickable. Self-development is likewise legal: an author may mark a thought and come back to it themselves. ONLY A SPARK CAN BE DEVELOPED, and \"only once\" is enforced by the DATABASE, not by this handler. developSpark's UPDATE carries `AND grade = 'spark'`, so two builders picking the same spark off the queue at the same instant cannot both succeed — the second matches zero rows. The pre-read below exists ONLY to tell a 404 apart from a 409 and to give a useful message; it is not the guard, and it deliberately does not try to be. THE STREAM-2 CREDIT IS PAID HERE, SPLIT (task 1002923 / R147, ADR 0185). R146 shipped this route paying nothing on purpose: with two people involved, the recipient was ambiguous, and awardIdeaFullPassCredit's idempotency predicate is (builder_id, reason) — so guessing the developer would NOT have blocked a later payment to the author, and one idea would have paid twice. R147 resolved that by moving the payee decision INTO the economy module: it reads captured_by + developed_by itself and books 70% to the developer, 30% to the spark author (one payee at 100% when they are the same person). This route can therefore call it safely — it no longer chooses who earns. The board window IS opened, because it is recipient-agnostic (it takes an idea and a score, not a builder) and without it a developed Full Idea could never reach ratification — the handoff would dead-end one step from done. Fail-open like every other caller of that port. rank: any-builder — development is the pickup, and the author's mark is the consent. Deliberately NOT pinned in route-rank-check: there is no gate here to protect, and pinning it would only assert that an open route stays open.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
6535
6535
|
"x-rank": "any-builder",
|
|
6536
6536
|
"x-source": "modules/ideas/routes/inbox.js",
|
|
6537
6537
|
"parameters": [
|
|
@@ -334,6 +334,102 @@ async function maybeAwardIdeaPromotionBonus(client, taskId, shipperBuilderId, mu
|
|
|
334
334
|
};
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
// The spark-handoff split (task 1002923 / BV1.R147, goal 1000062; ADR 0185)
|
|
339
|
+
//
|
|
340
|
+
// When one builder develops ANOTHER builder's Big Idea Spark into a Full Idea
|
|
341
|
+
// (R146), two people made the idea: the one who had the thought and said "I do
|
|
342
|
+
// not have the full picture", and the one who wrote out the five fields. Both
|
|
343
|
+
// existing ideator streams paid `captured_by` alone, so the developer earned
|
|
344
|
+
// nothing and the R145 queue had no reason to ever be picked up.
|
|
345
|
+
//
|
|
346
|
+
// OWNER DECISION (2026-08-22): 70% to the DEVELOPER, 30% to the spark author,
|
|
347
|
+
// on BOTH existing streams. The developer takes the larger share because
|
|
348
|
+
// developing someone else's spark is the scarce act the queue depends on — at
|
|
349
|
+
// 50/50 it pays strictly worse than authoring your own Full Idea, so nobody
|
|
350
|
+
// picks anything up. The author is not left thin: they keep 30% here AND the
|
|
351
|
+
// full trailing stream-1 share on every task shipped under the idea forever.
|
|
352
|
+
//
|
|
353
|
+
// A POLICY CONSTANT IN THIS FILE, not a table — the ADR 0172 §Consequences rule
|
|
354
|
+
// ("thresholds/factors are policy constants in credits.js", the ADR 0023
|
|
355
|
+
// precedent): it deserves a review and a commit, not a runtime edit.
|
|
356
|
+
const IDEA_DEVELOPER_SHARE = 0.70;
|
|
357
|
+
|
|
358
|
+
// splitIdeaCredit — divide one payout between the two parties.
|
|
359
|
+
//
|
|
360
|
+
// THREE PROPERTIES THIS MUST HAVE, each of which is a real bug if missed:
|
|
361
|
+
//
|
|
362
|
+
// 1. THE TOTAL IS CONSERVED. The developer's share is rounded and the author
|
|
363
|
+
// gets THE REMAINDER — never both rounded independently, which would pay
|
|
364
|
+
// 6 credits out of a 5-credit payout (round(3.5) + round(1.5) = 4 + 2).
|
|
365
|
+
// 2. SELF-DEVELOPMENT COLLAPSES TO ONE PAYEE. R146 deliberately allows an
|
|
366
|
+
// author to develop their own spark. Emitting two rows for the same builder
|
|
367
|
+
// would not double-pay — it would UNDER-pay, because the idempotency
|
|
368
|
+
// predicate is (builder_id, reason) and the second insert is silently
|
|
369
|
+
// refused, so the builder would receive only one of their two shares.
|
|
370
|
+
// 3. ZERO-VALUE ROWS ARE DROPPED. A 1-credit payout splits to 1/0; booking a
|
|
371
|
+
// 0-credit ledger row is noise that reads like a payment.
|
|
372
|
+
//
|
|
373
|
+
// Pure — no DB, no config. Returns [{ builderId, delta, role }], biggest first.
|
|
374
|
+
function splitIdeaCredit(delta, { authorId, developedBy } = {}) {
|
|
375
|
+
const total = Math.round(Number(delta) || 0);
|
|
376
|
+
if (total <= 0 || authorId == null) return [];
|
|
377
|
+
// IDS PASS THROUGH UNCONVERTED. builder_id is a bigint, which pg hands back as
|
|
378
|
+
// a STRING; Number()-ing it would risk precision on a large id and would also
|
|
379
|
+
// change the value type the pre-R147 code fed straight into the INSERT. Only
|
|
380
|
+
// the COMPARISON below normalises, via String() — a number/string mismatch
|
|
381
|
+
// between captured_by and developed_by must not become a phantom second payee.
|
|
382
|
+
if (developedBy == null) {
|
|
383
|
+
return [{ builderId: authorId, delta: total, role: 'author' }];
|
|
384
|
+
}
|
|
385
|
+
if (String(developedBy) === String(authorId)) {
|
|
386
|
+
return [{ builderId: authorId, delta: total, role: 'author_developer' }];
|
|
387
|
+
}
|
|
388
|
+
const developerCut = Math.round(total * IDEA_DEVELOPER_SHARE);
|
|
389
|
+
const authorCut = total - developerCut; // the remainder — conserves the total
|
|
390
|
+
const out = [];
|
|
391
|
+
if (developerCut > 0) out.push({ builderId: developedBy, delta: developerCut, role: 'developer' });
|
|
392
|
+
if (authorCut > 0) out.push({ builderId: authorId, delta: authorCut, role: 'spark_author' });
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Who are the two parties for this idea? Read from the DB rather than trusted
|
|
397
|
+
// from the caller, deliberately: R146's develop route could not safely pay
|
|
398
|
+
// anything precisely because the recipient was ambiguous at the call site. With
|
|
399
|
+
// the payees derived HERE, a caller cannot direct a payment at the wrong builder,
|
|
400
|
+
// and there is exactly one place that decides who earns from an idea.
|
|
401
|
+
async function ideaCreditParties(client, ideaId) {
|
|
402
|
+
const { rows } = await client.query(
|
|
403
|
+
`SELECT i.captured_by, g.developed_by
|
|
404
|
+
FROM idea_inbox i
|
|
405
|
+
LEFT JOIN ideas_grades g ON g.idea_id = i.id
|
|
406
|
+
WHERE i.id = $1`,
|
|
407
|
+
[ideaId]
|
|
408
|
+
);
|
|
409
|
+
if (rows.length === 0) return null;
|
|
410
|
+
return { authorId: rows[0].captured_by, developedBy: rows[0].developed_by };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Book one split across the ledger, idempotently per (builder, reason). Returns
|
|
414
|
+
// { credited, payees } where credited is the sum of what was NEWLY booked — so a
|
|
415
|
+
// re-run that finds both rows present reports 0, and a partially-booked split
|
|
416
|
+
// (one payee inserted, then a crash) completes on the next call.
|
|
417
|
+
async function bookIdeaSplit(client, { payees, reason, taskId = null, describe }) {
|
|
418
|
+
let credited = 0;
|
|
419
|
+
const booked = [];
|
|
420
|
+
for (const p of payees) {
|
|
421
|
+
const { rowCount } = await client.query(
|
|
422
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
|
|
423
|
+
SELECT $1, $2, $3, $4, $5
|
|
424
|
+
WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $4)`,
|
|
425
|
+
[p.builderId, taskId, p.delta, reason, describe(p)]
|
|
426
|
+
);
|
|
427
|
+
if (rowCount > 0) credited += p.delta;
|
|
428
|
+
booked.push({ ...p, booked: rowCount > 0 });
|
|
429
|
+
}
|
|
430
|
+
return { credited, payees: booked };
|
|
431
|
+
}
|
|
432
|
+
|
|
337
433
|
// Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
|
|
338
434
|
// its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
|
|
339
435
|
// of the reward port — the caller passes its `client` (a pool works too, since
|
|
@@ -362,29 +458,46 @@ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessS
|
|
|
362
458
|
if (completenessScore == null || !Number.isFinite(Number(completenessScore)) || Number(completenessScore) < IDEA_FULL_PASS_BAR) {
|
|
363
459
|
return { credited: 0, reason: 'below_bar' };
|
|
364
460
|
}
|
|
365
|
-
if (
|
|
461
|
+
if (ideaId == null) {
|
|
366
462
|
return { credited: 0, reason: 'missing_author_or_idea' };
|
|
367
463
|
}
|
|
464
|
+
// R147: the PARTIES come from the DB, not the call site. `authorId` is kept as
|
|
465
|
+
// a fallback only for a caller holding an idea the lookup cannot see (and for
|
|
466
|
+
// the pre-R147 call shape); when the row is there, it wins.
|
|
467
|
+
const parties = await ideaCreditParties(client, ideaId);
|
|
468
|
+
const resolvedAuthor = (parties && parties.authorId != null) ? parties.authorId : authorId;
|
|
469
|
+
if (resolvedAuthor == null) {
|
|
470
|
+
return { credited: 0, reason: 'missing_author_or_idea' };
|
|
471
|
+
}
|
|
472
|
+
const payees = splitIdeaCredit(IDEA_FULL_PASS_CREDIT, {
|
|
473
|
+
authorId: resolvedAuthor,
|
|
474
|
+
developedBy: parties ? parties.developedBy : null,
|
|
475
|
+
});
|
|
476
|
+
if (payees.length === 0) {
|
|
477
|
+
return { credited: 0, reason: 'zero_after_scaling' };
|
|
478
|
+
}
|
|
479
|
+
// Idempotency predicate is `builder_id = payee AND reason = <per-idea key>`.
|
|
480
|
+
// The `builder_id` term was originally an index optimisation (hitting
|
|
481
|
+
// `idx_credit_log_builder` instead of scanning a growing credit_log — the
|
|
482
|
+
// R143/IC2 grader finding), and R147 makes it load-bearing for CORRECTNESS
|
|
483
|
+
// too: a split books one row per payee under the SAME reason, so the predicate
|
|
484
|
+
// has to be per-builder or the second payee would be refused. That is also
|
|
485
|
+
// exactly why splitIdeaCredit collapses a self-developed idea to a single
|
|
486
|
+
// payee — two rows for one builder would be silently under-paid, not
|
|
487
|
+
// double-paid. credit_log is a CORE table, so a module migration cannot add an
|
|
488
|
+
// index on `reason` anyway (the additive-namespace rule).
|
|
368
489
|
const reason = `idea.credit.full_pass:${ideaId}`;
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
`INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
|
|
381
|
-
SELECT $1, NULL, $2, $3, $4
|
|
382
|
-
WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $3)`,
|
|
383
|
-
[authorId, IDEA_FULL_PASS_CREDIT, reason, description]
|
|
384
|
-
);
|
|
385
|
-
return rowCount > 0
|
|
386
|
-
? { credited: IDEA_FULL_PASS_CREDIT, reason: 'paid' }
|
|
387
|
-
: { credited: 0, reason: 'already_paid' };
|
|
490
|
+
const pct = Math.round(Number(completenessScore));
|
|
491
|
+
const { credited, payees: booked } = await bookIdeaSplit(client, {
|
|
492
|
+
payees,
|
|
493
|
+
reason,
|
|
494
|
+
describe: (p) => `Full Idea #${ideaId} passed (completeness ${pct}/100) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
credited,
|
|
498
|
+
reason: credited > 0 ? 'paid' : 'already_paid',
|
|
499
|
+
payees: booked,
|
|
500
|
+
};
|
|
388
501
|
}
|
|
389
502
|
|
|
390
503
|
// Stream 1 (ADR 0172 / goal 1000067 IC4): as each task descended from an idea
|
|
@@ -420,7 +533,8 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
420
533
|
`SELECT l.idea_id,
|
|
421
534
|
i.captured_by,
|
|
422
535
|
COALESCE(g.grade, 'quick') AS grade,
|
|
423
|
-
g.completeness_score AS completeness
|
|
536
|
+
g.completeness_score AS completeness,
|
|
537
|
+
g.developed_by AS developed_by
|
|
424
538
|
FROM ideas_task_lineage l
|
|
425
539
|
JOIN idea_inbox i ON i.id = l.idea_id
|
|
426
540
|
LEFT JOIN ideas_grades g ON g.idea_id = l.idea_id
|
|
@@ -430,7 +544,7 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
430
544
|
if (rows.length === 0 || rows[0].captured_by == null) {
|
|
431
545
|
return { credited: 0, reason: 'no_source_idea', ideaId: null, authorId: null };
|
|
432
546
|
}
|
|
433
|
-
const { idea_id: ideaId, captured_by: authorId, grade, completeness } = rows[0];
|
|
547
|
+
const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = rows[0];
|
|
434
548
|
// Grade factor: a Full Idea earns in proportion to its completeness; a Quick
|
|
435
549
|
// or Spark idea (no completeness score) earns the flat quick factor. A full
|
|
436
550
|
// idea with no score yet also falls back to the quick factor rather than 0 —
|
|
@@ -442,16 +556,24 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
442
556
|
if (delta <= 0) {
|
|
443
557
|
return { credited: 0, reason: 'zero_after_scaling', ideaId: Number(ideaId), authorId: Number(authorId) };
|
|
444
558
|
}
|
|
559
|
+
// R147 (ADR 0185): if someone else developed this spark, the per-task stream is
|
|
560
|
+
// split with them too — the same 70/30 rule as stream 2, so there is one rule
|
|
561
|
+
// to explain rather than two. A developer who shaped the idea shares in what
|
|
562
|
+
// the idea goes on to deliver.
|
|
563
|
+
const payees = splitIdeaCredit(delta, { authorId, developedBy });
|
|
564
|
+
if (payees.length === 0) {
|
|
565
|
+
return { credited: 0, reason: 'zero_after_scaling', ideaId: Number(ideaId), authorId: Number(authorId) };
|
|
566
|
+
}
|
|
445
567
|
const reason = `idea.credit.task_ship:${ideaId}:${taskId}`;
|
|
446
|
-
const
|
|
447
|
-
const {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
);
|
|
453
|
-
return
|
|
454
|
-
? { credited
|
|
568
|
+
const gradeLabel = `${grade}${completeness != null ? ` ${Math.round(Number(completeness))}%` : ''}`;
|
|
569
|
+
const { credited, payees: booked } = await bookIdeaSplit(client, {
|
|
570
|
+
payees,
|
|
571
|
+
reason,
|
|
572
|
+
taskId,
|
|
573
|
+
describe: (p) => `Task #${taskId} shipped under idea #${ideaId} (${gradeLabel}) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 1, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
|
|
574
|
+
});
|
|
575
|
+
return credited > 0
|
|
576
|
+
? { credited, reason: 'paid', ideaId: Number(ideaId), authorId: Number(authorId), payees: booked }
|
|
455
577
|
: { credited: 0, reason: 'already_paid', ideaId: Number(ideaId), authorId: Number(authorId) };
|
|
456
578
|
}
|
|
457
579
|
|
|
@@ -667,6 +789,9 @@ module.exports = {
|
|
|
667
789
|
maybeAwardIdeaPromotionBonus,
|
|
668
790
|
awardIdeaFullPassCredit,
|
|
669
791
|
awardIdeaTaskShipCredit,
|
|
792
|
+
// the spark-handoff split (task 1002923 / BV1.R147, ADR 0185)
|
|
793
|
+
splitIdeaCredit,
|
|
794
|
+
IDEA_DEVELOPER_SHARE,
|
|
670
795
|
IDEA_FULL_PASS_CREDIT,
|
|
671
796
|
IDEA_FULL_PASS_BAR,
|
|
672
797
|
IDEA_TASK_SHIP_BASE,
|
|
@@ -614,16 +614,15 @@ module.exports = function buildInboxRouter() {
|
|
|
614
614
|
// to tell a 404 apart from a 409 and to give a useful message; it is not the
|
|
615
615
|
// guard, and it deliberately does not try to be.
|
|
616
616
|
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// still pay it at the right time, to the right people, in the right shares.
|
|
617
|
+
// THE STREAM-2 CREDIT IS PAID HERE, SPLIT (task 1002923 / R147, ADR 0185).
|
|
618
|
+
// R146 shipped this route paying nothing on purpose: with two people involved,
|
|
619
|
+
// the recipient was ambiguous, and awardIdeaFullPassCredit's idempotency
|
|
620
|
+
// predicate is (builder_id, reason) — so guessing the developer would NOT have
|
|
621
|
+
// blocked a later payment to the author, and one idea would have paid twice.
|
|
622
|
+
// R147 resolved that by moving the payee decision INTO the economy module: it
|
|
623
|
+
// reads captured_by + developed_by itself and books 70% to the developer, 30%
|
|
624
|
+
// to the spark author (one payee at 100% when they are the same person). This
|
|
625
|
+
// route can therefore call it safely — it no longer chooses who earns.
|
|
627
626
|
//
|
|
628
627
|
// The board window IS opened, because it is recipient-agnostic (it takes an
|
|
629
628
|
// idea and a score, not a builder) and without it a developed Full Idea could
|
|
@@ -716,6 +715,28 @@ module.exports = function buildInboxRouter() {
|
|
|
716
715
|
developed_at: row.developed_at,
|
|
717
716
|
};
|
|
718
717
|
|
|
718
|
+
// Fail-open, exactly like the capture and resubmit paths: the development has
|
|
719
|
+
// already committed, and a credit-lane hiccup must never roll back somebody's
|
|
720
|
+
// work. The split (and who is in it) is decided inside the economy module.
|
|
721
|
+
try {
|
|
722
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
723
|
+
if (reward && reward.awardIdeaFullPassCredit) {
|
|
724
|
+
const r = await reward.awardIdeaFullPassCredit(api.pool, {
|
|
725
|
+
ideaId: id,
|
|
726
|
+
authorId: idea.captured_by,
|
|
727
|
+
completenessScore: row.completeness_score,
|
|
728
|
+
});
|
|
729
|
+
if (r && r.credited > 0) {
|
|
730
|
+
out.ideator_credit = r.credited;
|
|
731
|
+
// The per-payee breakdown, so a developer can see their share and the
|
|
732
|
+
// author theirs without reading the ledger.
|
|
733
|
+
if (r.payees) out.ideator_credit_split = r.payees;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
} catch (creditErr) {
|
|
737
|
+
api.logger('ideas').error(`develop: pass-credit failed (development intact, credit skipped): ${creditErr && creditErr.message}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
719
740
|
let board_window;
|
|
720
741
|
try {
|
|
721
742
|
const government = api.resolveOptional && api.resolveOptional('government');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.298",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.298",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.298",
|
|
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
|
@@ -1021,8 +1021,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1021
1021
|
// landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
|
|
1022
1022
|
// 1.19.297 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1023
1023
|
// landed since 1.19.296 with no explicit bump. run 32592502621. (task 1002620)
|
|
1024
|
+
// 1.19.298 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1025
|
+
// landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
|
|
1024
1026
|
// ---------------------------------------------------------------------------
|
|
1025
|
-
const CORE_VERSION = '1.19.
|
|
1027
|
+
const CORE_VERSION = '1.19.298'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1026
1028
|
|
|
1027
1029
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1028
1030
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// tests/idea_credit_split.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002923 (BV1.R147, goal 1000062; ADR 0185) — the spark-handoff credit
|
|
4
|
+
// split: 70% developer / 30% spark author, on both ideator streams.
|
|
5
|
+
//
|
|
6
|
+
// The three properties that are REAL BUGS if missed, asserted first:
|
|
7
|
+
// 1. the total is CONSERVED under rounding (round(3.5)+round(1.5) = 6 from a
|
|
8
|
+
// 5-credit payout is the naive mistake);
|
|
9
|
+
// 2. a SELF-DEVELOPED idea collapses to ONE payee — two rows for one builder
|
|
10
|
+
// would be silently UNDER-paid, because the idempotency predicate is
|
|
11
|
+
// (builder_id, reason) and the second insert is refused;
|
|
12
|
+
// 3. zero-value rows are DROPPED, not booked as 0-credit ledger noise.
|
|
13
|
+
//
|
|
14
|
+
// Then the two streams end-to-end against a stub client, and the develop route
|
|
15
|
+
// actually paying (the gap R146 deliberately left open).
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/idea_credit_split.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
|
+
const { splitIdeaCredit, IDEA_DEVELOPER_SHARE, IDEA_FULL_PASS_CREDIT, IDEA_TASK_SHIP_BASE } = credits;
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
async function test(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
30
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sum = (ps) => ps.reduce((n, p) => n + p.delta, 0);
|
|
34
|
+
|
|
35
|
+
// --- property 1: the total is conserved ---------------------------------
|
|
36
|
+
|
|
37
|
+
await test('the split CONSERVES the total at every payout size', () => {
|
|
38
|
+
for (let total = 1; total <= 120; total += 1) {
|
|
39
|
+
const ps = splitIdeaCredit(total, { authorId: 9, developedBy: 42 });
|
|
40
|
+
assert.equal(sum(ps), total, `total ${total} must not gain or lose a credit`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await test('a 5-credit payout splits 4/1, not 4/2 — the rounding trap', () => {
|
|
45
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 42 });
|
|
46
|
+
assert.equal(sum(ps), 5);
|
|
47
|
+
const dev = ps.find((p) => p.role === 'developer');
|
|
48
|
+
const author = ps.find((p) => p.role === 'spark_author');
|
|
49
|
+
assert.equal(dev.delta, 4, 'round(5 * 0.70) = 4');
|
|
50
|
+
assert.equal(author.delta, 1, 'the author gets the REMAINDER, not round(1.5)=2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('the developer gets the larger share, and it tracks the constant', () => {
|
|
54
|
+
assert.equal(IDEA_DEVELOPER_SHARE, 0.70, 'the owner decision, 2026-08-22');
|
|
55
|
+
const ps = splitIdeaCredit(100, { authorId: 9, developedBy: 42 });
|
|
56
|
+
assert.equal(ps.find((p) => p.role === 'developer').delta, 70);
|
|
57
|
+
assert.equal(ps.find((p) => p.role === 'spark_author').delta, 30);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- property 2: self-development collapses -----------------------------
|
|
61
|
+
|
|
62
|
+
await test('a SELF-developed idea is ONE payee at the full amount', () => {
|
|
63
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 9 });
|
|
64
|
+
assert.equal(ps.length, 1, 'two rows for one builder would be silently under-paid');
|
|
65
|
+
assert.equal(ps[0].delta, 5);
|
|
66
|
+
assert.equal(ps[0].builderId, 9);
|
|
67
|
+
assert.equal(ps[0].role, 'author_developer');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('string/number id mismatch still counts as the same person', () => {
|
|
71
|
+
// captured_by and developed_by come back from pg as strings on a bigint column.
|
|
72
|
+
const ps = splitIdeaCredit(5, { authorId: '9', developedBy: 9 });
|
|
73
|
+
assert.equal(ps.length, 1, 'a type difference must not become a phantom second payee');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('an UNDEVELOPED idea pays its author everything', () => {
|
|
77
|
+
const ps = splitIdeaCredit(5, { authorId: 9, developedBy: null });
|
|
78
|
+
assert.deepEqual(ps, [{ builderId: 9, delta: 5, role: 'author' }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- property 3: no zero rows -------------------------------------------
|
|
82
|
+
|
|
83
|
+
await test('a 1-credit payout pays the developer 1 and books NOTHING for the author', () => {
|
|
84
|
+
const ps = splitIdeaCredit(1, { authorId: 9, developedBy: 42 });
|
|
85
|
+
assert.equal(sum(ps), 1);
|
|
86
|
+
assert.equal(ps.length, 1, 'a 0-credit ledger row reads like a payment that was not made');
|
|
87
|
+
assert.equal(ps[0].role, 'developer');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('a zero or negative payout pays nobody', () => {
|
|
91
|
+
assert.deepEqual(splitIdeaCredit(0, { authorId: 9, developedBy: 42 }), []);
|
|
92
|
+
assert.deepEqual(splitIdeaCredit(-5, { authorId: 9, developedBy: 42 }), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('no author means no payment at all', () => {
|
|
96
|
+
assert.deepEqual(splitIdeaCredit(5, { authorId: null, developedBy: 42 }), []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --- stream 2, end to end ----------------------------------------------
|
|
100
|
+
|
|
101
|
+
// A stub client that records every INSERT and answers the parties lookup.
|
|
102
|
+
function stubClient({ authorId, developedBy, alreadyPaid = [] }) {
|
|
103
|
+
const inserts = [];
|
|
104
|
+
return {
|
|
105
|
+
inserts,
|
|
106
|
+
query: async (sql, params = []) => {
|
|
107
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
108
|
+
return { rows: [{ captured_by: authorId, developed_by: developedBy }] };
|
|
109
|
+
}
|
|
110
|
+
if (/FROM ideas_task_lineage/i.test(sql)) {
|
|
111
|
+
return { rows: [{ idea_id: '77', captured_by: authorId, grade: 'full', completeness: 100, developed_by: developedBy }] };
|
|
112
|
+
}
|
|
113
|
+
if (/INSERT INTO credit_log/i.test(sql)) {
|
|
114
|
+
const [builderId, , delta, reason] = params;
|
|
115
|
+
if (alreadyPaid.some((a) => String(a.builderId) === String(builderId) && a.reason === reason)) {
|
|
116
|
+
return { rowCount: 0 };
|
|
117
|
+
}
|
|
118
|
+
inserts.push({ builderId, delta, reason, description: params[4] });
|
|
119
|
+
return { rowCount: 1 };
|
|
120
|
+
}
|
|
121
|
+
return { rows: [], rowCount: 0 };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ON = { ideatorCredit: true };
|
|
127
|
+
|
|
128
|
+
await test('stream 2: a developed Full Idea pays BOTH parties under one reason key', async () => {
|
|
129
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
130
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
131
|
+
assert.equal(r.credited, IDEA_FULL_PASS_CREDIT, 'the whole payout is booked, split across two rows');
|
|
132
|
+
assert.equal(c.inserts.length, 2);
|
|
133
|
+
// One reason key for both — which is exactly why the idempotency predicate
|
|
134
|
+
// must include builder_id, or the second payee would be refused.
|
|
135
|
+
assert.equal(new Set(c.inserts.map((i) => i.reason)).size, 1);
|
|
136
|
+
assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
|
|
137
|
+
assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
|
|
138
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
|
|
139
|
+
assert.match(c.inserts.find((i) => i.builderId === '9').description, /spark author/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('stream 2: the PARTIES come from the DB, not from the caller', async () => {
|
|
143
|
+
// A caller naming the wrong author cannot redirect the money — the whole
|
|
144
|
+
// reason R146 could not safely pay.
|
|
145
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
146
|
+
await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '999', completenessScore: 100, config: ON });
|
|
147
|
+
assert.ok(!c.inserts.some((i) => i.builderId === '999'), 'the caller-supplied author is ignored when the row is readable');
|
|
148
|
+
assert.deepEqual(c.inserts.map((i) => i.builderId).sort(), ['42', '9']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('stream 2: an undeveloped Full Idea still pays its author the full 5', async () => {
|
|
152
|
+
const c = stubClient({ authorId: '9', developedBy: null });
|
|
153
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
154
|
+
assert.equal(r.credited, IDEA_FULL_PASS_CREDIT);
|
|
155
|
+
assert.equal(c.inserts.length, 1);
|
|
156
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
157
|
+
assert.equal(c.inserts[0].delta, IDEA_FULL_PASS_CREDIT);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('stream 2: a PARTIALLY booked split completes on a re-run, never double-pays', async () => {
|
|
161
|
+
const reason = 'idea.credit.full_pass:77';
|
|
162
|
+
// The developer's row landed; the author's did not (a crash between inserts).
|
|
163
|
+
const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }] });
|
|
164
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
165
|
+
assert.equal(c.inserts.length, 1, 'only the missing row is written');
|
|
166
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
167
|
+
assert.equal(r.credited, 1, 'credited reports only what was NEWLY booked');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('stream 2: a fully booked split reports already_paid and writes nothing', async () => {
|
|
171
|
+
const reason = 'idea.credit.full_pass:77';
|
|
172
|
+
const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }, { builderId: '9', reason }] });
|
|
173
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
|
|
174
|
+
assert.equal(c.inserts.length, 0);
|
|
175
|
+
assert.equal(r.credited, 0);
|
|
176
|
+
assert.equal(r.reason, 'already_paid');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('stream 2: below the bar nobody is paid, developed or not', async () => {
|
|
180
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
181
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 10, config: ON });
|
|
182
|
+
assert.equal(r.credited, 0);
|
|
183
|
+
assert.equal(r.reason, 'below_bar');
|
|
184
|
+
assert.equal(c.inserts.length, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('stream 2: the lane can still be switched off entirely', async () => {
|
|
188
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
189
|
+
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: { ideatorCredit: false } });
|
|
190
|
+
assert.equal(r.reason, 'ideator_lane_off');
|
|
191
|
+
assert.equal(c.inserts.length, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- stream 1, end to end ----------------------------------------------
|
|
195
|
+
|
|
196
|
+
await test('stream 1: a task shipped under a DEVELOPED idea splits the same way', async () => {
|
|
197
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
198
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
199
|
+
assert.equal(r.credited, IDEA_TASK_SHIP_BASE, 'a 100% full idea pays the full base, split');
|
|
200
|
+
assert.equal(c.inserts.length, 2);
|
|
201
|
+
assert.equal(sum(c.inserts.map((i) => ({ delta: i.delta }))), IDEA_TASK_SHIP_BASE);
|
|
202
|
+
assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
|
|
203
|
+
assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
|
|
204
|
+
// Same rule, so the same wording — one rule to explain, not two.
|
|
205
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /stream 1/);
|
|
206
|
+
assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test('stream 1: an undeveloped idea is unchanged — the author gets it all', async () => {
|
|
210
|
+
const c = stubClient({ authorId: '9', developedBy: null });
|
|
211
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
212
|
+
assert.equal(r.credited, IDEA_TASK_SHIP_BASE);
|
|
213
|
+
assert.equal(c.inserts.length, 1);
|
|
214
|
+
assert.equal(c.inserts[0].builderId, '9');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('stream 1: the per-(idea,task) reason key still scopes idempotency', async () => {
|
|
218
|
+
const c = stubClient({ authorId: '9', developedBy: '42' });
|
|
219
|
+
await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
220
|
+
for (const i of c.inserts) assert.equal(i.reason, 'idea.credit.task_ship:77:100');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
console.log(`\nidea_credit_split: ${passed} passed, ${failed} failed`);
|
|
224
|
+
if (failed > 0) process.exit(1);
|
|
@@ -93,9 +93,11 @@ const basePoolQuery = async (sql, params = []) => {
|
|
|
93
93
|
async function test(name, fn) {
|
|
94
94
|
queries = [];
|
|
95
95
|
portsResolved = [];
|
|
96
|
-
// Restored every test:
|
|
97
|
-
//
|
|
96
|
+
// Restored every test: cases below swap in a two-phase pool stub (a triager
|
|
97
|
+
// closing the idea mid-request) and reward/government port stubs, and none of
|
|
98
|
+
// them may leak into the next test.
|
|
98
99
|
api.pool.query = basePoolQuery;
|
|
100
|
+
api.resolveOptional = (name) => { portsResolved.push(name); return undefined; };
|
|
99
101
|
// The author is builder 9; the developer in call() defaults to 42.
|
|
100
102
|
idea = {
|
|
101
103
|
id: '77', title: 'a spark', body_md: 'the thought', status: 'open',
|
|
@@ -293,16 +295,51 @@ await test('a missing idea is a 404, not a 500', async () => {
|
|
|
293
295
|
|
|
294
296
|
// --- the money boundary: R147 owns the split ---------------------------
|
|
295
297
|
|
|
296
|
-
|
|
298
|
+
// R146 shipped this route paying NOTHING on purpose: with two people involved the
|
|
299
|
+
// recipient was ambiguous, and awardIdeaFullPassCredit's idempotency predicate is
|
|
300
|
+
// (builder_id, reason) — so guessing the developer would not have blocked a later
|
|
301
|
+
// payment to the author, and one idea would have paid twice. R147 (ADR 0185)
|
|
302
|
+
// resolved that by moving the payee decision INTO the economy module, so this
|
|
303
|
+
// route can now call it safely: it no longer chooses who earns.
|
|
304
|
+
await test('the stream-2 pass credit IS paid here now, and the route does not pick the payee', async () => {
|
|
305
|
+
let seen = null;
|
|
306
|
+
api.resolveOptional = (name) => {
|
|
307
|
+
portsResolved.push(name);
|
|
308
|
+
if (name === 'reward') {
|
|
309
|
+
return {
|
|
310
|
+
awardIdeaFullPassCredit: async (_client, args) => {
|
|
311
|
+
seen = args;
|
|
312
|
+
return { credited: 5, reason: 'paid', payees: [
|
|
313
|
+
{ builderId: '42', delta: 4, role: 'developer', booked: true },
|
|
314
|
+
{ builderId: '9', delta: 1, role: 'spark_author', booked: true },
|
|
315
|
+
] };
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
297
321
|
const r = await call();
|
|
298
322
|
assert.equal(r.status, 200);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// the
|
|
303
|
-
|
|
304
|
-
assert.
|
|
305
|
-
assert.equal(
|
|
323
|
+
assert.ok(portsResolved.includes('reward'), 'the reward port is consulted');
|
|
324
|
+
assert.equal(r.payload.idea.ideator_credit, 5, 'the total is surfaced');
|
|
325
|
+
assert.equal(r.payload.idea.ideator_credit_split.length, 2, 'and the per-payee breakdown');
|
|
326
|
+
// The route hands over the idea and the score — never a chosen recipient beyond
|
|
327
|
+
// the author fallback the economy module overrides from the DB.
|
|
328
|
+
assert.equal(seen.ideaId, 77);
|
|
329
|
+
assert.equal(seen.completenessScore, 100);
|
|
330
|
+
assert.ok(!('developedBy' in seen), 'the route does not tell economy who developed it');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await test('a credit failure does not roll back the development (fail-open)', async () => {
|
|
334
|
+
api.resolveOptional = (name) => {
|
|
335
|
+
portsResolved.push(name);
|
|
336
|
+
if (name === 'reward') return { awardIdeaFullPassCredit: async () => { throw new Error('synthetic credit outage'); } };
|
|
337
|
+
return undefined;
|
|
338
|
+
};
|
|
339
|
+
const r = await call();
|
|
340
|
+
assert.equal(r.status, 200, 'the development landed');
|
|
341
|
+
assert.equal(r.payload.developed, true);
|
|
342
|
+
assert.equal(r.payload.idea.ideator_credit, undefined);
|
|
306
343
|
});
|
|
307
344
|
|
|
308
345
|
await test('but the board window IS opened — otherwise the handoff dead-ends', async () => {
|
|
@@ -28,23 +28,35 @@ async function test(name, fn) {
|
|
|
28
28
|
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// A stub client whose credit_log "table" is an in-memory set of
|
|
32
|
-
// INSERT ... WHERE NOT EXISTS
|
|
33
|
-
//
|
|
34
|
-
|
|
31
|
+
// A stub client whose credit_log "table" is an in-memory set of (builder, reason)
|
|
32
|
+
// pairs, so the INSERT ... WHERE NOT EXISTS behaves exactly like Postgres: a
|
|
33
|
+
// second insert for the same pair is a no-op (rowCount 0).
|
|
34
|
+
//
|
|
35
|
+
// R147 (task 1002923) reshaped the write: the function now first LOOKS UP the
|
|
36
|
+
// idea's parties (captured_by + developed_by) so it — not its caller — decides
|
|
37
|
+
// who earns, and the INSERT carries task_id as a parameter, moving `reason` from
|
|
38
|
+
// $3 to $4. `developedBy` here defaults to null, i.e. an idea nobody developed,
|
|
39
|
+
// which is the case every test below was written for; the split itself is
|
|
40
|
+
// covered in tests/idea_credit_split.mjs.
|
|
41
|
+
function stubClient({ authorId = null, developedBy = null } = {}) {
|
|
35
42
|
const rows = [];
|
|
36
43
|
return {
|
|
37
44
|
rows,
|
|
38
45
|
async query(sql, params) {
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// The R147 parties lookup — who authored this idea, and who developed it.
|
|
47
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
48
|
+
return { rows: authorId == null ? [] : [{ captured_by: authorId, developed_by: developedBy }] };
|
|
49
|
+
}
|
|
50
|
+
// The idempotency subquery must still scope by builder_id AND reason. That
|
|
51
|
+
// was an index concern before (idx_credit_log_builder); under R147 it is
|
|
52
|
+
// also load-bearing for correctness, because a split books two rows under
|
|
53
|
+
// ONE reason and a reason-only predicate would refuse the second payee.
|
|
42
54
|
const builderId = params[0];
|
|
43
|
-
const reason = params[
|
|
44
|
-
assert.match(sql, /builder_id = \$1 AND reason = \$
|
|
55
|
+
const reason = params[3];
|
|
56
|
+
assert.match(sql, /builder_id = \$1 AND reason = \$4/, 'idempotency check must scope by builder_id AND reason');
|
|
45
57
|
const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
|
|
46
58
|
if (exists) return { rowCount: 0, rows: [] };
|
|
47
|
-
rows.push({ builder_id: builderId, delta: params[
|
|
59
|
+
rows.push({ builder_id: builderId, delta: params[2], reason, description: params[4] });
|
|
48
60
|
return { rowCount: 1, rows: [] };
|
|
49
61
|
},
|
|
50
62
|
};
|
|
@@ -54,7 +66,7 @@ const PASS = 72; // clears the 60 bar
|
|
|
54
66
|
const THIN = 40; // below the bar
|
|
55
67
|
|
|
56
68
|
await test('a passing Full Idea books exactly 5 credits to its author', async () => {
|
|
57
|
-
const c = stubClient();
|
|
69
|
+
const c = stubClient({ authorId: 3 });
|
|
58
70
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
59
71
|
assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
60
72
|
assert.equal(r.reason, 'paid');
|
|
@@ -65,7 +77,7 @@ await test('a passing Full Idea books exactly 5 credits to its author', async ()
|
|
|
65
77
|
});
|
|
66
78
|
|
|
67
79
|
await test('re-running pays nothing — idempotent per idea', async () => {
|
|
68
|
-
const c = stubClient();
|
|
80
|
+
const c = stubClient({ authorId: 3 });
|
|
69
81
|
const opts = { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } };
|
|
70
82
|
const first = await credits.awardIdeaFullPassCredit(c, opts);
|
|
71
83
|
const second = await credits.awardIdeaFullPassCredit(c, opts);
|
|
@@ -78,7 +90,7 @@ await test('re-running pays nothing — idempotent per idea', async () => {
|
|
|
78
90
|
});
|
|
79
91
|
|
|
80
92
|
await test('a below-bar or null score books 0 — scoring gates the pay, not capture', async () => {
|
|
81
|
-
const c = stubClient();
|
|
93
|
+
const c = stubClient({ authorId: 3 });
|
|
82
94
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 1, authorId: 3, completenessScore: THIN, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
83
95
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 2, authorId: 3, completenessScore: null, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
84
96
|
assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 3, authorId: 3, completenessScore: undefined, config: { ideatorCredit: true } })).reason, 'below_bar');
|
|
@@ -90,13 +102,13 @@ await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardles
|
|
|
90
102
|
const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
|
|
91
103
|
assert.equal(policy.perTaskCredit, false);
|
|
92
104
|
assert.equal(policy.ideatorCredit, true);
|
|
93
|
-
const c = stubClient();
|
|
105
|
+
const c = stubClient({ authorId: 3 });
|
|
94
106
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 5, authorId: 3, completenessScore: PASS, config: policy });
|
|
95
107
|
assert.equal(r.credited, 5, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
|
|
96
108
|
});
|
|
97
109
|
|
|
98
110
|
await test('an instance that opts the ideator lane out pays nothing', async () => {
|
|
99
|
-
const c = stubClient();
|
|
111
|
+
const c = stubClient({ authorId: 3 });
|
|
100
112
|
const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: 3, completenessScore: PASS, config: { ideatorCredit: false } });
|
|
101
113
|
assert.equal(r.credited, 0);
|
|
102
114
|
assert.equal(r.reason, 'ideator_lane_off');
|
|
@@ -104,9 +116,29 @@ await test('an instance that opts the ideator lane out pays nothing', async () =
|
|
|
104
116
|
});
|
|
105
117
|
|
|
106
118
|
await test('a missing author or idea is a safe 0, never a throw', async () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assert.equal((await credits.awardIdeaFullPassCredit(
|
|
119
|
+
// No ideaId at all: refused before the parties lookup even runs.
|
|
120
|
+
const noIdea = stubClient({ authorId: 3 });
|
|
121
|
+
assert.equal((await credits.awardIdeaFullPassCredit(noIdea, { ideaId: null, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
|
|
122
|
+
// An idea the lookup cannot see AND no caller-supplied author → still a safe 0.
|
|
123
|
+
const unknown = stubClient(); // lookup returns no row
|
|
124
|
+
assert.equal((await credits.awardIdeaFullPassCredit(unknown, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
|
|
125
|
+
assert.equal(unknown.rows.length, 0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('R147: the DB is authoritative for the author; the argument is only a fallback', async () => {
|
|
129
|
+
// The caller passing null no longer matters when the row is readable — the
|
|
130
|
+
// whole point of moving the payee decision into this module.
|
|
131
|
+
const known = stubClient({ authorId: 3 });
|
|
132
|
+
const r = await credits.awardIdeaFullPassCredit(known, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
133
|
+
assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
134
|
+
assert.equal(known.rows[0].builder_id, 3, 'paid the author the DB names');
|
|
135
|
+
|
|
136
|
+
// ...and when the lookup finds nothing, the passed author is still honoured,
|
|
137
|
+
// so a caller holding an idea this module cannot see is not silently unpaid.
|
|
138
|
+
const invisible = stubClient();
|
|
139
|
+
const f = await credits.awardIdeaFullPassCredit(invisible, { ideaId: 6, authorId: 7, completenessScore: PASS, config: { ideatorCredit: true } });
|
|
140
|
+
assert.equal(f.credited, credits.IDEA_FULL_PASS_CREDIT);
|
|
141
|
+
assert.equal(invisible.rows[0].builder_id, 7);
|
|
110
142
|
});
|
|
111
143
|
|
|
112
144
|
console.log(`\nidea_full_pass_credit: ${passed} passed, ${failed} failed`);
|