@bongos/core 1.19.541 → 1.19.542

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.541",
6
- "core_contract": "1.19.541",
7
- "source_commit": "0f2805ada935b696e72031342663a4193c5c802d",
5
+ "core_version": "1.19.542",
6
+ "core_contract": "1.19.542",
7
+ "source_commit": "ec461e927a5fe4bb98053989f04853aa378878ee",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-04T16:58:05.057Z",
9
+ "built_at": "2026-09-04T17:03:11.236Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 441,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2000,
14
+ "functional_verbatim": 2002,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2465,
20
- "tree_sha256": "281153bdf107ee76f439badeb13dc3cbd70393ce4b70d52084e542a9581684f8",
19
+ "file_count": 2467,
20
+ "tree_sha256": "15eb33889177142913c49676b48a92f947cc04ace14b1cc1b0055e773f996645",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2647,7 +2647,7 @@
2647
2647
  {
2648
2648
  "path": "docs/module-api-changelog.md",
2649
2649
  "mode": "0000644",
2650
- "sha256": "263a5d16f7cd22a3b6a193a4cff05994a8fcf7a0522b561ba802392c3328b4f2"
2650
+ "sha256": "f5af0c300e59c22fcfeaafba88b00bd39f65afb2f51529efb0981a898aebb766"
2651
2651
  },
2652
2652
  {
2653
2653
  "path": "docs/modules-contract.md",
@@ -5952,7 +5952,7 @@
5952
5952
  {
5953
5953
  "path": "modules/platform-identity/project-catalog.js",
5954
5954
  "mode": "0000644",
5955
- "sha256": "2f83a6c37f3cabb5ef04d20f0d6e92a39218528bad214a5e61e07eb3bba1468e"
5955
+ "sha256": "6021d893691000a164d7d16e6f24b5bd91215c6dcb252557f0ffcd5d1d467e32"
5956
5956
  },
5957
5957
  {
5958
5958
  "path": "modules/platform-identity/project-stats.js",
@@ -6037,7 +6037,7 @@
6037
6037
  {
6038
6038
  "path": "modules/provisioning/catalog-bridge.js",
6039
6039
  "mode": "0000644",
6040
- "sha256": "35a3c70ebe49b37a18044d6756d97b9caea87917a9fdbe0198fa05be25f5457a"
6040
+ "sha256": "dae214682f6115b3a4e11ac3bcd120460b07c2a517155ba0b7ec690f1db9f3ea"
6041
6041
  },
6042
6042
  {
6043
6043
  "path": "modules/provisioning/migrations/provisioning_001_tables.sql",
@@ -6112,13 +6112,18 @@
6112
6112
  {
6113
6113
  "path": "modules/provisioning/module.json",
6114
6114
  "mode": "0000644",
6115
- "sha256": "c451d94736219198d10d1bb8837ff9aecd6d4f90f8bf46790deba38f0d2168ec"
6115
+ "sha256": "6c19e18cd65ed87e3af53835af179bbe0a937e38456056fe49711c8b31b3e081"
6116
6116
  },
6117
6117
  {
6118
6118
  "path": "modules/provisioning/onboard-plan.js",
6119
6119
  "mode": "0000644",
6120
6120
  "sha256": "31f14cbf3d649c25f1f17c508ae19be2f39d9e1991fde17cdce08cb15837ffa1"
6121
6121
  },
6122
+ {
6123
+ "path": "modules/provisioning/pollers/catalog-backfill.js",
6124
+ "mode": "0000644",
6125
+ "sha256": "bf4a392ce4c545feb246b818ad2b37dd5e92050538d5202fb8980941d0ec52c9"
6126
+ },
6122
6127
  {
6123
6128
  "path": "modules/provisioning/pollers/liveness-sweep.js",
6124
6129
  "mode": "0000644",
@@ -6147,7 +6152,7 @@
6147
6152
  {
6148
6153
  "path": "modules/provisioning/routes/provisioning.js",
6149
6154
  "mode": "0000644",
6150
- "sha256": "00585a2de10a05c814fb0c1ad3cd6b564096b70b7729695b54953ab98c75601b"
6155
+ "sha256": "c6cfd3a1d0b4f39c4d23d65e1587ec50276aa4180ab48c520b4c22fdb6cf0a6c"
6151
6156
  },
6152
6157
  {
6153
6158
  "path": "modules/provisioning/starter-bundles.js",
@@ -7407,12 +7412,12 @@
7407
7412
  {
7408
7413
  "path": "package-lock.json",
7409
7414
  "mode": "0000644",
7410
- "sha256": "d1edde947ae28592c6bb37d8a935cc2c2c9c9ca45c9064c1579c8128d313e5ae"
7415
+ "sha256": "3292c34b3bdbd992383b7d539a5cc5e1734ac78aff6ca476db6d3e558a60db32"
7411
7416
  },
7412
7417
  {
7413
7418
  "path": "package.json",
7414
7419
  "mode": "0000644",
7415
- "sha256": "1f416b6226afeec3bb285d65c4d2bede292e7ad63c04a5701c54d7c59324c634"
7420
+ "sha256": "0e83db7f6c2b6d6310d2f20810817be1e483f774d64def7852895c2f85eff047"
7416
7421
  },
7417
7422
  {
7418
7423
  "path": "public-docs/index.html",
@@ -9097,7 +9102,7 @@
9097
9102
  {
9098
9103
  "path": "src/module-api.js",
9099
9104
  "mode": "0000644",
9100
- "sha256": "c9da65f8915d9cc8bb519797fb741a9644421066fbb97b29bb1d270fa874c1b4"
9105
+ "sha256": "980c4fd7007ae4d60c95392644476ef440e9d0e0a1eaa3aa4f19f4f3bd5cb69c"
9101
9106
  },
9102
9107
  {
9103
9108
  "path": "src/module-loader/catalog.js",
@@ -9587,7 +9592,7 @@
9587
9592
  {
9588
9593
  "path": "tests/catalog_only_client.mjs",
9589
9594
  "mode": "0000644",
9590
- "sha256": "2eb721978e016514ef53cf5083dbbc5d38a2db57cd75786fdd6b6a12e5488dff"
9595
+ "sha256": "cf03bbcb60691dadd4f7c9f4faf4122df6dea099f9ce3137b514b416cab3a5e8"
9591
9596
  },
9592
9597
  {
9593
9598
  "path": "tests/category_advisory_guard.mjs",
@@ -11324,6 +11329,11 @@
11324
11329
  "mode": "0000644",
11325
11330
  "sha256": "6a7a223a678b56333befaed017429e5b9d8a81e4405efae72a23355902517d9d"
11326
11331
  },
11332
+ {
11333
+ "path": "tests/project_catalog_backfill.mjs",
11334
+ "mode": "0000644",
11335
+ "sha256": "079b8e54e94a6c1576a02735ee9ea9f27baaaa266115fa21eafa51ba7c17c565"
11336
+ },
11327
11337
  {
11328
11338
  "path": "tests/project_catalog_bridge.mjs",
11329
11339
  "mode": "0000644",
@@ -11332,7 +11342,7 @@
11332
11342
  {
11333
11343
  "path": "tests/project_catalog_routes.mjs",
11334
11344
  "mode": "0000644",
11335
- "sha256": "afef4620252bc212b4326863abb021e8cd6033e258345e44cf78e97fc3b28b63"
11345
+ "sha256": "e8f3b09a5c06aa1d760b7605d6c40ba7b818594286a1bd6bb9496f222b7fc8e8"
11336
11346
  },
11337
11347
  {
11338
11348
  "path": "tests/project_door_ui.mjs",
@@ -1531,5 +1531,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1531
1531
  landed since 1.19.539 with no explicit bump. run 33895602533. (task 1002620)
1532
1532
  1.19.541 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1533
1533
  landed since 1.19.540 with no explicit bump. run 33897974683. (task 1002620)
1534
+ 1.19.542 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1535
+ landed since 1.19.541 with no explicit bump. run 33898437821. (task 1002620)
1534
1536
  ---------------------------------------------------------------------------
1535
1537
  ```
@@ -165,6 +165,29 @@ async function retireOwnedProject({ origin, ownerGithubId }, { pool = defaultPoo
165
165
  return rows[0] || null;
166
166
  }
167
167
 
168
+ // WHICH of these origins does the map already carry? The one question a BACKFILL has to ask
169
+ // and `publish` cannot answer (task 1003581). ADR 0246's projection is write-triggered, so an
170
+ // instance that predates it has no row until its owner happens to save something — and the
171
+ // only way to heal that is to re-project, which without this read would mean re-projecting
172
+ // EVERY instance at every boot and churning every row's updated_at forever.
173
+ //
174
+ // Deliberately UNFILTERED by owner and by status: "is there a row at this origin" is the
175
+ // question, so a hand-seeded row (owner NULL) or a hidden one counts as present and the
176
+ // backfill leaves it exactly where it is. Origins in, origins out — the catalog still learns
177
+ // no other module's schema, which is ADR 0246's reason for keeping the port narrow. PURE of
178
+ // side effects; one indexed lookup however many origins are asked about.
179
+ async function catalogedOrigins(origins, { pool = defaultPool } = {}) {
180
+ const list = [...new Set((Array.isArray(origins) ? origins : [origins])
181
+ .map((o) => String(o || '').replace(/\/+$/, ''))
182
+ .filter(Boolean))];
183
+ if (!list.length) return [];
184
+ const { rows } = await pool.query(
185
+ 'SELECT origin FROM platform_identity_projects WHERE origin = ANY($1::text[])',
186
+ [list],
187
+ );
188
+ return rows.map((r) => r.origin);
189
+ }
190
+
168
191
  // ONE control-plane project, projected into the catalog: the identity row first, then the map
169
192
  // row, then (on a re-point) the address it left. Both writes are claim-gated, so `claimed`
170
193
  // tells the caller whether the map actually carries this project — a false is a refusal, not
@@ -184,10 +207,13 @@ async function publishControlPlaneProject(entry, { retireOrigin = null, pool = d
184
207
  // module.json. The `project.publishGate` precedent, and for the same reason: provisioning is
185
208
  // the plane that OWNS these projects and may not import this module (the ADR 0083 one-way
186
209
  // rule), while this module owns the catalog's tables. One definition, reached through the
187
- // switchboard. `publish` is deliberately the only verb — a consumer maps its own row onto the
188
- // entry shape, so the catalog never learns another module's schema.
210
+ // switchboard. `publish` writes; `catalogued` is the one READ a consumer needs — "which of
211
+ // these origins are already on the map" — added by the backfill (task 1003581) because
212
+ // healing history without it means re-writing every row at every boot. Both speak only in
213
+ // origins and the entry shape, so the catalog still never learns another module's schema.
189
214
  const projectCatalogPort = Object.freeze({
190
215
  publish: (entry, opts) => publishControlPlaneProject(entry, opts),
216
+ catalogued: (origins) => catalogedOrigins(origins),
191
217
  });
192
218
 
193
219
  module.exports = {
@@ -197,6 +223,7 @@ module.exports = {
197
223
  upsertCatalogOnlyClient,
198
224
  upsertOwnedProject,
199
225
  retireOwnedProject,
226
+ catalogedOrigins,
200
227
  publishControlPlaneProject,
201
228
  projectCatalogPort,
202
229
  };
@@ -44,18 +44,31 @@ const CATALOG_HIDDEN_STATES = new Set(['tearing_down', 'torn_down']);
44
44
  // SAME row rather than orphaning it). The door comes from `effectiveSettings`, never the raw
45
45
  // jsonb, so an off-vocabulary stored value resolves to today's behaviour exactly as it does
46
46
  // on every other surface.
47
+ // The two halves of the projection that do NOT need the owner, split out so a caller that only
48
+ // wants the address can have it without a database read (task 1003581 — the backfill's presence
49
+ // check runs on every owner manage-page load, and resolving an owner just to build a string was
50
+ // a query per read forever). `catalogEntryForInstance` derives both from these, so there is
51
+ // still exactly ONE definition of each. Both PURE.
52
+ function catalogOriginForInstance(row) {
53
+ return row && row.slug && row.domain ? `https://${row.domain}` : null;
54
+ }
55
+ function catalogStatusForInstance(row) {
56
+ return CATALOG_HIDDEN_STATES.has(row && row.status) ? 'hidden' : 'active';
57
+ }
58
+
47
59
  function catalogEntryForInstance(row, { ownerGithubId } = {}) {
48
- if (!row || !row.slug || !row.domain) return null;
60
+ const origin = catalogOriginForInstance(row);
61
+ if (!origin) return null;
49
62
  const owner = Number(ownerGithubId);
50
63
  if (!Number.isFinite(owner) || owner <= 0) return null;
51
64
  return {
52
65
  clientId: row.slug,
53
- origin: `https://${row.domain}`,
66
+ origin,
54
67
  name: row.slug,
55
68
  description: row.description || null,
56
69
  type: row.type || null,
57
70
  visibility: effectiveSettings(row).visibility,
58
- status: CATALOG_HIDDEN_STATES.has(row.status) ? 'hidden' : 'active',
71
+ status: catalogStatusForInstance(row),
59
72
  ownerGithubId: owner,
60
73
  };
61
74
  }
@@ -98,8 +111,59 @@ async function publishInstanceToCatalog(db, row, { retireOrigin = null } = {}) {
98
111
  }
99
112
  }
100
113
 
114
+ // The origin a BACKFILL would put this instance's catalog row at, or null when it should not
115
+ // have one. PURE, and composed from the projection's own two halves rather than a second copy
116
+ // of its rules: no address means nothing to key a row on, and 'hidden' means the row would
117
+ // exist only to be invisible — writing that for a torn-down project that was NEVER catalogued
118
+ // is churn, not repair. A project the owner is still standing up ('requested' / 'provisioning'
119
+ // / 'error') IS backfillable — ADR 0246: the map is a directory of projects, not an uptime
120
+ // board. Deliberately says nothing about the OWNER, so the presence check it feeds costs no
121
+ // database read; a missing owner identity is `publishInstanceToCatalog`'s own refusal.
122
+ function backfillOriginForInstance(row) {
123
+ const origin = catalogOriginForInstance(row);
124
+ return origin && catalogStatusForInstance(row) === 'active' ? origin : null;
125
+ }
126
+
127
+ // Project ONE instance ONLY IF the map does not already carry it (task 1003581).
128
+ //
129
+ // WHY IT EXISTS. ADR 0246 made the catalog row a projection re-taken at every write that can
130
+ // change it — but a write-triggered projection has no opinion about HISTORY. Every instance
131
+ // that existed before that ADR landed has no `platform_identity_projects` row and no
132
+ // `platform_identity_sso_clients` row, so `POST /projects/invite` answers 404 `unknown_project`
133
+ // and its owner cannot invite anyone into a project they own. This is the repair, and it is
134
+ // deliberately conditional: re-projecting an instance that is already on the map would churn
135
+ // its `updated_at` on every owner page-load and every boot.
136
+ //
137
+ // SAFE ON A ROW SOMEONE ELSE HOLDS. "Already catalogued" is asked of the ORIGIN alone, so a
138
+ // hand-seeded row (owner NULL, unclaimable by ADR 0246's claim rule 2) counts as present and
139
+ // is never written to. Best-effort like every other call site: it never throws, and it reports
140
+ // why it did nothing.
141
+ //
142
+ // THE ALREADY-HEALED CASE COSTS ONE QUERY — the presence read, and nothing else. That is why
143
+ // the order below is address → map → owner rather than owner first: this rides an owner's
144
+ // manage-page read forever, long after the one boot that repaired the instance, so the steady
145
+ // state has to be the cheap one. The owner lookup happens inside `publishInstanceToCatalog`,
146
+ // i.e. only when there is actually a row to write.
147
+ async function healInstanceCatalog(db, row) {
148
+ const catalog = api.resolveOptional('project.catalog');
149
+ if (!catalog || typeof catalog.catalogued !== 'function') return { healed: false, reason: 'no_catalog' };
150
+ const origin = backfillOriginForInstance(row);
151
+ if (!origin) return { healed: false, reason: 'not_backfillable' };
152
+ try {
153
+ const present = await catalog.catalogued([origin]);
154
+ if (present.includes(origin)) return { healed: false, reason: 'already_catalogued' };
155
+ const out = await publishInstanceToCatalog(db, row);
156
+ return { healed: !!out.published, reason: out.published ? null : out.reason };
157
+ } catch (err) {
158
+ log.error({ err: err.message, slug: row && row.slug }, 'catalog heal failed (non-fatal)');
159
+ return { healed: false, reason: 'error' };
160
+ }
161
+ }
162
+
101
163
  module.exports = {
102
164
  CATALOG_HIDDEN_STATES,
103
165
  catalogEntryForInstance,
166
+ backfillOriginForInstance,
104
167
  publishInstanceToCatalog,
168
+ healInstanceCatalog,
105
169
  };
@@ -12,7 +12,7 @@
12
12
  "contributes": {
13
13
  "routes": ["provisioning"],
14
14
  "migrations": true,
15
- "pollers": ["liveness-sweep"]
15
+ "pollers": ["liveness-sweep", "catalog-backfill"]
16
16
  },
17
17
  "provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy"],
18
18
  "consumes": []
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/catalog-backfill.js — THE ONE-SHOT REPAIR OF HISTORY behind
4
+ // ADR 0246's catalog projection (task 1003581). The loader's startModulePollers requires this
5
+ // file and calls start(deps) at boot for the ENABLED module only — the liveness-sweep shape.
6
+ //
7
+ // WHY. ADR 0246 made the hub catalog row a PROJECTION of the provisioning row, re-taken at
8
+ // every write that can change it. A write-triggered projection is correct going forward and
9
+ // says nothing about the past: every instance that existed before that ADR landed has no
10
+ // catalog row and gets one only if its owner happens to edit its detail, its settings or its
11
+ // address. Until then `POST /projects/invite` resolves nothing and answers 404
12
+ // `unknown_project`, so **an owner cannot invite anyone into a project they own** — which is
13
+ // what blocked the C6 / BV1.R30 proof on live. This sweep is how the fleet heals itself on the
14
+ // next core upgrade, with nobody hand-running SQL.
15
+ //
16
+ // ONE-SHOT, NOT A CADENCE. Ongoing correctness is already the four write call sites' job, plus
17
+ // the owner-read self-heal in routes/provisioning.js; a repeating sweep would find nothing on
18
+ // every tick after the first. It runs once, a few seconds after boot, and stops.
19
+ //
20
+ // IT IS THE SAME REPAIR THE OWNER READ DOES, asked in one question instead of N. Both paths
21
+ // share `backfillOriginForInstance` (what is projectable) and `publishInstanceToCatalog` (the
22
+ // write, with ADR 0246's two claim rules inside its own statements), so they cannot drift; only
23
+ // the shape of the presence question differs — the sweep hands the whole fleet's origins to
24
+ // `catalogued` at once. In particular the hand-seeded catalog rows (owner_github_id NULL) come
25
+ // back as PRESENT and are never written.
26
+ //
27
+ // SAFE TO RUN EVERY START: idempotent (an instance already on the map is skipped, not
28
+ // re-written), bounded (SWEEP_LIMIT), logged through the module logger, and it never throws —
29
+ // `healInstanceCatalog` swallows its own errors and the tick has its own catch, because a hub
30
+ // hiccup must never take the boot.
31
+
32
+ const api = require('../../../src/module-api');
33
+ const provisioning = require('../provisioning');
34
+ const bridge = require('../catalog-bridge');
35
+
36
+ const log = api.logger('provisioning');
37
+
38
+ // A few seconds after boot: late enough that the route mounts have registered the
39
+ // `project.catalog` port, early enough that an owner opening their manage page finds the
40
+ // repair already done.
41
+ const FIRST_SWEEP_DELAY_MS = Number(process.env.PROVISIONING_CATALOG_BACKFILL_MS) || 15_000;
42
+ // Runaway guard, not an expected limit — the whole point is that this is a finite backlog.
43
+ const SWEEP_LIMIT = 500;
44
+ // Keep the one-line account readable on a big fleet.
45
+ const MAX_NAMES_SHOWN = 8;
46
+
47
+ let timer = null;
48
+ let started = false;
49
+
50
+ // The per-sweep disposition line, PURE so its wording is unit-tested without a sweep:
51
+ // [catalog-backfill] 22 instances: 5 projected (mercury, lars-test) · 1 already · 16 skipped
52
+ function formatSweepLine({ considered = 0, projected = 0, already = 0, skipped = 0, refused = 0, names = [] } = {}) {
53
+ const shown = names.slice(0, MAX_NAMES_SHOWN).join(', ');
54
+ const more = names.length > MAX_NAMES_SHOWN ? `, +${names.length - MAX_NAMES_SHOWN} more` : '';
55
+ const detail = names.length ? ` (${shown}${more})` : '';
56
+ const refusedPart = refused ? ` · ${refused} refused` : '';
57
+ return `[catalog-backfill] ${considered} instances: ${projected} projected${detail}`
58
+ + ` · ${already} already · ${skipped} skipped${refusedPart}`;
59
+ }
60
+
61
+ // ONE pass over the fleet, projecting every instance the map does not already carry.
62
+ // Exported + dependency-injected (pool, log) so the tests drive it directly — the timer is
63
+ // only a schedule.
64
+ //
65
+ // THE PRESENCE QUESTION IS ASKED ONCE, for the whole fleet. Calling the per-instance heal in a
66
+ // loop would be a round trip per row — the N+1 shape — where `catalogued` was built to take a
67
+ // list. It shares the heal path's rules rather than its composition: `backfillOriginForInstance`
68
+ // decides what is projectable and `publishInstanceToCatalog` does the write, so the two paths
69
+ // cannot disagree about either. What remains per-row is one primary-key owner lookup inside the
70
+ // write, and only for a row actually being repaired — once in that instance's lifetime.
71
+ async function sweepOnce(deps = {}) {
72
+ const db = deps.pool || api.pool;
73
+ const emit = deps.log || ((line) => log.info(line));
74
+ // No hub, no map to be on — the `publishVerdict` precedent. Nothing to say, nothing to do.
75
+ const catalog = api.resolveOptional('project.catalog');
76
+ if (!catalog || typeof catalog.catalogued !== 'function') return { considered: 0, skipped: 0, reason: 'no_catalog' };
77
+
78
+ const rows = (await provisioning.listAllInstances(db)).slice(0, SWEEP_LIMIT);
79
+ const candidates = rows
80
+ .map((row) => ({ row, origin: bridge.backfillOriginForInstance(row) }))
81
+ .filter((c) => c.origin);
82
+ const out = {
83
+ considered: rows.length, projected: 0, already: 0,
84
+ skipped: rows.length - candidates.length, refused: 0, names: [],
85
+ };
86
+ if (!candidates.length) return out;
87
+
88
+ let present;
89
+ try {
90
+ present = new Set(await catalog.catalogued(candidates.map((c) => c.origin)));
91
+ } catch (err) {
92
+ log.error({ err: err && err.message ? err.message : String(err) }, 'catalog backfill: the map could not be read');
93
+ return { ...out, reason: 'error' };
94
+ }
95
+
96
+ for (const { row, origin } of candidates) {
97
+ if (present.has(origin)) { out.already++; continue; }
98
+ const result = await bridge.publishInstanceToCatalog(db, row);
99
+ if (result.published) { out.projected++; out.names.push(row.slug); }
100
+ else if (result.reason === 'origin_held' || result.reason === 'error') out.refused++;
101
+ else out.skipped++;
102
+ }
103
+ // A fleet with nothing to repair says nothing — a control plane that has already healed
104
+ // should not print an empty account at every restart.
105
+ if (out.projected || out.refused) emit(formatSweepLine(out));
106
+ return out;
107
+ }
108
+
109
+ // Arm the one-shot sweep. Idempotent (a second call is a no-op) and kill-switched by
110
+ // PROVISIONING_CATALOG_BACKFILL_DISABLED=1. The timer is unref'd so it never holds the
111
+ // process open, and the tick's own catch means a failure is a log line, never a dead boot.
112
+ function start(deps = {}) {
113
+ if (process.env.PROVISIONING_CATALOG_BACKFILL_DISABLED === '1') return;
114
+ if (started) return;
115
+ started = true;
116
+ timer = setTimeout(() => {
117
+ sweepOnce(deps).catch((e) => log.error({ err: e && e.message ? e.message : String(e) }, 'catalog backfill sweep crashed'));
118
+ }, FIRST_SWEEP_DELAY_MS);
119
+ if (timer.unref) timer.unref();
120
+ }
121
+
122
+ function stop() {
123
+ started = false;
124
+ if (timer) { clearTimeout(timer); timer = null; }
125
+ }
126
+
127
+ module.exports = {
128
+ start,
129
+ stop,
130
+ // exported for tests
131
+ sweepOnce,
132
+ formatSweepLine,
133
+ SWEEP_LIMIT,
134
+ };
@@ -14,6 +14,8 @@
14
14
  // POST /provisioning/instances — request a new instance
15
15
  // GET /provisioning/instances — list the caller's instances
16
16
  // GET /provisioning/instances/:id — read one of the caller's instances
17
+ // (also SELF-HEALS its catalog projection,
18
+ // task 1003581 — see the handler)
17
19
  // POST /provisioning/instances/:id/teardown — request teardown of one's own
18
20
  // Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
19
21
  // GET /provisioning/fleet — the full instance roster
@@ -653,6 +655,14 @@ module.exports = function provisioningRoutes() {
653
655
  if (instanceAccessDenied(inst, req.builder)) {
654
656
  return res.fail('instance_not_found', 404);
655
657
  }
658
+ // SELF-HEAL THE CATALOG PROJECTION (task 1003581). ADR 0246's projection is
659
+ // write-triggered, so an instance older than it has no catalog row and its owner's
660
+ // invites 404 until they happen to save something. This own-scoped read — the manage
661
+ // page, past the access gate above — is the moment to close that. Conditional and
662
+ // best-effort: an instance already on the map costs ONE presence read and no write,
663
+ // which is the steady state this rides in forever, and it never throws, so it can
664
+ // never turn a read into a failure.
665
+ await catalogBridge.healInstanceCatalog(pool, inst).catch(() => {});
656
666
  const open = await provisioning.getOpenIntent(pool, id);
657
667
  // The GitHub-App leg's truth rides the same read (task 1003235), so the done
658
668
  // panel recovers it from server state after a reload — not from sessionStorage.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.541",
3
+ "version": "1.19.542",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.541",
9
+ "version": "1.19.542",
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.541",
3
+ "version": "1.19.542",
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
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.541'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.542'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -227,10 +227,14 @@ test('mounting the projects routes registers the `project.catalog` port', async
227
227
  assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
228
228
  });
229
229
 
230
- test('the port declares the catalog write and nothing else', () => {
231
- // One verb. A consumer maps its own row onto the entry shape, so the catalog never learns
232
- // another module’s schema and no module gets a general-purpose handle on these tables.
233
- assert.deepEqual(Object.keys(catalog.projectCatalogPort), ['publish']);
230
+ test('the port declares the catalog write, one presence read, and nothing else', () => {
231
+ // Two verbs, and the reason for the ceiling is unchanged: a consumer maps its own row onto
232
+ // the entry shape and asks about ORIGINS, so the catalog never learns another module's
233
+ // schema and no module gets a general-purpose handle on these tables. `catalogued` arrived
234
+ // with the backfill (task 1003581) because healing history without a presence read means
235
+ // re-writing every catalog row at every boot. Anything wider than these two is a new
236
+ // decision, and this list is where it has to be made.
237
+ assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'publish']);
234
238
  });
235
239
 
236
240
  test('the module manifest DECLARES the port it registers', async () => {
@@ -0,0 +1,402 @@
1
+ // tests/project_catalog_backfill.mjs — THE REPAIR OF HISTORY behind ADR 0246 (task 1003581).
2
+ //
3
+ // The bug: ADR 0246's catalog projection is WRITE-TRIGGERED. Its four call sites are all
4
+ // provisioning writes (create, the detail answers, the settings door, a re-pointed address),
5
+ // so every instance that existed BEFORE that ADR landed has no catalog row and gets one only
6
+ // if its owner happens to save something. Until then `POST /projects/invite` resolves nothing
7
+ // and answers 404 `unknown_project` — an owner cannot invite anyone into a project they own,
8
+ // which is what halted the C6 / BV1.R30 proof on live.
9
+ //
10
+ // What this file pins:
11
+ // 1. THE REPAIR — an instance the map does not carry is projected, through the SAME
12
+ // projection the write paths use.
13
+ // 2. IT IS IDEMPOTENT. An instance already on the map is skipped, not re-written: a heal
14
+ // that wrote unconditionally would churn every row's updated_at at every boot and on
15
+ // every owner page-load, which is the reason the port grew a READ at all.
16
+ // 3. IT REFUSES THE SAME THINGS THE PROJECTION REFUSES — no address, no owner identity, and
17
+ // a reclaimed instance whose row would exist only to be hidden.
18
+ // 4. THE HAND-SEEDED ROWS STAY UNCLAIMABLE (ADR 0246's two claim rules). An origin the map
19
+ // already holds is never written, whoever owns it — and if it somehow were, the upsert's
20
+ // own ON CONFLICT … WHERE refuses.
21
+ // 5. A STEALTH PROJECT REACHES THE MAP ANONYMOUSLY. The backfill puts rows on the map that
22
+ // were never there, so D2a has to hold for what it adds.
23
+ //
24
+ // DB-free: a recording fake hub standing in for the `project.catalog` port, a recording fake
25
+ // pool for the owner lookup, the real bridge/poller/catalog for everything else.
26
+ //
27
+ // Run: node --test --test-reporter=tap tests/project_catalog_backfill.mjs
28
+
29
+ import assert from 'node:assert/strict';
30
+ import { test, beforeEach } from 'node:test';
31
+ import { createRequire } from 'node:module';
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const api = require('../src/module-api.js');
35
+ const bridge = require('../modules/provisioning/catalog-bridge.js');
36
+ const backfill = require('../modules/provisioning/pollers/catalog-backfill.js');
37
+ const provisioning = require('../modules/provisioning/provisioning.js');
38
+ const catalog = require('../modules/platform-identity/project-catalog.js');
39
+ const feed = require('../modules/platform-identity/projects-feed.js');
40
+
41
+ // A live standalone instance from before ADR 0246: fully projectable, and on the map only if
42
+ // something puts it there. Instance 18's shape — the one whose owner could not invite anyone.
43
+ const STANDALONE = Object.freeze({
44
+ id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
45
+ hosting_shape: 'standalone', status: 'active', owner_builder_id: 42,
46
+ description: 'A harbour for spikes.', type: 'research',
47
+ settings: { visibility: 'public' },
48
+ });
49
+ const ORIGIN = 'https://spike-harbor.cloudbongos.test';
50
+
51
+ // The owner lookup the projection makes, and nothing else. Records every statement so a test
52
+ // can assert what the repair did — and did not — ask the database.
53
+ function fakePool(githubIdByBuilder = { 42: 9001 }) {
54
+ const calls = [];
55
+ return {
56
+ calls,
57
+ query: async (text, params = []) => {
58
+ calls.push({ text: String(text), params });
59
+ if (/FROM builders/i.test(String(text))) {
60
+ const gid = githubIdByBuilder[String(params[0])];
61
+ return { rows: gid ? [{ github_id: gid }] : [] };
62
+ }
63
+ return { rows: [] };
64
+ },
65
+ };
66
+ }
67
+
68
+ // A recording fake pool with queued responses, for asserting a statement's SHAPE.
69
+ function mockPool(responses = []) {
70
+ const calls = [];
71
+ let i = 0;
72
+ return {
73
+ calls,
74
+ query: async (text, params) => {
75
+ calls.push({ text: String(text), params: params || [] });
76
+ return responses[i++] || { rows: [], rowCount: 0 };
77
+ },
78
+ };
79
+ }
80
+
81
+ // The fake hub: `origins` is what the map already carries. Registered from INSIDE the first
82
+ // test rather than at import, because the port registry is process-global and single-provider
83
+ // — the "no hub at all" case can only be observed before anything registers one.
84
+ const hub = { origins: new Set(), publishes: [], reads: [], claimed: true, throws: false };
85
+
86
+ function registerHub() {
87
+ api.registerProvider('project.catalog', {
88
+ publish: async (entry, opts) => {
89
+ if (hub.throws) throw new Error('hub unreachable');
90
+ hub.publishes.push({ entry, opts });
91
+ if (hub.claimed) hub.origins.add(entry.origin);
92
+ return { claimed: hub.claimed };
93
+ },
94
+ catalogued: async (origins) => {
95
+ hub.reads.push(origins);
96
+ return origins.filter((o) => hub.origins.has(o));
97
+ },
98
+ });
99
+ }
100
+
101
+ // ── 0. the seam, FIRST, for the reason above ──────────────────────────────────────────────
102
+
103
+ test('with NO catalog provider the repair is an honest no-op, on both paths', async () => {
104
+ // platform-identity is hub-only (`default: false`). An instance running provisioning
105
+ // without it has no map to be repaired onto, and the honest answer is a reason, never an
106
+ // error — the `publishVerdict` precedent.
107
+ assert.equal(api.hasProvider('project.catalog'), false, 'nothing has registered one yet');
108
+ assert.deepEqual(await bridge.healInstanceCatalog(fakePool(), STANDALONE),
109
+ { healed: false, reason: 'no_catalog' });
110
+
111
+ // And the boot sweep does not even list the fleet: a vanilla control plane pays nothing
112
+ // for a repair that has no destination.
113
+ const pool = fakePool();
114
+ const out = await backfill.sweepOnce({ pool });
115
+ assert.equal(out.reason, 'no_catalog');
116
+ assert.equal(out.considered, 0);
117
+ assert.equal(pool.calls.length, 0);
118
+
119
+ registerHub(); // every test below runs against the hub
120
+ });
121
+
122
+ beforeEach(() => {
123
+ hub.origins = new Set();
124
+ hub.publishes = [];
125
+ hub.reads = [];
126
+ hub.claimed = true;
127
+ hub.throws = false;
128
+ });
129
+
130
+ // ── 1. the repair, and 2. its idempotence ─────────────────────────────────────────────────
131
+
132
+ test('an instance the map does not carry is PROJECTED — the pre-ADR-0246 instance heals', async () => {
133
+ // Arrange — the map is empty, exactly as it was for every instance older than the ADR.
134
+ const pool = fakePool();
135
+
136
+ // Act
137
+ const out = await bridge.healInstanceCatalog(pool, STANDALONE);
138
+
139
+ // Assert — one publish, carrying the SAME projection the four write call sites hand over:
140
+ // slug as both name and client_id, the owner's github id for attribution.
141
+ assert.equal(out.healed, true);
142
+ assert.equal(hub.publishes.length, 1);
143
+ assert.equal(hub.publishes[0].entry.origin, ORIGIN);
144
+ assert.equal(hub.publishes[0].entry.clientId, 'spike-harbor');
145
+ assert.equal(hub.publishes[0].entry.ownerGithubId, 9001);
146
+ assert.equal(hub.publishes[0].entry.status, 'active');
147
+ });
148
+
149
+ test('a SECOND heal writes NOTHING — the repair is idempotent, not a re-write', async () => {
150
+ // The property that lets this run on every owner page-load and at every boot. Without it
151
+ // the heal would churn every catalog row's updated_at forever.
152
+ const pool = fakePool();
153
+ await bridge.healInstanceCatalog(pool, STANDALONE);
154
+ assert.equal(hub.publishes.length, 1);
155
+
156
+ const second = await bridge.healInstanceCatalog(pool, STANDALONE);
157
+ assert.deepEqual(second.reason, 'already_catalogued');
158
+ assert.equal(second.healed, false);
159
+ assert.equal(hub.publishes.length, 1, 'the map was asked, and nothing was written');
160
+ assert.deepEqual(hub.reads.at(-1), [ORIGIN]);
161
+ });
162
+
163
+ test('the ALREADY-HEALED case costs one presence read and NO owner lookup', async () => {
164
+ // This is the steady state: every owner manage-page load, forever, long after the one boot
165
+ // that repaired the instance. Resolving the owner just to build an origin string would be a
166
+ // second query per read for the rest of the instance's life, so the order is address → map
167
+ // → owner, and the owner lookup lives inside the write.
168
+ hub.origins = new Set([ORIGIN]);
169
+ const pool = fakePool();
170
+ const out = await bridge.healInstanceCatalog(pool, STANDALONE);
171
+ assert.equal(out.reason, 'already_catalogued');
172
+ assert.equal(hub.reads.length, 1);
173
+ assert.deepEqual(pool.calls, [], 'the builders table is not touched to answer "is it there"');
174
+ });
175
+
176
+ test('a hub whose port predates the presence read is a no-op, not a crash', async () => {
177
+ // The port is versioned only by its shape. An older platform-identity registers `publish`
178
+ // alone, and the heal must notice rather than call undefined — the same fail-closed reading
179
+ // `resolveOptional` gets when there is no hub at all.
180
+ const saved = api.resolveOptional('project.catalog').catalogued;
181
+ const port = api.resolveOptional('project.catalog');
182
+ delete port.catalogued;
183
+ try {
184
+ assert.deepEqual(await bridge.healInstanceCatalog(fakePool(), STANDALONE),
185
+ { healed: false, reason: 'no_catalog' });
186
+ assert.equal((await backfill.sweepOnce({ pool: fakePool() })).reason, 'no_catalog');
187
+ assert.equal(hub.publishes.length, 0);
188
+ } finally {
189
+ port.catalogued = saved;
190
+ }
191
+ });
192
+
193
+ test('a claim the hub REFUSES is reported, not pretended', async () => {
194
+ // ADR 0246 claim rule 2: an origin someone else's row already holds is refused. The heal
195
+ // must carry that through rather than answering "healed".
196
+ hub.claimed = false;
197
+ const out = await bridge.healInstanceCatalog(fakePool(), STANDALONE);
198
+ assert.equal(out.healed, false);
199
+ assert.equal(out.reason, 'origin_held');
200
+ });
201
+
202
+ test('a hub that throws is SWALLOWED — a repair may never break the read it rides on', async () => {
203
+ hub.throws = true;
204
+ const out = await bridge.healInstanceCatalog(fakePool(), STANDALONE);
205
+ assert.equal(out.healed, false);
206
+ assert.equal(out.reason, 'error');
207
+ });
208
+
209
+ // ── 3. the refusals — the same ones the projection already makes ──────────────────────────
210
+
211
+ test('a RECLAIMED instance is never backfilled — a hidden row is churn, not repair', async () => {
212
+ // The projection maps tearing_down/torn_down → 'hidden'. Creating a row for a project that
213
+ // was never on the map, purely so it can be invisible, repairs nothing.
214
+ for (const status of ['tearing_down', 'torn_down']) {
215
+ const out = await bridge.healInstanceCatalog(fakePool(), { ...STANDALONE, status });
216
+ assert.equal(out.reason, 'not_backfillable', `${status} must not be backfilled`);
217
+ }
218
+ assert.equal(hub.publishes.length, 0);
219
+ assert.equal(hub.reads.length, 0, 'the hub is not even asked about a row we would not write');
220
+ });
221
+
222
+ test('an instance still standing up IS backfilled — the map is a directory, not an uptime board', async () => {
223
+ // ADR 0246: 'requested', 'provisioning' and 'error' are all "the owner is standing this up".
224
+ // Live instance 20 `walk-lantern` sits at 'error' with a domain and an owner, and its owner
225
+ // is as unable to invite anyone as instance 18's was.
226
+ for (const status of ['requested', 'provisioning', 'error']) {
227
+ hub.origins = new Set();
228
+ const out = await bridge.healInstanceCatalog(fakePool(), { ...STANDALONE, status });
229
+ assert.equal(out.healed, true, `${status} is a project that exists`);
230
+ }
231
+ });
232
+
233
+ test('an instance with NO ADDRESS asks the hub nothing at all', async () => {
234
+ // The catalog is keyed on origin, so there is nothing to ask about. Answered from the row
235
+ // alone — no round trip is spent discovering what the projection already knows.
236
+ const pool = fakePool();
237
+ const out = await bridge.healInstanceCatalog(pool, { ...STANDALONE, domain: null });
238
+ assert.equal(out.reason, 'not_backfillable');
239
+ assert.equal(hub.reads.length, 0);
240
+ assert.equal(pool.calls.length, 0);
241
+ });
242
+
243
+ test('an owner with no github identity is refused by the WRITE, not written un-refreshable', async () => {
244
+ // The hub's claim rule compares owners, so a row stamped with a null owner could never be
245
+ // refreshed. It is `publishInstanceToCatalog`'s own refusal — which is also why the owner is
246
+ // looked up there and not before the presence check.
247
+ const out = await bridge.healInstanceCatalog(fakePool({}), STANDALONE);
248
+ assert.equal(out.healed, false);
249
+ assert.equal(out.reason, 'no_owner_identity');
250
+ assert.equal(hub.publishes.length, 0);
251
+ });
252
+
253
+ // ── 4. the hand-seeded rows stay exactly where they are ───────────────────────────────────
254
+
255
+ test('an origin the map ALREADY holds is left alone, whoever owns it', async () => {
256
+ // The three hand-seeded catalog rows carry owner_github_id NULL and no provisioning row.
257
+ // "Is there a row at this origin" is deliberately not an ownership question, so a backfill
258
+ // that happened to meet one skips it instead of trying — and ADR 0246's consequence that
259
+ // those rows are an owner data decision, never a code change, survives this task.
260
+ hub.origins = new Set([ORIGIN]);
261
+ const out = await bridge.healInstanceCatalog(fakePool(), STANDALONE);
262
+ assert.equal(out.reason, 'already_catalogued');
263
+ assert.equal(hub.publishes.length, 0);
264
+ });
265
+
266
+ test('the presence read filters on ORIGIN alone — never on owner, never on status', async () => {
267
+ // If it filtered on owner, a hand-seeded row (owner NULL) would read as ABSENT and the
268
+ // backfill would try to claim it at every boot. If it filtered on status, a hidden row
269
+ // would too. Both are the same mistake, and this is where it would be made.
270
+ const pool = mockPool([{ rows: [{ origin: ORIGIN }] }]);
271
+ const out = await catalog.catalogedOrigins([ORIGIN, `${ORIGIN}/`], { pool });
272
+ assert.deepEqual(out, [ORIGIN]);
273
+ const { text, params } = pool.calls[0];
274
+ assert.match(text, /WHERE origin = ANY\(\$1::text\[\]\)/);
275
+ assert.doesNotMatch(text, /owner_github_id/);
276
+ assert.doesNotMatch(text, /\bstatus\b/);
277
+ assert.deepEqual(params, [[ORIGIN]], 'a trailing slash is the same origin, asked once');
278
+ });
279
+
280
+ test('an empty ask issues no statement', async () => {
281
+ const pool = mockPool();
282
+ assert.deepEqual(await catalog.catalogedOrigins([], { pool }), []);
283
+ assert.equal(pool.calls.length, 0);
284
+ });
285
+
286
+ test('the backfill writes through the SAME claim-gated upsert — a NULL owner can never match', async () => {
287
+ // The belt behind the read: even if the map answered "absent" for a row it holds, the
288
+ // upsert's own ON CONFLICT … WHERE is what refuses. `NULL = anything` is NULL, i.e. not
289
+ // true, so a hand-seeded row cannot be claimed by an instance at its address.
290
+ const pool = mockPool([{ rows: [] }]);
291
+ const out = await catalog.upsertOwnedProject({
292
+ clientId: 'spike-harbor', origin: ORIGIN, name: 'spike-harbor',
293
+ description: 'A harbour for spikes.', visibility: 'public', status: 'active', ownerGithubId: 9001,
294
+ }, { pool });
295
+ assert.match(pool.calls[0].text,
296
+ /WHERE\s+platform_identity_projects\.owner_github_id\s*=\s*EXCLUDED\.owner_github_id/);
297
+ assert.equal(out, null, 'a refused claim answers null, not a row');
298
+ });
299
+
300
+ // ── 5. the boot sweep ─────────────────────────────────────────────────────────────────────
301
+
302
+ // The live fleet's shape in miniature: one repairable, one already on the map, one reclaimed,
303
+ // one with no address, one whose owner has no github identity.
304
+ const FLEET = Object.freeze([
305
+ { ...STANDALONE },
306
+ { ...STANDALONE, id: 31, slug: 'mercury', domain: 'mercury.cloudbongos.test' },
307
+ { ...STANDALONE, id: 34, slug: 'testing', domain: 'testing.cloudbongos.test', status: 'torn_down' },
308
+ { ...STANDALONE, id: 13, slug: 'charter', domain: null },
309
+ { ...STANDALONE, id: 9, slug: 'test2', domain: 'test2.cloudbongos.test', owner_builder_id: 77 },
310
+ ]);
311
+
312
+ provisioning.listAllInstances = async () => FLEET.map((r) => ({ ...r }));
313
+
314
+ test('the boot sweep projects exactly the instances the map does not carry', async () => {
315
+ // Arrange — mercury is already on the map, as instance 18 became on live the moment a
316
+ // sibling task wrote its detail.
317
+ hub.origins = new Set(['https://mercury.cloudbongos.test']);
318
+
319
+ // Act
320
+ const out = await backfill.sweepOnce({ pool: fakePool(), log: () => {} });
321
+
322
+ // Assert
323
+ assert.equal(out.considered, 5);
324
+ assert.equal(out.projected, 1, 'only spike-harbor is both repairable and absent');
325
+ assert.deepEqual(out.names, ['spike-harbor']);
326
+ assert.equal(out.already, 1, 'mercury');
327
+ assert.equal(out.skipped, 3, 'torn down · no address · no owner identity');
328
+ assert.equal(hub.publishes.length, 1);
329
+ });
330
+
331
+ test('the sweep asks the map ONCE for the whole fleet — never a round trip per instance', async () => {
332
+ // `catalogued` takes a list because this is the caller it was built for. A per-row loop here
333
+ // would be a presence read per instance at every boot, which is the N+1 the batch exists to
334
+ // avoid — and the origins asked about are exactly the backfillable ones, no more.
335
+ await backfill.sweepOnce({ pool: fakePool(), log: () => {} });
336
+ assert.equal(hub.reads.length, 1);
337
+ assert.deepEqual(hub.reads[0], [
338
+ 'https://spike-harbor.cloudbongos.test',
339
+ 'https://mercury.cloudbongos.test',
340
+ 'https://test2.cloudbongos.test',
341
+ ]);
342
+ });
343
+
344
+ test('a fleet with nothing backfillable asks the map nothing', async () => {
345
+ const saved = provisioning.listAllInstances;
346
+ provisioning.listAllInstances = async () => [{ ...STANDALONE, status: 'torn_down' }];
347
+ try {
348
+ const out = await backfill.sweepOnce({ pool: fakePool(), log: () => {} });
349
+ assert.equal(out.considered, 1);
350
+ assert.equal(out.skipped, 1);
351
+ assert.equal(hub.reads.length, 0);
352
+ } finally {
353
+ provisioning.listAllInstances = saved;
354
+ }
355
+ });
356
+
357
+ test('a SECOND sweep writes nothing — safe to run at every single boot', async () => {
358
+ const deps = { pool: fakePool(), log: () => {} };
359
+ const first = await backfill.sweepOnce(deps);
360
+ assert.equal(first.projected, 2);
361
+
362
+ const second = await backfill.sweepOnce(deps);
363
+ assert.equal(second.projected, 0);
364
+ assert.equal(second.already, 2);
365
+ assert.equal(hub.publishes.length, 2, 'the two projected by the FIRST sweep, and no more');
366
+ });
367
+
368
+ test('the sweep says nothing when there was nothing to repair', async () => {
369
+ // A control plane that has already healed must not print an account at every restart.
370
+ hub.origins = new Set(['https://mercury.cloudbongos.test', ORIGIN]);
371
+ const lines = [];
372
+ await backfill.sweepOnce({ pool: fakePool(), log: (l) => lines.push(l) });
373
+ assert.deepEqual(lines, []);
374
+ });
375
+
376
+ test('the disposition line names what it repaired', async () => {
377
+ assert.equal(
378
+ backfill.formatSweepLine({ considered: 22, projected: 2, already: 1, skipped: 19, names: ['mercury', 'lars-test'] }),
379
+ '[catalog-backfill] 22 instances: 2 projected (mercury, lars-test) · 1 already · 19 skipped',
380
+ );
381
+ assert.match(backfill.formatSweepLine({ considered: 1, refused: 1 }), / · 1 refused$/);
382
+ });
383
+
384
+ // ── 6. what the repair puts on the map is still redactable (D2a) ──────────────────────────
385
+
386
+ test('a STEALTH instance the backfill adds is on the map ANONYMOUSLY — the row names nothing', async () => {
387
+ // The backfill's whole job is putting rows on the map that were never there, so the
388
+ // anonymisation has to hold for what it adds, not only for what the write paths added.
389
+ const pool = fakePool();
390
+ await bridge.healInstanceCatalog(pool, { ...STANDALONE, settings: { visibility: 'stealth' } });
391
+ const entry = catalog.normalizeEntry(hub.publishes[0].entry);
392
+ assert.equal(entry.visibility, 'stealth');
393
+
394
+ const wire = feed.feedShapeFor({
395
+ origin: entry.origin, name: entry.name, description: entry.description,
396
+ type: entry.type, visibility: entry.visibility, status: entry.status,
397
+ });
398
+ for (const field of ['origin', 'name', 'description', 'type']) {
399
+ assert.equal(wire[field], null, `${field} must not leave the server for a black hole`);
400
+ }
401
+ assert.deepEqual(Object.keys(wire).filter((k) => wire[k] !== null).sort(), ['orb', 'state']);
402
+ });
@@ -14,6 +14,12 @@
14
14
  // …and one negative: a settings save that changes nothing must not write to the catalog
15
15
  // either, for the same reason it does not restart the project.
16
16
  //
17
+ // Task 1003581 adds the FIFTH call site, and it is a READ: `GET /provisioning/instances/:id`
18
+ // self-heals the projection, because a write-triggered projection says nothing about the
19
+ // instances that existed BEFORE ADR 0246 landed — their owners' invites 404 until they happen
20
+ // to save something. Two pins: the owner's own read reaches the heal, and a REFUSED read never
21
+ // does (the repair may only ever ride an own-scoped request).
22
+ //
17
23
  // DB-free: the real router over loopback, data access and the bridge stubbed on the cached
18
24
  // module objects (the provisioning_settings.mjs pattern).
19
25
  //
@@ -40,6 +46,8 @@ const BASE_ROW = Object.freeze({
40
46
  let instance = { ...BASE_ROW };
41
47
  // Every catalog projection the routes ask for, in order.
42
48
  let published = [];
49
+ // Every self-heal the READ path asks for (task 1003581).
50
+ let healed = [];
43
51
 
44
52
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
45
53
  provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
@@ -72,6 +80,10 @@ catalogBridge.publishInstanceToCatalog = async (_db, row, opts = {}) => {
72
80
  published.push({ row: row ? { ...row } : null, ...opts });
73
81
  return { published: true };
74
82
  };
83
+ catalogBridge.healInstanceCatalog = async (_db, row) => {
84
+ healed.push(row ? { ...row } : null);
85
+ return { healed: true };
86
+ };
75
87
 
76
88
  // Destructured off the doorway at route-module load — stub before requiring the router.
77
89
  api.requireBuilder = (req, _res, next) => { req.builder = OWNER; next(); };
@@ -115,7 +127,7 @@ function call(method, path, body) {
115
127
  });
116
128
  }
117
129
 
118
- beforeEach(() => { instance = { ...BASE_ROW }; published = []; });
130
+ beforeEach(() => { instance = { ...BASE_ROW }; published = []; healed = []; });
119
131
 
120
132
  test('CREATE catalogues the project at birth, whatever its hosting shape', async () => {
121
133
  // The wizard hard-codes hosting_shape 'standalone', which is exactly the shape
@@ -212,6 +224,42 @@ test('a catalog hiccup can never turn a saved change into a failure', async () =
212
224
  }
213
225
  });
214
226
 
227
+ test('the OWNER READ self-heals the projection, with the row it just read (task 1003581)', async () => {
228
+ // The repair for every instance older than ADR 0246: its owner opens the manage page and
229
+ // the project catalogues itself, instead of waiting for them to save something they had no
230
+ // reason to change. Conditional and best-effort — what it then does is pinned in
231
+ // tests/project_catalog_backfill.mjs.
232
+ const res = await call('GET', '/provisioning/instances/18');
233
+ assert.equal(res.status, 200);
234
+ assert.equal(healed.length, 1);
235
+ assert.equal(healed[0].slug, 'spike-harbor');
236
+ assert.equal(published.length, 0, 'a read is not a write — only the heal path runs');
237
+ });
238
+
239
+ test('a REFUSED read heals NOTHING — the repair only ever rides an own-scoped request', async () => {
240
+ // instanceAccessDenied answers 404 for someone else's instance, and the heal must sit
241
+ // BEHIND that gate: an unauthenticated or wrong-owner read must never make the platform
242
+ // write a catalog row on a stranger's behalf.
243
+ instance = { ...BASE_ROW, owner_builder_id: 99 };
244
+ const res = await call('GET', '/provisioning/instances/18');
245
+ assert.equal(res.status, 404);
246
+ assert.equal(healed.length, 0);
247
+ });
248
+
249
+ test('a heal that throws can never turn the manage read into a failure', async () => {
250
+ // Same rule as every other call site: the repair is best-effort, and the owner's page must
251
+ // load whether or not the hub answered.
252
+ const saved = catalogBridge.healInstanceCatalog;
253
+ catalogBridge.healInstanceCatalog = async () => { throw new Error('hub unreachable'); };
254
+ try {
255
+ const res = await call('GET', '/provisioning/instances/18');
256
+ assert.equal(res.status, 200);
257
+ assert.equal(res.body.instance.slug, 'spike-harbor');
258
+ } finally {
259
+ catalogBridge.healInstanceCatalog = saved;
260
+ }
261
+ });
262
+
215
263
  test('mounting the routes registers the `provisioning.projectOwnedBy` port (task 1003580)', async () => {
216
264
  // The OTHER direction of the same seam: the catalog write travels provisioning → hub, and
217
265
  // this ownership read travels hub → provisioning, so the hub's invite door can own-scope a