@bongos/core 1.19.676 → 1.19.677

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.676",
6
- "core_contract": "1.19.676",
7
- "source_commit": "9aad4d0005991e8ba38f4443b4e5611a13aa3af8",
5
+ "core_version": "1.19.677",
6
+ "core_contract": "1.19.677",
7
+ "source_commit": "edcffc22cdab15c1f61e5cd2cb2b9d6a13872150",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-11T20:44:28.469Z",
9
+ "built_at": "2026-09-11T21:42:20.049Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 474,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2621,
20
- "tree_sha256": "b556a6e9644d99b65ffdf9ac64384eee16b20ecfb7e31ed60c875d76297310f3",
20
+ "tree_sha256": "2afeb3b9fd9e3cf3de8feb833f7710642830cc081102cfd1a58d9e7ea6057332",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1917,7 +1917,7 @@
1917
1917
  {
1918
1918
  "path": "docs/api/openapi.json",
1919
1919
  "mode": "0000644",
1920
- "sha256": "301cf1db00b9f18b84cb765bbaa359329a665b1ef754dff4c21c1550ec60cae5"
1920
+ "sha256": "45efd65847e2b9e5ade3d21eef0bc3ec3562184b109927f93425887ca99b02ba"
1921
1921
  },
1922
1922
  {
1923
1923
  "path": "docs/architecture.md",
@@ -2792,7 +2792,7 @@
2792
2792
  {
2793
2793
  "path": "docs/module-api-changelog.md",
2794
2794
  "mode": "0000644",
2795
- "sha256": "b8786c58575f93eb93661ee1d7e0745f0993f5666e5237ca073177df2d3ee9f4"
2795
+ "sha256": "2fbf6cfad023d90c93ffcc7cadce65e73e578cead78069011d2b47b23a0de8e5"
2796
2796
  },
2797
2797
  {
2798
2798
  "path": "docs/modules-contract.md",
@@ -6482,7 +6482,7 @@
6482
6482
  {
6483
6483
  "path": "modules/provisioning/provisioning.js",
6484
6484
  "mode": "0000644",
6485
- "sha256": "32083b07e4c676f9a68e0b78df3e5f5a0cea45fe0cb168a32fe1180165c99fbe"
6485
+ "sha256": "0f82fbdf4838a7e72d4128c76b4da7f05ed4578b8a20c167c7fb2e95cb1e9237"
6486
6486
  },
6487
6487
  {
6488
6488
  "path": "modules/provisioning/rate-limit.js",
@@ -6507,7 +6507,7 @@
6507
6507
  {
6508
6508
  "path": "modules/provisioning/routes/provisioning.js",
6509
6509
  "mode": "0000644",
6510
- "sha256": "202dcfb416317eda72696455ace487d36021377d87a6c122df9561bc087939a8"
6510
+ "sha256": "c83cec200215e9cb04446592e1aed32daf8e083142416bdc1f25d261f460d1cf"
6511
6511
  },
6512
6512
  {
6513
6513
  "path": "modules/provisioning/starter-bundles.js",
@@ -7767,12 +7767,12 @@
7767
7767
  {
7768
7768
  "path": "package-lock.json",
7769
7769
  "mode": "0000644",
7770
- "sha256": "3b0ebf2f1fb870085a40531295fb3c5989be5ab399e2493e43a1e3e844b0633c"
7770
+ "sha256": "f9c8562e66253cdfd8487dfb052d7a3443c477d4bb1abfef8b53946cd5104691"
7771
7771
  },
7772
7772
  {
7773
7773
  "path": "package.json",
7774
7774
  "mode": "0000644",
7775
- "sha256": "b4c1e3c5189ac7220af056624bdc0476a0d751c00aa76aba2e84416128280bc7"
7775
+ "sha256": "7be7c3c8b5ca557dbbf7f5a38453fedb85bd004e53b170e1746a03a02ab75ae9"
7776
7776
  },
7777
7777
  {
7778
7778
  "path": "public-docs/index.html",
@@ -9532,7 +9532,7 @@
9532
9532
  {
9533
9533
  "path": "src/module-api.js",
9534
9534
  "mode": "0000644",
9535
- "sha256": "406c218b56b011b4560887f2ae4e13f20e227fa4404e6763649e2eac2b0f5292"
9535
+ "sha256": "025ba17e4ac4f614e81bde6009db0ed9ba04ccfb3d226abcc92e9e51d24b97b9"
9536
9536
  },
9537
9537
  {
9538
9538
  "path": "src/module-loader/catalog.js",
@@ -12132,7 +12132,7 @@
12132
12132
  {
12133
12133
  "path": "tests/provisioning_settings_apply.mjs",
12134
12134
  "mode": "0000644",
12135
- "sha256": "7fc80fc5576aba1dd10c0416899ade5fa2c9380ea8c1a995aaa99745aa56c29c"
12135
+ "sha256": "a55a1c88958710a8b84048b88ba5d68b05e4489242a777852af8a2c9991462fb"
12136
12136
  },
12137
12137
  {
12138
12138
  "path": "tests/provisioning_settings_env.mjs",
@@ -12149,7 +12149,7 @@
12149
12149
  "provisioning"
12150
12150
  ],
12151
12151
  "summary": "PATCH /provisioning/instances/:id/settings",
12152
- "description": "PATCH /provisioning/instances/:id/settings — change a project's settings. Body: any subset of the known keys; strict validation refuses unknown keys and off-vocabulary values. rank: any authenticated builder (own resource; archon may change any — the override is audited via the event's actor). The platform's row is updated first; then, for an ACTIVE instance on a shape the runner can reach, a 'settings-apply' intent is enqueued (task 1003140): the control-plane runner patches the instance's web.env in place and RESTARTS it, because the branding pack is memoized for the process lifetime. A save that changes nothing neither writes nor restarts. The response is a request receipt — `saved` is what the platform now stores, and `push` { queued, restart, reachable, message } says whether a restart was queued and when the value takes effect — never a claim the instance already runs it; a client reads that back from the instance's own public GET /instance manifest (brand.project). 409 intent_conflict (details.recorded: true) when the open slot is held by other work — the change IS saved.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
12152
+ "description": "PATCH /provisioning/instances/:id/settings — change a project's settings. Body: any subset of the known keys; strict validation refuses unknown keys and off-vocabulary values. rank: any authenticated builder (own resource; archon may change any — the override is audited via the event's actor). The platform's row is updated first; then, for an ACTIVE instance on a shape the runner can reach, a 'settings-apply' intent is enqueued (task 1003140): the control-plane runner patches the instance's web.env in place and RESTARTS it, because the branding pack is memoized for the process lifetime. A save that changes nothing neither writes nor restarts. The response is a request receipt — `saved` is what the platform now stores, and `push` { queued, restart, reachable, message } says whether a restart was queued and when the value takes effect — never a claim the instance already runs it; a client reads that back from the instance's own public GET /instance manifest (brand.project). 409 intent_conflict (details.recorded: true) when the open slot is held by other work — the change IS saved. A save whose push did not get carried (that 409, or an enqueue that threw) marks the push OWED, which is what makes the \"send it again\" both of those name actually re-queue instead of hitting the no-op \"No change\" branch (task 1003524).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
12153
12153
  "x-rank": "any-builder",
12154
12154
  "x-source": "modules/provisioning/routes/provisioning.js",
12155
12155
  "parameters": [
@@ -1811,5 +1811,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1811
1811
  landed since 1.19.674 with no explicit bump. run 34639371461. (task 1002620)
1812
1812
  1.19.676 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1813
1813
  landed since 1.19.675 with no explicit bump. run 34645751352. (task 1002620)
1814
+ 1.19.677 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1815
+ landed since 1.19.676 with no explicit bump. run 34650610723. (task 1002620)
1814
1816
  ---------------------------------------------------------------------------
1815
1817
  ```
@@ -441,6 +441,12 @@ ${lines}
441
441
  `;
442
442
  }
443
443
 
444
+ // The reserved settings-jsonb key settingsPushOwed/recordSettingsPushOwed use
445
+ // (task 1003524). Deliberately NOT in SETTINGS_VOCAB or PLANET_VOCAB — it is the
446
+ // platform's own bookkeeping, not a setting an owner declares, and the test
447
+ // asserts it stays out of both so it can never start projecting as one.
448
+ const SETTINGS_PUSH_OWED_KEY = 'push_owed';
449
+
444
450
  // The instance's EFFECTIVE settings: the defaults overlaid with the stored,
445
451
  // on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
446
452
  // its default rather than leaking into the read surface. PURE.
@@ -466,6 +472,30 @@ function planetChoice(row) {
466
472
  return out;
467
473
  }
468
474
 
475
+ // Is a POLICY settings push OWED to this instance (task 1003524)? Set when the
476
+ // row was written but its push demonstrably did not get carried: the open slot
477
+ // was held by other work (409 intent_conflict), or the enqueue itself threw.
478
+ // Both of those answer the owner "send it again" — and without this marker that
479
+ // remedy is a NO-OP, because the re-send is by definition unchanged and takes
480
+ // the "No change" branch, leaving the saved value stranded on the platform. (The
481
+ // only way out was toggling to a third value and back.)
482
+ //
483
+ // It lives as a RESERVED key in the settings jsonb rather than a column so it
484
+ // costs no migration — the provisioning_009 rationale, which is why that column
485
+ // is jsonb in the first place. It can neither leak nor be client-written:
486
+ // effectiveSettings / planetChoice / settingsEnvVars project ONLY vocabulary
487
+ // keys, and the route builds its patch only from settingKeys.
488
+ //
489
+ // error_note is the sibling marker and the two do not overlap: that one latches
490
+ // a push that WAS queued and then failed (F19), this one a push that was never
491
+ // queued at all. Together they make "send it again" true on every branch that
492
+ // says it. PURE.
493
+ function settingsPushOwed(row) {
494
+ const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
495
+ ? row.settings : {};
496
+ return stored[SETTINGS_PUSH_OWED_KEY] === true;
497
+ }
498
+
469
499
  // A description as it may be STORED: trimmed, collapsed of runaway whitespace,
470
500
  // capped, and empty-means-absent. Returns null for anything that isn't real prose,
471
501
  // because null is what leaves the project dark-matter — an empty string stored in a
@@ -669,6 +699,25 @@ async function updateInstanceSettings(db, id, patch) {
669
699
  return rows[0] || null;
670
700
  }
671
701
 
702
+ // Mark (or clear) the owed-push marker settingsPushOwed reads (task 1003524).
703
+ // Same narrowness rule as recordDnsNote / clearErrorNote: no `status`, and no
704
+ // `updated_at` — this is the platform's own bookkeeping, and bumping updated_at
705
+ // would reshuffle the owner's card ordering (listInstancesForOwner orders by it)
706
+ // for a write the owner did not make. Clearing DELETES the key rather than
707
+ // storing false, so a row that never owed a push is byte-identical to one whose
708
+ // debt was settled.
709
+ async function recordSettingsPushOwed(db, id, owed) {
710
+ const { rows } = await db.query(
711
+ `UPDATE provisioning_instances
712
+ SET settings = CASE WHEN $2::boolean
713
+ THEN settings || $3::jsonb
714
+ ELSE settings - $4::text END
715
+ WHERE id = $1 RETURNING id, settings`,
716
+ [id, !!owed, JSON.stringify({ [SETTINGS_PUSH_OWED_KEY]: true }), SETTINGS_PUSH_OWED_KEY]
717
+ );
718
+ return rows[0] || null;
719
+ }
720
+
672
721
  // Fill in (or change) the project-detail answers AFTER creation — the other half
673
722
  // of "skippable" (task 1002334). Skipping the step at creation is a supported
674
723
  // default, but it is only a default and not a dead end if there is a way back; this
@@ -1290,6 +1339,7 @@ module.exports = {
1290
1339
  // pure
1291
1340
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
1292
1341
  defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
1342
+ settingsPushOwed,
1293
1343
  normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
1294
1344
  // pure — GitHub App Manifest flow (task 2080)
1295
1345
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
@@ -1298,6 +1348,8 @@ module.exports = {
1298
1348
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
1299
1349
  // data access — verification overlay (task 1002696)
1300
1350
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
1351
+ // data access — the owed-push marker (task 1003524)
1352
+ recordSettingsPushOwed,
1301
1353
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
1302
1354
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
1303
1355
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
@@ -853,7 +853,10 @@ module.exports = function provisioningRoutes() {
853
853
  // and when the value takes effect — never a claim the instance already runs
854
854
  // it; a client reads that back from the instance's own public GET /instance
855
855
  // manifest (brand.project). 409 intent_conflict (details.recorded: true) when
856
- // the open slot is held by other work — the change IS saved.
856
+ // the open slot is held by other work — the change IS saved. A save whose push
857
+ // did not get carried (that 409, or an enqueue that threw) marks the push OWED,
858
+ // which is what makes the "send it again" both of those name actually re-queue
859
+ // instead of hitting the no-op "No change" branch (task 1003524).
857
860
  router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
858
861
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
859
862
  if (!id) return;
@@ -898,7 +901,13 @@ module.exports = function provisioningRoutes() {
898
901
  const policyKeys = Object.keys(provisioning.SETTINGS_VOCAB);
899
902
  const policyTouched = policyKeys.some((k) => patch[k] !== undefined);
900
903
  const policyChanged = policyKeys.some((k) => patch[k] !== undefined && before[k] !== patch[k]);
901
- const policyResent = !!inst.error_note && policyTouched;
904
+ // A push is OWED when the last save's push did not get carried — a queued
905
+ // push that RAN and failed (error_note, the F19 re-kick), or one never queued
906
+ // at all (the 409 below, or an enqueue that threw; task 1003524). Both answer
907
+ // "send it again", and folding the second in is what makes that sentence
908
+ // true: the re-send is unchanged, so it would otherwise hit "No change".
909
+ const pushOwed = provisioning.settingsPushOwed(inst);
910
+ const policyResent = (!!inst.error_note || pushOwed) && policyTouched;
902
911
  const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
903
912
  if (changed) {
904
913
  await provisioning.recordEvent(pool, {
@@ -947,14 +956,25 @@ module.exports = function provisioningRoutes() {
947
956
  catch (err) {
948
957
  // The value IS saved — say so; a 500 here would deny a durable write.
949
958
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
959
+ // The remedy this sentence names has to WORK (task 1003524): nothing is
960
+ // going to carry the saved value, so record the debt. Best-effort — a failed
961
+ // marker write must never turn a durable settings write into a 500.
962
+ await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
950
963
  push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
951
964
  }
952
965
  if (r && r.conflict) {
966
+ // Saved, but the slot is held by work that will not carry this change, so
967
+ // "send it again once that settles" has to re-queue when they do (task 1003524).
968
+ await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
953
969
  return res.fail('intent_conflict', { status: 409,
954
970
  message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
955
971
  details: { action: r.intent.action, state: r.intent.state, recorded: true } });
956
972
  }
957
973
  if (r) {
974
+ // A push is now in hand — fresh, or a PENDING one that has not read the row
975
+ // yet — so either way it carries the current settings. Only written when
976
+ // there IS a debt, so the hot path stays a single UPDATE.
977
+ if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
958
978
  push = receipt({ queued: r.created, restart: true, message: r.created
959
979
  ? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
960
980
  : 'Saved. A restart is already queued for this project and will carry this change.' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.676",
3
+ "version": "1.19.677",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.676",
9
+ "version": "1.19.677",
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.676",
3
+ "version": "1.19.677",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/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.676'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.677'; // 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');
@@ -132,7 +132,19 @@ let enqueueCalls = [];
132
132
  let enqueueResult = null; // what enqueueSettingsApply reports (null → created:true)
133
133
  let enqueueThrows = null; // an Error to throw from enqueueSettingsApply
134
134
  let events = [];
135
+ let owedCalls = []; // every recordSettingsPushOwed(id, owed) the route makes
135
136
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
137
+ // The marker is a reserved key in the SAME settings jsonb, so the stub mutates the
138
+ // fixture the way the real UPDATE does — that is what lets a test re-send against
139
+ // the row its own previous save left behind.
140
+ const realRecordSettingsPushOwed = provisioning.recordSettingsPushOwed;
141
+ provisioning.recordSettingsPushOwed = async (_db, id, owed) => {
142
+ owedCalls.push({ id, owed });
143
+ const next = { ...(instance.settings || {}) };
144
+ if (owed) next.push_owed = true; else delete next.push_owed;
145
+ instance = { ...instance, settings: next };
146
+ return { id, settings: next };
147
+ };
136
148
  provisioning.updateInstanceSettings = async (_db, id, patch) => {
137
149
  updateCalls.push({ id, patch, enqueuedSoFar: enqueueCalls.length });
138
150
  instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
@@ -196,6 +208,7 @@ function reset({ inst, as } = {}) {
196
208
  enqueueResult = null;
197
209
  enqueueThrows = null;
198
210
  events = [];
211
+ owedCalls = [];
199
212
  }
200
213
  // every receipt carries the same four keys, and never a duration promise
201
214
  function receiptShape(push, { queued, restart, reachable }) {
@@ -354,6 +367,122 @@ await ta('ACTIVE dedicated: stored, NO intent, reachable:false, restart:true (a
354
367
  assert.doesNotMatch(r.body.push.message, /web\.env/, 'no file the owner has never seen');
355
368
  });
356
369
 
370
+ console.log("\nthe owed push — making \"send it again\" true (task 1003524):");
371
+
372
+ // THE HEADLINE, end to end, exactly as the bug was reported: the owner saves
373
+ // visibility (queued), saves joinability one card below while the first push is
374
+ // RUNNING (409), that run settles, and they do what the 409 told them to — re-send
375
+ // the SAME joinability. Before this fix the re-send was unchanged, so it took the
376
+ // "No change" branch and queued nothing: the value sat on the platform forever and
377
+ // the only way out was toggling to a third value and back.
378
+ await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHANGED re-send of B queues a NEW push', async () => {
379
+ reset();
380
+
381
+ // 1. visibility: a clean queue.
382
+ let r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
383
+ assert.equal(r.status, 200, r.raw);
384
+ assert.equal(r.body.push.queued, true);
385
+ assert.equal(enqueueCalls.length, 1);
386
+
387
+ // 2. joinability, while that push is RUNNING — saved, but nothing carries it.
388
+ enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'running' }, created: false, conflict: 'open_intent' };
389
+ r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
390
+ assert.equal(r.status, 409, r.raw);
391
+ assert.equal(r.body.error.details.recorded, true, 'the value IS stored');
392
+ assert.match(r.body.error.message, /send it again once that settles/i, 'the remedy this test exists to make true');
393
+ assert.deepEqual(owedCalls, [{ id: 18, owed: true }], 'the debt is recorded at the moment the push is refused');
394
+
395
+ // 3. that run settles; the owner re-sends the SAME joinability.
396
+ enqueueResult = null; // the slot is free again
397
+ const enqueuedBefore = enqueueCalls.length;
398
+ const writesBefore = updateCalls.length;
399
+ r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
400
+
401
+ assert.equal(r.status, 200, r.raw);
402
+ assert.doesNotMatch(r.body.push.message, /No change/, 'the whole bug: the remedy used to be a no-op');
403
+ assert.equal(r.body.push.queued, true, 'a NEW push is queued');
404
+ assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
405
+ assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
406
+ assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
407
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full' },
408
+ 'the value that finally ships is the one the owner saved');
409
+ });
410
+
411
+ await ta('an enqueue that THROWS records the debt too — its "send it again" is the same sentence', async () => {
412
+ reset();
413
+ enqueueThrows = new Error('db down');
414
+ const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
415
+ assert.equal(r.status, 200, r.raw);
416
+ assert.match(r.body.push.message, /send it again/i);
417
+ assert.deepEqual(owedCalls, [{ id: 18, owed: true }]);
418
+
419
+ // and the re-send it names now works
420
+ enqueueThrows = null;
421
+ const r2 = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
422
+ assert.equal(r2.body.push.queued, true, 'the unchanged re-send queues');
423
+ assert.equal(updateCalls.length, 1, 'still only the original write');
424
+ });
425
+
426
+ await ta('a push already in hand settles the debt; with no debt outstanding nothing is written (the hot path stays one UPDATE)', async () => {
427
+ // an identical PENDING push (created:false, no conflict) has not read the row yet,
428
+ // so it carries this change — the debt is settled just as for a fresh intent.
429
+ reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', push_owed: true } } });
430
+ enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'pending' }, created: false };
431
+ const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
432
+ assert.equal(r.status, 200, r.raw);
433
+ assert.deepEqual(owedCalls, [{ id: 18, owed: false }], 'a pending push carries it — debt settled');
434
+
435
+ reset();
436
+ await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
437
+ assert.deepEqual(owedCalls, [], 'no debt, no bookkeeping write');
438
+ });
439
+
440
+ await ta('an owed push is a POLICY debt: a planet-only re-send neither re-kicks it nor clears it', async () => {
441
+ reset({ inst: { ...ACTIVE, settings: { planet_template: 'bead-obsidian-tint', push_owed: true } } });
442
+ const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
443
+ assert.equal(r.status, 200, r.raw);
444
+ assert.equal(enqueueCalls.length, 0, 'the planet reaches no instance, so it re-kicks nothing');
445
+ assert.deepEqual(owedCalls, [], 'and the policy debt survives, still owed');
446
+ assert.equal(provisioning.settingsPushOwed(instance), true);
447
+ });
448
+
449
+ await ta('the marker never leaks: not a vocabulary key, not in any projection, and a client cannot write it', async () => {
450
+ const row = { settings: { push_owed: true, joinability: 'open', planet_template: 'bead-obsidian-tint' } };
451
+ assert.equal(provisioning.settingsPushOwed(row), true);
452
+ assert.ok(!('push_owed' in provisioning.SETTINGS_VOCAB), 'not a policy setting');
453
+ assert.ok(!('push_owed' in provisioning.PLANET_VOCAB), 'not a planet setting');
454
+ assert.ok(!('push_owed' in provisioning.effectiveSettings(row)), 'never in the saved projection');
455
+ assert.ok(!('push_owed' in provisioning.planetChoice(row)), 'never in the planet projection');
456
+ const env = provisioning.settingsEnvVars(row, 'BONGOS');
457
+ for (const k of Object.keys(env)) assert.doesNotMatch(k, /PUSH_OWED/, 'never reaches the instance web.env');
458
+ assert.equal(provisioning.settingsPushOwed({ settings: {} }), false);
459
+ assert.equal(provisioning.settingsPushOwed({ settings: { push_owed: 'yes' } }), false, 'strictly true, never truthy');
460
+ assert.equal(provisioning.settingsPushOwed(null), false);
461
+
462
+ // strict validation refuses it on the way in, so it is server-only by construction
463
+ reset();
464
+ const r = await patch('/provisioning/instances/18/settings', { push_owed: true });
465
+ assert.equal(r.status, 400, r.raw);
466
+ assert.equal(updateCalls.length, 0);
467
+ });
468
+
469
+ await ta('recordSettingsPushOwed writes NOTHING but the marker — no status, no updated_at, and clearing deletes the key', async () => {
470
+ const seen = [];
471
+ const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 18, settings: {} }] }; } };
472
+ await realRecordSettingsPushOwed(db, 18, true);
473
+ await realRecordSettingsPushOwed(db, 18, false);
474
+ for (const c of seen) {
475
+ assert.match(c.sql, /UPDATE provisioning_instances/);
476
+ assert.doesNotMatch(c.sql, /updated_at/, 'bookkeeping must not reshuffle the owner\'s card order');
477
+ assert.doesNotMatch(c.sql, /status/, 'and must not touch the lifecycle');
478
+ assert.match(c.sql, /settings - \$4::text/, 'clearing DELETES the key rather than storing false');
479
+ assert.equal(c.params[0], 18);
480
+ }
481
+ assert.equal(seen[0].params[1], true);
482
+ assert.equal(seen[1].params[1], false);
483
+ assert.equal(seen[0].params[3], 'push_owed', 'the reserved key, passed as a parameter not interpolated');
484
+ });
485
+
357
486
  console.log('\nthe walls that did not move:');
358
487
 
359
488
  await ta("another builder's PATCH → 404; nothing stored, nothing queued", async () => {