@bongos/core 1.19.1045 → 1.19.1047

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 (81) hide show
  1. package/.bongos-core.json +90 -115
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
  3. package/docs/adr/README.md +1 -1
  4. package/docs/copy-inventory.md +17 -24
  5. package/docs/copy-registry.json +24 -97
  6. package/docs/file-map.md +2 -3
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/recipes/core-release-pipeline.md +19 -0
  9. package/docs/recipes/windows-builders.md +1 -1
  10. package/modules/hall-ui/public/atlas.html +1 -1
  11. package/modules/hall-ui/public/blockers.html +1 -1
  12. package/modules/hall-ui/public/board-room.html +1 -1
  13. package/modules/hall-ui/public/collab.css +1 -1
  14. package/modules/hall-ui/public/collab.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/diagrams.html +1 -1
  18. package/modules/hall-ui/public/drachmae.html +1 -1
  19. package/modules/hall-ui/public/fleet.html +1 -1
  20. package/modules/hall-ui/public/gate.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea.html +1 -1
  24. package/modules/hall-ui/public/ideas.html +1 -1
  25. package/modules/hall-ui/public/index.html +1 -1
  26. package/modules/hall-ui/public/modules.html +1 -1
  27. package/modules/hall-ui/public/primer.html +1 -1
  28. package/modules/hall-ui/public/profile.html +1 -1
  29. package/modules/hall-ui/public/project-settings.html +1 -1
  30. package/modules/hall-ui/public/ranks.html +1 -1
  31. package/modules/hall-ui/public/roadmap.html +1 -1
  32. package/modules/hall-ui/public/{people.css → roster.css} +7 -11
  33. package/modules/hall-ui/public/roster.html +5 -5
  34. package/modules/hall-ui/public/roster.js +6 -7
  35. package/modules/hall-ui/public/roster.states.json +1 -1
  36. package/modules/hall-ui/public/sessions.html +1 -1
  37. package/modules/hall-ui/public/settings.html +2 -2
  38. package/modules/hall-ui/public/settings.js +2 -2
  39. package/modules/hall-ui/public/shell.js +3 -10
  40. package/modules/hall-ui/public/studio.html +1 -1
  41. package/modules/hall-ui/public/style.css +2 -9
  42. package/modules/hall-ui/public/task.html +1 -1
  43. package/modules/hall-ui/public/watch.html +1 -1
  44. package/modules/hall-ui/public/work.html +1 -1
  45. package/modules/hall-ui/records/builders-roster.md +1 -1
  46. package/modules/hall-ui/records/roster-and-ledger.md +2 -2
  47. package/modules/platform-identity/account-visibility.js +3 -3
  48. package/modules/platform-identity/platform-identity.js +2 -2
  49. package/modules/platform-identity/routes/profile.js +1 -1
  50. package/modules/platform-identity/routes/sso.js +4 -3
  51. package/modules/platform-identity/scouting-authz.js +2 -2
  52. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +12 -0
  56. package/scripts/gds/go-live.js +101 -5
  57. package/src/bongos/auth.js +9 -24
  58. package/src/bongos/serve-internal.js +13 -19
  59. package/src/module-api.js +1 -1
  60. package/tests/account_privacy_flags.mjs +1 -1
  61. package/tests/auth_resolve_failure.mjs +6 -6
  62. package/tests/core_upgrade_owner_door.mjs +1 -1
  63. package/tests/edit_hints.mjs +8 -8
  64. package/tests/effective_visibility_predicate.mjs +1 -1
  65. package/tests/go_live.mjs +143 -4
  66. package/tests/government_parity.mjs +1 -1
  67. package/tests/hall_builders_page_pass.mjs +2 -2
  68. package/tests/hall_nav.mjs +8 -8
  69. package/tests/hall_page_gate_map.mjs +14 -11
  70. package/tests/hall_roster_world.mjs +47 -78
  71. package/tests/module_contributions.mjs +0 -1
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
  74. package/tests/profile_rollup_consent.mjs +4 -4
  75. package/tests/profile_route.mjs +3 -26
  76. package/tests/scouting_owner_access_and_reach.mjs +4 -8
  77. package/modules/hall-ui/public/people.html +0 -79
  78. package/modules/hall-ui/public/people.js +0 -321
  79. package/modules/hall-ui/public/people.states.json +0 -89
  80. package/tests/people_directory_clip.mjs +0 -269
  81. package/tests/people_name_only.mjs +0 -263
package/tests/go_live.mjs CHANGED
@@ -595,15 +595,22 @@ const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.1
595
595
  '##END##',
596
596
  ].filter(Boolean).join('\n');
597
597
 
598
- // Records every script fed to a shell. `upgradeFails` drives the rollback path.
599
- function fakeShell({ upgradeFails = false, phaseCOut = phaseC() } = {}) {
598
+ // Records every script fed to a shell. `upgradeFails` drives the rollback path;
599
+ // `rehearsalRefuses` makes the --dry-run pre-flight say no (task 1004306).
600
+ function fakeShell({ upgradeFails = false, rehearsalRefuses = false, backupFails = false, pruned = '0', phaseCOut = phaseC() } = {}) {
600
601
  const scripts = [];
601
602
  const fn = (cmd, args, opts = {}) => {
602
603
  const input = opts.input || '';
603
604
  scripts.push(input);
605
+ if (backupFails && input.includes('##BACKUP##')) return { status: 1, stdout: '##TIMER##\n##BACKUP##\n', stderr: 'pg_dump: error: connection to server failed' };
606
+ if (input.includes('bongos upgrade') && input.includes('--dry-run')) {
607
+ return rehearsalRefuses
608
+ ? { status: 1, stdout: '', stderr: "\n✖ 5 unresolved artist reviews hold this deploy — the project runs artist_gate 'strict'\n" }
609
+ : { status: 0, stdout: ' [dry-run] would pin', stderr: '' };
610
+ }
604
611
  if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
605
612
  if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
606
- return { status: 0, stdout: '##TIMER##\n##BACKUP##\n##END##', stderr: '' };
613
+ return { status: 0, stdout: `##TIMER##\n##BACKUP##\n${input.includes('##PRUNE##') ? `##PRUNE##\n${pruned}\n` : ''}##END##`, stderr: '' };
607
614
  };
608
615
  fn.scripts = scripts;
609
616
  fn.all = () => scripts.join('\n');
@@ -660,7 +667,7 @@ test('with no deployTimer, nothing is stopped, started, or reported as restarted
660
667
  assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
661
668
  assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
662
669
  assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
663
- assert.match(stepNames(r)[0], /^backup \(no pull-deploy timer\)$/);
670
+ assert.match(stepNames(r)[1], /^backup \(no pull-deploy timer\)$/);
664
671
  });
665
672
 
666
673
  test('the platform instance still stops the timer and puts it back', () => {
@@ -728,3 +735,135 @@ test('the shipped co-tenant template is itself valid config', () => {
728
735
  assert.equal(sel.target.pinMode, 'local-commit');
729
736
  assert.equal(sel.target.deployTimer, undefined);
730
737
  });
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Refuse before you copy; keep ten (task 1004306)
741
+ //
742
+ // On cloudbongos.com, 2026-09-26, the artist gate refused two upgrades at 17:10 and
743
+ // 17:15 UTC — and each had already stopped the deploy timer and written a 65 MB
744
+ // pg_dump, because the backup was Phase A and the refusal lived inside Phase B.
745
+ // Nothing pruned the directory (37 dumps, 1.7 GB, at about one attempt a day). At a
746
+ // fifteen-minute cadence that fills the disk in about three days.
747
+ // ---------------------------------------------------------------------------
748
+
749
+ const withBackup = { ...okTarget, backupDb: 'instdb', backupDir: '~/backups', runAs: 'me' };
750
+
751
+ test('a refused rehearsal stops the go-live before any timer is stopped or any dump is written', () => {
752
+ const run = fakeShell({ rehearsalRefuses: true });
753
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
754
+
755
+ assert.equal(r.ok, false);
756
+ assert.equal(r.failedAt, 'PREFLIGHT');
757
+ assert.equal(r.refused, true);
758
+ assert.equal(run.scripts.length, 1, 'the rehearsal is the only thing that may run');
759
+ assert.ok(!/pg_dump/.test(run.all()), 'no backup may be written for a refused move');
760
+ assert.ok(!/systemctl stop/.test(run.all()), 'the deploy timer must be left running');
761
+ assert.deepEqual(stepNames(r), ['pre-flight (the upgrade, rehearsed with --dry-run)']);
762
+ });
763
+
764
+ test('the rehearsal is the REAL upgrade command plus --dry-run, and it runs first', () => {
765
+ const run = fakeShell();
766
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
767
+ assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(r.steps)}`);
768
+
769
+ // Same flags as the bump, so it asks exactly the gates the bump will ask — a
770
+ // rehearsal of a different command would be a check of something else.
771
+ assert.ok(run.scripts[0].includes(`${G.upgradeCommand(withBackup, '1.19.306')} --dry-run`));
772
+ const order = (needle) => run.scripts.findIndex((s) => s.includes(needle));
773
+ assert.ok(order('--dry-run') < order('pg_dump'), 'rehearse before the backup');
774
+ assert.ok(order('--dry-run') < order('systemctl stop'), 'rehearse before the timer stops');
775
+ assert.ok(order('pg_dump') < run.scripts.findIndex((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'the backup still precedes the real bump');
776
+ });
777
+
778
+ test('a refusal renders as "nothing changed", never as an unpushed pin', () => {
779
+ const run = fakeShell({ rehearsalRefuses: true });
780
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
781
+ const out = G.renderReport(r);
782
+ assert.match(out, /Nothing changed — still on 1\.19\.300\. No timer was stopped and no backup was written\./);
783
+ assert.match(out, /5 unresolved artist reviews hold this deploy/, "the upgrade's own reason is shown");
784
+ assert.ok(!/pin committed/.test(out), 'no pin line for a bump that never started');
785
+ });
786
+
787
+ test('a successful dump is followed by a prune, and the step says what it removed', () => {
788
+ const run = fakeShell({ pruned: '3' });
789
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
790
+ assert.equal(r.ok, true);
791
+ const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
792
+ assert.ok(phaseA.indexOf('##PRUNE##') > phaseA.indexOf('pg_dump'), 'prune only after the dump');
793
+ const step = r.steps.find((s) => /prune/.test(s.name));
794
+ assert.equal(step.name, `prune old pre-upgrade backups (keep the newest ${G.PRE_UPGRADE_KEEP})`);
795
+ assert.equal(step.detail, 'removed 3');
796
+ assert.equal(G.PRE_UPGRADE_KEEP, 10);
797
+ });
798
+
799
+ test('no backupDb, no dump, and no prune', () => {
800
+ const run = fakeShell();
801
+ const r = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run });
802
+ assert.equal(r.ok, true);
803
+ assert.ok(!/##PRUNE##/.test(run.all()));
804
+ assert.ok(!r.steps.some((s) => /prune/.test(s.name)));
805
+ });
806
+
807
+ test('a prune that cannot run warns and does not fail the deploy', () => {
808
+ const step = G.pruneStep('prune exited non-zero');
809
+ assert.equal(step.ok, true, 'the dump is good; tidiness must not cost the deploy');
810
+ assert.equal(step.warn, true);
811
+ assert.match(step.detail, /could not prune \(prune exited non-zero\)/);
812
+ assert.equal(G.pruneStep(null).warn, true, 'a missing section is not "nothing to remove"');
813
+ assert.equal(G.pruneStep('0').detail, 'nothing to remove');
814
+ assert.match(G.renderReport({ targetName: 'x', from: 'a', to: 'b', ok: true, steps: [step] }), /⚠ prune old pre-upgrade backups/);
815
+ });
816
+
817
+ test('the dump fails when pg_dump fails, and a failed dump puts the stopped timer back', () => {
818
+ // The composed command: pipefail inside, and the partial file removed on failure.
819
+ const run = fakeShell();
820
+ G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
821
+ const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
822
+ assert.ok(phaseA.includes('bash -c "set -o pipefail; mkdir -p ~/backups && { pg_dump instdb | gzip > ~/backups/pre-1.19.306-$TS.sql.gz || { rm -f ~/backups/pre-1.19.306-$TS.sql.gz; exit 1; }; }"'), phaseA);
823
+ assert.match(phaseA, /^TS="\$\(date -u \+%Y%m%dT%H%M%SZ\)"$/m, 'one timestamp for the dump and its cleanup');
824
+
825
+ const failing = fakeShell({ backupFails: true });
826
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run: failing });
827
+ assert.equal(r.ok, false);
828
+ assert.equal(r.failedAt, 'PREPARE');
829
+ assert.ok(!failing.scripts.some((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'no bump without a backup');
830
+ assert.match(failing.scripts[failing.scripts.length - 1], /systemctl start inst-deploy\.timer/, 'the stopped timer is restarted');
831
+ });
832
+
833
+ // The prune command itself, run for real in a scratch directory. The composed-string
834
+ // tests above cannot tell a glob that matches nothing from one that matches too much.
835
+ test('the prune keeps the newest ten by time, and leaves every other file alone', { skip: process.platform === 'win32' && 'needs a POSIX shell' }, async () => {
836
+ const { spawnSync } = await import('node:child_process');
837
+ const os = await import('node:os');
838
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'golive-prune-'));
839
+ try {
840
+ const dumps = [];
841
+ // 13 dumps, oldest first, whose NAME order disagrees with their TIME order.
842
+ for (let i = 0; i < 13; i++) {
843
+ const name = `pre-1.19.${i % 2 ? 99 - i : 100 + i}-202609${String(10 + i).padStart(2, '0')}T000000Z.sql.gz`;
844
+ const f = path.join(dir, name);
845
+ fs.writeFileSync(f, 'x');
846
+ const t = Date.UTC(2026, 8, 10 + i) / 1000;
847
+ fs.utimesSync(f, t, t);
848
+ dumps.push(name);
849
+ }
850
+ const others = ['pre-lane-sweep-20260823T203810Z.sql.gz', 'cloudbongos-2026-09-26T040000Z.sql.gz', 'notes.txt'];
851
+ for (const o of others) {
852
+ fs.writeFileSync(path.join(dir, o), 'x');
853
+ fs.utimesSync(path.join(dir, o), 1, 1); // older than every dump
854
+ }
855
+
856
+ const res = spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' });
857
+ assert.equal(res.status, 0, res.stderr);
858
+ assert.equal(res.stdout.trim(), '3');
859
+ const left = new Set(fs.readdirSync(dir));
860
+ for (const d of dumps.slice(0, 3)) assert.ok(!left.has(d), `${d} is one of the three oldest and must go`);
861
+ for (const d of dumps.slice(3)) assert.ok(left.has(d), `${d} is one of the newest ten and must stay`);
862
+ for (const o of others) assert.ok(left.has(o), `${o} is not a pre-upgrade dump and must never be pruned`);
863
+
864
+ // A second run has nothing left to do.
865
+ assert.equal(spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' }).stdout.trim(), '0');
866
+ } finally {
867
+ fs.rmSync(dir, { recursive: true, force: true });
868
+ }
869
+ });
@@ -280,7 +280,7 @@ function parsePageGates(src) {
280
280
  // Each entry's own object literal is bounded by [^}]* so the scan cannot run past a
281
281
  // gate that lacks a `permission` key and silently adopt the NEXT gate's. `permission`
282
282
  // is then found ANYWHERE inside that literal rather than assumed to come first —
283
- // requireProjectCuratePage already carries a second key, and key order in an object
283
+ // every gate carries a `deniedTo` beside it, and key order in an object
284
284
  // literal is not a contract anyone maintains.
285
285
  for (const m of block[1].matchAll(/(require[A-Za-z]*Page):\s*Object\.freeze\(\{([^}]*)\}\)/g)) {
286
286
  const perm = m[2].match(/permission:\s*'([^']+)'/);
@@ -223,9 +223,9 @@ test('B: a payload with no id degrades to the place alone', async () => {
223
223
  });
224
224
 
225
225
  test('B: the id wears the same mono/faint voice as the login', () => {
226
- const css = read('people.css');
226
+ const css = read('roster.css');
227
227
  const i = css.indexOf('.ledger__place .ledger__id {');
228
- assert.ok(i > 0, 'people.css styles the roster id');
228
+ assert.ok(i > 0, 'roster.css styles the roster id');
229
229
  const block = css.slice(i, css.indexOf('}', i));
230
230
  for (const token of ['var(--font-mono)', 'var(--ink-faint)']) {
231
231
  assert.ok(block.includes(token), `the id spends ${token}, the voice .dir-login already uses`);
@@ -266,10 +266,9 @@ test('sidebar groups read by audience, in order', () => {
266
266
  assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
267
267
  'the unlabeled group is the project itself (task 1002425 order)');
268
268
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
269
- // People (task 1002577): the /scouting directory graduated into the hall.
270
- // Gated cosmetically to match its data endpoint — `project.curate`, floor METIC,
271
- // so the nav entry reads 'government' since ADR 0174 (it said 'archon', then
272
- // was stricter than the wall). requireProjectCuratePage is the wall server-side.
269
+ // task 1003444: 'people' LEFT the group — the owner deleted the hall's
270
+ // cross-project directory (ADR 0337 D5); its home is the hub, and /people
271
+ // redirects to /roster. Builders is the hall's directory now.
273
272
  // task 1003172 (C02 of goal 1000075): 'collab' joins Community, FIRST. The
274
273
  // group is picked by whose data it is, and a help request is another builder
275
274
  // reaching you — not your own numbers (that is You), and not reference (Docs).
@@ -280,7 +279,8 @@ test('sidebar groups read by audience, in order', () => {
280
279
  // changed: it was a hash view on index.html (href /#leaderboard, view:
281
280
  // 'leaderboard') and is now a real page at /roster labelled Builders. That is
282
281
  // what made this group read as one real page plus a link into the home page.
283
- assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster', 'people'], 'Community = everyone else');
282
+ assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'], 'Community = everyone else, in this project');
283
+ assert.ok(!nav.flatMap((g) => g.items).some((i) => /^\/people\b/.test(i.href)), 'no nav item points at the deleted /people');
284
284
  assert.deepEqual(nav[3].items.map((i) => i.id),
285
285
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
286
286
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
@@ -343,11 +343,11 @@ test('THE CORRECTION: Community shows a xenos two items, Collab and Builders', (
343
343
  test('neither Community page hides behind a rank the page itself does not enforce', () => {
344
344
  // The invariant this task exists to restore: a nav flag may be narrower than
345
345
  // the wall for cosmetic reasons (/profile does it), but it must never assert a
346
- // RANK the page gate does not. /people keeps its rank flag legitimately — it
347
- // fronts project.curate, which requireProjectCuratePage really does enforce.
346
+ // RANK the page gate does not. /people was the one item with a legitimate rank
347
+ // flag; it left the hall in task 1003444, so the group now carries none.
348
348
  const community = parseNav(boot().sidebarHtml).find((g) => g.label === 'Community');
349
349
  const ranked = community.items.filter((i) => RANK_GATES.has(i.gate)).map((i) => i.id);
350
- assert.deepEqual(ranked, ['people'], 'only /people carries a rank flag, and its API enforces one');
350
+ assert.deepEqual(ranked, [], 'no Community item carries a rank flag');
351
351
  });
352
352
 
353
353
  test('no group head is gated wider than its own items', () => {
@@ -36,7 +36,6 @@ const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'ut
36
36
  // functions in src/bongos/auth.js):
37
37
  // requireBuilderPage page.view.public any signed-in builder (xenos+)
38
38
  // requireGovernmentPage page.view.government metic+ (ADR 0157)
39
- // requireProjectCuratePage project.curate metic+
40
39
  // requireBoardVotePage board.vote.cast xenos+ (ADR 0266)
41
40
  // That list is now CHECKED, not just written: "every page gate is dispatched"
42
41
  // below reads the gate table out of auth.js and fails if a gate here guards no
@@ -107,12 +106,13 @@ const ORACLE = {
107
106
  // The fleet cost page (task 1001949), on the atom its one read checks.
108
107
  '/builders/fleet': 'requireFleetPage',
109
108
 
110
- // Metic+ via the `project.curate` atom — the exact permission GET /scouting
111
- // checks, so the page can never drift wider than the API it fronts. Its
112
- // legacy twin /builders/scouting is gone (task 1003440): the URL now 301s to
113
- // /people before this dispatch runs, so it holds no gate and has no row —
114
- // tests/hall_roster_world.mjs owns the proof that the redirect is there.
115
- '/builders/people': 'requireProjectCuratePage',
109
+ // Gone (task 1003444, owner call 2026-09-25 — ADR 0337 D5): the cross-project
110
+ // directory left the hall, and /people now redirects to /roster before this
111
+ // dispatch runs, like its legacy twin /scouting (task 1003440).
112
+ // tests/hall_roster_world.mjs owns the proof that the redirect is there. The
113
+ // page gate that fronted it (requireProjectCuratePage) went with it; what
114
+ // still holds is GET /scouting's own gate, in the platform-identity module.
115
+ '/builders/people': null,
116
116
 
117
117
  // Metic+ since R16 (goal 1000069): the Government page is three rooms —
118
118
  // Permissions · Board Room · Constitution (reordered, task 1003269) — and only
@@ -138,8 +138,8 @@ const ORACLE = {
138
138
  // /roster joins them (task 1003441): it is this project’s own ledger, and its
139
139
  // one data source — GET /leaderboard — is deliberately unauthenticated,
140
140
  // mirroring the public status surface. The page must not be gated tighter than
141
- // the API it fronts, which is the same rule that puts /people at
142
- // project.curate. It was reachable to a stranger as the #leaderboard view on
141
+ // the API it fronts, which is the same rule that puts /deploy at
142
+ // core.pin.move. It was reachable to a stranger as the #leaderboard view on
143
143
  // the ungated index, so this preserves the audience rather than widening it.
144
144
  '/builders/roster': null,
145
145
  '/builders/': null,
@@ -310,6 +310,9 @@ const GONE_PAGES = {
310
310
  // Task 1004231 — the ideator's sky replaced both the room and the old sky with
311
311
  // one page at /thinking; /sky is a 301 to it, not a page.
312
312
  '/builders/sky': 'task 1004231 — the sky lives at /thinking now; /sky redirects there',
313
+ // Task 1003444 — the owner deleted the hall's cross-project directory; the hub
314
+ // community page (task 1003363) is its home, and /people redirects to /roster.
315
+ '/builders/people': 'task 1003444 — the hall has no cross-project directory; /people redirects to /roster',
313
316
  };
314
317
 
315
318
  test('a row that is null because the page left the hall serves no page', () => {
@@ -344,8 +347,8 @@ const AUTH_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/auth.js'), 'utf8');
344
347
  function readPageGateNames(src) {
345
348
  const table = /const PAGE_GATES = Object\.freeze\(\{([\s\S]*?)\n\}\);/.exec(src);
346
349
  assert.ok(table, 'PAGE_GATES is still a `const PAGE_GATES = Object.freeze({…});` literal');
347
- // Entry keys sit at exactly one indent level; `permission:` / `deniedTo:` /
348
- // `alsoAdmits:` are nested deeper, so the two-space anchor keeps them out.
350
+ // Entry keys sit at exactly one indent level; `permission:` / `deniedTo:`
351
+ // are nested deeper, so the two-space anchor keeps them out.
349
352
  return [...table[1].matchAll(/^ {2}(require\w+):/gm)].map((m) => m[1]);
350
353
  }
351
354
 
@@ -5,10 +5,10 @@
5
5
  // in the open and the rest behind the kit's disclosure, the readiness is the
6
6
  // family's figure band in the same dot language as the roll line, the view
7
7
  // toggle is a view and not a filter, the accent lands on the one live door; the
8
- // legacy /scouting door is deleted and its URL redirects onto the one directory
9
- // page (task 1003440), which shows the avatar, the name and the login
10
- // the API returns; the session ledger drops the nowrap + wrapper pair that broke
11
- // its sticky header, makes every id a door, and names its floor from the pack.
8
+ // cross-project directory is gone from the hall — /scouting was deleted by task
9
+ // 1003440 and /people by task 1003444, and both URLs now land on /roster; the
10
+ // session ledger drops the nowrap + wrapper pair that broke its sticky header,
11
+ // makes every id a door, and names its floor from the pack.
12
12
  import test from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
14
  import fs from 'node:fs';
@@ -156,8 +156,8 @@ test('goals: the card is the row folded — no box, one fact source, the subtitl
156
156
  assert.match(read('goals-page.js'), /gr-row gr-row--skel/, 'the boot skeleton is row-shaped');
157
157
  });
158
158
 
159
- test('goals.css and people.css spend only the fifteen, and every token they name exists', () => {
160
- for (const f of ['goals.css', 'people.css']) {
159
+ test('goals.css and roster.css spend only the fifteen, and every token they name exists', () => {
160
+ for (const f of ['goals.css', 'roster.css']) {
161
161
  const css = read(f);
162
162
  noAlias(css, f);
163
163
  tokensDefined(css, f);
@@ -165,89 +165,61 @@ test('goals.css and people.css spend only the fifteen, and every token they name
165
165
  });
166
166
 
167
167
  // =========================================================================
168
- // PEOPLE — one directory, one door
168
+ // PEOPLE — deleted from the hall; both old doors land on /roster
169
169
  // =========================================================================
170
170
 
171
- // Task 1003470 made /scouting render people.html byte-for-byte; task 1003440
172
- // removes the door itself. Two URLs onto one directory is a surface to keep in
173
- // sync that no reader ever asked for — but the URL still has to answer, or a
174
- // bookmark on the old door 404s.
175
- test('the legacy /scouting door is deleted, and nothing scouting-shaped is left in the hall', () => {
176
- for (const f of ['scouting.html', 'scouting.js', 'scouting.states.json', 'scouting.css']) {
177
- assert.ok(!fs.existsSync(path.join(PUB, f)), f + ' is gone — one directory, one door');
171
+ // Task 1003440 deleted the legacy /scouting door; task 1003444 deleted /people
172
+ // itself (owner call, 2026-09-25 evening — ADR 0337 D5): cross-project discovery
173
+ // belongs to the hub, and the hall's directory is /roster. Both URLs still have
174
+ // to answer, or a bookmark 404s.
175
+ test('the /people page and its legacy /scouting door are deleted, and nothing of either is left in the hall', () => {
176
+ for (const f of ['scouting.html', 'scouting.js', 'scouting.states.json', 'scouting.css',
177
+ 'people.html', 'people.js', 'people.states.json', 'people.css']) {
178
+ assert.ok(!fs.existsSync(path.join(PUB, f)), f + ' is gone — the hall carries no cross-project directory');
178
179
  }
179
180
  });
180
181
 
181
- test('the /scouting URL redirects to /people, and no dead gate dispatches to it', () => {
182
+ test('the /people and /scouting URLs redirect to /roster, and no gate dispatches to either', () => {
182
183
  const serve = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
183
- const i = serve.indexOf('const SCOUTING_LEGACY_RE');
184
- assert.ok(i > 0, 'the retired URL keeps a matcher, so the bookmark still lands somewhere');
185
- const block = serve.slice(i, i + 1200);
186
- assert.ok(block.includes('res.redirect(301,'), 'and answers with a permanent redirect');
187
- assert.ok(block.includes("/people"), 'pointing at the directory that replaced it');
184
+ const decl = 'const RETIRED_DIRECTORY_RE = ';
185
+ const i = serve.indexOf(decl);
186
+ assert.ok(i > 0, 'the retired URLs keep a matcher, so a bookmark still lands somewhere');
187
+ const block = serve.slice(i, i + 900);
188
+ // 302, not 301: /roster is the interim target until the hub community page
189
+ // (task 1003363) ships, and a browser caches a 301 past any re-point.
190
+ assert.ok(block.includes('res.redirect(302,'), 'and answers with a temporary redirect');
191
+ assert.ok(block.includes('/roster'), 'pointing at the in-project directory that remains');
188
192
  // RUN the shipped matcher, rather than trusting that it reads right. Anchoring
189
193
  // is the half that goes wrong: an unanchored twin would swallow the API route.
190
- const decl = 'const SCOUTING_LEGACY_RE = ';
191
- const j = serve.indexOf(decl);
192
- const rx = eval(serve.slice(j + decl.length, serve.indexOf(';', j))); // eslint-disable-line no-eval
193
- for (const p of ['/builders/scouting', '/builders/scouting/', '/builders/scouting.html']) {
194
+ const rx = eval(serve.slice(i + decl.length, serve.indexOf(';', i))); // eslint-disable-line no-eval
195
+ for (const p of ['/builders/people', '/builders/people/', '/builders/people.html',
196
+ '/builders/scouting', '/builders/scouting/', '/builders/scouting.html']) {
194
197
  assert.ok(rx.test(p), p + ' must reach the redirect');
195
198
  }
196
- for (const p of ['/api/gds/scouting', '/api/bongos/scouting', '/builders/people',
197
- '/builders/scoutingfoo', '/builders/scouting/extra']) {
198
- assert.ok(!rx.test(p), p + ' must NOT be redirected — the data route and the directory are not the door');
199
+ for (const p of ['/api/gds/scouting', '/api/bongos/scouting', '/builders/roster',
200
+ '/builders/peoplefoo', '/builders/people/extra', '/builders/scoutingfoo', '/builders/scouting/extra']) {
201
+ assert.ok(!rx.test(p), p + ' must NOT be redirected — the data route and the roster are not the door');
199
202
  }
200
203
  // The gate is the half that would rot silently: a page-gate branch still
201
204
  // naming a deleted page is a wall guarding nothing.
202
- assert.ok(!decomment(serve).includes('SCOUTING_PAGE_RE'),
203
- 'no page gate is left dispatching to a page that no longer exists');
204
- });
205
-
206
- test('the directory page loads its own script, sheet and toolkit', () => {
207
- const people = read('people.html');
208
- for (const html of [people]) {
209
- assert.match(html, /\/builders\/people\.js/);
210
- assert.match(html, /\/builders\/people\.css/);
211
- assert.match(html, /\/builders\/dom-utils\.js/, 'the toolkit the rank label and the number formats come from');
212
- assert.match(html, /<h1 class="page-head__h">People<\/h1>/);
205
+ const code = decomment(serve);
206
+ for (const gone of ['SCOUTING_PAGE_RE', 'PEOPLE_PAGE_RE', 'SCOUTING_LEGACY_RE', 'requireProjectCuratePage']) {
207
+ assert.ok(!code.includes(gone), `${gone} is gone — no gate dispatches to a page that no longer exists`);
213
208
  }
214
209
  });
215
210
 
216
- test('the directory row shows the avatar, the name and the login the API returns, and the name is a door', () => {
217
- const src = read('people.js');
218
- assert.match(src, /b\.avatar_url\s*\?\s*'<img class="dir-avatar" src="' \+ esc\(b\.avatar_url\)/, 'the avatar the pages dropped');
219
- assert.match(src, /dir-avatar dir-avatar--initial/, 'an initial when there is none');
220
- assert.match(src, /return b\.display_name \|\| b\.github_login \|\| \('#' \+ b\.github_id\);/, 'the display name reads first');
221
- assert.match(src, /class="lb-name dir-name" href="https:\/\/github\.com\/' \+ encodeURIComponent\(b\.github_login\)/,
222
- 'the shell\'s .lb-name, to the one page every listed builder has');
223
- assert.match(src, /'<span class="dir-login">@' \+ esc\(b\.github_login\)/, 'the login under the name says where the door goes');
224
- // The chip is the assertion; what goes IN it is the role vocabulary, not the column
225
- // (task 1004040). This line used to pin `esc(String(d))` — the raw value — so it read as
226
- // a guard on the chip while actually holding the Directory to rendering 'unclassified'
227
- // and a lowercase 'ui'. Pinning the wrapper and the helper keeps the original intent
228
- // without pinning the defect that happened to be inside it.
229
- assert.match(src, /<span class="fact-chip">' \+ esc\(roleLabel\(d\)\)/, 'roles are chips, spoken not printed');
230
- assert.doesNotMatch(src, /class="num"/, 'the shell\'s ledger__num, not the bare .num scouting.js had corrected');
231
- assert.match(src, /class="ledger__num"/);
232
- });
233
-
234
- test('the directory has a search, a sort and a craft lens on the kit bar, and pages through the kit', () => {
235
- const src = read('people.js');
236
- assert.match(src, /kit\.makeFilterBar\(\{/);
237
- assert.match(src, /search: \{ id: 'people-q'/);
238
- assert.match(src, /\{ id: 'people-sort', label: 'Sort'/);
239
- // task 1003936: the axis label is read from OTB.roleAxisLabel(), never typed.
240
- assert.match(src, /\{ id: 'people-craft', label: window\.OTB\.roleAxisLabel\(\)/);
241
- assert.match(src, /kit\.makePager\('people'/);
242
- assert.match(src, /kit\.makeLedger\(\{/);
243
- assert.doesNotMatch(src, /createElement\('table'\)/, 'no hand-built table');
244
- });
245
-
246
- test('the directory names its floor from the pack, and never says Archon-only', () => {
247
- const src = read('people.js');
248
- assert.match(src, /rankLabel\('metic'\)/);
249
- assert.doesNotMatch(decomment(src), /Archon-only/);
250
- assert.doesNotMatch(read('people.html'), /Archon/);
211
+ test('no hall page links to /people or loads its assets', () => {
212
+ const offenders = [];
213
+ for (const f of fs.readdirSync(PUB).filter((n) => /\.(html|js|css)$/.test(n))) {
214
+ const src = decomment(read(f)).replace(/<!--[\s\S]*?-->/g, '');
215
+ if (/['"(=]\/(builders\/)?people(\.html)?\/?['"?#)\s>]/.test(src)) offenders.push(`${f}: a /people link`);
216
+ if (/people\.(css|js)\b/.test(src)) offenders.push(`${f}: a people.css/people.js reference`);
217
+ }
218
+ assert.deepEqual(offenders, [], 'a link to the deleted page would bounce off the redirect for no reason');
219
+ // The roster's identity cell moved with it: the sheet is the roster's own now.
220
+ const roster = read('roster.html');
221
+ assert.match(roster, /\/builders\/roster\.css/, 'roster.html loads its own sheet');
222
+ assert.doesNotMatch(roster, /cross-project directory/, 'and no longer contrasts itself with a page the hall no longer has');
251
223
  });
252
224
 
253
225
  // =========================================================================
@@ -304,7 +276,7 @@ test('sessions: the floor is written from the pack\'s rank label, never a hardco
304
276
  // The harness carries what the pages read, and the states files ask for the new states
305
277
  // =========================================================================
306
278
 
307
- test('the harness feeds: rollups for every goal, the category set, avatars on the directory', () => {
279
+ test('the harness feeds: rollups for every goal and the category set', () => {
308
280
  const goals = readJson('goals.json').goals;
309
281
  const rollups = readJson('goals__rollups.json').rollups;
310
282
  const byGoal = new Set(rollups.map((r) => String(r.goal_id)));
@@ -315,16 +287,13 @@ test('the harness feeds: rollups for every goal, the category set, avatars on th
315
287
  assert.ok(goals.some((g) => g.category && g.category.slug), 'some goals carry a category');
316
288
  const cats = readJson('work-categories.json').categories;
317
289
  assert.ok(cats.length >= 2 && cats.every((c) => c.slug && c.label));
318
- const dir = readJson('scouting.json').builders;
319
- assert.ok(dir.some((b) => b.avatar_url) && dir.some((b) => !b.avatar_url), 'the directory fixture has builders with and without an avatar');
290
+ assert.ok(!fs.existsSync(path.join(FIX, 'scouting.json')), 'the directory fixture left with the only page that read it');
320
291
  });
321
292
 
322
293
  test('the states files exercise the redesign\'s new states', () => {
323
294
  const goals = JSON.parse(read('goals.states.json'));
324
295
  assert.ok(goals.states.filters && goals.states.cards, 'goals: the Filters disclosure and the cards view are measured');
325
296
  assert.deepEqual(goals.states.cards.expect.value, [['#f-goal-view', 'cards']]);
326
- const people = JSON.parse(read('people.states.json'));
327
- assert.ok(people.states.search, 'people: a search is measured');
328
297
  const sessions = JSON.parse(read('sessions.states.json'));
329
298
  assert.deepEqual(sessions.states.ledger.expect.visible, ['#stats-scroll', '#sessions-scroll']);
330
299
  });
@@ -227,7 +227,6 @@ test('the role axis has exactly one user-facing word, and no surface types it',
227
227
  const SURFACES = [
228
228
  'modules/hall-ui/public/work.js',
229
229
  'modules/hall-ui/public/goals-page.js',
230
- 'modules/hall-ui/public/people.js',
231
230
  'modules/hall-ui/public/profile.js',
232
231
  'modules/hall-ui/public/builders.js',
233
232
  ];
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'ff2017aa9bbbc507';
90
+ const SHELL_SHA = '6b28297270d1e1f0';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);