@bongos/core 1.19.292 → 1.19.293
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 +35 -20
- package/docs/adr/0184-ship-requires-an-assurance.md +106 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +13 -1
- package/docs/api-reference.md +1 -1
- package/modules/lifecycle/db.js +10 -18
- package/modules/lifecycle/publish-reconciler.js +8 -1
- package/modules/lifecycle/routes/tasks.js +8 -2
- package/modules/lifecycle/ship-assurance.js +159 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship.js +6 -4
- package/src/module-api.js +3 -1
- package/tests/orphaned_claim_ship.mjs +9 -4
- package/tests/pay_on_land.mjs +9 -4
- package/tests/ship_assurance.mjs +188 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.293",
|
|
6
|
+
"core_contract": "1.19.293",
|
|
7
|
+
"source_commit": "d37d65e03910c166c3a3af9827ffa2a2d8f8a204",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-22T16:56:18.512Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 321,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1393,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1736,
|
|
20
|
+
"tree_sha256": "a7ed89984671394923e64db2b93e69efd81ef112f856a58f58009bf4bc0c4b1d",
|
|
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": "
|
|
1410
|
+
"sha256": "0eafe881d241706331548cf8212c5e84f2f42167c2ac5688427820fa394d77ef"
|
|
1406
1411
|
},
|
|
1407
1412
|
{
|
|
1408
1413
|
"path": "docs/api-reference.md",
|
|
1409
1414
|
"mode": "0000644",
|
|
1410
|
-
"sha256": "
|
|
1415
|
+
"sha256": "8fb83f272ef8810df2c517d9514495f44016da2e23cdd67937463f40c0d1605d"
|
|
1411
1416
|
},
|
|
1412
1417
|
{
|
|
1413
1418
|
"path": "docs/api/openapi.json",
|
|
1414
1419
|
"mode": "0000644",
|
|
1415
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
4540
|
+
"sha256": "090cb33345196e3e3d42d1d6b81a591095b1f3c622becfff8e1eed4ce3f0577f"
|
|
4531
4541
|
},
|
|
4532
4542
|
{
|
|
4533
4543
|
"path": "package.json",
|
|
4534
4544
|
"mode": "0000644",
|
|
4535
|
-
"sha256": "
|
|
4545
|
+
"sha256": "78db3e19a56e9aa909f0fa6314315c2cec90439bb3cc82111b974937d05f3fb2"
|
|
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": "
|
|
5745
|
+
"sha256": "73c2e91340171d6a9112e869552be17e0f82aeb80a5c680205ac452d34c3ddc4"
|
|
5736
5746
|
},
|
|
5737
5747
|
{
|
|
5738
5748
|
"path": "scripts/gds/smoke-batch-claim.sh",
|
|
@@ -6177,7 +6187,7 @@
|
|
|
6177
6187
|
{
|
|
6178
6188
|
"path": "src/module-api.js",
|
|
6179
6189
|
"mode": "0000644",
|
|
6180
|
-
"sha256": "
|
|
6190
|
+
"sha256": "d9155f571379dfbb8e352671aad7a062e52027b225fe615d61a18c1452dde5a0"
|
|
6181
6191
|
},
|
|
6182
6192
|
{
|
|
6183
6193
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7867,7 +7877,7 @@
|
|
|
7867
7877
|
{
|
|
7868
7878
|
"path": "tests/orphaned_claim_ship.mjs",
|
|
7869
7879
|
"mode": "0000644",
|
|
7870
|
-
"sha256": "
|
|
7880
|
+
"sha256": "a67dd7e828196929e1fdcdc6cf09255aa583c2013b30632ad8eb847cc6ff5d3b"
|
|
7871
7881
|
},
|
|
7872
7882
|
{
|
|
7873
7883
|
"path": "tests/override_attribution.mjs",
|
|
@@ -7907,7 +7917,7 @@
|
|
|
7907
7917
|
{
|
|
7908
7918
|
"path": "tests/pay_on_land.mjs",
|
|
7909
7919
|
"mode": "0000644",
|
|
7910
|
-
"sha256": "
|
|
7920
|
+
"sha256": "6c623a5abe9aadf4821ad8ca291fb5d7805a7a5bc874bfc3cd7e51e83de289d2"
|
|
7911
7921
|
},
|
|
7912
7922
|
{
|
|
7913
7923
|
"path": "tests/permission_path.mjs",
|
|
@@ -8344,6 +8354,11 @@
|
|
|
8344
8354
|
"mode": "0000644",
|
|
8345
8355
|
"sha256": "57afd052aaf40ac487d53bf6850710e43c79babaeaa1859374fcc5d8200d92c2"
|
|
8346
8356
|
},
|
|
8357
|
+
{
|
|
8358
|
+
"path": "tests/ship_assurance.mjs",
|
|
8359
|
+
"mode": "0000644",
|
|
8360
|
+
"sha256": "6fc882d215ce78f968b25477db8596ea70a28765fd222b8a9ffca331e7f2827c"
|
|
8361
|
+
},
|
|
8347
8362
|
{
|
|
8348
8363
|
"path": "tests/ship_autolinkify.mjs",
|
|
8349
8364
|
"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.
|
package/docs/adr/README.md
CHANGED
|
@@ -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`.)
|
package/docs/api/openapi.json
CHANGED
|
@@ -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/
|
|
12965
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
12954
12966
|
},
|
|
12955
12967
|
"401": {
|
|
12956
12968
|
"$ref": "#/components/responses/Unauthorized"
|
package/docs/api-reference.md
CHANGED
|
@@ -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` |
|
|
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). |
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.293",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.293",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.293",
|
|
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/scripts/gds/ship.js
CHANGED
|
@@ -1193,7 +1193,8 @@ function shouldBailOnFetchFailure(fetchedMain, deployMode, via) {
|
|
|
1193
1193
|
// `n` the command that recovers it.
|
|
1194
1194
|
// Only landBailed makes the card say NOT landed. Purely additive: no bail site
|
|
1195
1195
|
// changes what it DOES, only what it now reports back.
|
|
1196
|
-
|
|
1196
|
+
// ADR 0184: an EMPTY ship has no commit to prove, so it must DECLARE itself.
|
|
1197
|
+
function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
|
|
1197
1198
|
function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
|
|
1198
1199
|
function landBailed(reason, nextStep = null) { return { ok: false, pending: false, reason, nextStep }; }
|
|
1199
1200
|
|
|
@@ -2534,7 +2535,7 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
2534
2535
|
if (dbOnly && shellOk('git fetch origin main')
|
|
2535
2536
|
&& zeroCommitVerdict(gitOk('git rev-list origin/main..HEAD --count'), true) === 'db-only') {
|
|
2536
2537
|
console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) from the main checkout - nothing to merge; advancing to shipped without merge/deploy.');
|
|
2537
|
-
return landed();
|
|
2538
|
+
return landed({ noArtifact: true, noArtifactReason: 'empty ship from the main checkout — no commits to merge' });
|
|
2538
2539
|
}
|
|
2539
2540
|
console.log(` auto-merge: skipped (current branch is ${branch})`);
|
|
2540
2541
|
// The merge source IS the current branch, so on main there is nothing to
|
|
@@ -2599,7 +2600,7 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
2599
2600
|
}
|
|
2600
2601
|
if (verdict === 'db-only') {
|
|
2601
2602
|
console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) - no commits ahead of main; advancing to shipped without merge/deploy.');
|
|
2602
|
-
return landed();
|
|
2603
|
+
return landed({ noArtifact: true, noArtifactReason: 'empty ship — no commits ahead of main' });
|
|
2603
2604
|
}
|
|
2604
2605
|
|
|
2605
2606
|
// #679 / ADR 0042: in 'ci' deploy mode the operator's machine never merges or
|
|
@@ -3236,7 +3237,8 @@ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOn
|
|
|
3236
3237
|
// task that never landed.
|
|
3237
3238
|
if (!merged.ok) return merged;
|
|
3238
3239
|
|
|
3239
|
-
const
|
|
3240
|
+
const shipBody = merged.noArtifact ? { no_artifact: true, no_artifact_reason: merged.noArtifactReason || 'deliberate empty ship (--db-only)' } : {}; // ADR 0184
|
|
3241
|
+
const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: shipBody });
|
|
3240
3242
|
if (!ship.ok) {
|
|
3241
3243
|
// task 1049 / 1002809: the server may have already flipped this task to
|
|
3242
3244
|
// shipped (reconcile-on-read, or the reconciler racing this call). Verify
|
package/src/module-api.js
CHANGED
|
@@ -1011,8 +1011,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1011
1011
|
// landed since 1.19.290 with no explicit bump. run 32580353000. (task 1002620)
|
|
1012
1012
|
// 1.19.292 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1013
1013
|
// landed since 1.19.291 with no explicit bump. run 32582800234. (task 1002620)
|
|
1014
|
+
// 1.19.293 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1015
|
+
// landed since 1.19.292 with no explicit bump. run 32586174253. (task 1002620)
|
|
1014
1016
|
// ---------------------------------------------------------------------------
|
|
1015
|
-
const CORE_VERSION = '1.19.
|
|
1017
|
+
const CORE_VERSION = '1.19.293'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1016
1018
|
|
|
1017
1019
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1018
1020
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -79,9 +79,14 @@ const idxOf = (queries, re) => queries.findIndex((q) => re.test(String(q.sql).re
|
|
|
79
79
|
const CLAIMS_CLOSE = /^UPDATE claims SET released_at = now\(\), outcome = COALESCE\(outcome, 'shipped'\) WHERE task_id = \$1 AND released_at IS NULL/;
|
|
80
80
|
const SHIP_TASK = /^UPDATE tasks SET status = 'shipped'/;
|
|
81
81
|
|
|
82
|
+
// task 1002483: shipTask now requires a landAssurance and is fail-closed.
|
|
83
|
+
// These tests exercise what happens AFTER the gate, so they pass the same
|
|
84
|
+
// 'proof' shape the reconciler and reconcile-on-read do.
|
|
85
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
86
|
+
|
|
82
87
|
await test('ship heals a strand: open claim on the shipped task is closed', async () => {
|
|
83
88
|
const pool = fakePool({ confirmed: true, orphanClaimIds: [1292] });
|
|
84
|
-
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool });
|
|
89
|
+
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool });
|
|
85
90
|
|
|
86
91
|
// the ship itself succeeded and the shipRow is returned
|
|
87
92
|
assert.equal(result.id, TASK_ID);
|
|
@@ -95,7 +100,7 @@ await test('ship heals a strand: open claim on the shipped task is closed', asyn
|
|
|
95
100
|
|
|
96
101
|
await test('claims-close runs in-txn: after BEGIN + the ship UPDATE, before COMMIT', async () => {
|
|
97
102
|
const pool = fakePool({ confirmed: true, orphanClaimIds: [1292] });
|
|
98
|
-
await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool });
|
|
103
|
+
await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool });
|
|
99
104
|
const q = pool._client.queries;
|
|
100
105
|
|
|
101
106
|
const iBegin = idxOf(q, /^BEGIN$/);
|
|
@@ -113,7 +118,7 @@ await test('normal ship is a no-op safety net: UPDATE runs, nothing to close', a
|
|
|
113
118
|
// resolveClaim already closed the claim on the happy path — the UPDATE matches
|
|
114
119
|
// zero rows. The ship must still succeed and still issue the (harmless) UPDATE.
|
|
115
120
|
const pool = fakePool({ confirmed: true, orphanClaimIds: [] });
|
|
116
|
-
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool });
|
|
121
|
+
const result = await shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool });
|
|
117
122
|
|
|
118
123
|
assert.equal(result.status, 'shipped');
|
|
119
124
|
const close = pool._client.queries.find((q) => CLAIMS_CLOSE.test(String(q.sql).replace(/\s+/g, ' ').trim()));
|
|
@@ -123,7 +128,7 @@ await test('normal ship is a no-op safety net: UPDATE runs, nothing to close', a
|
|
|
123
128
|
await test('rejected ship (not confirmed) never closes claims, and rolls back', async () => {
|
|
124
129
|
const pool = fakePool({ confirmed: false });
|
|
125
130
|
await assert.rejects(
|
|
126
|
-
() => shipTask({ taskId: TASK_ID, builderId: BUILDER_ID }, { pool }),
|
|
131
|
+
() => shipTask({ taskId: TASK_ID, builderId: BUILDER_ID, landAssurance: LAND_PROOF }, { pool }),
|
|
127
132
|
(e) => e.code === 'TASK_NOT_CONFIRMED',
|
|
128
133
|
);
|
|
129
134
|
const q = pool._client.queries;
|
package/tests/pay_on_land.mjs
CHANGED
|
@@ -82,12 +82,17 @@ const RECONCILER = 999; // the builder_id the reconciler passes to shipTask
|
|
|
82
82
|
const WORKER = 25; // the claim holder who actually did the work
|
|
83
83
|
const TASK = { id: 660, title: 'fixture task' };
|
|
84
84
|
|
|
85
|
+
// task 1002483: shipTask now requires a landAssurance and is fail-closed.
|
|
86
|
+
// These tests exercise what happens AFTER the gate, so they pass the same
|
|
87
|
+
// 'proof' shape the reconciler and reconcile-on-read do.
|
|
88
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
89
|
+
|
|
85
90
|
// =========================================================================
|
|
86
91
|
await test('ship: the per-task credit lands at SHIP (reason=task.shipped), attributed to the claim holder', async () => {
|
|
87
92
|
// shipped_by was set to the claim holder at confirm; the reconciler triggers
|
|
88
93
|
// the ship with its own builderId — attribution must still be the worker.
|
|
89
94
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, creditsReward: 15, kind: 'feature' });
|
|
90
|
-
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool });
|
|
95
|
+
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
91
96
|
|
|
92
97
|
assert.equal(out.status, 'shipped', 'task flips to shipped');
|
|
93
98
|
assert.equal(out.creditedBuilderId, WORKER, 'credit attributed to the claim holder, not the reconciler');
|
|
@@ -103,14 +108,14 @@ await test('ship: the per-task credit lands at SHIP (reason=task.shipped), attri
|
|
|
103
108
|
|
|
104
109
|
await test('ship: idempotent — a prior task.shipped credit means no second INSERT', async () => {
|
|
105
110
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, priorShipCredit: true });
|
|
106
|
-
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool });
|
|
111
|
+
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
107
112
|
assert.equal(out.creditsAwarded, 0, 'no fresh credit on a re-ship / reconciler re-sweep');
|
|
108
113
|
assert.equal(all(pool._client.queries, /INSERT INTO credit_log/).length, 0, 'must not double-insert credit');
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
await test('ship: credits_reward = 0 → no credit_log INSERT', async () => {
|
|
112
117
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, creditsReward: 0 });
|
|
113
|
-
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool });
|
|
118
|
+
const out = await shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
114
119
|
assert.equal(out.creditsAwarded, 0, 'a zero-reward task pays nothing');
|
|
115
120
|
assert.equal(all(pool._client.queries, /INSERT INTO credit_log/).length, 0, 'no INSERT for a 0-reward task');
|
|
116
121
|
});
|
|
@@ -118,7 +123,7 @@ await test('ship: credits_reward = 0 → no credit_log INSERT', async () => {
|
|
|
118
123
|
await test('ship: not in confirmed state → throws TASK_NOT_CONFIRMED, awards nothing', async () => {
|
|
119
124
|
const pool = fakePool({ claimHolderId: WORKER, shippedBy: WORKER, confirmed: false });
|
|
120
125
|
await assert.rejects(
|
|
121
|
-
() => shipTask({ taskId: TASK.id, builderId: RECONCILER }, { pool }),
|
|
126
|
+
() => shipTask({ taskId: TASK.id, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool }),
|
|
122
127
|
(err) => err.code === 'TASK_NOT_CONFIRMED',
|
|
123
128
|
'must throw TASK_NOT_CONFIRMED when the UPDATE matches no confirmed row'
|
|
124
129
|
);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// tests/ship_assurance.mjs — the ship gate (task 1002483, goal 1000024).
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE. db.shipTask is where the ledger says work is delivered AND where the
|
|
4
|
+
// credit is booked (ADR 0120 pays on land). Its only precondition was
|
|
5
|
+
// `WHERE status = 'confirmed'` — nothing about whether any code reached main. The
|
|
6
|
+
// two AUTOMATED paths each proved the land before calling, but the MANUAL route
|
|
7
|
+
// (POST /tasks/:id/ship) called it with nothing. So a task confirmed with no
|
|
8
|
+
// pushed code could be flipped to shipped by its claim holder, or by any Metic+
|
|
9
|
+
// sweeping /merge-mode, and it booked its task.shipped credit.
|
|
10
|
+
//
|
|
11
|
+
// It went untracked for two months because its tracking task (1000900) was closed
|
|
12
|
+
// in June on a rationale asserting task 1001547 had delivered the confirm gate.
|
|
13
|
+
// It had not — blocker 1000047's own final update says so, and the code agreed
|
|
14
|
+
// with the blocker, not the closure note. That is why these tests assert against
|
|
15
|
+
// the CODE and not against anyone's account of it.
|
|
16
|
+
//
|
|
17
|
+
// THE GATE. shipTask now requires a `landAssurance` and is FAIL-CLOSED: no
|
|
18
|
+
// assurance, no ship. Two legitimate kinds:
|
|
19
|
+
// proof the caller verified this task's own commit is on main
|
|
20
|
+
// no_artifact a deliberate DB-only ship — an explicitly supported shape
|
|
21
|
+
// (CLAUDE.md, "Direct DB writes are work too") that task 1002478
|
|
22
|
+
// documents as a dead end below Archon. Naming it as a KIND is
|
|
23
|
+
// what stops this gate from making 1002478 worse: a no-artifact
|
|
24
|
+
// ship becomes a recorded answer rather than an absence.
|
|
25
|
+
//
|
|
26
|
+
// DB-free: the validator is pure, and the fail-closed refusal happens before any
|
|
27
|
+
// pool use, so shipTask can be called with no database at all.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/ship_assurance.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import url from 'node:url';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
39
|
+
const db = require(path.join(ROOT, 'modules/lifecycle/db.js'));
|
|
40
|
+
const { normalizeShipAssurance, SHIP_ASSURANCE_KINDS } = db;
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
async function t(name, fn) {
|
|
45
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
46
|
+
catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('the ship assurance — what counts as a shippable reason');
|
|
50
|
+
|
|
51
|
+
await t('a missing assurance is refused', () => {
|
|
52
|
+
for (const bad of [undefined, null, '', 0, false, [], 'proof']) {
|
|
53
|
+
const r = normalizeShipAssurance(bad);
|
|
54
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must not pass`);
|
|
55
|
+
assert.equal(r.code, 'ship_assurance_required');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t('an unknown kind is refused, and the refusal lists what is allowed', () => {
|
|
60
|
+
const r = normalizeShipAssurance({ kind: 'trust_me', reason: 'it landed, honest' });
|
|
61
|
+
assert.equal(r.ok, false);
|
|
62
|
+
assert.equal(r.code, 'ship_assurance_bad_kind');
|
|
63
|
+
assert.deepEqual(r.allowed, SHIP_ASSURANCE_KINDS);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await t('a reasonless assurance is refused — the ledger records WHY, not just that', () => {
|
|
67
|
+
for (const reason of [undefined, null, '', ' ', '\n\t ']) {
|
|
68
|
+
const r = normalizeShipAssurance({ kind: 'proof', reason });
|
|
69
|
+
assert.equal(r.ok, false, `${JSON.stringify(reason)} is not a reason`);
|
|
70
|
+
assert.equal(r.code, 'ship_assurance_reason_required');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t('both legitimate kinds pass, and the reason is tidied', () => {
|
|
75
|
+
const p = normalizeShipAssurance({ kind: 'proof', reason: ' published tip abc1234 in main \n' });
|
|
76
|
+
assert.equal(p.ok, true);
|
|
77
|
+
assert.deepEqual(p.value, { kind: 'proof', reason: 'published tip abc1234 in main' });
|
|
78
|
+
|
|
79
|
+
const n = normalizeShipAssurance({ kind: 'no_artifact', reason: 'DB-only: widened a goal scope wall' });
|
|
80
|
+
assert.equal(n.ok, true);
|
|
81
|
+
assert.equal(n.value.kind, 'no_artifact');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t('a runaway reason is capped rather than refused', () => {
|
|
85
|
+
const r = normalizeShipAssurance({ kind: 'proof', reason: 'x'.repeat(5000) });
|
|
86
|
+
assert.equal(r.ok, true, 'a long reason is still a reason — cap it, do not lose the ship');
|
|
87
|
+
assert.equal(r.value.reason.length, 500);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
console.log('\nshipTask is FAIL-CLOSED, before it touches a database');
|
|
91
|
+
|
|
92
|
+
await t('shipTask with NO assurance throws ship_assurance_required', async () => {
|
|
93
|
+
// No DB is configured here on purpose: the gate must refuse before any pool
|
|
94
|
+
// use, so a caller that cannot account for the ship never even opens a txn.
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
() => db.shipTask({ taskId: 1, builderId: 1 }),
|
|
97
|
+
(e) => e.code === 'ship_assurance_required',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await t('shipTask with a MALFORMED assurance throws too — not a silent default', async () => {
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
() => db.shipTask({ taskId: 1, builderId: 1, landAssurance: { kind: 'proof' } }),
|
|
104
|
+
(e) => e.code === 'ship_assurance_reason_required',
|
|
105
|
+
);
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
() => db.shipTask({ taskId: 1, builderId: 1, landAssurance: { kind: 'nope', reason: 'x'.repeat(20) } }),
|
|
108
|
+
(e) => e.code === 'ship_assurance_bad_kind',
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log('\nevery call site accounts for its ship');
|
|
113
|
+
|
|
114
|
+
// Source tripwires. The behavioural tests above prove the gate refuses; these
|
|
115
|
+
// prove nobody routes AROUND it. A new caller that forgets the assurance would
|
|
116
|
+
// fail at runtime — but only on the path someone exercises, which for a ship path
|
|
117
|
+
// can be weeks later and in production.
|
|
118
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
119
|
+
|
|
120
|
+
await t('all three production call sites pass a landAssurance', () => {
|
|
121
|
+
const sites = [
|
|
122
|
+
['modules/lifecycle/publish-reconciler.js', 'the reconciler sweep'],
|
|
123
|
+
['modules/lifecycle/routes/tasks.js', 'the manual ship route + reconcile-on-read'],
|
|
124
|
+
];
|
|
125
|
+
let found = 0;
|
|
126
|
+
for (const [rel, label] of sites) {
|
|
127
|
+
const src = read(rel);
|
|
128
|
+
for (const m of src.matchAll(/db\.shipTask\(\{([\s\S]{0,300}?)\}\)/g)) {
|
|
129
|
+
found++;
|
|
130
|
+
assert.match(m[1], /landAssurance/,
|
|
131
|
+
`${label} (${rel}) calls db.shipTask without a landAssurance — that is the hole reopening`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
assert.equal(found, 3, `expected 3 production shipTask call sites, found ${found}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await t('the manual ship path PROVES the land, and refuses when it cannot', () => {
|
|
138
|
+
// The derivation lives in ship-assurance.js (a sibling of ship-preflight), not
|
|
139
|
+
// in the already-oversized route file — so the assertions follow it there.
|
|
140
|
+
const src = read('modules/lifecycle/ship-assurance.js');
|
|
141
|
+
assert.match(src, /landProvenForTask/,
|
|
142
|
+
'the manual path must DERIVE a proof — it is the path that had none');
|
|
143
|
+
assert.match(src, /ship_land_not_proven/,
|
|
144
|
+
'and it must REFUSE when there is no proof and no declaration');
|
|
145
|
+
assert.match(src, /no_artifact/,
|
|
146
|
+
'with the deliberate no-artifact declaration as the only other way through');
|
|
147
|
+
|
|
148
|
+
// ORDER: prove first, declaration second. A declaration must never be able to
|
|
149
|
+
// override a proof that was available, and someone who pushed real work should
|
|
150
|
+
// never have to declare anything.
|
|
151
|
+
assert.ok(src.indexOf('landProvenForTask') < src.indexOf('body.no_artifact === true'),
|
|
152
|
+
'the proof must be attempted BEFORE the declaration is consulted');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('the route wires the derivation and answers its refusal verbatim', () => {
|
|
156
|
+
const src = read('modules/lifecycle/routes/tasks.js');
|
|
157
|
+
const i = src.indexOf("router.post('/tasks/:id/ship'");
|
|
158
|
+
assert.ok(i > -1);
|
|
159
|
+
const handler = src.slice(i, src.indexOf('router.', i + 20));
|
|
160
|
+
assert.match(handler, /gateManualShip/, 'the route must call the gate');
|
|
161
|
+
assert.match(handler, /if \(!landAssurance\) return;/,
|
|
162
|
+
'and must BAIL when the gate refused — the refusal is already on the wire');
|
|
163
|
+
// The assurance passed to the write must be the DERIVED value, never an inline
|
|
164
|
+
// literal — `{ kind: 'proof', reason: 'assumed' }` would satisfy the gate's shape
|
|
165
|
+
// while asserting a land nobody checked. (Found by injecting exactly that and
|
|
166
|
+
// watching an earlier draft of this test stay green.)
|
|
167
|
+
assert.match(handler, /db\.shipTask\(\{[^}]*landAssurance\s*\}\)/,
|
|
168
|
+
'the route must pass the DERIVED landAssurance variable, not an inline literal');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('the no-artifact declaration cannot be made silently', () => {
|
|
172
|
+
const src = read('modules/lifecycle/ship-assurance.js');
|
|
173
|
+
assert.match(src, /no_artifact_reason_required/,
|
|
174
|
+
'declaring "no artifact" without saying what was delivered must be refused');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await t('the CLI carries the declaration through its deliberate-empty path', () => {
|
|
178
|
+
// Without this, the gate would refuse exactly the --db-only / --allow-empty
|
|
179
|
+
// flow CLAUDE.md calls a supported shape — turning a correctness fix into a
|
|
180
|
+
// regression for the builders who rely on it.
|
|
181
|
+
const src = read('scripts/gds/ship.js');
|
|
182
|
+
assert.match(src, /no_artifact:\s*true/, 'ship.js must send the declaration on an empty land');
|
|
183
|
+
assert.match(src, /landed\(\{ noArtifact: true/,
|
|
184
|
+
'and the deliberate-empty land sites must mark themselves as such');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
188
|
+
process.exit(failed ? 1 : 0);
|