@bongos/core 1.19.501 → 1.19.503
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 +30 -20
- package/.gitattributes +10 -0
- package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +4 -0
- package/docs/adr/0236-many-ideas-one-task-the-lineage-key-is-the-pair.md +98 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +14 -1
- package/modules/ideas/inbox.js +84 -11
- package/modules/ideas/migrations/ideas_006_lineage_many_ideas_one_task.sql +98 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/git-merge-regen.js +20 -0
- package/src/module-api.js +1 -1
- package/tests/git_merge_regen.mjs +87 -0
- package/tests/idea_task_lineage.mjs +163 -27
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.503",
|
|
6
|
+
"core_contract": "1.19.503",
|
|
7
|
+
"source_commit": "df473e8241e4225ae20d599c3158e244199d4e16",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-03T17:20:56.366Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 427,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1943,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2394,
|
|
20
|
+
"tree_sha256": "d2fa5b6500a8ab75eb8c81b3ce9e8b56dfe858dad8ac0ac161f7b9653f0b83d9",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
{
|
|
318
318
|
"path": ".gitattributes",
|
|
319
319
|
"mode": "0000644",
|
|
320
|
-
"sha256": "
|
|
320
|
+
"sha256": "3848ed851a03fd11f8dae94c1e94b802c4aa625efaa7f4aff27438a1fdfb67ac"
|
|
321
321
|
},
|
|
322
322
|
{
|
|
323
323
|
"path": "CLAUDE.md",
|
|
@@ -877,7 +877,7 @@
|
|
|
877
877
|
{
|
|
878
878
|
"path": "docs/adr/0082-server-side-merge-conflict-auto-resolution.md",
|
|
879
879
|
"mode": "0000644",
|
|
880
|
-
"sha256": "
|
|
880
|
+
"sha256": "7da7140202139c123e832f527167c96de18eb498ae21243cd30cea6b133c8f90"
|
|
881
881
|
},
|
|
882
882
|
{
|
|
883
883
|
"path": "docs/adr/0083-modular-architecture-strangler-build-plan.md",
|
|
@@ -1684,10 +1684,15 @@
|
|
|
1684
1684
|
"mode": "0000644",
|
|
1685
1685
|
"sha256": "f3573d82c77dc2b7d006f4ee8051b9b5a247669d2ac4c046c2de7c282d4e65ff"
|
|
1686
1686
|
},
|
|
1687
|
+
{
|
|
1688
|
+
"path": "docs/adr/0236-many-ideas-one-task-the-lineage-key-is-the-pair.md",
|
|
1689
|
+
"mode": "0000644",
|
|
1690
|
+
"sha256": "87b350049a0ed1b7be0c58fc2ae6ecd18a077b21ca689563a8763f925a37eef6"
|
|
1691
|
+
},
|
|
1687
1692
|
{
|
|
1688
1693
|
"path": "docs/adr/README.md",
|
|
1689
1694
|
"mode": "0000644",
|
|
1690
|
-
"sha256": "
|
|
1695
|
+
"sha256": "16d284a7a9b1b70f1ba2aa3550a380ed76ace41b985c5f6a309c9899acf6b5f8"
|
|
1691
1696
|
},
|
|
1692
1697
|
{
|
|
1693
1698
|
"path": "docs/api-reference.md",
|
|
@@ -2572,7 +2577,7 @@
|
|
|
2572
2577
|
{
|
|
2573
2578
|
"path": "docs/module-api-changelog.md",
|
|
2574
2579
|
"mode": "0000644",
|
|
2575
|
-
"sha256": "
|
|
2580
|
+
"sha256": "617f270d120002ae0d222e6454744f946f25c7f531557365389f199bf0d2555c"
|
|
2576
2581
|
},
|
|
2577
2582
|
{
|
|
2578
2583
|
"path": "docs/modules-contract.md",
|
|
@@ -4117,7 +4122,7 @@
|
|
|
4117
4122
|
{
|
|
4118
4123
|
"path": "modules/economy/credits.js",
|
|
4119
4124
|
"mode": "0000644",
|
|
4120
|
-
"sha256": "
|
|
4125
|
+
"sha256": "08890c50f8c8b2551a495e8a54e0e4fb2925b928ece5dc9aaf2090ea1c37adb3"
|
|
4121
4126
|
},
|
|
4122
4127
|
{
|
|
4123
4128
|
"path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
|
|
@@ -5137,7 +5142,7 @@
|
|
|
5137
5142
|
{
|
|
5138
5143
|
"path": "modules/ideas/inbox.js",
|
|
5139
5144
|
"mode": "0000644",
|
|
5140
|
-
"sha256": "
|
|
5145
|
+
"sha256": "5ebd1524a5593b70f74a83bdaa3a6d772eb820ea3d962f48dedace970efcc119"
|
|
5141
5146
|
},
|
|
5142
5147
|
{
|
|
5143
5148
|
"path": "modules/ideas/migrations/ideas_001_grades.sql",
|
|
@@ -5164,6 +5169,11 @@
|
|
|
5164
5169
|
"mode": "0000644",
|
|
5165
5170
|
"sha256": "3c7a48505c922bd0742888bc9c0598d776a4de0cc3d2745bd08d6452e07f34b9"
|
|
5166
5171
|
},
|
|
5172
|
+
{
|
|
5173
|
+
"path": "modules/ideas/migrations/ideas_006_lineage_many_ideas_one_task.sql",
|
|
5174
|
+
"mode": "0000644",
|
|
5175
|
+
"sha256": "51f790ec7d5c0ad9941ed16174e260db0de18be4d4cb18af0d704a1190a1beee"
|
|
5176
|
+
},
|
|
5167
5177
|
{
|
|
5168
5178
|
"path": "modules/ideas/module.json",
|
|
5169
5179
|
"mode": "0000644",
|
|
@@ -7217,12 +7227,12 @@
|
|
|
7217
7227
|
{
|
|
7218
7228
|
"path": "package-lock.json",
|
|
7219
7229
|
"mode": "0000644",
|
|
7220
|
-
"sha256": "
|
|
7230
|
+
"sha256": "74063906121c7bd41daa897aa6710baff1f0737e954fb570e86f3374194cce85"
|
|
7221
7231
|
},
|
|
7222
7232
|
{
|
|
7223
7233
|
"path": "package.json",
|
|
7224
7234
|
"mode": "0000644",
|
|
7225
|
-
"sha256": "
|
|
7235
|
+
"sha256": "a56c6e485d5d87daba754dce001fc77ee52e566801e333a5e03221d161bc3682"
|
|
7226
7236
|
},
|
|
7227
7237
|
{
|
|
7228
7238
|
"path": "public-docs/index.html",
|
|
@@ -7737,7 +7747,7 @@
|
|
|
7737
7747
|
{
|
|
7738
7748
|
"path": "scripts/gds/git-merge-regen.js",
|
|
7739
7749
|
"mode": "0000644",
|
|
7740
|
-
"sha256": "
|
|
7750
|
+
"sha256": "f9a391ac6f32b2d5ca939b48186b6d9cb7aa171554652a5b5afbc4a408aae203"
|
|
7741
7751
|
},
|
|
7742
7752
|
{
|
|
7743
7753
|
"path": "scripts/gds/go-live.js",
|
|
@@ -8892,7 +8902,7 @@
|
|
|
8892
8902
|
{
|
|
8893
8903
|
"path": "src/module-api.js",
|
|
8894
8904
|
"mode": "0000644",
|
|
8895
|
-
"sha256": "
|
|
8905
|
+
"sha256": "24b41b0c2d49f19e302831644599b853f1f10ded65aeb56b3024b9de6a9d4cf7"
|
|
8896
8906
|
},
|
|
8897
8907
|
{
|
|
8898
8908
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9882,7 +9892,7 @@
|
|
|
9882
9892
|
{
|
|
9883
9893
|
"path": "tests/git_merge_regen.mjs",
|
|
9884
9894
|
"mode": "0000644",
|
|
9885
|
-
"sha256": "
|
|
9895
|
+
"sha256": "2d3574914c1f9c843396f498a8e68c2fd8ec2a5bc574c98f64dc2505aa1ccba8"
|
|
9886
9896
|
},
|
|
9887
9897
|
{
|
|
9888
9898
|
"path": "tests/github_push_ancestry.mjs",
|
|
@@ -10507,7 +10517,7 @@
|
|
|
10507
10517
|
{
|
|
10508
10518
|
"path": "tests/idea_task_lineage.mjs",
|
|
10509
10519
|
"mode": "0000644",
|
|
10510
|
-
"sha256": "
|
|
10520
|
+
"sha256": "ee3271a4964f4bc30748bc101e9d276dac5575b9b5bcebd41f655391d1b7df35"
|
|
10511
10521
|
},
|
|
10512
10522
|
{
|
|
10513
10523
|
"path": "tests/idea_task_ship_credit.mjs",
|
package/.gitattributes
CHANGED
|
@@ -74,6 +74,16 @@ src/bongos/CLAUDE.md merge=otb-regen
|
|
|
74
74
|
src/bongos/routes/CLAUDE.md merge=otb-regen
|
|
75
75
|
scripts/gds/CLAUDE.md merge=otb-regen
|
|
76
76
|
|
|
77
|
+
# docs/copy-registry.json + docs/copy-inventory.md (task 1003543): the copy
|
|
78
|
+
# registry's committed pair (scripts/gds/copy-inventory.js) — fully generated from
|
|
79
|
+
# a tree scan, nothing hand-authored in either, so two branches that each touch
|
|
80
|
+
# hall/status/landing copy regenerate them differently and conflict like ordinary
|
|
81
|
+
# files. Same otb-regen shape as docs/session-log-index.md above (whole-file
|
|
82
|
+
# rebuild, all three merge sides ignored); REGENERABLE in git-merge-regen.js
|
|
83
|
+
# carries the matching rule.
|
|
84
|
+
docs/copy-registry.json merge=otb-regen
|
|
85
|
+
docs/copy-inventory.md merge=otb-regen
|
|
86
|
+
|
|
77
87
|
# Append-only index tables (task 1385 / ADR 0082). docs/adr/README.md is a
|
|
78
88
|
# hand-maintained ADR index whose ONLY recurring conflict is two branches each
|
|
79
89
|
# appending a new row — there is no generator to regen it from, so `otb-regen`
|
|
@@ -48,6 +48,10 @@ The PR's required checks (unit / trufflehog / audit / gate-review) re-run on the
|
|
|
48
48
|
- `docs/adr/README.md` itself now carries `merge=union`, so this ADR's own index row — and every future one — stops conflicting.
|
|
49
49
|
- The generated-artifact set is defined once (`REGENERABLE` in `git-merge-regen.js`, mirrored by `.gitattributes` and the `fitness.js` freshness gate); adding a future generated file means adding it in those places together.
|
|
50
50
|
|
|
51
|
+
## Addendum (task 1003543, 2026-09-03) — the copy registry joined the whole-file set
|
|
52
|
+
|
|
53
|
+
Two hall PRs in one session (526 for task 1003069, 531 for task 1003157) both ran `scripts/gds/copy-inventory.js` and sat `confirmed` with GitHub `mergeable=CONFLICTING` for 20+ minutes: `docs/copy-inventory.md` + `docs/copy-registry.json` carried no `.gitattributes` entry and no `REGENERABLE` rule, so they conflicted as ordinary text/JSON and the server resolver could not heal them (`resolveBranchConflicts` *did* run — its log line fired for both PRs — but the `otb-regen` driver refused the unregistered paths and the merge came back non-zero, which the resolver correctly reported as `unresolvable_conflict`; the reconciler dispatch was never the gap). Both files are, like `docs/repo-map.md` and `docs/session-log-index.md`, 100% generated from a tree scan with nothing hand-authored in either — `copy-inventory.js`'s own header says so ("Deterministic on a given tree — regenerate and commit rather than hand-editing"), so they needed the whole-file `otb-regen` shape, not `union`. The fix: `docs/copy-registry.json merge=otb-regen` + `docs/copy-inventory.md merge=otb-regen` in `.gitattributes`, both added to `REGENERABLE` in `git-merge-regen.js` with a regeneration rule that calls `copy-inventory.js`'s `buildRegistry()`/`renderReport()` directly (ignoring all three merge sides, same as the repo-map heal). No change was needed to `conflict-resolve.js`, `publish-reconciler.js`, or `ship-preflight-steps.js` — none of the three keeps its own copy of the generated-file list; all three read `REGENERABLE` (or, for the resolver, register the driver that reads it), so the new pair is covered everywhere the existing four already were.
|
|
54
|
+
|
|
51
55
|
## Alternatives considered
|
|
52
56
|
|
|
53
57
|
- **Lower deploy/merge access to `thetes`** (the owner's first instinct) — rejected: reopens the Builder 25 hole for zero benefit here (the stranded session was already Archon; rank was never the blocker).
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 0236 — Many ideas → one task: the lineage key is the pair
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-03
|
|
5
|
+
- **Tasks:** [#1003515](https://cloudbongos.com/builders#/task/1003515) (this decision, promoted from
|
|
6
|
+
idea 1000815), [#1002997](https://cloudbongos.com/builders#/task/1002997) (created the lineage table),
|
|
7
|
+
[#1003065](https://cloudbongos.com/builders#/task/1003065) (the R01 diff whose grader flagged the shape)
|
|
8
|
+
- **Goal:** [#1000071](https://cloudbongos.com/builders#/goal/1000071) — Idea routing
|
|
9
|
+
- **Related:** [ADR 0172](<redacted>.md) (the ideator credit lane this
|
|
10
|
+
table is the rail for), [ADR 0185](<redacted>.md) (the other place an idea has more
|
|
11
|
+
than one claimant), [ADR 0234](<redacted>.md)
|
|
12
|
+
(routing, the third `origin`)
|
|
13
|
+
|
|
14
|
+
## 1. The problem
|
|
15
|
+
|
|
16
|
+
`ideas_002_idea_tasks.sql` made `task_id` the **primary key** of
|
|
17
|
+
`ideas_task_lineage`, on the stated reasoning that "a task descends from at most
|
|
18
|
+
one idea". `linkIdeaTask` matched it:
|
|
19
|
+
|
|
20
|
+
```sql
|
|
21
|
+
ON CONFLICT (task_id) DO UPDATE SET idea_id = EXCLUDED.idea_id, origin = EXCLUDED.origin
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
So promoting a **second** idea into a task that already had a link did not fail
|
|
25
|
+
and did not warn — it *replaced* the first idea's lineage. `updateIdea` fires
|
|
26
|
+
`linkIdeaTask` on any promote that sets `promoted_to_task_id`, so two ordinary
|
|
27
|
+
triage promotes at one task were enough. That happened on prod on 2026-08-21:
|
|
28
|
+
ideas 1000811 and 1000812 were both promoted into task 1003150 (two call sites of
|
|
29
|
+
one mechanism), and idea 1000811's link is gone — only its weaker
|
|
30
|
+
`idea_inbox.promoted_to_task_id` pointer survived.
|
|
31
|
+
|
|
32
|
+
This table is the rail [ADR 0172](<redacted>.md)'s
|
|
33
|
+
stream 1 pays against (`<redacted>`, `sourceIdeaForTask`), so an
|
|
34
|
+
overwritten row is an ideator silently not paid for work their idea produced,
|
|
35
|
+
with no trace anywhere that it happened. It also made "N duplicate reports merged
|
|
36
|
+
into one task" a **lossy** operation — the exact triage move the inbox exists to
|
|
37
|
+
encourage.
|
|
38
|
+
|
|
39
|
+
The grading panel's hacker worker named this shape on the R01 diff and was told
|
|
40
|
+
it was unreachable. It was unreachable on the *routing* path it examined and
|
|
41
|
+
fully reachable on the ordinary *promote* path, which needs no idempotency edge
|
|
42
|
+
at all. The finding was right about the shape and wrong about the reach, and only
|
|
43
|
+
the half it named was checked.
|
|
44
|
+
|
|
45
|
+
## 2. The decision
|
|
46
|
+
|
|
47
|
+
**Many ideas → one task is a real relation, so the key is the pair.**
|
|
48
|
+
`ideas_006_lineage_many_ideas_one_task.sql` drops the single-column primary key
|
|
49
|
+
and re-adds `ideas_task_lineage_pkey` as `PRIMARY KEY (task_id, idea_id)`. Both
|
|
50
|
+
INSERT sites (`linkIdeaTask` and `routeIdeaToGoal`'s transaction) target the pair
|
|
51
|
+
and `DO UPDATE SET origin` — never `idea_id`, which is now part of the identity
|
|
52
|
+
rather than a mutable field. `created_at` is left alone by the upsert, so *which
|
|
53
|
+
link came first* is a durable fact a re-link cannot rewrite.
|
|
54
|
+
|
|
55
|
+
The alternatives were considered and rejected. **Refusing the second link**
|
|
56
|
+
(assert-no-existing-lineage) turns a legitimate triage merge into an error and
|
|
57
|
+
teaches triagers to avoid merging. **Logging the replacement loudly** keeps a
|
|
58
|
+
correct-by-luck ledger that is still lossy. The table was already the
|
|
59
|
+
"many-tasks" side table with a non-unique `idea_id`; making the other direction
|
|
60
|
+
real is the smaller change, not the bigger one.
|
|
61
|
+
|
|
62
|
+
**Where one row is still required, the earliest link wins** —
|
|
63
|
+
`ORDER BY created_at, idea_id`, stated in SQL rather than left to the planner.
|
|
64
|
+
That rule is shared by `sourceIdeaForTask`, `credits.awardIdeaTaskShipCredit`,
|
|
65
|
+
and the nod queue's one-row-per-task predicate (a single constant embedded in
|
|
66
|
+
both the list and its count, so the pill can never disagree with the rows).
|
|
67
|
+
|
|
68
|
+
## 3. Consequences
|
|
69
|
+
|
|
70
|
+
- **A task subsumed from N ideas counts once toward *each* idea's shipped-task
|
|
71
|
+
credit.** Every co-source idea keeps its own row, so
|
|
72
|
+
`<redacted>` no longer drops to zero for whoever was overwritten,
|
|
73
|
+
and the idea trail on a builder's profile shows the shared task under each of
|
|
74
|
+
their ideas. It cannot double-count within one idea: the pair is unique.
|
|
75
|
+
- **The per-ship payout still names one idea — the earliest.** Whether a shared
|
|
76
|
+
task should pay *every* co-source ideator is a decision about how much a
|
|
77
|
+
shipped task is worth in total, not a bug fix, and it is deliberately not made
|
|
78
|
+
here. The idempotency key is already `idea.credit.task_ship:<idea>:<task>`, so
|
|
79
|
+
it is a small change whenever the owner wants it.
|
|
80
|
+
- **The migration backfills.** Every `idea_inbox` row with a
|
|
81
|
+
`promoted_to_task_id` whose task carries no row for *that* idea gets its link
|
|
82
|
+
back (`ON CONFLICT DO NOTHING`, so surviving rows keep their own `origin` and
|
|
83
|
+
`created_at`). Restored rows are labelled `origin='promote'`: where the erased
|
|
84
|
+
row had been a routed one, its `'route'` provenance is unrecoverable from that
|
|
85
|
+
column alone, and a restored link with a coarse origin beats a missing one —
|
|
86
|
+
`origin` weighs a payout, the link is whether there is one.
|
|
87
|
+
- **A lineage write that fails is now reported.** `updateIdea` still swallows the
|
|
88
|
+
failure (the triage verdict has already committed; lineage is a ledger
|
|
89
|
+
convenience, not a gate) but logs a `LINEAGE GAP` line through
|
|
90
|
+
`api.logger('ideas')`. A silent swallow on top of a silent overwrite was two
|
|
91
|
+
layers of the same mistake.
|
|
92
|
+
- **`DROP CONSTRAINT` here is additive.** Postgres has no "widen a key" verb, so
|
|
93
|
+
the change is drop-then-re-add of the same-named constraint — the shape
|
|
94
|
+
`fitness.js`'s `isConstraintWidening` exempts, and the one
|
|
95
|
+
[`ideas_003`](../../modules/ideas/migrations/<redacted>.sql)
|
|
96
|
+
already recorded for a CHECK. The new key permits a strict superset of the old
|
|
97
|
+
one, so no existing row can be invalidated. The migration asserts its own
|
|
98
|
+
effect and raises if the primary key is still single-column.
|
package/docs/adr/README.md
CHANGED
|
@@ -323,5 +323,6 @@ This keeps the decision history honest and traceable.
|
|
|
323
323
|
| 0233 | [**A copy proposal is a task carrying a patch, and the applier is the only writer** ([task 1003114](https://cloudbongos.com/builders#/task/1003114) · goal 1000074, criterion `artist-edits-land-as-tasks`). Goal 1000074 asks for two things that pull against each other in its own text: the artist must change copy WITHOUT an engineer, and there must be NO LIVE CMS because every copy change is a claimed, graded, shipped task. R02 shipped the half that needs no reconciliation (the flag) and [ADR 0178](<redacted>.md) §3 pinned the absence of any column that could hold replacement text, naming this task as the one that would have to keep it true. Decisions: (1) the shape is **borrowed from the design half, not invented** — ADR 0081 already has a tool authoring and an `adapter.export()` landing the output as a committed diff under a claim inside a path guard, so the artist is the tool, the copy desk is where they author, and the proposal is the export. (2) A proposal is a **TASK carrying a fenced `copy-proposal` patch**, never a row: the ledger is where a proposal belongs anyway, nothing renders a task description as product copy, a reviewer can read the change without running anything, and `copy-desk` gains NO migration (the obvious-but-wrong R03 is a `copy_desk_proposals` table, which is the forbidden column with extra steps). The module gains `consumes: ["lifecycle"]` and calls `createTask` through the port — it can put a request INTO the ledger and nothing can pull copy OUT of it. (3) `scripts/gds/copy-apply.js` **re-derives the position** rather than trusting the patch, which required teaching R01s extractor to record a source SPAN and deliberately dropping it from the committed registry: an offset goes stale the moment anything above it changes, while the id is designed to survive exactly that movement, so the span is recomputed at the moment of the edit by the same code that decided the string was copy. (4) The **placeholder arithmetic** is what makes a patch mechanical rather than a note for an engineer — the applier puts the sources own `${expr}` back, k-th hole to k-th expression, so an artist may MOVE a value within a sentence but never add or drop one, checked at proposal time and again against the real source. (5) Every ambiguity is a **named refusal** (`target_gone`, `ambiguous_target`, `no_exact_span`, `placeholder_mismatch`, `path_outside_surface` — the design adapters path guard, derived from R01s own SURFACES), because a wrong edit is worse than no edit; after writing, the applier regenerates the registry and checks that what it wrote is what the inventory now reads. (6) Proposing is **open to every rank** (an artist is a craft, not a rank) and lands at `backlog`, so a proposal is a request that passes ordinary triage. Rejected: a proposals table, a column beside the flag, trusting the patchs line number, an escape-aware index map to buy spans for nine strings, auto-closing the flag a proposal answers.](<redacted>.md) | copy-desk / the artist loop |
|
|
324
324
|
| 0234 | [**Idea routing: capture-time promotion, the landing matrix, and the homeless-only inbox** ([task 1003064](https://cloudbongos.com/builders#/task/1003064) · goal 1000071, "Idea routing"). Idea 1000771 named the failure: Discord `#bugs` created tasks DIRECTLY against a hardcoded, already-shipped version — no `captured_by`, no lineage, so the ideator-credit lane had nothing to pay against — while every other open idea sat waiting for a human triage pass that dropped its promotion into a version catch-all, because nothing in capture ever named a goal. Records what R01/R02/R05/R07/R13 shipped. `captureIdea` (`modules/ideas/inbox.js`) stays the ONE capture chokepoint — routing is one optional `goalId` argument, never a second creation door — and when the matrix routes, `routeIdeaToGoal` writes the idea row, the task (via the `lifecycle.createTask` port) and an `ideas_task_lineage` row (`origin='route'`) in ONE transaction, so a route can never mint attribution-free work the way `#bugs` did. `modules/ideas/routing.js`'s `decideLanding` is PURE and fail-closed: a bug from a Metic+ filer hard-lands `ready`, a sub-Metic bug or any other kind with a goal soft-lands `backlog`, a Full Idea or `criterion-proposal`/`decision` never routes, no goal named stays the pre-routing inbox path. `authorizeRoutedLanding` is the BINDING gate underneath the matrix's decision — a goal reaching protected scope raises the task's rank floor to Metic and REFUSES a sub-Metic filer outright, while membership is deliberately NOT required (reporting isn't authoring). `core_222`'s `suggested_goal_id` is an advisory hint only — the real home is `tasks.goal_id`, set by construction — and `/idea-triage` (`scripts/gds/triage.js`) now sends `goal_id` on promotion, so the inbox is homeless-only. Divergence recorded: R11 (Discord `#bugs`) hard-lands a linked Metic+ builder's bug immediately through the SAME shared matrix rather than the spec's "always soft-land, pending queue" design, because a local copy of the rank split would only drift from the one definition; and R12 (Discord `#ideas` hints) is NOT YET SHIPPED, so `#ideas` carries no goal hint today despite the interview decision that it should, hint-only, never route.](<redacted>.md) | ideas / lifecycle |
|
|
325
325
|
| 0235 | [**A fix category is `kind='bug'` under a goal, not a new column** ([task 1003215](https://cloudbongos.com/builders#/task/1003215) · goal 1000071, "Idea routing" — the structural half of idea 1000828; task 1003214 shipped the advisory's first quiet case, task 1003157 extended it). A fix for something that should already work needs a home under an existing goal WITHOUT a new goal being born and WITHOUT a stored column restating what `tasks.kind` already says. Not a global "Bugs" goal either: a goal spanning every module makes the [ADR 0086](<redacted>.md) scope wall either everything or nothing, and a goal that can never reach `achieved` (a bug container never finishes) pollutes the exact signal [ADR 0183](<redacted>.md)'s auto-satisfy depends on. Not [ADR 0192 "a category orients and authorises nothing"](<redacted>.md) either, despite the structural resemblance: that decision forbids any gate from branching on a goal's category, while THIS decision is two decision surfaces deliberately branching on `kind='bug'` — `adviseGoal()` (`modules/lifecycle/goal-advisory.js`) goes silent for a bug under all three of its reasons (`no_goal`, `catch_all`, `no_criterion_link` — "a fix is the goal's 'fix category', not a delivery against one of its stated outcomes," its own doc comment) and `countTasksWithoutCriterionLink()` (`modules/lifecycle/db-deps-criteria.js`, surfaced on the hall's goal detail view) excludes `kind='bug'` from its count for the same reason. No hall/status change was needed for GROUPING bugs under a goal: every task card already renders its `kind` as a visible tag and the goal detail view's Filters already offer a `Kind` lens with `bug` as a value — verified against the live code, not built new. Rejected: a `tasks.category`/`fix_category` column or enum, a global cross-module "Bugs" goal, a bug-specific grouping widget beside the general-purpose kind facility that already works.](<redacted>.md) | ideas / lifecycle |
|
|
326
|
+
| 0236 | [**Many ideas → one task: the lineage key is the pair** ([task 1003515](https://cloudbongos.com/builders#/task/1003515) · goal 1000071, "Idea routing" — promoted from idea 1000815; the table was built by [task 1002997](https://cloudbongos.com/builders#/task/1002997)). `ideas_task_lineage.task_id` was the PRIMARY KEY on the reasoning that "a task descends from at most one idea", and `linkIdeaTask` wrote `ON CONFLICT (task_id) DO UPDATE SET idea_id = EXCLUDED.idea_id` — so promoting a SECOND idea into a task that already had a link **silently replaced the first idea's lineage**: no error, no warning, no log line. Two ordinary triage promotes at one task were enough, and it happened on prod on 2026-08-21 (ideas 1000811 + 1000812 → task 1003150). Because this table is the rail [ADR 0172](<redacted>.md) stream 1 pays against, an overwritten row is an **ideator not paid** for work their idea produced, with nothing recording it — and it makes merging duplicate reports into one task a LOSSY move, which is the triage the inbox exists to encourage. The R01 grader named this shape and was told it was unreachable; it was unreachable on the ROUTING path examined and fully reachable on the ordinary PROMOTE path. **Decision: many ideas → one task is a real relation, so the key is the pair** — `ideas_006` re-adds `ideas_task_lineage_pkey` as `PRIMARY KEY (task_id, idea_id)` and both INSERT sites `DO UPDATE SET origin`, never `idea_id`. Refusing the second link would turn a legitimate merge into an error; logging the replacement keeps a lossy ledger. **Where one row is still required the EARLIEST link wins** (`ORDER BY created_at, idea_id`, stated in SQL rather than left to the planner): `sourceIdeaForTask`, `credits.awardIdeaTaskShipCredit`, and the nod queue's one-row-per-task predicate — one constant shared by the list and its count, so the pill can never disagree with the rows. Consequences: a task subsumed from N ideas **counts once toward each idea's** shipped-task credit (and cannot double-count within one — the pair is unique); the per-ship payout still names one idea, the earliest, because paying every co-source ideator is a decision about total worth, not a bug fix; the migration **backfills** from `idea_inbox.promoted_to_task_id`, the weaker pointer that survived each overwrite, labelling restored rows `origin='promote'`; and `updateIdea` still swallows a lineage failure but now reports it as a `LINEAGE GAP` through `api.logger` — a silent swallow on top of a silent overwrite was two layers of one mistake.](<redacted>.md) | ideas / economy |
|
|
326
327
|
|
|
327
328
|
> ⚠️ **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.
|
|
@@ -1439,5 +1439,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1439
1439
|
landed since 1.19.499 with no explicit bump. run 33772499045. (task 1002620)
|
|
1440
1440
|
1.19.501 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1441
1441
|
landed since 1.19.500 with no explicit bump. run 33774087943. (task 1002620)
|
|
1442
|
+
1.19.502 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1443
|
+
landed since 1.19.501 with no explicit bump. run 33777587058. (task 1002620)
|
|
1444
|
+
1.19.503 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1445
|
+
landed since 1.19.502 with no explicit bump. run 33783963947. (task 1002620)
|
|
1442
1446
|
---------------------------------------------------------------------------
|
|
1443
1447
|
```
|
|
@@ -529,6 +529,17 @@ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessS
|
|
|
529
529
|
// every no-pay path (no source idea, lane off, already paid). Idempotent per
|
|
530
530
|
// (idea, task) via reason='idea.credit.task_ship:<idea>:<task>', builder-scoped
|
|
531
531
|
// so the NOT EXISTS hits idx_credit_log_builder (the IC2 index lesson).
|
|
532
|
+
//
|
|
533
|
+
// ONE SOURCE IDEA, THE EARLIEST (task 1003515 / ADR 0236). ideas_006 made
|
|
534
|
+
// (task_id, idea_id) the lineage key, so a task subsumed from several ideas now
|
|
535
|
+
// keeps a row for EACH — which means this read must choose, and an unordered
|
|
536
|
+
// rows[0] would have let the query planner choose the payee. It pays the link
|
|
537
|
+
// recorded FIRST, matching inbox.sourceIdeaForTask so the ledger and the money
|
|
538
|
+
// name the same idea. Paying EVERY co-source idea is a separate decision about
|
|
539
|
+
// how much a shipped task is worth in total, not a bug fix — and the reason key
|
|
540
|
+
// above is already per-(idea, task), so it is a small change if the owner wants
|
|
541
|
+
// it. What this task fixes is that a co-source idea is no longer ERASED: it
|
|
542
|
+
// still counts in countShippedTasksForIdea and still shows in the idea trail.
|
|
532
543
|
async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig() } = {}) {
|
|
533
544
|
if (config && config.ideatorCredit === false) {
|
|
534
545
|
return { credited: 0, reason: 'ideator_lane_off' };
|
|
@@ -543,7 +554,9 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
543
554
|
FROM ideas_task_lineage l
|
|
544
555
|
JOIN idea_inbox i ON i.id = l.idea_id
|
|
545
556
|
LEFT JOIN ideas_grades g ON g.idea_id = l.idea_id
|
|
546
|
-
WHERE l.task_id = $1
|
|
557
|
+
WHERE l.task_id = $1
|
|
558
|
+
ORDER BY l.created_at ASC, l.idea_id ASC
|
|
559
|
+
LIMIT 1`,
|
|
547
560
|
[taskId]
|
|
548
561
|
);
|
|
549
562
|
if (rows.length === 0 || rows[0].captured_by == null) {
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -445,11 +445,16 @@ async function routeIdeaToGoal({
|
|
|
445
445
|
// d. The lineage row — origin='route' (ideas_003 widened the CHECK for
|
|
446
446
|
// exactly this). THIS is the row goal 1000067's ideator credit will pay
|
|
447
447
|
// against, and the reason all of this is one transaction.
|
|
448
|
+
//
|
|
449
|
+
// The conflict target is the PAIR (task 1003515 / ADR 0236): ideas_006
|
|
450
|
+
// made (task_id, idea_id) the primary key, so a second idea landing on
|
|
451
|
+
// the same task ADDS a link instead of erasing the first one's. See
|
|
452
|
+
// linkIdeaTask below for why the surviving row's origin is refreshed.
|
|
448
453
|
await client.query(
|
|
449
454
|
`INSERT INTO ideas_task_lineage (task_id, idea_id, origin)
|
|
450
455
|
VALUES ($1, $2, 'route')
|
|
451
|
-
ON CONFLICT (task_id) DO UPDATE
|
|
452
|
-
SET
|
|
456
|
+
ON CONFLICT (task_id, idea_id) DO UPDATE
|
|
457
|
+
SET origin = EXCLUDED.origin`,
|
|
453
458
|
[task.id, idea.id]
|
|
454
459
|
);
|
|
455
460
|
|
|
@@ -786,17 +791,46 @@ async function updateIdea(id, { status, kind, promoted_to_task_id, reviewed_at,
|
|
|
786
791
|
// later pay "5 per task shipped under this idea". This is ADDITIVE to the
|
|
787
792
|
// existing single-FK promoted_to_task_id (unchanged) — best-effort so it can
|
|
788
793
|
// never fail a triage verdict that already committed on the line above.
|
|
794
|
+
//
|
|
795
|
+
// STILL SWALLOWED, NO LONGER SILENT (task 1003515 / ADR 0236). The catch stays
|
|
796
|
+
// — the triage verdict committed on the line above and a ledger convenience
|
|
797
|
+
// must not fail it — but a lineage row that never landed is an ideator who
|
|
798
|
+
// will never be paid, and until now nothing anywhere said so. It reports
|
|
799
|
+
// through the module's logger seam (api.logger, the same one routes/inbox.js
|
|
800
|
+
// uses) rather than console.*, which is what the whole codebase logs through.
|
|
789
801
|
if (promoted_to_task_id !== undefined && promoted_to_task_id !== null) {
|
|
790
802
|
try { await linkIdeaTask(id, promoted_to_task_id, 'promote'); }
|
|
791
|
-
catch (e) {
|
|
803
|
+
catch (e) {
|
|
804
|
+
api.logger('ideas').error(
|
|
805
|
+
`LINEAGE GAP: idea ${id} promoted to task ${promoted_to_task_id} but its ideas_task_lineage row failed (the promote stands; ideator credit for this task will not pay): ${e && e.message}`
|
|
806
|
+
);
|
|
807
|
+
}
|
|
792
808
|
}
|
|
793
809
|
return rows[0];
|
|
794
810
|
}
|
|
795
811
|
|
|
796
812
|
// Record that a task descends from an idea (task 1002997 / BV1.R173). The
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
813
|
+
// many-ideas-many-tasks lineage stream 1 reads. Keyed by the PAIR
|
|
814
|
+
// (task_id, idea_id), idempotent: re-linking the same (idea, task) refreshes
|
|
815
|
+
// only its origin, and linking a SECOND idea to a task ADDS a link.
|
|
816
|
+
//
|
|
817
|
+
// WHY THE PAIR, AND WHY THIS FUNCTION IS THE REASON (task 1003515 / ADR 0236).
|
|
818
|
+
// task_id alone was the primary key until ideas_006, and this INSERT carried
|
|
819
|
+
// `ON CONFLICT (task_id) DO UPDATE SET idea_id = EXCLUDED.idea_id` — so
|
|
820
|
+
// promoting a second idea into one task SILENTLY ERASED the first idea's
|
|
821
|
+
// lineage, and with it that ideator's claim on everything the task goes on to
|
|
822
|
+
// ship. It happened on prod (ideas 1000811 + 1000812 → task 1003150, 2026-08-21).
|
|
823
|
+
// Merging duplicate reports into one task is a normal triage move, so the schema
|
|
824
|
+
// now says so.
|
|
825
|
+
//
|
|
826
|
+
// DO UPDATE SET origin, not DO NOTHING. The pair is what must never be lost;
|
|
827
|
+
// `origin` is a mutable FACT ABOUT that pair (how the task came from the idea)
|
|
828
|
+
// and the caller writing it now knows more than the caller who wrote it first —
|
|
829
|
+
// a route later re-promoted through triage is genuinely both, and the newest
|
|
830
|
+
// call is the more specific description. Nothing hangs on the old value: origin
|
|
831
|
+
// weighs a payout, it never gates one. created_at is left alone by the DO
|
|
832
|
+
// UPDATE, so "which link came first" — the tie-break every single-row read below
|
|
833
|
+
// uses — stays the ORIGINAL answer and cannot be rewritten by a re-link.
|
|
800
834
|
//
|
|
801
835
|
// TWO ORIGINS, ONE WIRED TODAY. `origin='promote'` is called live below, from
|
|
802
836
|
// updateIdea, on every triage promote. `origin='develop'` is the SEAM for R146
|
|
@@ -810,8 +844,8 @@ async function linkIdeaTask(ideaId, taskId, origin = 'promote') {
|
|
|
810
844
|
await pool.query(
|
|
811
845
|
`INSERT INTO ideas_task_lineage (task_id, idea_id, origin)
|
|
812
846
|
VALUES ($1, $2, $3)
|
|
813
|
-
ON CONFLICT (task_id) DO UPDATE
|
|
814
|
-
SET
|
|
847
|
+
ON CONFLICT (task_id, idea_id) DO UPDATE
|
|
848
|
+
SET origin = EXCLUDED.origin`,
|
|
815
849
|
[taskId, ideaId, origin]
|
|
816
850
|
);
|
|
817
851
|
}
|
|
@@ -819,6 +853,12 @@ async function linkIdeaTask(ideaId, taskId, origin = 'promote') {
|
|
|
819
853
|
// Count tasks that have SHIPPED under an idea (task 1002997 / BV1.R173). This is
|
|
820
854
|
// the query stream 1 (IC4) pays against: `WHERE idea_id = N` hits the index, and
|
|
821
855
|
// the tasks join filters to shipped. Returns a plain integer.
|
|
856
|
+
//
|
|
857
|
+
// A task SUBSUMED FROM SEVERAL IDEAS counts ONCE for EACH of them (task 1003515
|
|
858
|
+
// / ADR 0236) — the composite key gives every co-source idea its own row, so
|
|
859
|
+
// this count no longer silently drops to zero for whichever ideator was
|
|
860
|
+
// overwritten. It cannot double-count within one idea either: (task_id, idea_id)
|
|
861
|
+
// is unique, so one idea can hold at most one link to a given task.
|
|
822
862
|
async function countShippedTasksForIdea(ideaId) {
|
|
823
863
|
const { rows } = await pool.query(
|
|
824
864
|
`SELECT COUNT(*)::int AS n
|
|
@@ -833,12 +873,24 @@ async function countShippedTasksForIdea(ideaId) {
|
|
|
833
873
|
// The source idea of a task, or null (task 1002997 / BV1.R173). The reverse
|
|
834
874
|
// lookup stream 1's ship hook (IC4) uses: given a task that just shipped, which
|
|
835
875
|
// idea — and which author — does it pay?
|
|
876
|
+
//
|
|
877
|
+
// ONE ROW, AND IT IS THE EARLIEST LINK (task 1003515 / ADR 0236). Since ideas_006
|
|
878
|
+
// a task may descend from SEVERAL ideas, so "the source idea" is a choice this
|
|
879
|
+
// function has to make rather than a fact the key hands it. It returns the link
|
|
880
|
+
// that was recorded FIRST — the idea the work was actually opened for — and says
|
|
881
|
+
// so in SQL rather than leaving it to whatever order Postgres happens to return,
|
|
882
|
+
// because an unordered LIMIT-less rows[0] is a payee chosen by the planner. The
|
|
883
|
+
// tie-break on idea_id keeps two links written in the same transaction
|
|
884
|
+
// deterministic. Callers wanting the COMPLETE ancestry read the table (or the
|
|
885
|
+
// trail's lineage array), not this.
|
|
836
886
|
async function sourceIdeaForTask(taskId) {
|
|
837
887
|
const { rows } = await pool.query(
|
|
838
888
|
`SELECT l.idea_id, l.origin, i.captured_by
|
|
839
889
|
FROM ideas_task_lineage l
|
|
840
890
|
JOIN idea_inbox i ON i.id = l.idea_id
|
|
841
|
-
WHERE l.task_id = $1
|
|
891
|
+
WHERE l.task_id = $1
|
|
892
|
+
ORDER BY l.created_at ASC, l.idea_id ASC
|
|
893
|
+
LIMIT 1`,
|
|
842
894
|
[taskId]
|
|
843
895
|
);
|
|
844
896
|
return rows[0] || null;
|
|
@@ -864,6 +916,24 @@ async function sourceIdeaForTask(taskId) {
|
|
|
864
916
|
// Deliberately NOT filtered by goal membership or by the viewer: any Metic+ may
|
|
865
917
|
// give the nod (Q3 rejected goal-member-first review as surface cost), so every
|
|
866
918
|
// Metic+ sees one queue. The route is what gates rank; this is a plain read.
|
|
919
|
+
//
|
|
920
|
+
// ONE ROW PER TASK (task 1003515 / ADR 0236). Since ideas_006 a task can hold
|
|
921
|
+
// several lineage rows, so a plain JOIN through the lineage would list — and
|
|
922
|
+
// count — the same task once per source idea, showing a Metic+ the same nod
|
|
923
|
+
// twice. Two routed links on one task should be unreachable (a route mints its
|
|
924
|
+
// task with sourceRef `idea-<id>`, so each routed idea gets its own), but
|
|
925
|
+
// "unreachable in practice" is the precise reasoning that produced the bug this
|
|
926
|
+
// task fixes, and the queue must not be the place it is discovered. The
|
|
927
|
+
// predicate keeps the EARLIEST routed link, the same rule sourceIdeaForTask
|
|
928
|
+
// uses, and BOTH the list and the count below embed this one constant so the
|
|
929
|
+
// pill can never disagree with the rows.
|
|
930
|
+
const ROUTE_LINK_IS_EARLIEST = `NOT EXISTS (
|
|
931
|
+
SELECT 1 FROM ideas_task_lineage l2
|
|
932
|
+
WHERE l2.task_id = l.task_id
|
|
933
|
+
AND l2.origin = 'route'
|
|
934
|
+
AND (l2.created_at, l2.idea_id) < (l.created_at, l.idea_id)
|
|
935
|
+
)`;
|
|
936
|
+
|
|
867
937
|
async function listRoutedAwaitingNod({ limit = 20 } = {}, deps = {}) {
|
|
868
938
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
869
939
|
const { rows } = await activePool.query(
|
|
@@ -878,6 +948,7 @@ async function listRoutedAwaitingNod({ limit = 20 } = {}, deps = {}) {
|
|
|
878
948
|
LEFT JOIN goals g ON g.id = t.goal_id
|
|
879
949
|
WHERE l.origin = 'route'
|
|
880
950
|
AND t.status = 'backlog'
|
|
951
|
+
AND ${ROUTE_LINK_IS_EARLIEST}
|
|
881
952
|
ORDER BY t.created_at ASC
|
|
882
953
|
LIMIT $1`,
|
|
883
954
|
// The origin filter rides idx_ideas_task_lineage_route (ideas_004): a PARTIAL
|
|
@@ -898,8 +969,10 @@ async function countRoutedAwaitingNod(deps = {}) {
|
|
|
898
969
|
`SELECT COUNT(*)::int AS n
|
|
899
970
|
FROM ideas_task_lineage l
|
|
900
971
|
JOIN tasks t ON t.id = l.task_id
|
|
901
|
-
WHERE l.origin = 'route' AND t.status = 'backlog'
|
|
902
|
-
|
|
972
|
+
WHERE l.origin = 'route' AND t.status = 'backlog'
|
|
973
|
+
AND ${ROUTE_LINK_IS_EARLIEST}`
|
|
974
|
+
// Same predicate ⇒ same index (ideas_004) as the list above — including the
|
|
975
|
+
// one-row-per-task clause, which is shared as a constant rather than copied.
|
|
903
976
|
);
|
|
904
977
|
return rows[0] ? rows[0].n : 0;
|
|
905
978
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
-- ideas_006_lineage_many_ideas_one_task.sql — a task may descend from MANY ideas
|
|
2
|
+
-- (task 1003515, goal 1000071 "Idea routing"; ADR 0236).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. ideas_002 made task_id the PRIMARY KEY of ideas_task_lineage, on the
|
|
5
|
+
-- reasoning that "a task descends from AT MOST ONE idea". linkIdeaTask therefore
|
|
6
|
+
-- wrote `ON CONFLICT (task_id) DO UPDATE SET idea_id = EXCLUDED.idea_id`, so
|
|
7
|
+
-- promoting a SECOND idea into a task that already had a link SILENTLY REPLACED
|
|
8
|
+
-- the first idea's lineage. No error, no warning, no log line. It happened on
|
|
9
|
+
-- prod on 2026-08-21: ideas 1000811 and 1000812 were both promoted into task
|
|
10
|
+
-- 1003150 (two call sites of one mechanism — a normal and desirable triage move)
|
|
11
|
+
-- and idea 1000811's link was erased, leaving only the weaker
|
|
12
|
+
-- idea_inbox.promoted_to_task_id pointer behind.
|
|
13
|
+
--
|
|
14
|
+
-- WHY IT IS WORTH CHANGING THE KEY. This table is the rail goal 1000067
|
|
15
|
+
-- (ideator compensation) pays against — countShippedTasksForIdea and
|
|
16
|
+
-- sourceIdeaForTask are the queries that pay it. A silently overwritten row
|
|
17
|
+
-- means an ideator is simply not paid for work that descended from their idea,
|
|
18
|
+
-- with nothing anywhere recording that it happened. It also makes "N ideas
|
|
19
|
+
-- subsumed into one task" a LOSSY operation, and merging duplicate reports into
|
|
20
|
+
-- one task is exactly the triage move the inbox is supposed to encourage.
|
|
21
|
+
--
|
|
22
|
+
-- THE DECISION: many ideas → one task is a REAL relation, so the key is the
|
|
23
|
+
-- PAIR. The table already permitted one idea → many tasks (idea_id was never
|
|
24
|
+
-- unique); this makes the symmetric direction real instead of destructive.
|
|
25
|
+
-- Wherever a SINGLE-row answer is still required — sourceIdeaForTask, the ship
|
|
26
|
+
-- hook's credit read, the nod queue — the EARLIEST link wins, deterministically
|
|
27
|
+
-- (created_at, then idea_id). ADR 0236.
|
|
28
|
+
--
|
|
29
|
+
-- WHY THIS IS ADDITIVE DESPITE THE `DROP CONSTRAINT`. The same shape ideas_003
|
|
30
|
+
-- recorded for its CHECK: Postgres has no "widen a key" verb, so the change is
|
|
31
|
+
-- necessarily drop-then-re-add of the SAME-named constraint, and fitness.js's
|
|
32
|
+
-- isConstraintWidening exempts exactly that pattern. It is additive in EFFECT —
|
|
33
|
+
-- the new key permits a strict SUPERSET of the old one (every row legal before
|
|
34
|
+
-- is legal after), no existing row is touched and no data is lost. The dropped
|
|
35
|
+
-- name must be Postgres's auto-generated `ideas_task_lineage_pkey` from the
|
|
36
|
+
-- ideas_002 inline PRIMARY KEY, or the DROP silently no-ops and the erasure
|
|
37
|
+
-- continues; the safety net at the bottom is what proves the widening took.
|
|
38
|
+
--
|
|
39
|
+
-- The other indexes are untouched and stay valid: idx_ideas_task_lineage_idea
|
|
40
|
+
-- (ideas_002, on idea_id — the credit count's index) and
|
|
41
|
+
-- idx_ideas_task_lineage_route (ideas_004, partial on task_id WHERE
|
|
42
|
+
-- origin='route' — the nod queue's) are independent objects; only the implicit
|
|
43
|
+
-- unique index behind the primary key is rebuilt, now over both columns.
|
|
44
|
+
--
|
|
45
|
+
-- Module migrations are ADDITIVE and NAMESPACED: every object here is
|
|
46
|
+
-- ideas_-prefixed, no down-migration, idempotent (safe to re-run).
|
|
47
|
+
|
|
48
|
+
BEGIN;
|
|
49
|
+
|
|
50
|
+
ALTER TABLE ideas_task_lineage DROP CONSTRAINT IF EXISTS ideas_task_lineage_pkey;
|
|
51
|
+
ALTER TABLE ideas_task_lineage ADD CONSTRAINT ideas_task_lineage_pkey
|
|
52
|
+
PRIMARY KEY (task_id, idea_id);
|
|
53
|
+
|
|
54
|
+
-- BACKFILL — restore every link the old upsert could have erased, not only the
|
|
55
|
+
-- one incident we happen to know about. idea_inbox.promoted_to_task_id is the
|
|
56
|
+
-- weaker pointer that SURVIVED each overwrite (updateIdea writes it in the same
|
|
57
|
+
-- call that wrote the lineage row), so it is the standing record of what the
|
|
58
|
+
-- lineage lost: every promoted idea whose task carries no row for THAT idea gets
|
|
59
|
+
-- its link back.
|
|
60
|
+
--
|
|
61
|
+
-- origin='promote' for all of them. promoted_to_task_id is written by the triage
|
|
62
|
+
-- promote path, so that is the right label for the overwhelming majority; where
|
|
63
|
+
-- the erased row had been a ROUTED one its 'route' provenance is unrecoverable
|
|
64
|
+
-- from this column alone and it is relabelled promote. A restored link with a
|
|
65
|
+
-- slightly coarse origin beats a missing one — origin only WEIGHS a payout,
|
|
66
|
+
-- while the link is whether there is a payout at all.
|
|
67
|
+
--
|
|
68
|
+
-- ON CONFLICT DO NOTHING, so a link that survived keeps its own origin and
|
|
69
|
+
-- created_at untouched, and re-running this migration writes nothing.
|
|
70
|
+
INSERT INTO ideas_task_lineage (task_id, idea_id, origin)
|
|
71
|
+
SELECT i.promoted_to_task_id, i.id, 'promote'
|
|
72
|
+
FROM idea_inbox i
|
|
73
|
+
WHERE i.promoted_to_task_id IS NOT NULL
|
|
74
|
+
ON CONFLICT (task_id, idea_id) DO NOTHING;
|
|
75
|
+
|
|
76
|
+
-- SAFETY NET — a migration that cannot prove its own effect must not report
|
|
77
|
+
-- success (the ideas_003 / ideas_005 precedent). Read-only, no DDL: if the
|
|
78
|
+
-- primary key is still the single column task_id then the DROP above targeted a
|
|
79
|
+
-- differently-named constraint, the widening did not take, and the very next
|
|
80
|
+
-- double promote silently erases attribution again — the exact failure this file
|
|
81
|
+
-- exists to end, which would otherwise be discovered the same way it was the
|
|
82
|
+
-- first time: by accident, months later, with the evidence already gone.
|
|
83
|
+
DO $$
|
|
84
|
+
DECLARE pk_cols text;
|
|
85
|
+
BEGIN
|
|
86
|
+
SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
|
|
87
|
+
FROM pg_constraint c
|
|
88
|
+
JOIN unnest(c.conkey) AS k(attnum) ON true
|
|
89
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
|
90
|
+
WHERE c.conrelid = 'ideas_task_lineage'::regclass
|
|
91
|
+
AND c.contype = 'p';
|
|
92
|
+
IF pk_cols IS DISTINCT FROM 'idea_id,task_id' THEN
|
|
93
|
+
RAISE EXCEPTION
|
|
94
|
+
'ideas_006: ideas_task_lineage primary key is (%) — expected the composite (task_id, idea_id); the widening did not take.', COALESCE(pk_cols, 'none');
|
|
95
|
+
END IF;
|
|
96
|
+
END $$;
|
|
97
|
+
|
|
98
|
+
COMMIT;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.503",
|
|
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.503",
|
|
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.503",
|
|
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",
|
|
@@ -28,6 +28,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
28
28
|
const G = require('./gen-repo-map');
|
|
29
29
|
const FM = require('./gen-file-map');
|
|
30
30
|
const SI = require('./gen-session-index');
|
|
31
|
+
const CI = require('./copy-inventory');
|
|
31
32
|
|
|
32
33
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
33
34
|
|
|
@@ -36,10 +37,18 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
36
37
|
// hand-written prose with one generated block; docs/file-map.md is hand-written
|
|
37
38
|
// prose with TWO generated section blocks (ADR 0066). (task 1385: session-log-index
|
|
38
39
|
// joined so a confirmed PR that only conflicts on it self-heals like the rest.)
|
|
40
|
+
// docs/copy-inventory.md + docs/copy-registry.json (task 1003543) are the copy
|
|
41
|
+
// registry's own pair — like repo-map.md, `copy-inventory.js` writes them
|
|
42
|
+
// wholesale from a tree scan with nothing hand-authored in either, so any two
|
|
43
|
+
// branches that both touch hall/status/landing copy regenerate them differently
|
|
44
|
+
// and conflict on ordinary text; they join the whole-file group for the same
|
|
45
|
+
// reason session-log-index.md did.
|
|
39
46
|
const REGENERABLE = new Set([
|
|
40
47
|
'docs/repo-map.md',
|
|
41
48
|
'docs/file-map.md',
|
|
42
49
|
'docs/session-log-index.md',
|
|
50
|
+
'docs/copy-inventory.md',
|
|
51
|
+
'docs/copy-registry.json',
|
|
43
52
|
...G.NESTED_DIRS.map((d) => `${d}/CLAUDE.md`),
|
|
44
53
|
]);
|
|
45
54
|
|
|
@@ -86,6 +95,17 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
|
|
|
86
95
|
return { content: SI.renderIndex(SI.collectEntries()), ok: true };
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
// docs/copy-registry.json / docs/copy-inventory.md: both artifacts of one scan
|
|
99
|
+
// (copy-inventory.js), fully generated — ignore all three sides, rebuild from
|
|
100
|
+
// the merged tree. Reuse buildRegistry() rather than shelling out to write() so
|
|
101
|
+
// this stays in-process like the repo-map/session-index heals above.
|
|
102
|
+
if (relpath === 'docs/copy-registry.json') {
|
|
103
|
+
return { content: `${JSON.stringify(CI.buildRegistry(), null, 2)}\n`, ok: true };
|
|
104
|
+
}
|
|
105
|
+
if (relpath === 'docs/copy-inventory.md') {
|
|
106
|
+
return { content: CI.renderReport(CI.buildRegistry()), ok: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
const model = G.buildModel();
|
|
90
110
|
|
|
91
111
|
// docs/repo-map.md is fully generated — ignore all three sides, rebuild it.
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.503'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
// markers remain) so a human resolves it — the heal never clobbers real edits.
|
|
15
15
|
// 4. The merge-driver entrypoint writes %A + exits 0 (heal) / 1 (real conflict).
|
|
16
16
|
// 5. A non-generated path is never touched (not in REGENERABLE).
|
|
17
|
+
// 6. END-TO-END: a real `git merge` invokes the driver via .gitattributes.
|
|
18
|
+
// 7. .gitattributes' merge=otb-regen lines and REGENERABLE agree (task 1003543).
|
|
19
|
+
// 8. docs/copy-registry.json + docs/copy-inventory.md (task 1003543) — the copy
|
|
20
|
+
// registry's own pair — regenerate clean from the merged tree, same as
|
|
21
|
+
// repo-map.md, ignoring the conflicted sides.
|
|
22
|
+
// 9. END-TO-END for docs/copy-registry.json: two branches that each regenerate
|
|
23
|
+
// it differently merge without conflict markers via the real .gitattributes +
|
|
24
|
+
// git-config wiring, and the result equals a fresh regeneration.
|
|
17
25
|
//
|
|
18
26
|
// Run: node tests/git_merge_regen.mjs
|
|
19
27
|
|
|
@@ -30,6 +38,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
30
38
|
const repo = path.resolve(here, '..');
|
|
31
39
|
const regen = require('../scripts/gds/git-merge-regen.js');
|
|
32
40
|
const G = require('../scripts/gds/gen-repo-map.js');
|
|
41
|
+
const CI = require('../scripts/gds/copy-inventory.js');
|
|
33
42
|
|
|
34
43
|
const MARKERS = /^(<{7}|={7}|>{7})/m;
|
|
35
44
|
|
|
@@ -148,4 +157,82 @@ function withBlockBody(src, body) {
|
|
|
148
157
|
}
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
// ── 7) .gitattributes' merge=otb-regen lines and REGENERABLE agree (task 1003543) ─
|
|
161
|
+
// The whole scheme is two hand-maintained lists naming the same files (one in
|
|
162
|
+
// .gitattributes, one in REGENERABLE) — nothing checked that they still matched, so
|
|
163
|
+
// docs/copy-inventory.md + docs/copy-registry.json could be (and were) added to
|
|
164
|
+
// neither and merge like ordinary text. This pins the two lists as one set.
|
|
165
|
+
{
|
|
166
|
+
const attrsText = fs.readFileSync(path.join(repo, '.gitattributes'), 'utf8');
|
|
167
|
+
const otbRegenPaths = [...attrsText.matchAll(/^(\S+)\s+merge=otb-regen\s*$/gm)].map((m) => m[1]);
|
|
168
|
+
assert.ok(otbRegenPaths.length > 0, 'expected at least one merge=otb-regen line in .gitattributes');
|
|
169
|
+
for (const p of otbRegenPaths) {
|
|
170
|
+
assert.ok(regen.REGENERABLE.has(p), `.gitattributes declares merge=otb-regen for ${p} but REGENERABLE (git-merge-regen.js) does not know it`);
|
|
171
|
+
}
|
|
172
|
+
for (const p of regen.REGENERABLE) {
|
|
173
|
+
assert.ok(otbRegenPaths.includes(p), `REGENERABLE (git-merge-regen.js) knows ${p} but .gitattributes has no merge=otb-regen line for it`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 8) docs/copy-registry.json + docs/copy-inventory.md regenerate clean ────────
|
|
178
|
+
// (task 1003543 — the copy registry's own pair, from copy-inventory.js. Both
|
|
179
|
+
// artifacts of one scan, fully generated — like docs/repo-map.md, all three
|
|
180
|
+
// conflicted sides are ignored and the merged tree is rebuilt from scratch.)
|
|
181
|
+
{
|
|
182
|
+
const freshReg = CI.buildRegistry();
|
|
183
|
+
const freshJson = `${JSON.stringify(freshReg, null, 2)}\n`;
|
|
184
|
+
const freshMd = CI.renderReport(freshReg);
|
|
185
|
+
|
|
186
|
+
const rJson = regen.regenerateResolved('docs/copy-registry.json', {
|
|
187
|
+
base: '{}\n', ours: '<<<<<<< ours\n{"a":1}\n=======\n{"b":2}\n>>>>>>> theirs\n', theirs: 'nonsense\n',
|
|
188
|
+
});
|
|
189
|
+
assert.equal(rJson.ok, true, 'copy-registry.json heal must succeed');
|
|
190
|
+
assert.ok(!MARKERS.test(rJson.content), 'copy-registry.json heal must leave no conflict markers');
|
|
191
|
+
assert.equal(rJson.content, freshJson, 'copy-registry.json heal must equal a fresh regeneration');
|
|
192
|
+
|
|
193
|
+
const rMd = regen.regenerateResolved('docs/copy-inventory.md', {
|
|
194
|
+
base: '# base\n', ours: '<<<<<<< ours\n# a\n=======\n# b\n>>>>>>> theirs\n', theirs: 'nonsense\n',
|
|
195
|
+
});
|
|
196
|
+
assert.equal(rMd.ok, true, 'copy-inventory.md heal must succeed');
|
|
197
|
+
assert.ok(!MARKERS.test(rMd.content), 'copy-inventory.md heal must leave no conflict markers');
|
|
198
|
+
assert.equal(rMd.content, freshMd, 'copy-inventory.md heal must equal a fresh regeneration');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 9) END-TO-END: two branches that both regenerate docs/copy-registry.json ────
|
|
202
|
+
// differently merge clean via the real .gitattributes + git-config wiring — the
|
|
203
|
+
// exact PR 526/531 shape from task 1003543 (two hall PRs both ran copy-inventory.js
|
|
204
|
+
// concurrently and conflicted as ordinary JSON/markdown). Mirrors test 6 above.
|
|
205
|
+
{
|
|
206
|
+
const driver = path.join(repo, 'scripts/gds/git-merge-regen.js');
|
|
207
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-gitmerge-copyreg-'));
|
|
208
|
+
const g = (...a) => {
|
|
209
|
+
const r = spawnSync('git', a, { cwd: tmp, encoding: 'utf8' });
|
|
210
|
+
return { status: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
|
|
211
|
+
};
|
|
212
|
+
const writeReg = (s) => fs.writeFileSync(path.join(tmp, 'docs', 'copy-registry.json'), s);
|
|
213
|
+
try {
|
|
214
|
+
g('init', '-q');
|
|
215
|
+
g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
|
|
216
|
+
g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
|
|
217
|
+
fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/copy-registry.json merge=otb-regen\n');
|
|
218
|
+
fs.mkdirSync(path.join(tmp, 'docs'));
|
|
219
|
+
writeReg('{"schema":0}\n'); g('add', '-A'); g('commit', '-qm', 'base');
|
|
220
|
+
g('checkout', '-qb', 'feature');
|
|
221
|
+
writeReg('{"schema":0,"feature":true}\n'); g('add', '-A'); g('commit', '-qm', 'feat');
|
|
222
|
+
g('checkout', '-q', 'master');
|
|
223
|
+
if (g('rev-parse', '--verify', 'master').status !== 0) g('checkout', '-q', 'main');
|
|
224
|
+
writeReg('{"schema":0,"master":true}\n'); g('add', '-A'); g('commit', '-qm', 'mast');
|
|
225
|
+
const merge = g('merge', '--no-edit', 'feature');
|
|
226
|
+
assert.equal(merge.status, 0, `real git merge must auto-resolve via the driver; got ${merge.status} :: ${merge.out}`);
|
|
227
|
+
const unmerged = g('diff', '--name-only', '--diff-filter=U').out.trim();
|
|
228
|
+
assert.equal(unmerged, '', `no file may be left unmerged; got: ${unmerged}`);
|
|
229
|
+
const healed = fs.readFileSync(path.join(tmp, 'docs', 'copy-registry.json'), 'utf8');
|
|
230
|
+
assert.ok(!MARKERS.test(healed), 'merged copy-registry.json must have no conflict markers');
|
|
231
|
+
assert.equal(healed, `${JSON.stringify(CI.buildRegistry(), null, 2)}\n`,
|
|
232
|
+
'merged copy-registry.json must equal a fresh regeneration from the (real) merged tree');
|
|
233
|
+
} finally {
|
|
234
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
151
238
|
console.log('git_merge_regen: all assertions passed');
|
|
@@ -1,54 +1,85 @@
|
|
|
1
1
|
// tests/idea_task_lineage.mjs
|
|
2
2
|
//
|
|
3
3
|
// task 1002997 (BV1.R173, goal 1000067) — idea → task lineage.
|
|
4
|
+
// task 1003515 (goal 1000071, ADR 0236) — the key is the PAIR, so a second idea
|
|
5
|
+
// promoted into one task no longer ERASES the first idea's attribution.
|
|
4
6
|
//
|
|
5
7
|
// The done-when as assertions: an idea can be traced to every task descended
|
|
6
|
-
// from it; a
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// task
|
|
8
|
+
// from it; a task can be traced back to every idea it descends from; a promote
|
|
9
|
+
// sets the link (additive to the single-FK promoted_to_task_id, which is
|
|
10
|
+
// unchanged); counting "tasks shipped under idea N" is one query and credits a
|
|
11
|
+
// SHARED task to each of its source ideas; the reverse lookup names ONE source
|
|
12
|
+
// idea deterministically — the earliest — for the ship hook that pays; and a
|
|
13
|
+
// lineage write that fails is reported rather than swallowed in silence.
|
|
10
14
|
//
|
|
11
15
|
// DB-free the tests/idea_grades.mjs way: pool.query patched on the doorway
|
|
12
|
-
// before inbox.js loads. The stub models ideas_task_lineage as an in-memory
|
|
13
|
-
//
|
|
14
|
-
// behave like Postgres.
|
|
16
|
+
// before inbox.js loads. The stub models ideas_task_lineage as an in-memory map
|
|
17
|
+
// keyed by (task_id, idea_id) — the ideas_006 primary key — so the ON CONFLICT
|
|
18
|
+
// upsert, the ORDER BY created_at tie-break and the joins behave like Postgres.
|
|
15
19
|
//
|
|
16
20
|
// Run: node tests/idea_task_lineage.mjs
|
|
17
21
|
|
|
18
22
|
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
19
24
|
import { createRequire } from 'node:module';
|
|
20
25
|
|
|
21
26
|
const require = createRequire(import.meta.url);
|
|
22
27
|
const api = require('../src/module-api.js');
|
|
23
28
|
|
|
24
29
|
// in-memory stand-ins for the two tables the lineage queries touch
|
|
25
|
-
let lineage = new Map(); // task_id -> { idea_id, origin }
|
|
30
|
+
let lineage = new Map(); // "task_id|idea_id" -> { task_id, idea_id, origin, created_at }
|
|
26
31
|
let tasks = new Map(); // task_id -> { status }
|
|
27
32
|
let ideaInbox = new Map(); // idea_id -> { captured_by }
|
|
28
33
|
let lastUpdate = null;
|
|
34
|
+
let logged = []; // what api.logger('ideas').error was told
|
|
35
|
+
let clock = 0; // stands in for created_at DEFAULT now()
|
|
36
|
+
let linkShouldThrow = null;
|
|
37
|
+
|
|
38
|
+
const key = (taskId, ideaId) => `${taskId}|${ideaId}`;
|
|
39
|
+
const linksForTask = (taskId) => [...lineage.values()]
|
|
40
|
+
.filter((r) => r.task_id === String(taskId))
|
|
41
|
+
.sort((a, b) => (a.created_at - b.created_at) || (Number(a.idea_id) - Number(b.idea_id)));
|
|
29
42
|
|
|
30
43
|
api.pool.query = async (sql, params = []) => {
|
|
31
|
-
// linkIdeaTask
|
|
44
|
+
// linkIdeaTask / the route INSERT: ON CONFLICT (task_id, idea_id) DO UPDATE
|
|
32
45
|
if (/INSERT INTO ideas_task_lineage/i.test(sql)) {
|
|
46
|
+
if (linkShouldThrow) throw new Error(linkShouldThrow);
|
|
47
|
+
assert.match(sql, /ON CONFLICT \(task_id, idea_id\)/,
|
|
48
|
+
'the conflict target is the PAIR — keying on task_id alone is what erased attribution');
|
|
33
49
|
const [taskId, ideaId, origin] = params;
|
|
34
|
-
|
|
50
|
+
const k = key(taskId, ideaId);
|
|
51
|
+
const existing = lineage.get(k);
|
|
52
|
+
// DO UPDATE SET origin — created_at is NOT rewritten, so "which link came
|
|
53
|
+
// first" survives a re-link (the tie-break every single-row read uses).
|
|
54
|
+
lineage.set(k, {
|
|
55
|
+
task_id: String(taskId),
|
|
56
|
+
idea_id: String(ideaId),
|
|
57
|
+
origin: origin ?? 'promote',
|
|
58
|
+
created_at: existing ? existing.created_at : clock++,
|
|
59
|
+
});
|
|
35
60
|
return { rowCount: 1, rows: [] };
|
|
36
61
|
}
|
|
37
62
|
// countShippedTasksForIdea: JOIN tasks, WHERE idea_id=$1 AND status='shipped'
|
|
38
63
|
if (/COUNT\(\*\)::int AS n/i.test(sql)) {
|
|
39
64
|
const ideaId = String(params[0]);
|
|
40
65
|
let n = 0;
|
|
41
|
-
for (const
|
|
42
|
-
if (row.idea_id === ideaId && (tasks.get(
|
|
66
|
+
for (const row of lineage.values()) {
|
|
67
|
+
if (row.idea_id === ideaId && (tasks.get(row.task_id) || {}).status === 'shipped') n++;
|
|
43
68
|
}
|
|
44
69
|
return { rows: [{ n }] };
|
|
45
70
|
}
|
|
46
|
-
// sourceIdeaForTask: JOIN idea_inbox, WHERE task_id=$1
|
|
71
|
+
// sourceIdeaForTask: JOIN idea_inbox, WHERE task_id=$1, earliest link only
|
|
47
72
|
if (/l\.idea_id, l\.origin, i\.captured_by/i.test(sql)) {
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
assert.match(sql, /ORDER BY l\.created_at ASC, l\.idea_id ASC/,
|
|
74
|
+
'the one row it returns is CHOSEN in SQL, not by the planner');
|
|
75
|
+
assert.match(sql, /LIMIT 1/);
|
|
76
|
+
const row = linksForTask(params[0])[0];
|
|
50
77
|
if (!row) return { rows: [] };
|
|
51
|
-
return { rows: [{
|
|
78
|
+
return { rows: [{
|
|
79
|
+
idea_id: row.idea_id,
|
|
80
|
+
origin: row.origin,
|
|
81
|
+
captured_by: (ideaInbox.get(row.idea_id) || {}).captured_by ?? null,
|
|
82
|
+
}] };
|
|
52
83
|
}
|
|
53
84
|
// updateIdea's CTE UPDATE ... RETURNING — record it, return a plausible row
|
|
54
85
|
if (/UPDATE idea_inbox/i.test(sql)) {
|
|
@@ -59,12 +90,25 @@ api.pool.query = async (sql, params = []) => {
|
|
|
59
90
|
return { rows: [{ id: params[0], status: 'open', grade: 'quick' }] };
|
|
60
91
|
};
|
|
61
92
|
|
|
93
|
+
// The module logs through the published seam (api.logger), never console.* —
|
|
94
|
+
// capture it so "the swallow now reports" is an assertion, not a promise.
|
|
95
|
+
api.logger = () => ({
|
|
96
|
+
error: (msg) => { logged.push(String(msg)); },
|
|
97
|
+
warn: () => {}, info: () => {}, debug: () => {},
|
|
98
|
+
});
|
|
99
|
+
|
|
62
100
|
const inbox = require('../modules/ideas/inbox.js');
|
|
63
101
|
|
|
102
|
+
const SRC = (p) => readFileSync(new URL(p, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
|
103
|
+
const MIGRATION = SRC('../modules/ideas/migrations/ideas_006_lineage_many_ideas_one_task.sql');
|
|
104
|
+
const INBOX_SRC = SRC('../modules/ideas/inbox.js');
|
|
105
|
+
const CREDITS_SRC = SRC('../modules/economy/credits.js');
|
|
106
|
+
|
|
64
107
|
let passed = 0;
|
|
65
108
|
let failed = 0;
|
|
66
109
|
async function test(name, fn) {
|
|
67
|
-
lineage = new Map(); tasks = new Map(); ideaInbox = new Map();
|
|
110
|
+
lineage = new Map(); tasks = new Map(); ideaInbox = new Map();
|
|
111
|
+
lastUpdate = null; logged = []; clock = 0; linkShouldThrow = null;
|
|
68
112
|
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
69
113
|
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
70
114
|
}
|
|
@@ -74,17 +118,25 @@ await test('linkIdeaTask records one idea → many tasks', async () => {
|
|
|
74
118
|
await inbox.linkIdeaTask(5, 101, 'promote');
|
|
75
119
|
await inbox.linkIdeaTask(5, 102, 'develop');
|
|
76
120
|
assert.equal(lineage.size, 3);
|
|
77
|
-
for (const
|
|
121
|
+
for (const row of lineage.values()) assert.equal(row.idea_id, '5', 'all three trace to idea 5');
|
|
78
122
|
});
|
|
79
123
|
|
|
80
|
-
|
|
124
|
+
// THE REGRESSION THIS TASK EXISTS FOR (task 1003515). Before ideas_006 the
|
|
125
|
+
// second promote overwrote the first and idea 5 lost its claim silently.
|
|
126
|
+
await test('two ideas promoted into ONE task keep BOTH links — neither is erased', async () => {
|
|
81
127
|
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
82
|
-
await inbox.linkIdeaTask(
|
|
83
|
-
assert.equal(lineage.size,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
128
|
+
await inbox.linkIdeaTask(9, 100, 'promote');
|
|
129
|
+
assert.equal(lineage.size, 2, 'a task descends from both ideas, not from whichever came last');
|
|
130
|
+
assert.deepEqual(linksForTask(100).map((r) => r.idea_id), ['5', '9'],
|
|
131
|
+
'both ideas are still attached to task 100');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await test('re-linking the SAME (idea, task) is idempotent and refreshes only origin', async () => {
|
|
135
|
+
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
136
|
+
await inbox.linkIdeaTask(5, 100, 'route');
|
|
137
|
+
assert.equal(lineage.size, 1, 'the pair is unique — no second row');
|
|
138
|
+
assert.equal(lineage.get('100|5').origin, 'route', 'the newer caller describes the pair');
|
|
139
|
+
assert.equal(lineage.get('100|5').created_at, 0, 'created_at is NOT rewritten, so "first link" survives');
|
|
88
140
|
});
|
|
89
141
|
|
|
90
142
|
await test('countShippedTasksForIdea counts only shipped descendants', async () => {
|
|
@@ -98,6 +150,17 @@ await test('countShippedTasksForIdea counts only shipped descendants', async ()
|
|
|
98
150
|
assert.equal(await inbox.countShippedTasksForIdea(404), 0, 'an idea with no tasks counts 0');
|
|
99
151
|
});
|
|
100
152
|
|
|
153
|
+
// The credit consequence of the composite key: a task subsumed from N ideas
|
|
154
|
+
// counts ONCE toward EACH idea's shipped-task credit, and never twice for one.
|
|
155
|
+
await test('a SHARED task counts once for each of its source ideas', async () => {
|
|
156
|
+
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
157
|
+
await inbox.linkIdeaTask(9, 100, 'promote');
|
|
158
|
+
await inbox.linkIdeaTask(5, 100, 'promote'); // a re-link must not inflate the count
|
|
159
|
+
tasks.set('100', { status: 'shipped' });
|
|
160
|
+
assert.equal(await inbox.countShippedTasksForIdea(5), 1, 'idea 5 is credited for the shared task');
|
|
161
|
+
assert.equal(await inbox.countShippedTasksForIdea(9), 1, 'and so is idea 9 — it used to be credited 0');
|
|
162
|
+
});
|
|
163
|
+
|
|
101
164
|
await test('sourceIdeaForTask names the idea + author, or null', async () => {
|
|
102
165
|
ideaInbox.set('5', { captured_by: '42' });
|
|
103
166
|
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
@@ -108,13 +171,33 @@ await test('sourceIdeaForTask names the idea + author, or null', async () => {
|
|
|
108
171
|
assert.equal(await inbox.sourceIdeaForTask(999), null, 'a task with no source idea is null');
|
|
109
172
|
});
|
|
110
173
|
|
|
174
|
+
await test('sourceIdeaForTask returns the EARLIEST link when a task has several', async () => {
|
|
175
|
+
ideaInbox.set('5', { captured_by: '42' });
|
|
176
|
+
ideaInbox.set('9', { captured_by: '77' });
|
|
177
|
+
await inbox.linkIdeaTask(5, 100, 'promote'); // first
|
|
178
|
+
await inbox.linkIdeaTask(9, 100, 'promote'); // second
|
|
179
|
+
const src = await inbox.sourceIdeaForTask(100);
|
|
180
|
+
assert.equal(src.idea_id, '5', 'the idea the work was opened for, not whichever row came back first');
|
|
181
|
+
assert.equal(src.captured_by, '42');
|
|
182
|
+
// and re-linking the earlier pair does not move it to the back of the queue
|
|
183
|
+
await inbox.linkIdeaTask(5, 100, 'promote');
|
|
184
|
+
assert.equal((await inbox.sourceIdeaForTask(100)).idea_id, '5', 'a re-link cannot rewrite who was first');
|
|
185
|
+
});
|
|
186
|
+
|
|
111
187
|
await test('a promote through updateIdea sets the lineage AND the legacy single FK', async () => {
|
|
112
188
|
await inbox.updateIdea(5, { status: 'promoted', promoted_to_task_id: 100 });
|
|
113
189
|
// legacy single FK still written (the UPDATE carried it)
|
|
114
190
|
assert.ok(lastUpdate && lastUpdate.params.includes(100), 'promoted_to_task_id still set on idea_inbox');
|
|
115
191
|
// and the many-tasks lineage recorded it too
|
|
116
|
-
assert.equal(lineage.get('100')?.idea_id, '5', 'lineage row added on promote');
|
|
117
|
-
assert.equal(lineage.get('100')?.origin, 'promote');
|
|
192
|
+
assert.equal(lineage.get('100|5')?.idea_id, '5', 'lineage row added on promote');
|
|
193
|
+
assert.equal(lineage.get('100|5')?.origin, 'promote');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await test('a second promote into the same task through updateIdea keeps the first idea', async () => {
|
|
197
|
+
await inbox.updateIdea(5, { status: 'promoted', promoted_to_task_id: 100 });
|
|
198
|
+
await inbox.updateIdea(9, { status: 'promoted', promoted_to_task_id: 100 });
|
|
199
|
+
assert.equal(lineage.size, 2, 'the exact prod incident (ideas 1000811 + 1000812 → task 1003150)');
|
|
200
|
+
assert.ok(lineage.has('100|5') && lineage.has('100|9'));
|
|
118
201
|
});
|
|
119
202
|
|
|
120
203
|
await test('a non-promote update does not touch lineage', async () => {
|
|
@@ -122,5 +205,58 @@ await test('a non-promote update does not touch lineage', async () => {
|
|
|
122
205
|
assert.equal(lineage.size, 0, 'discard writes no lineage row');
|
|
123
206
|
});
|
|
124
207
|
|
|
208
|
+
// The swallow stays (the triage verdict already committed) but it must SPEAK:
|
|
209
|
+
// a lineage row that never landed is an ideator who will never be paid.
|
|
210
|
+
await test('a failed lineage write is REPORTED, and never fails the promote', async () => {
|
|
211
|
+
linkShouldThrow = 'connection reset';
|
|
212
|
+
const row = await inbox.updateIdea(5, { status: 'promoted', promoted_to_task_id: 100 });
|
|
213
|
+
assert.ok(row, 'the promote still returns — lineage is a ledger convenience, not a gate');
|
|
214
|
+
assert.equal(logged.length, 1, 'exactly one report, through api.logger');
|
|
215
|
+
assert.match(logged[0], /LINEAGE GAP/);
|
|
216
|
+
assert.match(logged[0], /idea 5/);
|
|
217
|
+
assert.match(logged[0], /task 100/);
|
|
218
|
+
assert.match(logged[0], /connection reset/, 'the underlying cause rides along');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---- the schema half: the migration is the thing that makes all of the above true ----
|
|
222
|
+
|
|
223
|
+
await test('ideas_006 replaces the task_id primary key with the composite pair', () => {
|
|
224
|
+
assert.match(MIGRATION, /ALTER TABLE ideas_task_lineage DROP CONSTRAINT IF EXISTS ideas_task_lineage_pkey/,
|
|
225
|
+
'the dropped name must be the auto-generated one from ideas_002, or the DROP silently no-ops');
|
|
226
|
+
assert.match(MIGRATION, /ADD CONSTRAINT ideas_task_lineage_pkey\s*\n?\s*PRIMARY KEY \(task_id, idea_id\)/,
|
|
227
|
+
're-added under the SAME name — the additive-widening shape fitness.js exempts');
|
|
228
|
+
assert.match(MIGRATION, /RAISE EXCEPTION/, 'a migration that cannot prove its own effect must not report success');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('ideas_006 backfills every link the old upsert could have erased', () => {
|
|
232
|
+
assert.match(MIGRATION, /INSERT INTO ideas_task_lineage \(task_id, idea_id, origin\)/);
|
|
233
|
+
assert.match(MIGRATION, /FROM idea_inbox i/,
|
|
234
|
+
'promoted_to_task_id is the weaker pointer that survived each overwrite');
|
|
235
|
+
assert.match(MIGRATION, /promoted_to_task_id IS NOT NULL/);
|
|
236
|
+
assert.match(MIGRATION, /ON CONFLICT \(task_id, idea_id\) DO NOTHING/,
|
|
237
|
+
'a link that survived keeps its own origin and created_at; re-running writes nothing');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await test('no write path keys on task_id alone any more', () => {
|
|
241
|
+
// comments stripped: linkIdeaTask's header QUOTES the old, erasing statement
|
|
242
|
+
// as the reason the key changed, and that citation is not a call site.
|
|
243
|
+
const code = INBOX_SRC.split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n');
|
|
244
|
+
assert.ok(!/ON CONFLICT \(task_id\)/.test(code),
|
|
245
|
+
'both INSERT sites (linkIdeaTask and the route transaction) must target the pair');
|
|
246
|
+
assert.equal((INBOX_SRC.match(/ON CONFLICT \(task_id, idea_id\)/g) || []).length, 2,
|
|
247
|
+
'linkIdeaTask + routeIdeaToGoal');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await test('every single-row read of the lineage picks the earliest link', () => {
|
|
251
|
+
// the nod queue: list and count share ONE predicate constant, so the pill can
|
|
252
|
+
// never disagree with the rows a Metic+ is shown
|
|
253
|
+
assert.match(INBOX_SRC, /const ROUTE_LINK_IS_EARLIEST = `NOT EXISTS/);
|
|
254
|
+
assert.equal((INBOX_SRC.match(/\$\{ROUTE_LINK_IS_EARLIEST\}/g) || []).length, 2,
|
|
255
|
+
'listRoutedAwaitingNod + countRoutedAwaitingNod');
|
|
256
|
+
// and the money: the ship hook's credit read must not let the planner choose a payee
|
|
257
|
+
assert.match(CREDITS_SRC, /FROM ideas_task_lineage l[\s\S]{0,400}ORDER BY l\.created_at ASC, l\.idea_id ASC\s*\n?\s*LIMIT 1/,
|
|
258
|
+
'awardIdeaTaskShipCredit pays the earliest source idea, matching sourceIdeaForTask');
|
|
259
|
+
});
|
|
260
|
+
|
|
125
261
|
console.log(`\nidea_task_lineage: ${passed} passed, ${failed} failed`);
|
|
126
262
|
if (failed > 0) process.exit(1);
|