@bongos/core 1.20.83 → 1.21.1

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 (73) hide show
  1. package/.bongos-core.json +151 -61
  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 +10 -10
  14. package/docs/copy-registry.json +12 -12
  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 +6 -0
  18. package/docs/page-inventory.json +8 -3
  19. package/docs/page-readings.json +382 -373
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/modules/hall-ui/public/genesis-home.js +17 -3
  23. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  24. package/modules/lifecycle/workflow-dispatch.js +21 -11
  25. package/modules/npm-release/release.js +10 -1
  26. package/modules/onboarding/founding-birth.js +116 -0
  27. package/modules/onboarding/genesis-home.js +72 -5
  28. package/modules/onboarding/port.js +9 -2
  29. package/modules/onboarding/routes/onboarding.js +8 -1
  30. package/modules/provisioning/birth.js +64 -0
  31. package/modules/provisioning/dns-resolves.js +54 -0
  32. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  33. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  34. package/modules/provisioning/provisioning.js +7 -6
  35. package/modules/provisioning/routes/provisioning.js +6 -2
  36. package/modules/provisioning/tests/liveness.mjs +48 -0
  37. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  38. package/modules/public-landing/public/projects.html +40 -15
  39. package/modules/sessions/db.js +58 -0
  40. package/modules/sessions/routes/sessions.js +97 -0
  41. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  42. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  43. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  44. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  45. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +35 -0
  49. package/scripts/gds/provision.js +77 -53
  50. package/scripts/gds/session-digest.js +11 -3
  51. package/scripts/gds/session-transcript-build.js +155 -0
  52. package/scripts/gds/session-transcript-upload.js +29 -0
  53. package/scripts/gds/ship-session-upload.js +6 -1
  54. package/scripts/gds/upgrade-commit-identity.js +32 -0
  55. package/scripts/gds/upgrade.js +1 -1
  56. package/src/bongos/routes.js +3 -0
  57. package/src/branding.js +9 -0
  58. package/src/module-api.js +1 -1
  59. package/tests/founding_birth.mjs +200 -0
  60. package/tests/founding_mode.mjs +3 -1
  61. package/tests/genesis_home.mjs +71 -2
  62. package/tests/hub_map_orb_states.mjs +5 -1
  63. package/tests/npm_release_release.mjs +37 -2
  64. package/tests/projects_hub.mjs +5 -3
  65. package/tests/projects_hub_dns_ready.mjs +153 -0
  66. package/tests/projects_hub_pre_uat.mjs +9 -5
  67. package/tests/provision_dns_first.mjs +171 -0
  68. package/tests/provision_settings_apply.mjs +6 -1
  69. package/tests/provisioning_settings_env.mjs +49 -0
  70. package/tests/session_transcript.mjs +273 -0
  71. package/tests/ui_design_kit.mjs +1 -1
  72. package/tests/upgrade_commit_identity.mjs +89 -0
  73. package/tests/wizard_demo.mjs +29 -3
@@ -57,7 +57,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
57
57
 
58
58
  **Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
59
59
 
60
- **Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub App needs **Actions: write** to start the job. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
60
+ **Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub credential needs **Actions: write** to start the job: the `GITHUB_PUSH_TOKEN` access token (what cloudbongos.com uses), or the GitHub App where one is configured. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
61
61
 
62
62
  The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
63
63
 
@@ -0,0 +1,35 @@
1
+ -- core_269_session_transcripts.sql — the FULL, secret-scrubbed transcript of every
2
+ -- session (ADR 0051, task 1001004).
3
+ --
4
+ -- WHY A SEPARATE TABLE and not a widened session_records.detail / a new jsonb
5
+ -- column on it: session_records is the BFG's hot, searched, paged table
6
+ -- (searchSessions, summarizeSessions, /mine all SELECT from it, and its rows are
7
+ -- KB by design — ADR 0027). A transcript is MB. Putting it on the same row would
8
+ -- make every list query drag (or have to remember to exclude) a fat jsonb, and
9
+ -- would make a TOASTed-column slip on one query a performance incident. One row
10
+ -- per session in each table, joined on session_id, keeps the evaluator's read
11
+ -- path exactly as cheap as it is today. ADR 0051 §1 allowed either shape
12
+ -- ("new transcript jsonb column, or a widened detail"); this is the third
13
+ -- option that satisfies its intent without the cost.
14
+ --
15
+ -- The content is scrubbed ON THE BUILDER'S BOX before upload (modules/security/
16
+ -- secret-scrub.js, the task-1003 scrubber); this table never holds raw text.
17
+ --
18
+ -- builder_id is denormalised from session_records ON PURPOSE: the read route's
19
+ -- ownership check (own transcript vs Archon-only cross-builder) must not depend
20
+ -- on a join succeeding, and the upload's ownership guard needs it at write time.
21
+ --
22
+ -- Retention: keep forever (ADR 0051 §4, Lars 2026-06-13). Storage-cost work is a
23
+ -- deliberate future task, not designed in here.
24
+
25
+ CREATE TABLE IF NOT EXISTS session_transcripts (
26
+ session_id text PRIMARY KEY, -- Claude Code session id, == session_records.session_id
27
+ builder_id bigint NOT NULL REFERENCES builders(id),
28
+ turn_count integer NOT NULL DEFAULT 0,
29
+ byte_size integer NOT NULL DEFAULT 0, -- serialized size of `turns`, for corpus accounting
30
+ truncated boolean NOT NULL DEFAULT false, -- true when the uploader hit its own size ceiling
31
+ turns jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-turn scrubbed content (prompts, assistant text, tool calls + results)
32
+ uploaded_at timestamptz NOT NULL DEFAULT now()
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_session_transcripts_builder ON session_transcripts (builder_id);
@@ -79,7 +79,7 @@
79
79
  var now = g.stages.find(function (s) { return s.state === 'now'; });
80
80
  var sub = g.deciding
81
81
  ? 'Deciding the plan' + (g.day ? ' · day ' + g.day : '')
82
- : 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.day ? ' · day ' + g.day : '');
82
+ : 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.demo ? ' · demo' : g.day ? ' · day ' + g.day : '');
83
83
  return '<div class="genesis__head"><h2 id="genesis-h" class="scroll__h">Genesis</h2>'
84
84
  + '<p class="genesis__sub">' + esc(sub) + '</p></div>' + stepperHtml(g);
85
85
  }
@@ -157,7 +157,10 @@
157
157
  function stagesHtml(g) {
158
158
  return '<ol class="genesis__stages">' + g.stages.map(function (s) {
159
159
  var wakes = (s.wakes || []).map(function (id) { return ROOM[id] || id; });
160
- var due = s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
160
+ // A demo's stage is minutes long (task 1004506): say how long, and due at a time.
161
+ var due = s.minutes
162
+ ? ' · ' + s.minutes + ' min' + (s.state === 'now' && s.due ? ', due ' + new Date(s.due).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '')
163
+ : s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
161
164
  return '<li class="genesis__stage is-' + s.state + '">'
162
165
  + '<span class="genesis__stage-n">0' + s.number + '</span>'
163
166
  + '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
@@ -225,12 +228,23 @@
225
228
  return document.documentElement.getAttribute('data-founding') === 'on';
226
229
  }
227
230
 
231
+ // A demo's genesis is expedited (task 1004506): its plan was settled from the demo's
232
+ // own picks before anyone saw it, so it never shows the decide form, only this line.
233
+ function demoHtml(g) {
234
+ var d = g.demo;
235
+ if (!d) return '';
236
+ var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
237
+ return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
238
+ + 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
239
+ + esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
240
+ }
241
+
228
242
  function render(host, state) {
229
243
  var g = state.genesis;
230
244
  if (!g) { host.hidden = true; return; }
231
245
  host.innerHTML = headHtml(g) + (g.deciding
232
246
  ? decideHtml(g, state.gov)
233
- : lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
247
+ : demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
234
248
  host.hidden = false;
235
249
  }
236
250
 
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "The genesis home of a DEMO, born into genesis (task 1004506): 3 people, 4 hours each. The plan was settled at birth from the demo's picks, so there is no decide form: one line names the plan, and each stage reads in minutes (scope in progress, due at a time). /me says founding at scope (kit fixture me-founding-demo.json) and /founding/genesis answers founding-genesis-demo.json, built from the real server shaper. A real project's genesis is index-genesis.states.json and index-genesis-decide.states.json; a hall that is not founding is index.states.json.",
3
+ "page": "/builders/",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/", "fixtures": { "me": "me-founding-demo", "founding-genesis": "founding-genesis-demo" } },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
8
+ "_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
+ "states": {
11
+ "demo": { "auth": true, "actions": [["wait", 800]], "expect": { "visible": ["#genesis-home", "#founding-band"] } }
12
+ }
13
+ }
@@ -5,16 +5,21 @@
5
5
  // Kept beside github-push.js rather than in it: it shares that file's credential and headers,
6
6
  // and nothing else.
7
7
 
8
- const { resolveToken, ghHeaders, isSafeBranch } = require('./github-push');
8
+ const { getInstallationToken, ghHeaders, isSafeBranch } = require('./github-push');
9
9
  const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
10
10
 
11
11
  // dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
12
12
  // Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
13
- // leaves GitHub: the server holds only the GitHub App key it already holds, and the App needs
14
- // Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
15
- // { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT, REFUSED
16
- // (GitHub said no: 403 = the App lacks Actions: write, 404 = no such workflow or no access,
17
- // 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
13
+ // leaves GitHub: the server uses only the GitHub credential it already holds, which needs
14
+ // Actions: write for this one call. Returns { ok:true, credential } on GitHub's 204, otherwise
15
+ // { ok:false, code, status?, credential? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
16
+ // REFUSED (GitHub said no: 403 = the credential lacks Actions: write, 404 = no such workflow or no
17
+ // access, 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
18
+ //
19
+ // `credential` says WHICH one was used — 'app' (a GitHub App installation token) or 'token' (the
20
+ // GITHUB_PUSH_TOKEN access token) — so a refusal can name the thing the owner must edit
21
+ // (task 1004518: cloudbongos.com uses the access token, and a message naming "the App" sent the
22
+ // owner to the wrong settings page). Resolved in resolveToken's order: App first, then the token.
18
23
  //
19
24
  // ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
20
25
  // another module; an allowlist keeps a buggy or future borrower from running any other workflow in
@@ -22,8 +27,13 @@ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
22
27
  const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
23
28
  async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
24
29
  if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
25
- let token;
26
- try { token = await resolveToken(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED' }; }
30
+ let token = deps.token || null;
31
+ let credential = token ? (deps.credential || 'token') : null;
32
+ if (!token) {
33
+ try { token = await (deps.appToken || getInstallationToken)(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED', credential: 'app' }; }
34
+ if (token) credential = 'app';
35
+ }
36
+ if (!token && process.env.GITHUB_PUSH_TOKEN) { token = process.env.GITHUB_PUSH_TOKEN; credential = 'token'; }
27
37
  if (!token) return { ok: false, code: 'UNCONFIGURED' };
28
38
  const repoInfo = deps.repoInfo || loadRepoInfo();
29
39
  if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
@@ -35,10 +45,10 @@ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps =
35
45
  headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
36
46
  body: JSON.stringify({ ref, inputs }),
37
47
  });
38
- if (res.status === 204 || res.ok) return { ok: true };
39
- return { ok: false, code: 'REFUSED', status: res.status };
48
+ if (res.status === 204 || res.ok) return { ok: true, credential };
49
+ return { ok: false, code: 'REFUSED', status: res.status, credential };
40
50
  } catch (_) {
41
- return { ok: false, code: 'UNREACHABLE' };
51
+ return { ok: false, code: 'UNREACHABLE', credential };
42
52
  }
43
53
  }
44
54
 
@@ -95,6 +95,15 @@ async function readReleaseLedger(pool) {
95
95
  }
96
96
  }
97
97
 
98
+ // The credential a refusal names, in the words of the settings page the owner must open: GitHub
99
+ // lists an App under Developer settings → GitHub Apps, and an access token under Developer
100
+ // settings → Personal access tokens (task 1004518).
101
+ function credentialName(credential) {
102
+ if (credential === 'app') return "the server's GitHub App";
103
+ if (credential === 'token') return "the server's GitHub access token (GITHUB_PUSH_TOKEN, under Developer settings → Personal access tokens)";
104
+ return "the server's GitHub credential";
105
+ }
106
+
98
107
  /**
99
108
  * What the Release button does. Returns { status, body } for the route to send.
100
109
  *
@@ -118,7 +127,7 @@ async function requestRelease({ version, registry, dispatch } = {}) {
118
127
  }
119
128
  const why = !r ? 'no answer'
120
129
  : r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
121
- : r.code === 'REFUSED' && r.status === 403 ? "GitHub refused: the server's GitHub App needs the Actions: write permission"
130
+ : r.code === 'REFUSED' && r.status === 403 ? `GitHub refused: ${credentialName(r.credential)} needs the Actions: write permission on this repository`
122
131
  : r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
123
132
  : r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
124
133
  : r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-birth.js — A NEW PROJECT IS BORN INTO GENESIS (task
4
+ // 1004506 / BV2.PS18, goal 1000121; spec docs/specs/bongos-v2-project-startup.md step 5,
5
+ // D4, D8, D9).
6
+ //
7
+ // THE PROBLEM. POST /founding/start existed, but nothing called it, so a project made
8
+ // through the startup flow opened as an ordinary hall. The create request lands on the
9
+ // HUB, while founding mode lives in the NEW INSTANCE's own project_settings, and the hub
10
+ // holds no way into that database. So the hub only says so, and the instance acts:
11
+ //
12
+ // 1. The hub's create route stamps a reserved key on the new row
13
+ // (modules/provisioning/birth.js) — never on a row that already exists.
14
+ // 2. The runner writes it into the instance's web.env with the other settings
15
+ // companions: <PREFIX>_FOUNDING_BIRTH, plus <PREFIX>_DEMO_PEOPLE / _DEMO_HOURS
16
+ // when the row is a demo. src/branding.js maps them onto `founding.*`, which is
17
+ // server-only (clientBranding never publishes it).
18
+ // 3. Here, the instance reads that marker and, once, starts founding — and for a
19
+ // demo, also settles the expedited plan, so its decide stage needs no choices.
20
+ //
21
+ // WHEN IT RUNS: lazily, on the first founding read (GET /me through the port, and
22
+ // GET /founding/genesis), not at boot. The first /me is the hall's first paint after the
23
+ // founder's first sign-in, so the hall opens already in genesis; and by then the
24
+ // founder is seated and the kickoff seed has run, so the board the demo's stages are
25
+ // filed on exists. At boot it would race the module load order (the lifecycle port the
26
+ // plan files tasks through may not be registered yet) and a database that is still
27
+ // coming up.
28
+ //
29
+ // WHY IT CANNOT RACE OR TOUCH AN OLD PROJECT.
30
+ // * No marker, no run: a project created before this shipped has none (D8), and a
31
+ // marker-less read resolves without touching the database.
32
+ // * Once per process: one shared promise, so two concurrent first reads share one run.
33
+ // * Once for good: founding is started only when it was never started and never
34
+ // ended (founding.started_at / ended_at), so a restart, a later settings push that
35
+ // rewrites the env, or an owner who already started by hand changes nothing. A demo
36
+ // carried over keeps its state for the same reason: genesis happened once.
37
+ // * The demo's plan is saved only while the project is still deciding with no plan,
38
+ // and savePlan itself refuses outside 'decide'; the stage tasks are filed by a seeder
39
+ // that skips a step already filed.
40
+
41
+ const fm = require('./founding-mode');
42
+ const genesis = require('./genesis-home');
43
+
44
+ // The demo's limits and the bounds check are genesis-home.js's own, never retyped here.
45
+ const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
46
+
47
+ // birthMarker(founding) — PURE. The resolved branding pack's `founding` block → whether
48
+ // this project was born through the startup flow, and its demo time box if a demo. A
49
+ // malformed demo value reads as no demo (the project still founds, with the normal plan).
50
+ function birthMarker(founding) {
51
+ const f = founding && typeof founding === 'object' ? founding : {};
52
+ const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
53
+ if (!born) return { born: false, demo: null };
54
+ const people = Number(f.demoPeople);
55
+ const hours = Number(f.demoHours);
56
+ const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
57
+ return { born: true, demo };
58
+ }
59
+
60
+ function markerFrom(deps) {
61
+ if (deps.marker) return deps.marker;
62
+ let b;
63
+ try { b = require('../../src/module-api').branding(); } catch (_) { return { born: false, demo: null }; }
64
+ return birthMarker(b && b.founding);
65
+ }
66
+
67
+ // bornFounding(deps) — the guarded birth. Returns what it did:
68
+ // { ran: false, reason } nothing to do (no marker, already, alive)
69
+ // { ran: true, started, plan, demo } it started founding and/or saved the plan
70
+ // { ran: false, reason, retry: true } the demo plan could not be saved yet
71
+ async function bornFounding(deps = {}) {
72
+ const marker = markerFrom(deps);
73
+ if (!marker.born) return { ran: false, reason: 'no_marker' };
74
+ const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
75
+ const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
76
+ if (ended && ended.value) return { ran: false, reason: 'already_alive' };
77
+ let didStart = false;
78
+ if (!(started && started.value)) {
79
+ const r = await genesis.startFounding(null, deps);
80
+ if (!r.ok) return { ran: false, reason: r.reason };
81
+ didStart = true;
82
+ }
83
+ if (!marker.demo) return didStart ? { ran: true, started: true, plan: false, demo: false } : { ran: false, reason: 'already_started' };
84
+ // The demo's expedited plan: only while still deciding, and only if none is saved.
85
+ const mode = await fm.foundingModeFor(genesis.modeDepsFor({ ...deps, getSetting: get }));
86
+ const planRow = await get(genesis.PLAN_KEY);
87
+ if (!mode || mode.stage !== 'decide' || (planRow && planRow.value)) {
88
+ return didStart ? { ran: true, started: true, plan: false, demo: true } : { ran: false, reason: 'already_started' };
89
+ }
90
+ const saved = await genesis.savePlan(genesis.demoPlan(marker.demo), null, deps);
91
+ if (!saved.ok) return { ran: false, reason: saved.reason, retry: true };
92
+ return { ran: true, started: didStart, plan: true, demo: true };
93
+ }
94
+
95
+ // ensureBirth(deps) — the once-per-process door the reads call. Shares one run between
96
+ // concurrent callers; forgets a run that failed or asked to retry, so a later read tries
97
+ // again. Never throws: a birth hiccup must never fail the read that triggered it.
98
+ let once = null;
99
+ function ensureBirth(deps = {}) {
100
+ if (once) return once;
101
+ const p = bornFounding(deps).then((r) => {
102
+ if (r && r.retry) once = null;
103
+ return r;
104
+ }, (err) => {
105
+ once = null;
106
+ try { require('../../src/module-api').logger('onboarding').error('[onboarding] founding birth failed', err); } catch (_) { /* no logger: nothing to do */ }
107
+ return { ran: false, reason: 'error' };
108
+ });
109
+ once = p;
110
+ return p;
111
+ }
112
+
113
+ // Test seam: forget the per-process run.
114
+ function resetBirthForTests() { once = null; }
115
+
116
+ module.exports = { birthMarker, bornFounding, ensureBirth, resetBirthForTests };
@@ -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;