@bongos/core 1.19.292 → 1.19.294

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.292",
6
- "core_contract": "1.19.292",
7
- "source_commit": "57bc876c876c64bf07069a3646e253006a917fc6",
5
+ "core_version": "1.19.294",
6
+ "core_contract": "1.19.294",
7
+ "source_commit": "fffe6738492ac7d113228cc4f9b1d5eae0ed16d3",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-22T15:49:03.336Z",
9
+ "built_at": "2026-08-22T17:51:40.637Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 320,
12
+ "docs_redacted": 321,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1391,
14
+ "functional_verbatim": 1395,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1733,
20
- "tree_sha256": "ca47008669375b8c24cbecd5f6142a05b189fce9c02e8c3d2bdc29a2da237f87",
19
+ "file_count": 1738,
20
+ "tree_sha256": "70e835883854335f17289f54d647911185cfc4cfb1ec5b17424a88e6bbd8156f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1399,20 +1399,25 @@
1399
1399
  "mode": "0000644",
1400
1400
  "sha256": "e7d0245b21406ddcd118dce29ffb1df5a4797e33d66231aa6bc734316c19530f"
1401
1401
  },
1402
+ {
1403
+ "path": "docs/adr/0184-ship-requires-an-assurance.md",
1404
+ "mode": "0000644",
1405
+ "sha256": "d68cae6169d7ef251fdf6c721e63a124bd3bf46979ffa51459db435426d24f8b"
1406
+ },
1402
1407
  {
1403
1408
  "path": "docs/adr/README.md",
1404
1409
  "mode": "0000644",
1405
- "sha256": "5e8bcc7eff0a9940760952e4dffe3855474e506439d677625f411164ae226a35"
1410
+ "sha256": "0eafe881d241706331548cf8212c5e84f2f42167c2ac5688427820fa394d77ef"
1406
1411
  },
1407
1412
  {
1408
1413
  "path": "docs/api-reference.md",
1409
1414
  "mode": "0000644",
1410
- "sha256": "2c4a9b56aea5a8e1a42c70ad5c2646c0d6db6d713c13b8d2068bd3a2fa6ac27e"
1415
+ "sha256": "8fb83f272ef8810df2c517d9514495f44016da2e23cdd67937463f40c0d1605d"
1411
1416
  },
1412
1417
  {
1413
1418
  "path": "docs/api/openapi.json",
1414
1419
  "mode": "0000644",
1415
- "sha256": "fcb40a7d655517e6dd24b2d4e0e00a9fcace75b2eb0aa89348d2546abf8df422"
1420
+ "sha256": "f51be43a281c9609980fca57d2fa11cafb460a414e506174715d53c08cef6965"
1416
1421
  },
1417
1422
  {
1418
1423
  "path": "docs/architecture.md",
@@ -3777,7 +3782,7 @@
3777
3782
  {
3778
3783
  "path": "modules/lifecycle/db.js",
3779
3784
  "mode": "0000644",
3780
- "sha256": "18b2053d9195936be86d98e00812ab69980986f91cf83bc9db054441036192fd"
3785
+ "sha256": "f4d3df748f39109c775ae167712926cd44832aba0651251f508adc4a8d4e57fe"
3781
3786
  },
3782
3787
  {
3783
3788
  "path": "modules/lifecycle/dead-deps.js",
@@ -3862,7 +3867,7 @@
3862
3867
  {
3863
3868
  "path": "modules/lifecycle/publish-reconciler.js",
3864
3869
  "mode": "0000644",
3865
- "sha256": "900fc9ecfe91a0c1e9cd62b619f609c1416196d502c007d0bbb87c41387b6163"
3870
+ "sha256": "8f08c6d58db41ba247aaf5b04b5a8988365b469e96c0ad04c336f272ef308c4e"
3866
3871
  },
3867
3872
  {
3868
3873
  "path": "modules/lifecycle/routes/analytics.js",
@@ -3902,13 +3907,18 @@
3902
3907
  {
3903
3908
  "path": "modules/lifecycle/routes/tasks.js",
3904
3909
  "mode": "0000644",
3905
- "sha256": "0600b76953e28b2c5828301606c72e2490756d43a2eb74b4bc92dca9d795cb77"
3910
+ "sha256": "3dbbf03588eb70c3d84b1d98f1e7a94a2c7bd0612d4835bd0fe1cc0470b802b4"
3906
3911
  },
3907
3912
  {
3908
3913
  "path": "modules/lifecycle/routes/versions.js",
3909
3914
  "mode": "0000644",
3910
3915
  "sha256": "044ca86e5605c19dd8e523da9eb061b4a90ed0af199ec1288c3c5f1b1bb8f46b"
3911
3916
  },
3917
+ {
3918
+ "path": "modules/lifecycle/ship-assurance.js",
3919
+ "mode": "0000644",
3920
+ "sha256": "2938d8ea651af09b3c73d294057c153ce079170ae34a42e3e94025daab097dcf"
3921
+ },
3912
3922
  {
3913
3923
  "path": "modules/lifecycle/ship-card.js",
3914
3924
  "mode": "0000644",
@@ -4527,12 +4537,12 @@
4527
4537
  {
4528
4538
  "path": "package-lock.json",
4529
4539
  "mode": "0000644",
4530
- "sha256": "417cc856bf6eca0ed8c214b12624c6bc7567bb96b07a5999ebf5b72ab6b4ca05"
4540
+ "sha256": "4555259c80ad21945412441d859ab9ff5f68be5adb28d7c02b2d405b42339398"
4531
4541
  },
4532
4542
  {
4533
4543
  "path": "package.json",
4534
4544
  "mode": "0000644",
4535
- "sha256": "06e136123405fad423973fd79250b7baae689b122c0975e41f369ae87cdfe9e9"
4545
+ "sha256": "26ce6a3328f57b80487d9b5fd31db9cb1c200c93d8f5ec307f763b39e8b28e6e"
4536
4546
  },
4537
4547
  {
4538
4548
  "path": "public-docs/index.html",
@@ -5732,7 +5742,7 @@
5732
5742
  {
5733
5743
  "path": "scripts/gds/ship.js",
5734
5744
  "mode": "0000644",
5735
- "sha256": "542d36dfe7dd25d20936844b237b6681b1e64d34c66448438cf7ef9d86abb79f"
5745
+ "sha256": "73c2e91340171d6a9112e869552be17e0f82aeb80a5c680205ac452d34c3ddc4"
5736
5746
  },
5737
5747
  {
5738
5748
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5779,6 +5789,11 @@
5779
5789
  "mode": "0000644",
5780
5790
  "sha256": "5cc69c5f3301912325aa48481e63141523ad75241f021e0e8f1a6dc592048bdd"
5781
5791
  },
5792
+ {
5793
+ "path": "scripts/gds/strand-watch.js",
5794
+ "mode": "0000644",
5795
+ "sha256": "abcf87286fa3b3be81c18e5927495f64c140322a83223c4dc673d8325c08121f"
5796
+ },
5782
5797
  {
5783
5798
  "path": "scripts/gds/task.js",
5784
5799
  "mode": "0000644",
@@ -6177,7 +6192,7 @@
6177
6192
  {
6178
6193
  "path": "src/module-api.js",
6179
6194
  "mode": "0000644",
6180
- "sha256": "a00525dccc2576669f8793418e40f7232dd2f8662163900142b561ce3c93a2db"
6195
+ "sha256": "ea4af468628d2a5b45ee1e1916622975f263fc4059cff13a49cadb898f35a4db"
6181
6196
  },
6182
6197
  {
6183
6198
  "path": "src/module-loader/catalog.js",
@@ -7867,7 +7882,7 @@
7867
7882
  {
7868
7883
  "path": "tests/orphaned_claim_ship.mjs",
7869
7884
  "mode": "0000644",
7870
- "sha256": "ecc09c00cc35a2a77bb8a2519332ee3da5b8a5ed8e580e5b21eb4ca43f12a10b"
7885
+ "sha256": "a67dd7e828196929e1fdcdc6cf09255aa583c2013b30632ad8eb847cc6ff5d3b"
7871
7886
  },
7872
7887
  {
7873
7888
  "path": "tests/override_attribution.mjs",
@@ -7907,7 +7922,7 @@
7907
7922
  {
7908
7923
  "path": "tests/pay_on_land.mjs",
7909
7924
  "mode": "0000644",
7910
- "sha256": "f5ff4080073fa5e6b8d764860dc677b6e617b7b055c2233047163e9c0ed66ce9"
7925
+ "sha256": "6c623a5abe9aadf4821ad8ca291fb5d7805a7a5bc874bfc3cd7e51e83de289d2"
7911
7926
  },
7912
7927
  {
7913
7928
  "path": "tests/permission_path.mjs",
@@ -8344,6 +8359,11 @@
8344
8359
  "mode": "0000644",
8345
8360
  "sha256": "57afd052aaf40ac487d53bf6850710e43c79babaeaa1859374fcc5d8200d92c2"
8346
8361
  },
8362
+ {
8363
+ "path": "tests/ship_assurance.mjs",
8364
+ "mode": "0000644",
8365
+ "sha256": "6fc882d215ce78f968b25477db8596ea70a28765fd222b8a9ffca331e7f2827c"
8366
+ },
8347
8367
  {
8348
8368
  "path": "tests/ship_autolinkify.mjs",
8349
8369
  "mode": "0000644",
@@ -8519,6 +8539,11 @@
8519
8539
  "mode": "0000644",
8520
8540
  "sha256": "2ddc5ada648e7eabb41d3ae0afd39f885f3840c8ec14483f83330e8eade11330"
8521
8541
  },
8542
+ {
8543
+ "path": "tests/strand_watch.mjs",
8544
+ "mode": "0000644",
8545
+ "sha256": "bba2bde5b7a4a25ccddff58fb2fde0ef84ef67224e63819232221a461234a30f"
8546
+ },
8522
8547
  {
8523
8548
  "path": "tests/streak.mjs",
8524
8549
  "mode": "0000644",
@@ -0,0 +1,106 @@
1
+ # 0184 — A ship must account for itself: `shipTask` requires a land proof or a declared no-artifact
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-19
5
+ - **Tasks:** [#1002483](https://cloudbongos.com/builders#/task/1002483) (goal 1000024, build-pipeline integrity). Carried forward from blocker 1000047
6
+ - **Extends:** [ADR 0120](<redacted>.md) — pay on land. This makes "land" checkable at the write.
7
+ - **Related:** ADR 0055 (server-mediated publish), 0137 (land → release → deploy are three gates), 0082 (generated-file conflict resolver); tasks 1313 / 1547 / 1002615 / 1002724 (the per-task land proof), [#1002478](https://cloudbongos.com/builders#/task/1002478) (the no-artifact dead end)
8
+
9
+ ## Context
10
+
11
+ `db.shipTask` is the confirmed→shipped flip. Since ADR 0120 it is also **where the credit is booked**.
12
+ Its only precondition was:
13
+
14
+ ```sql
15
+ UPDATE tasks SET status = 'shipped' … WHERE id = $1 AND status = 'confirmed'
16
+ ```
17
+
18
+ Nothing about whether any code reached `main`.
19
+
20
+ The two **automated** paths each proved the land before calling it — the reconciler sweep and
21
+ reconcile-on-read both run `landProvenForTask` and refuse without a proof. The **manual** route,
22
+ `POST /tasks/:id/ship`, called `shipTask` with nothing. It is `requireBuilder` +
23
+ `gateTaskOwnership(allowSupervisor: true)`, so a task confirmed with no pushed code could be flipped
24
+ to shipped by its claim holder, or by **any Metic+ running `/merge-mode`**, and it would book its
25
+ `task.shipped` credit.
26
+
27
+ Blast radius was genuinely small — it takes two deliberate steps, the automated healers refuse, and
28
+ ADR 0120 made strands loud and gated the builder's next claims. That is exactly why it kept being
29
+ deferred. **And why it needed a decision rather than another deferral:** it had resurfaced as a
30
+ blocker repeatedly, and its tracking task ([#1000900](https://cloudbongos.com/builders#/task/1000900))
31
+ was closed in June on a rationale asserting task 1001547 had delivered a confirm-time gate. It had
32
+ not — blocker 1000047's own final update says so explicitly, and the code agreed with the blocker, not
33
+ with the closure note. So the hole was untracked for two months **because a record was wrong**, which
34
+ is the same failure this ADR's gate is about: a ledger entry nobody could check.
35
+
36
+ ## Decision
37
+
38
+ ### 1. The gate lives at the WRITE, not at the route
39
+
40
+ `shipTask` requires a `landAssurance` and is **fail-closed** — no assurance, no ship, thrown before
41
+ any pool use.
42
+
43
+ Gating only `POST /tasks/:id/ship` was the cheaper option and was rejected: it leaves the invariant
44
+ unenforced at the layer that actually moves the ledger, so the next caller added anywhere reopens it
45
+ silently. There are three call sites today; the point is that there is no way to add a fourth that
46
+ ships without accounting for it.
47
+
48
+ ### 2. Two kinds, and the second one is why this doesn't make things worse
49
+
50
+ ```
51
+ proof the caller verified THIS task's own commit is on main
52
+ no_artifact a deliberate DB-only ship, declared and recorded
53
+ ```
54
+
55
+ A no-artifact ship is an **explicitly supported shape** — CLAUDE.md's "Direct DB writes are work too"
56
+ — and task 1002478 documents that it is already a dead end below Archon. A naive land-proof gate would
57
+ have made that strictly worse, which 1002483 warned about in as many words: *solve them together or
58
+ they will fight.*
59
+
60
+ Naming `no_artifact` as a **kind** is the resolution. A DB-only ship stops being an absence the gate
61
+ has to be talked out of and becomes a recorded answer: the builder declares it, says what was
62
+ delivered, and the ledger keeps both. That is a small step toward 1002478 rather than away from it.
63
+
64
+ ### 3. Every assurance carries a reason, and the reason is audited
65
+
66
+ Both kinds require non-empty prose, capped at 500 characters, written to `audit_log` after the
67
+ transaction commits. The ledger should say **why** a ship was allowed, not merely that it happened —
68
+ which is precisely what was missing when this hole went unnoticed.
69
+
70
+ The audit write is post-commit and best-effort by construction (`insertAuditLog` uses the module pool,
71
+ not the transaction's client). An audit miss must never roll back a landed ship — the same posture
72
+ `src/bongos/middleware/audit.js` already takes.
73
+
74
+ ### 4. The CLI declares its own empty ships
75
+
76
+ `scripts/gds/ship.js` POSTs `/ship` with an empty body after a `--db-only` / `--allow-empty` land. Left
77
+ alone, this gate would have refused exactly the flow CLAUDE.md calls supported — turning a correctness
78
+ fix into a regression for every builder shipping DB-only work. The deliberate-empty land sites now mark
79
+ themselves, and the CLI sends the declaration.
80
+
81
+ ## Consequences
82
+
83
+ - **A confirmed-but-unpushed task can no longer be manually shipped or paid.** The refusal names both
84
+ remedies: push the branch and let the reconciler land it, or declare the no-artifact ship.
85
+ - **The hall's task page ships through the same route** and will now receive a `409
86
+ ship_land_not_proven` for an unprovable task. That is the correct behaviour — the UI should not be
87
+ able to phantom-ship either — but its error rendering was not reviewed here and may show the refusal
88
+ poorly. Worth a look.
89
+ - **`shipTask`'s signature changed.** Three production call sites and four test files were updated. Any
90
+ out-of-repo caller breaks loudly rather than shipping blind, which is the intended direction.
91
+ - **A STALE `ship.js` against an updated server cannot complete a DB-only ship.** The CLI is what sends
92
+ the `no_artifact` declaration; an older checkout POSTs an empty body and collects
93
+ `409 ship_land_not_proven` on a task that has no commit to prove. The refusal names exactly what to
94
+ send, but the old CLI will not send it, so the remedy is to update the checkout (or re-send by hand).
95
+ Bounded rather than open-ended — the CLI travels with the core, so on the monolith and on a consumer
96
+ instance the two move together; the skew window is a builder running a stale clone or dev box. Worth
97
+ knowing before the first report, because the symptom ("I cannot ship my DB-only task") does not
98
+ obviously point at a version skew. **Note this affects ONLY no-artifact ships:** a normal ship is
99
+ proven server-side from git and needs nothing from the CLI.
100
+ - **This does not gate CONFIRM.** A codeless confirm is still accepted; what it can no longer do is
101
+ reach `shipped` and book a credit. Gating confirm is a separate question — confirm means "verified",
102
+ not "landed", and conflating them would break the legitimate confirm-then-land sequence.
103
+ - **Rejected — option (c), record the residual as tolerable.** Defensible on blast radius, and it was
104
+ genuinely close. It lost because the residual had already survived one incorrect closure and several
105
+ blocker filings; a decision to accept it would have been the fourth record asserting this was
106
+ handled, with nothing checkable behind it.
@@ -257,5 +257,6 @@ This keeps the decision history honest and traceable.
257
257
  | 0181 | [**Teardown accepts every state but `torn_down`, and deliberately keeps the address** ([task 1003042](https://cloudbongos.com/builders#/task/1003042), goal 1000058). Walk finding N5: an instance at `status='error'` could never be removed — the owner's teardown answered 409 `not_teardownable` and the archon `force-teardown`, which exists as the escape hatch, answered the SAME refusal, because both read one hand-kept whitelist `['provisioning','active','tearing_down']` that omitted `requested` and `error`. No role had a path out, so errored rows accumulated permanently (instance 15 `swag` from 2026-08-07). The whitelist justified itself as "nothing running to tear down", which is FALSE for `error`: a provision failing mid-run lands there AFTER it may have created the database, taken a port, and written the systemd unit, Caddy block and DNS record — a partial standup is the thing most in need of reclaiming. `TEARDOWN_FROM` is now DERIVED from `INSTANCE_STATES` minus `torn_down`, so a future state is teardownable by DEFAULT — the bug was an allow-list that silently omitted states, and for a failure mode whose signature is "a row with no exit", the safe default is that an exit exists. The runner needed no change (its steps are each `allowFail`); only the web guard refused. The one surviving refusal is now ONE shared helper: the copies had drifted, and force-teardown's positional `res.fail` form put the bare code `not_teardownable` on the wire as the human text. **The `port` and `domain` are deliberately NOT released** — releasing the port alone would be WORSE than the status quo, because the `<slug>.caddy` block survives teardown and proxies the hostname to a PORT NUMBER, so a later instance given that port would answer at the torn-down project's still-live address (verified live: two projects torn down 2026-08-13 still resolved on 2026-08-19). Holding the port is what keeps the address inert at a harmless 502; releasing both is [task 1002897](https://cloudbongos.com/builders#/task/1002897). Retention is also correct for revive, which re-uses the same hostname. Copy was corrected to match: it had promised "the address stops answering", which was never true.](0181-teardown-state-gate-and-address-retention.md) | provisioning / lifecycle |
258
258
  | 0182 | [**Project visibility is TWO axes: a derived publish gate and a stored join door** ([task 1002320](https://cloudbongos.com/builders#/task/1002320), goal 1000046). Goal 1000046 seeded 31 tasks each citing "per R01" — this ADR — so nothing in the goal could start until it landed: it gated **29 open tasks**, the largest single chokepoint in the repo, while itself parked at `backlog` with no dependencies. The spec its planning task cites was never written, so this is the design of record. The goal's criterion says **four** states (Public/Private/Stealth/Unlisted) and R04 says **three** stored plus a derived dark-matter; both are right and conflating them is the trap. There are two INDEPENDENT axes: **publishability** (DERIVED from the identity gate — name + description, per the owner interview) decides whether a project is on the map at all, and **the join door** (STORED `visibility ∈ public|private|stealth`, owner-set) decides how it renders and who gets in. Four user-facing states = 3 × publishable + one derived state that collapses all three. The column name `visibility` is a mild lie kept for consistency with `goals.visibility` (ADR 0112) — it does NOT decide visibility, publishability does — and the ADR is the mitigation. All three stored states are VISIBLE; only the derived state is invisible, which is what makes the 3+1 split honest rather than a workaround. `private` inherits ADR 0112's exact meaning (visible-but-closed); `stealth` exposes EXISTENCE but not IDENTITY, and its redaction is **server-side in the feed** — the naive client-side version leaks to anyone opening the network tab, and the orbs page is a static site OUTSIDE this repo (R09) so it could never be enforced there. Three mechanisms touch "does this appear" and are separated: `status` (operational), `visibility` (owner), `featured` (Archon editorial) — on the map iff `status='active' AND publishable`, with `featured` only ranking among those. That is a real behaviour change: today's predicate is `featured=true AND status='active'`, so **featuring is currently load-bearing for visibility at all** and the goal's owner-control premise silently does not work without it. Visibility is owned by the INSTANCE and the hub row is a CACHE (ADR 0141 §3 + the admin-console spike's D1) — so `owner_github_id` being "advisory, not an authority" is CORRECT rather than a gap, R05 moves to an instance surface, and R08's absent-report default is fail-closed (unreported ⇒ dark matter, off the map rather than exposed by omission). Per-state join rules compose with the separate `open|closed` join policy ([task 1002817](https://cloudbongos.com/builders#/task/1002817)) by one rule: **visibility sets the ceiling, join policy may only narrow** — otherwise the two settings are a coin-flip decided by evaluation order. Finally, `stealth` COLLIDES with the existing instance-wide pre-launch password gate (`STEALTH_PASSWORD`, `src/bongos/stealth-gate.js`), which means nearly the opposite (a whole instance made unreadable vs a project publicly on the map, anonymously); per ADR 0174 the product meaning keeps the word and the infra rename is filed separately rather than decided by an ADR about visibility. Rejected: a stored 4-valued enum (makes `unlisted` compete with `private`, and lets publishable+unlisted disagree), a separate `published` boolean (can drift from the identity it summarises; derived cannot), and keeping `featured` as the visibility gate (an owner who sets public and appears nowhere has not been given control).](<redacted>.md) | platform-identity / project visibility |
259
259
  | 0183 | [**A done-when criterion closes itself; the review queue becomes a place to look** ([task 1002069](https://cloudbongos.com/builders#/task/1002069), goal 1000072, owner request 2026-07-05). ADR 0086 §6 made `satisfied` reachable exactly one way — a Metic+ calling `POST /done-when/:id/satisfy` — even though the signal it gated on is unambiguous (**every** gating task shipped) and a human confirming it adds no information. When nobody runs `/goal-review` the result is not a careful pause but a **lying board**: this session measured goal 1000072 at `6/9 shipped, 0/5 criteria`. Now one helper, `doneWhen.autoSatisfyShippedCriteria(exec, {taskId})`, runs from **two** places for two different reasons — inside `shipTask`'s txn (scoped, **immediate**, on the txn client so a rolled-back ship cannot leave a goal achieved) and unscoped on every `publish-reconciler` tick (**backlog drain** for criteria whose tasks shipped before this existed, since nothing will ship against them again, plus **self-heal**). Idempotent by construction — it reads only `satisfied=false` rows — which is what makes an every-tick sweep safe. **The load-bearing subtlety is the delivery guard.** Auto-close needs BOTH "nothing still pending" AND **≥ 1 task actually `shipped`**; the second is not redundant, because since [task 1002651](https://cloudbongos.com/builders#/task/1002651) made `abandoned` terminal-not-pending, a criterion whose tasks were **all cut** satisfies the first clause *vacuously* and would auto-close on ZERO delivered work, cascading its goal to `achieved` (measured: `buildCriterionProgress` returns `pending_review:true` for exactly that shape). Yet `pending_review` **keeps** that wider meaning on purpose — it asks "should a human LOOK at this", and 1002651 chose that an all-abandoned criterion surfaces so someone decides whether it still means anything. So: everything that auto-closes was flagged; not everything flagged auto-closes. An earlier draft "aligned" the two predicates and broke that intent — `tests/criterion_progress.mjs` caught it; **do not re-align them**, the gap is the design. `/goal-review` is kept, demoted from gate to viewing surface; manual `POST /satisfy` stays for what automation cannot judge (a criterion with no linked tasks never auto-closes). Honest limits recorded: auto-satisfy cannot help a criterion nothing is LINKED to, and the in-txn close logs before commit (accepted — threading the summary out through `withTx` would cost ~5 lines in the over-threshold `db.js` under shrink-only ratchet policy). Rejected: sweep-only (up to 5 min to see your own goal close), hook-only (never drains the backlog), a stored `pending_review` column (second source of truth), deleting `/goal-review`.](<redacted>.md) | lifecycle / goal tier |
260
+ | 0184 | [**A ship must account for itself: `shipTask` requires a land proof or a declared no-artifact** ([task 1002483](https://cloudbongos.com/builders#/task/1002483), goal 1000024). `db.shipTask` is the confirmed→shipped flip and, since [ADR 0120](<redacted>.md), where the CREDIT is booked — yet its only precondition was `WHERE status = 'confirmed'`, nothing about whether code reached main. The two AUTOMATED paths each proved the land first; the MANUAL route passed nothing, so a task confirmed with no pushed code could be shipped by its claim holder **or by any Metic+ sweeping `/merge-mode`**, booking its `task.shipped` credit. It stayed untracked for two months because its tracking task was closed in June asserting task 1001547 had delivered the gate — it had not, blocker 1000047's own final update says so, and the code agreed with the blocker rather than the closure note: **a hole hidden by a record nobody checked**, which is the same failure the gate is about. Decision: the gate lives at the WRITE, not the route (a route gate leaves the invariant unenforced at the layer that moves the ledger, so the next caller reopens it silently), and `shipTask` is FAIL-CLOSED on a required `landAssurance`. TWO kinds, and the second is why this does not make things worse: `proof` (this task's commit is on main) and **`no_artifact`** — a deliberate DB-only ship, an explicitly supported shape (CLAUDE.md "Direct DB writes are work too") that [task 1002478](https://cloudbongos.com/builders#/task/1002478) documents as a dead end below Archon. A naive proof-only gate would have made 1002478 strictly worse; naming no-artifact as a KIND turns it from an absence the gate must be talked out of into a recorded answer. Both carry a reason, audited post-commit and best-effort (an audit miss must never roll back a landed ship). `ship.js` now declares its own `--db-only`/`--allow-empty` lands, without which this fix would have regressed the very flow CLAUDE.md calls supported. Rejected: (b) gate only the route — leaves the write unguarded; (c) record the residual as tolerable — defensible on blast radius and genuinely close, but it would have been the FOURTH record asserting this was handled with nothing checkable behind it. Does NOT gate confirm: a codeless confirm is still accepted, it simply cannot reach `shipped` or pay.](<redacted>.md) | lifecycle / integrity |
260
261
 
261
262
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -12938,6 +12938,18 @@
12938
12938
  "description": "Path parameter `id`."
12939
12939
  }
12940
12940
  ],
12941
+ "requestBody": {
12942
+ "required": false,
12943
+ "content": {
12944
+ "application/json": {
12945
+ "schema": {
12946
+ "type": "object",
12947
+ "additionalProperties": true
12948
+ }
12949
+ }
12950
+ },
12951
+ "x-validated": true
12952
+ },
12941
12953
  "responses": {
12942
12954
  "200": {
12943
12955
  "description": "Success.",
@@ -12950,7 +12962,7 @@
12950
12962
  }
12951
12963
  },
12952
12964
  "400": {
12953
- "$ref": "#/components/responses/BadRequest"
12965
+ "$ref": "#/components/responses/ValidationFailed"
12954
12966
  },
12955
12967
  "401": {
12956
12968
  "$ref": "#/components/responses/Unauthorized"
@@ -516,7 +516,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
516
516
  | POST | `/api/bongos/tasks/:id/promote` | `metic+archon` | — | backlog→ready promotion; scope mutation; ADR 0090 (was archon). |
517
517
  | POST | `/api/bongos/tasks/:id/publish-branch` | `any-builder` | `branch`, `head_sha`, `bundle_b64`, `value_summary` | |
518
518
  | GET | `/api/bongos/tasks/:id/publish-status` | `any-builder` | — | GET /tasks/:id/publish-status?branch=<branch> — poll the PR + prod-deploy state for a server-mediated publish so ship.js can confirm the … |
519
- | POST | `/api/bongos/tasks/:id/ship` | `any-builder` | — | confirmed → shipped. |
519
+ | POST | `/api/bongos/tasks/:id/ship` | `any-builder` | validated | confirmed → shipped. |
520
520
  | POST | `/api/bongos/tasks/:id/vote` | `metic+archon` | `vote` | POST /tasks/:id/vote { vote: 1 \| -1 \| 0 } — cast/change/clear a peer vote on a confirmed/shipped task. |
521
521
  | GET | `/api/bongos/tasks/claimable` | `any-builder` | — | NOTE: /tasks/claimable must come BEFORE /tasks/:id or Express will route 'claimable' to the :id param. |
522
522
  | GET | `/api/bongos/tasks/newcomer-floor` | `any-builder` | — | GET /tasks/newcomer-floor — supply gauge for the newcomer queue (task 614). |
@@ -30,6 +30,7 @@
30
30
 
31
31
  const api = require('../../src/module-api');
32
32
  const { pool, withTx, insertAuditLog, LIVE_RANK_LADDER, RANK_TIER } = api;
33
+ const log = api.logger('lifecycle'); // namespaced; the ratchet counts bare console calls
33
34
  // The seam registry (resolveOptional/emit/emitAsync) — the doorway re-exports it,
34
35
  // so the module reaches the `reward`/`onboarding` ports + emits kernel events
35
36
  // (task.shipped, builder.box-reconcile-needed) without importing core.
@@ -2377,22 +2378,6 @@ async function removeDependency(fromKind, fromId, toKind, toId) {
2377
2378
  return rowCount > 0;
2378
2379
  }
2379
2380
 
2380
- // Edges for a node. reverse=false: what (kind,id) depends on; reverse=true: what
2381
- // depends on (kind,id).
2382
-
2383
- async function getDependencies(kind, id, { reverse = false } = {}) {
2384
- const selfKind = reverse ? 'to_kind' : 'from_kind';
2385
- const selfId = reverse ? 'to_id' : 'from_id';
2386
- const { rows } = await pool.query(
2387
- `SELECT from_kind, from_id, to_kind, to_id, created_at
2388
- FROM dependencies
2389
- WHERE ${selfKind} = $1 AND ${selfId} = $2
2390
- ORDER BY from_kind, from_id, to_kind, to_id`,
2391
- [kind, id]
2392
- );
2393
- return rows;
2394
- }
2395
-
2396
2381
  // ---------------------------------------------------------------------------
2397
2382
  // Task ↔ criterion links (task_criteria, migration 055 / ADR 0025; populated on
2398
2383
  // task-create + manage endpoints by task 438). Mirrors the task_dependencies
@@ -3725,7 +3710,11 @@ async function needsRebaseTasksForBuilder(builderId, client = pool) {
3725
3710
  return rows;
3726
3711
  }
3727
3712
 
3728
- async function shipTask({ taskId, builderId }, deps = {}) {
3713
+ // The ship gate (task 1002483, ADR 0184) — see ./ship-assurance for the why.
3714
+ const { normalizeShipAssurance, assertShipAssurance, recordShipAssurance, SHIP_ASSURANCE_KINDS } = require('./ship-assurance');
3715
+
3716
+ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
3717
+ const shipAssuranceValue = assertShipAssurance(landAssurance); // FAIL-CLOSED, ADR 0184
3729
3718
  // #538 test seam (mirrors confirmTask): a caller-supplied pool is honored ONLY
3730
3719
  // under NODE_ENV==='test', so a regression test can drive the txn with a
3731
3720
  // SQL-aware fakeClient. Production callers omit deps → module pool.
@@ -3888,8 +3877,11 @@ async function shipTask({ taskId, builderId }, deps = {}) {
3888
3877
  builderId: actualShipperId, rank: 'thetes', status: rankGraduation.status, actor: 'auto-graduation:#692',
3889
3878
  }).catch(() => {});
3890
3879
  }
3880
+ await recordShipAssurance({ insertAuditLog, taskId, builderId: actualShipperId, assurance: shipAssuranceValue, log });
3891
3881
  return {
3892
3882
  ...shipRow,
3883
+ shipAssurance: shipAssuranceValue, // echoed so a caller can show it, not guess
3884
+
3893
3885
  graduatedTo: rankGraduation ? 'thetes' : null,
3894
3886
  // ADR 0120: the per-task reward that landed on THIS ship (0 on a re-ship /
3895
3887
  // reconciler re-sweep — idempotent). The ship route surfaces it so the
@@ -5700,7 +5692,6 @@ module.exports = {
5700
5692
  findCycle,
5701
5693
  addDependency,
5702
5694
  removeDependency,
5703
- getDependencies,
5704
5695
  unsatisfiedDeps,
5705
5696
  parseCriterionRef,
5706
5697
  resolveCriterionIds,
@@ -5719,6 +5710,7 @@ module.exports = {
5719
5710
  restoreTask, // ADR 0152 — the abandoned→backlog undo that pairs with the Metic+ abandon grant
5720
5711
  applyGrade,
5721
5712
  shipTask,
5713
+ normalizeShipAssurance, SHIP_ASSURANCE_KINDS, // ADR 0184 — re-exported from ./ship-assurance
5722
5714
  flagTaskNeedsRebase, // ADR 0120 part 2 — reconciler flags an unlandable confirmed task for its builder
5723
5715
  needsRebaseTasksForBuilder, // ADR 0120 part 3/5 — the claim gate + hall/CLI read the builder's owed rebases
5724
5716
  listConfirmedTasksForReconcile, // task 1134 — server-owned publish reconciler feed
@@ -705,7 +705,14 @@ async function runSweep(deps = {}) {
705
705
  }
706
706
 
707
707
  try {
708
- const shipped = await db.shipTask({ taskId: r.task_id, builderId: r.builder_id });
708
+ // task 1002483: the sweep only reaches here on a PROVEN land (the `if
709
+ // (!landed) … continue` above), so it hands that proof to the gate rather
710
+ // than letting shipTask re-derive it. landNote carries which probe proved it.
711
+ const shipped = await db.shipTask({
712
+ taskId: r.task_id,
713
+ builderId: r.builder_id,
714
+ landAssurance: { kind: 'proof', reason: landNote || 'reconciler: land proven' },
715
+ });
709
716
  reconciled++;
710
717
  dispositions.push(`${r.task_id}:shipped`);
711
718
  logFn(
@@ -14,6 +14,8 @@ const { buildGoalAdvisory } = require('../goal-advisory');
14
14
  // longer imports the grader — it resolves the `grade` kernel PORT at grade time
15
15
  // (seams.resolveOptional('grade'), in POST /tasks/:id/grade below).
16
16
  const githubPush = require('../github-push');
17
+ const shipAssurance = require('../ship-assurance');
18
+ const RECONCILE_PROOF = Object.freeze({ kind: 'proof', reason: 'publish-status reconcile: land proven' });
17
19
  const seams = api;
18
20
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
19
21
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
@@ -1138,8 +1140,12 @@ module.exports = function buildTasksRouter() {
1138
1140
  // OWN confirmed task. allowSupervisor lets Metic+ (who run /merge-mode) ship
1139
1141
  // across builders; the claim holder ships their own; everyone else is 403'd.
1140
1142
  if (!(await gateTaskOwnership(req, res, id, { allowSupervisor: true }))) return;
1143
+ if (validateOrRespond(req, res, shipAssurance.SHIP_BODY_SCHEMA)) return; // ADR 0184
1141
1144
  try {
1142
- const task = await db.shipTask({ taskId: id, builderId: req.builder.id });
1145
+ // The ship gate (ADR 0184) — this route is the path that had none.
1146
+ const landAssurance = await shipAssurance.gateManualShip(res, { taskId: id, body: req.body || {}, db, githubPush, log: api.logger('lifecycle') });
1147
+ if (!landAssurance) return;
1148
+ const task = await db.shipTask({ taskId: id, builderId: req.builder.id, landAssurance });
1143
1149
  // task 1749: emit task.shipped on THIS entry point too, not just the
1144
1150
  // publish-status reconcile-on-read path (line ~1545) + the reconciler
1145
1151
  // sweep. A direct ship here (e.g. /merge-mode sweeping the confirmed
@@ -1718,7 +1724,7 @@ module.exports = function buildTasksRouter() {
1718
1724
  }
1719
1725
  if (githubPush.landConfirmed(status) && !reconcile_blocked) {
1720
1726
  try {
1721
- const shipped = await db.shipTask({ taskId: id, builderId: req.builder.id });
1727
+ const shipped = await db.shipTask({ taskId: id, builderId: req.builder.id, landAssurance: RECONCILE_PROOF }); // ADR 0184
1722
1728
  reconciled = true;
1723
1729
  // task 1045: the box that published can't post the Discord broadcast —
1724
1730
  // it holds no webhook secret (ADR 0053 "box holds nothing"), so a box
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/ship-assurance.js — WHY a ship was allowed (task 1002483,
4
+ // ADR 0184). A sibling of ship-preflight.js / ship-card.js: pure, DB-free, and
5
+ // deliberately NOT inside db.js, which is already an oversized file the quality
6
+ // ratchet measures.
7
+ //
8
+ // THE HOLE THIS CLOSES. db.shipTask is the confirmed→shipped flip and, since
9
+ // ADR 0120, where the CREDIT is booked. Its only precondition was
10
+ // `WHERE status = 'confirmed'` — nothing about whether any code reached main. The
11
+ // two AUTOMATED paths each proved the land before calling; the MANUAL route
12
+ // passed nothing, so a task confirmed with no pushed code could be flipped to
13
+ // shipped by its claim holder or by any Metic+ sweeping /merge-mode, booking its
14
+ // task.shipped credit.
15
+ //
16
+ // TWO KINDS, and the second is the reason this gate does not make things worse:
17
+ //
18
+ // proof the caller verified THIS task's own commit is on main.
19
+ // no_artifact a deliberate DB-only ship — an explicitly supported shape
20
+ // (CLAUDE.md, "Direct DB writes are work too") that task 1002478
21
+ // documents as a DEAD END below Archon today. Naming it a KIND is
22
+ // what keeps this gate from worsening that: a no-artifact ship
23
+ // becomes a recorded answer, not an absence the gate has to be
24
+ // talked out of.
25
+ //
26
+ // Both carry a `reason`, so the audit row says WHY a ship was allowed rather than
27
+ // merely that it happened — which is exactly what was missing when this hole went
28
+ // unnoticed for two months behind an incorrect closure note.
29
+
30
+ // The write-schema for the manual ship route's declaration fields (ADR 0118
31
+ // requires every write route to validate its body). Declared beside the gate
32
+ // that consumes them.
33
+ const SHIP_BODY_SCHEMA = Object.freeze({
34
+ no_artifact: { type: 'boolean' },
35
+ no_artifact_reason: { type: 'string', maxLength: 500 },
36
+ });
37
+
38
+ const KINDS = Object.freeze(['proof', 'no_artifact']);
39
+ const MAX_REASON_CHARS = 500;
40
+
41
+ // Validate an assurance. Returns { ok, value } | { ok:false, code, allowed }.
42
+ // FAIL-CLOSED by construction: every unhappy shape returns a code, and shipTask
43
+ // throws on any of them, so a caller that cannot say why this is a legitimate
44
+ // ship never reaches the write.
45
+ function normalizeShipAssurance(a) {
46
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
47
+ return { ok: false, code: 'ship_assurance_required' };
48
+ }
49
+ if (!KINDS.includes(a.kind)) {
50
+ return { ok: false, code: 'ship_assurance_bad_kind', allowed: KINDS };
51
+ }
52
+ const reason = String(a.reason == null ? '' : a.reason).replace(/\s+/g, ' ').trim();
53
+ if (!reason) return { ok: false, code: 'ship_assurance_reason_required' };
54
+ return { ok: true, value: { kind: a.kind, reason: reason.slice(0, MAX_REASON_CHARS) } };
55
+ }
56
+
57
+ // Derive the assurance for a MANUAL ship (POST /tasks/:id/ship) — the path that
58
+ // had no gate at all. Returns { assurance } to proceed, or { refusal } the route
59
+ // answers verbatim.
60
+ //
61
+ // Order matters: PROVE first, and only fall back to the declaration. A caller who
62
+ // pushed real work should never have to declare anything, and a declaration must
63
+ // never be able to override an available proof.
64
+ //
65
+ // A probe miss is NOT a licence to ship. It means "no proof yet", which lands on
66
+ // the same refusal — the fail direction is to wait, never to pay.
67
+ //
68
+ // `deps` is injected so this is unit-testable with no network and no DB.
69
+ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeError }) {
70
+ try {
71
+ const publishedSha = await db.getClaimPublishedSha(taskId);
72
+ const status = await githubPush.getPublishStatus({ taskId, publishedSha });
73
+ const proof = await githubPush.landProvenForTask({ taskId, publishedSha, status });
74
+ if (proof && proof.proven) return { assurance: { kind: 'proof', reason: proof.reason } };
75
+ } catch (e) {
76
+ if (onProbeError) onProbeError(e);
77
+ }
78
+
79
+ if (body.no_artifact === true) {
80
+ const reason = String(body.no_artifact_reason || '').trim();
81
+ if (!reason) {
82
+ return {
83
+ refusal: {
84
+ code: 'no_artifact_reason_required',
85
+ status: 400,
86
+ details: {
87
+ what_this_means: 'A no-artifact ship is legitimate, but the ledger records WHY. Say what was delivered (a DB row, a config change, a decision) in no_artifact_reason.',
88
+ },
89
+ },
90
+ };
91
+ }
92
+ return { assurance: { kind: 'no_artifact', reason } };
93
+ }
94
+
95
+ return {
96
+ refusal: {
97
+ code: 'ship_land_not_proven',
98
+ status: 409,
99
+ details: {
100
+ what_this_means: 'This task has no commit provably on main, so shipping it here would book a credit for work nobody can point at.',
101
+ your_options: [
102
+ 'Push the branch and let it land — the reconciler ships it automatically once the merge is provable.',
103
+ 'If this task genuinely has no code artifact, re-send with { "no_artifact": true, "no_artifact_reason": "…" } so the ledger records the declaration.',
104
+ ],
105
+ },
106
+ },
107
+ };
108
+ }
109
+
110
+
111
+ // Record WHY a ship was allowed, not merely that it happened. POST-COMMIT and
112
+ // best-effort by design: insertAuditLog uses the module pool rather than the
113
+ // ship's transaction client, and an audit miss must never roll back a landed ship
114
+ // (the posture src/bongos/middleware/audit.js already takes).
115
+ async function recordShipAssurance({ insertAuditLog, taskId, builderId, assurance, log }) {
116
+ try {
117
+ await insertAuditLog({
118
+ builderId,
119
+ route: `/tasks/${taskId}/ship`,
120
+ method: 'SHIP',
121
+ requestBodyRedacted: { ship_assurance: assurance },
122
+ responseStatus: 200,
123
+ surface: 'lifecycle',
124
+ });
125
+ } catch (e) {
126
+ if (log) log.warn('shipTask: ship-assurance audit write failed (non-blocking):', e && e.message);
127
+ }
128
+ }
129
+
130
+ // Throwing form, so the caller is one line. Returns the normalized value or
131
+ // throws with the named code — shipTask's FAIL-CLOSED precondition.
132
+ function assertShipAssurance(landAssurance) {
133
+ const a = normalizeShipAssurance(landAssurance);
134
+ if (a.ok) return a.value;
135
+ const e = new Error('ship assurance required');
136
+ e.code = a.code;
137
+ e.allowed = a.allowed;
138
+ throw e;
139
+ }
140
+
141
+ // Route form: derive, or answer the refusal on `res` and return null. Keeps the
142
+ // gate a two-line concern in the (oversized) route file.
143
+ async function gateManualShip(res, opts) {
144
+ const { log, taskId } = opts;
145
+ const derived = await deriveShipAssurance({
146
+ ...opts,
147
+ onProbeError: (e) => log && log.warn(`POST /tasks/${taskId}/ship: land probe failed (${e && e.message}) — no proof`),
148
+ });
149
+ if (derived.refusal) {
150
+ res.fail(derived.refusal.code, derived.refusal.status, derived.refusal.details);
151
+ return null;
152
+ }
153
+ return derived.assurance;
154
+ }
155
+
156
+ module.exports = {
157
+ normalizeShipAssurance, deriveShipAssurance, recordShipAssurance,
158
+ assertShipAssurance, gateManualShip, SHIP_BODY_SCHEMA, SHIP_ASSURANCE_KINDS: KINDS, MAX_REASON_CHARS,
159
+ };