@bongos/core 1.19.127 → 1.19.129

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.127",
6
- "core_contract": "1.19.127",
7
- "source_commit": "f4b67a76dc2ad911a7c9257ba4280de4ac7904c7",
5
+ "core_version": "1.19.129",
6
+ "core_contract": "1.19.129",
7
+ "source_commit": "4f6f1fe2d419e1f7a6b2877ce6d3b2f793308257",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-15T00:49:23.419Z",
9
+ "built_at": "2026-08-15T02:03:44.422Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 303,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1245,
14
+ "functional_verbatim": 1246,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1569,
20
- "tree_sha256": "deedfbf01298656cc039a9d567722f3689778d62d8aacc97a230ac4490a39c7a",
19
+ "file_count": 1570,
20
+ "tree_sha256": "15adcb143cdb095a549345805c5491c08b64429b4ef6730ef4878344c15d2ea9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3844,6 +3844,11 @@
3844
3844
  "mode": "0000644",
3845
3845
  "sha256": "ddbc1f7e659e716fcbb3ba3ec6c1237756f46dbd895bcb27a1dc925a7c782e76"
3846
3846
  },
3847
+ {
3848
+ "path": "modules/provisioning/migrations/provisioning_008_dns_note.sql",
3849
+ "mode": "0000644",
3850
+ "sha256": "8dc04a18ad77563a2bd73029bf13cab03fb5dadd1b62850fddbde475257e6a76"
3851
+ },
3847
3852
  {
3848
3853
  "path": "modules/provisioning/module.json",
3849
3854
  "mode": "0000644",
@@ -3862,7 +3867,7 @@
3862
3867
  {
3863
3868
  "path": "modules/provisioning/provisioning.js",
3864
3869
  "mode": "0000644",
3865
- "sha256": "59fd212df421b2bdcdfaef4871aa1927c371d3f7d6affafe44e810135acd12b6"
3870
+ "sha256": "90aa880898f6b3235e8b2a1a553695c63c29920968edb9c9af58364902118e33"
3866
3871
  },
3867
3872
  {
3868
3873
  "path": "modules/provisioning/rate-limit.js",
@@ -3872,7 +3877,7 @@
3872
3877
  {
3873
3878
  "path": "modules/provisioning/routes/provisioning.js",
3874
3879
  "mode": "0000644",
3875
- "sha256": "321db8769eda7c5f6f385e90edaeec70fc071f0bc2e53e6147705153b0d32d3a"
3880
+ "sha256": "eddf451d5ee5085563af258b5c605c445776c9e226587341f9b4b8b405492240"
3876
3881
  },
3877
3882
  {
3878
3883
  "path": "modules/provisioning/tests/liveness.mjs",
@@ -3882,7 +3887,7 @@
3882
3887
  {
3883
3888
  "path": "modules/provisioning/tests/provisioning.mjs",
3884
3889
  "mode": "0000644",
3885
- "sha256": "4a5c817d88771a62ccb5d26aea865976b7246a253b2ad7b00e11e3be856586ef"
3890
+ "sha256": "0dc18db288580ca00792194ce5f50a13aae8c445c14d7fc42a2095ab2379c93f"
3886
3891
  },
3887
3892
  {
3888
3893
  "path": "modules/provisioning/ui/.gitkeep",
@@ -4167,12 +4172,12 @@
4167
4172
  {
4168
4173
  "path": "package-lock.json",
4169
4174
  "mode": "0000644",
4170
- "sha256": "ecb64df7be6949adcb0d88b5f21ca3014724b598fa1a6d939a3a50d8f9696aa6"
4175
+ "sha256": "113fa76500c41f49120cb6a76558c693e3862d5f7dbd754c6b6f9e604bd0d45e"
4171
4176
  },
4172
4177
  {
4173
4178
  "path": "package.json",
4174
4179
  "mode": "0000644",
4175
- "sha256": "5be9320e605a4b67ba2d63fa4ab831b99bda91a847c11338fd5e0ed6a65d0779"
4180
+ "sha256": "88877c77760c0d9d44361b8522fa2e871a8a62f85a500454acc9af972db8b00e"
4176
4181
  },
4177
4182
  {
4178
4183
  "path": "public-docs/index.html",
@@ -4427,7 +4432,7 @@
4427
4432
  {
4428
4433
  "path": "public-landing/projects.html",
4429
4434
  "mode": "0000644",
4430
- "sha256": "6dcb73054fa58d3b0254f1d82a7f639ed465db2c2fd1e83e26f29b80532047e5"
4435
+ "sha256": "ac866c928701c41ac7f055f8807590a3db71eb2538502a54a6fca9ac1d79868d"
4431
4436
  },
4432
4437
  {
4433
4438
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5082,7 +5087,7 @@
5082
5087
  {
5083
5088
  "path": "scripts/gds/provision.js",
5084
5089
  "mode": "0000644",
5085
- "sha256": "a10501b65207bbd72f9fc039d4cf1ef0be7f85dc832e8e95e1b26aa966e507b8"
5090
+ "sha256": "1925ed112ec553a95388b81cccc1fb003ba2a0e91a5a80c6e315b45895688d0f"
5086
5091
  },
5087
5092
  {
5088
5093
  "path": "scripts/gds/publish-credential-check.js",
@@ -5757,7 +5762,7 @@
5757
5762
  {
5758
5763
  "path": "src/module-api.js",
5759
5764
  "mode": "0000644",
5760
- "sha256": "80e087f2181512cfdf51030c27aeb7aaa4235421c4e06c9d7c4a000d418b0d0a"
5765
+ "sha256": "0d17e220c27abd9a9064c461404fa8fdf2f352f404e10673b76ff8a34ccd04c2"
5761
5766
  },
5762
5767
  {
5763
5768
  "path": "src/module-loader/catalog.js",
@@ -7297,7 +7302,7 @@
7297
7302
  {
7298
7303
  "path": "tests/provision.mjs",
7299
7304
  "mode": "0000644",
7300
- "sha256": "edb6c5da9ec32536d50f7cb648b81b026b7926f73f891feb0906fc7343b5b11d"
7305
+ "sha256": "5297caf5005fd26c8435aa374511882a6c2d232e43f07cdc6ec19d1168ee1cc1"
7301
7306
  },
7302
7307
  {
7303
7308
  "path": "tests/provisioning_default_domain.mjs",
@@ -7307,7 +7312,7 @@
7307
7312
  {
7308
7313
  "path": "tests/provisioning_domain_attach.mjs",
7309
7314
  "mode": "0000644",
7310
- "sha256": "fbbd7397fd03181e9c2ea58ed8accb1a9d9d66d4c904a09265161e1034b02f9f"
7315
+ "sha256": "856f434c8e1a8e8ad549c81689f1bbe27ff555e4ba08f9b485c3e9d46838a4a5"
7311
7316
  },
7312
7317
  {
7313
7318
  "path": "tests/provisioning_github_app_routes.mjs",
@@ -7332,7 +7337,7 @@
7332
7337
  {
7333
7338
  "path": "tests/public_landing_projects.mjs",
7334
7339
  "mode": "0000644",
7335
- "sha256": "2d4dea6cd6be287c1d7629009adc64fba526b1ebbe53a800ae43499e1b8e3f18"
7340
+ "sha256": "7f2fe0290fc0a429b54ca53d449453e9009a57e40bb8003b2718beef33be2309"
7336
7341
  },
7337
7342
  {
7338
7343
  "path": "tests/public_mirror_export.mjs",
@@ -0,0 +1,15 @@
1
+ -- provisioning_008_dns_note.sql — transport the runner's "DNS was skipped" fact
2
+ -- to the owner's card (task 1002720 / QA-walk F16).
3
+ --
4
+ -- WHY. With BOX_DNS_HOOK unset, domain-attach wires env + Caddy but SKIPS the
5
+ -- A-record upsert — a fact that lived only in the runner's log while the owner's
6
+ -- card promised the address "will answer shortly" (it never would). This column
7
+ -- carries the outstanding manual step as a plain sentence the card renders.
8
+ --
9
+ -- OVERLAY, NOT A STATE (the provisioning_006 rule): nullable, no default,
10
+ -- never written by the lifecycle. NULL = nothing outstanding. A later attach
11
+ -- with a working hook clears it.
12
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS dns_note text;
13
+
14
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_008_dns_note')
15
+ ON CONFLICT (version) DO NOTHING;
@@ -198,6 +198,9 @@ function publicInstance(row) {
198
198
  // them differently ("live?" vs "unreachable"). Never collapse the two.
199
199
  last_verified_at: row.last_verified_at || null,
200
200
  last_verified_up: row.last_verified_up == null ? null : !!row.last_verified_up,
201
+ // The outstanding manual-DNS step, if the runner had no hook to point the
202
+ // A record (task 1002720 / F16). null = nothing outstanding.
203
+ dns_note: row.dns_note || null,
201
204
  cost_estimate_usd: Number(row.cost_estimate_usd || 0),
202
205
  error_note: row.error_note || null,
203
206
  created_at: row.created_at,
@@ -372,6 +375,30 @@ async function recordLivenessProbe(db, id, up) {
372
375
  return rows[0] || null;
373
376
  }
374
377
 
378
+ // Record (or clear, with null) the outstanding manual-DNS sentence the card
379
+ // renders (task 1002720 / F16). Same narrowness rule as recordLivenessProbe:
380
+ // no `status`, no `updated_at` — the runner's lifecycle authorship and the
381
+ // owner's card ordering stay untouched.
382
+ async function recordDnsNote(db, id, note) {
383
+ const { rows } = await db.query(
384
+ `UPDATE provisioning_instances SET dns_note = $2 WHERE id = $1 RETURNING id, dns_note`,
385
+ [id, note || null]
386
+ );
387
+ return rows[0] || null;
388
+ }
389
+
390
+ // Clear the error_note latch (task 1002720 / F19). The retire leg of the intent
391
+ // runner sets it when work stops; a later SUCCESSFUL run must release it, or the
392
+ // card shows "we stopped trying" forever on a healed instance. Same narrowness
393
+ // rule: no status, no updated_at.
394
+ async function clearErrorNote(db, id) {
395
+ const { rows } = await db.query(
396
+ `UPDATE provisioning_instances SET error_note = NULL WHERE id = $1 RETURNING id`,
397
+ [id]
398
+ );
399
+ return rows[0] || null;
400
+ }
401
+
375
402
  // The caller's own instances, newest first.
376
403
  async function listInstancesForOwner(db, ownerBuilderId) {
377
404
  const { rows } = await db.query(
@@ -701,7 +728,7 @@ module.exports = {
701
728
  isProvisionedHostname,
702
729
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
703
730
  // data access — verification overlay (task 1002696)
704
- listActiveInstancesForLiveness, recordLivenessProbe,
731
+ listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
705
732
  createInstance, setInstanceStatus, updateInstanceDomain,
706
733
  enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
707
734
  recordEvent, instanceStateForSlug, costLedger,
@@ -589,6 +589,28 @@ module.exports = function provisioningRoutes() {
589
589
  }
590
590
  const current = provisioning.normalizeHostname(inst.domain);
591
591
  if (current && current === domain) {
592
+ // Re-sending the SAME address is the owner's only re-kick when the
593
+ // wiring stopped (task 1002720 / F19) — the mirror of what POST
594
+ // /instances already does for a snagged standup. Only when the wiring
595
+ // DEMONSTRABLY needs it — a retired intent left its error_note, or the
596
+ // probe says the address doesn't answer — and nothing is in flight. A
597
+ // healthy instance (no note, verified up or unprobed) keeps the
598
+ // idempotent no-op below.
599
+ const needsRewire = inst.error_note || inst.last_verified_up === false;
600
+ if (inst.status === 'active' && needsRewire && !(await provisioning.getOpenIntent(pool, id))) {
601
+ const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
602
+ if (created) {
603
+ await provisioning.recordEvent(pool, {
604
+ instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
605
+ detail: `domain ${domain} re-attach queued (owner re-kick)`, actor: 'api:self',
606
+ }).catch(() => {});
607
+ }
608
+ // created:false = a racing enqueue got there first — still truthful.
609
+ return res.json({ ok: true, changed: false, queued: !!created, domain: inst.domain,
610
+ message: created
611
+ ? 'Wiring that address again — this card updates when it starts.'
612
+ : 'That address is already being wired — this card updates when it starts.' });
613
+ }
592
614
  return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
593
615
  message: 'This instance already has that address.' });
594
616
  }
@@ -640,8 +662,11 @@ module.exports = function provisioningRoutes() {
640
662
  actor: 'api:self',
641
663
  }).catch(() => {});
642
664
  res.json({ ok: true, changed: true, queued, domain,
665
+ // State-only sentence (task 1002720 / F16): the web tier holds no cloud
666
+ // tokens and cannot know whether a DNS hook exists on the runner, so
667
+ // "it will answer there shortly" was a promise it could not keep.
643
668
  message: queued
644
- ? 'Your instance is being wired to its new address — it will answer there shortly.'
669
+ ? 'Your instance is being wired to its new address — this card will show whether it answers.'
645
670
  : 'Address recorded — it will be wired when the instance is provisioned.' });
646
671
  } catch (err) {
647
672
  console.error('[provisioning] POST /provisioning/instances/:id/domain', err);
@@ -235,5 +235,26 @@ t('publicInstance projects the declared type — nullable, never silently droppe
235
235
  assert.equal(q.type, null, 'a row predating provisioning_007 projects null, not undefined');
236
236
  });
237
237
 
238
+ t('publicInstance projects dns_note — nullable, never silently dropped (task 1002720 / F16)', () => {
239
+ const p = P.publicInstance({ id: 1, slug: 'x', status: 'active', dns_note: 'point the A record at 203.0.113.5' });
240
+ assert.match(p.dns_note, /A record/, 'the owner-facing manual-DNS sentence must reach the card');
241
+ const q = P.publicInstance({ id: 2, slug: 'y', status: 'active' });
242
+ assert.equal(q.dns_note, null, 'a row predating provisioning_008 projects null, not undefined');
243
+ });
244
+
245
+ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no status, no updated_at', async () => {
246
+ let sql = '', params = null;
247
+ const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 5 }] }; } };
248
+ await P.recordDnsNote(db, 5, 'note text');
249
+ assert.match(sql, /SET dns_note = \$2/);
250
+ assert.doesNotMatch(sql, /status|updated_at/, 'the overlay must never reshuffle lifecycle or card order');
251
+ assert.deepEqual(params, [5, 'note text']);
252
+ await P.recordDnsNote(db, 5, null);
253
+ assert.deepEqual(params, [5, null], 'null clears');
254
+ await P.clearErrorNote(db, 5);
255
+ assert.match(sql, /SET error_note = NULL/);
256
+ assert.doesNotMatch(sql, /status\s*=|updated_at/, 'releasing the latch must not touch the lifecycle');
257
+ });
258
+
238
259
  console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
239
260
  process.exit(failed ? 1 : 0);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.127",
3
+ "version": "1.19.129",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.127",
9
+ "version": "1.19.129",
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.127",
3
+ "version": "1.19.129",
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",
@@ -228,6 +228,17 @@ h1[tabindex="-1"]:focus{outline:none;}
228
228
  .prog.isLive .pnote{color:color-mix(in srgb,var(--ok) 75%,var(--ink));font-size:14px;}
229
229
  .prog.isErr{background:var(--err-soft);border-color:color-mix(in srgb,var(--err) 25%,var(--bg-card));}
230
230
  .prog.isErr .pnote{color:var(--err);}
231
+ /* teardown's calm neutral (F22) — transcribed from cosmos's .strip.flowDown,
232
+ the same .prog-mirrors-.strip duplication as the rest of this block */
233
+ .prog.flowDown{border-color:var(--line);background:linear-gradient(180deg,var(--neutral-soft) 0%,var(--bg-card) 100%);}
234
+ .prog.flowDown .stages li:not(:first-child)::before{background:var(--line);}
235
+ .prog.flowDown .stages li.done:not(:first-child)::before{background:var(--ink-faint);}
236
+ .prog.flowDown .sdot{border-color:var(--line);}
237
+ .prog.flowDown .stages li.done .sdot{border-color:var(--ink-faint);background:var(--ink-faint);}
238
+ .prog.flowDown .stages li.now .sdot{border-color:var(--ink-soft);}
239
+ .prog.flowDown .stages li.now .sdot::after{background:var(--ink-soft);}
240
+ .prog.flowDown .slbl{color:var(--ink-faint);}
241
+ .prog.flowDown .stages li.now .slbl,.prog.flowDown .stages li.done .slbl{color:var(--ink-soft);}
231
242
  .again{
232
243
  display:inline-block;margin-top:14px;font-size:13px;font-weight:700;color:var(--ink-faint);
233
244
  background:none;border:none;cursor:pointer;font-family:var(--font-body);text-decoration:underline;
@@ -2864,25 +2875,55 @@ h1[tabindex="-1"]:focus{outline:none;}
2864
2875
  var watchPending = false; /* a parked progress watch, resumed on re-entry */
2865
2876
  var watchGen = 0; /* same orphaning contract as manifestGen above */
2866
2877
  function stopPolling() { watchGen += 1; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } }
2867
- function progressMarkup(stageNow, stagesDone, note, cls) {
2868
- var names = ['Request filed', 'Standing it up', 'Live'];
2869
- return '<div class="prog ' + (cls || '') + '" role="status" aria-live="polite">' +
2870
- '<ul class="stages">' + names.map(function (n, i) {
2878
+ /* The done panel's own flow vocabularies (F22). A local mirror of the mine
2879
+ view's FLOWS — that table lives in the OTHER page IIFE, out of scope here,
2880
+ the same isolation that makes .prog duplicate .strip. */
2881
+ var PROG_FLOWS = {
2882
+ standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
2883
+ revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
2884
+ teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' }
2885
+ };
2886
+ function progressMarkup(stageNow, stagesDone, note, cls, flow) {
2887
+ var f = PROG_FLOWS[flow || 'standup'];
2888
+ return '<div class="prog ' + (f.cls ? f.cls + ' ' : '') + (cls || '') + '" role="status" aria-live="polite">' +
2889
+ '<ul class="stages">' + f.names.map(function (n, i) {
2871
2890
  var c = stagesDone > i ? 'done' : (stageNow === i ? 'now' : '');
2872
2891
  return '<li class="' + c + '"><span class="sdot"></span><span class="slbl">' + n + '</span></li>';
2873
2892
  }).join('') + '</ul>' +
2874
2893
  '<p class="pnote">' + note + '</p></div>';
2875
2894
  }
2876
2895
  /* Renders the current instance state; returns true when the state is terminal
2877
- (no further polling wanted). */
2896
+ (no further polling wanted). The strip is chosen by the OPEN INTENT first,
2897
+ then status (F22): a torn-down instance with a queued revive must never be
2898
+ told to "start another project". */
2878
2899
  function renderProgress(inst) {
2879
2900
  var el = $('liveProgress');
2880
2901
  if (!el) return true;
2881
2902
  var s = inst && inst.status;
2903
+ var revive = !!(inst && inst.torn_down_at);
2904
+ var oi = inst && inst.open_intent;
2905
+ /* requested/provisioning/tearing_down speak their STATUS (a requested
2906
+ instance always has its provision intent open — the intent branch
2907
+ would make the status branch dead and diverge from the mine card) */
2908
+ if (oi && s !== 'provisioning' && s !== 'tearing_down' && s !== 'requested') {
2909
+ if (oi.action === 'provision') {
2910
+ el.innerHTML = progressMarkup(0, 0,
2911
+ (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this panel updates when it starts.',
2912
+ '', s === 'torn_down' ? 'revive' : 'standup');
2913
+ return false;
2914
+ }
2915
+ if (oi.action === 'teardown') {
2916
+ el.innerHTML = progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown');
2917
+ return false;
2918
+ }
2919
+ }
2882
2920
  if (s === 'requested') {
2883
- el.innerHTML = progressMarkup(1, 1, 'Waiting for a runner to pick it up. This updates the moment it starts.');
2921
+ el.innerHTML = progressMarkup(1, 1, 'Waiting for a runner to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup');
2884
2922
  } else if (s === 'provisioning') {
2885
- el.innerHTML = progressMarkup(1, 1, 'Raising the server, database, and address — nothing needed from you.');
2923
+ el.innerHTML = progressMarkup(1, 1,
2924
+ revive ? 'Bringing it back on the newest platform version — nothing needed from you.'
2925
+ : 'Raising the server, database, and address — nothing needed from you.',
2926
+ '', revive ? 'revive' : 'standup');
2886
2927
  } else if (s === 'active') {
2887
2928
  var addr = inst.domain ? 'https://' + inst.domain : null;
2888
2929
  el.innerHTML = progressMarkup(-1, 3,
@@ -2894,9 +2935,13 @@ h1[tabindex="-1"]:focus{outline:none;}
2894
2935
  false "keeps retrying" — retries stop, and the retry door is the mine view */
2895
2936
  el.innerHTML = progressMarkup(1, 1,
2896
2937
  'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from <a href="?view=mine">your projects page</a>.', 'isErr');
2897
- } else if (s === 'tearing_down' || s === 'torn_down') {
2898
- /* a deliberate end is calm, never styled as a failure (direction §5) */
2899
- el.innerHTML = progressMarkup(-1, 0, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.');
2938
+ } else if (s === 'tearing_down') {
2939
+ /* a deliberate end is calm, never styled as a failure (direction §5) —
2940
+ and it speaks the teardown vocabulary, not the standup's (F22) */
2941
+ el.innerHTML = progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; ' +
2942
+ (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'), '', 'teardown');
2943
+ } else if (s === 'torn_down') {
2944
+ el.innerHTML = progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.', '', 'teardown');
2900
2945
  return true;
2901
2946
  } else {
2902
2947
  el.innerHTML = progressMarkup(0, 1, 'Filed. Watching for the standup to start…');
@@ -2936,6 +2981,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2936
2981
  .then(function (data) {
2937
2982
  if (myGen !== watchGen) return;
2938
2983
  var inst = data.instance;
2984
+ if (inst) inst.open_intent = data.open_intent || null; /* F22: the strip reads intents */
2939
2985
  /* the checklist reflects live state (task 1002694): a status transition —
2940
2986
  e.g. the scaffold landing — re-renders the steps (change-only, so the
2941
2987
  5s tick never clobbers an open dropdown or mid-flight button) */
@@ -3198,6 +3244,10 @@ h1[tabindex="-1"]:focus{outline:none;}
3198
3244
  /* domainless instances get their first address; an assigned default may be
3199
3245
  upgraded to a bring-your-own domain (the server allows exactly that re-point) */
3200
3246
  function canAttachDomain(inst) {
3247
+ /* the re-kick door (F19): an active instance whose address wiring stopped
3248
+ (error_note, no intent in flight) can re-send its own domain — the route
3249
+ re-enqueues the attach for exactly this shape */
3250
+ if (inst.status === 'active' && inst.domain && inst.error_note && !inst.open_intent) return true;
3201
3251
  return (!inst.domain || inst.domain === assignedDefaultFor(inst)) &&
3202
3252
  (inst.status === 'requested' || inst.status === 'error' || inst.status === 'active');
3203
3253
  }
@@ -3258,7 +3308,8 @@ h1[tabindex="-1"]:focus{outline:none;}
3258
3308
  : 'Raising the server, database, and address — nothing needed from you.');
3259
3309
  }
3260
3310
  if (s === 'tearing_down') {
3261
- return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours; the database is kept.');
3311
+ return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours; ' +
3312
+ (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'));
3262
3313
  }
3263
3314
  if (inst.open_intent) {
3264
3315
  var a = inst.open_intent.action;
@@ -3297,6 +3348,14 @@ h1[tabindex="-1"]:focus{outline:none;}
3297
3348
  /* ── rendering ───────────────────────────────────────────────────────── */
3298
3349
 
3299
3350
  var SHAPE_LABEL = { standalone: 'its own server', 'co-tenant': 'shared server', dedicated: 'its own server' };
3351
+ /* what a teardown ACTUALLY does, per hosting shape (F17 — the DECIDED policy:
3352
+ retention is deliberate on shared boxes; a dedicated machine takes its
3353
+ database with it). Copy follows the runner, never the other way around. */
3354
+ function teardownTruth(inst) {
3355
+ return inst && inst.hosting_shape === 'dedicated'
3356
+ ? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched.'
3357
+ : 'The server goes away and the address stops answering. Your repository is untouched. The database is kept, so bringing it back later restores its memory.';
3358
+ }
3300
3359
 
3301
3360
  function addressRow(inst) {
3302
3361
  if (inst.domain) {
@@ -3305,6 +3364,9 @@ h1[tabindex="-1"]:focus{outline:none;}
3305
3364
  heartbeat earns it — never the runner finishing its script */
3306
3365
  var link = '<a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
3307
3366
  if (inst.last_verified_up) return 'Answering at ' + link;
3367
+ /* the runner had no DNS hook (F16): the CAUSE beats the bare symptom —
3368
+ a successful A record flips last_verified_up and retires the note */
3369
+ if (inst.dns_note) return 'Its address is ' + link + '. ' + esc(inst.dns_note);
3308
3370
  if (inst.last_verified_up === false) return 'Not answering at ' + link + ' right now.';
3309
3371
  return 'Its address is ' + link + ' — confirming it answers.';
3310
3372
  }
@@ -3335,20 +3397,28 @@ h1[tabindex="-1"]:focus{outline:none;}
3335
3397
  var addr = addressRow(inst);
3336
3398
  if (addr) body += '<div class="addr">' + addr + '</div>';
3337
3399
  if (canAttachDomain(inst)) {
3400
+ var rekick = inst.status === 'active' && inst.domain && inst.error_note;
3338
3401
  body += '<div class="attach">' +
3339
- '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253">' +
3402
+ '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253"' +
3403
+ (rekick ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
3340
3404
  '<button type="button" class="btn small" data-attach="' + iid + '">' +
3341
- (inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
3405
+ (rekick ? 'Try the address again' : inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
3342
3406
  }
3343
- if (inst.status === 'error' && !inst.open_intent) {
3407
+ if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
3344
3408
  /* once a retry is queued the strip tells the story — the stale error
3345
- paragraph would just contradict it (owner walk, 2026-08-12) */
3409
+ paragraph would just contradict it (owner walk, 2026-08-12). Also
3410
+ covers an ACTIVE instance whose intent retired with a note (F19) —
3411
+ the work stopped and the card must say so, not stay silent. */
3346
3412
  body += '<div class="projMsg err">' + errMarkup(inst) + '</div>';
3347
3413
  }
3348
3414
  body += stripFor(inst);
3349
- if (gone) {
3415
+ if (gone && !inst.open_intent) {
3416
+ /* with a revive queued, the strip tells the story — this paragraph would
3417
+ contradict it (F22, the same guard the error paragraph has) */
3350
3418
  body += '<div class="projMsg">Torn down' + (inst.torn_down_at ? ' ' + esc(shortDate(inst.torn_down_at)) : '') +
3351
- '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>';
3419
+ (inst.hosting_shape === 'dedicated'
3420
+ ? '. The repository is still yours. Bringing it back stands up a fresh machine with an empty database. Or <a href="?view=new">start another</a>.</div>'
3421
+ : '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>');
3352
3422
  }
3353
3423
  var lm = lastMsg[inst.id];
3354
3424
  body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '">' + (lm ? lm.text : '') + '</div>';
@@ -3522,7 +3592,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3522
3592
  var act = $('act-' + id);
3523
3593
  if (!act) return;
3524
3594
  act.innerHTML = '<div class="confirmRow">' +
3525
- '<span class="plain">Tear it down? The server goes away and the address stops answering. Your repository is untouched. The database is kept, so bringing it back later restores its memory.</span>' +
3595
+ '<span class="plain">Tear it down? ' + teardownTruth(byId[id]) + '</span>' +
3526
3596
  '<button type="button" class="btn small danger" data-teardown-yes="' + esc(id) + '">Yes, tear it down</button>' +
3527
3597
  '<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it running</button></div>';
3528
3598
  }
@@ -169,6 +169,14 @@ const CONFIG = {
169
169
 
170
170
  const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
171
171
 
172
+ // Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
173
+ // instance is a full Node boot away from reporting auth_configured=true, and the
174
+ // old 10×1s poll routinely lost that race — recording a failure for a setup that
175
+ // succeeded, then burning the SINGLE-USE manifest code on the retry. ~3 min of
176
+ // patience; the poll runs after the intent loop, so it delays nothing.
177
+ const MANIFEST_VERIFY_TRIES = numEnv('PROVISION_MANIFEST_VERIFY_TRIES', 90);
178
+ const MANIFEST_VERIFY_INTERVAL_MS = numEnv('PROVISION_MANIFEST_VERIFY_INTERVAL_MS', 2000);
179
+
172
180
  // ---------------------------------------------------------------------------
173
181
  // Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
174
182
  // ---------------------------------------------------------------------------
@@ -1503,9 +1511,21 @@ async function provisionInstance(inst, deps) {
1503
1511
  // only on the control plane — it must never run on the (token-free) co-hosting
1504
1512
  // box even when the box-mutating steps above went there over SSH (task 2065).
1505
1513
  controlExec(CONFIG.dnsHook, { env: dnsEnv });
1514
+ // The clear half of the birth-path note (F16): a revive after the operator
1515
+ // configured the hook must retire the stale "point your A record" sentence.
1516
+ if (apply && provisioning.recordDnsNote) {
1517
+ await provisioning.recordDnsNote(db, inst.id, null).catch(() => {});
1518
+ }
1506
1519
  }
1507
1520
  } else {
1508
1521
  log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
1522
+ // Birth-path twin of the domain-attach note (F16): a domain with no hook is
1523
+ // the same silent skip here, so the owner card gets the same sentence.
1524
+ if (apply && inst.domain) {
1525
+ if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id,
1526
+ `Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
1527
+ ).catch(() => {});
1528
+ }
1509
1529
  }
1510
1530
 
1511
1531
  // ── step 6: Caddy per-instance site snippet + reload ──────────────────────
@@ -1520,17 +1540,55 @@ async function provisionInstance(inst, deps) {
1520
1540
  } else { log(' (Caddy on the droplet via cloud-init)'); }
1521
1541
 
1522
1542
  // ── step 7: healthz ───────────────────────────────────────────────────────
1543
+ // The probe RESULT is recorded, not discarded (task 1002720 / QA-walk F15: a
1544
+ // refused connection used to ride silently into 'active' and the card said
1545
+ // live). Bounded retry so a slow first boot is not slandered — the
1546
+ // placeManifestCreds loop shape. Still ok:true either way: a failed probe
1547
+ // must not flip the intent to failed (cmdRunIntents would burn the 3-attempt
1548
+ // budget on an instance that may simply be slow); the liveness overlay is
1549
+ // what tells the owner the truth.
1523
1550
  log(' [healthz] verify the instance answers');
1524
- if (local) boxExec(healthzCmd(inst), { allowFail: true });
1525
- else log(' (verified post-boot)');
1551
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
1552
+ let healthy = null; // null = not observed (dry-run / dedicated)
1553
+ if (local) {
1554
+ const tries = apply ? 10 : 1;
1555
+ for (let i = 0; i < tries; i++) {
1556
+ const res = boxExec(healthzCmd(inst), { allowFail: true });
1557
+ if (!apply) break; // dry-run: exactly one plan line
1558
+ if (res && !res.softFailed) { healthy = true; break; }
1559
+ healthy = false;
1560
+ if (i < tries - 1) await sleep(1000);
1561
+ }
1562
+ } else log(' (verified post-boot)');
1526
1563
 
1527
- // Mark active.
1564
+ // Mark active. error_note: null — a stale note from an earlier failed run
1565
+ // must not ride into the active state (F15's second half; the co-tenant +
1566
+ // standalone paths never cleared it). The runner hands its own observation
1567
+ // to the liveness overlay so the card gets an honest verdict immediately
1568
+ // instead of waiting ~5 min for the first sweep.
1528
1569
  if (apply) {
1529
- await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, provisioned_at: new Date().toISOString() });
1530
- await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active', detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})`, actor: 'runner' });
1570
+ await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, error_note: null, provisioned_at: new Date().toISOString() });
1571
+ // The overlay column is DOMAIN-scoped when a domain exists (the sweep
1572
+ // probes https://<domain>; loopback only when there is none), and the card
1573
+ // renders up=true as "Answering at <domain>". This probe is loopback, so:
1574
+ // a FAILURE is a verdict on any shape (a dead process answers nowhere),
1575
+ // but a PASS proves only the process — for a domain-bearing instance the
1576
+ // address claim stays "not checked yet" until the sweep earns it.
1577
+ const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
1578
+ if (probeIsVerdict && provisioning.recordLivenessProbe) {
1579
+ await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
1580
+ }
1581
+ await provisioning.recordEvent(db, {
1582
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
1583
+ detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})` +
1584
+ (healthy === false ? ' — not answering /healthz yet' : ''),
1585
+ actor: 'runner',
1586
+ });
1531
1587
  }
1532
- log(` ✓ ${inst.slug} provisioned`);
1533
- return { ok: true, port: inst.port };
1588
+ log(healthy === false
1589
+ ? ` ⚠ ${inst.slug} provisioned, but it is not answering /healthz yet — its card will read "unreachable"`
1590
+ : ` ✓ ${inst.slug} provisioned`);
1591
+ return { ok: true, port: inst.port, healthy };
1534
1592
  }
1535
1593
 
1536
1594
  // The cloud-init user-data for a dedicated droplet: run the co-tenant standup
@@ -1616,6 +1674,7 @@ async function domainAttachInstance(inst, deps) {
1616
1674
 
1617
1675
  // DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
1618
1676
  // the hook needs the Cloudflare token, which never leaves the control plane).
1677
+ let dnsSkipped = false;
1619
1678
  if (CONFIG.dnsHook) {
1620
1679
  if (!recordIp) {
1621
1680
  const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
@@ -1627,7 +1686,11 @@ async function domainAttachInstance(inst, deps) {
1627
1686
  controlExec(CONFIG.dnsHook, { env: dnsEnv });
1628
1687
  }
1629
1688
  } else {
1630
- log(' [dns] skipped (BOX_DNS_HOOK unset) — point the A record manually');
1689
+ // Still ok:true below — an unset hook is unretryable, and ok:false would
1690
+ // burn the intent's retry budget on it. The skip is surfaced to the OWNER
1691
+ // via dns_note (F16), not just this operator log line.
1692
+ dnsSkipped = true;
1693
+ log(` [dns] skipped (BOX_DNS_HOOK unset) — point ${inst.domain}'s A record at ${recordIp || 'this server'} manually`);
1631
1694
  }
1632
1695
 
1633
1696
  log(' [caddy] write site snippet + reload');
@@ -1638,12 +1701,24 @@ async function domainAttachInstance(inst, deps) {
1638
1701
  boxExec(healthzCmd(inst), { allowFail: true });
1639
1702
 
1640
1703
  if (apply) {
1704
+ // Written BOTH ways so a later successful attach clears a stale note.
1705
+ if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id, dnsSkipped
1706
+ ? `Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
1707
+ : null).catch(() => {});
1708
+ // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
1709
+ // from an earlier retired attach must not outlive its own recovery.
1710
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
1641
1711
  await provisioning.recordEvent(db, {
1642
1712
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
1643
- detail: `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`, actor: 'runner',
1713
+ detail: dnsSkipped
1714
+ ? `domain ${inst.domain} wired (env + caddy); DNS SKIPPED — no hook configured`
1715
+ : `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`,
1716
+ actor: 'runner',
1644
1717
  }).catch(() => {});
1645
1718
  }
1646
- log(` ✓ ${inst.slug} addressed as ${inst.domain}`);
1719
+ log(dnsSkipped
1720
+ ? ` ✓ ${inst.slug} configured for ${inst.domain} — DNS still needs your A record`
1721
+ : ` ✓ ${inst.slug} addressed as ${inst.domain}`);
1647
1722
  return { ok: true };
1648
1723
  }
1649
1724
 
@@ -1750,11 +1825,14 @@ async function placeManifestCreds(inst, mrow, deps) {
1750
1825
 
1751
1826
  // 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
1752
1827
  // the same length/charset guard oauth-secret.js uses against silent truncation).
1828
+ // From here on the single-use manifest code is SPENT (the conversion POST
1829
+ // consumed it) — every return carries codeSpent:true so the drain never
1830
+ // resets the row for a retry that would re-convert a burned code and 404.
1753
1831
  const idV = oauthSecret.validateClientId(creds.clientId);
1754
1832
  const secV = oauthSecret.validateClientSecret(creds.clientSecret);
1755
1833
  if (!idV.ok || !secV.ok) {
1756
1834
  const why = [!idV.ok ? `id: ${idV.reason}` : null, !secV.ok ? `secret: ${secV.reason}` : null].filter(Boolean).join('; ');
1757
- return { ok: false, error: `GitHub returned unusable creds (${why})` };
1835
+ return { ok: false, codeSpent: apply, error: `GitHub returned unusable creds (${why})` };
1758
1836
  }
1759
1837
 
1760
1838
  // 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
@@ -1764,21 +1842,24 @@ async function placeManifestCreds(inst, mrow, deps) {
1764
1842
  boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1765
1843
 
1766
1844
  // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
1845
+ // PATIENT (F23): the child must boot before it can answer; losing this race
1846
+ // used to record a failure for a setup that had succeeded.
1767
1847
  if (apply) {
1848
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
1768
1849
  let authConfigured = false;
1769
- for (let i = 0; i < 10; i++) {
1850
+ for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
1770
1851
  const out = boxExec(healthzCmd(inst), { allowFail: true });
1771
1852
  if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
1772
- await new Promise((r) => setTimeout(r, 1000));
1853
+ await sleep(MANIFEST_VERIFY_INTERVAL_MS);
1773
1854
  }
1774
1855
  if (!authConfigured) {
1775
- return { ok: false, error: 'placed creds but /healthz still reports auth_configured=false — sign-in not live yet' };
1856
+ return { ok: false, codeSpent: true, error: 'the credentials are placed, but the project did not report sign-in ready in time' };
1776
1857
  }
1777
1858
  log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
1778
1859
  } else {
1779
1860
  log(' [oauth-manifest] would probe /healthz for auth_configured after restart');
1780
1861
  }
1781
- return { ok: true, appId: creds.appId, appSlug: creds.appSlug };
1862
+ return { ok: true, codeSpent: apply, appId: creds.appId, appSlug: creds.appSlug };
1782
1863
  }
1783
1864
 
1784
1865
  // Drain provisioning_oauth_manifests rows awaiting conversion (status 'code_received').
@@ -1820,11 +1901,17 @@ async function drainOAuthManifests(deps) {
1820
1901
  if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
1821
1902
  const result = await placeManifestCreds(inst, mrow, deps);
1822
1903
  if (result && result.ok === false) {
1823
- if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
1904
+ // A SPENT code is terminal, always (F23): GitHub's manifest code is
1905
+ // single-use, so resetting the row to 'code_received' guarantees the
1906
+ // next tick re-converts a burned code and 404s forever. Only failures
1907
+ // that happened BEFORE the conversion stay retryable.
1908
+ if (result.codeSpent) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error, github_code: null });
1909
+ else if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
1824
1910
  else await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', { last_error: result.error });
1825
1911
  errors++; log(` ✖ oauth-manifest #${mrow.id}: ${result.error}`);
1826
1912
  } else {
1827
- await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug });
1913
+ // github_code: null on done too — a finished row holds no re-convertible code.
1914
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug, github_code: null });
1828
1915
  await provisioning.recordEvent(db, {
1829
1916
  instanceId: inst.id, ownerBuilderId: mrow.requested_by, event: 'oauth-manifest-done',
1830
1917
  detail: `sign-in wired via GitHub App Manifest (app ${result.appSlug || result.appId || '?'})`, actor: 'runner',
@@ -1880,8 +1967,17 @@ async function cmdRunIntents(deps) {
1880
1967
  else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1881
1968
  else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
1882
1969
  if (result && result.ok === false) {
1883
- if (intent.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1884
- else await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1970
+ if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
1971
+ await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1972
+ // A retired intent stops silently otherwise (F19): record the reason
1973
+ // on the instance WITHOUT changing its lifecycle status — passing the
1974
+ // CURRENT status makes the status write a no-op, so a healthy active
1975
+ // instance is never marked broken over a domain hiccup, but the card
1976
+ // can say why the work stopped.
1977
+ await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed' }).catch(() => {});
1978
+ } else {
1979
+ await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1980
+ }
1885
1981
  errors++;
1886
1982
  } else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
1887
1983
  } catch (e) {
package/src/module-api.js CHANGED
@@ -681,8 +681,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
681
681
  // landed since 1.19.125 with no explicit bump. run 31853055269. (task 1002620)
682
682
  // 1.19.127 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
683
683
  // landed since 1.19.126 with no explicit bump. run 31854730552. (task 1002620)
684
+ // 1.19.128 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
685
+ // landed since 1.19.127 with no explicit bump. run 31857842473. (task 1002620)
686
+ // 1.19.129 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
687
+ // landed since 1.19.128 with no explicit bump. run 31858158416. (task 1002620)
684
688
  // ---------------------------------------------------------------------------
685
- const CORE_VERSION = '1.19.127'; // CI auto-patch carrier (ADR 0161); see changelog above
689
+ const CORE_VERSION = '1.19.129'; // CI auto-patch carrier (ADR 0161); see changelog above
686
690
 
687
691
  // A namespaced logger so a module's log lines are attributable + consistent.
688
692
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -1033,17 +1033,40 @@ await ta('placeManifestCreds rejects a dedicated droplet (no post-hoc exec path)
1033
1033
  assert.equal(noCode.ok, false); assert.match(noCode.error, /no github_code/);
1034
1034
  });
1035
1035
 
1036
- await ta('placeManifestCreds fails when /healthz never reports auth_configured (never claims success)', async () => {
1036
+ await ta('verification timeout reports the code as SPENT and does not disown the placement (F23)', async () => {
1037
1037
  const inst = { ...coTenant, domain: 'mercury.example.com' };
1038
1038
  const deps = {
1039
- apply: true, privileged: false, log: () => {},
1039
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1040
1040
  convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }),
1041
1041
  exec: () => ({ ok: true, stdout: '{"ok":true,"auth_configured":false}' }),
1042
1042
  writeFile: () => ({ ok: true }),
1043
1043
  };
1044
1044
  const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1045
1045
  assert.equal(r.ok, false);
1046
- assert.match(r.error, /auth_configured=false/);
1046
+ assert.equal(r.codeSpent, true, 'the conversion happened — the single-use code is burned');
1047
+ assert.match(r.error, /credentials are placed/, 'the error owns what DID happen');
1048
+ assert.doesNotMatch(r.error, /\b\d+s\b|minute|second/i, 'no wall-clock promises (§5.2)');
1049
+ });
1050
+
1051
+ await ta('verification PATIENCE wins the boot race — one conversion, success (F23)', async () => {
1052
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1053
+ let probes = 0, converts = 0;
1054
+ const deps = {
1055
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1056
+ convertManifestCode: async () => { converts++; return { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }; },
1057
+ exec: (cmd) => {
1058
+ if (/healthz/.test(cmd)) {
1059
+ probes++;
1060
+ return { ok: true, stdout: probes > 20 ? '{"auth_configured":true}' : '{"auth_configured":false}' };
1061
+ }
1062
+ return { ok: true, stdout: '' };
1063
+ },
1064
+ writeFile: () => ({ ok: true }),
1065
+ };
1066
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1067
+ assert.equal(r.ok, true, 'a slow child boot is waited out, not recorded as a failure');
1068
+ assert.equal(converts, 1, 'the single-use code is converted exactly once');
1069
+ assert.ok(probes > 10, 'the old 10-probe budget would have lost this race');
1047
1070
  });
1048
1071
 
1049
1072
  await ta('drainOAuthManifests (--apply): claims a code_received row, places creds, resolves done', async () => {
@@ -1064,9 +1087,57 @@ await ta('drainOAuthManifests (--apply): claims a code_received row, places cred
1064
1087
  assert.equal(resolved.length, 1);
1065
1088
  assert.equal(resolved[0].status, 'done');
1066
1089
  assert.equal(resolved[0].patch.app_slug, 'app-3');
1090
+ assert.equal(resolved[0].patch.github_code, null, 'a done row holds no re-convertible code (F23)');
1067
1091
  assert.ok(events.includes('oauth-manifest-done'));
1068
1092
  });
1069
1093
 
1094
+ await ta('a SPENT code is never re-converted — terminal error, no code_received reset (F23)', async () => {
1095
+ // A stateful one-row store: claimNext honors status, resolve writes it back —
1096
+ // exactly the reset loop that used to re-convert a burned code on the next tick.
1097
+ const row = { id: 21, instance_id: 1, github_code: 'BURNED', attempts: 1, status: 'code_received' };
1098
+ const resolves = []; let converts = 0;
1099
+ const deps = {
1100
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1101
+ db: {}, writeFile: () => ({ ok: true }),
1102
+ exec: (cmd) => (/healthz/.test(cmd) ? { ok: true, stdout: '{"auth_configured":false}' } : { ok: true, stdout: '' }),
1103
+ convertManifestCode: async () => { converts++; return { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 3, appSlug: 'a' }; },
1104
+ provisioning: {
1105
+ claimNextOAuthManifest: async () => (row.status === 'code_received' ? { ...row } : null),
1106
+ getInstanceById: async () => ({ ...coTenant, domain: 'mercury.example.com' }),
1107
+ resolveOAuthManifest: async (_db, _id, status, patch = {}) => {
1108
+ resolves.push({ status, patch });
1109
+ row.status = status;
1110
+ if ('github_code' in patch) row.github_code = patch.github_code;
1111
+ },
1112
+ recordEvent: async () => {},
1113
+ },
1114
+ };
1115
+ await P.drainOAuthManifests(deps); // tick 1: converts, placement verification times out
1116
+ await P.drainOAuthManifests(deps); // tick 2: must find nothing to claim
1117
+ assert.equal(converts, 1, 'the single-use code was converted exactly once across ticks');
1118
+ assert.equal(row.status, 'error', 'a spent code is terminal');
1119
+ assert.equal(row.github_code, null, 'the burned code is scrubbed from the row');
1120
+ assert.ok(!resolves.some((x) => x.status === 'code_received'), 'never reset for a doomed retry');
1121
+ });
1122
+
1123
+ await ta('a PRE-conversion failure stays retryable — the narrowing spared the retry path (F23)', async () => {
1124
+ let claims = 0; const resolves = [];
1125
+ const deps = {
1126
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1127
+ db: {}, exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
1128
+ provisioning: {
1129
+ claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 22, instance_id: 1, github_code: 'C', attempts: 1 } : null),
1130
+ // dedicated shape → placeManifestCreds refuses BEFORE converting (codeSpent absent)
1131
+ getInstanceById: async () => ({ ...coTenant, hosting_shape: 'dedicated', domain: 'd.example.com' }),
1132
+ resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
1133
+ recordEvent: async () => {},
1134
+ },
1135
+ };
1136
+ await P.drainOAuthManifests(deps);
1137
+ assert.equal(resolves.length, 1);
1138
+ assert.equal(resolves[0].status, 'code_received', 'an unconverted code may retry on a later tick');
1139
+ });
1140
+
1070
1141
  console.log('\nprivileged mode (PROVISION_SUDO — task 2066):');
1071
1142
 
1072
1143
  t('dbCreateCmd: privileged runs createdb as the postgres role; default is bare (peer auth)', () => {
@@ -1502,17 +1573,20 @@ console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its l
1502
1573
  // bodies), and recorded events. No status writes to capture — domain-attach must
1503
1574
  // never touch the instance lifecycle.
1504
1575
  function attachRecorder({ apply = false } = {}) {
1505
- const cmds = [], envs = [], writes = [], events = [];
1576
+ const cmds = [], envs = [], writes = [], events = [], dnsNotes = [];
1506
1577
  const deps = {
1507
1578
  apply, log: () => {},
1508
1579
  db: { query: async () => ({ rows: [] }) },
1509
- provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
1580
+ provisioning: {
1581
+ recordEvent: async (_db, e) => { events.push(e); },
1582
+ recordDnsNote: async (_db, id, note) => { dnsNotes.push({ id, note }); },
1583
+ },
1510
1584
  exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
1511
1585
  writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
1512
1586
  doApi: null,
1513
1587
  checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
1514
1588
  };
1515
- return { deps, cmds, envs, writes, events };
1589
+ return { deps, cmds, envs, writes, events, dnsNotes };
1516
1590
  }
1517
1591
 
1518
1592
  const activeStandalone = {
@@ -1630,5 +1704,197 @@ await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through
1630
1704
  assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
1631
1705
  });
1632
1706
 
1707
+ console.log('\nprovisioning truthfulness (task 1002720 — QA-walk F15/F16/F19):');
1708
+
1709
+ // An apply-mode recorder for provisionInstance: every command succeeds except
1710
+ // (optionally) the healthz probe, which soft-fails the way a refused connection
1711
+ // does under allowFail.
1712
+ function truthRecorder({ healthzFails = false } = {}) {
1713
+ const cmds = [], statuses = [], patches = [], probes = [], logs = [], events = [], dnsNotes = [];
1714
+ const deps = {
1715
+ apply: true, privileged: false, log: (m) => logs.push(String(m)), sleep: async () => {},
1716
+ db: { query: async () => ({ rows: [] }) },
1717
+ provisioning: {
1718
+ setInstanceStatus: async (_db, _id, s, patch) => { statuses.push(s); patches.push(patch || {}); },
1719
+ recordLivenessProbe: async (_db, _id, up) => { probes.push(up); },
1720
+ recordDnsNote: async (_db, _id, note) => { dnsNotes.push(note); },
1721
+ recordEvent: async (_db, e) => { events.push(e); },
1722
+ getInstanceBySlug: async () => null, getInstanceById: async () => null,
1723
+ claimNextIntent: async () => null, resolveIntent: async () => {},
1724
+ enqueueIntent: async () => ({ created: true }),
1725
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
1726
+ },
1727
+ exec: (cmd) => {
1728
+ cmds.push(cmd);
1729
+ if (healthzFails && /healthz/.test(cmd)) return { ok: true, stdout: '', softFailed: true };
1730
+ return { ok: true, stdout: '' };
1731
+ },
1732
+ writeFile: () => ({ ok: true }),
1733
+ doApi: null,
1734
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
1735
+ getGithubToken: async () => null,
1736
+ };
1737
+ return { deps, cmds, statuses, patches, probes, logs, events, dnsNotes };
1738
+ }
1739
+
1740
+ await ta('F15: a refused /healthz is RECORDED — probe handed to the overlay, note cleared, success line qualified', async () => {
1741
+ const { deps, cmds, statuses, patches, probes, logs, events } = truthRecorder({ healthzFails: true });
1742
+ const r = await P.provisionInstance({ ...coTenant }, deps);
1743
+ assert.equal(r.ok, true, 'a failed probe must NOT fail the run — that would burn the intent retry budget');
1744
+ assert.equal(r.healthy, false);
1745
+ assert.equal(statuses[statuses.length - 1], 'active', 'the lifecycle still activates (the overlay carries the truth)');
1746
+ assert.equal(patches[patches.length - 1].error_note, null, 'a stale error_note never rides into active');
1747
+ assert.deepEqual(probes, [false], 'the runner hands its observation to the liveness overlay');
1748
+ assert.ok(cmds.filter((c) => /healthz/.test(c)).length >= 2, 'a slow boot gets a bounded retry, not one shot');
1749
+ assert.ok(logs.some((l) => /not answering/.test(l)), 'the operator line is qualified, not a bare ✓');
1750
+ const activeEvent = events.find((e) => e.event === 'active');
1751
+ assert.match(activeEvent.detail, /not answering/, 'the event detail carries the observation');
1752
+ });
1753
+
1754
+ await ta('F15: a passing /healthz records up=true immediately — no five-minute "live?" limbo', async () => {
1755
+ const { deps, cmds, probes, logs } = truthRecorder();
1756
+ const r = await P.provisionInstance({ ...coTenant }, deps);
1757
+ assert.equal(r.ok, true);
1758
+ assert.equal(r.healthy, true);
1759
+ assert.deepEqual(probes, [true]);
1760
+ assert.equal(cmds.filter((c) => /healthz/.test(c)).length, 1, 'a pass ends the loop on the first probe');
1761
+ assert.ok(logs.some((l) => /✓ mercury provisioned$/.test(l)), 'the plain success line survives');
1762
+ });
1763
+
1764
+ await ta('F16: a no-hook attach is honest — ok:true, dns_note written, event says SKIPPED', async () => {
1765
+ const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
1766
+ P.CONFIG.dnsHook = null; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
1767
+ try {
1768
+ const { deps, events, dnsNotes } = attachRecorder({ apply: true });
1769
+ const r = await P.domainAttachInstance({ ...activeStandalone }, deps);
1770
+ assert.equal(r.ok, true, 'an unset hook is unretryable — ok:false would burn the retry budget');
1771
+ assert.equal(dnsNotes.length, 1);
1772
+ assert.match(dnsNotes[0].note, /A record/, 'the owner is told the outstanding manual step');
1773
+ assert.match(dnsNotes[0].note, /203\.0\.113\.9/, 'the note names the target IP');
1774
+ assert.doesNotMatch(dnsNotes[0].note, /shortly|minute|second/i, 'no wall-clock promises (§5.2)');
1775
+ const detail = events.map((e) => e.detail).join('\n');
1776
+ assert.match(detail, /DNS SKIPPED/, 'the event stops claiming DNS was re-asserted');
1777
+ } finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
1778
+ });
1779
+
1780
+ await ta('F16: a hooked attach CLEARS a stale dns_note', async () => {
1781
+ const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
1782
+ P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh'; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
1783
+ try {
1784
+ const { deps, dnsNotes } = attachRecorder({ apply: true });
1785
+ const r = await P.domainAttachInstance({ ...activeStandalone }, deps);
1786
+ assert.equal(r.ok, true);
1787
+ assert.deepEqual(dnsNotes, [{ id: activeStandalone.id, note: null }], 'a successful attach retires the note');
1788
+ } finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
1789
+ });
1790
+
1791
+ await ta('F19: a RETIRED intent records its reason on the instance without changing its status', async () => {
1792
+ let claims = 0; const resolved = []; const statusWrites = [];
1793
+ const deps = {
1794
+ apply: true, log: () => {},
1795
+ db: { query: async () => ({ rows: [] }) },
1796
+ provisioning: {
1797
+ claimNextIntent: async () => (claims++ === 0
1798
+ ? { id: 9, instance_id: 18, action: 'domain-attach', attempts: P.MAX_INTENT_ATTEMPTS } : null),
1799
+ // an ACTIVE instance with no domain → the attach leg returns ok:false (unretryable)
1800
+ getInstanceById: async () => ({ ...activeStandalone, domain: null }),
1801
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
1802
+ setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
1803
+ recordEvent: async () => {},
1804
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
1805
+ },
1806
+ exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
1807
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
1808
+ };
1809
+ const r = await P.cmdRunIntents(deps);
1810
+ assert.equal(r.errors, 1);
1811
+ assert.equal(resolved[0].state, 'error', 'the intent retires');
1812
+ assert.equal(statusWrites.length, 1, 'the retire is surfaced on the instance');
1813
+ assert.equal(statusWrites[0].s, 'active', 'status write is a NO-OP on status — a healthy instance is never marked broken');
1814
+ assert.match(statusWrites[0].patch.error_note, /no domain recorded/, 'the reason survives where the card reads it');
1815
+ });
1816
+
1817
+ await ta('F19: a NON-final failure re-pends the intent and writes nothing on the instance', async () => {
1818
+ let claims = 0; const resolved = []; const statusWrites = []; const updates = [];
1819
+ const deps = {
1820
+ apply: true, log: () => {},
1821
+ db: { query: async (sql) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push(sql); return { rows: [] }; } },
1822
+ provisioning: {
1823
+ claimNextIntent: async () => (claims++ === 0
1824
+ ? { id: 10, instance_id: 18, action: 'domain-attach', attempts: 1 } : null),
1825
+ getInstanceById: async () => ({ ...activeStandalone, domain: null }),
1826
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
1827
+ setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
1828
+ recordEvent: async () => {},
1829
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
1830
+ },
1831
+ exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
1832
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
1833
+ };
1834
+ await P.cmdRunIntents(deps);
1835
+ assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
1836
+ assert.equal(updates.length, 1, 're-pended via the intents table');
1837
+ assert.equal(statusWrites.length, 0, 'no instance write until the intent actually retires');
1838
+ });
1839
+
1840
+ console.log('\nreview-panel hardening (task 1002720 — the loopback/latch/birth-note seams):');
1841
+
1842
+ await ta('a PASSING loopback probe on a DOMAIN-bearing instance records NO verdict — the address claim belongs to the sweep', async () => {
1843
+ const { deps, probes } = truthRecorder();
1844
+ const r = await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
1845
+ assert.equal(r.ok, true);
1846
+ assert.equal(r.healthy, true);
1847
+ assert.deepEqual(probes, [], 'loopback-up proves the process, not the domain — "Answering at" must stay unearned');
1848
+ });
1849
+
1850
+ await ta('a FAILED loopback probe is a verdict on any shape — recorded even with a domain', async () => {
1851
+ const { deps, probes } = truthRecorder({ healthzFails: true });
1852
+ const r = await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
1853
+ assert.equal(r.healthy, false);
1854
+ assert.deepEqual(probes, [false], 'a dead process answers nowhere — down is down');
1855
+ });
1856
+
1857
+ await ta('birth-path dns_note: written when the hook is unset, CLEARED when a later run has one', async () => {
1858
+ const savedHook = P.CONFIG.dnsHook, savedPub = P.CONFIG.publicIp;
1859
+ P.CONFIG.publicIp = '203.0.113.5';
1860
+ try {
1861
+ P.CONFIG.dnsHook = null;
1862
+ const a = truthRecorder();
1863
+ await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, a.deps);
1864
+ assert.equal(a.dnsNotes.length, 1, 'the no-hook birth writes the note');
1865
+ assert.match(a.dnsNotes[0], /A record/);
1866
+ P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
1867
+ const b = truthRecorder();
1868
+ await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, b.deps);
1869
+ assert.deepEqual(b.dnsNotes, [null], 'a hooked run (e.g. a revive) retires the stale note');
1870
+ } finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.publicIp = savedPub; }
1871
+ });
1872
+
1873
+ await ta('a successful domain-attach releases the F19 error_note latch', async () => {
1874
+ const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
1875
+ P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh'; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
1876
+ try {
1877
+ const { deps } = attachRecorder({ apply: true });
1878
+ const cleared = [];
1879
+ deps.provisioning.clearErrorNote = async (_db, id) => { cleared.push(id); };
1880
+ const r = await P.domainAttachInstance({ ...activeStandalone, error_note: 'DNS preflight failed' }, deps);
1881
+ assert.equal(r.ok, true);
1882
+ assert.deepEqual(cleared, [activeStandalone.id], 'the "we stopped trying" paragraph must not outlive its recovery');
1883
+ } finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
1884
+ });
1885
+
1886
+ await ta('UNUSABLE creds from a successful conversion still report codeSpent — no re-POST of a burned code', async () => {
1887
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1888
+ const deps = {
1889
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1890
+ convertManifestCode: async () => ({ clientId: 'short', clientSecret: 'also-short' }),
1891
+ exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
1892
+ };
1893
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1894
+ assert.equal(r.ok, false);
1895
+ assert.match(r.error, /unusable creds/);
1896
+ assert.equal(r.codeSpent, true, 'the conversion RESOLVED — GitHub burned the code even though the creds are junk');
1897
+ });
1898
+
1633
1899
  console.log(`\nprovision: ${passed} passed, ${failed} failed`);
1634
1900
  process.exit(failed ? 1 : 0);
@@ -189,6 +189,46 @@ await ta('same domain again is idempotent — 200, changed:false, nothing re-que
189
189
  assert.equal(enqueueCalls.length, 0);
190
190
  });
191
191
 
192
+ await ta('same domain again RE-ENQUEUES when the wiring stopped — the owner re-kick (task 1002720 / F19)', async () => {
193
+ // A retired domain-attach intent left its error_note; re-sending the same
194
+ // address is the owner's re-kick. The idempotent no-op above still holds for
195
+ // a healthy instance — this branch needs the demonstrated failure.
196
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'DNS preflight failed' } });
197
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
198
+ assert.equal(res.status, 200, res.raw);
199
+ assert.equal(res.body.changed, false);
200
+ assert.equal(res.body.queued, true, 'the attach intent re-queues');
201
+ assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
202
+ assert.equal(updateCalls.length, 0, 'the domain itself is untouched');
203
+ assert.match(res.body.message, /Wiring that address again/);
204
+ });
205
+
206
+ await ta('a verified-DOWN address re-enqueues too; an open intent keeps the no-op', async () => {
207
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', last_verified_up: false } });
208
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
209
+ assert.equal(res.body.queued, true, 'probed-down wiring may be re-kicked');
210
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'x' } });
211
+ const savedGetOpen = provisioning.getOpenIntent;
212
+ provisioning.getOpenIntent = async () => ({ id: 5, action: 'domain-attach' });
213
+ try {
214
+ const res2 = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
215
+ assert.equal(res2.body.queued, false, 'an in-flight intent is never doubled');
216
+ assert.equal(enqueueCalls.length, 0);
217
+ } finally { provisioning.getOpenIntent = savedGetOpen; }
218
+ });
219
+
220
+ await ta('re-kick honors enqueueIntent created:false — a racing enqueue is never double-reported', async () => {
221
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'x' } });
222
+ const savedEnqueue = provisioning.enqueueIntent;
223
+ provisioning.enqueueIntent = async (...a) => { enqueueCalls.push({ action: a[2] }); return { intent: { id: 5 }, created: false }; };
224
+ try {
225
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
226
+ assert.equal(res.status, 200, res.raw);
227
+ assert.equal(res.body.queued, false, 'queued mirrors created, like the sibling attach path');
228
+ assert.match(res.body.message, /already being wired/);
229
+ } finally { provisioning.enqueueIntent = savedEnqueue; }
230
+ });
231
+
192
232
  await ta('a DIFFERENT domain already set is a truthful 409 domain_already_set (re-pointing is not this route)', async () => {
193
233
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'old.cloudbongos.com' } });
194
234
  const res = await post('/provisioning/instances/18/domain', { domain: 'new.cloudbongos.com' });
@@ -117,6 +117,39 @@ test('the empty state points a new owner at the wizard view — never at a hall'
117
117
  assert.doesNotMatch(body, /Enter the Hall/i);
118
118
  });
119
119
 
120
+ test('teardown copy states what the runner actually does, per hosting shape (F17)', async () => {
121
+ const { body } = await request('/projects', 'game.test.local');
122
+ // the DECIDED policy: shared shapes keep the database; a dedicated machine
123
+ // takes its database with it — the copy branches on the shape field
124
+ assert.match(body, /The database is kept/, 'the kept-shape promise is present');
125
+ assert.match(body, /database goes with (it|the machine)/, 'the dedicated branch exists');
126
+ assert.match(body, /hosting_shape === 'dedicated'/, 'shape-driven, not flat prose');
127
+ assert.match(body, /repository is untouched|repository stays yours|repository is still yours/i);
128
+ // the pre-walk over-claim can never come back
129
+ assert.doesNotMatch(body, /server and database go away/i);
130
+ assert.doesNotMatch(body, /database (is|will be) (deleted|dropped|destroyed|removed)/i);
131
+ });
132
+
133
+ test('the done panel reads the open intent — a queued revive is never told to start another (F22)', async () => {
134
+ const { body } = await request('/projects', 'game.test.local');
135
+ const fn = (body.match(/function renderProgress\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
136
+ assert.ok(fn, 'renderProgress() must be extractable');
137
+ assert.match(fn, /open_intent/, 'the strip is chosen from the open intent, not status alone');
138
+ assert.match(fn, /revive/, 'a queued revive on a torn-down instance gets the revive vocabulary');
139
+ // a requested instance ALWAYS has its provision intent open — the status
140
+ // branch must win there or it goes dead and diverges from the mine card
141
+ assert.match(fn, /!== 'requested'/, 'requested speaks its status, not its (always-present) intent');
142
+ assert.match(body, /inst\.open_intent = data\.open_intent \|\| null/, 'the watch hands the intent through');
143
+ // teardown speaks its own stage names in the done panel, in its own palette
144
+ assert.match(fn, /'teardown'/, 'the teardown flow is used');
145
+ assert.match(body, /\.prog\.flowDown/, 'the .prog strip has the teardown palette (transcribed from .strip.flowDown)');
146
+ });
147
+
148
+ test('the address row surfaces the manual-DNS note the runner recorded (F16)', async () => {
149
+ const { body } = await request('/projects', 'game.test.local');
150
+ assert.match(body, /inst\.dns_note/, 'the card reads the dns_note the runner writes');
151
+ });
152
+
120
153
  test('the watch give-up hands the owner their projects view — "the hall has it" is dead', async () => {
121
154
  // the wizard (and its 33-minute watch cap) lives on /projects now
122
155
  const { body } = await request('/projects', 'game.test.local');