@bongos/core 1.19.126 → 1.19.128

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.
@@ -169,6 +169,14 @@ const CONFIG = {
169
169
 
170
170
  const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
171
171
 
172
+ // Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
173
+ // instance is a full Node boot away from reporting auth_configured=true, and the
174
+ // old 10×1s poll routinely lost that race — recording a failure for a setup that
175
+ // succeeded, then burning the SINGLE-USE manifest code on the retry. ~3 min of
176
+ // patience; the poll runs after the intent loop, so it delays nothing.
177
+ const MANIFEST_VERIFY_TRIES = numEnv('PROVISION_MANIFEST_VERIFY_TRIES', 90);
178
+ const MANIFEST_VERIFY_INTERVAL_MS = numEnv('PROVISION_MANIFEST_VERIFY_INTERVAL_MS', 2000);
179
+
172
180
  // ---------------------------------------------------------------------------
173
181
  // Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
174
182
  // ---------------------------------------------------------------------------
@@ -1503,9 +1511,21 @@ async function provisionInstance(inst, deps) {
1503
1511
  // only on the control plane — it must never run on the (token-free) co-hosting
1504
1512
  // box even when the box-mutating steps above went there over SSH (task 2065).
1505
1513
  controlExec(CONFIG.dnsHook, { env: dnsEnv });
1514
+ // The clear half of the birth-path note (F16): a revive after the operator
1515
+ // configured the hook must retire the stale "point your A record" sentence.
1516
+ if (apply && provisioning.recordDnsNote) {
1517
+ await provisioning.recordDnsNote(db, inst.id, null).catch(() => {});
1518
+ }
1506
1519
  }
1507
1520
  } else {
1508
1521
  log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
1522
+ // Birth-path twin of the domain-attach note (F16): a domain with no hook is
1523
+ // the same silent skip here, so the owner card gets the same sentence.
1524
+ if (apply && inst.domain) {
1525
+ if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id,
1526
+ `Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
1527
+ ).catch(() => {});
1528
+ }
1509
1529
  }
1510
1530
 
1511
1531
  // ── step 6: Caddy per-instance site snippet + reload ──────────────────────
@@ -1520,17 +1540,55 @@ async function provisionInstance(inst, deps) {
1520
1540
  } else { log(' (Caddy on the droplet via cloud-init)'); }
1521
1541
 
1522
1542
  // ── step 7: healthz ───────────────────────────────────────────────────────
1543
+ // The probe RESULT is recorded, not discarded (task 1002720 / QA-walk F15: a
1544
+ // refused connection used to ride silently into 'active' and the card said
1545
+ // live). Bounded retry so a slow first boot is not slandered — the
1546
+ // placeManifestCreds loop shape. Still ok:true either way: a failed probe
1547
+ // must not flip the intent to failed (cmdRunIntents would burn the 3-attempt
1548
+ // budget on an instance that may simply be slow); the liveness overlay is
1549
+ // what tells the owner the truth.
1523
1550
  log(' [healthz] verify the instance answers');
1524
- if (local) boxExec(healthzCmd(inst), { allowFail: true });
1525
- else log(' (verified post-boot)');
1551
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
1552
+ let healthy = null; // null = not observed (dry-run / dedicated)
1553
+ if (local) {
1554
+ const tries = apply ? 10 : 1;
1555
+ for (let i = 0; i < tries; i++) {
1556
+ const res = boxExec(healthzCmd(inst), { allowFail: true });
1557
+ if (!apply) break; // dry-run: exactly one plan line
1558
+ if (res && !res.softFailed) { healthy = true; break; }
1559
+ healthy = false;
1560
+ if (i < tries - 1) await sleep(1000);
1561
+ }
1562
+ } else log(' (verified post-boot)');
1526
1563
 
1527
- // Mark active.
1564
+ // Mark active. error_note: null — a stale note from an earlier failed run
1565
+ // must not ride into the active state (F15's second half; the co-tenant +
1566
+ // standalone paths never cleared it). The runner hands its own observation
1567
+ // to the liveness overlay so the card gets an honest verdict immediately
1568
+ // instead of waiting ~5 min for the first sweep.
1528
1569
  if (apply) {
1529
- await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, provisioned_at: new Date().toISOString() });
1530
- await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active', detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})`, actor: 'runner' });
1570
+ await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, error_note: null, provisioned_at: new Date().toISOString() });
1571
+ // The overlay column is DOMAIN-scoped when a domain exists (the sweep
1572
+ // probes https://<domain>; loopback only when there is none), and the card
1573
+ // renders up=true as "Answering at <domain>". This probe is loopback, so:
1574
+ // a FAILURE is a verdict on any shape (a dead process answers nowhere),
1575
+ // but a PASS proves only the process — for a domain-bearing instance the
1576
+ // address claim stays "not checked yet" until the sweep earns it.
1577
+ const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
1578
+ if (probeIsVerdict && provisioning.recordLivenessProbe) {
1579
+ await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
1580
+ }
1581
+ await provisioning.recordEvent(db, {
1582
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
1583
+ detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})` +
1584
+ (healthy === false ? ' — not answering /healthz yet' : ''),
1585
+ actor: 'runner',
1586
+ });
1531
1587
  }
1532
- log(` ✓ ${inst.slug} provisioned`);
1533
- return { ok: true, port: inst.port };
1588
+ log(healthy === false
1589
+ ? ` ⚠ ${inst.slug} provisioned, but it is not answering /healthz yet — its card will read "unreachable"`
1590
+ : ` ✓ ${inst.slug} provisioned`);
1591
+ return { ok: true, port: inst.port, healthy };
1534
1592
  }
1535
1593
 
1536
1594
  // The cloud-init user-data for a dedicated droplet: run the co-tenant standup
@@ -1616,6 +1674,7 @@ async function domainAttachInstance(inst, deps) {
1616
1674
 
1617
1675
  // DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
1618
1676
  // the hook needs the Cloudflare token, which never leaves the control plane).
1677
+ let dnsSkipped = false;
1619
1678
  if (CONFIG.dnsHook) {
1620
1679
  if (!recordIp) {
1621
1680
  const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
@@ -1627,7 +1686,11 @@ async function domainAttachInstance(inst, deps) {
1627
1686
  controlExec(CONFIG.dnsHook, { env: dnsEnv });
1628
1687
  }
1629
1688
  } else {
1630
- log(' [dns] skipped (BOX_DNS_HOOK unset) — point the A record manually');
1689
+ // Still ok:true below — an unset hook is unretryable, and ok:false would
1690
+ // burn the intent's retry budget on it. The skip is surfaced to the OWNER
1691
+ // via dns_note (F16), not just this operator log line.
1692
+ dnsSkipped = true;
1693
+ log(` [dns] skipped (BOX_DNS_HOOK unset) — point ${inst.domain}'s A record at ${recordIp || 'this server'} manually`);
1631
1694
  }
1632
1695
 
1633
1696
  log(' [caddy] write site snippet + reload');
@@ -1638,12 +1701,24 @@ async function domainAttachInstance(inst, deps) {
1638
1701
  boxExec(healthzCmd(inst), { allowFail: true });
1639
1702
 
1640
1703
  if (apply) {
1704
+ // Written BOTH ways so a later successful attach clears a stale note.
1705
+ if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id, dnsSkipped
1706
+ ? `Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
1707
+ : null).catch(() => {});
1708
+ // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
1709
+ // from an earlier retired attach must not outlive its own recovery.
1710
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
1641
1711
  await provisioning.recordEvent(db, {
1642
1712
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
1643
- detail: `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`, actor: 'runner',
1713
+ detail: dnsSkipped
1714
+ ? `domain ${inst.domain} wired (env + caddy); DNS SKIPPED — no hook configured`
1715
+ : `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`,
1716
+ actor: 'runner',
1644
1717
  }).catch(() => {});
1645
1718
  }
1646
- log(` ✓ ${inst.slug} addressed as ${inst.domain}`);
1719
+ log(dnsSkipped
1720
+ ? ` ✓ ${inst.slug} configured for ${inst.domain} — DNS still needs your A record`
1721
+ : ` ✓ ${inst.slug} addressed as ${inst.domain}`);
1647
1722
  return { ok: true };
1648
1723
  }
1649
1724
 
@@ -1750,11 +1825,14 @@ async function placeManifestCreds(inst, mrow, deps) {
1750
1825
 
1751
1826
  // 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
1752
1827
  // the same length/charset guard oauth-secret.js uses against silent truncation).
1828
+ // From here on the single-use manifest code is SPENT (the conversion POST
1829
+ // consumed it) — every return carries codeSpent:true so the drain never
1830
+ // resets the row for a retry that would re-convert a burned code and 404.
1753
1831
  const idV = oauthSecret.validateClientId(creds.clientId);
1754
1832
  const secV = oauthSecret.validateClientSecret(creds.clientSecret);
1755
1833
  if (!idV.ok || !secV.ok) {
1756
1834
  const why = [!idV.ok ? `id: ${idV.reason}` : null, !secV.ok ? `secret: ${secV.reason}` : null].filter(Boolean).join('; ');
1757
- return { ok: false, error: `GitHub returned unusable creds (${why})` };
1835
+ return { ok: false, codeSpent: apply, error: `GitHub returned unusable creds (${why})` };
1758
1836
  }
1759
1837
 
1760
1838
  // 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
@@ -1764,21 +1842,24 @@ async function placeManifestCreds(inst, mrow, deps) {
1764
1842
  boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1765
1843
 
1766
1844
  // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
1845
+ // PATIENT (F23): the child must boot before it can answer; losing this race
1846
+ // used to record a failure for a setup that had succeeded.
1767
1847
  if (apply) {
1848
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
1768
1849
  let authConfigured = false;
1769
- for (let i = 0; i < 10; i++) {
1850
+ for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
1770
1851
  const out = boxExec(healthzCmd(inst), { allowFail: true });
1771
1852
  if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
1772
- await new Promise((r) => setTimeout(r, 1000));
1853
+ await sleep(MANIFEST_VERIFY_INTERVAL_MS);
1773
1854
  }
1774
1855
  if (!authConfigured) {
1775
- return { ok: false, error: 'placed creds but /healthz still reports auth_configured=false — sign-in not live yet' };
1856
+ return { ok: false, codeSpent: true, error: 'the credentials are placed, but the project did not report sign-in ready in time' };
1776
1857
  }
1777
1858
  log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
1778
1859
  } else {
1779
1860
  log(' [oauth-manifest] would probe /healthz for auth_configured after restart');
1780
1861
  }
1781
- return { ok: true, appId: creds.appId, appSlug: creds.appSlug };
1862
+ return { ok: true, codeSpent: apply, appId: creds.appId, appSlug: creds.appSlug };
1782
1863
  }
1783
1864
 
1784
1865
  // Drain provisioning_oauth_manifests rows awaiting conversion (status 'code_received').
@@ -1820,11 +1901,17 @@ async function drainOAuthManifests(deps) {
1820
1901
  if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
1821
1902
  const result = await placeManifestCreds(inst, mrow, deps);
1822
1903
  if (result && result.ok === false) {
1823
- if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
1904
+ // A SPENT code is terminal, always (F23): GitHub's manifest code is
1905
+ // single-use, so resetting the row to 'code_received' guarantees the
1906
+ // next tick re-converts a burned code and 404s forever. Only failures
1907
+ // that happened BEFORE the conversion stay retryable.
1908
+ if (result.codeSpent) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error, github_code: null });
1909
+ else if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
1824
1910
  else await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', { last_error: result.error });
1825
1911
  errors++; log(` ✖ oauth-manifest #${mrow.id}: ${result.error}`);
1826
1912
  } else {
1827
- await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug });
1913
+ // github_code: null on done too — a finished row holds no re-convertible code.
1914
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug, github_code: null });
1828
1915
  await provisioning.recordEvent(db, {
1829
1916
  instanceId: inst.id, ownerBuilderId: mrow.requested_by, event: 'oauth-manifest-done',
1830
1917
  detail: `sign-in wired via GitHub App Manifest (app ${result.appSlug || result.appId || '?'})`, actor: 'runner',
@@ -1880,8 +1967,17 @@ async function cmdRunIntents(deps) {
1880
1967
  else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1881
1968
  else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
1882
1969
  if (result && result.ok === false) {
1883
- if (intent.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1884
- else await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1970
+ if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
1971
+ await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1972
+ // A retired intent stops silently otherwise (F19): record the reason
1973
+ // on the instance WITHOUT changing its lifecycle status — passing the
1974
+ // CURRENT status makes the status write a no-op, so a healthy active
1975
+ // instance is never marked broken over a domain hiccup, but the card
1976
+ // can say why the work stopped.
1977
+ await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed' }).catch(() => {});
1978
+ } else {
1979
+ await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1980
+ }
1885
1981
  errors++;
1886
1982
  } else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
1887
1983
  } catch (e) {
@@ -62,6 +62,20 @@ async function getBuilderById(id) {
62
62
  return rows[0] ?? null;
63
63
  }
64
64
 
65
+ // getFoundingBuilder — the instance's first admitted HUMAN builder (task
66
+ // 1002739): the founding owner a newborn instance greets, and the anchor of
67
+ // the builder-needs founding-grace window. system_role IS NULL is load-bearing:
68
+ // core migrations 065/067 seed the BFG + Discord-bot system principals into
69
+ // builders on EVERY instance before anyone can sign in, so the lowest ids on a
70
+ // newborn DB are not the founder. First human row by id, regardless of status —
71
+ // the founder stays the founder even if later deactivated.
72
+ async function getFoundingBuilder() {
73
+ const { rows } = await pool.query(
74
+ `SELECT id, created_at FROM builders WHERE system_role IS NULL ORDER BY id ASC LIMIT 1`
75
+ );
76
+ return rows[0] ?? null;
77
+ }
78
+
65
79
  // listActiveBuilders — the minimal, any-builder-readable roster (task 1877): an
66
80
  // invite-picker needs a real name of every builder to invite, not just the
67
81
  // leaderboard's top-100-by-credits window. Deliberately narrow columns — no
@@ -337,6 +351,7 @@ module.exports = {
337
351
  withTx,
338
352
  sessionPermsSelect,
339
353
  getBuilderById,
354
+ getFoundingBuilder,
340
355
  listActiveBuilders,
341
356
  insertAuditLog,
342
357
  LIVE_RANK_LADDER,
package/src/bongos/db.js CHANGED
@@ -38,7 +38,7 @@ const seams = require('../module-seams');
38
38
  // published doorway resolves through. Re-exported below so every caller is
39
39
  // byte-identical; db-kernel depends only on ./pool, never ./db (one-way).
40
40
  const {
41
- withTx, getBuilderById, listActiveBuilders, insertAuditLog, LIVE_RANK_LADDER,
41
+ withTx, getBuilderById, getFoundingBuilder, listActiveBuilders, insertAuditLog, LIVE_RANK_LADDER,
42
42
  // Auth/identity/admission + the rank vocabulary carved to db-kernel (ADR 0102,
43
43
  // finishing ADR 0091 §1). Re-exported below so db.js's other importers + its own
44
44
  // internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
@@ -978,6 +978,7 @@ module.exports = {
978
978
  upsertBuilder,
979
979
  seatFirstAdminIfUnclaimed,
980
980
  getBuilderById,
981
+ getFoundingBuilder,
981
982
  listActiveBuilders,
982
983
  linkAnthropicAccount,
983
984
  getBuilderByGithubId,
@@ -83,7 +83,7 @@ module.exports = function buildMeRouter() {
83
83
  const [
84
84
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
85
85
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
86
- behaviorPrefs, geminiKeyRow, needsRebaseTasks,
86
+ behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder,
87
87
  ] = await Promise.all([
88
88
  db.getBuilderById(req.builder.id),
89
89
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -144,6 +144,10 @@ module.exports = function buildMeRouter() {
144
144
  // without a follow-up call. Through the `lifecycle` port; [] on a
145
145
  // lifecycle-less instance (guarded — the port is resolveOptional).
146
146
  lc() ? lc().needsRebaseTasksForBuilder(req.builder.id) : [],
147
+ // task 1002739: the instance's first admitted builder — anchors the
148
+ // builder-needs founding-grace window (a newborn instance's founder gets a
149
+ // welcome, not a nag). Best-effort: null just means no grace.
150
+ db.getFoundingBuilder().catch(() => null),
147
151
  ]);
148
152
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
149
153
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -274,6 +278,12 @@ module.exports = function buildMeRouter() {
274
278
  rank: builder?.rank,
275
279
  ownKeySet: !!(geminiKeyRow && geminiKeyRow.enc),
276
280
  sharedKeyConfigured: artSharedKeyConfigured(),
281
+ // founding grace (task 1002739 / direction §5.6): a newborn instance's
282
+ // founder gets a welcome roster, not a coverage nag. The module owns the
283
+ // policy (window length, copy); we pass the raw facts.
284
+ isFoundingOwner: !!(foundingBuilder && String(foundingBuilder.id) === String(req.builder.id)),
285
+ founderCreatedAt: foundingBuilder ? foundingBuilder.created_at : null,
286
+ projectName: (() => { try { return branding().identity?.productName || null; } catch { return null; } })(),
277
287
  }) : [],
278
288
  };
279
289
  // Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
package/src/module-api.js CHANGED
@@ -679,8 +679,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
679
679
  // landed since 1.19.124 with no explicit bump. run 31849445746. (task 1002620)
680
680
  // 1.19.126 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
681
681
  // landed since 1.19.125 with no explicit bump. run 31853055269. (task 1002620)
682
+ // 1.19.127 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
683
+ // landed since 1.19.126 with no explicit bump. run 31854730552. (task 1002620)
684
+ // 1.19.128 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
685
+ // landed since 1.19.127 with no explicit bump. run 31857842473. (task 1002620)
682
686
  // ---------------------------------------------------------------------------
683
- const CORE_VERSION = '1.19.126'; // CI auto-patch carrier (ADR 0161); see changelog above
687
+ const CORE_VERSION = '1.19.128'; // CI auto-patch carrier (ADR 0161); see changelog above
684
688
 
685
689
  // A namespaced logger so a module's log lines are attributable + consistent.
686
690
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -95,5 +95,119 @@ t('missing rank fails safe — not action_needed without an explicit rank', () =
95
95
  assert.ok(['covered', 'action_needed', 'satisfied'].includes(n.state));
96
96
  });
97
97
 
98
+ console.log('founding grace (task 1002739, direction §5.6):');
99
+
100
+ const DAY = 24 * 60 * 60 * 1000;
101
+ const bornNow = new Date().toISOString();
102
+
103
+ t('newborn founder: welcome item first, greeting by project name', () => {
104
+ const r = needs.computeNeeds({
105
+ rank: 'archon', ownKeySet: false,
106
+ isFoundingOwner: true, founderCreatedAt: bornNow, projectName: 'Mercury',
107
+ });
108
+ assert.equal(r.items[0].id, 'founding_welcome');
109
+ assert.match(r.items[0].title, /Mercury/);
110
+ assert.equal(r.items[0].state, 'covered');
111
+ assert.equal(r.items[0].action, null);
112
+ });
113
+
114
+ t('newborn founder: zero action_needed — every nag is suppressed', () => {
115
+ const r = needs.computeNeeds({
116
+ rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: bornNow,
117
+ });
118
+ assert.equal(r.has_action_needed, false);
119
+ assert.equal(r.action_needed_count, 0);
120
+ for (const n of r.items) assert.notEqual(n.state, 'action_needed');
121
+ });
122
+
123
+ t('a suppressed nag keeps its identity but loses the demand', () => {
124
+ const r = needs.computeNeeds({
125
+ rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: bornNow,
126
+ });
127
+ const artItem = r.items.find((n) => n.id === 'art_key');
128
+ assert.ok(artItem, 'the art_key item must still be listed, not hidden');
129
+ assert.equal(artItem.state, 'covered');
130
+ assert.equal(artItem.action, null);
131
+ assert.doesNotMatch(artItem.message, /coverage has ended/i);
132
+ });
133
+
134
+ t('the grace window is seven days — the policy number is pinned, not self-referential', () => {
135
+ // A fixed injected clock, and 7 written as a literal on both sides: mutating
136
+ // FOUNDING_GRACE_DAYS (a "tune", a bad merge) must fail here, not slide.
137
+ assert.equal(needs.FOUNDING_GRACE_DAYS, 7);
138
+ const born = Date.parse('2026-01-01T00:00:00Z');
139
+ const ctx = { rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: '2026-01-01T00:00:00Z' };
140
+ const inWindow = needs.computeNeeds({ ...ctx, nowMs: born + 7 * DAY - 60_000 });
141
+ assert.equal(inWindow.has_action_needed, false);
142
+ assert.equal(inWindow.items[0].id, 'founding_welcome');
143
+ const expired = needs.computeNeeds({ ...ctx, nowMs: born + 7 * DAY + 60_000 });
144
+ assert.equal(expired.has_action_needed, true);
145
+ assert.ok(!expired.items.find((n) => n.id === 'founding_welcome'));
146
+ });
147
+
148
+ t('grace expires: past FOUNDING_GRACE_DAYS the founder is nagged like anyone (real clock)', () => {
149
+ const old = new Date(Date.now() - (needs.FOUNDING_GRACE_DAYS * DAY + 60_000)).toISOString();
150
+ const r = needs.computeNeeds({
151
+ rank: 'archon', ownKeySet: false, isFoundingOwner: true, founderCreatedAt: old,
152
+ });
153
+ assert.equal(r.has_action_needed, true);
154
+ assert.ok(!r.items.find((n) => n.id === 'founding_welcome'));
155
+ });
156
+
157
+ t('a non-founder on a newborn instance is not suppressed', () => {
158
+ const r = needs.computeNeeds({
159
+ rank: 'thetes', ownKeySet: false, isFoundingOwner: false, founderCreatedAt: bornNow,
160
+ });
161
+ assert.equal(r.has_action_needed, true);
162
+ assert.ok(!r.items.find((n) => n.id === 'founding_welcome'));
163
+ });
164
+
165
+ t('satisfied stays satisfied during grace — no fake downgrades', () => {
166
+ const r = needs.computeNeeds({
167
+ rank: 'archon', ownKeySet: true, isFoundingOwner: true, founderCreatedAt: bornNow,
168
+ });
169
+ assert.equal(r.items.find((n) => n.id === 'art_key').state, 'satisfied');
170
+ });
171
+
172
+ t('no founder facts at all → no grace, no welcome (vanilla ctx unchanged)', () => {
173
+ const r = needs.computeNeeds({ rank: 'thetes', ownKeySet: false });
174
+ assert.equal(r.has_action_needed, true);
175
+ assert.ok(!r.items.find((n) => n.id === 'founding_welcome'));
176
+ });
177
+
178
+ console.log('the producing side (DB-free source pins — the policy is only as real as its facts):');
179
+
180
+ // The seven policy cases above hand computeNeeds its ctx. These pin the two
181
+ // places that PRODUCE that ctx, because both were wrong-or-unverified once:
182
+ // the founder query returned the migration-seeded BFG system principal (ids 1-2
183
+ // on every newborn DB — review panel, 2026-08-15), and a key rename in me.js
184
+ // would silently disable the grace while every policy case stayed green. A
185
+ // DB-backed newborn-instance test can't run in the unit gate, so the source is
186
+ // the checkable artifact.
187
+ import { readFileSync } from 'node:fs';
188
+ import { fileURLToPath } from 'node:url';
189
+ import path from 'node:path';
190
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
191
+
192
+ t('getFoundingBuilder skips the seeded system principals (system_role IS NULL)', () => {
193
+ const src = readFileSync(path.join(ROOT, 'src/bongos/db-kernel.js'), 'utf8');
194
+ const fn = src.slice(src.indexOf('function getFoundingBuilder'));
195
+ const sql = fn.slice(0, fn.indexOf('return'));
196
+ assert.match(sql, /FROM builders/);
197
+ assert.match(sql, /system_role IS NULL/,
198
+ 'the founder query must exclude system principals — migrations 065/067 seed them at the lowest ids on every instance');
199
+ assert.match(sql, /ORDER BY id ASC/);
200
+ });
201
+
202
+ t('the /me aggregator feeds computeNeeds the founder facts under their contract names', () => {
203
+ const src = readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
204
+ const call = src.slice(src.indexOf('computeNeeds({'), src.indexOf('}) : []'));
205
+ for (const key of ['isFoundingOwner', 'founderCreatedAt', 'projectName']) {
206
+ assert.match(call, new RegExp(`${key}\\s*:`),
207
+ `me.js must pass ${key} into computeNeeds — a rename silently disables the founding grace`);
208
+ }
209
+ assert.match(src, /getFoundingBuilder\(\)/, 'me.js must resolve the founder row');
210
+ });
211
+
98
212
  console.log(`\n${passed} passed, ${failed} failed`);
99
213
  process.exit(failed ? 1 : 0);