@bongos/core 1.19.991 → 1.19.992

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.991",
6
- "core_contract": "1.19.991",
7
- "source_commit": "e80119e1a3eaf21835fda089e6279bdff4043c04",
5
+ "core_version": "1.19.992",
6
+ "core_contract": "1.19.992",
7
+ "source_commit": "b050bdcbee08970a2f54ba939e4ef7f7b2583381",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T20:27:54.477Z",
9
+ "built_at": "2026-09-23T20:51:51.595Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 518,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2398,
14
+ "functional_verbatim": 2401,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2942,
20
- "tree_sha256": "2e7708f19e4b524ede30ec609d2809a2d7e6bc77953d5d3106cbd01170134253",
19
+ "file_count": 2945,
20
+ "tree_sha256": "4097079e3007248addbcbb19a43c23b3584f074f63cfb4a36951d04dd28d1886",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2712,7 +2712,7 @@
2712
2712
  {
2713
2713
  "path": "docs/module-api-changelog.md",
2714
2714
  "mode": "0000644",
2715
- "sha256": "4f19e3d159ebd6ef4268a44b5805c0ad09cedcfb482d4e36e486f892e1e8df6e"
2715
+ "sha256": "47569dfa1c5d507174585d586c5ab619caa3bbf99bbeb24cdb26381ea25cb5e7"
2716
2716
  },
2717
2717
  {
2718
2718
  "path": "docs/modules-contract.md",
@@ -6629,6 +6629,11 @@
6629
6629
  "mode": "0000644",
6630
6630
  "sha256": "a99596f5d3780a426ce9a5fcb287123dc860a04dfa08ce32a9ab81b2e06938ef"
6631
6631
  },
6632
+ {
6633
+ "path": "modules/provisioning/error-note.js",
6634
+ "mode": "0000644",
6635
+ "sha256": "f00a21da3c27933cd34cb66a7d99ef1104eac1180d0655e8fa59e96f9a366ace"
6636
+ },
6632
6637
  {
6633
6638
  "path": "modules/provisioning/migrations/provisioning_001_tables.sql",
6634
6639
  "mode": "0000644",
@@ -6754,6 +6759,11 @@
6754
6759
  "mode": "0000644",
6755
6760
  "sha256": "949130ba321fc382c30fd7bfb76572e8415492b8641f644719cef3658a415cb9"
6756
6761
  },
6762
+ {
6763
+ "path": "modules/provisioning/migrations/provisioning_026_error_note_action.sql",
6764
+ "mode": "0000644",
6765
+ "sha256": "6697b56875a74f7a69c4dfcdcf1e3553d8c0eed1f54e12d4763d72b805ada46f"
6766
+ },
6757
6767
  {
6758
6768
  "path": "modules/provisioning/module.json",
6759
6769
  "mode": "0000644",
@@ -6792,7 +6802,7 @@
6792
6802
  {
6793
6803
  "path": "modules/provisioning/provisioning.js",
6794
6804
  "mode": "0000644",
6795
- "sha256": "a6c7b6cdaca50106bd59b86e0272a2e24fe269a43e4a7f6e3a63504e73fa74d5"
6805
+ "sha256": "17958cac0da80a65e5b835df4517e145c741dd43ff468ad79b92d75eb1dc62c9"
6796
6806
  },
6797
6807
  {
6798
6808
  "path": "modules/provisioning/rate-limit.js",
@@ -6837,7 +6847,7 @@
6837
6847
  {
6838
6848
  "path": "modules/provisioning/routes/provisioning.js",
6839
6849
  "mode": "0000644",
6840
- "sha256": "a10d9ac50f6be584c33a6bb559ad9d0431c2ac7f4655c23fcdf47baedf540cd2"
6850
+ "sha256": "7738dad310a0de636739fecd9d98036c1d9e4381f7e972337fa8c01d59ccfe9c"
6841
6851
  },
6842
6852
  {
6843
6853
  "path": "modules/provisioning/routes/repo-private.js",
@@ -8167,12 +8177,12 @@
8167
8177
  {
8168
8178
  "path": "package-lock.json",
8169
8179
  "mode": "0000644",
8170
- "sha256": "8693404033a617a03140037d079df84b7e71b967394f7f4069b0681667761be8"
8180
+ "sha256": "3b59f773dda29971e3d49ea6d78364db3e51600641e9211c9860dfb0aa1b1d9e"
8171
8181
  },
8172
8182
  {
8173
8183
  "path": "package.json",
8174
8184
  "mode": "0000644",
8175
- "sha256": "4b6ac3b9b46c3053276678a73c6aaef328f9a832523b8efc82386645aabc6345"
8185
+ "sha256": "56028c71f30866f4beea2f6902771f011ce4fefe51b5448b5b48075bf2fdf8f1"
8176
8186
  },
8177
8187
  {
8178
8188
  "path": "public-docs/index.html",
@@ -9127,7 +9137,7 @@
9127
9137
  {
9128
9138
  "path": "scripts/gds/provision-core-upgrade.js",
9129
9139
  "mode": "0000644",
9130
- "sha256": "3e9467d52cf9b704fc3cfa9f103e86c22b4128bdc021dee104e192f5f13a5ccf"
9140
+ "sha256": "9d6ef473dcf4b9d8bbaa8c5a796d8d21f761b3732a0279981ff9e60aadf25919"
9131
9141
  },
9132
9142
  {
9133
9143
  "path": "scripts/gds/provision-disconnect.js",
@@ -9152,7 +9162,7 @@
9152
9162
  {
9153
9163
  "path": "scripts/gds/provision-repo-private.js",
9154
9164
  "mode": "0000644",
9155
- "sha256": "1209c7ae79f6ba568d8ae5b0da28a4a52d27447d24b0faf7d8496196e0110138"
9165
+ "sha256": "f342f5f37c96c3167a867697af0edaa70d07ec5e27eee29a0cde3fdf7e360c4d"
9156
9166
  },
9157
9167
  {
9158
9168
  "path": "scripts/gds/provision-repo.js",
@@ -9172,7 +9182,7 @@
9172
9182
  {
9173
9183
  "path": "scripts/gds/provision.js",
9174
9184
  "mode": "0000644",
9175
- "sha256": "7747b46f241bddb4257a56461703f82603dd58e18d22bd2c547fd7137749d0e6"
9185
+ "sha256": "6662c9bf5e47532a4a996eead3d48599b8f0d469818ab4b7c545421ab78955d8"
9176
9186
  },
9177
9187
  {
9178
9188
  "path": "scripts/gds/publish-credential-check.js",
@@ -10212,7 +10222,7 @@
10212
10222
  {
10213
10223
  "path": "src/module-api.js",
10214
10224
  "mode": "0000644",
10215
- "sha256": "fa9ef668d1c3a3762533b0b74415725117d45c789d3f8b3b2c6cda3ab8344b47"
10225
+ "sha256": "860067ab748034a9318878640268ecb791138d914e0ee3e8ea7d6ee2ee6137f8"
10216
10226
  },
10217
10227
  {
10218
10228
  "path": "src/module-loader/catalog.js",
@@ -13357,7 +13367,7 @@
13357
13367
  {
13358
13368
  "path": "tests/provision_repo_private.mjs",
13359
13369
  "mode": "0000644",
13360
- "sha256": "cd97e188c5b842b60d99b250d164a2b2da39e25fb56081064c9ec44107518356"
13370
+ "sha256": "2bce22a05f7e0e51a412145657ec5c662e9f2545b8097be197a502e2d8ba4713"
13361
13371
  },
13362
13372
  {
13363
13373
  "path": "tests/provision_restart.mjs",
@@ -13407,7 +13417,7 @@
13407
13417
  {
13408
13418
  "path": "tests/provisioning_domain_attach.mjs",
13409
13419
  "mode": "0000644",
13410
- "sha256": "8a5bc0b4b1ab854cb1c5dc2d8c4a1330f38c445455651e2de15b258938acc6ef"
13420
+ "sha256": "de931d33b0c0557a0901f3f5bb5403e5f6e9b49aa8c0a2955de81d4ea7fd2d2a"
13411
13421
  },
13412
13422
  {
13413
13423
  "path": "tests/provisioning_domain_lookup_index.mjs",
@@ -13424,6 +13434,11 @@
13424
13434
  "mode": "0000644",
13425
13435
  "sha256": "1c6ea2d8ec4a8b3836a1976511ac3727fd158ac88db373efe89950bbadf4296f"
13426
13436
  },
13437
+ {
13438
+ "path": "tests/provisioning_error_note_scope.mjs",
13439
+ "mode": "0000644",
13440
+ "sha256": "741d5bfeb01be36d9293fa0f9225a60574c4ef58e3f3649f22919fd82c928df1"
13441
+ },
13427
13442
  {
13428
13443
  "path": "tests/provisioning_github_app_routes.mjs",
13429
13444
  "mode": "0000644",
@@ -13472,7 +13487,7 @@
13472
13487
  {
13473
13488
  "path": "tests/provisioning_settings_apply.mjs",
13474
13489
  "mode": "0000644",
13475
- "sha256": "ab01149d73158276a73464bc0feca3474ee2f054db03af378c9b43874a2056fb"
13490
+ "sha256": "f4ac3da586f80cc85664c9fb3b04794153f707b334ccedbeef780d901510e20f"
13476
13491
  },
13477
13492
  {
13478
13493
  "path": "tests/provisioning_settings_env.mjs",
@@ -2456,5 +2456,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2456
2456
  landed since 1.19.989 with no explicit bump. run 35912578496. (task 1002620)
2457
2457
  1.19.991 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2458
2458
  landed since 1.19.990 with no explicit bump. run 35916061384. (task 1002620)
2459
+ 1.19.992 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2460
+ landed since 1.19.991 with no explicit bump. run 35918717050. (task 1002620)
2459
2461
  ---------------------------------------------------------------------------
2460
2462
  ```
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ // error-note.js — the "we stopped trying" note (F19, task 1002720) belongs to the action
3
+ // that wrote it (task 1004197).
4
+ //
5
+ // The intent runner's retire leg writes error_note AND error_note_action (provisioning_026).
6
+ // A successful run releases only its own action's note: a restart that worked says nothing
7
+ // about a domain that never attached, so another action's note stays on the card. The
8
+ // owner's re-kick doors ask the same question — re-sent settings re-queue a push only over
9
+ // a settings-apply note. A note with no recorded action predates the column and counts as
10
+ // anyone's, which is how every note behaved before.
11
+ //
12
+ // Its own file because provisioning.js sits at the size ratchet.
13
+
14
+ // Is there a note on this row that `action` wrote (or that nobody is recorded as writing)? PURE.
15
+ function ownsErrorNote(inst, action) {
16
+ if (!inst || !inst.error_note) return false;
17
+ return inst.error_note_action == null || inst.error_note_action === action;
18
+ }
19
+
20
+ // Release the note after a successful run of `action`. One statement, so a note another
21
+ // action writes in between is never erased. Same narrowness rule as recordDnsNote: no
22
+ // status, no updated_at.
23
+ async function clearErrorNote(db, id, action) {
24
+ const { rows } = await db.query(
25
+ `UPDATE provisioning_instances SET error_note = NULL, error_note_action = NULL
26
+ WHERE id = $1 AND error_note IS NOT NULL AND (error_note_action IS NULL OR error_note_action = $2)
27
+ RETURNING id`,
28
+ [id, action ?? null]
29
+ );
30
+ return rows[0] || null;
31
+ }
32
+
33
+ module.exports = { clearErrorNote, ownsErrorNote };
@@ -0,0 +1,20 @@
1
+ -- provisioning_026_error_note_action.sql — record WHICH action wrote an instance's
2
+ -- "we stopped trying" note (task 1004197; the F19 latch, task 1002720).
3
+ --
4
+ -- WHY. A retired intent writes error_note onto the project card, and a later successful run
5
+ -- releases it. Without the author, "released" meant ANY success: a restart that worked erased
6
+ -- a domain-attach note although the domain still was not attached, and a make-private that
7
+ -- worked cleared nothing at all. With it, a success clears only the note its own action wrote,
8
+ -- and the owner's re-kick doors (same settings / same address re-sent) fire only over a note
9
+ -- their own action left.
10
+ --
11
+ -- NULL means "written before this column existed" (or never written); readers treat such a
12
+ -- note as nobody's in particular, which is exactly how every note behaved before.
13
+ --
14
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
15
+
16
+ BEGIN;
17
+
18
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS error_note_action text;
19
+
20
+ COMMIT;
@@ -1012,17 +1012,9 @@ async function recordDnsNote(db, id, note) {
1012
1012
  return rows[0] || null;
1013
1013
  }
1014
1014
 
1015
- // Clear the error_note latch (task 1002720 / F19). The retire leg of the intent
1016
- // runner sets it when work stops; a later SUCCESSFUL run must release it, or the
1017
- // card shows "we stopped trying" forever on a healed instance. Same narrowness
1018
- // rule: no status, no updated_at.
1019
- async function clearErrorNote(db, id) {
1020
- const { rows } = await db.query(
1021
- `UPDATE provisioning_instances SET error_note = NULL WHERE id = $1 RETURNING id`,
1022
- [id]
1023
- );
1024
- return rows[0] || null;
1025
- }
1015
+ // The error_note latch (task 1002720 / F19) belongs to the action that wrote it (task
1016
+ // 1004197, provisioning_026). Its own file: this one is at the size ratchet.
1017
+ const { clearErrorNote, ownsErrorNote } = require('./error-note.js');
1026
1018
 
1027
1019
  // The caller's own instances, newest first.
1028
1020
  async function listInstancesForOwner(db, ownerBuilderId) {
@@ -1119,7 +1111,7 @@ async function setInstanceStatus(db, id, status, patch = {}) {
1119
1111
  const params = [id, status];
1120
1112
  const COLS = {
1121
1113
  domain: 'domain', db_name: 'db_name', port: 'port', tier: 'tier',
1122
- target_ref: 'target_ref', error_note: 'error_note',
1114
+ target_ref: 'target_ref', error_note: 'error_note', error_note_action: 'error_note_action',
1123
1115
  cost_estimate_usd: 'cost_estimate_usd',
1124
1116
  provisioned_at: 'provisioned_at', torn_down_at: 'torn_down_at',
1125
1117
  };
@@ -1487,7 +1479,7 @@ module.exports = {
1487
1479
  isProvisionedHostname,
1488
1480
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
1489
1481
  // data access — verification overlay (task 1002696)
1490
- listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
1482
+ listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote, ownsErrorNote,
1491
1483
  // data access — the owed-push marker (task 1003524)
1492
1484
  recordSettingsPushOwed,
1493
1485
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
@@ -901,7 +901,7 @@ module.exports = function provisioningRoutes() {
901
901
  // "send it again", and folding the second in is what makes that sentence
902
902
  // true: the re-send is unchanged, so it would otherwise hit "No change".
903
903
  const pushOwed = provisioning.settingsPushOwed(inst);
904
- const policyResent = (!!inst.error_note || pushOwed) && policyTouched;
904
+ const policyResent = (provisioning.ownsErrorNote(inst, 'settings-apply') || pushOwed) && policyTouched;
905
905
  // Raising `artist_gate` to 'strict' also stamps WHEN (ADR 0241 §5 makes the
906
906
  // tightening non-retroactive, and the instance cannot honour that without the
907
907
  // date). It rides the same single UPDATE as the setting it belongs to, and is
@@ -1131,7 +1131,7 @@ module.exports = function provisioningRoutes() {
1131
1131
  // probe says the address doesn't answer — and nothing is in flight. A
1132
1132
  // healthy instance (no note, verified up or unprobed) keeps the
1133
1133
  // idempotent no-op below.
1134
- const needsRewire = inst.error_note || inst.last_verified_up === false;
1134
+ const needsRewire = provisioning.ownsErrorNote(inst, 'domain-attach') || inst.last_verified_up === false;
1135
1135
  if (inst.status === 'active' && needsRewire && !(await provisioning.getOpenIntent(pool, id))) {
1136
1136
  const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
1137
1137
  if (created) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.991",
3
+ "version": "1.19.992",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.991",
9
+ "version": "1.19.992",
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.991",
3
+ "version": "1.19.992",
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",
@@ -274,7 +274,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
274
274
  detail: served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed`,
275
275
  actor: intent.requested_by || 'runner',
276
276
  }).catch(() => {});
277
- if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
277
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'core-upgrade').catch(() => {});
278
278
  log(served ? ` ✓ ${inst.slug} serves core ${served}` : ` ⚠ ${inst.slug} upgraded to ${to}, but did not answer /version — unconfirmed`);
279
279
  return { ok: true, served, confirmed: served === to };
280
280
  }
@@ -89,6 +89,8 @@ async function repoPrivateInstance(inst, deps) {
89
89
  detail: `${repo.owner}/${repo.name} made private — the project now reads it with its own read-only deploy key${wired.minted ? ' (newly registered)' : ''}`,
90
90
  actor: 'runner',
91
91
  }).catch(() => {});
92
+ // A make-private that failed earlier left "still public" on the card; this run made it private.
93
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'repo-private').catch(() => {});
92
94
  log(` ✓ ${repo.owner}/${repo.name} is private; ${inst.slug} reads it with its own deploy key`);
93
95
  return { ok: true, minted: !!wired.minted };
94
96
  }
@@ -844,7 +844,7 @@ async function domainAttachInstance(inst, deps) {
844
844
  : null).catch(() => {});
845
845
  // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
846
846
  // from an earlier retired attach must not outlive its own recovery.
847
- if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
847
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'domain-attach').catch(() => {});
848
848
  await provisioning.recordEvent(db, {
849
849
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
850
850
  detail: dnsSkipped
@@ -957,7 +957,7 @@ async function settingsApplyInstance(inst, deps) {
957
957
  if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
958
958
  // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
959
959
  // from an earlier retired push must not outlive its own recovery.
960
- if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
960
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'settings-apply').catch(() => {});
961
961
  await provisioning.recordEvent(db, {
962
962
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'settings-applied',
963
963
  detail: `settings pushed (${shown}) — web.env patched in place; ${outcome}`,
@@ -1038,8 +1038,8 @@ async function restartInstance(inst, deps) {
1038
1038
  // that HAS a domain. Silence after the whole budget is a verdict on any shape.
1039
1039
  const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
1040
1040
  if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
1041
- // A restart that worked retires any "we stopped trying" note left by earlier
1042
- // work (the F19 latch) — the note outliving its own recovery is the bug.
1041
+ // A restart that worked retires the "we stopped trying" note a failed restart
1042
+ // left (the F19 latch); another action's note stays (task 1004197).
1043
1043
  // DELIBERATELY gated on `healthy`, NOT on probeIsVerdict: the two answer
1044
1044
  // different questions. probeIsVerdict asks "may this run claim the instance is
1045
1045
  // UP?" — a loopback pass may not, for an instance whose address the sweep owns.
@@ -1047,7 +1047,7 @@ async function restartInstance(inst, deps) {
1047
1047
  // answered is that work finishing, whatever the public address does next.
1048
1048
  // Holding the note until the sweep agrees would leave a stale "nothing more
1049
1049
  // will happen" on the card of a project that just visibly recovered.
1050
- if (healthy && provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
1050
+ if (healthy && provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'restart').catch(() => {});
1051
1051
  // Name WHAT was observed, not a verdict this probe cannot reach. healthzCmd is
1052
1052
  // loopback (127.0.0.1:<port>) while the liveness sweep probes the PUBLIC
1053
1053
  // https://<domain>/healthz — so an instance can answer here while DNS, Caddy or
@@ -1303,7 +1303,7 @@ async function cmdRunIntents(deps) {
1303
1303
  // CURRENT status makes the status write a no-op, so a healthy active
1304
1304
  // instance is never marked broken over a domain hiccup, but the card
1305
1305
  // can say why the work stopped.
1306
- await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed' }).catch(() => {});
1306
+ await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed', error_note_action: intent.action }).catch(() => {});
1307
1307
  } else {
1308
1308
  await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1309
1309
  }
@@ -1312,7 +1312,7 @@ async function cmdRunIntents(deps) {
1312
1312
  } catch (e) {
1313
1313
  await provisioning.resolveIntent(db, intent.id, 'error', e.message).catch(() => {});
1314
1314
  // Surface the failure on the instance too, so a stuck instance is visible.
1315
- await provisioning.setInstanceStatus(db, intent.instance_id, 'error', { error_note: e.message }).catch(() => {});
1315
+ await provisioning.setInstanceStatus(db, intent.instance_id, 'error', { error_note: e.message, error_note_action: intent.action }).catch(() => {});
1316
1316
  errors++; log(` ✖ intent #${intent.id}: ${e.message}`);
1317
1317
  }
1318
1318
  }
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.991'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.992'; // 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');
@@ -92,6 +92,22 @@ await ta('the deploy key is registered and the checkout re-pointed BEFORE the re
92
92
  assert.equal(r.timeline[flip][2], 'example-owner/demo', 'the flip targets the project repository');
93
93
  });
94
94
 
95
+ await ta('a make-private that worked releases the note an earlier retired make-private left (task 1004197); a failed one does not', async () => {
96
+ // Arrange
97
+ const ok = recorder();
98
+ const bad = recorder({ flipFails: new Error('GitHub said no') });
99
+ const cleared = [];
100
+ for (const r of [ok, bad]) r.deps.provisioning.clearErrorNote = async (_db, id, action) => { cleared.push({ id, action, r }); };
101
+ // Act
102
+ const good = await repoPrivateInstance(liveStandalone, ok.deps);
103
+ const failed = await repoPrivateInstance(liveStandalone, bad.deps);
104
+ // Assert
105
+ assert.equal(good.ok, true);
106
+ assert.equal(failed.ok, false);
107
+ assert.deepEqual(cleared.map(({ id, action }) => ({ id, action })), [{ id: 18, action: 'repo-private' }], 'cleared once, by the run that worked, scoped to its own action');
108
+ assert.equal(cleared[0].r, ok);
109
+ });
110
+
95
111
  await ta('the wiring is a PRE-flip wiring — it runs although GitHub still reports the repository public', async () => {
96
112
  const r = recorder();
97
113
  await repoPrivateInstance(liveStandalone, r.deps);
@@ -245,6 +245,17 @@ await ta('same domain again RE-ENQUEUES when the wiring stopped — the owner re
245
245
  assert.match(res.body.message, /Wiring that address again/);
246
246
  });
247
247
 
248
+ await ta('another action\'s note is NOT a re-wire: the same address re-sent over a retired RESTART\'s note stays the no-op (task 1004197)', async () => {
249
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'could not restart spike', error_note_action: 'restart' } });
250
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
251
+ assert.equal(res.status, 200, res.raw);
252
+ assert.equal(res.body.queued, false, 'a restart note says nothing about the wiring');
253
+ assert.equal(enqueueCalls.length, 0);
254
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'DNS preflight failed', error_note_action: 'domain-attach' } });
255
+ const own = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
256
+ assert.equal(own.body.queued, true, 'its OWN retired attach still re-kicks');
257
+ });
258
+
248
259
  await ta('a verified-DOWN address re-enqueues too; an open intent keeps the no-op', async () => {
249
260
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', last_verified_up: false } });
250
261
  const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
@@ -0,0 +1,147 @@
1
+ // tests/provisioning_error_note_scope.mjs — the "we stopped trying" note belongs to the
2
+ // action that wrote it (task 1004197; the F19 latch, task 1002720).
3
+ //
4
+ // What is pinned:
5
+ // 1. both retire paths record the retiring intent's action beside the note, through the
6
+ // setInstanceStatus whitelist, and provisioning_026 adds that column idempotently;
7
+ // 2. clearErrorNote releases only its own action's note (or one with no recorded
8
+ // action), in one narrow statement; ownsErrorNote answers the same question for the
9
+ // owner's re-kick doors;
10
+ // 3. every success leg names its own action — no unscoped clear is left.
11
+ // The re-kick doors themselves are pinned in tests/provisioning_settings_apply.mjs and
12
+ // tests/provisioning_domain_attach.mjs.
13
+ //
14
+ // DB-free: CI has no Postgres, so the SQL is exercised through a recording fake.
15
+ //
16
+ // Run: node tests/provisioning_error_note_scope.mjs
17
+ import { strict as assert } from 'node:assert';
18
+ import { createRequire } from 'node:module';
19
+ import { readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { makeRunner } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const P = require('../scripts/gds/provision.js');
27
+ const { clearErrorNote, ownsErrorNote } = require('../modules/provisioning/error-note.js');
28
+ const provisioning = require('../modules/provisioning/provisioning.js');
29
+
30
+ const { test, summary } = makeRunner();
31
+
32
+ const activeStandalone = {
33
+ id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
34
+ port: 3010, db_name: 'spike', domain: null, target_ref: 'jaxri/spike-harbor',
35
+ };
36
+
37
+ function recordingDb(rows = [{ id: 18 }]) {
38
+ const calls = [];
39
+ return { calls, query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } };
40
+ }
41
+
42
+ // ---- 1. the author is recorded ------------------------------------------------------
43
+
44
+ function retireDeps(getInstanceById) {
45
+ let claims = 0;
46
+ const patches = [];
47
+ const deps = {
48
+ apply: true, log: () => {},
49
+ db: { query: async () => ({ rows: [] }) },
50
+ provisioning: {
51
+ claimNextIntent: async () => (claims++ === 0
52
+ ? { id: 9, instance_id: 18, action: 'domain-attach', attempts: P.MAX_INTENT_ATTEMPTS } : null),
53
+ getInstanceById,
54
+ resolveIntent: async () => {},
55
+ setInstanceStatus: async (_db, _id, _s, patch) => { patches.push(patch); },
56
+ recordEvent: async () => {},
57
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
58
+ },
59
+ exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
60
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
61
+ checkCaddyWiring: () => null,
62
+ };
63
+ return { deps, patches };
64
+ }
65
+
66
+ await test('a retire that returned ok:false records its action beside the note', async () => {
67
+ // Arrange: an active instance with no domain makes the attach leg fail on its last attempt.
68
+ const r = retireDeps(async () => ({ ...activeStandalone }));
69
+ // Act
70
+ await P.cmdRunIntents(r.deps);
71
+ // Assert
72
+ assert.equal(r.patches.length, 1);
73
+ assert.ok(r.patches[0].error_note, 'the reason reaches the card');
74
+ assert.equal(r.patches[0].error_note_action, 'domain-attach');
75
+ });
76
+
77
+ await test('a retire by a throw records its action beside the note', async () => {
78
+ const r = retireDeps(async () => { throw new Error('box unreachable'); });
79
+ await P.cmdRunIntents(r.deps);
80
+ assert.deepEqual(r.patches[0], { error_note: 'box unreachable', error_note_action: 'domain-attach' });
81
+ });
82
+
83
+ await test('setInstanceStatus writes error_note_action (it is on the whitelist)', async () => {
84
+ const db = recordingDb();
85
+ await provisioning.setInstanceStatus(db, 18, 'active', { error_note: 'x', error_note_action: 'restart' });
86
+ assert.match(db.calls[0].sql, /error_note_action = \$\d/);
87
+ assert.ok(db.calls[0].params.includes('restart'));
88
+ });
89
+
90
+ await test('provisioning_026 adds the column idempotently, and nothing else', () => {
91
+ const sql = readFileSync(path.join(ROOT, 'modules/provisioning/migrations/provisioning_026_error_note_action.sql'), 'utf8')
92
+ .replace(/--.*$/gm, '');
93
+ assert.match(sql, /ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS error_note_action text;/);
94
+ assert.doesNotMatch(sql, /DROP|UPDATE|DELETE/i, 'additive only (ADR 0083)');
95
+ });
96
+
97
+ // ---- 2. who may release the note ------------------------------------------------------
98
+
99
+ await test('ownsErrorNote: own action, or no recorded action, owns the note; another action does not', () => {
100
+ assert.equal(ownsErrorNote({ error_note: 'x', error_note_action: 'restart' }, 'restart'), true);
101
+ assert.equal(ownsErrorNote({ error_note: 'x', error_note_action: null }, 'restart'), true, 'a note from before the column is anyone\'s, as before');
102
+ assert.equal(ownsErrorNote({ error_note: 'x' }, 'restart'), true);
103
+ assert.equal(ownsErrorNote({ error_note: 'x', error_note_action: 'domain-attach' }, 'restart'), false);
104
+ assert.equal(ownsErrorNote({ error_note: null, error_note_action: 'restart' }, 'restart'), false, 'no note, nothing to own');
105
+ assert.equal(ownsErrorNote(null, 'restart'), false);
106
+ });
107
+
108
+ await test('clearErrorNote releases only its own action\'s note, in one narrow statement', async () => {
109
+ const db = recordingDb();
110
+ const out = await clearErrorNote(db, 18, 'repo-private');
111
+ assert.deepEqual(out, { id: 18 });
112
+ assert.equal(db.calls.length, 1, 'one statement — no read-then-write window');
113
+ const { sql, params } = db.calls[0];
114
+ assert.match(sql, /SET error_note = NULL, error_note_action = NULL/);
115
+ assert.match(sql, /error_note IS NOT NULL AND \(error_note_action IS NULL OR error_note_action = \$2\)/,
116
+ 'another action\'s note does not match, so it stays');
117
+ assert.deepEqual(params, [18, 'repo-private']);
118
+ assert.doesNotMatch(sql, /status\s*=|updated_at/, 'releasing the latch never touches the lifecycle');
119
+ });
120
+
121
+ await test('an unscoped clear passes null, which releases only a note with no recorded action', async () => {
122
+ const db = recordingDb([]);
123
+ assert.equal(await clearErrorNote(db, 18), null);
124
+ assert.deepEqual(db.calls[0].params, [18, null]);
125
+ });
126
+
127
+ await test('provisioning.js re-exports both from error-note.js', () => {
128
+ assert.equal(provisioning.clearErrorNote, clearErrorNote);
129
+ assert.equal(provisioning.ownsErrorNote, ownsErrorNote);
130
+ });
131
+
132
+ // ---- 3. every success leg names its own action -------------------------------------
133
+
134
+ await test('each success leg clears with its OWN action, and no unscoped clear is left', () => {
135
+ const want = {
136
+ 'scripts/gds/provision.js': ['domain-attach', 'settings-apply', 'restart'],
137
+ 'scripts/gds/provision-core-upgrade.js': ['core-upgrade'],
138
+ 'scripts/gds/provision-repo-private.js': ['repo-private'],
139
+ };
140
+ for (const [file, actions] of Object.entries(want)) {
141
+ const src = readFileSync(path.join(ROOT, file), 'utf8');
142
+ const calls = [...src.matchAll(/clearErrorNote\(db, inst\.id(?:, '([a-z-]+)')?\)/g)].map((m) => m[1] || '(none)');
143
+ assert.deepEqual(calls.sort(), [...actions].sort(), `${file}: ${calls.join(', ')}`);
144
+ }
145
+ });
146
+
147
+ summary();
@@ -323,6 +323,20 @@ await ta('the owner re-kick: same values re-sent while a retired push left its e
323
323
  receiptShape(r.body.push, { queued: true, restart: true, reachable: true });
324
324
  });
325
325
 
326
+ await ta('another action\'s note is NOT a re-kick: same values re-sent over a retired RESTART\'s note → "No change", nothing queued (task 1004197)', async () => {
327
+ // A settings push that works cannot release a restart's note, so treating that note as
328
+ // "the push is owed" would re-queue a push (and a restart) on every unchanged save.
329
+ reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated' }, error_note: 'could not restart spike', error_note_action: 'restart' } });
330
+ const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
331
+ assert.equal(r.status, 200, r.raw);
332
+ assert.equal(enqueueCalls.length, 0, 'no push re-queued');
333
+ assert.equal(updateCalls.length, 0);
334
+ reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated' }, error_note: 'could not apply the settings on spike', error_note_action: 'settings-apply' } });
335
+ const own = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
336
+ assert.deepEqual(enqueueCalls.map((c) => c.id), [18], 'its OWN retired push still re-kicks');
337
+ receiptShape(own.body.push, { queued: true, restart: true, reachable: true });
338
+ });
339
+
326
340
  console.log('\nthe state gate — store, queue nothing, say when it takes effect:');
327
341
 
328
342
  await ta('requested / provisioning: stored, NO intent (standup writes the same values), message says so', async () => {