@bongos/core 1.19.883 → 1.19.884

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.883",
6
- "core_contract": "1.19.883",
7
- "source_commit": "38ac16c0f1716d2a3d3b040a38b4d61bcafb4598",
5
+ "core_version": "1.19.884",
6
+ "core_contract": "1.19.884",
7
+ "source_commit": "3f7e3ac7e91fe3fb3fdddb27aaf5b35aa5dbac69",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T19:12:16.216Z",
9
+ "built_at": "2026-09-19T19:21:38.333Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 498,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2315,
14
+ "functional_verbatim": 2316,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2839,
20
- "tree_sha256": "d96e15159f4ccd498d4c218b70f298a70541b1d97dae0f21124c8fde891881f0",
19
+ "file_count": 2840,
20
+ "tree_sha256": "6ecb18c6aa4049ca3b7a3d42526392093f1fbf7df2dcda05ec5cb24a73783653",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "a433058b1d11a710689cb4eebe94d4182f5d644bc2df341359ee4ec6348718b4"
370
+ "sha256": "890072b8a2de95a5f1913f4a5fdefc7462f1977b8cb7a18869cb50706c5a645e"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -2052,7 +2052,7 @@
2052
2052
  {
2053
2053
  "path": "docs/api/openapi.json",
2054
2054
  "mode": "0000644",
2055
- "sha256": "4a74552e803444eb13792a0c61fc41da324bda5c35c50b070d8980025d4d4935"
2055
+ "sha256": "19cb42bb52d123f89bf24d9e4b8667955f0fc682b2481790f0c48d8d1ed09e79"
2056
2056
  },
2057
2057
  {
2058
2058
  "path": "docs/architecture.md",
@@ -2607,7 +2607,7 @@
2607
2607
  {
2608
2608
  "path": "docs/module-api-changelog.md",
2609
2609
  "mode": "0000644",
2610
- "sha256": "a8791c0dfacd9fb522681fa5af7ddfcc63937dd597f0c30a2fa1b42498f4ee01"
2610
+ "sha256": "6b51b92e71f93545a7e272f422f229831541e92eb5c90859d3f4e1fd629a7598"
2611
2611
  },
2612
2612
  {
2613
2613
  "path": "docs/modules-contract.md",
@@ -5947,7 +5947,7 @@
5947
5947
  {
5948
5948
  "path": "modules/lifecycle/routes/tasks.js",
5949
5949
  "mode": "0000644",
5950
- "sha256": "d6b50dd007aefca08424f63b648cd07f139b98b696f470b83e7adb33bb7fc5c7"
5950
+ "sha256": "45d897c864cea14dfc02c706c6c0b4838ad5869fad81e30843fcc5d9736340a2"
5951
5951
  },
5952
5952
  {
5953
5953
  "path": "modules/lifecycle/routes/version-route-authz.js",
@@ -7917,12 +7917,12 @@
7917
7917
  {
7918
7918
  "path": "package-lock.json",
7919
7919
  "mode": "0000644",
7920
- "sha256": "469884a3047b45a3ef2d4913c2316c2a8e2c7232bba7ff0c3d7ee3002b964be0"
7920
+ "sha256": "b60a400b217ed94a7b0ffd2575edd06201aff6c044a5e83d7d96cdba2195f825"
7921
7921
  },
7922
7922
  {
7923
7923
  "path": "package.json",
7924
7924
  "mode": "0000644",
7925
- "sha256": "1651df890fb2d85a35efe8281ba5c81a0c683b16db37158b2ed985d11544c683"
7925
+ "sha256": "c148cb29465f0fd0441a9f172d5340d973cc462fa607c4a0757681dec0b22380"
7926
7926
  },
7927
7927
  {
7928
7928
  "path": "public-docs/index.html",
@@ -9292,7 +9292,7 @@
9292
9292
  {
9293
9293
  "path": "scripts/gds/ship-land.js",
9294
9294
  "mode": "0000644",
9295
- "sha256": "805017578a7d69ca2c79d105e21add7862b41e352256e5fbdfad3e3740e69ced"
9295
+ "sha256": "53f43c609206ea62e71f562aaceed316b6aea217612f74dcd800e30f06b17ea5"
9296
9296
  },
9297
9297
  {
9298
9298
  "path": "scripts/gds/ship-memory-sync.js",
@@ -9307,7 +9307,7 @@
9307
9307
  {
9308
9308
  "path": "scripts/gds/ship-merge.js",
9309
9309
  "mode": "0000644",
9310
- "sha256": "b85dd18d8214f1e732d4bd584fbad037c5505a16703e867e18e42bafef0bed33"
9310
+ "sha256": "dae19af925b80dfb2755fbf5f854ae29d04050f3b2217d99c3619e6debf067ee"
9311
9311
  },
9312
9312
  {
9313
9313
  "path": "scripts/gds/ship-preflight-steps.js",
@@ -9347,7 +9347,7 @@
9347
9347
  {
9348
9348
  "path": "scripts/gds/ship.js",
9349
9349
  "mode": "0000644",
9350
- "sha256": "9c279a8852e46758e498334fbeb25e94be2895930a6c65b400c2c1baaac1c270"
9350
+ "sha256": "d17284c5e00afe793c967c3ef0a1ef34b53fa6495ae137fb2724d8f7ebeb6afc"
9351
9351
  },
9352
9352
  {
9353
9353
  "path": "scripts/gds/skill-lint.js",
@@ -9932,7 +9932,7 @@
9932
9932
  {
9933
9933
  "path": "src/module-api.js",
9934
9934
  "mode": "0000644",
9935
- "sha256": "b64003ea52fe47207f7723521aba5b1bec5b7286f3e545a4b323d379259b99ab"
9935
+ "sha256": "f217dfe6e71dd6bd38f0ca7a02d4a7b85a2c49301d552dd4b1b098114db04985"
9936
9936
  },
9937
9937
  {
9938
9938
  "path": "src/module-loader/catalog.js",
@@ -13072,7 +13072,7 @@
13072
13072
  {
13073
13073
  "path": "tests/publish_branch_route.mjs",
13074
13074
  "mode": "0000644",
13075
- "sha256": "f688f5da9bfc392ce203ee3b89dfa9ba23fd66ca4ebac603f6fb6faedb83b5c7"
13075
+ "sha256": "27bf0ca4f9a5a2067e584d91fecf11da8daadea70fccf5e3fb48ee3bbbed39a4"
13076
13076
  },
13077
13077
  {
13078
13078
  "path": "tests/publish_gate.mjs",
@@ -13579,6 +13579,11 @@
13579
13579
  "mode": "0000644",
13580
13580
  "sha256": "a3b7858c255f1c7ea692866dbadd7321f77bd88c8380c429b7cb5f6b1b65496b"
13581
13581
  },
13582
+ {
13583
+ "path": "tests/ship_land_proof.mjs",
13584
+ "mode": "0000644",
13585
+ "sha256": "53ed4c14be08e332622708f79e7bc856b6cc81ee3f14f561f39edd23bca378c3"
13586
+ },
13582
13587
  {
13583
13588
  "path": "tests/ship_merge_gate.mjs",
13584
13589
  "mode": "0000644",
@@ -410,7 +410,7 @@ export interface PostSsoTokenResponse { github_id: unknown; github_login: unknow
410
410
  export interface PostTaskRecommendationsRequest { task_id: number; recommended_to: number; reason: string }
411
411
  export interface PostTaskRecommendationsResponse { ok: boolean; recommendation: unknown }
412
412
  export interface PostTasksIdAttestGateRequest { head_sha: string }
413
- export interface PostTasksIdAttestGateResponse { ok: boolean; attested: unknown; reason: unknown }
413
+ export interface PostTasksIdAttestGateResponse { ok: boolean; attested: unknown; reason: unknown; tip_recorded: unknown }
414
414
  export interface PostTasksIdConfirmRequest { verified?: unknown }
415
415
  export interface PostTasksIdCriteriaRequest { criterion_ids?: unknown[]; criterion_id?: StringifiableId }
416
416
  export interface PostTasksIdCriteriaResponse { ok: boolean; added: unknown; criteria: unknown }
@@ -24632,12 +24632,14 @@
24632
24632
  "type": "boolean"
24633
24633
  },
24634
24634
  "attested": {},
24635
- "reason": {}
24635
+ "reason": {},
24636
+ "tip_recorded": {}
24636
24637
  },
24637
24638
  "required": [
24638
24639
  "ok",
24639
24640
  "attested",
24640
- "reason"
24641
+ "reason",
24642
+ "tip_recorded"
24641
24643
  ]
24642
24644
  },
24643
24645
  "PostTasksIdConfirmRequest": {
@@ -2240,5 +2240,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2240
2240
  landed since 1.19.881 with no explicit bump. run 35463113839. (task 1002620)
2241
2241
  1.19.883 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2242
2242
  landed since 1.19.882 with no explicit bump. run 35463573638. (task 1002620)
2243
+ 1.19.884 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2244
+ landed since 1.19.883 with no explicit bump. run 35464075741. (task 1002620)
2243
2245
  ---------------------------------------------------------------------------
2244
2246
  ```
@@ -1357,17 +1357,31 @@ module.exports = function buildTasksRouter() {
1357
1357
  // it here too. Sits BEFORE the credential 503 on purpose: the tip is true
1358
1358
  // regardless, and a later-provisioned credential lets the sweep heal
1359
1359
  // retroactively. Best-effort, mirroring publish-branch.
1360
+ //
1361
+ // task 1004055: and SAY whether it took. setClaimPublishedSha returns null when
1362
+ // no claim row matched, which read identically to success — so a caller that had
1363
+ // just merged to main was told nothing, and only found out one call later as an
1364
+ // opaque ship_land_not_proven 409, with the work already on main and the task
1365
+ // parked at 'confirmed'. `tip_recorded` travels on BOTH exits, so the answer does
1366
+ // not depend on which one the request happens to take.
1367
+ let tipRecorded = false;
1360
1368
  try {
1361
- await db.setClaimPublishedSha({ taskId: id, builderId: req.builder.id, sha: headSha });
1369
+ const claimId = await db.setClaimPublishedSha({ taskId: id, builderId: req.builder.id, sha: headSha });
1370
+ tipRecorded = claimId != null;
1371
+ if (!tipRecorded) log.warn('[gds] attest-gate: no claim row to record the published tip on', id);
1362
1372
  } catch (e) {
1363
1373
  log.error('[gds] setClaimPublishedSha via attest-gate (non-blocking)', id, e && e.message);
1364
1374
  }
1365
1375
  if (!githubPush.isConfigured()) {
1366
- return res.fail('push_unconfigured', { status: 503, message: 'No server push credential (GitHub App / GITHUB_PUSH_TOKEN) to post the gate attestation. ' +
1367
- 'A gate-surface PR will need an owner review until the operator provisions it.' });
1376
+ return res.fail('push_unconfigured', {
1377
+ status: 503,
1378
+ message: 'No server push credential (GitHub App / GITHUB_PUSH_TOKEN) to post the gate attestation. ' +
1379
+ 'A gate-surface PR will need an owner review until the operator provisions it.',
1380
+ details: { tip_recorded: tipRecorded },
1381
+ });
1368
1382
  }
1369
1383
  const { attested, reason } = await postGateAuthorTrust({ rank: req.builder.rank, sha: headSha, taskId: id });
1370
- return res.json({ ok: true, attested, reason });
1384
+ return res.json({ ok: true, attested, reason, tip_recorded: tipRecorded });
1371
1385
  });
1372
1386
 
1373
1387
  // GET /override-requests?status=open — Archon queue. Status defaults to open;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.883",
3
+ "version": "1.19.884",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.883",
9
+ "version": "1.19.884",
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.883",
3
+ "version": "1.19.884",
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",
@@ -438,24 +438,100 @@ async function pollServerPublish(taskId, branch) {
438
438
  // Best-effort + never throws: a non-Metic author, an unconfigured server, or a
439
439
  // transient failure just leaves the PR un-attested → it escalates (the safe
440
440
  // direction). A non-gate PR is unaffected by the attestation either way.
441
+ //
442
+ // task 1004055 — the endpoint does TWO things, and only one of them was ever
443
+ // reported. Besides the attestation (best-effort, and declining is a legitimate
444
+ // answer) it records claims.published_head_sha, the tip that makes the ship
445
+ // PROVABLE — and that half is load-bearing, not best-effort: without it POST /ship
446
+ // refuses the confirmed→shipped flip. So this returns { recorded, attested, reason }
447
+ // rather than undefined, and callers act on `recorded`. It still never throws.
441
448
  async function attestGateTrust(taskId, headSha) {
442
- if (!headSha) return;
449
+ if (!headSha) return { recorded: false, attested: false, reason: 'no head SHA to attest' };
450
+ let r;
443
451
  try {
444
- const r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
445
- if (r.ok && r.data) {
446
- if (r.data.attested) {
447
- console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
448
- } else {
449
- console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
450
- }
451
- } else if (r.status === 503) {
452
- console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
452
+ r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
453
+ } catch (e) {
454
+ // task 1004055: a bare `catch (_) {}` here meant a thrown request error left NO
455
+ // trace at all — the quietest of the failure shapes, and the one that reached
456
+ // the builder as an unexplained ship_land_not_proven.
457
+ console.log(` ci-land: gate attestation request failed (${(e && e.message) || 'unknown error'}).`);
458
+ return { recorded: false, attested: false, reason: `request failed (${(e && e.message) || 'unknown error'})` };
459
+ }
460
+ const recorded = landProofRecorded(r);
461
+ if (r.ok && r.data) {
462
+ if (r.data.attested) {
463
+ console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
453
464
  } else {
454
- console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
465
+ console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
455
466
  }
456
- } catch (_) {
457
- /* best-effort — never block the ship on the attestation */
467
+ return { recorded, attested: !!r.data.attested, reason: r.data.reason || null };
468
+ }
469
+ if (r.status === 503) {
470
+ console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
471
+ return { recorded, attested: false, reason: 'the server has no push credential' };
458
472
  }
473
+ console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
474
+ return { recorded, attested: false, reason: `HTTP ${r.status}` };
475
+ }
476
+
477
+ // landProofRecorded — PURE. Did an attest-gate response actually record
478
+ // claims.published_head_sha (task 1004055)? That row is the durable per-task land
479
+ // proof; without it POST /tasks/:id/ship refuses with ship_land_not_proven and the
480
+ // task strands at 'confirmed' with its work already on main.
481
+ //
482
+ // The server SAYS so in `tip_recorded` — on the 200 body and in the 503's details,
483
+ // because the write deliberately sits BEFORE the credential check. Prefer that.
484
+ // A server that predates the field sends neither, which is the NORMAL state here:
485
+ // an instance runs a PINNED core, so it lags main by however long the pin sits
486
+ // still. For those, fall back to the route's contract — the write is above the
487
+ // credential 503, so 200 and 503 both mean the tip landed and nothing else does.
488
+ // Exported so the truth table is pinned with no server.
489
+ function landProofRecorded(r) {
490
+ if (!r || typeof r.status !== 'number') return false;
491
+ const body = r.data || {};
492
+ const explicit = body.tip_recorded !== undefined
493
+ ? body.tip_recorded
494
+ : (body.error && body.error.details ? body.error.details.tip_recorded : undefined);
495
+ if (explicit !== undefined) return explicit === true;
496
+ return r.status === 200 || r.status === 503;
497
+ }
498
+
499
+ // landProofWarningLines — PURE. What to print when the proof did NOT get recorded
500
+ // (task 1004055). Without it POST /ship refuses, the task parks at 'confirmed' —
501
+ // which gates the builder's WHOLE queue — and the publish-reconciler eventually
502
+ // writes strand:land_not_proven, after which nothing heals it. Say it HERE, while
503
+ // the sha is still on screen, and hand over the calls that fix it, rather than
504
+ // letting it surface as an opaque 409 later.
505
+ //
506
+ // `merged` is the tense, and it is the whole difference between the two callers:
507
+ // the local-merge path has ALREADY put the work on main and needs the full
508
+ // two-call recovery now; the PR path has only pushed, so the land may still be
509
+ // minutes away and the same recovery would be premature. Exported so both
510
+ // wordings are pinned.
511
+ function landProofWarningLines(taskId, sha, reason, { merged = true } = {}) {
512
+ const head = sha || '<the merge commit on main>';
513
+ const why = ` WARNING: the land proof for task ${taskId} was NOT recorded (${reason || 'unknown'}).`;
514
+ const fix = ` node scripts/gds/api.js POST /tasks/${taskId}/attest-gate --body '{"head_sha":"${head}"}'`;
515
+ if (!merged) {
516
+ return [
517
+ '',
518
+ why,
519
+ ' Nothing is lost yet — the branch is pushed and the PR still lands normally.',
520
+ ' But if the ship then refuses with ship_land_not_proven, re-record the tip',
521
+ ' against the merge commit and re-run the ship:',
522
+ ` node scripts/gds/api.js POST /tasks/${taskId}/attest-gate --body '{"head_sha":"<the merge commit on main>"}'`,
523
+ ` node scripts/gds/ship.js ${taskId}`,
524
+ ];
525
+ }
526
+ return [
527
+ '',
528
+ why,
529
+ ' The merge IS on main, but the server cannot prove the land is this task\'s, so',
530
+ ' the confirmed -> shipped flip will be refused (ship_land_not_proven) and the',
531
+ ' task will park at confirmed — which blocks every new claim you make. Fix it:',
532
+ fix,
533
+ ` node scripts/gds/api.js POST /tasks/${taskId}/ship --body '{}'`,
534
+ ];
459
535
  }
460
536
 
461
537
  // pickStaleGateReviewRun — PURE. Among `gh run list --json
@@ -596,7 +672,14 @@ async function ciLandLocal(taskId, branch, valueSummary) {
596
672
  // and a Metic+ gate-surface PR auto-passes — race-free for a fresh PR, since the
597
673
  // `gh pr create` below is what fires gate-review and it runs only after this
598
674
  // returns. (A reused open PR is handled by the re-run nudge after auto-merge.)
599
- await attestGateTrust(taskId, headSha);
675
+ // task 1004055: the same call also records the land proof, so a miss here is not
676
+ // cosmetic — check it rather than firing and forgetting. Not fatal on this path:
677
+ // the branch is pushed and the PR lands regardless, so say what to do IF the ship
678
+ // is later refused, and carry on.
679
+ const gateProof = await attestGateTrust(taskId, headSha);
680
+ if (!gateProof.recorded) {
681
+ for (const line of landProofWarningLines(taskId, headSha, gateProof.reason, { merged: false })) console.log(line);
682
+ }
600
683
 
601
684
  // Ensure exactly one PR for this branch. gh pr create is idempotent-ish: it
602
685
  // errors if a PR already exists, so check first and reuse it.
@@ -723,6 +806,12 @@ module.exports = {
723
806
  ciLand,
724
807
  classifyLandOutcome,
725
808
  formatPublishFailure,
809
+ // task 1004055: the land-proof half of attest-gate. attestGateTrust is exported
810
+ // so the LOCAL-merge path can record a proof it previously never recorded at all;
811
+ // the two pure halves so the truth table + the wording are pinned without a server.
812
+ attestGateTrust,
813
+ landProofRecorded,
814
+ landProofWarningLines,
726
815
  // task 1002572 (PART 2): the bail ids + the pure "can a PR still land this?"
727
816
  // decision, so the single-checkout fallback is opt-in per bail and unit-tested.
728
817
  LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
@@ -20,9 +20,11 @@ const { mainIsProtected, pushVia, readDeployConfigRaw, resolveDeployMode, resolv
20
20
  const {
21
21
  LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
22
22
  LAND_BAIL_NO_TASK_BRANCH,
23
+ attestGateTrust,
23
24
  ciLand,
24
25
  landBailRecoverableByPr,
25
26
  landBailed,
27
+ landProofWarningLines,
26
28
  landed,
27
29
  } = require('./ship-land.js');
28
30
  const { runSmoke } = require('./ship-grade.js');
@@ -301,6 +303,32 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
301
303
  return landBailed('could not push the merged main to origin', '/merge-mode');
302
304
  }
303
305
 
306
+ // task 1004055: RECORD THE LAND PROOF — the step this path never had.
307
+ //
308
+ // The two OTHER land paths each tell the server which commit is this task's:
309
+ // ciLandLocal posts attest-gate after its push, ciLandServer gets it from
310
+ // publish-branch. This path merges and pushes main ITSELF, so nothing ever did,
311
+ // and claims.published_head_sha stayed NULL. It also opens no PR and deletes the
312
+ // branch below, so getPublishStatus can find nothing by head ref either — which
313
+ // left deriveShipAssurance with neither of its two proofs and made the POST /ship
314
+ // three lines later refuse with ship_land_not_proven, EVERY time, with the work
315
+ // already merged. The task then parked at 'confirmed', gating its builder's whole
316
+ // queue, until someone ran the recovery by hand (five consecutive ships,
317
+ // 2026-09-19; twelve refusals in one day's audit log).
318
+ //
319
+ // The merge commit is the sha to record: the push above just put it on main, so
320
+ // the ancestry check cannot fail, and it is what the hand-recovery always used.
321
+ // It goes HERE, immediately after the push and BEFORE both the deploy step (which
322
+ // can bail) and the branch deletion (which destroys the only other evidence) —
323
+ // the proof must exist from the first moment the work is on main.
324
+ const mergeSha = gitOk('git rev-parse HEAD', { cwd: REPO_ROOT });
325
+ const proof = await attestGateTrust(taskId, mergeSha);
326
+ if (proof.recorded) {
327
+ console.log(` auto-merge: land proof recorded (merge ${String(mergeSha).slice(0, 8)} on main).`);
328
+ } else {
329
+ for (const line of landProofWarningLines(taskId, mergeSha, proof.reason)) console.log(line);
330
+ }
331
+
304
332
  // Deploy step. The core carries NO deploy target of its own — under the
305
333
  // standalone model a core change reaches a live instance via package-core +
306
334
  // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
@@ -127,6 +127,8 @@ const {
127
127
  landPending,
128
128
  landPendingLines,
129
129
  landPollAfterAutoMerge,
130
+ landProofRecorded,
131
+ landProofWarningLines,
130
132
  landed,
131
133
  pickStaleGateReviewRun,
132
134
  reconcileBlockedLines,
@@ -266,6 +268,12 @@ module.exports = {
266
268
  // object, so a consumer MUST test `.ok`; the old `if (!merged)` would sail past
267
269
  // a bail. Also pins pending (a poll timeout) as distinct from bailed.
268
270
  landed, landPending, landBailed, strandCommand,
271
+ // task 1004055: the land proof the LOCAL-merge path never recorded. landProofRecorded
272
+ // is the "did the tip actually land?" truth table (explicit tip_recorded wins; a
273
+ // pinned older core falls back to the route's before-the-credential-check contract);
274
+ // landProofWarningLines is the recovery the builder gets when it did not, in the
275
+ // tense of whichever path is speaking. Both pure — exported so they pin without a server.
276
+ landProofRecorded, landProofWarningLines,
269
277
  // task 1001916: the publish-branch failure formatter — exported pure so both
270
278
  // wire shapes (envelope + legacy flat), the step/reason surfacing, and the
271
279
  // non-fast-forward remedy hint are pinned without a server.
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.883'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.884'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -471,11 +471,11 @@ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even w
471
471
  const router = buildTasksRouter();
472
472
  const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
473
473
  const handler = layer.route.stack[layer.route.stack.length - 1].handle;
474
- const out = { failCode: null, jsonBody: null };
474
+ const out = { failCode: null, failOpts: null, jsonBody: null };
475
475
  const res = {
476
476
  status() { return this; },
477
477
  json(b) { out.jsonBody = b; return this; },
478
- fail(code) { out.failCode = code; return this; },
478
+ fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
479
479
  };
480
480
  // rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
481
481
  const req = { params: { id: '77' }, body: { head_sha: 'a'.repeat(40) }, builder: { id: 95, rank: 'archon' } };
@@ -483,9 +483,77 @@ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even w
483
483
  assert.equal(out.failCode, 'push_unconfigured', 'no credential → still the usual 503 to the caller');
484
484
  assert.deepEqual(recorded, [{ taskId: 77, builderId: 95, sha: 'a'.repeat(40) }],
485
485
  'the tip is recorded BEFORE the credential check — the wave-strand cure');
486
+ // task 1004055: and the 503 must SAY the tip landed. This exit is the one a
487
+ // caller is most likely to misread as total failure; ship.js decides whether
488
+ // to warn the builder off this flag, so it has to travel on the error too.
489
+ assert.equal(out.failOpts && out.failOpts.details && out.failOpts.details.tip_recorded, true,
490
+ 'the 503 must carry tip_recorded:true — the write happened before it');
491
+ });
492
+ } finally {
493
+ dbMod.setClaimPublishedSha = savedSet;
494
+ }
495
+ });
496
+
497
+ // task 1004055: the SILENT MISS. setClaimPublishedSha returns null when the UPDATE
498
+ // matched no claim row, and the old route discarded that return value — so a caller
499
+ // that had already merged to main was told nothing was wrong, and only found out one
500
+ // call later as an opaque ship_land_not_proven 409, with the task parked at
501
+ // 'confirmed' gating its builder's whole queue. `tip_recorded` is what makes that
502
+ // answerable at the moment it happens.
503
+ await t('POST /tasks/:id/attest-gate reports tip_recorded:false when no claim row matched', async () => {
504
+ const dbMod = require('../modules/lifecycle/db.js');
505
+ const savedSet = dbMod.setClaimPublishedSha;
506
+ const savedHolder = dbMod.getLastClaimHolder;
507
+ // null = "the UPDATE matched nothing", the shape db-ship.js really returns.
508
+ dbMod.setClaimPublishedSha = async () => null;
509
+ dbMod.getLastClaimHolder = async () => ({ builder_id: 95 });
510
+ try {
511
+ // A credential IS configured, so the handler reaches res.json; a below-Metic rank
512
+ // means postGateAuthorTrust declines on the pure rank check and never fetches.
513
+ await withEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: 'ghp_fake_for_isConfigured' }, async () => {
514
+ const router = buildTasksRouter();
515
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
516
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
517
+ const out = { failCode: null, jsonBody: null };
518
+ const res = {
519
+ status() { return this; },
520
+ json(b) { out.jsonBody = b; return this; },
521
+ fail(code) { out.failCode = code; return this; },
522
+ };
523
+ const req = { params: { id: '77' }, body: { head_sha: 'b'.repeat(40) }, builder: { id: 95, rank: 'thetes' } };
524
+ await handler(req, res);
525
+ assert.equal(out.failCode, null, 'a below-Metic attest is a 200, not a failure');
526
+ assert.equal(out.jsonBody.ok, true);
527
+ assert.equal(out.jsonBody.attested, false, 'below Metic → no attestation, as before');
528
+ assert.equal(out.jsonBody.tip_recorded, false,
529
+ 'nothing was recorded, and the response must say so rather than reading as success');
530
+ });
531
+ } finally {
532
+ dbMod.setClaimPublishedSha = savedSet;
533
+ dbMod.getLastClaimHolder = savedHolder;
534
+ }
535
+ });
536
+
537
+ await t('POST /tasks/:id/attest-gate reports tip_recorded:true on the happy path', async () => {
538
+ const dbMod = require('../modules/lifecycle/db.js');
539
+ const savedSet = dbMod.setClaimPublishedSha;
540
+ const savedHolder = dbMod.getLastClaimHolder;
541
+ dbMod.setClaimPublishedSha = async () => 4242; // a claim id = the UPDATE matched
542
+ dbMod.getLastClaimHolder = async () => ({ builder_id: 95 });
543
+ try {
544
+ await withEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: 'ghp_fake_for_isConfigured' }, async () => {
545
+ const router = buildTasksRouter();
546
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
547
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
548
+ const out = { jsonBody: null };
549
+ const res = { status() { return this; }, json(b) { out.jsonBody = b; return this; }, fail() { return this; } };
550
+ const req = { params: { id: '77' }, body: { head_sha: 'c'.repeat(40) }, builder: { id: 95, rank: 'thetes' } };
551
+ await handler(req, res);
552
+ assert.equal(out.jsonBody.tip_recorded, true);
486
553
  });
487
554
  } finally {
488
555
  dbMod.setClaimPublishedSha = savedSet;
556
+ dbMod.getLastClaimHolder = savedHolder;
489
557
  }
490
558
  });
491
559
 
@@ -0,0 +1,218 @@
1
+ // tests/ship_land_proof.mjs
2
+ //
3
+ // task 1004055 — the ship that merges to main and then refuses to record it.
4
+ //
5
+ // ship.js has three land paths. Two of them tell the server which commit is this
6
+ // task's: ciLandLocal posts attest-gate after its push, ciLandServer gets it from
7
+ // publish-branch. The LOCAL-merge path (mergeLocally, 'laptop' deploy mode) merged
8
+ // into main and pushed it ITSELF, and told the server nothing — so
9
+ // claims.published_head_sha stayed NULL. It also opens no PR and deletes the branch
10
+ // on origin, so getPublishStatus finds nothing by head ref either. That left
11
+ // deriveShipAssurance with neither of its two proofs, and the POST /ship three lines
12
+ // later refused with ship_land_not_proven EVERY time, with the work already merged.
13
+ // The task parked at 'confirmed', gating its builder's whole queue, until someone ran
14
+ // attest-gate + ship by hand. Five consecutive ships on 2026-09-19; twelve refusals
15
+ // in that day's audit log.
16
+ //
17
+ // What is pinned here:
18
+ // 1. attestGateTrust REPORTS whether the tip landed, and never throws
19
+ // 2. landProofRecorded's truth table — including the pinned-older-core fallback
20
+ // 3. the warning is in the right TENSE for the path that emits it
21
+ // 4. the local-merge path records a tip, before it deletes the branch
22
+ //
23
+ // DB-free and network-free: the only collaborator attestGateTrust has is cli-lib's
24
+ // cliClient, stubbed through the require cache below. Nothing here spawns git, gh,
25
+ // or a server.
26
+ //
27
+ // Run: node tests/ship_land_proof.mjs
28
+
29
+ import { strict as assert } from 'node:assert';
30
+ import { createRequire } from 'node:module';
31
+ import fs from 'node:fs';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const require = createRequire(import.meta.url);
35
+
36
+ // The stub MUST be in place before ship-land.js is first required — it destructures
37
+ // cliClient at module load.
38
+ let nextAttest = null;
39
+ const cliLibPath = require.resolve('../scripts/gds/cli-lib');
40
+ require.cache[cliLibPath] = {
41
+ id: cliLibPath,
42
+ filename: cliLibPath,
43
+ loaded: true,
44
+ exports: {
45
+ cliClient: async () => ({
46
+ tasks: {
47
+ postTasksIdAttestGate: async ({ id, body }) => {
48
+ if (typeof nextAttest === 'function') return nextAttest({ id, body });
49
+ return nextAttest;
50
+ },
51
+ },
52
+ }),
53
+ isBoxScope: () => false,
54
+ },
55
+ };
56
+
57
+ const { attestGateTrust, landProofRecorded, landProofWarningLines } = require('../scripts/gds/ship-land.js');
58
+
59
+ let passed = 0;
60
+ let failed = 0;
61
+ async function t(name, fn) {
62
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
63
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
64
+ }
65
+
66
+ // attestGateTrust narrates to stdout; swallow that so the test output stays readable.
67
+ async function quietly(fn) {
68
+ const real = console.log;
69
+ const said = [];
70
+ console.log = (...a) => said.push(a.join(' '));
71
+ try { return { value: await fn(), said: said.join('\n') }; }
72
+ finally { console.log = real; }
73
+ }
74
+
75
+ const MERGE_SHA = '5c4db10e2a1f4c6b8d3e9f70a1b2c3d4e5f60718';
76
+
77
+ // --- 1. attestGateTrust reports, and never throws -----------------------------
78
+
79
+ await t('a 200 carrying tip_recorded:true → recorded, and the sha is what we sent', async () => {
80
+ let seen = null;
81
+ nextAttest = ({ id, body }) => {
82
+ seen = { id, body };
83
+ return { ok: true, status: 200, data: { ok: true, attested: true, reason: null, tip_recorded: true } };
84
+ };
85
+ const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
86
+ assert.equal(value.recorded, true);
87
+ assert.equal(value.attested, true);
88
+ assert.deepEqual(seen, { id: 1004055, body: { head_sha: MERGE_SHA } });
89
+ });
90
+
91
+ // The case the field exists for: the attestation succeeded, so the old code called
92
+ // the whole thing a success — but no claim row matched, so nothing was recorded and
93
+ // the ship was already doomed.
94
+ await t('a 200 whose claim lookup matched nothing → NOT recorded, even though attested', async () => {
95
+ nextAttest = { ok: true, status: 200, data: { ok: true, attested: true, reason: null, tip_recorded: false } };
96
+ const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
97
+ assert.equal(value.attested, true, 'the gate attestation did succeed');
98
+ assert.equal(value.recorded, false, 'but the land proof did not land');
99
+ });
100
+
101
+ await t('a 503 push_unconfigured still recorded the tip (the write precedes the credential check)', async () => {
102
+ nextAttest = { ok: false, status: 503, data: { error: { code: 'push_unconfigured', details: { tip_recorded: true } } } };
103
+ const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
104
+ assert.equal(value.recorded, true);
105
+ assert.equal(value.attested, false);
106
+ });
107
+
108
+ await t('a 403 never reached the write → NOT recorded', async () => {
109
+ nextAttest = { ok: false, status: 403, data: { error: { code: 'not_your_task' } } };
110
+ const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
111
+ assert.equal(value.recorded, false);
112
+ });
113
+
114
+ // The quietest of the old failure shapes: a bare `catch (_) {}` left no trace at all.
115
+ await t('a thrown request is reported, not swallowed, and never propagates', async () => {
116
+ nextAttest = () => { throw new Error('fetch failed'); };
117
+ const { value, said } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
118
+ assert.equal(value.recorded, false);
119
+ assert.match(value.reason, /fetch failed/);
120
+ assert.match(said, /fetch failed/, 'the failure must reach the log too, not only the return value');
121
+ });
122
+
123
+ await t('no head SHA → not recorded, and no request is attempted', async () => {
124
+ nextAttest = () => { throw new Error('should not have been called'); };
125
+ const { value } = await quietly(() => attestGateTrust(1004055, null));
126
+ assert.equal(value.recorded, false);
127
+ });
128
+
129
+ // --- 2. landProofRecorded: the truth table ------------------------------------
130
+
131
+ await t('explicit tip_recorded wins over any status reading', () => {
132
+ assert.equal(landProofRecorded({ ok: true, status: 200, data: { tip_recorded: true } }), true);
133
+ assert.equal(landProofRecorded({ ok: true, status: 200, data: { tip_recorded: false } }), false);
134
+ assert.equal(landProofRecorded({ ok: false, status: 503, data: { error: { details: { tip_recorded: false } } } }), false);
135
+ });
136
+
137
+ // A live instance runs a PINNED core, so it lags main by however long the pin sits
138
+ // still — cloudbongos.com was serving 1.19.871 against a main declaring 1.19.881 on
139
+ // the day this was diagnosed. Those servers send no tip_recorded at all, and reading
140
+ // its absence as "not recorded" would make every healthy land cry wolf.
141
+ await t('older core, no tip_recorded: 200 and 503 both mean the tip landed', () => {
142
+ assert.equal(landProofRecorded({ ok: true, status: 200, data: { ok: true, attested: false, reason: 'rank_below_metic' } }), true);
143
+ assert.equal(landProofRecorded({ ok: false, status: 503, data: { error: { code: 'push_unconfigured' } } }), true);
144
+ });
145
+
146
+ await t('older core: every other status → NOT recorded', () => {
147
+ for (const status of [400, 403, 404, 409, 500, 502]) {
148
+ assert.equal(
149
+ landProofRecorded({ ok: false, status, data: { error: { code: 'not_your_task' } } }),
150
+ false,
151
+ `HTTP ${status} must not count as a recorded tip`
152
+ );
153
+ }
154
+ });
155
+
156
+ await t('no response at all → NOT recorded', () => {
157
+ assert.equal(landProofRecorded(null), false);
158
+ assert.equal(landProofRecorded(undefined), false);
159
+ assert.equal(landProofRecorded({}), false);
160
+ });
161
+
162
+ // --- 3. the warning is in the right tense -------------------------------------
163
+
164
+ await t('merged path: names the merge sha and gives the full two-call recovery', () => {
165
+ const lines = landProofWarningLines(1004055, MERGE_SHA).join('\n');
166
+ assert.match(lines, /NOT recorded/);
167
+ assert.match(lines, /merge IS on main/);
168
+ assert.ok(lines.includes(`"head_sha":"${MERGE_SHA}"`), 'must name the exact sha to re-record');
169
+ assert.match(lines, /POST \/tasks\/1004055\/attest-gate/);
170
+ assert.match(lines, /POST \/tasks\/1004055\/ship/);
171
+ });
172
+
173
+ // The PR path has only PUSHED — the land may still be minutes out, and telling a
174
+ // builder their merge is on main when it is not is the same class of lie this whole
175
+ // task is about.
176
+ await t('un-merged path: does not claim the work is on main, and does not ship blind', () => {
177
+ const lines = landProofWarningLines(1004055, MERGE_SHA, 'HTTP 403', { merged: false }).join('\n');
178
+ assert.doesNotMatch(lines, /merge IS on main/);
179
+ assert.match(lines, /the PR still lands normally/);
180
+ assert.ok(!lines.includes('/ship --body'), 'must not hand over a ship call before anything merged');
181
+ assert.match(lines, /ship\.js 1004055/);
182
+ });
183
+
184
+ await t('the reason is surfaced, and a missing sha degrades to a placeholder', () => {
185
+ assert.match(landProofWarningLines(7, MERGE_SHA, 'HTTP 403').join('\n'), /HTTP 403/);
186
+ assert.match(landProofWarningLines(7, null).join('\n'), /<the merge commit on main>/);
187
+ });
188
+
189
+ // --- 4. the local-merge path records a tip at all -----------------------------
190
+ //
191
+ // mergeLocally has no dependency seam — it merges a real second checkout, runs smoke
192
+ // and pushes main — so this is asserted against the source. Weaker than driving it,
193
+ // but it is exactly the assertion that failed before the fix: the file contained no
194
+ // reference to ANY of the three ways to record a tip, and the order of the two calls
195
+ // is the other half of the bug (deleting the branch first destroys the only evidence
196
+ // a later sweep could have used).
197
+
198
+ const mergeSrc = fs.readFileSync(fileURLToPath(new URL('../scripts/gds/ship-merge.js', import.meta.url)), 'utf8');
199
+
200
+ await t('ship-merge.js records the merge commit as this task\'s land proof', () => {
201
+ assert.match(mergeSrc, /attestGateTrust\(taskId, mergeSha\)/,
202
+ 'the local-merge path must record a land proof — before the fix it recorded none');
203
+ });
204
+
205
+ await t('the proof is recorded BEFORE the origin branch is deleted', () => {
206
+ const attestAt = mergeSrc.indexOf('attestGateTrust(taskId, mergeSha)');
207
+ const deleteAt = mergeSrc.indexOf("'--delete'");
208
+ assert.ok(attestAt > 0, 'the attest call must be present');
209
+ assert.ok(deleteAt > 0, 'the branch delete must be present');
210
+ assert.ok(attestAt < deleteAt, 'the land proof must be recorded before the branch is deleted');
211
+ });
212
+
213
+ await t('a miss reaches the builder at the merge, not as an opaque 409 later', () => {
214
+ assert.match(mergeSrc, /landProofWarningLines\(taskId, mergeSha, proof\.reason\)/);
215
+ });
216
+
217
+ console.log(`\n${passed} passed, ${failed} failed`);
218
+ process.exit(failed === 0 ? 0 : 1);