@bongos/core 1.21.18 → 1.21.20
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.
- package/.bongos-core.json +60 -35
- package/docs/copy-inventory.md +5 -5
- package/docs/copy-registry.json +6 -6
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +196 -197
- package/modules/autonomy/governor-docket.js +118 -4
- package/modules/economy/cost.js +36 -0
- package/modules/economy/governor-docket.js +81 -0
- package/modules/economy/routes/cost.js +2 -0
- package/modules/government/docket-floors.js +222 -0
- package/modules/government/docket-vocab.js +20 -0
- package/modules/government/docket.js +186 -5
- package/modules/hall-ui/public/street-amend.js +240 -0
- package/modules/hall-ui/public/street-card.js +4 -1
- package/modules/hall-ui/public/street-inside.js +99 -43
- package/modules/hall-ui/public/street.css +8 -0
- package/modules/hall-ui/public/street.html +5 -3
- package/modules/hall-ui/public/street.js +164 -11
- package/modules/hall-ui/public/street.states.json +12 -5
- package/modules/ideas/governor-docket.js +42 -1
- package/modules/lifecycle/governor-docket.js +197 -2
- package/modules/security/routes/reports.js +16 -0
- package/modules/security/security.js +5 -2
- package/modules/ui-design/kit/fixtures/government-constitution-rules.json +184 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/governor_docket.mjs +186 -2
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_street_amend.mjs +336 -0
- package/tests/hall_street_inside.mjs +101 -5
- package/tests/rank_tier_single_source.mjs +5 -0
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
//
|
|
30
30
|
// { reading: 'flow', gate: …, read: async (ctx) => { figure, line, value, hot } }
|
|
31
31
|
//
|
|
32
|
+
// { floor: 'release.live_month', gate: …, // one of docket-vocab FLOORS
|
|
33
|
+
// read: async (ctx) => { count, line, lines: [[text, meta]], hot } }
|
|
34
|
+
//
|
|
35
|
+
// { history: 'releases', building: 'release', gate: …, // the record drawers
|
|
36
|
+
// read: async (ctx) => [{ id, title, at, outcome }, …] }
|
|
37
|
+
//
|
|
38
|
+
// A FLOOR (task 1004527) is a state rather than a queue — who sits, what the
|
|
39
|
+
// month cost, what went live — read by the module that owns it, under the same
|
|
40
|
+
// atom as its own route. HISTORY is what closed: rulings, amendments, releases,
|
|
41
|
+
// sign-offs, payouts; the building interior files it into drawers by month.
|
|
42
|
+
//
|
|
32
43
|
// `ctx` is { builderId, rank, has(permission) } — `has` answers from the
|
|
33
44
|
// viewer's permission set resolved ONCE for this request, server-side and
|
|
34
45
|
// uncached (ADR 0016). A contribution with no recognisable gate is DROPPED, never
|
|
@@ -61,9 +72,15 @@ const db = require('./db');
|
|
|
61
72
|
const { resolveBoardMembers } = require('./board-membership');
|
|
62
73
|
const resolver = require('./resolver');
|
|
63
74
|
const decisionRules = require('./decision-rules');
|
|
75
|
+
const docketFloors = require('./docket-floors');
|
|
76
|
+
const governmentConfig = require('./config');
|
|
64
77
|
|
|
65
78
|
const log = api.logger('government');
|
|
66
79
|
|
|
80
|
+
// This module's own floors are answered under the docket route's own atom (the
|
|
81
|
+
// same one GET /government/constitution and the genesis stages require).
|
|
82
|
+
const GOVERNMENT_GATE = Object.freeze({ permission: 'board.vote.cast' });
|
|
83
|
+
|
|
67
84
|
const CONTRIBUTION_POINT = 'governor.docket';
|
|
68
85
|
|
|
69
86
|
// Every source the reference design names, with the building it stands in. The
|
|
@@ -94,6 +111,13 @@ const COMING_SOURCES = Object.freeze([
|
|
|
94
111
|
reason: 'The project has no budget or spending votes recorded yet.' }),
|
|
95
112
|
]);
|
|
96
113
|
|
|
114
|
+
// Floors the design names whose engine does not exist yet. Drawn, and say why.
|
|
115
|
+
const COMING_FLOORS = Object.freeze([
|
|
116
|
+
Object.freeze({ floor: 'charter.purpose', reason: 'The charter has no written purpose yet. A section for it is coming.' }),
|
|
117
|
+
]);
|
|
118
|
+
const MAX_FLOOR_LINES = 6;
|
|
119
|
+
const MAX_HISTORY_PER_SOURCE = 60;
|
|
120
|
+
|
|
97
121
|
|
|
98
122
|
// A source read that has not answered by now is reported unavailable rather than
|
|
99
123
|
// holding the whole city hostage (one source calls GitHub, one the npm registry).
|
|
@@ -151,6 +175,31 @@ function normalizeMatter(raw, { source, building }) {
|
|
|
151
175
|
};
|
|
152
176
|
}
|
|
153
177
|
|
|
178
|
+
// One floor as a contributor handed it → { count, line, lines, hot }. Pure.
|
|
179
|
+
// Anything that is not a plain object is not a floor (→ null, read as
|
|
180
|
+
// unavailable); a count is kept only as the short figure it was given.
|
|
181
|
+
function normalizeFloor(raw) {
|
|
182
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
183
|
+
const str = (v, n) => (v == null ? '' : String(v).slice(0, n));
|
|
184
|
+
const count = raw.count == null || raw.count === '' ? '' : str(raw.count, 12);
|
|
185
|
+
const lines = (Array.isArray(raw.lines) ? raw.lines : [])
|
|
186
|
+
.filter((l) => Array.isArray(l) && l[0] != null && String(l[0]).trim())
|
|
187
|
+
.slice(0, MAX_FLOOR_LINES)
|
|
188
|
+
.map((l) => [str(l[0], 200), str(l[1], 40)]);
|
|
189
|
+
return { count, line: str(raw.line, 90) || null, lines, hot: raw.hot === true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// One closed thing for the record → the docket's one shape. Pure. A row with no
|
|
193
|
+
// title or no readable date is dropped: an undated record is not a record.
|
|
194
|
+
function normalizeHistory(raw, { source, building }) {
|
|
195
|
+
if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
|
|
196
|
+
const at = iso(raw.at);
|
|
197
|
+
if (!at) return null;
|
|
198
|
+
const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
|
|
199
|
+
if (!vocab.BUILDING_KEYS.includes(b)) return null;
|
|
200
|
+
return { id: `${source}:${raw.id}`, source, building: b, title: String(raw.title).slice(0, 200), at, outcome: raw.outcome ? String(raw.outcome).slice(0, 40) : null };
|
|
201
|
+
}
|
|
202
|
+
|
|
154
203
|
// Does this viewer pass this gate? Fail-closed: anything unrecognised is false.
|
|
155
204
|
function gateAllows(gate, ctx) {
|
|
156
205
|
if (!gate || typeof gate !== 'object') return false;
|
|
@@ -346,12 +395,16 @@ function withTimeout(promise, ms) {
|
|
|
346
395
|
function registeredContributions() {
|
|
347
396
|
const sources = new Map();
|
|
348
397
|
const readings = new Map();
|
|
398
|
+
const floors = new Map();
|
|
399
|
+
const histories = new Map();
|
|
349
400
|
for (const c of api.contributions(CONTRIBUTION_POINT)) {
|
|
350
401
|
if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
|
|
351
402
|
if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
|
|
352
403
|
else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
|
|
404
|
+
else if (typeof c.floor === 'string' && c.floor) floors.set(c.floor, c);
|
|
405
|
+
else if (typeof c.history === 'string' && c.history && vocab.BUILDING_KEYS.includes(c.building)) histories.set(c.history, c);
|
|
353
406
|
}
|
|
354
|
-
return { sources, readings };
|
|
407
|
+
return { sources, readings, floors, histories };
|
|
355
408
|
}
|
|
356
409
|
|
|
357
410
|
// ── the collector ────────────────────────────────────────────────────────────
|
|
@@ -362,13 +415,22 @@ function registeredContributions() {
|
|
|
362
415
|
// `decisions` is the resolved decision-rules table (board.decisionRulesView), or
|
|
363
416
|
// null when it could not be read — each building's `decision` is then null
|
|
364
417
|
// rather than a guess.
|
|
365
|
-
|
|
418
|
+
// `government` is what docketFor() read once for this module's own floors and
|
|
419
|
+
// record (loadGovernment below); null leaves those floors `unavailable`.
|
|
420
|
+
async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS, decisions = null, government = null } = {}) {
|
|
366
421
|
const held = new Set(permissions || []);
|
|
367
422
|
const ctx = Object.freeze({
|
|
368
423
|
builderId: String(viewer.id),
|
|
369
424
|
rank: viewer.rank || null,
|
|
370
425
|
has: (p) => held.has(p),
|
|
371
426
|
});
|
|
427
|
+
// A per-request memo every read may use, so two floors that need the same rows
|
|
428
|
+
// share one query (task 1004527). Scoped to this call: nothing outlives it.
|
|
429
|
+
const memo = new Map();
|
|
430
|
+
const once = (key, fn) => {
|
|
431
|
+
if (!memo.has(key)) memo.set(key, Promise.resolve().then(fn));
|
|
432
|
+
return memo.get(key);
|
|
433
|
+
};
|
|
372
434
|
const sources = new Map(contributions.sources);
|
|
373
435
|
sources.set('board', BOARD_SOURCE); // the board is read here, never contributed
|
|
374
436
|
const comingKeys = new Set(COMING_SOURCES.map((c) => c.source));
|
|
@@ -392,7 +454,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
392
454
|
}
|
|
393
455
|
if (!gateAllows(c.gate, ctx)) { row.status = 'not_yours'; return; }
|
|
394
456
|
try {
|
|
395
|
-
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
|
|
457
|
+
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now, once })), timeoutMs);
|
|
396
458
|
const list = (Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source: decl.source, building: c.building || decl.building })).filter(Boolean);
|
|
397
459
|
row.status = 'live';
|
|
398
460
|
row.count = list.length;
|
|
@@ -421,7 +483,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
421
483
|
if (!impl) return { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
|
|
422
484
|
if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
|
|
423
485
|
try {
|
|
424
|
-
const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters })), timeoutMs);
|
|
486
|
+
const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters, once })), timeoutMs);
|
|
425
487
|
if (!r) return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
|
|
426
488
|
return {
|
|
427
489
|
...base,
|
|
@@ -437,17 +499,83 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
437
499
|
}
|
|
438
500
|
}));
|
|
439
501
|
|
|
502
|
+
// Floors (task 1004527): contributed, this module's own (answered from
|
|
503
|
+
// `government`, under the docket's own atom), and Ideas to ratify (from the
|
|
504
|
+
// board's matters, so it can never disagree with the pins).
|
|
505
|
+
const govAllowed = gateAllows(GOVERNMENT_GATE, ctx);
|
|
506
|
+
const comingFloors = new Map(COMING_FLOORS.map((c) => [c.floor, c.reason]));
|
|
507
|
+
const floorImpls = new Map(contributions.floors || []);
|
|
508
|
+
const boardRow = sourceStatus.find((s) => s.source === 'board');
|
|
509
|
+
const floorsP = Promise.all(vocab.FLOORS.map(async (decl) => {
|
|
510
|
+
const base = { key: decl.key, building: decl.building, status: null, count: '', line: null, lines: [], hot: false, reason: null };
|
|
511
|
+
const live = (f) => (f ? { ...base, status: 'live', ...f } : { ...base, status: 'unavailable', reason: 'It could not be read just now.' });
|
|
512
|
+
if (decl.key === 'planning.ideas') {
|
|
513
|
+
if (!boardRow || boardRow.status !== 'live') return { ...base, status: boardRow ? boardRow.status : 'unavailable', reason: boardRow ? boardRow.reason : null };
|
|
514
|
+
return live(normalizeFloor(docketFloors.ideasFloor(matters)));
|
|
515
|
+
}
|
|
516
|
+
const own = docketFloors.BUILDERS[decl.key];
|
|
517
|
+
if (own) {
|
|
518
|
+
if (!govAllowed) return { ...base, status: 'not_yours' };
|
|
519
|
+
let f = null;
|
|
520
|
+
try { f = government ? normalizeFloor(own(government)) : null; } catch (err) { log.warn({ floor: decl.key, err: err && err.message }, 'governor docket floor failed'); }
|
|
521
|
+
return live(f);
|
|
522
|
+
}
|
|
523
|
+
const impl = floorImpls.get(decl.key);
|
|
524
|
+
if (!impl) {
|
|
525
|
+
return comingFloors.has(decl.key)
|
|
526
|
+
? { ...base, status: 'coming', reason: comingFloors.get(decl.key) }
|
|
527
|
+
: { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
|
|
528
|
+
}
|
|
529
|
+
if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
|
|
530
|
+
try {
|
|
531
|
+
return live(normalizeFloor(await withTimeout(Promise.resolve(impl.read(ctx, { now, once })), timeoutMs)));
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log.warn({ floor: decl.key, err: err && err.message }, 'governor docket floor failed');
|
|
534
|
+
return { ...base, status: 'unavailable', reason: err && err.message === 'timeout' ? 'It did not answer in time.' : 'It could not be read just now.' };
|
|
535
|
+
}
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
// The record: what closed, from every module that keeps it, newest first.
|
|
539
|
+
const history = [];
|
|
540
|
+
const historySources = [];
|
|
541
|
+
if (govAllowed && government) {
|
|
542
|
+
history.push(...docketFloors.governmentHistory(government).map((h) => normalizeHistory(h, { source: 'government', building: h.building })).filter(Boolean));
|
|
543
|
+
historySources.push({ source: 'government', status: 'live' });
|
|
544
|
+
}
|
|
545
|
+
const historyP = Promise.all([...(contributions.histories || new Map())].map(async ([key, c]) => {
|
|
546
|
+
if (!gateAllows(c.gate, ctx)) { historySources.push({ source: key, building: c.building, status: 'not_yours' }); return; }
|
|
547
|
+
try {
|
|
548
|
+
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now, once })), timeoutMs);
|
|
549
|
+
const list = (Array.isArray(raw) ? raw : []).slice(0, MAX_HISTORY_PER_SOURCE).map((h) => normalizeHistory(h, { source: key, building: c.building })).filter(Boolean);
|
|
550
|
+
history.push(...list);
|
|
551
|
+
historySources.push({ source: key, building: c.building, status: 'live' });
|
|
552
|
+
} catch (err) {
|
|
553
|
+
log.warn({ history: key, err: err && err.message }, 'governor docket history failed');
|
|
554
|
+
historySources.push({ source: key, building: c.building, status: 'unavailable' });
|
|
555
|
+
}
|
|
556
|
+
}));
|
|
557
|
+
const [floors] = await Promise.all([floorsP, historyP]);
|
|
558
|
+
history.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : a.id < b.id ? -1 : 1));
|
|
559
|
+
historySources.sort((a, b) => (a.source < b.source ? -1 : 1));
|
|
560
|
+
|
|
440
561
|
const counts = countStates(matters);
|
|
441
562
|
const decisionBy = new Map((Array.isArray(decisions) ? decisions : []).filter(Boolean).map((d) => [d.area, d]));
|
|
563
|
+
const holdersBy = govAllowed && government && government.holders ? government.holders : {};
|
|
442
564
|
const buildings = vocab.BUILDINGS.map((b) => {
|
|
443
565
|
const here = matters.filter((m) => m.building === b.key);
|
|
444
566
|
const d = decisionBy.get(b.key) || null;
|
|
567
|
+
const held = Array.isArray(holdersBy[b.key]) ? holdersBy[b.key] : null;
|
|
445
568
|
return {
|
|
446
569
|
key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id),
|
|
447
570
|
decision: d ? {
|
|
448
571
|
who: d.who, rule: d.rule, rule_words: d.rule_words, decided_by: d.decided_by,
|
|
449
572
|
as_today: d.as_today, sitting: d.sitting, set_by_item_id: d.set_by_item_id,
|
|
450
573
|
} : null,
|
|
574
|
+
// Who holds the seat now, as the row seats them (task 1004527): the board's
|
|
575
|
+
// members, a sitting's predicate, or the holders of the fixed gate's atom.
|
|
576
|
+
// null when it could not be resolved — never an invented seat.
|
|
577
|
+
holders: held ? held.slice(0, 12).map((h) => person({ id: h.builder_id ?? h.id, login: h.github_login ?? h.login, name: h.display_name ?? h.name })).filter(Boolean) : null,
|
|
578
|
+
holder_count: held ? held.length : null,
|
|
451
579
|
};
|
|
452
580
|
});
|
|
453
581
|
return {
|
|
@@ -459,6 +587,9 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
459
587
|
buildings,
|
|
460
588
|
sources: sourceStatus,
|
|
461
589
|
readings,
|
|
590
|
+
floors,
|
|
591
|
+
history,
|
|
592
|
+
history_sources: historySources,
|
|
462
593
|
vocabulary: {
|
|
463
594
|
buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES,
|
|
464
595
|
// The table's rule set — the matter glyphs plus vacant (no one holds it).
|
|
@@ -477,14 +608,64 @@ async function docketFor(builder) {
|
|
|
477
608
|
log.warn({ err: err && err.message }, 'governor docket: decision rules unreadable');
|
|
478
609
|
return null;
|
|
479
610
|
});
|
|
480
|
-
|
|
611
|
+
const government = permissions.includes(GOVERNMENT_GATE.permission) ? await loadGovernment({ decisions }) : null;
|
|
612
|
+
return buildDocket({ viewer: builder, permissions, decisions, government });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// This module's own floors and record, read ONCE per request (task 1004527),
|
|
616
|
+
// each piece isolated: a read that fails leaves its piece null, which the pure
|
|
617
|
+
// builders in docket-floors.js turn into an `unavailable` floor.
|
|
618
|
+
// holders per building, the people its decision-rules row seats now: the
|
|
619
|
+
// board's members (charter), a sitting's predicate, or the active
|
|
620
|
+
// holders of a rank that grants the fixed gate's atom; [] when vacant.
|
|
621
|
+
async function loadGovernment({ decisions }) {
|
|
622
|
+
const safe = (p, what) => Promise.resolve().then(p).catch((err) => {
|
|
623
|
+
log.warn({ err: err && err.message, what }, 'governor docket: a government read failed');
|
|
624
|
+
return null;
|
|
625
|
+
});
|
|
626
|
+
const { board: live } = governmentConfig.loadGovernmentConfig();
|
|
627
|
+
const [members, genesis, rankRows, assignments, amendments, room] = await Promise.all([
|
|
628
|
+
safe(() => resolveBoardMembers(live), 'members'),
|
|
629
|
+
safe(() => board.genesisStageStatus(), 'genesis'),
|
|
630
|
+
safe(() => db.listRanksWithGrants(), 'ranks'),
|
|
631
|
+
safe(() => db.listAssignments(), 'assignments'),
|
|
632
|
+
safe(() => db.listAmendmentHistory(50), 'amendments'),
|
|
633
|
+
safe(() => board.boardRoomView({ closedLimit: 50 }), 'closed'),
|
|
634
|
+
]);
|
|
635
|
+
const ranks = rankRows && assignments ? rankRows.map((r) => ({
|
|
636
|
+
rank_key: r.rank_key, label: r.label,
|
|
637
|
+
holders: assignments.filter((a) => a.rank_key === r.rank_key).length,
|
|
638
|
+
})) : null;
|
|
639
|
+
let holders = null;
|
|
640
|
+
if (Array.isArray(decisions)) {
|
|
641
|
+
holders = {};
|
|
642
|
+
await Promise.all(decisions.filter(Boolean).map(async (d) => {
|
|
643
|
+
if (d.decided_by === 'board') { holders[d.area] = members; return; }
|
|
644
|
+
if (d.decided_by === 'nobody') { holders[d.area] = []; return; }
|
|
645
|
+
if (d.decided_by === 'gate') {
|
|
646
|
+
if (!rankRows || !d.gate) { holders[d.area] = null; return; }
|
|
647
|
+
const keys = rankRows.filter((r) => (r.permissions || []).includes(d.gate)).map((r) => r.rank_key);
|
|
648
|
+
holders[d.area] = keys.length ? await safe(() => db.getActiveBuildersWithRank(keys), `holders:${d.area}`) : [];
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
holders[d.area] = await safe(() => resolveBoardMembers({ membership: d.decided_by }), `holders:${d.area}`);
|
|
652
|
+
}));
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
board: live, decisions, members, genesis, ranks, assignments, amendments, holders,
|
|
656
|
+
closed: room && Array.isArray(room.closed) ? room.closed : null,
|
|
657
|
+
};
|
|
481
658
|
}
|
|
482
659
|
|
|
483
660
|
module.exports = {
|
|
484
661
|
CONTRIBUTION_POINT,
|
|
485
662
|
EXPECTED_SOURCES,
|
|
486
663
|
COMING_SOURCES,
|
|
664
|
+
COMING_FLOORS,
|
|
487
665
|
SOURCE_TIMEOUT_MS,
|
|
666
|
+
normalizeFloor,
|
|
667
|
+
normalizeHistory,
|
|
668
|
+
loadGovernment,
|
|
488
669
|
SETTLED_WINDOW_MS,
|
|
489
670
|
normalizeMatter,
|
|
490
671
|
gateAllows,
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// modules/hall-ui/public/street-amend.js — CHANGE HOW THIS WORKS: the drafted
|
|
2
|
+
// amendment a building's interior opens (task 1004530, goal 1000125).
|
|
3
|
+
//
|
|
4
|
+
// From any building, "change how this works" drafts an amendment to that
|
|
5
|
+
// building's row of the who-decides-what table (GET /government/constitution →
|
|
6
|
+
// decision_rules.areas[]), prefilled with the rule in force and a proposed one,
|
|
7
|
+
// shown as struck and inserted text. The governor picks who and which rule —
|
|
8
|
+
// from the vocabulary only (the server's rules list and the rank ladder, never
|
|
9
|
+
// free text) — adds a reason, and files it. A filed amendment is a sitting on
|
|
10
|
+
// the Charter, voted through the matter card (street-card.js). There is no
|
|
11
|
+
// settings page: this draft is the only way in, and the board is the only way
|
|
12
|
+
// it takes effect.
|
|
13
|
+
//
|
|
14
|
+
// WHERE IT GOES. A building's row → POST /government/board/decision-rules
|
|
15
|
+
// { rules: [{ area, decided_by, rule }], rationale_md }. The Charter's own row
|
|
16
|
+
// is the board itself, so the Charter's draft is a constitution amendment of
|
|
17
|
+
// the board's members and pass rule → POST /government/board/items (the live
|
|
18
|
+
// board block, those two fields changed). The server's permission checks
|
|
19
|
+
// decide who may file; a refusal is said in plain words (refusal()).
|
|
20
|
+
//
|
|
21
|
+
// THE WORDS. The struck and inserted text is street-card.js's own change() run
|
|
22
|
+
// over the amendment exactly as the server will store it, so the card the
|
|
23
|
+
// council votes on shows the words the governor drafted, by construction.
|
|
24
|
+
//
|
|
25
|
+
// PURE: no DOM, no fetch, no clock. street.js draws what this returns.
|
|
26
|
+
|
|
27
|
+
(function (root) {
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
// The standard rank ladder, lowest first (held equal to the server's
|
|
31
|
+
// membership-predicate RANK_ORDER by test).
|
|
32
|
+
var RANKS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
33
|
+
// The table's words for the board's pass rules, and back (decision-rules.js
|
|
34
|
+
// PASS_RULE_TO_DECISION_RULE).
|
|
35
|
+
var TO_PASS = { first: 'first_ratifier', consent: 'consent', majority: 'majority', unanimous: 'unanimous' };
|
|
36
|
+
var FROM_PASS = { first_ratifier: 'first', consent: 'consent', majority: 'majority', unanimous: 'unanimous' };
|
|
37
|
+
var BOARD_FIELDS = ['membership', 'pass_rule', 'window_minutes', 'close_early_on_full_turnout'];
|
|
38
|
+
var REASON_MAX = 5000;
|
|
39
|
+
|
|
40
|
+
function str(v) { return v == null ? '' : String(v); }
|
|
41
|
+
function card() { return root.GovMatterCard || null; }
|
|
42
|
+
function memberWords(p) { var C = card(); return C ? C.memberWords(p) : str(p); }
|
|
43
|
+
function isPredicate(p) { return /^rank:[a-z0-9,+-]+$/.test(str(p)); }
|
|
44
|
+
|
|
45
|
+
function areasOf(constitution) {
|
|
46
|
+
var d = constitution && constitution.decision_rules;
|
|
47
|
+
return d && Array.isArray(d.areas) ? d.areas : null;
|
|
48
|
+
}
|
|
49
|
+
function vocabOf(constitution) {
|
|
50
|
+
var d = constitution && constitution.decision_rules;
|
|
51
|
+
return d && Array.isArray(d.rules) ? d.rules.filter(function (r) { return r && r.key; }) : [];
|
|
52
|
+
}
|
|
53
|
+
function rowOf(constitution, area) {
|
|
54
|
+
var as = areasOf(constitution) || [];
|
|
55
|
+
for (var i = 0; i < as.length; i++) if (as[i] && as[i].area === area) return as[i];
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Who may decide here: as today, the board's own members, a step of the
|
|
60
|
+
// rank ladder, whoever decides now, or no one. Values only, never free text.
|
|
61
|
+
function whoOptions(area, row, board) {
|
|
62
|
+
var out = [], seen = {};
|
|
63
|
+
function add(value, words) { if (!value || seen[value]) return; seen[value] = true; out.push({ value: value, words: words }); }
|
|
64
|
+
if (area !== 'charter') add('gate', 'as today: ' + str(row && row.today));
|
|
65
|
+
if (board && isPredicate(board.membership)) add(board.membership, 'the board: ' + memberWords(board.membership));
|
|
66
|
+
// the top of the ladder alone is exactly that rank (rank:archon+ is rank:archon)
|
|
67
|
+
RANKS.forEach(function (k, i) { var v = 'rank:' + k + (i === RANKS.length - 1 ? '' : '+'); add(v, memberWords(v)); });
|
|
68
|
+
var cur = area === 'charter' ? board && board.membership : row && row.decided_by;
|
|
69
|
+
if (isPredicate(cur)) add(cur, memberWords(cur));
|
|
70
|
+
if (area !== 'charter') add('nobody', 'no one: the building stands empty');
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Which rules fit who decides: as today decides alone (no rule to pick), no
|
|
75
|
+
// one is vacant, the charter takes the board's four pass rules, a sitting any
|
|
76
|
+
// rule but vacant. Drawn from the server's vocabulary only.
|
|
77
|
+
function ruleOptions(area, decidedBy, vocab) {
|
|
78
|
+
if (decidedBy === 'gate' || decidedBy === 'nobody') return [];
|
|
79
|
+
return (vocab || []).filter(function (r) {
|
|
80
|
+
if (area === 'charter') return Object.prototype.hasOwnProperty.call(TO_PASS, r.key);
|
|
81
|
+
return r.key !== 'vacant';
|
|
82
|
+
}).map(function (r) { return { value: r.key, words: str(r.words) }; });
|
|
83
|
+
}
|
|
84
|
+
function fixedRule(decidedBy) { return decidedBy === 'gate' ? null : decidedBy === 'nobody' ? 'vacant' : undefined; }
|
|
85
|
+
function hasValue(list, v) { for (var i = 0; i < list.length; i++) if (list[i].value === v) return true; return false; }
|
|
86
|
+
|
|
87
|
+
// The draft for one building, or { ok: false, reason } when the table could
|
|
88
|
+
// not be read. The proposal starts as the most likely change: a building
|
|
89
|
+
// decided as today goes to the board's members by consent; one already
|
|
90
|
+
// changed (or empty) goes back to as today; the charter changes its rule.
|
|
91
|
+
function draftFor(area, constitution) {
|
|
92
|
+
if (!areasOf(constitution)) return { ok: false, reason: 'unread' };
|
|
93
|
+
var row = rowOf(constitution, area);
|
|
94
|
+
if (!row) return { ok: false, reason: 'unknown_area' };
|
|
95
|
+
var vocab = vocabOf(constitution);
|
|
96
|
+
if (!vocab.length) return { ok: false, reason: 'unread' };
|
|
97
|
+
var board = constitution.board || null;
|
|
98
|
+
var charter = area === 'charter';
|
|
99
|
+
if (charter && (!board || !isPredicate(board.membership))) return { ok: false, reason: 'unread' };
|
|
100
|
+
var d = {
|
|
101
|
+
ok: true, area: area, name: str(row.name), charter: charter, row: row, board: board, vocab: vocab,
|
|
102
|
+
current: charter
|
|
103
|
+
? { decided_by: board.membership, rule: FROM_PASS[board.pass_rule] || null }
|
|
104
|
+
: { decided_by: row.decided_by, rule: row.decided_by === 'gate' ? null : row.rule },
|
|
105
|
+
decided_by: null, rule: null, reason: '',
|
|
106
|
+
};
|
|
107
|
+
if (charter) {
|
|
108
|
+
d.decided_by = board.membership;
|
|
109
|
+
d.rule = d.current.rule === 'consent' ? 'majority' : 'consent';
|
|
110
|
+
} else if (row.as_today) {
|
|
111
|
+
d.decided_by = board && isPredicate(board.membership) ? board.membership : 'rank:metic+';
|
|
112
|
+
d.rule = 'consent';
|
|
113
|
+
} else {
|
|
114
|
+
d.decided_by = 'gate';
|
|
115
|
+
d.rule = null;
|
|
116
|
+
}
|
|
117
|
+
return settle(d);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Keep a draft inside the vocabulary after a pick: the rule follows who.
|
|
121
|
+
function settle(d) {
|
|
122
|
+
var who = whoOptions(d.area, d.row, d.board);
|
|
123
|
+
if (!hasValue(who, d.decided_by)) d.decided_by = who.length ? who[0].value : null;
|
|
124
|
+
var fixed = fixedRule(d.decided_by);
|
|
125
|
+
var rules = ruleOptions(d.area, d.decided_by, d.vocab);
|
|
126
|
+
if (fixed !== undefined) d.rule = fixed;
|
|
127
|
+
else if (!hasValue(rules, d.rule)) d.rule = hasValue(rules, d.current.rule) ? d.current.rule : rules.length ? rules[0].value : null;
|
|
128
|
+
d.whoOptions = who;
|
|
129
|
+
d.ruleOptions = rules;
|
|
130
|
+
return d;
|
|
131
|
+
}
|
|
132
|
+
function copy(d) { var o = {}; for (var k in d) if (Object.prototype.hasOwnProperty.call(d, k)) o[k] = d[k]; return o; }
|
|
133
|
+
|
|
134
|
+
// One pick: field 'who' | 'rule' | 'reason'. A value outside the vocabulary
|
|
135
|
+
// is refused (the draft comes back unchanged).
|
|
136
|
+
function pick(d, field, value) {
|
|
137
|
+
if (!d || !d.ok) return d;
|
|
138
|
+
var n = copy(d);
|
|
139
|
+
if (field === 'who') { if (!hasValue(d.whoOptions, value)) return d; n.decided_by = value; }
|
|
140
|
+
else if (field === 'rule') { if (!hasValue(d.ruleOptions, value)) return d; n.rule = value; }
|
|
141
|
+
else if (field === 'reason') { n.reason = str(value).slice(0, REASON_MAX); return n; }
|
|
142
|
+
else return d;
|
|
143
|
+
return settle(n);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function changed(d) {
|
|
147
|
+
return !!(d && d.ok && (d.decided_by !== d.current.decided_by || (d.rule || null) !== (d.current.rule || null)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The amendment as the server will store it, shaped as a board item, so
|
|
151
|
+
// street-card.js's change() reads it exactly as the card will.
|
|
152
|
+
function ruleRow(d) {
|
|
153
|
+
var r = { area: d.area, decided_by: d.decided_by };
|
|
154
|
+
if (d.decided_by !== 'gate') r.rule = d.rule;
|
|
155
|
+
return r;
|
|
156
|
+
}
|
|
157
|
+
function proposal(d) {
|
|
158
|
+
var p = {};
|
|
159
|
+
BOARD_FIELDS.forEach(function (f) { p[f] = d.board[f] === undefined ? null : d.board[f]; });
|
|
160
|
+
p.membership = d.decided_by;
|
|
161
|
+
p.pass_rule = TO_PASS[d.rule] || d.board.pass_rule;
|
|
162
|
+
if (typeof p.close_early_on_full_turnout !== 'boolean') p.close_early_on_full_turnout = false;
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
function asItem(d) {
|
|
166
|
+
if (d.charter) return { subject_type: 'constitutional_amendment', subject: { kind: 'constitution', proposed: proposal(d) } };
|
|
167
|
+
return { subject_type: 'constitutional_amendment', subject: { kind: 'decision_rules', proposed: { decision_rules: [ruleRow(d)] } } };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The struck and inserted rows: [{ label, struck, inserted }].
|
|
171
|
+
function change(d) {
|
|
172
|
+
if (!changed(d)) return [];
|
|
173
|
+
var C = card();
|
|
174
|
+
if (!C || !C.change) return [];
|
|
175
|
+
var docket = { buildings: [{ key: d.area, decision: d.row }] };
|
|
176
|
+
var ch = C.change(asItem(d), docket, { board: d.board });
|
|
177
|
+
return ch && Array.isArray(ch.rows) ? ch.rows : [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Why the draft cannot be filed yet, in words, or '' when it can.
|
|
181
|
+
function check(d) {
|
|
182
|
+
if (!d || !d.ok) return 'How this building decides could not be read just now.';
|
|
183
|
+
if (!changed(d)) return 'That is how it works today. Pick a different way to change it.';
|
|
184
|
+
if (!str(d.reason).trim()) return 'Say why, so the council knows what it is voting on.';
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The request that files it (the server's permission checks decide).
|
|
189
|
+
function request(d) {
|
|
190
|
+
if (check(d)) return null;
|
|
191
|
+
var api = (card() && card().API) || '/api/bongos';
|
|
192
|
+
var why = str(d.reason).trim();
|
|
193
|
+
if (d.charter) return { method: 'POST', url: api + '/government/board/items', body: { subject_type: 'constitutional_amendment', proposal: proposal(d), rationale_md: why } };
|
|
194
|
+
return { method: 'POST', url: api + '/government/board/decision-rules', body: { rules: [ruleRow(d)], rationale_md: why } };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The server's refusal, plainly.
|
|
198
|
+
var REASONS = {
|
|
199
|
+
unknown_rank: 'There is no such rank yet, so that would seat no one.',
|
|
200
|
+
charter_is_the_board: 'The charter is decided by the board itself. Change it from the Charter.',
|
|
201
|
+
only_the_charter_is_the_board: 'Only the charter is decided by the board.',
|
|
202
|
+
bad_rule: 'That is not a way this project decides.',
|
|
203
|
+
bad_decided_by: 'That is not a seat this project knows.',
|
|
204
|
+
gate_takes_no_rule: 'As today, the building decides alone: there is no rule to pick.',
|
|
205
|
+
nobody_is_vacant: 'An empty building decides nothing.',
|
|
206
|
+
bad_membership: 'That is not a seat this project knows.',
|
|
207
|
+
bad_pass_rule: 'That is not a way the board can decide.',
|
|
208
|
+
};
|
|
209
|
+
function refusal(status, body) {
|
|
210
|
+
var e = body && body.error ? body.error : {};
|
|
211
|
+
var det = e.details || {};
|
|
212
|
+
if (e.code === 'proposer_would_lose_their_seat' && det.what_this_means) return str(det.what_this_means);
|
|
213
|
+
if (e.code === 'no_change') return 'That already is how it works. Nothing was filed.';
|
|
214
|
+
if (status === 401) return 'Sign in to file an amendment.';
|
|
215
|
+
if (status === 403) return 'Filing an amendment is not yours to do here. The server refused it.';
|
|
216
|
+
if ((e.code === 'invalid_decision_rules' || e.code === 'invalid_proposal') && det.reason) {
|
|
217
|
+
return REASONS[det.reason] || 'The server refused it: ' + str(det.reason).replace(/_/g, ' ') + '.';
|
|
218
|
+
}
|
|
219
|
+
var C = card();
|
|
220
|
+
return C ? C.refusal(status, body) : 'It could not be filed just now.';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// What the street says once it is filed, and which sitting to mark.
|
|
224
|
+
function answer(d, body) {
|
|
225
|
+
var item = body && body.item;
|
|
226
|
+
return {
|
|
227
|
+
toast: 'Filed. Your amendment to ' + (d && d.name ? d.name : 'this building') + ' is on the Charter for the council to vote on.',
|
|
228
|
+
building: 'charter',
|
|
229
|
+
matter: item && item.id != null ? 'board:' + item.id : null,
|
|
230
|
+
kind: 'light',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
var api = {
|
|
235
|
+
RANKS: RANKS, draftFor: draftFor, pick: pick, changed: changed, change: change,
|
|
236
|
+
check: check, request: request, refusal: refusal, answer: answer, whoOptions: whoOptions, ruleOptions: ruleOptions,
|
|
237
|
+
};
|
|
238
|
+
root.GovAmend = api;
|
|
239
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
240
|
+
}(typeof window !== 'undefined' ? window : globalThis));
|
|
@@ -195,7 +195,8 @@
|
|
|
195
195
|
return {
|
|
196
196
|
label: BUILDING_NAMES[r.area] || str(r.area),
|
|
197
197
|
struck: today ? [today.who, today.rule_words].filter(Boolean).join(', ') : '',
|
|
198
|
-
|
|
198
|
+
// as today and no one say it all in their seat's words (task 1004530)
|
|
199
|
+
inserted: [r.who || memberWords(r.decided_by), r.decided_by === 'gate' || r.decided_by === 'nobody' ? '' : r.rule ? RULE_WORDS[r.rule] || r.rule : ''].filter(Boolean).join(', '),
|
|
199
200
|
};
|
|
200
201
|
});
|
|
201
202
|
return rows.length ? { rows: rows } : null;
|
|
@@ -691,6 +692,8 @@
|
|
|
691
692
|
API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
|
|
692
693
|
model: model, request: request, check: check, answer: answer, refusal: refusal, render: render,
|
|
693
694
|
memberWords: memberWords, windowWords: windowWords,
|
|
695
|
+
// the wording change, for a drafted amendment's struck and inserted text (task 1004530)
|
|
696
|
+
change: changeOf,
|
|
694
697
|
};
|
|
695
698
|
root.GovMatterCard = api;
|
|
696
699
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|