@bongos/core 1.24.1 → 1.24.3

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 (42) hide show
  1. package/.bongos-core.json +68 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
  8. package/docs/adr/README.md +1 -1
  9. package/docs/api/openapi.json +140 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +11 -6
  12. package/docs/copy-registry.json +63 -8
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +2 -1
  15. package/docs/page-readings.json +35 -26
  16. package/modules/economy/governor-docket.js +12 -6
  17. package/modules/government/access-routes.js +70 -0
  18. package/modules/government/access.js +88 -0
  19. package/modules/government/asks.js +5 -2
  20. package/modules/government/db.js +38 -0
  21. package/modules/government/docket.js +12 -2
  22. package/modules/government/migrations/government_036_access_holders.sql +33 -0
  23. package/modules/government/routes/government.js +9 -0
  24. package/modules/government/routing.js +11 -3
  25. package/modules/government/waiting.js +4 -0
  26. package/modules/hall-ui/public/blockers-by-person.js +2 -0
  27. package/modules/hall-ui/public/government-access.js +108 -0
  28. package/modules/hall-ui/public/government.html +21 -0
  29. package/modules/hall-ui/public/street-inside.js +1 -1
  30. package/modules/hall-ui/records/blockers-people.md +14 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +16 -0
  34. package/scripts/bongos/government-walk.js +3 -0
  35. package/src/bongos/route-rank-check.js +4 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/access_holders.mjs +214 -0
  38. package/tests/government_board_membership.mjs +4 -1
  39. package/tests/government_routes.mjs +1 -1
  40. package/tests/government_seed.mjs +1 -0
  41. package/tests/governor_docket.mjs +7 -3
  42. package/tests/hall_street_inside.mjs +2 -2
@@ -0,0 +1,214 @@
1
+ // tests/access_holders.mjs — who holds the keys OUTSIDE Bongos, and blockers that need
2
+ // them (task 1005000, goal 1000111).
3
+ //
4
+ // WHAT IT GUARDS (the task's done-when, one by one):
5
+ // 1. Only the owner can record a holder: the writes refuse anyone without
6
+ // government.manage; the read is the docket's atom.
7
+ // 2. An ask on access:<key> resolves to exactly the recorded holders, and to the
8
+ // owner when none is recorded.
9
+ // 3. Such a blocker shows under its holder as "needs repo-owner access".
10
+ // 4. No secret is stored: the table has no column a credential could live in.
11
+ // And the trap: it is a record of who to ask, never authority — no gate, rank check
12
+ // or permission atom reads it.
13
+ //
14
+ // Also: the vocabulary, the ask grammar (asks.SEAT) and both migration CHECKs agree.
15
+ // DB-free: the government db is stubbed by property. Run: node --test tests/access_holders.mjs
16
+
17
+ import { test, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import fs from 'node:fs';
20
+ import http from 'node:http';
21
+ import path from 'node:path';
22
+ import { createRequire } from 'node:module';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const gov = (f) => path.join(ROOT, 'modules', 'government', f);
28
+ const access = require(gov('access.js'));
29
+ const asks = require(gov('asks.js'));
30
+ const routing = require(gov('routing.js'));
31
+ const waiting = require(gov('waiting.js'));
32
+ const leads = require(gov('leads.js'));
33
+ const people = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'blockers-by-person.js'));
34
+ const page = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government-access.js'));
35
+ const SQL = fs.readFileSync(gov('migrations/government_036_access_holders.sql'), 'utf8');
36
+
37
+ const owner = leads.ownerOf([{ id: 1, github_login: 'lars', display_name: 'Lars' }]);
38
+ const KIM = { access_key: 'github-admin', builder_id: 5, github_login: 'kim', display_name: 'Kim', granted_at: '2026-10-29T00:00:00Z' };
39
+ const blocker = (id, waitsOn) => ({ id: `blockers:${id}`, source: 'blockers', building: 'planning', state: 'needs', title: `Blocker ${id}`, opened_at: '2026-10-29T00:00:00Z', ref: { type: 'blocker', id: String(id) }, ask: { question: 'Allow it?', waits_on: waitsOn, if_nobody_answers: 'It stays stuck.' } });
40
+
41
+ test('one vocabulary: access.js, the holders CHECK and the asks CHECK name the same keys', () => {
42
+ const holders = /access_key IN \(([^)]+)\)/.exec(SQL)[1].match(/'([^']+)'/g).map((s) => s.slice(1, -1));
43
+ assert.deepEqual(holders, access.KEYS);
44
+ const seat = new RegExp(/CHECK \(waits_on ~ '([^']+)'\)/.exec(SQL)[1]);
45
+ for (const s of ['owner', 'lead:planning', 'board:charter', 'committee:review.testers', ...access.KEYS.map((k) => `access:${k}`)]) {
46
+ assert.equal(seat.test(s), true, `${s}: the table takes it`);
47
+ assert.equal(asks.SEAT.test(s), true, `${s}: the module takes it`);
48
+ }
49
+ for (const s of ['access:nope', 'access:', 'access:github-admin.x', 'lead:nowhere']) {
50
+ assert.equal(seat.test(s), false, `${s}: the table refuses it`);
51
+ assert.equal(asks.SEAT.test(s), false, `${s}: the module refuses it`);
52
+ }
53
+ });
54
+
55
+ test('an ask can wait on an outside access, and reads as its holder', () => {
56
+ const ok = asks.checkAsk({
57
+ question: 'Allow the publish token to change CI workflow files?',
58
+ options: [{ key: 'yes', label: 'Add the scope', result: 'It lands on the next ship.' }, { key: 'no', label: 'Leave it', result: 'It stays stuck.' }],
59
+ recommend: { key: 'yes', why: 'One setting fixes it.' },
60
+ waits_on: 'access:github-admin',
61
+ if_nobody_answers: 'It stays stuck.',
62
+ });
63
+ assert.equal(ok.ok, true, JSON.stringify(ok.errors || []));
64
+ assert.equal(asks.seatWords('access:github-admin'), 'the repo owner');
65
+ assert.equal(asks.checkAsk({ ...ok.ask, waits_on: 'access:nope' }).ok, false);
66
+ });
67
+
68
+ test('access:<key> resolves to exactly the recorded holders, and to the owner when none is', () => {
69
+ const seats = { leads: [], owner, holders: {}, access: access.holdersByKey([KIM]) };
70
+ const held = routing.resolveSeat('access:github-admin', seats);
71
+ assert.deepEqual([held.seat, held.label, held.builder_ids, held.fell_to_owner], ['access:github-admin', 'the repo owner', ['5'], false]);
72
+ const unheld = routing.resolveSeat('access:dns', seats);
73
+ assert.deepEqual([unheld.seat, unheld.builder_ids, unheld.fell_to_owner], ['owner', ['1'], true]);
74
+ const route = routing.routeMatter(blocker(1, 'access:github-admin'), '5', seats, Date.now());
75
+ assert.deepEqual([route.yours, route.people], [true, [{ id: '5', login: 'kim', name: 'Kim' }]]);
76
+ assert.equal(routing.routeMatter(blocker(1, 'access:github-admin'), '1', seats, Date.now()).yours, false, 'not the owner\'s while a holder is recorded');
77
+ // no access read at all (an instance that has not migrated): every access seat falls to the owner
78
+ assert.deepEqual(routing.resolveSeat('access:github-admin', { leads: [], owner, holders: {} }).builder_ids, ['1']);
79
+ });
80
+
81
+ test('a blocker on an access seat shows under its holder as "needs repo-owner access"', () => {
82
+ const g = waiting.groupByPerson([blocker(7, 'access:github-admin'), blocker(8, 'access:payments')], { leads: [], owner, holders: {}, access: access.holdersByKey([KIM]) });
83
+ const kim = g.people.find((p) => p.person.name === 'Kim');
84
+ assert.deepEqual(kim.items.map((i) => [i.id, i.needs]), [['7', 'needs repo-owner access']]);
85
+ const lars = g.people.find((p) => p.person.name === 'Lars');
86
+ assert.deepEqual(lars.items.map((i) => [i.id, i.needs, i.fell_to_owner]), [['8', 'needs access to payments', true]]);
87
+ const esc = (s) => String(s);
88
+ const html = people.peopleHtml({ available: true, ...g }, { esc, fmtDate: (d) => d.slice(0, 10) });
89
+ assert.match(html, /needs repo-owner access · filed/);
90
+ assert.match(html, /needs access to payments, and nobody is recorded holding it/);
91
+ assert.equal(people.asWords({ as: 'the owner', asked: 'owner' }), 'as the owner', 'an ordinary seat reads as before');
92
+ });
93
+
94
+ test('no secret is stored, and nothing that gates reads it', () => {
95
+ const cols = /CREATE TABLE IF NOT EXISTS government_access_holders \(([\s\S]*?)\n\);/.exec(SQL)[1]
96
+ .split('\n').map((l) => /^\s+([a-z_]+)\s/.exec(l)).filter(Boolean).map((m) => m[1]).filter((c) => c !== 'PRIMARY');
97
+ assert.deepEqual(cols, ['access_key', 'builder_id', 'granted_by', 'granted_at']);
98
+ for (const f of ['src/bongos/auth.js', 'modules/government/resolver.js', 'modules/government/catalog.js', 'src/bongos/act-alone.js']) {
99
+ const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
100
+ assert.doesNotMatch(src, /access_holders|listAccessHolders|require\(['"][./]*access['"]\)/, `${f} never reads access holders`);
101
+ }
102
+ const catalog = require(gov('catalog.js'));
103
+ const keys = catalog.PERMISSIONS.map((p) => p.key || p);
104
+ assert.ok(keys.length > 50, 'the real catalog, not an empty list');
105
+ for (const k of access.KEYS) assert.ok(!keys.some((p) => String(p).includes(k)), `${k} is not a permission atom`);
106
+ });
107
+
108
+ test('the access view: each access, what it is, its holders, or that it falls to the owner', () => {
109
+ const v = access.accessView([KIM, { ...KIM, access_key: 'not-a-key' }]);
110
+ assert.deepEqual(v.map((a) => a.key), access.KEYS);
111
+ const gh = v.find((a) => a.key === 'github-admin');
112
+ assert.deepEqual([gh.seat, gh.holders.map((h) => h.name), gh.falls_to_owner], ['access:github-admin', ['Kim'], false]);
113
+ assert.equal(v.find((a) => a.key === 'dns').falls_to_owner, true);
114
+ const html = page.accessHtml(v, (s) => String(s).replace(/</g, '&lt;'));
115
+ assert.match(html, /Repo owner/);
116
+ assert.match(html, /data-act="revoke" data-key="github-admin" data-id="5"/);
117
+ assert.match(html, /Nobody recorded — it falls to the owner/);
118
+ // the picker and the list say the same name, capitalised the same way
119
+ const opts = page.optionsHtml(v, (s) => String(s));
120
+ for (const a of v) {
121
+ assert.match(opts, new RegExp('<option value="' + a.key + '">' + a.title + '</option>'));
122
+ assert.ok(html.includes('>' + a.title + '<'), a.key + ': the list shows the same name');
123
+ assert.match(a.title, /^[A-Z]/);
124
+ }
125
+ });
126
+
127
+ test('every access reads as plain words when a blocker needs it — no "access access"', () => {
128
+ for (const k of access.KEYS) {
129
+ const w = access.needsWords(k);
130
+ assert.match(w, /^needs [a-z]/, k);
131
+ assert.doesNotMatch(w, /access access|-access access/, k + ': ' + w);
132
+ }
133
+ assert.equal(access.needsWords('github-admin'), 'needs repo-owner access');
134
+ });
135
+
136
+ test('the City keeps routing when the access read fails or is missing: every access seat falls to the owner', async () => {
137
+ const docket = require(gov('docket.js'));
138
+ const db = require(gov('db.js'));
139
+ const real = { leads: db.listLeads, archons: db.listActiveArchons, access: db.listAccessHolders };
140
+ db.listLeads = async () => [];
141
+ db.listActiveArchons = async () => [{ id: 1, github_login: 'lars', display_name: 'Lars' }];
142
+ try {
143
+ db.listAccessHolders = async () => { throw new Error('relation government_access_holders does not exist'); };
144
+ const failed = await docket.loadSeats({ holders: {} });
145
+ assert.ok(failed, 'seats still read');
146
+ assert.deepEqual(routing.resolveSeat('access:github-admin', failed).builder_ids, ['1'], 'falls to the owner');
147
+ assert.deepEqual(failed.access['github-admin'], []);
148
+ db.listAccessHolders = undefined;
149
+ const missing = await docket.loadSeats({ holders: {} });
150
+ assert.ok(missing, 'a db layer without the read still routes');
151
+ assert.deepEqual(routing.resolveSeat('access:dns', missing).builder_ids, ['1']);
152
+ db.listAccessHolders = async () => [KIM];
153
+ assert.deepEqual(routing.resolveSeat('access:github-admin', await docket.loadSeats({ holders: {} })).builder_ids, ['5']);
154
+ } finally {
155
+ db.listLeads = real.leads; db.listActiveArchons = real.archons; db.listAccessHolders = real.access;
156
+ }
157
+ });
158
+
159
+ // --- the routes, through the real router and the real permission gate ---------------
160
+
161
+ const express = require('express');
162
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
163
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
164
+ const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
165
+ const govDb = require(gov('db.js'));
166
+
167
+ const rows = [];
168
+ const REAL = { list: govDb.listAccessHolders, grant: govDb.grantAccess, revoke: govDb.revokeAccess, who: govDb.getActiveBuilder };
169
+ govDb.listAccessHolders = async () => rows.slice();
170
+ govDb.grantAccess = async (k, id, by) => { if (rows.some((r) => r.access_key === k && r.builder_id === id)) return false; rows.push({ access_key: k, builder_id: id, granted_by: by, github_login: 'kim', display_name: 'Kim' }); return true; };
171
+ govDb.revokeAccess = async (k, id) => { const i = rows.findIndex((r) => r.access_key === k && r.builder_id === id); if (i < 0) return false; rows.splice(i, 1); return true; };
172
+ govDb.getActiveBuilder = async (id) => (id === 5 ? { id: 5, github_login: 'kim', display_name: 'Kim' } : null);
173
+ const realRequireBuilder = api.requireBuilder;
174
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon' }; next(); };
175
+ function grant(keys) { seams._reset(); seams.registerProvider('government', { resolveBuilderPermissions: async () => keys }); }
176
+ grant(['government.manage', 'board.vote.cast']);
177
+ const buildRouter = require(gov('routes/government.js'));
178
+ const app = express();
179
+ app.use(express.json());
180
+ app.use(attachFail);
181
+ app.use(buildRouter());
182
+ const server = http.createServer(app);
183
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
184
+ const BASE = `http://127.0.0.1:${server.address().port}`;
185
+ after(() => {
186
+ server.close();
187
+ api.requireBuilder = realRequireBuilder;
188
+ govDb.listAccessHolders = REAL.list; govDb.grantAccess = REAL.grant; govDb.revokeAccess = REAL.revoke; govDb.getActiveBuilder = REAL.who;
189
+ seams._reset();
190
+ });
191
+ const call = async (method, p, body) => {
192
+ const res = await fetch(BASE + p, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
193
+ return { status: res.status, json: await res.json().catch(() => null) };
194
+ };
195
+
196
+ test('only the owner records a holder; everyone who can open the City can read who holds what', async () => {
197
+ grant(['board.vote.cast']);
198
+ assert.equal((await call('POST', '/government/access/github-admin/holders', { builder_id: 5 })).status, 403, 'a voter cannot record');
199
+ assert.equal((await call('DELETE', '/government/access/github-admin/holders/5')).status, 403, 'nor remove');
200
+ assert.equal((await call('GET', '/government/access')).status, 200, 'but can read');
201
+ grant(['government.manage', 'board.vote.cast']);
202
+ const added = await call('POST', '/government/access/github-admin/holders', { builder_id: 5 });
203
+ assert.deepEqual([added.status, added.json.added], [201, true]);
204
+ assert.equal(rows[0].granted_by, 1, 'who recorded it rides the row');
205
+ assert.deepEqual([(await call('POST', '/government/access/github-admin/holders', { builder_id: 5 })).status], [200], 'idempotent');
206
+ const read = await call('GET', '/government/access');
207
+ assert.deepEqual(read.json.access.find((a) => a.key === 'github-admin').holders.map((h) => h.id), ['5']);
208
+ assert.equal((await call('POST', '/government/access/nope/holders', { builder_id: 5 })).status, 400, 'an unknown access');
209
+ assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 99 })).status, 404, 'an unknown builder');
210
+ assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 'x' })).status, 400);
211
+ assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 5, token: 'ghp_x' })).status, 400, 'strict: an unknown field (a token, say) is refused, never stored');
212
+ const removed = await call('DELETE', '/government/access/github-admin/holders/5');
213
+ assert.deepEqual([removed.status, removed.json.removed, rows.length], [200, true, 0]);
214
+ });
@@ -256,7 +256,10 @@ test('the membership queries exclude machines and the deactivated — structural
256
256
  const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
257
257
  const start = src.indexOf('getActiveBuildersWithRank');
258
258
  assert.ok(start > -1);
259
- const block = src.slice(start);
259
+ // bounded to the two membership reads: a later query filtering status for its own
260
+ // reasons (task 1005000's access holders) is not one of them
261
+ const end = src.indexOf('\nasync function ', src.indexOf('async function builderHoldsRankActive') + 1);
262
+ const block = src.slice(start, end > start ? end : undefined);
260
263
  assert.equal((block.match(/b\.status = 'active'/g) || []).length, 2, 'both reads filter status');
261
264
  assert.equal((block.match(/b\.system_role IS NULL/g) || []).length, 2, 'both reads exclude principals');
262
265
  });
@@ -104,7 +104,7 @@ test('every /governance route gates via REAL middleware on exactly its PINNED at
104
104
  // task 1004712: the treasury is read, and its pots and borrows decided, where the room is (the holder and the seat are the walls).
105
105
  // task 1004715: the strictness presets are read where the law book is.
106
106
  // task 1004716: the counts are read where the docket they count is.
107
- const boardFamily = (key) => key.includes('/board/') || key.includes('/government/asks') || key.endsWith('/government/constitution') || key.endsWith('/government/docket') || key.includes('/government/blockers/') || key.endsWith('/government/counts') || key.endsWith('/government/laws') || key.endsWith('/government/leads') || key.endsWith('/government/buildouts') || key.includes('/government/treasury') || key.endsWith('/government/presets');
107
+ const boardFamily = (key) => key.includes('/board/') || key.includes('/government/asks') || key.endsWith('/government/constitution') || key.endsWith('/government/docket') || key.includes('/government/blockers/') || key.endsWith('/government/access') || key.endsWith('/government/counts') || key.endsWith('/government/laws') || key.endsWith('/government/leads') || key.endsWith('/government/buildouts') || key.includes('/government/treasury') || key.endsWith('/government/presets');
108
108
  for (const r of classified.filter((x) => !boardFamily(x.key))) {
109
109
  assert.deepEqual(r.perms, ['government.manage'], `${r.key}: the admin surface gates on the manage atom`);
110
110
  }
@@ -52,6 +52,7 @@ const NON_GRANT_MIGRATIONS = new Set([
52
52
  'government_030_buildouts.sql', // a Lead's build-outs, on the record at once or by a passed vote (task 1004711)
53
53
  'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
54
54
  'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
55
+ 'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
55
56
  ]);
56
57
 
57
58
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -678,10 +678,14 @@ test('automation, treasury and disputes floors read their own ledgers, and say s
678
678
  const rep = autonomyDocket.reportsFloor([{ routine: 'builder', decision: 'go', created_at: '2026-09-30T00:00:00Z' }, { routine: 'idea-triage', decision: 'no-go', reason: 'nothing open', created_at: '2026-09-01T00:00:00Z' }], NOW);
679
679
  assert.deepEqual([rep.count, rep.lines.map((l) => l[0])], ['1', ['The builder went ahead.', 'Idea sorting held back: nothing open.']], 'said in words, not the gate\'s keys');
680
680
  const months = [{ month: '2026-10', rewards_credits: 1180, rewarded_builders: 6, grading_usd: 310.4, hosting_usd: 120 }, { month: '2026-09', rewards_credits: 4920, rewarded_builders: 8, grading_usd: 0, hosting_usd: 160 }];
681
- assert.deepEqual([economyDocket.rewardsFloor(months, NOW).count, economyDocket.rewardsFloor(months, NOW).line], ['$1,180', '$1,180 this month · 6 builders']);
681
+ assert.deepEqual([economyDocket.rewardsFloor(months, NOW).count, economyDocket.rewardsFloor(months, NOW).line], ['1,180 credits', '1,180 credits this month · 6 builders']);
682
682
  assert.deepEqual(economyDocket.hostingFloor(months, NOW).lines[1], ['$160 in sep.', 'last month']);
683
683
  assert.equal(economyDocket.gradingFloor([], NOW).count, '$0', 'a month with no rows is a real zero, read from the ledger');
684
- assert.deepEqual(economyDocket.treasuryHistory(months).map((h) => h.title), ['Paid $1,180 to 6 builders', 'AI grading cost $310', 'Hosting cost $120', 'Paid $4,920 to 8 builders', 'Hosting cost $160']);
684
+ assert.deepEqual(economyDocket.treasuryHistory(months).map((h) => h.title), ['6 builders earned 1,180 credits', 'AI grading cost $310', 'Hosting cost $120', '8 builders earned 4,920 credits', 'Hosting cost $160']);
685
+ // Credits are a contribution meter, not money (ADR 0385/0387, task 1005006): no $ sign, no dollar peg, no "paid".
686
+ const rewardWords = JSON.stringify([economyDocket.rewardsFloor(months, NOW), economyDocket.treasuryHistory(months).filter((h) => h.outcome === 'rewards')]);
687
+ assert.doesNotMatch(rewardWords, /\$|dollar|\bpaid\b/i, 'the rewards line counts credits, never money');
688
+ assert.equal(economyDocket.rewardsFloor([{ month: '2026-10', rewards_credits: 1, rewarded_builders: 1 }], NOW).count, '1 credit');
685
689
  assert.deepEqual(reportsRoute.rulingHistory([{ id: 1, title: 'sign-in limits', status: 'fixed', fixed_at: '2026-08-02T00:00:00Z' }, { id: 2, title: 'open one', status: 'open' }, { id: 3, title: 'undated', status: 'wontfix' }]).map((h) => [h.title, h.outcome]), [['Security report: sign-in limits', 'fixed']]);
686
690
  });
687
691
 
@@ -706,7 +710,7 @@ test('one ledger read per docket request: the treasury floors and record share i
706
710
  for (const f of ['modules/economy/routes/cost.js', 'modules/autonomy/routes/autonomy.js']) require(path.join(ROOT, f))();
707
711
  const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'autonomy.precheck'], now: NOW + 1 });
708
712
  const by = Object.fromEntries(d.floors.map((f) => [f.key, f]));
709
- assert.deepEqual([by['treasury.rewards'].count, by['treasury.hosting'].count, by['automation.spending_limit'].status], ['$10', '$3', 'live']);
713
+ assert.deepEqual([by['treasury.rewards'].count, by['treasury.hosting'].count, by['automation.spending_limit'].status], ['10 credits', '$3', 'live']);
710
714
  assert.equal(reads, 1, 'one treasury read for three floors and the record');
711
715
  assert.equal(spends, 1, 'one spend read for the money reading and the spending limit');
712
716
  assert.equal(fences, 1, 'one fence read for the switch, allowed goals and the switch\'s record');
@@ -303,12 +303,12 @@ test('every floor of every building is bound to a docket feed: none is left unwi
303
303
 
304
304
  test('a live floor shows exactly what the docket says: its count, line and lines; hot is being decided', () => {
305
305
  const d = docket([], { floors: [
306
- floorRow('treasury.rewards', { count: '$2,400', line: '$2,400 this month · 6 builders', lines: [['Paid to builders for shipped work.', 'each ship']] }),
306
+ floorRow('treasury.rewards', { count: '2,400 credits', line: '2,400 credits this month · 6 builders', lines: [['Credits earned for shipped work.', 'each ship']] }),
307
307
  floorRow('automation.allowed_goals', { count: '2', line: '2 goals it may build', lines: [['goal 7', 'first in line'], ['goal 99', 'since sep 2']] }),
308
308
  floorRow('charter.founding', { count: '', line: '1 of 2 founding steps passed', lines: [['The charter.', 'passed']], hot: true }),
309
309
  ] });
310
310
  const t = I.civicInfo('treasury', { docket: d, now: NOW }).floors.find((f) => f.name === 'Builder rewards');
311
- assert.deepEqual([t.count, t.line, t.status, t.lines], ['$2,400', '$2,400 this month · 6 builders', 'ok', [['Paid to builders for shipped work.', 'each ship']]]);
311
+ assert.deepEqual([t.count, t.line, t.status, t.lines], ['2,400 credits', '2,400 credits this month · 6 builders', 'ok', [['Credits earned for shipped work.', 'each ship']]]);
312
312
  const a = I.civicInfo('automation', { docket: d, goals: [goal(7, 'open', 'Onboarding')], now: NOW }).floors.find((f) => f.name === 'Allowed goals');
313
313
  assert.deepEqual(a.lines, [['Onboarding', 'first in line'], ['goal 99', 'since sep 2']], 'a goal it knows is lettered by title; one it does not keeps its id');
314
314
  const c = I.buildingModel({ key: 'charter', docket: d, mode: 'dark', now: NOW });