@bongos/core 1.20.83 → 1.21.2

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.
Files changed (96) hide show
  1. package/.bongos-core.json +186 -81
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +60 -59
  14. package/docs/copy-registry.json +72 -63
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +9 -3
  19. package/docs/page-readings.json +427 -410
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/migrations/core_270_craft_achievements.sql +128 -0
  23. package/modules/economy/achievements.js +199 -4
  24. package/modules/economy/credits.js +16 -0
  25. package/modules/economy/reward.js +3 -0
  26. package/modules/hall-ui/public/builders.js +1 -1
  27. package/modules/hall-ui/public/genesis-home.js +17 -3
  28. package/modules/hall-ui/public/hall-render.js +24 -2
  29. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  30. package/modules/hall-ui/public/roster.css +21 -0
  31. package/modules/hall-ui/public/roster.html +3 -0
  32. package/modules/hall-ui/public/roster.js +121 -21
  33. package/modules/hall-ui/public/roster.states.json +26 -1
  34. package/modules/hall-ui/records/builders-roster.md +20 -0
  35. package/modules/lifecycle/publish-reconciler.js +25 -0
  36. package/modules/lifecycle/workflow-dispatch.js +21 -11
  37. package/modules/npm-release/release.js +10 -1
  38. package/modules/onboarding/founding-birth.js +116 -0
  39. package/modules/onboarding/genesis-home.js +72 -5
  40. package/modules/onboarding/port.js +9 -2
  41. package/modules/onboarding/routes/onboarding.js +8 -1
  42. package/modules/provisioning/birth.js +64 -0
  43. package/modules/provisioning/dns-resolves.js +54 -0
  44. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  45. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  46. package/modules/provisioning/provisioning.js +7 -6
  47. package/modules/provisioning/routes/provisioning.js +6 -2
  48. package/modules/provisioning/tests/liveness.mjs +48 -0
  49. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  50. package/modules/public-landing/public/projects.html +40 -15
  51. package/modules/sessions/db.js +58 -0
  52. package/modules/sessions/routes/sessions.js +97 -0
  53. package/modules/status-ui/public/status.js +39 -7
  54. package/modules/status-ui/public/style.css +24 -0
  55. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  56. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  57. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  58. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  59. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +45 -0
  63. package/scripts/gds/provision.js +77 -53
  64. package/scripts/gds/session-digest.js +11 -3
  65. package/scripts/gds/session-transcript-build.js +155 -0
  66. package/scripts/gds/session-transcript-upload.js +29 -0
  67. package/scripts/gds/ship-session-upload.js +6 -1
  68. package/scripts/gds/upgrade-commit-identity.js +32 -0
  69. package/scripts/gds/upgrade.js +1 -1
  70. package/scripts/hall-preview/refresh-fixtures.js +2 -0
  71. package/src/bongos/db.js +13 -0
  72. package/src/bongos/role-stats.js +36 -2
  73. package/src/bongos/routes/public.js +13 -2
  74. package/src/bongos/routes.js +3 -0
  75. package/src/branding.js +9 -0
  76. package/src/module-api.js +1 -1
  77. package/tests/achievements_reconcile.mjs +13 -0
  78. package/tests/craft_achievements.mjs +349 -0
  79. package/tests/founding_birth.mjs +200 -0
  80. package/tests/founding_builders.mjs +2 -1
  81. package/tests/founding_mode.mjs +3 -1
  82. package/tests/genesis_home.mjs +71 -2
  83. package/tests/hall_builders_page_pass.mjs +2 -1
  84. package/tests/hub_map_orb_states.mjs +5 -1
  85. package/tests/leaderboard_roles.mjs +272 -0
  86. package/tests/npm_release_release.mjs +37 -2
  87. package/tests/projects_hub.mjs +5 -3
  88. package/tests/projects_hub_dns_ready.mjs +153 -0
  89. package/tests/projects_hub_pre_uat.mjs +9 -5
  90. package/tests/provision_dns_first.mjs +171 -0
  91. package/tests/provision_settings_apply.mjs +6 -1
  92. package/tests/provisioning_settings_env.mjs +49 -0
  93. package/tests/session_transcript.mjs +273 -0
  94. package/tests/ui_design_kit.mjs +1 -1
  95. package/tests/upgrade_commit_identity.mjs +89 -0
  96. package/tests/wizard_demo.mjs +29 -3
@@ -75,7 +75,65 @@ function parsePlan(body = {}) {
75
75
  if (government !== null && !/^[a-z][a-z-]{1,39}$/.test(government)) return { ok: false, reason: 'bad_government' };
76
76
  const expected = b.expected_builders == null || b.expected_builders === '' ? null : Number(b.expected_builders);
77
77
  if (expected !== null && !intIn(expected, 1, 100000)) return { ok: false, reason: 'bad_expected_builders' };
78
- return { ok: true, plan: { days, passes, advisors, government, expected_builders: expected, existing: b.existing === true } };
78
+ const plan = { days, passes, advisors, government, expected_builders: expected, existing: b.existing === true };
79
+ // A DEMO's plan (task 1004506) also carries its time box and each stage's length in
80
+ // minutes. Only the birth path writes these (demoPlan below): the plan route's schema
81
+ // does not name them, so a client cannot send one.
82
+ if (b.demo != null) {
83
+ const d = b.demo;
84
+ if (!d || !intIn(d.people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) || !intIn(d.hours_each, DEMO_HOURS[0], DEMO_HOURS[1])) return { ok: false, reason: 'bad_demo' };
85
+ plan.demo = { people: d.people, hours_each: d.hours_each };
86
+ }
87
+ if (b.minutes != null) {
88
+ plan.minutes = {};
89
+ for (const s of PROPER) {
90
+ const m = b.minutes[s];
91
+ if (!intIn(m, 1, MAX_DEMO_MINUTES)) return { ok: false, reason: 'bad_minutes', stage: s };
92
+ plan.minutes[s] = m;
93
+ }
94
+ }
95
+ return { ok: true, plan };
96
+ }
97
+
98
+ // ── the demo's expedited genesis (task 1004506) ──────────────────────────────
99
+ // Owner ruling, 2026-10-01: a demo DOES go through genesis, but expedited, because the
100
+ // point of a demo is that the whole thing is fast. So a demo's plan is settled before
101
+ // the founder ever sees it, from what the demo already picked: Monarchy / BDFL (the
102
+ // founder's one yes closes each stage, spec D4's fast-track), one pass, no advisors,
103
+ // the demo's people as the headcount. Its stages are minutes, not days: genesis gets a
104
+ // fifth of the hours each person has (at least ten minutes, at most two hours), split
105
+ // by how much each stage asks, so a small team reaches "come alive" with most of the
106
+ // time box left for building.
107
+ const DEMO_PEOPLE = Object.freeze([1, 12]); // modules/provisioning/demo.js DEMO_LIMITS
108
+ const DEMO_HOURS = Object.freeze([1, 40]);
109
+ const MAX_DEMO_MINUTES = 240;
110
+ const DEMO_SHARE = 0.2;
111
+ const DEMO_GENESIS_MINUTES = Object.freeze([10, 120]);
112
+ const DEMO_WEIGHTS = Object.freeze({ scope: 3, strategize: 2, charter: 2, legalize: 1, legislation: 2 });
113
+
114
+ // demoMinutes(hoursEach) — PURE. Each stage's length in minutes, from the time box.
115
+ function demoMinutes(hoursEach) {
116
+ const [lo, hi] = DEMO_GENESIS_MINUTES;
117
+ const budget = Math.min(hi, Math.max(lo, Math.round(hoursEach * 60 * DEMO_SHARE)));
118
+ const total = Object.values(DEMO_WEIGHTS).reduce((a, b) => a + b, 0);
119
+ const out = {};
120
+ for (const s of PROPER) out[s] = Math.max(1, Math.round((budget * DEMO_WEIGHTS[s]) / total));
121
+ return out;
122
+ }
123
+
124
+ // demoPlan({ people, hours_each }) — PURE. The plan body a demo is born with; it goes
125
+ // through parsePlan like any other, so it is held to the same ranges.
126
+ function demoPlan({ people, hours_each: hoursEach }) {
127
+ return {
128
+ days: Object.fromEntries(PROPER.map((s) => [s, 1])),
129
+ minutes: demoMinutes(hoursEach),
130
+ passes: 1,
131
+ advisors: 'none',
132
+ government: 'monarchy',
133
+ expected_builders: people,
134
+ existing: false,
135
+ demo: { people, hours_each: hoursEach },
136
+ };
79
137
  }
80
138
 
81
139
  function readPlanValue(raw) {
@@ -99,8 +157,11 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
99
157
  const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
100
158
  const b = board[key] || null;
101
159
  const days = plan && plan.days && plan.days[key] ? plan.days[key] : info.days;
160
+ // A demo's stage is minutes long (task 1004506); its due time is counted in them.
161
+ const minutes = plan && plan.minutes && plan.minutes[key] ? plan.minutes[key] : null;
102
162
  const began = stageAt[key] ? new Date(stageAt[key]) : null;
103
- const due = began && Number.isFinite(began.getTime()) ? new Date(began.getTime() + days * 86400000).toISOString() : null;
163
+ const span = minutes ? minutes * 60000 : days * 86400000;
164
+ const due = began && Number.isFinite(began.getTime()) ? new Date(began.getTime() + span).toISOString() : null;
104
165
  return {
105
166
  key,
106
167
  number: idx,
@@ -110,6 +171,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
110
171
  done_when: info.done_when,
111
172
  wakes: fm.WAKES[key] || [],
112
173
  days,
174
+ minutes,
113
175
  due,
114
176
  state: idx < at ? 'done' : idx === at ? 'now' : 'later',
115
177
  tasks: list,
@@ -131,6 +193,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
131
193
  day: mode.day,
132
194
  deciding: mode.stage === 'decide',
133
195
  plan: plan || null,
196
+ // The demo's time box when this genesis is a demo's (task 1004506), else null.
197
+ demo: plan && plan.demo ? plan.demo : null,
134
198
  stages,
135
199
  // The close door: every task of the current stage finished, and not already sitting.
136
200
  can_close: !!(now && now.total > 0 && now.finished === now.total && !sitting && !passed),
@@ -193,8 +257,9 @@ async function readGenesis(deps = {}) {
193
257
  return shapeGenesis({ mode, plan, tasks, board, stageAt });
194
258
  }
195
259
 
196
- // startFounding — a brand-new project enters genesis (the create flow's birth calls
197
- // this). Refused once the project has come alive: genesis happens once (spec D8).
260
+ // startFounding — a brand-new project enters genesis (founding-birth.js calls this once
261
+ // on a project born through the startup flow, task 1004506). Refused once the project
262
+ // has come alive: genesis happens once (spec D8).
198
263
  async function startFounding(builderId, deps = {}) {
199
264
  const s = seams(deps);
200
265
  const ended = await s.get(fm.KEYS.ENDED_AT);
@@ -269,6 +334,8 @@ async function onStagePassed(deps = {}) {
269
334
  }
270
335
 
271
336
  module.exports = {
272
- PLAN_KEY, STAGE_INFO, PROPER, parsePlan, shapeGenesis,
337
+ PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
273
338
  readGenesis, startFounding, savePlan, closeStage, comeAlive, onStagePassed,
339
+ // the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
340
+ modeDepsFor: (deps) => modeDeps(seams(deps)),
274
341
  };
@@ -41,6 +41,7 @@
41
41
  const onboardingState = require('./onboarding-state');
42
42
  const newcomerRestock = require('./newcomer-restock');
43
43
  const foundingMode = require('./founding-mode');
44
+ const foundingBirth = require('./founding-birth');
44
45
 
45
46
  module.exports = {
46
47
  // --- the newcomer onboarding state machine (onboarding-state.js) ---------
@@ -56,8 +57,14 @@ module.exports = {
56
57
 
57
58
  // --- project founding mode (founding-mode.js, task 1004422) ---------------
58
59
  // me.js reads it for the hall shell (null = not founding = the hall as today);
59
- // the genesis home (task 1004423) drives the write.
60
- foundingModeFor: (...a) => foundingMode.foundingModeFor(...a),
60
+ // the genesis home (task 1004423) drives the write. A project born through the
61
+ // startup flow enters genesis on this, its first read (founding-birth.js, task
62
+ // 1004506): once, guarded, and a no-op without the birth marker. A caller passing
63
+ // its own deps (a test) skips the birth.
64
+ foundingModeFor: async (...a) => {
65
+ if (!a.length) await foundingBirth.ensureBirth();
66
+ return foundingMode.foundingModeFor(...a);
67
+ },
61
68
  setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
62
69
 
63
70
  // --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
@@ -36,6 +36,7 @@ const onboardingPort = require('../port');
36
36
  const buildAccessRequestsRouter = require('./access-requests');
37
37
  const foundingBuilders = require('../founding-builders');
38
38
  const genesis = require('../genesis-home');
39
+ const foundingBirth = require('../founding-birth');
39
40
 
40
41
  // Register the `onboarding` kernel PORT. See the header + port.js for the full
41
42
  // consumer list and the graceful-degradation contract.
@@ -76,6 +77,7 @@ const GENESIS_REFUSALS = {
76
77
  not_at_legislation: 409, legislation_not_passed: 409, stage_tasks_unfinished: 409,
77
78
  no_stage_tasks: 409, unknown_stage: 400, no_board: 503,
78
79
  bad_days: 400, bad_passes: 400, bad_advisors: 400, bad_government: 400, bad_expected_builders: 400,
80
+ bad_demo: 400, bad_minutes: 400,
79
81
  };
80
82
  let genesisListening = false;
81
83
 
@@ -101,7 +103,12 @@ function mountGenesisRoutes(router) {
101
103
  res.status(500).json({ error: { code: 'genesis_failed', message: 'internal error' } });
102
104
  }
103
105
  };
104
- router.get('/founding/genesis', api.requireBuilder, handle(async (req) => ({ genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) })));
106
+ // A project born through the startup flow enters genesis on its first founding read
107
+ // (founding-birth.js, task 1004506) — here and on GET /me through the port.
108
+ router.get('/founding/genesis', api.requireBuilder, handle(async (req) => {
109
+ await foundingBirth.ensureBirth();
110
+ return { genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) };
111
+ }));
105
112
  router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.startFounding(req.builder.id)));
106
113
  // The plan's ranges and vocabularies are genesis-home.js parsePlan's (one source);
107
114
  // this schema only names the fields so an unknown one is refused.
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/birth.js — the BIRTH MARKER: this project was made through the
4
+ // startup flow, so it is born into genesis (task 1004506 / BV2.PS18; spec
5
+ // docs/specs/bongos-v2-project-startup.md step 5 + D8).
6
+ //
7
+ // Founding mode lives in the NEW INSTANCE's own project_settings, and this process (the
8
+ // hub's web tier) has no way into that database — the one rule of this module. So the
9
+ // hub only says so, and the instance acts on it (modules/onboarding/founding-birth.js):
10
+ //
11
+ // * the create route stamps `founding_birth` (a reserved settings-jsonb key, like
12
+ // push_owed and look_accent: platform bookkeeping, out of SETTINGS_VOCAB, so the
13
+ // settings PATCH cannot write it and effectiveSettings never projects it) on the
14
+ // row it JUST created — never on one that already existed, so a project made before
15
+ // this shipped is never born into anything (D8);
16
+ // * the companions below carry it, and a demo's time box, into the instance's
17
+ // web.env beside the other settings, at standup and on every settings-apply push.
18
+ //
19
+ // Always emitted, empty meaning "not born through the flow" / "not a demo", for the
20
+ // same reason as every companion: the runner patches these lines in place, and a key
21
+ // that came and went would leave a stale line behind.
22
+
23
+ const BIRTH_KEY = 'founding_birth';
24
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
25
+
26
+ const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
27
+
28
+ // bornAt(row) — PURE. The birth stamp, or '' for a row that has none (or a mangled one).
29
+ function bornAt(row) {
30
+ const v = String(storedOf(row)[BIRTH_KEY] || '');
31
+ return ISO.test(v) && Number.isFinite(new Date(v).getTime()) ? v : '';
32
+ }
33
+
34
+ // demoBox(row) — PURE. A born demo's { people, hours_each }, or null. Only a row that is
35
+ // both born and a demo carries one: an older demo has no marker and founds nothing.
36
+ function demoBox(row) {
37
+ if (!bornAt(row)) return null;
38
+ const d = row && row.demo;
39
+ if (!d || typeof d !== 'object') return null;
40
+ const people = Number(d.people);
41
+ const hours = Number(d.hours_each);
42
+ if (!Number.isInteger(people) || !Number.isInteger(hours) || people < 1 || hours < 1) return null;
43
+ return { people, hours_each: hours };
44
+ }
45
+
46
+ // The companion env (SETTINGS_COMPANION_ENV). src/branding.js maps them onto founding.*.
47
+ const BIRTH_COMPANION_ENV = Object.freeze({
48
+ FOUNDING_BIRTH: bornAt,
49
+ DEMO_PEOPLE: (row) => { const d = demoBox(row); return d ? String(d.people) : ''; },
50
+ DEMO_HOURS: (row) => { const d = demoBox(row); return d ? String(d.hours_each) : ''; },
51
+ });
52
+
53
+ // stampBirth(db, instanceId, now) — the create route's one write, on the row it just
54
+ // made. Write-once: a stamp already there is kept (the WHERE says so).
55
+ async function stampBirth(db, instanceId, now = new Date()) {
56
+ await db.query(
57
+ `UPDATE provisioning_instances
58
+ SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::text)
59
+ WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
60
+ [instanceId, BIRTH_KEY, now.toISOString()],
61
+ );
62
+ }
63
+
64
+ module.exports = { BIRTH_KEY, BIRTH_COMPANION_ENV, stampBirth };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/dns-resolves.js — has the platform's own lookup seen this
4
+ // project's address answer? (task 1004505, BV2.PS17; migration provisioning_036)
5
+ //
6
+ // WHY. A visitor's resolver caches a "not found" answer for the zone's negative TTL
7
+ // (cloudbongos.com's SOA minimum is 1800s). The hub used to link an address as soon as
8
+ // the row read 'active', so a founder who clicked before the name answered anywhere
9
+ // could not reach their own project from their home network for up to half an hour.
10
+ // The fix has two halves: the runner creates the DNS record FIRST (scripts/gds/
11
+ // provision.js), and the hub holds every link to the address until `dns_resolves` is
12
+ // true — a fact only a lookup the PLATFORM made can establish, never the status alone.
13
+ //
14
+ // Who writes it: the runner, at the end of a standup (minutes after the record was
15
+ // created, so its own lookup cannot race the record into a cached miss), and the
16
+ // liveness sweep, whose SSRF gate already resolves every active address. Sticky once
17
+ // seen; a changed domain clears it (updateInstanceDomain in ./provisioning.js).
18
+ // Its own file because ./provisioning.js sits at the 1500-line size ratchet.
19
+
20
+ const dns = require('node:dns').promises;
21
+
22
+ // The projection publicInstance carries. PURE. No domain ⇒ nothing to resolve.
23
+ function dnsResolves(row) {
24
+ return !!(row && row.domain && row.dns_resolved_at);
25
+ }
26
+
27
+ // Record that a lookup answered. Narrow like recordDnsNote: no `status`, no
28
+ // `updated_at`, and only the FIRST sighting writes, so a sweep never rewrites it.
29
+ async function recordDnsResolved(db, id) {
30
+ const { rows } = await db.query(
31
+ `UPDATE provisioning_instances SET dns_resolved_at = now()
32
+ WHERE id = $1 AND dns_resolved_at IS NULL RETURNING id, dns_resolved_at`,
33
+ [id]
34
+ );
35
+ return rows[0] || null;
36
+ }
37
+
38
+ // One bounded lookup: true when the name resolves to at least one address, false on
39
+ // any failure or after `timeoutMs`. Never throws. `lookup` is injectable for tests.
40
+ async function lookupAnswers(host, { lookup, timeoutMs = 5000 } = {}) {
41
+ if (!host) return false;
42
+ let timer = null;
43
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); });
44
+ try {
45
+ const ask = Promise.resolve((lookup || dns.lookup)(host, { all: true }))
46
+ .then((addrs) => Array.isArray(addrs) ? addrs.some((a) => a && a.address) : !!(addrs && addrs.address))
47
+ .catch(() => false);
48
+ return await Promise.race([ask, timeout]);
49
+ } finally {
50
+ if (timer) clearTimeout(timer);
51
+ }
52
+ }
53
+
54
+ module.exports = { dnsResolves, recordDnsResolved, lookupAnswers };
@@ -0,0 +1,30 @@
1
+ -- provisioning_036_dns_resolved.sql — when the platform's own lookup first saw a
2
+ -- project's address answer (task 1004505, BV2.PS17).
3
+ --
4
+ -- WHY THIS EXISTS. A visitor's resolver caches a "not found" answer for the zone's
5
+ -- negative TTL (30 minutes on cloudbongos.com). The hub used to link a project's
6
+ -- address as soon as the row said 'active', and the founder's first click, made
7
+ -- before the name answered anywhere, poisoned their own home network for half an
8
+ -- hour. The hub now holds every link to the address until this column is set.
9
+ --
10
+ -- WHAT IT ADDS.
11
+ -- provisioning_instances.dns_resolved_at — NULL until a lookup made by the
12
+ -- platform (the runner at the end of a standup, or the liveness sweep's gate)
13
+ -- returned an address for the row's domain. Projected as `dns_resolves`.
14
+ -- A changed domain clears it (updateInstanceDomain), like the liveness overlay.
15
+ --
16
+ -- OVERLAY, NOT A STATE (the provisioning_006 rule): nullable, no default, never
17
+ -- written by the lifecycle. The backfill marks every address the sweep has
18
+ -- already seen answer, so no live project loses its link when this lands.
19
+ --
20
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
21
+
22
+ BEGIN;
23
+
24
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS dns_resolved_at timestamptz;
25
+
26
+ UPDATE provisioning_instances
27
+ SET dns_resolved_at = last_verified_at
28
+ WHERE dns_resolved_at IS NULL AND last_verified_up IS TRUE AND domain IS NOT NULL;
29
+
30
+ COMMIT;
@@ -224,6 +224,11 @@ async function sweepOnce(deps = {}) {
224
224
  }
225
225
  const probe = await probeHealthz(url, { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl, pin });
226
226
  const rec = await provisioning.recordLivenessProbe(db, row.id, probe.up);
227
+ // The gate's lookup IS the platform's own lookup (task 1004505): an address it
228
+ // resolved, or one that answered, may now be linked on the hub. First sighting only.
229
+ if (row.domain && !row.dns_resolved_at && (pin || probe.up)) {
230
+ await provisioning.recordDnsResolved(db, row.id).catch((e) => log(`[liveness] dns record failed (non-blocking): ${e && e.message ? e.message : e}`));
231
+ }
227
232
  return { row, probed: true, prevUp: rec ? rec.prev_up : null, ...probe };
228
233
  }));
229
234
 
@@ -24,7 +24,8 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
24
24
  // projection both have to read them.
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
26
  const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); // the demo marker's owner view (task 1004420)
27
- const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look');
27
+ const { dnsResolves, recordDnsResolved, lookupAnswers } = require('./dns-resolves'); // has the platform's lookup seen the address answer (task 1004505)
28
+ const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); // born into genesis (task 1004506)
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
@@ -466,7 +467,7 @@ function publicInstance(row) {
466
467
  last_verified_up: row.last_verified_up == null ? null : !!row.last_verified_up,
467
468
  // The outstanding manual-DNS step, if the runner had no hook to point the
468
469
  // A record (task 1002720 / F16). null = nothing outstanding.
469
- dns_note: row.dns_note || null,
470
+ dns_note: row.dns_note || null, dns_resolves: dnsResolves(row), // the platform's lookup saw it answer; the hub links it only then (task 1004505)
470
471
  cost_estimate_usd: Number(row.cost_estimate_usd || 0),
471
472
  error_note: row.error_note || null,
472
473
  created_at: row.created_at,
@@ -518,7 +519,7 @@ function settingsEnvVars(row, prefix) {
518
519
  // cutoff and every unresolved review counts, which is the right answer for a
519
520
  // project that has been strict since it stood up.
520
521
  const SETTINGS_COMPANION_ENV = Object.freeze({
521
- ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, // + accent, logo (look.js)
522
+ ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js)
522
523
  });
523
524
 
524
525
  // The web.env block the runner writes at STANDUP (task 1003139): a comment that
@@ -974,7 +975,7 @@ async function updateInstanceDomain(db, id, domain, releasing = null) {
974
975
  ELSE array_append(released_domains, $3)
975
976
  END,
976
977
  last_verified_at = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_at END,
977
- last_verified_up = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_up END,
978
+ last_verified_up = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_up END, dns_resolved_at = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE dns_resolved_at END,
978
979
  updated_at = now()
979
980
  WHERE id = $1 RETURNING *`,
980
981
  [id, domain, host, MAX_RELEASED_DOMAINS]
@@ -1004,7 +1005,7 @@ async function clearReleasedDomains(db, id) {
1004
1005
  // against it would only ever record a false "down". `limit` is a runaway guard.
1005
1006
  async function listActiveInstancesForLiveness(db, { limit = 500 } = {}) {
1006
1007
  const { rows } = await db.query(
1007
- `SELECT id, slug, domain, port, settings FROM provisioning_instances
1008
+ `SELECT id, slug, domain, port, settings, dns_resolved_at FROM provisioning_instances
1008
1009
  WHERE status = 'active'
1009
1010
  ORDER BY id
1010
1011
  LIMIT $1`,
@@ -1486,7 +1487,7 @@ module.exports = {
1486
1487
  isProvisionedHostname,
1487
1488
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
1488
1489
  // data access — verification overlay (task 1002696)
1489
- listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote, ownsErrorNote,
1490
+ listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote, ownsErrorNote, dnsResolves, recordDnsResolved, lookupAnswers,
1490
1491
  // data access — the owed-push marker (task 1003524)
1491
1492
  recordSettingsPushOwed,
1492
1493
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
@@ -64,7 +64,7 @@ const catalogBridge = require('../catalog-bridge');
64
64
  const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
65
65
  const { callbackPage } = require('./callback-page');
66
66
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
67
- const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo');
67
+ const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
68
68
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
69
69
  const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
70
70
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
@@ -471,6 +471,11 @@ module.exports = function provisioningRoutes() {
471
471
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
472
472
  return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
473
473
  }
474
+ // Born into genesis (task 1004506, ../birth.js): only the row this request MADE, and before the
475
+ // provision intent exists, so the runner's web.env already carries the marker and the demo's box.
476
+ // Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
477
+ if (created) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
478
+ const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
474
479
  // Enqueue the provision intent (idempotent: an already-open intent is returned)
475
480
  // — but ONLY when the instance actually needs standing up. Blindly enqueueing on
476
481
  // a re-request of an ACTIVE instance left a stale open provision intent that then
@@ -493,7 +498,6 @@ module.exports = function provisioningRoutes() {
493
498
  // description exists — the publish gate alone decides that — but the row has to be
494
499
  // there for the gate to have anything to decide about, and for the wizard's invite
495
500
  // step to have a project to invite into while standup is still running.
496
- const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
497
501
  await catalogBridge.publishInstanceToCatalog(pool, demoRow || instance).catch(() => {});
498
502
  // A re-request cannot change an existing instance's domain — SAY so instead of
499
503
  // silently dropping it (task 1002692); the domain route is the real path.
@@ -234,6 +234,54 @@ await ta('an unresolvable domain still probes and records an honest DOWN (the qa
234
234
  assert.deepEqual({ blocked: out.blocked, down: out.down }, { blocked: 0, down: 1 });
235
235
  assert.deepEqual(out.reasons, ['ghostly:ENOTFOUND']);
236
236
  assert.deepEqual(db.updates.map((u) => u.up), [false], 'DNS-less domain = honest down, not a silent skip');
237
+ assert.ok(!db.updates.some((u) => /dns_resolved_at/.test(u.sql)), 'an address that did not resolve is never marked as resolving');
238
+ });
239
+
240
+ console.log('\ndns_resolves (task 1004505 — the hub links an address only once the platform saw it answer):');
241
+
242
+ const isDnsWrite = (u) => /SET dns_resolved_at = now\(\)/.test(u.sql);
243
+
244
+ await ta('a domain the gate RESOLVED is recorded as resolving — once, even when the probe itself is down', async () => {
245
+ const db = fakeDb([{ id: 9, slug: 'fresh', domain: 'fresh.example.com', port: null, dns_resolved_at: null }]);
246
+ await liveness.sweepOnce({
247
+ pool: db, log: () => {},
248
+ lookup: async () => [{ address: '93.184.216.34', family: 4 }],
249
+ fetchImpl: async () => { throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); },
250
+ });
251
+ const writes = db.updates.filter(isDnsWrite);
252
+ assert.equal(writes.length, 1, 'the lookup answered, so the address may be linked');
253
+ assert.equal(writes[0].id, 9);
254
+ assert.match(writes[0].sql, /dns_resolved_at IS NULL/, 'only the first sighting writes');
255
+ assert.ok(!/status|updated_at/.test(writes[0].sql), 'an overlay: no status, no updated_at');
256
+ });
257
+
258
+ await ta('an address already marked is not written again', async () => {
259
+ const db = fakeDb([{ id: 10, slug: 'old', domain: 'old.example.com', port: null, dns_resolved_at: '2026-10-01T10:00:00Z' }]);
260
+ await liveness.sweepOnce({
261
+ pool: db, log: () => {},
262
+ lookup: async () => [{ address: '93.184.216.34', family: 4 }],
263
+ fetchImpl: async () => new Response('ok', { status: 200 }),
264
+ });
265
+ assert.equal(db.updates.filter(isDnsWrite).length, 0);
266
+ });
267
+
268
+ t('publicInstance projects dns_resolves: false without a sighting or a domain, true with both', () => {
269
+ assert.equal(provisioning.publicInstance({ id: 1, slug: 'a', status: 'active', domain: 'a.example.com' }).dns_resolves, false);
270
+ assert.equal(provisioning.publicInstance({ id: 1, slug: 'a', status: 'active', domain: 'a.example.com', dns_resolved_at: '2026-10-01T10:00:00Z' }).dns_resolves, true);
271
+ assert.equal(provisioning.publicInstance({ id: 1, slug: 'a', status: 'active', domain: null, dns_resolved_at: '2026-10-01T10:00:00Z' }).dns_resolves, false);
272
+ });
273
+
274
+ await ta('lookupAnswers: an address is an answer; a miss, a throw or a hang is not', async () => {
275
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: async () => [{ address: '1.2.3.4', family: 4 }] }), true);
276
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: async () => [] }), false);
277
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: async () => { throw new Error('ENOTFOUND'); } }), false);
278
+ assert.equal(await provisioning.lookupAnswers('a.example.com', { lookup: () => new Promise(() => {}), timeoutMs: 20 }), false);
279
+ assert.equal(await provisioning.lookupAnswers('', {}), false);
280
+ });
281
+
282
+ t('a changed domain clears the sighting with the rest of the overlay', () => {
283
+ const src = provisioning.updateInstanceDomain.toString();
284
+ assert.match(src, /dns_resolved_at = CASE WHEN domain IS DISTINCT FROM \$2 THEN NULL ELSE dns_resolved_at END/);
237
285
  });
238
286
 
239
287
  await ta('the gate hands back the address it vetted, so the probe can pin to it (no re-resolution)', async () => {
@@ -0,0 +1,85 @@
1
+ {
2
+ "_": "Projects whose address does not resolve yet (task 1004505): the same page, the same stub, the instance list aliased to two projects the platform's own lookup has not seen answer. The mine rows and the manage page say the address is being set up and carry no link to it — a click before the name exists is cached as not found for up to half an hour. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-dns-pending.states.json",
3
+ "page": "/projects",
4
+ "surface": "public-landing",
5
+ "modes": [
6
+ "dark"
7
+ ],
8
+ "modeQuery": false,
9
+ "stub": {
10
+ "fixtures": {
11
+ "provisioning-instances": "provisioning-instances-dns-pending"
12
+ }
13
+ },
14
+ "ignoreRequests": [
15
+ "/api/(gds|bongos)/me$",
16
+ "/my-projects",
17
+ "/provisioning/onboard-plan",
18
+ "/env-manifest$"
19
+ ],
20
+ "ignoreConsole": [
21
+ "/api/(gds|bongos)/me",
22
+ "\\b401\\b",
23
+ "\\b404\\b",
24
+ "Unauthorized"
25
+ ],
26
+ "ignoreControls": [
27
+ ".skip",
28
+ ".vh"
29
+ ],
30
+ "states": {
31
+ "in-mine-dns-pending": {
32
+ "auth": true,
33
+ "url": "/projects?view=mine",
34
+ "expect": {
35
+ "visible": [
36
+ "#projectsList .proj .addr"
37
+ ],
38
+ "count": [
39
+ [
40
+ "#projectsList .proj",
41
+ 2
42
+ ],
43
+ [
44
+ "#projectsList .proj .actions a",
45
+ 2
46
+ ],
47
+ [
48
+ "#projectsList a[href^=\"https://\"]",
49
+ 0
50
+ ],
51
+ [
52
+ "#projectsList a.worldOrb",
53
+ 0
54
+ ]
55
+ ]
56
+ }
57
+ },
58
+ "in-manage-dns-pending": {
59
+ "auth": true,
60
+ "url": "/projects?view=manage&id=106",
61
+ "expect": {
62
+ "visible": [
63
+ "#mMain",
64
+ "#mAddr"
65
+ ],
66
+ "count": [
67
+ [
68
+ "#mAddr a",
69
+ 0
70
+ ],
71
+ [
72
+ "#mActs a[href^=\"https://\"]",
73
+ 0
74
+ ]
75
+ ],
76
+ "text": [
77
+ [
78
+ "#mName",
79
+ "beacon"
80
+ ]
81
+ ]
82
+ }
83
+ }
84
+ }
85
+ }