@bongos/core 1.19.1015 → 1.19.1017

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 (82) hide show
  1. package/.bongos-core.json +159 -94
  2. package/.claude/skills/planning-session/SKILL.md +1 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -2
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
  9. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  10. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
  11. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
  12. package/docs/adr/README.md +3 -0
  13. package/docs/api/openapi.json +60 -5
  14. package/docs/api-reference.md +8 -2
  15. package/docs/architecture.md +4 -1
  16. package/docs/copy-inventory.md +5 -3
  17. package/docs/copy-registry.json +22 -4
  18. package/docs/file-map.md +1 -2
  19. package/docs/module-api-changelog.md +5 -1
  20. package/migrations/core_253_idea_filed_via.sql +59 -0
  21. package/modules/agents/pollers/event-router.js +2 -0
  22. package/modules/discord/discord-bugs.js +2 -0
  23. package/modules/discord/discord-inbound.js +3 -0
  24. package/modules/hall-ui/public/people.html +11 -0
  25. package/modules/hall-ui/public/people.js +77 -11
  26. package/modules/hall-ui/public/people.states.json +1 -1
  27. package/modules/hall-ui/public/sky-data.js +22 -8
  28. package/modules/hall-ui/public/sky-draw.js +3 -2
  29. package/modules/hall-ui/public/sky-panel.js +1 -1
  30. package/modules/ideas/born-of.js +55 -0
  31. package/modules/ideas/inbox.js +23 -8
  32. package/modules/ideas/projection.js +48 -0
  33. package/modules/ideas/routes/inbox.js +20 -0
  34. package/modules/lifecycle/sky.js +25 -4
  35. package/modules/platform-identity/account-visibility.js +33 -2
  36. package/modules/platform-identity/activity-snapshots.js +47 -0
  37. package/modules/platform-identity/builder-search.js +153 -0
  38. package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
  39. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  40. package/modules/platform-identity/module.json +1 -1
  41. package/modules/platform-identity/platform-identity.js +13 -0
  42. package/modules/platform-identity/routes/community-search.js +120 -0
  43. package/modules/platform-identity/routes/scouting.js +14 -7
  44. package/modules/platform-identity/scouting-name-only.js +47 -0
  45. package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
  46. package/modules/platform-identity/tests/platform-identity.mjs +21 -0
  47. package/modules/public-landing/public/projects-floor.states.json +9 -2
  48. package/modules/public-landing/public/projects.html +69 -4
  49. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  50. package/modules/ui-design/styles/expedition/mock.html +16 -3
  51. package/package-lock.json +2 -2
  52. package/package.json +1 -1
  53. package/scripts/gds/capture.js +11 -0
  54. package/scripts/gds/run-unit-tests.js +4 -0
  55. package/src/module-api.js +1 -1
  56. package/tests/account_privacy_flags.mjs +4 -1
  57. package/tests/activity_snapshots_db.mjs +97 -0
  58. package/tests/card_apply_ui.mjs +297 -0
  59. package/tests/community_search_route.mjs +385 -0
  60. package/tests/discord_bugs.mjs +1 -0
  61. package/tests/discord_inbound.mjs +1 -0
  62. package/tests/effective_visibility_predicate.mjs +49 -1
  63. package/tests/goal_map_page.mjs +14 -0
  64. package/tests/guild_namespace_reserved.mjs +76 -0
  65. package/tests/hall_sky.mjs +33 -16
  66. package/tests/idea_filed_via.mjs +185 -0
  67. package/tests/landing_page.mjs +8 -0
  68. package/tests/people_name_only.mjs +262 -0
  69. package/tests/profile_rollup_consent.mjs +5 -1
  70. package/tests/profile_route.mjs +23 -5
  71. package/tests/project_admin_console_model.mjs +105 -0
  72. package/tests/recruiter_sliver_boundary.mjs +3 -0
  73. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  74. package/tests/sky_read.mjs +19 -2
  75. package/tests/ui_design_skills.mjs +9 -0
  76. package/tests/ui_design_styles.mjs +13 -0
  77. package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
  78. package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
  79. package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
  80. package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
  81. package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
  82. package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
@@ -0,0 +1,185 @@
1
+ // tests/idea_filed_via.mjs — WHERE AN IDEA WAS FILED FROM, and WHICH THOUGHTS
2
+ // BECAME THE STRUCTURE (task 1004233 / sky part 5 of 5, goal 1000095; ADR 0331 §4).
3
+ //
4
+ // The sky's prototype guessed two things from text: whether a session or a
5
+ // person filed a thought, and which thoughts became goals or rings. This suite
6
+ // pins the two ledger facts that replaced those guesses:
7
+ // - the column: additive, nullable, no default, NO BACKFILL — a row older
8
+ // than the column stays NULL, because NULL is not 'cli';
9
+ // - the vocabulary: one closed set, spelled in projection.js and mirrored by
10
+ // the migration's CHECK, so a bad value is a named 400 and never a 500;
11
+ // - each writer's value: the route reads the door off the SESSION (web is the
12
+ // hall, cli is the CLI, anything else the API) and lets the body add only
13
+ // 'planning'; capture.js names 'planning' and nothing else; the Discord
14
+ // paths and the agents' sink name their own on the port; a new row always
15
+ // records a door;
16
+ // - the born read: first ratification wins, ids as strings, through the
17
+ // ideas.bornOf port so lifecycle never reads an ideas table itself.
18
+ //
19
+ // DB-free: captureIdea runs against a recording pool (the idea_quick_guard
20
+ // harness), readBornOf against a fake pool, the rest is pure or read from source.
21
+ //
22
+ // Run: node tests/idea_filed_via.mjs
23
+ process.env.NODE_ENV = 'test';
24
+ import { strict as assert } from 'node:assert';
25
+ import { createRequire } from 'node:module';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { makeRunner } from './helpers.mjs';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
34
+ const api = require(path.join(ROOT, 'src/module-api.js'));
35
+ let queries = [];
36
+ let nextRows = [];
37
+ api.pool.query = async (sql, params = []) => { queries.push({ sql, params }); return { rows: nextRows }; };
38
+ const inbox = require(path.join(ROOT, 'modules/ideas/inbox.js'));
39
+ const projection = require(path.join(ROOT, 'modules/ideas/projection.js'));
40
+ const { readBornOf } = require(path.join(ROOT, 'modules/ideas/born-of.js'));
41
+
42
+ const { test, summary } = makeRunner();
43
+ const reset = () => { queries = []; nextRows = [{ id: '41', title: 'an idea', status: 'open', grade: 'quick' }]; };
44
+ const FIVE = ['hall', 'cli', 'discord', 'planning', 'api'];
45
+ const migrationFile = fs.readdirSync(path.join(ROOT, 'migrations')).find((f) => /^core_\d+_idea_filed_via\.sql$/.test(f));
46
+
47
+ // ═══ the migration ═══════════════════════════════════════════════════════════
48
+
49
+ await test('the column is a CORE migration: additive, nullable, no default, and the CHECK names exactly the five doors', () => {
50
+ assert.ok(migrationFile, 'migrations/core_NNN_idea_filed_via.sql exists (idea_inbox is a core table — ADR 0083 §Decision #5)');
51
+ const sql = read(path.join('migrations', migrationFile));
52
+ assert.match(sql, /ALTER TABLE idea_inbox\s+ADD COLUMN IF NOT EXISTS filed_via text;/);
53
+ assert.equal(/filed_via text\s+(NOT NULL|DEFAULT)/i.test(sql), false, 'nullable, no default');
54
+ const check = /CHECK \(filed_via IS NULL OR filed_via IN \(([^)]*)\)\)/.exec(sql);
55
+ assert.ok(check, 'a CHECK closes the set');
56
+ assert.deepEqual(check[1].split(',').map((s) => s.trim().replace(/'/g, '')), FIVE);
57
+ assert.match(sql, /IF NOT EXISTS \(\s*SELECT 1 FROM pg_constraint/, 'the CHECK add is guarded — replay-safe');
58
+ assert.ok(sql.includes(`INSERT INTO schema_migrations (version) VALUES ('${migrationFile.replace(/\.sql$/, '')}')`), 'the stem is the version');
59
+ });
60
+
61
+ await test('NULL IS NOT \'cli\': the migration backfills nothing, and no writer guesses a door from text', () => {
62
+ const sql = read(path.join('migrations', migrationFile));
63
+ assert.equal(/UPDATE idea_inbox/i.test(sql), false, 'no backfill — a historic row stays NULL');
64
+ assert.equal(/title|body_md|ILIKE|~\*/.test(sql.replace(/^--.*$/gm, '')), false, 'nothing in the SQL reads the idea\'s words');
65
+ const src = read('modules/ideas/inbox.js') + read('modules/ideas/projection.js') + read('modules/ideas/routes/inbox.js');
66
+ assert.equal(/filed_via\s*=\s*CASE|classify\([^)]*\)\s*===?\s*'(cli|api)'/.test(src), false, 'no code derives a door from the text either');
67
+ });
68
+
69
+ // ═══ the vocabulary ══════════════════════════════════════════════════════════
70
+
71
+ await test('ONE closed set, spelled in projection.js, carried by every read', () => {
72
+ assert.deepEqual([...projection.FILED_VIA_VALUES], FIVE);
73
+ assert.ok(Object.isFrozen(projection.FILED_VIA_VALUES));
74
+ assert.ok(projection.IDEA_COL_NAMES.includes('filed_via'), 'on the shared column list — so the trail (and GET /sky/mine, which is the trail) returns it');
75
+ assert.match(projection.IDEA_COLS_Q, /\bi\.filed_via\b/);
76
+ for (const v of FIVE) assert.equal(projection.isValidFiledVia(v), true);
77
+ for (const v of ['session', 'person', '', null, undefined, 'HALL']) assert.equal(projection.isValidFiledVia(v), false, `${v} is not a door`);
78
+ assert.deepEqual([inbox.FILED_VIA_VALUES, inbox.resolveFiledVia], [projection.FILED_VIA_VALUES, projection.resolveFiledVia], 'the facade re-exports the one declaration');
79
+ });
80
+
81
+ // ═══ the door is read off the session ════════════════════════════════════════
82
+
83
+ await test('the session says which door: a web cookie is the hall, a device-flow token is the CLI, anything else is the API', () => {
84
+ const { filedViaForSource } = projection;
85
+ assert.equal(filedViaForSource('web'), 'hall');
86
+ assert.equal(filedViaForSource('cli'), 'cli');
87
+ assert.equal(filedViaForSource('box'), 'api');
88
+ assert.equal(filedViaForSource(null), 'api', 'a legacy sourceless session is the API, not a guess');
89
+ assert.equal(filedViaForSource(undefined), 'api');
90
+ });
91
+
92
+ await test('the body may ADD only \'planning\'; repeating the session\'s own door is harmless; any other claim is refused with the reason', () => {
93
+ const { resolveFiledVia } = projection;
94
+ assert.deepEqual(resolveFiledVia('cli', undefined), { ok: true, value: 'cli' });
95
+ assert.deepEqual(resolveFiledVia('web', null), { ok: true, value: 'hall' });
96
+ assert.deepEqual(resolveFiledVia('cli', 'planning'), { ok: true, value: 'planning' }, 'the planning session is a CLI filing no session can tell apart');
97
+ assert.deepEqual(resolveFiledVia('web', 'planning'), { ok: true, value: 'planning' });
98
+ assert.deepEqual(resolveFiledVia('web', 'hall'), { ok: true, value: 'hall' }, 'naming your own door changes nothing');
99
+ const spoof = resolveFiledVia('cli', 'hall');
100
+ assert.equal(spoof.ok, false, 'a CLI cannot say it came through the hall');
101
+ assert.deepEqual(spoof.detail.allowed, ['planning', 'cli']);
102
+ assert.match(spoof.detail.reason, /read off your session \(cli\)/);
103
+ const junk = resolveFiledVia('web', 'session');
104
+ assert.equal(junk.ok, false);
105
+ assert.match(junk.detail.reason, /must be one of hall, cli, discord, planning, api/);
106
+ });
107
+
108
+ await test('POST /inbox reads the door off req.gdsSession, refuses a bad claim as a named 400, and hands captureIdea the value', () => {
109
+ const src = read('modules/ideas/routes/inbox.js');
110
+ assert.match(src, /filed_via: \{ type: 'string', maxLength: 16 \}/, 'the body field is declared (strict validation would 400 it otherwise)');
111
+ assert.match(src, /inbox\.resolveFiledVia\(req\.gdsSession && req\.gdsSession\.source, body\.filed_via\)/, 'the SESSION source, never the body alone');
112
+ assert.match(src, /res\.fail\('bad_filed_via', 400, door\.detail\)/);
113
+ assert.match(src, /filedVia: door\.value,/);
114
+ assert.match(src, /err\.code === 'BAD_FILED_VIA' \? 400 :/, 'a value that slipped past the route is still a 400, never a constraint 500');
115
+ });
116
+
117
+ // ═══ every writer ════════════════════════════════════════════════════════════
118
+
119
+ await test('captureIdea WRITES the door on the row, defaults a silent port caller to \'api\', and refuses a value outside the set', async () => {
120
+ reset();
121
+ await inbox.captureIdea({ title: 'from the bot', capturedBy: '7', filedVia: 'discord' });
122
+ assert.equal(queries.length, 1);
123
+ assert.match(queries[0].sql, /INSERT INTO idea_inbox[\s\S]*filed_via\)/, 'the column is in the INSERT');
124
+ assert.equal(queries[0].params.at(-1), 'discord', 'and the value is the last bind');
125
+ reset();
126
+ await inbox.captureIdea({ title: 'a program said nothing', capturedBy: null });
127
+ assert.equal(queries[0].params.at(-1), 'api', 'a NEW row always records a door — NULL is for rows older than the column');
128
+ reset();
129
+ await assert.rejects(inbox.captureIdea({ title: 'x', capturedBy: '7', filedVia: 'session' }), (e) => e.code === 'BAD_FILED_VIA' && e.filedVia === 'session' && Array.isArray(e.allowed));
130
+ assert.equal(queries.length, 0, 'refused before any write');
131
+ });
132
+
133
+ await test('the ROUTED insert (a filing that becomes a task in the same act) writes the door too', () => {
134
+ const src = read('modules/ideas/inbox.js');
135
+ const inserts = [...src.matchAll(/INSERT INTO idea_inbox[\s\S]*?RETURNING \$\{IDEA_COLS\}/g)].map((m) => m[0]);
136
+ assert.equal(inserts.length, 2, 'the inbox insert and the routed insert');
137
+ for (const ins of inserts) assert.match(ins, /filed_via/, 'both name the column');
138
+ assert.match(src, /capturedBy, goalId, landing, filerRank, touches, filedVia,\s*\}, activePool\)/, 'and the routed path is handed the value');
139
+ });
140
+
141
+ await test('capture.js names \'planning\' and nothing else — every other CLI filing\'s door is read off the session', () => {
142
+ const src = read('scripts/gds/capture.js');
143
+ assert.match(src, /const viaFromArg = arg\('--via', args\);/);
144
+ assert.match(src, /if \(viaFromArg !== null && viaFromArg !== 'planning'\)[\s\S]{0,400}process\.exit\(2\)/, 'a door the CLI may not name is refused before the request');
145
+ assert.match(src, /if \(viaFromArg\) payload\.filed_via = viaFromArg;/, 'sent only when given — a pinned core that predates the field never sees it');
146
+ assert.equal(/filed_via: 'cli'/.test(src), false, 'never claims cli — the session already says so');
147
+ assert.equal(/postInbox\(/.test(read('scripts/gds/spark.js')), false, 'spark.js files no idea row of its own (a spark is made from an existing idea), so its door is the row\'s');
148
+ assert.match(read('.claude/skills/planning-session/SKILL.md'), /--via planning/, 'the planning session is told to name its door');
149
+ });
150
+
151
+ await test('the Discord doors and the agents\' sink name their own door on the port', () => {
152
+ assert.match(read('modules/discord/discord-inbound.js'), /filedVia: 'discord',/);
153
+ assert.match(read('modules/discord/discord-bugs.js'), /filedVia: 'discord',/);
154
+ assert.match(read('modules/agents/pollers/event-router.js'), /filedVia: 'api',/);
155
+ });
156
+
157
+ // ═══ born of a thought ═══════════════════════════════════════════════════════
158
+
159
+ function bornPool({ goals = [], criteria = [] } = {}) {
160
+ const calls = [];
161
+ return { calls, async query(sql) { calls.push(sql); return { rows: /ideas_goal_ratifications/.test(sql) ? goals : criteria }; } };
162
+ }
163
+
164
+ await test('readBornOf: goals from the goal ratifications, criteria from the proposal audit, the FIRST ratification wins, ids as strings', async () => {
165
+ const pool = bornPool({
166
+ goals: [{ goal_id: '7', idea_id: '10' }, { goal_id: '7', idea_id: '99' }, { goal_id: 8, idea_id: 11 }],
167
+ criteria: [{ criterion_id: '70', idea_id: '12' }, { criterion_id: '71', idea_id: null }],
168
+ });
169
+ const born = await readBornOf({ pool });
170
+ assert.deepEqual(born, { goals: { 7: '10', 8: '11' }, criteria: { 70: '12' } });
171
+ assert.equal(pool.calls.length, 2);
172
+ assert.match(pool.calls[0], /FROM ideas_goal_ratifications\s+ORDER BY ratified_at, id/);
173
+ assert.match(pool.calls[1], /FROM criterion_proposal_audit\s+ORDER BY ratified_at, id/);
174
+ assert.deepEqual(await readBornOf({ pool: bornPool() }), { goals: {}, criteria: {} }, 'nothing ratified is an empty map');
175
+ });
176
+
177
+ await test('the read is served through the ideas.bornOf port, beside ideas.capture, so lifecycle\'s GET /sky never reads an ideas table', () => {
178
+ assert.match(read('modules/ideas/routes/inbox.js'), /api\.registerProvider\('ideas\.bornOf', bornOf\.readBornOf\)/);
179
+ const sky = read('modules/lifecycle/sky.js');
180
+ assert.match(sky, /resolveOptional\('ideas\.bornOf'\)/);
181
+ assert.equal(/ideas_goal_ratifications|criterion_proposal_audit|require\(['"][^'"]*ideas/.test(sky), false);
182
+ });
183
+
184
+ console.log('');
185
+ summary();
@@ -600,3 +600,11 @@ test('the chip is the profile trigger, built with createElement; Sign out is the
600
600
  assert.match(CODE, /var signout = document\.querySelector\('#menuProfile a\.signout'\);\s*if \(signout\) signout\.addEventListener\('click', function \(e\) \{ e\.preventDefault\(\); doLogout\(\); \}\);/, 'the honest href, upgraded to the POST');
601
601
  assert.doesNotMatch(CODE, /className = 'pill signout'/, 'no sign-out pill in the bar any more');
602
602
  });
603
+
604
+ test('the retired gate assets (world/assets/clouds|creatures|plates) are gone — the chrome landing ships its plates from assets/world/ instead (task 1003650)', () => {
605
+ assert.ok(!fs.existsSync(path.join(LANDING, 'world')), 'modules/public-landing/public/world/ no longer exists');
606
+ assert.doesNotMatch(HTML, /\bworld\/assets\//, 'the page links no path under the retired world/assets/ folder');
607
+ assert.doesNotMatch(WORLD, /\bworld\/assets\//, 'world.css references no path under the retired folder');
608
+ // the chrome landing's own plates (task 1003298) live at assets/world/ and are untouched by this cleanup
609
+ assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-bongos-chrome-b.jpg')), 'the chrome plate at assets/world/ is untouched (task 1003649 converts it separately)');
610
+ });
@@ -0,0 +1,262 @@
1
+ // tests/people_name_only.mjs — the People directory lists a hidden builder by
2
+ // name alone (task 1002291 / R15, ADR 0334; privacy spec D2's recruiter column:
3
+ // "listed name-only (if not opted out)").
4
+ //
5
+ // GET /scouting now answers two tiers: `builders`, ranked on the cross-project
6
+ // record, and `name_only`, the builders who hid that record but are still open
7
+ // to recruiting. The server decides who is in each (tests/scouting_owner_access_-
8
+ // and_reach.mjs); this file holds the PAGE to the rules that keep the second
9
+ // tier honest on screen:
10
+ // - it is its own panel, revealed only when the server sends a row
11
+ // - a row is the identity cell and nothing else — no figure, no role, no date
12
+ // - it is never ranked: alphabetical whatever the sort lens says
13
+ // - search reads a name; a role filter never matches (the role is part of the
14
+ // record the builder hid)
15
+ // - the page still makes ONE read
16
+ //
17
+ // It runs the REAL people.js and the REAL kit in a vm, over a DOM fake one step
18
+ // deeper than tests/people_directory_clip.mjs's: an element keeps the node it
19
+ // hands out for a selector, so the <tbody> the kit fills is the one read back.
20
+ //
21
+ // Run: node tests/people_name_only.mjs
22
+ import { strict as assert } from 'node:assert';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import vm from 'node:vm';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { makeRunner } from './helpers.mjs';
28
+
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
31
+ const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
32
+ const PEOPLE_JS = read('modules/hall-ui/public/people.js');
33
+ const PEOPLE_HTML = read('modules/hall-ui/public/people.html');
34
+
35
+ const { test, summary } = makeRunner();
36
+ const tick = () => new Promise((r) => setTimeout(r, 0));
37
+
38
+ function makeEl(id) {
39
+ const kids = new Map();
40
+ const listeners = {};
41
+ return {
42
+ id,
43
+ innerHTML: '', textContent: '', value: '',
44
+ hidden: false, disabled: false, className: '',
45
+ options: [], style: {}, dataset: {},
46
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
47
+ appendChild() {}, setAttribute() {}, removeAttribute() {},
48
+ addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
49
+ fire(type) { (listeners[type] || []).forEach((fn) => fn({ target: { matches: () => true } })); },
50
+ querySelector(sel) {
51
+ if (!kids.has(sel)) kids.set(sel, makeEl(sel));
52
+ return kids.get(sel);
53
+ },
54
+ querySelectorAll() { return []; },
55
+ };
56
+ }
57
+
58
+ function realRoleLabel() {
59
+ const src = read('modules/hall-ui/public/dom-utils.js');
60
+ const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
61
+ const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
62
+ new Function('window', 'document', src)(win, doc);
63
+ return win.OTB.roleLabel;
64
+ }
65
+
66
+ // Ranked rows carry a record; the sentinels are what a name-only row must never show.
67
+ const RANKED = [
68
+ { github_id: 1, github_login: 'zed-ranked', display_name: 'Zed', avatar_url: null, project_count: 2,
69
+ total_credits: 900, total_tasks_shipped: 9, total_karma: 4, last_active: '2026-09-01T00:00:00Z', disciplines: ['engineer'] },
70
+ { github_id: 2, github_login: 'amy-ranked', display_name: 'Amy', avatar_url: null, project_count: 1,
71
+ total_credits: 100, total_tasks_shipped: 1, total_karma: 1, last_active: '2026-09-02T00:00:00Z', disciplines: ['artist'] },
72
+ ];
73
+ // Alphabetical by login, as the server sends them.
74
+ const NAMED = [
75
+ { github_login: 'bea-hidden', display_name: 'Bea', avatar_url: null },
76
+ { github_login: 'cal-hidden', display_name: null, avatar_url: 'https://avatars.githubusercontent.com/u/7?v=4' },
77
+ ];
78
+
79
+ function harness(payload) {
80
+ const els = new Map();
81
+ const getEl = (id) => {
82
+ if (!els.has(id)) els.set(id, makeEl(id));
83
+ return els.get(id);
84
+ };
85
+ const documentObj = {
86
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
87
+ getElementById: getEl,
88
+ querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
89
+ querySelectorAll() { return []; },
90
+ createElement() { return makeEl(); },
91
+ addEventListener() {},
92
+ };
93
+ const requests = [];
94
+ const store = new Map();
95
+ const windowObj = {
96
+ location: { assign() {}, href: '/people', search: '' },
97
+ localStorage: {
98
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
99
+ setItem: (k, v) => store.set(k, String(v)),
100
+ removeItem: (k) => store.delete(k),
101
+ },
102
+ addEventListener() {},
103
+ OTBBranding: { currencyLabel: () => 'Credits' },
104
+ OTB: {
105
+ roleAxisLabel: () => 'Role',
106
+ roleLabel: realRoleLabel(),
107
+ fmtInt: (n) => String(Number(n) || 0),
108
+ rankLabel: (r) => String(r ?? ''),
109
+ },
110
+ };
111
+ const sandbox = {
112
+ window: windowObj, document: documentObj, location: windowObj.location,
113
+ console: { error() {}, warn() {}, log() {} },
114
+ CSS: { escape: (s) => String(s) },
115
+ setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON,
116
+ fetch: (url) => {
117
+ requests.push(String(url));
118
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) });
119
+ },
120
+ };
121
+ sandbox.globalThis = sandbox;
122
+ vm.createContext(sandbox);
123
+ vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
124
+ vm.runInContext(PEOPLE_JS, sandbox, { filename: 'people.js' });
125
+
126
+ const filters = () => getEl('people-filters');
127
+ return {
128
+ getEl,
129
+ requests,
130
+ rankedRows: () => getEl('people-body').querySelector('tbody').innerHTML,
131
+ namedRows: () => getEl('people-named-body').querySelector('tbody').innerHTML,
132
+ // Drive the kit's filter bar the way a reader does: set the control, fire.
133
+ setFilter(id, value) {
134
+ filters().querySelector(`#${id}`).value = value;
135
+ filters().fire(id === 'people-q' ? 'input' : 'change');
136
+ },
137
+ };
138
+ }
139
+
140
+ const loginsIn = (html) => [...html.matchAll(/class="dir-login">@([^<]+)</g)].map((m) => m[1]);
141
+
142
+ // ---------------------------------------------------------------------------
143
+
144
+ await test('the page carries the name-only panel, hidden until the server sends a row', () => {
145
+ const tag = PEOPLE_HTML.match(/<section[^>]*id="people-named-scroll"[^>]*>/);
146
+ assert.ok(tag, 'a panel of its own — a hidden builder is not a row in the ranked ledger');
147
+ assert.match(tag[0], /class="scroll"/, "the shell's panel, not a fourth card level");
148
+ assert.match(tag[0], /\bhidden\b/, 'it ships hidden: an instance with no such builder shows nothing');
149
+ assert.match(PEOPLE_HTML, /id="people-named-h"[^>]*>[^<]+<span class="count-pill" id="people-named-count" hidden>/,
150
+ 'a heading with its own count');
151
+ const lede = (PEOPLE_HTML.match(/<p class="scroll__lede" id="people-named-lede">([^<]+)<\/p>/) || [])[1] || '';
152
+ assert.match(lede, /private/i, 'the lede says why these builders show no record');
153
+ assert.match(lede, /ranked/i, 'and that they are never ranked');
154
+ assert.match(lede, /filter/i, 'and that a role filter never finds them');
155
+ assert.match(PEOPLE_HTML, /id="people-named-body"/, 'and a mount for the ledger');
156
+ });
157
+
158
+ await test('name-only rows render the identity cell and nothing else', async () => {
159
+ const h = harness({ builders: RANKED, name_only: NAMED });
160
+ await tick(); await tick();
161
+ assert.equal(h.getEl('people-named-scroll').hidden, false, 'revealed once a row arrives');
162
+ assert.equal(h.getEl('people-named-count').textContent, '2');
163
+ assert.equal(h.getEl('people-named-count').hidden, false);
164
+ const rows = h.namedRows();
165
+ assert.deepEqual(loginsIn(rows), ['bea-hidden', 'cal-hidden'], 'each builder, by login');
166
+ assert.match(rows, /<a class="lb-name dir-name" href="https:\/\/github\.com\/bea-hidden"/,
167
+ 'the same door the ranked row has — the one page every listed builder has');
168
+ assert.match(rows, /<img class="dir-avatar" src="https:\/\/avatars\.githubusercontent\.com\/u\/7\?v=4"/, 'and the picture');
169
+ assert.match(rows, />cal-hidden</, 'a builder with no display name reads as their login');
170
+ assert.doesNotMatch(rows, /ledger__num|ledger__date|fact-chip/,
171
+ 'no figure, no date and no role cell: every one of those is the record the builder hid');
172
+ });
173
+
174
+ await test('the ranked ledger is untouched by the second tier', async () => {
175
+ const h = harness({ builders: RANKED, name_only: NAMED });
176
+ await tick(); await tick();
177
+ assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked', 'amy-ranked'],
178
+ 'ranked in the server order, as before — and no hidden builder among them');
179
+ assert.equal(h.getEl('people-count').textContent, '2', 'its count is the ranked count');
180
+ assert.match(h.getEl('people-sub').textContent, /2 more .*by name only/,
181
+ 'the subtitle counts both tiers, so it does not claim the ranked list is everyone');
182
+ });
183
+
184
+ await test('a sort lens never re-orders the name-only list — it is not ranked', async () => {
185
+ const h = harness({ builders: RANKED, name_only: [...NAMED].reverse() });
186
+ await tick(); await tick();
187
+ const before = loginsIn(h.namedRows());
188
+ for (const lens of ['ships', 'credits', 'karma', 'projects', 'recent']) {
189
+ h.setFilter('people-sort', lens);
190
+ assert.deepEqual(loginsIn(h.namedRows()), before, `the ${lens} lens has nothing to sort a hidden record by`);
191
+ }
192
+ assert.deepEqual(loginsIn(h.rankedRows()), ['amy-ranked', 'zed-ranked'],
193
+ 'CONTROL: the ranked list DID re-order — the last lens, most recently active, puts Amy first');
194
+ });
195
+
196
+ await test('search reads a name; a role filter never matches a hidden builder', async () => {
197
+ const h = harness({ builders: RANKED, name_only: NAMED });
198
+ await tick(); await tick();
199
+ h.setFilter('people-q', 'bea');
200
+ assert.deepEqual(loginsIn(h.namedRows()), ['bea-hidden'], 'a hidden builder is findable by name');
201
+ assert.deepEqual(loginsIn(h.rankedRows()), [], 'and the ranked list filters the same search');
202
+ h.setFilter('people-q', '');
203
+ h.setFilter('people-craft', 'engineer');
204
+ assert.deepEqual(loginsIn(h.namedRows()), [], 'the role is part of the hidden record, so a role filter finds no one here');
205
+ assert.match(h.namedRows(), /ledger__empty/, 'and says so in the panel rather than leaving it blank');
206
+ assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked'], 'CONTROL: the role filter does apply to the ranked list');
207
+ });
208
+
209
+ await test('no name-only rows — or an older server that sends no such key — leaves the panel hidden', async () => {
210
+ for (const payload of [{ builders: RANKED, name_only: [] }, { builders: RANKED }]) {
211
+ const h = harness(payload);
212
+ await tick(); await tick();
213
+ assert.equal(h.getEl('people-named-scroll').hidden, true, 'never revealed');
214
+ assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked', 'amy-ranked'], 'and the directory renders as before');
215
+ }
216
+ });
217
+
218
+ await test('only hidden builders listed: the page renders them rather than the empty-directory notice', async () => {
219
+ const h = harness({ builders: [], name_only: NAMED });
220
+ await tick(); await tick();
221
+ assert.doesNotMatch(h.getEl('people-body').textContent, /Nobody is publicly listed/,
222
+ 'the directory is not empty — two builders are listed by name');
223
+ assert.deepEqual(loginsIn(h.namedRows()), ['bea-hidden', 'cal-hidden']);
224
+ assert.match(h.rankedRows(), /ledger__empty/, 'the ranked ledger states its own empty case');
225
+ const sub = h.getEl('people-sub').textContent;
226
+ assert.match(sub, /^2 builders listed across hosted projects by name only\./,
227
+ 'the subtitle counts who IS listed');
228
+ assert.doesNotMatch(sub, /\b0 builders|more by name only/, 'not "0 builders … 2 more" — a count of nothing');
229
+ });
230
+
231
+ await test('a FULL name-only response is hedged, and says a search cannot reach past it', async () => {
232
+ const CEILING = 500;
233
+ const full = Array.from({ length: CEILING }, (_, i) => ({ github_login: `h${String(i).padStart(3, '0')}`, display_name: null, avatar_url: null }));
234
+ let h = harness({ builders: RANKED, name_only: full });
235
+ await tick(); await tick();
236
+ assert.equal(h.getEl('people-named-count').textContent, `${CEILING}+`, 'the pill may not state a total the page cannot know');
237
+ const foot = h.getEl('people-named-foot');
238
+ assert.equal(foot.hidden, false, 'the footnote shows on a clipped list');
239
+ assert.match(foot.textContent, /clipped at the first 500 by login/);
240
+ assert.match(foot.textContent, /Search only reads what this page loaded/, 'naming the one lens that reads this list');
241
+ assert.match(PEOPLE_HTML, /<p class="ledger__note" id="people-named-foot" hidden><\/p>/, 'the slot ships hidden');
242
+ h = harness({ builders: RANKED, name_only: full.slice(0, CEILING - 1) });
243
+ await tick(); await tick();
244
+ assert.equal(h.getEl('people-named-foot').hidden, true, 'one short of the ceiling is a complete list');
245
+ assert.equal(h.getEl('people-named-count').textContent, String(CEILING - 1));
246
+ });
247
+
248
+ await test('both tiers empty: the consent-boundary notice still explains the empty page', async () => {
249
+ const h = harness({ builders: [], name_only: [] });
250
+ await tick(); await tick();
251
+ assert.match(h.getEl('people-body').textContent, /Nobody is publicly listed/);
252
+ assert.equal(h.getEl('people-named-scroll').hidden, true, 'and the second panel stays hidden');
253
+ });
254
+
255
+ await test('the page still makes ONE read for both tiers', async () => {
256
+ const h = harness({ builders: RANKED, name_only: NAMED });
257
+ await tick(); await tick();
258
+ assert.equal(h.requests.length, 1);
259
+ assert.match(h.requests[0], /^\/api\/bongos\/scouting\?limit=/);
260
+ });
261
+
262
+ summary();
@@ -62,6 +62,7 @@ const ALL_ORIGINS = [PUB_ORIGIN, PRIV_ORIGIN, STEALTH_ORIGIN, UNREAD_ORIGIN];
62
62
  const q = (sql, params) => pool.query(sql, params);
63
63
 
64
64
  async function cleanup() {
65
+ await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
65
66
  await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
66
67
  await q(`DELETE FROM platform_identity_project_memberships WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
67
68
  await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
@@ -81,7 +82,9 @@ try {
81
82
  // error dressed up as a boundary failure — instead of a clean assertion.
82
83
  // 011 and 020 carry the columns the naming rule reads (description, visibility,
83
84
  // the door-known pair); 012_recent_ships the column the rollup SELECTs; 008 and
84
- // 009 the handle and display_name_source columns upsertAccount writes.
85
+ // 009 the handle and display_name_source columns upsertAccount writes; 022 is
86
+ // the table upsertActivity now also writes on every call (task 1002977) — step
87
+ // 5 below issues that INSERT, and a fresh database without it fails 42P01.
85
88
  await q(migrationSql('platform_identity_001_tables.sql'));
86
89
  await q(migrationSql('platform_identity_004_projects.sql'));
87
90
  await q(migrationSql('platform_identity_005_builder_activity.sql'));
@@ -93,6 +96,7 @@ try {
93
96
  await q(migrationSql('platform_identity_012_recent_ships.sql'));
94
97
  await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
95
98
  await q(migrationSql('platform_identity_020_visibility_pull.sql'));
99
+ await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
96
100
  await cleanup();
97
101
 
98
102
  // CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
@@ -420,6 +420,17 @@ const engine = {
420
420
  if (/NOT pa\.hide_stats/.test(sql)) rows = rows.filter((r) => !r.hide_stats);
421
421
  return { rows: rows.map((r) => ({ ...r.row })) };
422
422
  }
423
+ // listScoutingNameOnly — the directory's name-only tier (task 1002291). The
424
+ // same literal-minded rule: only the predicates it declares. Each row comes
425
+ // back WHOLE, numbers included, so the route's projection has to drop them.
426
+ if (/^\s*SELECT github_login, display_name, avatar_url\s+FROM platform_identity_accounts/.test(sql)) {
427
+ let rows = SCOUTING_TABLE;
428
+ if (/profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
429
+ if (/terms_accepted_at IS NOT NULL/.test(sql)) rows = rows.filter((r) => r.terms_accepted_at != null);
430
+ if (/account_visibility = 'public'/.test(sql)) rows = rows.filter((r) => r.account_visibility === 'public');
431
+ if (/AND hide_stats\)/.test(sql)) rows = rows.filter((r) => r.hide_stats);
432
+ return { rows: rows.map((r) => ({ ...r, ...r.row })) };
433
+ }
423
434
  // setAccountHideStats — the own-scoped write behind PATCH /me/cross-project.
424
435
  if (/INSERT INTO platform_identity_accounts/.test(sql)) {
425
436
  hubAccount.hide_stats = !!params[4];
@@ -635,16 +646,23 @@ scoutApp.use(attachFail);
635
646
  scoutApp.use('/api/bongos', buildScoutingRoutes());
636
647
  const scout = await listen(scoutApp);
637
648
 
638
- await t('R06.3 GET /scouting drops the hidden, the provisional AND the private builder', async () => {
649
+ await t('R06.3 GET /scouting ranks the visible builder, lists the HIDDEN one by name alone, and drops the provisional AND the private', async () => {
639
650
  engine.calls.length = 0;
640
651
  const r = await get(scout.port, '/api/bongos/scouting');
641
652
  assert.equal(r.status, 200);
642
653
  const logins = r.json.builders.map((b) => b.github_login);
643
654
  assert.deepEqual(logins, ['r06-visible'],
644
- 'only the consented, publicly-visible, non-hidden builder is listed (task 1002967 added the visibility axis)');
645
- assert.equal(engine.calls.length, 1, 'exactly ONE read — nothing can re-enter via a second, unfiltered query');
646
- for (const s of [HIDDEN_LOGIN, PROV_LOGIN, PRIVATE_LOGIN, '88817', '6613', '5519', '71713', '63127', '4409', '2287']) {
647
- assert.ok(!r.text.includes(s), `the excluded builder leaks nothing — found "${s}"`);
655
+ 'only the consented, publicly-visible, non-hidden builder is RANKED (task 1002967 added the visibility axis)');
656
+ // Task 1002291 (privacy spec D2): a hidden builder is "listed name-only", not
657
+ // dropped. Hide takes the record away; the name stays findable.
658
+ assert.deepEqual(r.json.name_only, [{ github_login: HIDDEN_LOGIN, display_name: 'Hidden', avatar_url: null }],
659
+ 'the hidden builder is listed by login, name and avatar — and nothing else');
660
+ assert.equal(engine.calls.length, 2, 'exactly TWO reads — the ranked tier and the name-only tier');
661
+ for (const c of engine.calls) {
662
+ assert.match(c.sql, /profile_state = 'active'/, 'and each carries the consent predicate — nothing re-enters unfiltered');
663
+ }
664
+ for (const s of [PROV_LOGIN, PRIVATE_LOGIN, '88817', '6613', '5519', '71713', '63127', '4409', '2287']) {
665
+ assert.ok(!r.text.includes(s), `the excluded builder, and the hidden builder's record, leak nothing — found "${s}"`);
648
666
  }
649
667
  });
650
668
 
@@ -0,0 +1,105 @@
1
+ // tests/project_admin_console_model.mjs — the project admin console model holds
2
+ // (task 1002813, R1 of the console wave; the ADR whose filename ends in ADR_SLUG).
3
+ //
4
+ // The ADR decides three things and then tells three build tasks what to build on
5
+ // them. Its decisions rest on facts about live code, and a decision whose premises
6
+ // drift quietly is worse than none, so the load-bearing ones are pinned here:
7
+ //
8
+ // D1 the hub relays and notifies; it never writes a project's admission row
9
+ // D2 an invite is a row the existing gate already reads — `kind` and
10
+ // `created_by` are descriptive and never enter the admission read
11
+ // D3 the admission verdict stays `access_request.review` at the archon floor,
12
+ // and `project.recruit`, once minted, takes exactly the shape the ADR fixes
13
+ //
14
+ // The ADR is found by its slug, not its number, because a parallel ADR can take
15
+ // the number first and force a renumber (tests/adr_renumber_integrity.mjs).
16
+ //
17
+ // Run: node --test --test-reporter=tap tests/project_admin_console_model.mjs
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { createRequire } from 'node:module';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const ADR_DIR = path.join(ROOT, 'docs', 'adr');
28
+ const ADR_SLUG = 'an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md';
29
+ const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
30
+ const { stripComments } = require(path.join(ROOT, 'scripts', 'gds', 'category-advisory-guard.js'));
31
+
32
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
33
+
34
+ function findAdr() {
35
+ const hits = fs.readdirSync(ADR_DIR).filter((f) => /^\d{4}-/.test(f) && f.endsWith(ADR_SLUG));
36
+ assert.equal(hits.length, 1, `exactly one ADR ends in ${ADR_SLUG}; found ${hits.length}`);
37
+ return hits[0];
38
+ }
39
+
40
+ function jsFilesUnder(dir) {
41
+ const out = [];
42
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ if (entry.name === 'node_modules' || entry.name === 'tests') continue;
44
+ const full = path.join(dir, entry.name);
45
+ if (entry.isDirectory()) out.push(...jsFilesUnder(full));
46
+ else if (entry.name.endsWith('.js')) out.push(full);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ test('the ADR exists, is indexed, and ADR 0141 names it as superseding its §5 invitations note', () => {
52
+ const file = findAdr();
53
+ const index = read('docs', 'adr', 'README.md');
54
+ assert.ok(index.includes(`](${file})`), 'docs/adr/README.md carries an index row linking the ADR');
55
+
56
+ const status = read('docs', 'adr', '0141-cross-site-identity-federated-hub-idp.md')
57
+ .split(/\r?\n/)
58
+ .find((l) => l.startsWith('- **Status:**'));
59
+ assert.ok(status, 'ADR 0141 keeps a Status line');
60
+ assert.ok(status.includes(file), 'ADR 0141 Status line links the superseding ADR');
61
+ assert.match(status, /§5/, 'the supersession is partial — it names §5, not the whole of ADR 0141');
62
+
63
+ const specHead = read('docs', 'specs', 'bongos-v1-project-admin-console.md').split(/\r?\n/)[0];
64
+ assert.ok(specHead.includes(file), 'the spike spec sends its reader to the ADR that narrowed it');
65
+ });
66
+
67
+ // Both admission reads: the gate's own (hasInvitedAccessRequest) and the state the
68
+ // "confirming access" waiting page polls (accessStateByLogin), which must agree with it.
69
+ test('D2: both admission reads look at status alone — an invite and an approved application admit identically', () => {
70
+ const src = read('src', 'bongos', 'db-kernel.js');
71
+ for (const fn of ['hasInvitedAccessRequest', 'accessStateByLogin']) {
72
+ const m = new RegExp(`async function ${fn}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src);
73
+ assert.ok(m, `${fn} is still an admission read in db-kernel.js`);
74
+ const body = m[1];
75
+ assert.match(body, /status\s*=\s*'invited'/, `${fn}: admission is status = invited`);
76
+ assert.doesNotMatch(body, /\bkind\b/, `${fn}: kind is descriptive; it must never decide admission`);
77
+ assert.doesNotMatch(body, /\bcreated_by\b/, `${fn}: created_by is audit data; it must never decide admission`);
78
+ }
79
+ });
80
+
81
+ test('D1: no hub (platform-identity) source writes access_requests — the hub relays, it never admits', () => {
82
+ const writers = [];
83
+ for (const f of jsFilesUnder(path.join(ROOT, 'modules', 'platform-identity'))) {
84
+ const code = stripComments(fs.readFileSync(f, 'utf8'));
85
+ if (/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+access_requests\b/i.test(code)) {
86
+ writers.push(path.relative(ROOT, f));
87
+ }
88
+ }
89
+ assert.deepEqual(writers, [], 'a project admits on its own instance; the hub reaches it only through a relay');
90
+ });
91
+
92
+ test('D3: the verdict stays access_request.review at archon, and project.recruit takes the fixed shape', () => {
93
+ const review = catalog.byKey('access_request.review');
94
+ assert.ok(review, 'access_request.review exists');
95
+ assert.equal(review.floor, 'archon', 'the admission verdict stays at the archon floor');
96
+
97
+ // R3 (task 1002815) mints the atom. Until then there is nothing to check; once
98
+ // it lands, this is the shape the ADR fixed, so a drifted mint reds here.
99
+ const recruit = catalog.byKey('project.recruit');
100
+ if (!recruit) return;
101
+ assert.equal(recruit.floor, 'archon', 'the per-project invite floor defaults to archon (ADR 0217 D1)');
102
+ assert.equal(recruit.system, false, 'grantable, and composable into a custom rank');
103
+ assert.ok(!recruit.substrate, 'enforced in the app, so a grant is never inert');
104
+ assert.ok(recruit.principal == null, 'held by ranks, never by a machine principal');
105
+ });