@bongos/core 1.19.898 → 1.19.900

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.898",
6
- "core_contract": "1.19.898",
7
- "source_commit": "83c5474eb902fdb151eafe46b8e71103ec8fddc3",
5
+ "core_version": "1.19.900",
6
+ "core_contract": "1.19.900",
7
+ "source_commit": "b8386b789a905d1c25afd561989ef249965fd27f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-20T03:57:02.228Z",
9
+ "built_at": "2026-09-20T05:47:20.635Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 501,
12
+ "docs_redacted": 502,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2277,
14
+ "functional_verbatim": 2281,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2804,
20
- "tree_sha256": "81209f20e18badef8c7699d57df22d9f10d76dcb7cdb49f1f1e3bdcffdc1a8ce",
19
+ "file_count": 2809,
20
+ "tree_sha256": "4b02605822334dafaf22a01bd8c5d34a16066b4f1afa4b9ee147b59d385f3c3b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2054,10 +2054,15 @@
2054
2054
  "mode": "0000644",
2055
2055
  "sha256": "be07bd84a0167bbadfd3569286a5a69da403336739a88fccb5a5b6231e225af6"
2056
2056
  },
2057
+ {
2058
+ "path": "docs/adr/0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md",
2059
+ "mode": "0000644",
2060
+ "sha256": "9b301cd17f96c3f0b49010f046b994e112a5e1f971797964b836bc578578dd70"
2061
+ },
2057
2062
  {
2058
2063
  "path": "docs/adr/README.md",
2059
2064
  "mode": "0000644",
2060
- "sha256": "85666c7a834c711519264da5f88e2ff1cc950fc1d9c6038d9d0251e9ba7c9db0"
2065
+ "sha256": "85bb537649c78b08e36e2de94dfde8449760ca163577a7fac63603da4074dd37"
2061
2066
  },
2062
2067
  {
2063
2068
  "path": "docs/api-reference.md",
@@ -2627,7 +2632,7 @@
2627
2632
  {
2628
2633
  "path": "docs/module-api-changelog.md",
2629
2634
  "mode": "0000644",
2630
- "sha256": "3549eff34b79f43215969e5f04c584ba107742c4c8404314105a1da6c356cfa6"
2635
+ "sha256": "180322d6ddd7b22d93aa7fada4e8124cc9949afc06b97995a8394c1d472a31be"
2631
2636
  },
2632
2637
  {
2633
2638
  "path": "docs/modules-contract.md",
@@ -5304,6 +5309,11 @@
5304
5309
  "mode": "0000644",
5305
5310
  "sha256": "07e5666362c91f92d34388b4f6f4507cc5387866d317970478e6cae417c19975"
5306
5311
  },
5312
+ {
5313
+ "path": "modules/ideas/descent.js",
5314
+ "mode": "0000644",
5315
+ "sha256": "b735a1a1c6bf892ca631b1ae8d1bac7643ea9adf39d243ef8a6c27d83451dbca"
5316
+ },
5307
5317
  {
5308
5318
  "path": "modules/ideas/homeless-home.js",
5309
5319
  "mode": "0000644",
@@ -5344,10 +5354,20 @@
5344
5354
  "mode": "0000644",
5345
5355
  "sha256": "51f790ec7d5c0ad9941ed16174e260db0de18be4d4cb18af0d704a1190a1beee"
5346
5356
  },
5357
+ {
5358
+ "path": "modules/ideas/migrations/ideas_007_task_descent_edges.sql",
5359
+ "mode": "0000644",
5360
+ "sha256": "76f2af78af1ebb01e97ce182142c9de6b044d617cec102f9e7174ec513157c98"
5361
+ },
5347
5362
  {
5348
5363
  "path": "modules/ideas/module.json",
5349
5364
  "mode": "0000644",
5350
- "sha256": "c9a5e2bab38b9e45e4934f578f4ebbf0966f9e971318548d5c445d2f6a3de3a7"
5365
+ "sha256": "d78cc041fcd92485e38684a216a846e163ea443a8afea9f304aca3a2ac086d4d"
5366
+ },
5367
+ {
5368
+ "path": "modules/ideas/pollers/descent-writer.js",
5369
+ "mode": "0000644",
5370
+ "sha256": "80faf8d20df4f051000460a0453472bd1a177ca4e9e9a31e8c5b7f3065575995"
5351
5371
  },
5352
5372
  {
5353
5373
  "path": "modules/ideas/routes/blockers.js",
@@ -7737,12 +7757,12 @@
7737
7757
  {
7738
7758
  "path": "package-lock.json",
7739
7759
  "mode": "0000644",
7740
- "sha256": "e7c6f92f244fabe2e8a57f1a74f196dab668c42884b5132aa41f10317b7b6640"
7760
+ "sha256": "a64d0eb048b0bbaebc11226da0ba59ecf3724630719a4c1e6f598d4e93f7fb64"
7741
7761
  },
7742
7762
  {
7743
7763
  "path": "package.json",
7744
7764
  "mode": "0000644",
7745
- "sha256": "f9d896d331e9ecea3db23ec5d58bcec8488e3dbc1c924a4a94d27dc10b1c3798"
7765
+ "sha256": "0e217566767957cb7ec7607c94c10c1e99bf7b5aba1605e886058ce7929b1c2f"
7746
7766
  },
7747
7767
  {
7748
7768
  "path": "public-docs/index.html",
@@ -9752,7 +9772,7 @@
9752
9772
  {
9753
9773
  "path": "src/module-api.js",
9754
9774
  "mode": "0000644",
9755
- "sha256": "cfa865647882f01bac0dbf3f653f66544a05e283895b36a8d5906b32891e2302"
9775
+ "sha256": "633c325acecc72b19dc2c6758f4bcd8e5712bd4251681bbd25b1aff4e9e907bd"
9756
9776
  },
9757
9777
  {
9758
9778
  "path": "src/module-loader/catalog.js",
@@ -11864,6 +11884,11 @@
11864
11884
  "mode": "0000644",
11865
11885
  "sha256": "8bfc36537ffccae702e74e8763534fa535486a2eb8bbf9ee546a2c7368d1dc6b"
11866
11886
  },
11887
+ {
11888
+ "path": "tests/idea_task_descent.mjs",
11889
+ "mode": "0000644",
11890
+ "sha256": "3adfccf9381985ad2fbf4e8911ef6cd0fcb66714a13c77de5a8318d185000d66"
11891
+ },
11867
11892
  {
11868
11893
  "path": "tests/idea_task_lineage.mjs",
11869
11894
  "mode": "0000644",
@@ -0,0 +1,69 @@
1
+ # ADR 0313 — A removal is derived from the server's own merge diff and confirmed by a person
2
+
3
+ **Status:** accepted · **Date:** 2026-09-19 · **Task:** [task 1004081](https://cloudbongos.com/builders#/task/1004081) (goal 1000095 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) (deferred exactly this) · [ADR 0312](0312-provenance-is-a-second-ideas-owned-edge-table.md) (the descent this dims) · [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) (the runner a rule is appended to) · [ADR 0055](0055-server-mediated-branch-publish.md) (why the server holds the authoritative diff) · [ADR 0049](0049-split-parallel-safety-contract.md) (`touches[]` is advisory)
6
+
7
+ ## Context
8
+
9
+ Brightness is the live share of a descent (spec decision 20), so something has to be able to say that shipped work was later removed. **Nothing records that today.** `shipped` is terminal — `modules/lifecycle/rot.js` says so in as many words — there is no reverted status, and `tasks.merged_into_task_id` is CHECK-constrained to `abandoned` rows (`core_232`), so it can only ever describe work cut *before* it landed.
10
+
11
+ The owner settled the shape and not the detail (spec decision 13): **the system proposes, a person confirms.** A derived-only signal dims the sky for refactors, renames and file moves; a declared-only one rots because nobody remembers. This ADR settles the detail.
12
+
13
+ ## What the spike found
14
+
15
+ **1. `cascade.js` no longer ships with an empty rule set.** The task description says it does; that was true when the task was written and is not true now. `CASCADES` carries a production rule (`artist-review-on-ship`), and ADR 0242 fixed the extension contract: *append an entry, never touch the runner.* This strengthens the decision rather than weakening it — there is a working precedent to copy, including the anti-recursion guard (`source !== CASCADE_SOURCE`) that a removal rule needs for the same reason.
16
+
17
+ **2. `netNegativeBonusAmount` cannot be the trigger.** The task names it as the nearest existing signal, and it is — in spirit. Structurally it is unusable:
18
+
19
+ - **It has no target.** `removed > added` over a whole ship says *that* deletion happened, never *what* was deleted or whose work it was. Brightness needs the target; the boolean carries none.
20
+ - **It is client-supplied.** It reads `signals.diff_stats`, which arrives in the grade request body. `grader-score.js` documents the exact forgery it already defends against — a `diff_stats` claiming a net-negative that the submitted diff does not support.
21
+ - **Brightness affects money.** Size is a payout sum and brightness sits beside it. Deriving either from a forgeable, target-less counter would be wrong even if it were convenient.
22
+
23
+ **3. There is no durable record of what any ship touched.** `committed_files` exists at grade time for the protected-path check and `diff_stats` for the bonus, both client-supplied, and **neither is persisted** — no table stores them. `touches[]` is advisory by ADR 0049. So a removal cannot be reconstructed from history at all; it can only be derived at the moment a ship lands.
24
+
25
+ ## Decision
26
+
27
+ ### The signal's source is the server's own merge diff
28
+
29
+ Landing is server-mediated (ADR 0055): the server holds the repository and performs the merge, so it can compute the real diff rather than be told about one. The removal signal is derived **there**, from the merge the server just made, and never from a client-supplied signal. This is the only reason the derivation can be trusted with something that moves credits.
30
+
31
+ ### Attribution runs through `claims.published_head_sha`, not commit text
32
+
33
+ To say *whose* work was deleted, the deleted hunks are blamed back to the commit that introduced them, and that commit is resolved to a task through **`claims.published_head_sha`** (`migrations/171`) — the server-pushed tip, already persisted per claim.
34
+
35
+ The obvious alternative, parsing `(task NNNN)` out of commit messages, was rejected: of the last 15 commits on `main`, **7** carry a task id. Merge commits and release carriers do not, and a convention that holds most of the time is not a basis for a payout. The sha mapping is written by the server at publish time and cannot be forgotten by an author.
36
+
37
+ `published_head_sha` is nullable, so attribution can come back empty — a ship that predates `migrations/171`, or one that rode in on another branch. **An unattributable deletion files no proposal.** Silence is the correct output: a wrong removal proposal costs a person's attention and dims a real star.
38
+
39
+ ### What counts as removed
40
+
41
+ Only **work that shipped and was later deleted**. Three exclusions, each of which the Sky artboard already draws:
42
+
43
+ - A **rename or move is not a removal.** Git's own rename detection resolves it; the work still stands.
44
+ - A **rewrite in place is not a removal.** Lines changing is how code lives.
45
+ - A **cold body is not a remnant.** An idea declined at triage never ignited — nothing was built, so nothing was lost. Removal applies only below a task that actually shipped.
46
+
47
+ ### The proposal, the confirmation, and the clock
48
+
49
+ - **Propose:** a new entry appended to `CASCADES` on the ship trigger. It files a *question*, never a verdict, and carries the anti-recursion guard the existing rule uses.
50
+ - **Confirm:** Metic+, `task.promote`, on the existing awaiting-nod surface (spec decision 15). No new permission and no new page.
51
+ - **Rot:** an unanswered proposal enters the existing rot feed on the normal clock (spec decision 14), so the queue cannot silently grow.
52
+
53
+ Only a **confirmed** proposal dims a star. A pending one changes nothing the ideator sees.
54
+
55
+ ## Consequences
56
+
57
+ **Brightness can only fall forward from here.** The derivation runs at merge time and nothing durable records the past, so removals before this ships are not recoverable. That is consistent with spec decision 17 — pre-existing ideas get lineage backfilled but not payouts — and it means a historical marker is required on those sizes, or the number quietly lies about what it means.
58
+
59
+ **The server gains a git-reading step on the merge path.** It is the one place with both the repository and the authority, but it is also the path a stranded ship already fails on. The rule must be non-blocking: a derivation that errors files nothing and never fails the merge. Landing work is more important than dimming a star on time.
60
+
61
+ **Two signals now exist over the same diff, and they must not be confused.** `netNegativeBonusAmount` rewards a builder for simplifying, and this one records that an origin's descendant work is gone. The same net-negative ship can legitimately trigger the bonus and no removal at all.
62
+
63
+ ## Alternatives rejected
64
+
65
+ - **Derived-only, no confirmation.** The owner's decision, and correct: refactors, renames and moves would dim the sky continuously.
66
+ - **Declared-only** (a human marks work removed). Rots because nobody remembers — the same reason the descent edges are not left to memory in ADR 0312.
67
+ - **`netNegativeBonusAmount` as the trigger.** Target-less, client-supplied, already known-forgeable.
68
+ - **Commit-message parsing for attribution.** Holds for under half of `main`'s recent commits.
69
+ - **A `removed` task status.** `shipped` is terminal by design and much of the ledger leans on that. A removal is a fact about work's *fate*, recorded beside it, not a new terminal state that ship rollup would have to learn.
@@ -403,4 +403,5 @@ This keeps the decision history honest and traceable.
403
403
  | 0309 | [**Module enablement is a database row, above the committed pack and below env** ([task 1002857](https://cloudbongos.com/builders#/task/1002857), goal 1000091). The hall Modules-tab toggle ([ADR 0107](0107-module-upstreaming-contribution-to-core-catalog.md) §9) wrote `config/modules.json` and reloaded the cache, so it looked like it worked — but that file is the committed instance pack and the pull-deploy runs `git reset --hard origin/main`, so the toggle reverted on the next deploy, **silently**. Enablement moves to `module_enablement_overrides` (migration `core_248`), read as one precedence layer: neutral pack → instance pack → **DB override** → env → default. **Above the pack** because the pack is what a deploy restores, so an override below it would be erased by the event it exists to survive; **below env** because `<PREFIX>_MODULE_<KEY>` is the incident knob and must not need a reachable database. **Auto-committing to `main` stays rejected** ([ADR 0016](<redacted>.md)): durability belongs in the DB, not in an HTTP handler that writes to the default branch. Two open design points settled: the **scope key** is the resolved instance pack path (the only axis two instances sharing one database differ on, so one sibling's toggle cannot flip the other's module), and the DB is read into a **snapshot** refreshed at boot and on write rather than inline, because `loadModules()` is synchronous and making it async would ripple into every consumer of a module flag. An unreachable database is a **no-op** — no row means "no opinion", not "off". Does not decide: whether a toggle should also restart the process so remounting is immediate.](0309-durable-module-enablement-db-override.md) | modules / ops |
404
404
  | 0310 | [**A speciality OFFERS skills; the adopter turns them on, one at a time, having been shown what each does** ([task 1003978](https://cloudbongos.com/builders#/task/1003978), goal 1000095). Completes [ADR 0296](0296-a-role-speciality-is-data-over-three-existing-surfaces.md), which defined a speciality as knowledge + skills + an interaction contract and shipped two of the three: `grep -i skill` across the module's schema, domain and routes returns only the header sentences describing a thing that is not there — prose asserting behaviour the code does not have, and the feature's own definition at that. Task 1003978 withheld an implementation until the authority question was answered, and the answer is the strong one: **naming a skill GRANTS NOTHING**. Skills are materialised by `claude-materialize.js` and permission is rank-gated server-side with no caching ([ADR 0016](<redacted>.md)); a speciality offers a menu, and a menu is not an entitlement. **Two layers, because one layer is the blanket the owner ruled against**: the catalog holds what a speciality OFFERS, the already-per-builder `specialities_adoptions` row holds what THIS builder turned on — so two economist ideators share a speciality's knowledge and contract and need not share one enabled skill. **The default is nothing enabled, and that is a position about LEARNING rather than a safe default**: the owner's ruling is that adoption must *walk through what comes with each* because *people need to learn the system*. A settings page of unchecked boxes satisfies the letter and defeats the intent, so the bar is a user test — a person who has finished adopting can say what each skill does. Sharing needs no new mechanism: `owner_kind` / `visibility` / `offered` / `sellable` already ship the private→project→public ladder, and skills ride it. Builds nothing; the three-task order (schema, walkthrough, injection) is filed, and the four documentation sites that claimed a shipped ingredient now say decided-not-built.](0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md) | specialities / methodology |
405
405
  | 0311 | [**An idea is a permanent origin tag, and the payout follows its descent** ([task 1004076](https://cloudbongos.com/builders#/task/1004076), goal 1000095 — BONGOS-V2). An idea today is a CONTAINER for work: it leaves `idea_inbox` when it names a goal and lives on as a task, so the better-aimed the thought the faster it vanishes from whoever had it. Reported as a small confusion — a thought filed from the hall routed into a goal, the confirmation line cleared on the next keystroke, and five minutes later the owner could not find it. The idea becomes an **immutable origin tag** instead, the only thing that can answer where a piece of work came from, and **the ideator payout follows its whole descent** rather than stopping at the earliest lineage link (`awardIdeaTaskShipCredit`), decaying by depth — full at the direct task, halving each hop — so an infinite descent is worth at most twice the direct task. That is what gives the tag teeth: if it does not survive a fork, someone does not get paid. **Size and brightness are two channels, not one number**: size is the sum of those payouts and never falls, brightness is how much of that work is still alive, so a thought whose work the project tore out renders LARGE AND DEAD rather than looking like one that never mattered. **Karma was rejected as the basis** — `karma_log` carries no task key and no idea key, deliberately ([`migrations/040_builder_karma.sql`](../../migrations/040_builder_karma.sql)), so "the karma a thought caused" is not computable; credits already are idea-attributed. **The task hierarchy is NOT widened** to carry the descent — it is capped at one level and refuses cross-version parents because ship rollup and claim safety lean on it, and provenance is a different relation from work decomposition. **Nothing records that shipped work was later removed** (`shipped` is terminal, `merged_into_task_id` is CHECK-constrained to abandoned rows), so the first `cascade.js` rule PROPOSES a removal and a person confirms it — derived-only would dim for refactors and renames, declared-only would rot. A ratified **Full Idea becomes a goal or a criterion**, carrying a recommendation the board rules on, because scope is the thing you learn last; the five questions are replaced and VERSIONED so prior filings keep their own. Dropped three tasks this invalidated and amended a fourth whose title asserted the exact exclusivity it reverses. Does not decide: the decay rate beyond its default, what precisely counts as removed, how the edges are physically recorded, or how any of it is drawn.](0311-an-idea-is-a-permanent-origin-tag.md) | ideas / economy |
406
- | 0312 | [**Provenance is a SECOND ideas-owned edge table, written by the seams that already know** ([task 1004080](https://cloudbongos.com/builders#/task/1004080), goal 1000095 — BONGOS-V2). The spike [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: how the descent edges are physically recorded. A descent (`idea → task → rewrite → split → removal`) is four levels deep and crosses a version boundary, and the task hierarchy refuses both — `attachParent` enforces `SELF_PARENT`, `VERSION_MISMATCH`, `PARENT_HAS_PARENT` and `TARGET_HAS_CHILDREN`, all load-bearing for ship rollup and claim safety. The spike was told to CONFIRM `ideas_task_lineage` rather than assume it, and confirmed one half while refuting the other: the table is right as the **idea→task anchor** (bipartite, no depth cap, no version predicate) but CANNOT carry the task→task edge, because a derivation is a property of the task PAIR and [ADR 0236](0236-many-ideas-one-task-the-lineage-key-is-the-pair.md) made co-source ideas both legal and common — a `derived_from_task_id` column would write the same derivation once per co-source idea and let two rows disagree about where a task came from, the same under-keying that silently lost attribution on prod once already. A merge also gives a task SEVERAL parents, so the relation is a **DAG, not a tree**. Decision: a new `ideas_task_descent` (`task_id`, `parent_task_id`, CHECK'd `relation`, PK on the pair, indexed both ways), with NO depth cap and NO version predicate, and **cycles refused at write time** because the payout walks this graph and a cycle is a non-terminating walk rather than a cosmetic error. On who writes it — the question the task actually set, since an edge depending on memory will have holes — **three seams already know and none is telling anyone**: `cascade.js` encodes the originating task id inside `source_ref` as a STRING nothing can join on, `tasks.merged_into_task_id` already records the fold, and `parent_task_id` already records the split. Only `rewrite` and cross-version `merge` stay manual, which is honest — they are editorial acts with no automatic signal, and their absence degrades the picture rather than corrupting it. What it fixes: `sourceIdeaForTask` and `awardIdeaTaskShipCredit` are both one-hop `LIMIT 1` point reads, so a DERIVED task resolves to `no_source_idea` and the origin is not paid at all. Does not decide: the decay rate beyond its default, what counts as removed ([task 1004081](https://cloudbongos.com/builders#/task/1004081)), or how any of it is drawn.](0312-provenance-is-a-second-ideas-owned-edge-table.md) | ideas / lifecycle |
406
+ | 0312 | [**Provenance is a SECOND ideas-owned edge table, written by the seams that already know** ([task 1004080](https://cloudbongos.com/builders#/task/1004080), goal 1000095 — BONGOS-V2). The spike [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: how the descent edges are physically recorded. A descent (`idea → task → rewrite → split → removal`) is four levels deep and crosses a version boundary, and the task hierarchy refuses both — `attachParent` enforces `SELF_PARENT`, `VERSION_MISMATCH`, `PARENT_HAS_PARENT` and `TARGET_HAS_CHILDREN`, all load-bearing for ship rollup and claim safety. The spike was told to CONFIRM `ideas_task_lineage` rather than assume it, and confirmed one half while refuting the other: the table is right as the **idea→task anchor** (bipartite, no depth cap, no version predicate) but CANNOT carry the task→task edge, because a derivation is a property of the task PAIR and [ADR 0236](0236-many-ideas-one-task-the-lineage-key-is-the-pair.md) made co-source ideas both legal and common — a `derived_from_task_id` column would write the same derivation once per co-source idea and let two rows disagree about where a task came from, the same under-keying that silently lost attribution on prod once already. A merge also gives a task SEVERAL parents, so the relation is a **DAG, not a tree**. Decision: a new `ideas_task_descent` (`task_id`, `parent_task_id`, CHECK'd `relation`, PK on the pair, indexed both ways), with NO depth cap and NO version predicate, and **cycles refused at write time** because the payout walks this graph and a cycle is a non-terminating walk rather than a cosmetic error. On who writes it — the question the task actually set, since an edge depending on memory will have holes — **three seams already know and none is telling anyone**: `cascade.js` encodes the originating task id inside `source_ref` as a STRING nothing can join on, `tasks.merged_into_task_id` already records the fold, and `parent_task_id` already records the split. Only `rewrite` and cross-version `merge` stay manual, which is honest — they are editorial acts with no automatic signal, and their absence degrades the picture rather than corrupting it. What it fixes: `sourceIdeaForTask` and `awardIdeaTaskShipCredit` are both one-hop `LIMIT 1` point reads, so a DERIVED task resolves to `no_source_idea` and the origin is not paid at all. Does not decide: the decay rate beyond its default, what counts as removed ([ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md)), or how any of it is drawn.](0312-provenance-is-a-second-ideas-owned-edge-table.md) | ideas / lifecycle |
407
+ | 0313 | [**A removal is derived from the SERVER's own merge diff and confirmed by a person** ([task 1004081](https://cloudbongos.com/builders#/task/1004081), goal 1000095 — BONGOS-V2). The other half [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: what counts as removed, so that brightness can fall at all. Nothing records it today — `shipped` is terminal (`rot.js` says so outright) and `merged_into_task_id` is CHECK'd to `abandoned` rows, describing only work cut BEFORE it landed. Owner decision 13 set the shape (system proposes, person confirms); this settles the detail, and corrects two premises the task was written on. **`cascade.js` is no longer empty** — it carries a production rule and [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) fixed the contract (append an entry, never touch the runner), so there is a precedent to copy including its anti-recursion guard. **`netNegativeBonusAmount` cannot be the trigger** despite being the named nearest signal: it is an aggregate with NO TARGET (it says deletion happened, never what or whose), it is CLIENT-SUPPLIED via `signals.diff_stats`, and `grader-score.js` already documents the forgery it defends against — and brightness sits beside a payout, so a forgeable target-less counter cannot carry it. **Nothing durable records what any ship touched**: `committed_files` and `diff_stats` are client-supplied at grade time and never persisted, `touches[]` is advisory ([ADR 0049](0049-split-parallel-safety-contract.md)), so a removal cannot be reconstructed from history — only derived as a ship lands. Decision: derive it from the SERVER's own merge diff ([ADR 0055](0055-server-mediated-branch-publish.md) — the server holds the repo and does the merge, so it computes the diff rather than being told one), and attribute the deleted hunks through **`claims.published_head_sha`** (`migrations/171`), NOT by parsing `(task NNNN)` out of commit text — only 7 of `main`'s last 15 commits carry a task id, and a convention that holds most of the time cannot underwrite a payout. An UNATTRIBUTABLE deletion files nothing: a wrong proposal costs attention and dims a real star. A rename, a move and a rewrite-in-place are NOT removals, and a cold body is not a remnant — removal applies only below a task that actually shipped. The proposal is an appended `CASCADES` entry filing a QUESTION; confirmation is Metic+/`task.promote` on the existing awaiting-nod surface; an unanswered one rots on the normal clock. Only a CONFIRMED proposal dims a star. The derivation must be NON-BLOCKING — landing work matters more than dimming a star on time. Brightness can only fall forward from here, so historical sizes need a marker or the number lies.](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md) | ideas / lifecycle |
@@ -2270,5 +2270,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2270
2270
  landed since 1.19.896 with no explicit bump. run 35485346707. (task 1002620)
2271
2271
  1.19.898 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2272
2272
  landed since 1.19.897 with no explicit bump. run 35487863230. (task 1002620)
2273
+ 1.19.899 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2274
+ landed since 1.19.898 with no explicit bump. run 35488237998. (task 1002620)
2275
+ 1.19.900 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2276
+ landed since 1.19.899 with no explicit bump. run 35492534307. (task 1002620)
2273
2277
  ---------------------------------------------------------------------------
2274
2278
  ```
@@ -0,0 +1,214 @@
1
+ // modules/ideas/descent.js — task→task provenance edges: the relation that
2
+ // makes an origin tag survive a rewrite, a split and a merge (task 1004086,
3
+ // BV2.R07, goal 1000095; ADR 0312).
4
+ //
5
+ // WHY THIS IS ITS OWN FILE, beside inbox.js rather than inside it. ADR 0312's
6
+ // central claim is that provenance is a DIFFERENT RELATION from both work
7
+ // decomposition (tasks.parent_task_id) and idea→task lineage
8
+ // (ideas_task_lineage): the first answers "what is this work part of", the
9
+ // second "which thought opened this work", and this one "where did this work
10
+ // come from". Keeping it in its own module is that argument in the file layout
11
+ // — and it keeps inbox.js under the size ratchet, which adding it there broke.
12
+ //
13
+ // Reads the shared pool through the published doorway (src/module-api.js — the
14
+ // only core file a module may require, ADR 0083), the same way inbox.js does.
15
+
16
+ const api = require('../../src/module-api');
17
+ const { pool } = api;
18
+
19
+ // The walk is bounded, but the bound is a backstop, not the safety property —
20
+ // see walk() below, which cannot revisit a node and therefore cannot loop.
21
+ const DESCENT_MAX_DEPTH = 64;
22
+
23
+ const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'cascade']);
24
+
25
+ // walk — breadth-first over the edge table, one query PER LEVEL.
26
+ //
27
+ // WHY NOT A RECURSIVE CTE, which is the obvious shape and was the first cut.
28
+ // A recursive CTE's working table re-expands every path independently, so a
29
+ // densely-connected DAG — paths that converge and diverge again, which is
30
+ // exactly what a merge followed by a split produces — costs work exponential
31
+ // in depth, and a final GROUP BY cannot undo that because the blow-up has
32
+ // already happened inside the recursion. A depth bound caps the depth and does
33
+ // nothing about the breadth.
34
+ //
35
+ // Level-by-level BFS with a visited set fixes both at once. A node is expanded
36
+ // AT MOST ONCE ever, so the total work is bounded by the size of the reachable
37
+ // subgraph rather than by the number of paths through it, and the FIRST time a
38
+ // node is reached is necessarily its SHORTEST depth — which is the depth the
39
+ // payout decays by, and the one that stops a single descendant paying the same
40
+ // origin twice. It also makes cycle-safety structural rather than a bound to
41
+ // remember: a cycle simply has nothing new to visit and the walk ends.
42
+ //
43
+ // The cost is one round trip per level instead of one per walk. Depth decay
44
+ // halves each hop (spec decision 16), so a descent deep enough for that to
45
+ // matter is already worth almost nothing.
46
+ async function walk(startId, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
47
+ const q = client || pool;
48
+ // Two literal statements rather than one with an interpolated column name:
49
+ // nothing here is caller-controlled, and a query built by concatenation is a
50
+ // shape a reader (and a scanner) has to stop and prove safe.
51
+ const SQL_UP = `SELECT parent_task_id AS id, relation
52
+ FROM ideas_task_descent
53
+ WHERE task_id = ANY($1::bigint[])`;
54
+ const SQL_DOWN = `SELECT task_id AS id, relation
55
+ FROM ideas_task_descent
56
+ WHERE parent_task_id = ANY($1::bigint[])`;
57
+ const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
58
+
59
+ const found = new Map();
60
+ const visited = new Set([String(startId)]);
61
+ let frontier = [Number(startId)];
62
+
63
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth += 1) {
64
+ const { rows } = await q.query(sql, [frontier]);
65
+ const next = [];
66
+ for (const r of rows) {
67
+ const key = String(r.id);
68
+ if (visited.has(key)) continue; // already reached at a shallower depth
69
+ visited.add(key);
70
+ found.set(key, { id: r.id, depth, relation: r.relation });
71
+ next.push(Number(r.id));
72
+ }
73
+ frontier = next;
74
+ }
75
+
76
+ return [...found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id));
77
+ }
78
+
79
+ // ancestorsOfTask — every task this one descends from, nearest first, each at
80
+ // its shortest depth. This is what the payout (R09) walks.
81
+ function ancestorsOfTask(taskId, opts = {}) {
82
+ return walk(taskId, 'up', opts);
83
+ }
84
+
85
+ // descendantsOfTask — the other direction, for the descent view (R10).
86
+ function descendantsOfTask(taskId, opts = {}) {
87
+ return walk(taskId, 'down', opts);
88
+ }
89
+
90
+ // linkTaskDescent — record that `taskId` came out of `parentTaskId`.
91
+ //
92
+ // Idempotent on the pair, because the derivation below runs on every ship and
93
+ // may legitimately see the same signal twice.
94
+ async function linkTaskDescent(taskId, parentTaskId, relation, { client = null } = {}) {
95
+ const child = Number(taskId);
96
+ const parent = Number(parentTaskId);
97
+ if (!Number.isInteger(child) || !Number.isInteger(parent) || child <= 0 || parent <= 0) {
98
+ throw { code: 'DESCENT_BAD_ID', task_id: taskId, parent_task_id: parentTaskId };
99
+ }
100
+ if (child === parent) {
101
+ throw { code: 'DESCENT_SELF_EDGE', task_id: child };
102
+ }
103
+ // Validated here as well as by the DB CHECK. The constraint is the guarantee;
104
+ // this is the error message — a rejected enum should name the caller's bad
105
+ // value, not surface as a constraint violation from three frames down.
106
+ if (!DESCENT_RELATIONS.includes(relation)) {
107
+ throw { code: 'DESCENT_BAD_RELATION', relation, allowed: DESCENT_RELATIONS };
108
+ }
109
+ // Would this close a loop? It does if the proposed PARENT already DESCENDS
110
+ // from the child. Unbounded on purpose: walk() cannot revisit a node, so it
111
+ // always terminates, and a bound here would silently miss a cycle whose
112
+ // closing edge sits deeper than the bound — which is the one case that
113
+ // matters, because that cycle is the one that gets written.
114
+ const reachable = await descendantsOfTask(child, { client, maxDepth: Infinity });
115
+ if (reachable.some((r) => Number(r.id) === parent)) {
116
+ throw { code: 'DESCENT_CYCLE', task_id: child, parent_task_id: parent };
117
+ }
118
+ const q = client || pool;
119
+ await q.query(
120
+ `INSERT INTO ideas_task_descent (task_id, parent_task_id, relation)
121
+ VALUES ($1, $2, $3)
122
+ ON CONFLICT (task_id, parent_task_id) DO UPDATE
123
+ SET relation = EXCLUDED.relation`,
124
+ [child, parent, relation]
125
+ );
126
+ }
127
+
128
+ // ── the derivation: three signals the system already has ────────────────────
129
+ //
130
+ // ADR 0312's finding was that three seams already know a task was derived and
131
+ // none of them is telling anyone. This turns each into an edge.
132
+ //
133
+ // WHY IT IS PULLED FROM `tasks` HERE rather than written by the code that
134
+ // causes it. A module may not import another module (ADR 0083, rule 1), so
135
+ // modules/lifecycle/cascade.js cannot call into this file, and `tasks` is the
136
+ // protected lifecycle module's table so this may not add a column to it. What
137
+ // IS available is a read of `tasks` (the ideas_002 precedent — a FK, and a
138
+ // SELECT, are reads) and the `task.shipped` event on the published doorway.
139
+ // So the ideas module derives its own edges from facts already on the row,
140
+ // reacting to the event. Nothing in lifecycle changes.
141
+ const CASCADE_SOURCE_REF = /-(\d+)$/;
142
+
143
+ async function deriveEdgesForTask(taskId, { client = null } = {}) {
144
+ const q = client || pool;
145
+ const id = Number(taskId);
146
+ if (!Number.isInteger(id) || id <= 0) return [];
147
+
148
+ const { rows } = await q.query(
149
+ `SELECT t.id, t.source, t.source_ref, t.parent_task_id,
150
+ (SELECT array_agg(m.id)
151
+ FROM tasks m
152
+ WHERE m.merged_into_task_id = t.id) AS folded_in
153
+ FROM tasks t
154
+ WHERE t.id = $1`,
155
+ [id]
156
+ );
157
+ const t = rows[0];
158
+ if (!t) return [];
159
+
160
+ const edges = [];
161
+
162
+ // 1. CASCADE — cascade.js writes `cascade-<key>-<originating id>` into
163
+ // source_ref. The key itself contains dashes ('artist-review-on-ship'),
164
+ // so the id is the trailing numeric run, not the third segment.
165
+ if (t.source === 'cascade' && typeof t.source_ref === 'string') {
166
+ const m = CASCADE_SOURCE_REF.exec(t.source_ref);
167
+ if (m) edges.push({ parent: Number(m[1]), relation: 'cascade' });
168
+ }
169
+
170
+ // 2. SPLIT — the one-level work decomposition. The hierarchy keeps owning
171
+ // what the work is PART OF; this records that it CAME FROM it.
172
+ if (t.parent_task_id != null) {
173
+ edges.push({ parent: Number(t.parent_task_id), relation: 'split' });
174
+ }
175
+
176
+ // 3. FOLD — tasks abandoned INTO this one (core_232 constrains
177
+ // merged_into_task_id to abandoned rows). The mockup's "satellite": work
178
+ // that stopped being its own thing and now orbits this one.
179
+ for (const foldedId of t.folded_in || []) {
180
+ edges.push({ parent: Number(foldedId), relation: 'fold' });
181
+ }
182
+
183
+ return edges.filter((e) => Number.isInteger(e.parent) && e.parent > 0 && e.parent !== id);
184
+ }
185
+
186
+ // syncDescentForTask — derive and record, tolerantly.
187
+ //
188
+ // Returns what it wrote and what it skipped rather than throwing: this runs off
189
+ // a ship event, and a provenance edge must never be able to break a ship. A
190
+ // refused cycle is a normal outcome here, not an error.
191
+ async function syncDescentForTask(taskId, { client = null } = {}) {
192
+ const edges = await deriveEdgesForTask(taskId, { client });
193
+ const written = [];
194
+ const skipped = [];
195
+ for (const e of edges) {
196
+ try {
197
+ await linkTaskDescent(taskId, e.parent, e.relation, { client });
198
+ written.push(e);
199
+ } catch (err) {
200
+ skipped.push({ ...e, code: (err && err.code) || 'UNKNOWN' });
201
+ }
202
+ }
203
+ return { written, skipped };
204
+ }
205
+
206
+ module.exports = {
207
+ DESCENT_MAX_DEPTH,
208
+ DESCENT_RELATIONS,
209
+ linkTaskDescent,
210
+ ancestorsOfTask,
211
+ descendantsOfTask,
212
+ deriveEdgesForTask,
213
+ syncDescentForTask,
214
+ };
@@ -0,0 +1,111 @@
1
+ -- ideas_007_task_descent_edges.sql — task→task provenance edges (task 1004086,
2
+ -- BV2.R07, goal 1000095 "Working area 6"; ADR 0312).
3
+ --
4
+ -- WHY A SECOND TABLE AND NOT A COLUMN ON ideas_task_lineage. The spike (task
5
+ -- 1004080) was told to confirm the lineage table rather than assume it, and
6
+ -- confirmed one half while refuting the other. As the idea→task ANCHOR the
7
+ -- table is right: it is bipartite, carries no depth cap and no version
8
+ -- predicate, and so has none of the invariants that close the task hierarchy to
9
+ -- this. But a derivation is a property of the TASK PAIR, not of the
10
+ -- (task, idea) pair that ideas_006 made the key. Adding derived_from_task_id
11
+ -- there would write the same derivation once per co-source idea — and
12
+ -- co-source ideas are legal and EXPECTED since ideas_006 (merging duplicate
13
+ -- reports into one task is the triage move the inbox exists to encourage) — so
14
+ -- two rows for one task could disagree about where that task came from, with
15
+ -- nothing to arbitrate. ideas_006 exists precisely because an under-keyed row
16
+ -- silently lost attribution on prod and it took months to notice.
17
+ --
18
+ -- WHY NOT tasks.parent_task_id. modules/lifecycle/hierarchy.js caps parentage
19
+ -- at ONE level and refuses cross-version trees (SELF_PARENT, VERSION_MISMATCH,
20
+ -- PARENT_HAS_PARENT, TARGET_HAS_CHILDREN). A descent — idea → task → rewrite →
21
+ -- split → removal — is four levels and crosses a version boundary. Both
22
+ -- invariants are load-bearing for ship rollup and claim safety, and widening
23
+ -- them is out of scope (spec decision 8). Provenance is a DIFFERENT RELATION
24
+ -- from work decomposition: parent_task_id answers "what is this work part of",
25
+ -- this table answers "where did this work come from". They are not redundant.
26
+ --
27
+ -- WHY IT LIVES HERE, NAMESPACED. `tasks` is the PROTECTED lifecycle module and
28
+ -- a module migration may not ALTER a table outside its own namespace (ADR 0083
29
+ -- §Decision #5, fitness Check 11). So this is an IDEAS-OWNED side table keyed by
30
+ -- task ids — the same shape ideas_002 used for the same reason. FKs to existing
31
+ -- tables are reads, not schema changes to them, so it stays inside the wall.
32
+ --
33
+ -- Module migrations are ADDITIVE and NAMESPACED: every object is ideas_-prefixed,
34
+ -- no down-migration, idempotent (safe to re-run).
35
+
36
+ BEGIN;
37
+
38
+ CREATE TABLE IF NOT EXISTS ideas_task_descent (
39
+ -- The DERIVED task — the one that came out of something else.
40
+ task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
41
+
42
+ -- What it came FROM. A task may have SEVERAL parents: a merge folds two
43
+ -- pieces of work into one, so this is a DAG, not a tree. That is why the key
44
+ -- is the pair rather than task_id alone — the ideas_006 lesson, applied up
45
+ -- front instead of after an incident.
46
+ parent_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
47
+
48
+ -- HOW it was derived. Three of these are written from signals the system
49
+ -- ALREADY has and currently throws away (ADR 0312): 'cascade' from the
50
+ -- originating id that cascade.js encodes inside source_ref as a string
51
+ -- nothing can join on, 'fold' from tasks.merged_into_task_id, 'split' from
52
+ -- tasks.parent_task_id. Only 'rewrite' and cross-version 'merge' are
53
+ -- editorial acts with no automatic signal, so only those depend on someone
54
+ -- saying so — and their absence degrades the picture rather than corrupting
55
+ -- it. Widen this CHECK the ideas_003 way if a sixth relation ever appears.
56
+ relation text NOT NULL
57
+ CHECK (relation IN ('rewrite', 'split', 'merge', 'fold', 'cascade')),
58
+
59
+ created_at timestamptz NOT NULL DEFAULT now(),
60
+
61
+ -- A task may hold at most one edge to a given parent; several parents are fine.
62
+ PRIMARY KEY (task_id, parent_task_id),
63
+
64
+ -- A SELF-EDGE IS AN INFINITE WALK, NOT A COSMETIC ERROR. The payout walks
65
+ -- this graph (R09) and the descent reads it (R10), so a cycle does not render
66
+ -- oddly — it fails to terminate. This CHECK refuses the one-node case in the
67
+ -- schema; longer cycles cannot be expressed as a row constraint and are
68
+ -- refused by the writer, which walks to the root before inserting.
69
+ CONSTRAINT ideas_task_descent_no_self_edge CHECK (task_id <> parent_task_id)
70
+ );
71
+
72
+ -- Two directions, two indexes, because both are hot and neither is served by
73
+ -- the other. The DESCENT walks DOWN from an origin (children of a parent); the
74
+ -- PAYOUT walks UP from a task that just shipped (its ancestors). The primary
75
+ -- key's implicit index already covers the (task_id, …) direction, so only the
76
+ -- parent side needs its own.
77
+ CREATE INDEX IF NOT EXISTS idx_ideas_task_descent_parent
78
+ ON ideas_task_descent (parent_task_id);
79
+
80
+ -- SAFETY NET — a migration that cannot prove its own effect must not report
81
+ -- success (the ideas_003 / ideas_005 / ideas_006 precedent). Read-only, no DDL.
82
+ -- If the table came back without the composite key, a task could hold two
83
+ -- contradictory edges to the same parent; if it came back without the self-edge
84
+ -- CHECK, the first bad write hangs the payout walk instead of being refused.
85
+ DO $$
86
+ DECLARE pk_cols text; has_self_check boolean;
87
+ BEGIN
88
+ SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
89
+ FROM pg_constraint c
90
+ JOIN unnest(c.conkey) AS k(attnum) ON true
91
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
92
+ WHERE c.conrelid = 'ideas_task_descent'::regclass
93
+ AND c.contype = 'p';
94
+ IF pk_cols IS DISTINCT FROM 'parent_task_id,task_id' THEN
95
+ RAISE EXCEPTION
96
+ 'ideas_007: ideas_task_descent primary key is (%) — expected the composite (task_id, parent_task_id).', COALESCE(pk_cols, 'none');
97
+ END IF;
98
+
99
+ SELECT EXISTS (
100
+ SELECT 1 FROM pg_constraint
101
+ WHERE conrelid = 'ideas_task_descent'::regclass
102
+ AND contype = 'c'
103
+ AND conname = 'ideas_task_descent_no_self_edge'
104
+ ) INTO has_self_check;
105
+ IF NOT has_self_check THEN
106
+ RAISE EXCEPTION
107
+ 'ideas_007: the no-self-edge CHECK is missing — a self-edge would make the payout walk non-terminating.';
108
+ END IF;
109
+ END $$;
110
+
111
+ COMMIT;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "key": "ideas",
3
3
  "title": "Ideas",
4
- "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue \u2014 the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 \u00a71); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 \u00a75 \u2014 retiring the temporary `captureIdea` doorway leak).",
4
+ "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
7
  "maintenance": {
@@ -11,6 +11,9 @@
11
11
  "routes": [
12
12
  "inbox",
13
13
  "blockers"
14
+ ],
15
+ "pollers": [
16
+ "descent-writer"
14
17
  ]
15
18
  },
16
19
  "provides": [
@@ -0,0 +1,69 @@
1
+ // modules/ideas/pollers/descent-writer.js — the production writer for
2
+ // task→task provenance edges (task 1004086, BV2.R07, goal 1000095; ADR 0312).
3
+ //
4
+ // WHY A POLLER AND NOT A CALL FROM THE CODE THAT CAUSES THE DERIVATION. The
5
+ // three signals ADR 0312 identified all live on `tasks`, which belongs to the
6
+ // protected `lifecycle` module, and a module may not import another module
7
+ // (ADR 0083, rule 1: one doorway, one direction). So cascade.js cannot call
8
+ // into `ideas`, and `ideas` cannot reach into lifecycle's code. The published
9
+ // doorway's `task.shipped` event is the seam that legitimately joins them, and
10
+ // the agents module already uses it for exactly this shape of reaction
11
+ // (modules/agents/pollers/event-router.js).
12
+ //
13
+ // "Poller" here means "something the module runs in the web process", not
14
+ // "something on a clock" — it owns no timer, and the same off→on replacement
15
+ // idiom keeps a reloaded loader from accumulating duplicate listeners, which
16
+ // would make every ship derive its edges twice.
17
+ //
18
+ // A PROVENANCE EDGE MUST NEVER BREAK A SHIP. The handler is deliberately
19
+ // total: syncDescentForTask already returns its refusals rather than throwing
20
+ // (a refused cycle is a normal outcome, not an error), and this wraps the whole
21
+ // call anyway. The worst failure mode available to it is a missing edge, which
22
+ // degrades the picture. The one it must not have is a thrown error on the ship
23
+ // path — a reactor must never break the actor.
24
+
25
+ const api = require('../../../src/module-api');
26
+ const { syncDescentForTask } = require('../descent');
27
+
28
+ const log = api.logger('ideas/descent-writer');
29
+ const EVENT = 'task.shipped';
30
+
31
+ let _off = null;
32
+
33
+ // The event carries the TASK ROW, the same subject modules/agents receives.
34
+ async function onShipped(task) {
35
+ const taskId = task && task.id;
36
+ if (taskId == null) return undefined;
37
+ try {
38
+ const { written, skipped } = await syncDescentForTask(taskId);
39
+ if (written.length > 0 || skipped.length > 0) {
40
+ log.info(
41
+ `ideas/descent-writer: task ${taskId} — ${written.length} edge(s) recorded`
42
+ + (skipped.length > 0 ? `, ${skipped.length} refused` : ''),
43
+ );
44
+ }
45
+ } catch (err) {
46
+ // A reactor must never break the actor.
47
+ log.error(`ideas/descent-writer: derivation threw for task ${taskId}: ${err && err.message}`);
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function start() {
53
+ if (_off) return;
54
+ _off = api.on(EVENT, onShipped);
55
+ log.info(`[ideas/descent-writer] listening on ${EVENT} — deriving provenance edges from source_ref, parent_task_id and merged_into_task_id`);
56
+ }
57
+
58
+ function stop() {
59
+ if (_off) { try { _off(); } catch (_) { /* already gone */ } }
60
+ _off = null;
61
+ }
62
+
63
+ module.exports = {
64
+ start,
65
+ stop,
66
+ // Exported for tests: the handler is the whole reaction, and it needs no
67
+ // server to prove.
68
+ onShipped,
69
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.898",
3
+ "version": "1.19.900",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.898",
9
+ "version": "1.19.900",
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.898",
3
+ "version": "1.19.900",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.898'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.900'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,340 @@
1
+ // tests/idea_task_descent.mjs
2
+ //
3
+ // task 1004086 (BV2.R07, goal 1000095) — task→task descent edges. ADR 0312.
4
+ //
5
+ // WHAT IS ASSERTED HERE, AND WHY IT IS DB-FREE. The parts of this feature that
6
+ // can go wrong silently are JavaScript, not SQL: the cycle refusal, the
7
+ // breadth-first de-duplication, and the derivation that turns three existing
8
+ // signals into edges. A cycle does not render oddly — the payout (R09) and the
9
+ // descent (R10) both walk this graph, so a cycle fails to terminate. A merge
10
+ // makes the graph a DAG rather than a tree, so a task reachable by two paths
11
+ // must be counted ONCE at its SHORTEST depth, or the same origin is paid twice
12
+ // for one descendant.
13
+ //
14
+ // The client is injected (every reader and writer takes { client }), so those
15
+ // paths run for real against a recorded query log rather than being simulated.
16
+ // A real-Postgres suite would SELF-SKIP on this machine, and a skipped test
17
+ // prints ok and counts as passed — which is how a green lane hides what never
18
+ // ran. The DDL half is therefore asserted as a SOURCE invariant instead.
19
+ //
20
+ // Run: node tests/idea_task_descent.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const descent = require('../modules/ideas/descent.js');
31
+
32
+ const MIGRATION = path.join(ROOT, 'modules/ideas/migrations/ideas_007_task_descent_edges.sql');
33
+ const DDL = fs.readFileSync(MIGRATION, 'utf8');
34
+ const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
35
+ const WRITER_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/pollers/descent-writer.js'), 'utf8');
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ function test(name, fn) {
40
+ return Promise.resolve()
41
+ .then(fn)
42
+ .then(() => { passed += 1; console.log('ok ' + name); })
43
+ .catch((e) => { failed += 1; console.log('FAIL ' + name + '\n ' + (e && (e.message || JSON.stringify(e)))); });
44
+ }
45
+
46
+ // A client that answers each query from a queue, recording what it was asked.
47
+ function fakeClient(queue) {
48
+ const log = [];
49
+ return {
50
+ log,
51
+ query: async (text, params) => { log.push({ text, params }); return { rows: queue.shift() ?? [] }; },
52
+ };
53
+ }
54
+
55
+ // A client that answers the BFS from a real adjacency map, so the walk is
56
+ // exercised as a walk rather than against canned rows.
57
+ function graphClient(edges) {
58
+ // edges: [{ task_id, parent_task_id, relation }]
59
+ const log = [];
60
+ return {
61
+ log,
62
+ query: async (text, params) => {
63
+ log.push({ text, params });
64
+ if (!Array.isArray(params[0])) return { rows: [] }; // the INSERT, not a walk
65
+ const frontier = params[0].map(Number);
66
+ const up = /parent_task_id AS id/.test(text);
67
+ const rows = edges
68
+ .filter((e) => frontier.includes(Number(up ? e.task_id : e.parent_task_id)))
69
+ .map((e) => ({ id: up ? e.parent_task_id : e.task_id, relation: e.relation }));
70
+ return { rows };
71
+ },
72
+ };
73
+ }
74
+
75
+ // ── the migration ───────────────────────────────────────────────────────────
76
+
77
+ await test('the migration declares the COMPOSITE key — one edge per (task, parent) pair', () => {
78
+ assert.match(DDL, /PRIMARY KEY \(task_id, parent_task_id\)/,
79
+ 'the key must be the pair: a merge gives a task several parents');
80
+ });
81
+
82
+ await test('the migration refuses a SELF-EDGE in the schema', () => {
83
+ assert.match(DDL, /CHECK \(task_id <> parent_task_id\)/);
84
+ assert.match(DDL, /ideas_task_descent_no_self_edge/);
85
+ });
86
+
87
+ await test('the migration indexes the PARENT direction — the descent walk', () => {
88
+ assert.match(DDL, /CREATE INDEX IF NOT EXISTS idx_ideas_task_descent_parent/);
89
+ });
90
+
91
+ await test('the migration is additive and namespaced (ADR 0083 §5)', () => {
92
+ assert.doesNotMatch(DDL, /DROP TABLE|TRUNCATE|DROP COLUMN/i,
93
+ 'a module migration may not carry destructive DDL');
94
+ for (const m of DDL.matchAll(/CREATE (?:TABLE|INDEX)(?: IF NOT EXISTS)? (\w+)/g)) {
95
+ assert.ok(m[1].startsWith('ideas_') || m[1].startsWith('idx_ideas_'),
96
+ `object ${m[1]} is outside the ideas_ namespace`);
97
+ }
98
+ });
99
+
100
+ await test('the migration proves its own effect — the safety net is present', () => {
101
+ assert.match(DDL, /RAISE EXCEPTION/,
102
+ 'the ideas_003/005/006 precedent: a migration that cannot prove its effect must not report success');
103
+ });
104
+
105
+ // ── the writer ──────────────────────────────────────────────────────────────
106
+
107
+ await test('a SELF-EDGE is refused before any query is issued', async () => {
108
+ const c = fakeClient([]);
109
+ await assert.rejects(
110
+ () => descent.linkTaskDescent(7, 7, 'rewrite', { client: c }),
111
+ (e) => e.code === 'DESCENT_SELF_EDGE'
112
+ );
113
+ assert.equal(c.log.length, 0, 'the self case must not reach the database at all');
114
+ });
115
+
116
+ await test('a LONGER CYCLE is refused — the walk would not terminate', async () => {
117
+ // 9 already descends from 5 (5 → 9), so making 5 descend from 9 closes a loop.
118
+ const c = graphClient([{ task_id: 9, parent_task_id: 5, relation: 'split' }]);
119
+ await assert.rejects(
120
+ () => descent.linkTaskDescent(5, 9, 'merge', { client: c }),
121
+ (e) => e.code === 'DESCENT_CYCLE'
122
+ );
123
+ assert.ok(
124
+ c.log.every((q) => !/INSERT/i.test(q.text)),
125
+ 'the cycle must be refused BEFORE the row is written'
126
+ );
127
+ });
128
+
129
+ await test('a cycle DEEPER than the default bound is still refused', async () => {
130
+ // A chain 5 → 6 → … → 80, far past DESCENT_MAX_DEPTH (64). The closing edge
131
+ // is the one that matters, because it is the one that would get written.
132
+ const chain = [];
133
+ for (let i = 5; i < 80; i += 1) chain.push({ task_id: i + 1, parent_task_id: i, relation: 'split' });
134
+ const c = graphClient(chain);
135
+ await assert.rejects(
136
+ () => descent.linkTaskDescent(5, 80, 'merge', { client: c }),
137
+ (e) => e.code === 'DESCENT_CYCLE'
138
+ );
139
+ });
140
+
141
+ await test('an unknown relation is refused by NAME, not by a constraint violation', async () => {
142
+ const c = fakeClient([]);
143
+ await assert.rejects(
144
+ () => descent.linkTaskDescent(5, 9, 'inspired-by', { client: c }),
145
+ (e) => e.code === 'DESCENT_BAD_RELATION' && e.allowed.includes('cascade')
146
+ );
147
+ assert.equal(c.log.length, 0);
148
+ });
149
+
150
+ await test('a legitimate edge IS written, and upserts on the pair', async () => {
151
+ const c = graphClient([]);
152
+ await descent.linkTaskDescent(5, 9, 'split', { client: c });
153
+ const ins = c.log.find((q) => /INSERT INTO ideas_task_descent/i.test(q.text));
154
+ assert.ok(ins, 'the edge must be inserted');
155
+ assert.match(ins.text, /ON CONFLICT \(task_id, parent_task_id\) DO UPDATE/,
156
+ 'the derivation runs on every ship, so the write must be idempotent');
157
+ assert.deepEqual(ins.params, [5, 9, 'split']);
158
+ });
159
+
160
+ await test('a non-numeric id is refused rather than passed to SQL', async () => {
161
+ const c = fakeClient([]);
162
+ await assert.rejects(
163
+ () => descent.linkTaskDescent('abc', 9, 'split', { client: c }),
164
+ (e) => e.code === 'DESCENT_BAD_ID'
165
+ );
166
+ assert.equal(c.log.length, 0);
167
+ });
168
+
169
+ // ── the walk ────────────────────────────────────────────────────────────────
170
+
171
+ await test('a task reachable TWICE is returned ONCE, at its SHORTEST depth', async () => {
172
+ // The DAG a merge produces: 1 → 2 → 4 and 1 → 3 → 4, plus the short edge
173
+ // 1 → 4. Node 4 is reachable at depth 1 and depth 2; the payout must decay by
174
+ // the shorter one, and must not pay twice.
175
+ const c = graphClient([
176
+ { task_id: 2, parent_task_id: 1, relation: 'split' },
177
+ { task_id: 3, parent_task_id: 1, relation: 'split' },
178
+ { task_id: 4, parent_task_id: 2, relation: 'merge' },
179
+ { task_id: 4, parent_task_id: 3, relation: 'merge' },
180
+ { task_id: 4, parent_task_id: 1, relation: 'cascade' },
181
+ ]);
182
+ const got = await descent.descendantsOfTask(1, { client: c });
183
+ const ids = got.map((r) => Number(r.id));
184
+ assert.deepEqual(ids, [2, 3, 4], 'each node once, nearest first');
185
+ assert.equal(got.find((r) => Number(r.id) === 4).depth, 1, 'shortest path wins');
186
+ });
187
+
188
+ await test('a node is EXPANDED at most once — the convergent DAG does not blow up', async () => {
189
+ // Same shape, one level deeper. A recursive CTE re-expands every path; this
190
+ // asserts the walk does not, by counting the queries it issues: one per
191
+ // LEVEL, never one per path.
192
+ const c = graphClient([
193
+ { task_id: 2, parent_task_id: 1, relation: 'split' },
194
+ { task_id: 3, parent_task_id: 1, relation: 'split' },
195
+ { task_id: 4, parent_task_id: 2, relation: 'merge' },
196
+ { task_id: 4, parent_task_id: 3, relation: 'merge' },
197
+ { task_id: 5, parent_task_id: 4, relation: 'split' },
198
+ ]);
199
+ const got = await descent.descendantsOfTask(1, { client: c });
200
+ assert.deepEqual(got.map((r) => Number(r.id)), [2, 3, 4, 5]);
201
+ // One query per LEVEL — {1}, {2,3}, {4}, {5} — with the last returning
202
+ // nothing and ending the walk. Four queries for a graph carrying several
203
+ // distinct paths through node 4: the count tracks DEPTH, not the number of
204
+ // paths, which is exactly what a recursive CTE cannot promise.
205
+ assert.equal(c.log.length, 4, `one query per level, got ${c.log.length}`);
206
+ });
207
+
208
+ await test('a CYCLE in the stored data cannot hang the walk', async () => {
209
+ const c = graphClient([
210
+ { task_id: 2, parent_task_id: 1, relation: 'split' },
211
+ { task_id: 1, parent_task_id: 2, relation: 'merge' }, // the loop
212
+ ]);
213
+ const got = await descent.descendantsOfTask(1, { client: c });
214
+ assert.deepEqual(got.map((r) => Number(r.id)), [2], 'the start is never revisited');
215
+ });
216
+
217
+ await test('both directions walk the same way', async () => {
218
+ const c = graphClient([
219
+ { task_id: 9, parent_task_id: 5, relation: 'split' },
220
+ { task_id: 12, parent_task_id: 9, relation: 'rewrite' },
221
+ ]);
222
+ const up = await descent.ancestorsOfTask(12, { client: c });
223
+ assert.deepEqual(up.map((r) => Number(r.id)), [9, 5]);
224
+ assert.deepEqual(up.map((r) => r.depth), [1, 2], 'depth is the hop count the payout decays by');
225
+ });
226
+
227
+ // ── the derivation ──────────────────────────────────────────────────────────
228
+
229
+ await test('a CASCADE task derives its edge from the id inside source_ref', async () => {
230
+ // The key itself contains dashes, so the id is the trailing numeric run.
231
+ const c = fakeClient([[{
232
+ id: 900, source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1003583',
233
+ parent_task_id: null, folded_in: null,
234
+ }]]);
235
+ const edges = await descent.deriveEdgesForTask(900, { client: c });
236
+ assert.deepEqual(edges, [{ parent: 1003583, relation: 'cascade' }]);
237
+ });
238
+
239
+ await test('a SPLIT derives from parent_task_id, and a FOLD from merged_into', async () => {
240
+ const c = fakeClient([[{
241
+ id: 900, source: 'manual', source_ref: null,
242
+ parent_task_id: 500, folded_in: [401, 402],
243
+ }]]);
244
+ const edges = await descent.deriveEdgesForTask(900, { client: c });
245
+ assert.deepEqual(edges, [
246
+ { parent: 500, relation: 'split' },
247
+ { parent: 401, relation: 'fold' },
248
+ { parent: 402, relation: 'fold' },
249
+ ]);
250
+ });
251
+
252
+ await test('a task with no derivation signal produces NO edges', async () => {
253
+ const c = fakeClient([[{
254
+ id: 900, source: 'manual', source_ref: null, parent_task_id: null, folded_in: null,
255
+ }]]);
256
+ assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
257
+ });
258
+
259
+ await test('a source_ref that is not a cascade id yields nothing, not a NaN edge', async () => {
260
+ const c = fakeClient([[{
261
+ id: 900, source: 'cascade', source_ref: 'cascade-some-key-with-no-id',
262
+ parent_task_id: null, folded_in: null,
263
+ }]]);
264
+ assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
265
+ });
266
+
267
+ await test('a self-referential signal is filtered out before it is written', async () => {
268
+ const c = fakeClient([[{
269
+ id: 900, source: 'cascade', source_ref: 'cascade-k-900',
270
+ parent_task_id: 900, folded_in: null,
271
+ }]]);
272
+ assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
273
+ });
274
+
275
+ await test('syncDescentForTask REPORTS a refusal instead of throwing', async () => {
276
+ // A ship must never break because a provenance edge was refused.
277
+ const c = {
278
+ log: [],
279
+ query: async (text, params) => {
280
+ c.log.push({ text, params });
281
+ if (/FROM tasks t/.test(text)) {
282
+ return { rows: [{ id: 5, source: 'manual', source_ref: null, parent_task_id: 9, folded_in: null }] };
283
+ }
284
+ // The cycle probe: 9 already descends from 5.
285
+ if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, relation: 'split' }] };
286
+ return { rows: [] };
287
+ },
288
+ };
289
+ const res = await descent.syncDescentForTask(5, { client: c });
290
+ assert.equal(res.written.length, 0);
291
+ assert.equal(res.skipped.length, 1);
292
+ assert.equal(res.skipped[0].code, 'DESCENT_CYCLE');
293
+ });
294
+
295
+ // ── the production call path ────────────────────────────────────────────────
296
+
297
+ await test('the module DECLARES the poller, or nothing ever runs it', () => {
298
+ assert.ok(MANIFEST.contributes.pollers?.includes('descent-writer'),
299
+ 'a poller file with no manifest entry is a silent no-op (ADR 0289)');
300
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules/ideas/pollers/descent-writer.js')),
301
+ 'and a declared seam whose artifact is absent is the same no-op in reverse');
302
+ });
303
+
304
+ await test('the writer subscribes to task.shipped and cannot double-subscribe', () => {
305
+ assert.match(WRITER_SRC, /api\.on\('task\.shipped'|EVENT = 'task\.shipped'/);
306
+ assert.match(WRITER_SRC, /if \(_off\) return;/,
307
+ 'a reloaded loader must not accumulate listeners and derive every ship twice');
308
+ });
309
+
310
+ await test('the writer uses APIs the doorway actually publishes', () => {
311
+ // The first cut guessed `api.log.child(...)`, which does not exist on
312
+ // src/module-api.js. The optional-chaining fallback meant it silently
313
+ // degraded to `console` instead of failing, so nothing caught it — the
314
+ // shape of bug a static read finds and a green suite does not.
315
+ const doorway = require(path.join(ROOT, "src/module-api.js"));
316
+ for (const m of WRITER_SRC.matchAll(/\bapi\.([A-Za-z_$][\w$]*)/g)) {
317
+ assert.ok(m[1] in doorway,
318
+ `the writer calls api.${m[1]}, which the published doorway does not export`);
319
+ }
320
+ });
321
+
322
+ await test('the writer reads the task id the way the event actually carries it', () => {
323
+ // task.shipped hands the subscriber the TASK ROW — the same subject
324
+ // modules/agents/pollers/event-router.js receives as `task`. Guarding
325
+ // against payload shapes that never occur reads as uncertainty about the
326
+ // contract.
327
+ assert.doesNotMatch(WRITER_SRC, /payload\.task_id|payload\.task\b/,
328
+ "speculative payload shapes: the event carries the task row");
329
+ });
330
+ await test('the handler swallows its own failure — a reactor must not break the actor', async () => {
331
+ const writer = require('../modules/ideas/pollers/descent-writer.js');
332
+ // No client injected, so syncDescentForTask reaches the real pool and throws.
333
+ // The handler must still resolve.
334
+ await writer.onShipped({ id: 999999999 });
335
+ await writer.onShipped({}); // no id at all
336
+ await writer.onShipped(null);
337
+ });
338
+
339
+ console.log(`\n${passed} passed, ${failed} failed`);
340
+ process.exit(failed === 0 ? 0 : 1);