@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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.292",
3
+ "version": "1.19.294",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.292",
9
+ "version": "1.19.294",
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.292",
3
+ "version": "1.19.294",
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",
@@ -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
- function landed() { return { ok: true, pending: false, reason: null, nextStep: null }; }
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 ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: {} });
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
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/strand-watch.js — say out loud that a ship got stuck (task 1003163).
5
+ //
6
+ // THE GAP. Nothing watched for a stranded ship. On 2026-08-22 a manual status sweep found
7
+ // task 1002483 parked at `completed` for THREE DAYS carrying 651 lines of tested work on a
8
+ // local branch, task 1002792 parked two days behind it, and task 1002459 sitting at
9
+ // `confirmed` for FOUR DAYS with no artifact anywhere. Not one of the three had produced a
10
+ // single signal; all were found by accident.
11
+ //
12
+ // WHY land-watch.js IS NOT THIS (it looks like it should be). land-watch.js renders the
13
+ // SHIPPED banner after an async CI land (task 1687). It cannot detect a strand: it reads
14
+ // machine-local breadcrumbs that ship.js writes only at CONFIRMED-exit — so a task parked at
15
+ // `completed` never gets one — it DROPS a breadcrumb after 30 minutes by design, and it is
16
+ // per-machine, so a strand on a box nobody reopens is invisible forever.
17
+ //
18
+ // THE SIGNAL ALREADY EXISTS. This is the part worth knowing: the publish-reconciler ALREADY
19
+ // diagnoses the worst case and already writes it down. Task 1002459 has carried
20
+ // `needs_rebase_reason = 'strand:land_not_proven'` since 2026-08-18 — the reconciler probed
21
+ // it, could not prove a land, gave up, and recorded exactly that. Nobody ever read the field.
22
+ // So this module is not a detector. It is the READOUT of a diagnosis the system performs and
23
+ // then discards, which is why it is small.
24
+ //
25
+ // SHAPE COPIED FROM deploy-divergence.js, deliberately. That check was written after the
26
+ // ten-day staleness incident (blocker 1000097 / task 1002521), is wired into SessionStart in
27
+ // .claude/settings.json, and on 2026-08-22 it reported the stale pin in the first message of
28
+ // the session — which is how that whole day's work started. A cheap check, on a path that
29
+ // already runs, reporting loudly, beats a monitoring subsystem that can itself silently stop.
30
+ // So: fail-open, bounded, and SILENT when the board is clean.
31
+ //
32
+ // Usage:
33
+ // node scripts/gds/strand-watch.js # human-readable report
34
+ // node scripts/gds/strand-watch.js --line # ONE line, or nothing; always exit 0
35
+ // node scripts/gds/strand-watch.js --json # machine shape
36
+ // node scripts/gds/strand-watch.js --hours N # override the `completed` threshold
37
+ //
38
+ // Exit codes: 0 always on --line (fail-open). Otherwise 0 clean, 1 strands found, 2 undetermined.
39
+
40
+ const { apiCall, arg, hasFlag } = require('./cli-lib');
41
+
42
+ // ---- thresholds -------------------------------------------------------------
43
+ // Reasoned, not magic. Written down because a future reader will want to retune them.
44
+ //
45
+ // `confirmed` means the grade PASSED and only the land is outstanding. The publish-reconciler
46
+ // sweeps every ~5 minutes, so one hour is twelve missed sweeps — already anomalous, while
47
+ // still leaving room for a slow CI queue or a transient GitHub failure.
48
+ const CONFIRMED_STALE_MS = 60 * 60 * 1000;
49
+ // `completed` means the grade did NOT pass. A builder may legitimately be mid-recovery for
50
+ // hours (see the /grade-recover loop), so this is deliberately generous: 12 hours means the
51
+ // strand survived a night, which no active recovery does. The three real cases were 2–3 DAYS.
52
+ const COMPLETED_STALE_MS = 12 * 60 * 60 * 1000;
53
+
54
+ // A reconciler-recorded strand needs NO threshold. The reconciler already probed every branch
55
+ // convention, failed to prove a land, and wrote the reason down; waiting longer cannot change
56
+ // that verdict, and task 1002459 proves the alternative is sitting for days.
57
+ const STRAND_REASON_PREFIX = 'strand:';
58
+
59
+ const WATCHED = ['completed', 'confirmed'];
60
+ const DEFAULT_TIMEOUT_MS = 8000;
61
+ // Page size per watched status. A queue past this would silently lose the OLDEST strands —
62
+ // exactly the class of quiet cap this whole check exists to abolish — so a full page is
63
+ // reported as `truncated` rather than passed off as complete. 200 is far above any healthy
64
+ // board (the real numbers were 1 and 2).
65
+ const PAGE_LIMIT = 200;
66
+
67
+ // ---- classification (pure — this is the unit-testable core) -----------------
68
+
69
+ // Task ids are numeric and reconciler reasons come from a fixed vocabulary, so both are safe
70
+ // to print. TITLES ARE NOT, and that omission is deliberate — see the trust-boundary note on
71
+ // `line()` below.
72
+ const safeId = (v) => (/^\d{1,19}$/.test(String(v)) ? String(v) : null);
73
+ const safeReason = (v) => (typeof v === 'string' && /^[a-z0-9_:.-]{1,64}$/i.test(v) ? v : null);
74
+
75
+ function humanAge(ms) {
76
+ if (!Number.isFinite(ms) || ms < 0) return 'unknown';
77
+ const h = ms / 3600000;
78
+ if (h < 1) return `${Math.max(1, Math.round(ms / 60000))}m`;
79
+ if (h < 48) return `${Math.round(h)}h`;
80
+ return `${Math.round(h / 24)}d`;
81
+ }
82
+
83
+ // Decide whether ONE task is a strand, and if so which shape and what fixes it. The two
84
+ // shapes need opposite responses, which is the whole reason to distinguish them:
85
+ // completed -> the GRADE parked it; re-grade or fix findings (/grade-recover).
86
+ // confirmed -> the grade passed and the LAND is outstanding (/merge-mode is the fallback).
87
+ // A reconciler-recorded strand is called out separately because it is terminal without a
88
+ // human: the automated healers have already refused it.
89
+ function classifyStrand(task, { nowMs, confirmedStaleMs = CONFIRMED_STALE_MS, completedStaleMs = COMPLETED_STALE_MS } = {}) {
90
+ const t = task || {};
91
+ const status = String(t.status || '');
92
+ if (!WATCHED.includes(status)) return null;
93
+
94
+ const id = safeId(t.id);
95
+ if (!id) return null;
96
+
97
+ const updated = Date.parse(t.updated_at || '');
98
+ const ageMs = Number.isFinite(updated) && Number.isFinite(nowMs) ? nowMs - updated : NaN;
99
+
100
+ const reason = safeReason(t.needs_rebase_reason);
101
+ const reconcilerGaveUp = status === 'confirmed'
102
+ && t.needs_rebase === true
103
+ && !!reason
104
+ && reason.toLowerCase().startsWith(STRAND_REASON_PREFIX);
105
+
106
+ if (reconcilerGaveUp) {
107
+ return {
108
+ id, status, ageMs, age: humanAge(ageMs), shape: 'unprovable', reason,
109
+ remedy: 'check whether any artifact exists at all — a confirm with no branch and nothing on main was codeless',
110
+ };
111
+ }
112
+
113
+ const limit = status === 'confirmed' ? confirmedStaleMs : completedStaleMs;
114
+ // An unreadable updated_at must not manufacture a strand — fail QUIET on bad data here,
115
+ // because this text is injected into every session and a false alarm trains people to
116
+ // scroll past the real one.
117
+ if (!Number.isFinite(ageMs) || ageMs <= limit) return null;
118
+
119
+ return status === 'confirmed'
120
+ ? { id, status, ageMs, age: humanAge(ageMs), shape: 'unlanded', reason: null,
121
+ remedy: '/merge-mode is the documented manual fallback' }
122
+ : { id, status, ageMs, age: humanAge(ageMs), shape: 'ungraded', reason: null,
123
+ remedy: '/grade-recover diagnoses which of the three shapes parked it' };
124
+ }
125
+
126
+ // Sort worst-first so a truncated line shows the most urgent: reconciler-refused, then oldest.
127
+ const SHAPE_RANK = { unprovable: 0, unlanded: 1, ungraded: 2 };
128
+ function rankStrands(strands) {
129
+ return strands.slice().sort((a, b) => {
130
+ const d = (SHAPE_RANK[a.shape] ?? 9) - (SHAPE_RANK[b.shape] ?? 9);
131
+ return d !== 0 ? d : (b.ageMs || 0) - (a.ageMs || 0);
132
+ });
133
+ }
134
+
135
+ // ---- fetch ------------------------------------------------------------------
136
+
137
+ async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confirmedStaleMs, completedStaleMs, call = apiCall } = {}) {
138
+ // CONCURRENT, not sequential. This runs on the SessionStart path beside deploy-divergence,
139
+ // so a serial loop would double the latency the builder waits through for no reason.
140
+ const withTimeout = (p, label) => Promise.race([
141
+ p,
142
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
143
+ ]).catch((e) => { throw new Error(`could not read ${label} tasks: ${e.message || e}`); });
144
+
145
+ let pages;
146
+ try {
147
+ pages = await Promise.all(WATCHED.map(async (status) => {
148
+ const res = await withTimeout(call('GET', `/api/gds/tasks?status=${status}&limit=${PAGE_LIMIT}`), status);
149
+ const body = res && (res.data || res);
150
+ const tasks = body && Array.isArray(body.tasks) ? body.tasks : null;
151
+ if (!tasks) throw new Error(`unexpected shape from /tasks?status=${status}`);
152
+ return { status, tasks };
153
+ }));
154
+ } catch (e) {
155
+ return { undetermined: true, reason: e.message || String(e), strands: [], truncated: [] };
156
+ }
157
+
158
+ const strands = [];
159
+ const truncated = [];
160
+ for (const { status, tasks } of pages) {
161
+ if (tasks.length >= PAGE_LIMIT) truncated.push(status);
162
+ for (const t of tasks) {
163
+ const s = classifyStrand(t, { nowMs, confirmedStaleMs, completedStaleMs });
164
+ if (s) strands.push(s);
165
+ }
166
+ }
167
+ return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated };
168
+ }
169
+
170
+ // ---- rendering --------------------------------------------------------------
171
+
172
+ // ONE line for SessionStart, or null. Null IS the silence: a clean board must say nothing,
173
+ // or the line becomes wallpaper and the real warning gets scrolled past.
174
+ //
175
+ // TRUST BOUNDARY — why no task titles. A task title is builder-authored free text, and this
176
+ // string is injected into an agent's context at the start of every session. Interpolating a
177
+ // title raw would hand anyone who can file a task a standing prompt-injection channel, which
178
+ // is the same reasoning deploy-divergence.js applies to the /version response it reads (it
179
+ // accepts only a strict semver for exactly this purpose). Ids and a fixed reason vocabulary
180
+ // carry all the signal that is needed and none of the risk — the agent can GET the task.
181
+ function line(r, { max = 3 } = {}) {
182
+ if (!r || r.undetermined) return null;
183
+ const trunc = Array.isArray(r.truncated) && r.truncated.length
184
+ ? ` (the ${r.truncated.join(' and ')} queue${r.truncated.length > 1 ? 's' : ''} filled a full ${PAGE_LIMIT}-row page, so older strands may be unlisted)`
185
+ : '';
186
+ if (!r.strands.length) {
187
+ // A full page with no strand still means coverage was capped — say so rather than imply
188
+ // a clean board, which is the silent-cap failure this check is against.
189
+ return trunc ? `Strand check could not see the whole board${trunc}. Run: node scripts/gds/strand-watch.js` : null;
190
+ }
191
+ const shown = r.strands.slice(0, max)
192
+ .map((s) => `task ${s.id} (${s.status}, ${s.age}${s.reason ? `, ${s.reason}` : ''})`);
193
+ const more = (r.strands.length > shown.length ? ` +${r.strands.length - shown.length} more` : '') + trunc;
194
+ const worst = r.strands[0];
195
+ // Lead with what the WORST shape actually means. "work is finished but not landed" is true
196
+ // of a confirmed strand and false of a completed one (the grade parked that, so the work may
197
+ // not be finished at all) — one generic sentence for both would be wrong half the time.
198
+ const lead = {
199
+ unprovable: 'the reconciler already probed this and gave up, so it will never self-heal',
200
+ unlanded: 'the grade passed and only the land is outstanding',
201
+ ungraded: 'the grade parked this; it is not waiting on a land',
202
+ }[worst.shape] || 'parked mid-pipeline';
203
+ return `Stranded ship${r.strands.length === 1 ? '' : 's'}: ${shown.join('; ')}${more}. `
204
+ + `Worst: ${lead} — ${worst.remedy}. `
205
+ + 'A strand produces no signal of its own; this line is the only one.';
206
+ }
207
+
208
+ function report(r) {
209
+ if (!r) { console.error('strand-watch: no result'); return; }
210
+ if (r.undetermined) { console.error(`strand-watch: undetermined — ${r.reason}`); return; }
211
+ console.log('');
212
+ console.log(' Stranded ships');
213
+ console.log(' ──────────────────────────────────────────────────────────');
214
+ if (Array.isArray(r.truncated) && r.truncated.length) {
215
+ console.log(` ! coverage capped: the ${r.truncated.join(' and ')} queue(s) returned a full ${PAGE_LIMIT}-row page,`);
216
+ console.log(' so strands older than the cutoff are NOT listed below.');
217
+ }
218
+ if (r.ok) {
219
+ console.log(' ✓ none — nothing parked at completed or confirmed past its threshold.');
220
+ console.log('');
221
+ return;
222
+ }
223
+ for (const s of r.strands) {
224
+ console.log(` task ${s.id} ${s.status.padEnd(9)} ${String(s.age).padStart(4)} ${s.shape}${s.reason ? ` (${s.reason})` : ''}`);
225
+ console.log(` ${s.remedy}`);
226
+ }
227
+ console.log('');
228
+ console.log(' "completed" means the grade parked it. "confirmed" means it passed and the');
229
+ console.log(' land never finished. Neither state produces a signal on its own.');
230
+ console.log('');
231
+ }
232
+
233
+ async function main() {
234
+ const hours = Number(arg('--hours'));
235
+ const opts = Number.isFinite(hours) && hours > 0 ? { completedStaleMs: hours * 3600000 } : {};
236
+
237
+ // --line: the SessionStart shape. FAIL-OPEN — a strand is context for the agent, and an
238
+ // unreachable API is nothing at all; neither is a failure of the session this rides into.
239
+ // Note it sets exitCode and RETURNS rather than calling process.exit: an explicit exit here
240
+ // races libuv's teardown of the request socket and aborts the process on Windows/Node 24
241
+ // (the documented `UV_HANDLE_CLOSING` assertion — see cli-lib's cliExit note and
242
+ // deploy-divergence's identical comment).
243
+ if (hasFlag('--line')) {
244
+ try {
245
+ const one = line(await check(opts));
246
+ if (one) console.log(one);
247
+ } catch { /* no session, offline, unreadable — all silence */ }
248
+ process.exitCode = 0;
249
+ return;
250
+ }
251
+
252
+ const r = await check(opts);
253
+ if (hasFlag('--json')) console.log(JSON.stringify(r, null, 2));
254
+ else report(r);
255
+ process.exitCode = r.undetermined ? 2 : (r.ok ? 0 : 1);
256
+ }
257
+
258
+ if (require.main === module) {
259
+ main().catch((e) => { console.error('strand-watch: failed —', e.message || e); process.exit(2); });
260
+ }
261
+
262
+ module.exports = {
263
+ check, line, report, classifyStrand, rankStrands, humanAge,
264
+ CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
265
+ };
package/src/module-api.js CHANGED
@@ -1011,8 +1011,12 @@ 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)
1016
+ // 1.19.294 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1017
+ // landed since 1.19.293 with no explicit bump. run 32588977219. (task 1002620)
1014
1018
  // ---------------------------------------------------------------------------
1015
- const CORE_VERSION = '1.19.292'; // CI auto-patch carrier (ADR 0161); see changelog above
1019
+ const CORE_VERSION = '1.19.294'; // CI auto-patch carrier (ADR 0161); see changelog above
1016
1020
 
1017
1021
  // A namespaced logger so a module's log lines are attributable + consistent.
1018
1022
  // 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;
@@ -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);