@bongos/core 1.19.592 → 1.19.593

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 (39) hide show
  1. package/.bongos-core.json +61 -41
  2. package/.claude/skills/planning-session/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +3 -1
  5. package/clients/bongos-client/index.cjs +3 -1
  6. package/clients/bongos-client/index.d.ts +6 -2
  7. package/clients/bongos-client/index.mjs +3 -1
  8. package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
  9. package/docs/api/openapi.json +103 -7
  10. package/docs/api-reference.md +4 -3
  11. package/docs/copy-inventory.md +135 -116
  12. package/docs/copy-registry.json +319 -138
  13. package/docs/module-api-changelog.md +2 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
  15. package/modules/government/catalog.js +20 -11
  16. package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
  17. package/modules/hall-ui/public/goals-render.js +182 -6
  18. package/modules/hall-ui/public/goals.css +51 -0
  19. package/modules/hall-ui/public/roadmap.css +5 -0
  20. package/modules/hall-ui/public/roadmap.js +25 -0
  21. package/modules/hall-ui/public/task-detail.js +22 -11
  22. package/modules/lifecycle/db-goals.js +34 -2
  23. package/modules/lifecycle/db-versions.js +195 -3
  24. package/modules/lifecycle/db.js +4 -1
  25. package/modules/lifecycle/routes/goals.js +15 -1
  26. package/modules/lifecycle/routes/version-route-authz.js +131 -1
  27. package/modules/lifecycle/routes/versions.js +79 -1
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/triage.js +60 -26
  31. package/src/module-api.js +1 -1
  32. package/tests/goal_archive_hall.mjs +198 -0
  33. package/tests/government_require_permission.mjs +7 -3
  34. package/tests/government_seed.mjs +101 -9
  35. package/tests/promote_goal_id.mjs +85 -30
  36. package/tests/publish_reconciler.mjs +35 -5
  37. package/tests/task_detail_ui.mjs +36 -9
  38. package/tests/version_close_route.mjs +261 -0
  39. package/tests/version_override_visibility.mjs +176 -0
@@ -220,15 +220,37 @@ async function resolveGoalDefault(idea, versionId, { client = cliClient } = {})
220
220
  // default — declining has to be said ('-' or 'none'), because silently dropping a
221
221
  // real default on a bare Enter is how work ends up in the general bucket by
222
222
  // accident. Declining is legal: the server then applies the catch-all itself.
223
+ // applyGoalAnswer — read the operator's goal answer at the triage prompt.
224
+ //
225
+ // BV1.R22 (task 1003609, goal 1000086): THE GENERAL BUCKET IS GONE. Every branch
226
+ // that used to resolve to `goalId: null` did so because a goal-less create fell
227
+ // into the version's catch-all "<version> — general" goal. R11 (task 1003598)
228
+ // deleted that fallback and made `goal_id` required, so `null` is no longer an
229
+ // opt-out — it is a `400 goal_id_required` from the server, arriving after the
230
+ // operator has typed a title, a description and a priority, and losing all of it.
231
+ //
232
+ // So the two escapes become refusals, and they refuse HERE, before the round trip:
233
+ // '-' / 'none' — was "file it in the general bucket"; there is no bucket.
234
+ // a junk answer — was silently downgraded to the bucket with a warning, which
235
+ // is the worse of the two: a typo'd goal id read as a decision.
236
+ //
237
+ // `ok: false` means re-ask. The caller loops rather than aborting the whole
238
+ // triage run, because the operator's other answers are still good.
223
239
  function applyGoalAnswer(defaultGoalId, rawAnswer) {
224
240
  const answer = String(rawAnswer == null ? '' : rawAnswer).trim();
225
- if (answer === '') return { goalId: defaultGoalId, warn: null };
226
- if (answer === '-' || answer.toLowerCase() === 'none') return { goalId: null, warn: null };
241
+ if (answer === '') {
242
+ return defaultGoalId != null
243
+ ? { ok: true, goalId: defaultGoalId, warn: null }
244
+ : { ok: false, goalId: null, warn: 'a goal is required — every task belongs to one. Enter a goal id (GET /goals?version=<id> lists them), or "s" to skip this idea.' };
245
+ }
246
+ if (answer === '-' || answer.toLowerCase() === 'none') {
247
+ return { ok: false, goalId: null, warn: 'the version\'s "general" catch-all no longer exists (task 1003598) — a task must name a real goal. Enter a goal id, or "s" to skip this idea.' };
248
+ }
227
249
  const parsed = Number(answer);
228
250
  if (!Number.isInteger(parsed) || parsed <= 0) {
229
- return { goalId: null, warn: "not a goal id falling back to the version's general bucket" };
251
+ return { ok: false, goalId: null, warn: `"${answer}" is not a goal id. Enter a number, or "s" to skip this idea.` };
230
252
  }
231
- return { goalId: parsed, warn: null };
253
+ return { ok: true, goalId: parsed, warn: null };
232
254
  }
233
255
 
234
256
  async function createTask({ versionId, title, description, priority, sourceRef, goalId = null }) {
@@ -239,13 +261,13 @@ async function createTask({ versionId, title, description, priority, sourceRef,
239
261
  // make, and the server classifier still runs. Corrected rather than deleted so
240
262
  // the next reader does not re-derive it.)
241
263
  //
242
- // task 1003071 (R13): goal_id is the point of this task. Omitting it does NOT
243
- // leave the task goal-lessdb.createTask (modules/lifecycle/db.js) defaults a
244
- // goal-less create into the version's catch-all "<version> general" goal. That
245
- // bucket is the actual defect: it carries no done-when criteria, so since ADR
246
- // 0183 made criteria close themselves off LINKED tasks, work parked there can
247
- // never advance anything, and it does not read as real goal work on the board.
248
- // Naming the goal here is what stops a triaged promotion falling into it.
264
+ // task 1003071 (R13) named the goal here; BV1.R22 (task 1003609) makes it
265
+ // MANDATORY. R11 (task 1003598) deleted the "<version> general" catch-all that
266
+ // a goal-less create used to fall into, so omitting goal_id is now a server
267
+ // `400 goal_id_required` rather than a quiet mis-filing. Reaching this function
268
+ // without one is a bug in the caller, and it throws rather than posting: the
269
+ // prompt above re-asks until it has a real id, so the only way here with null is
270
+ // a new call site that forgot which should fail at its author, not in prod.
249
271
  const body = {
250
272
  version_id: versionId,
251
273
  title,
@@ -255,7 +277,10 @@ async function createTask({ versionId, title, description, priority, sourceRef,
255
277
  source: 'idea_inbox',
256
278
  source_ref: sourceRef ?? null,
257
279
  };
258
- if (goalId != null) body.goal_id = Number(goalId);
280
+ if (goalId == null) {
281
+ throw new Error('createTask: goal_id is required — every task belongs to a goal (task 1003598 deleted the catch-all fallback).');
282
+ }
283
+ body.goal_id = Number(goalId);
259
284
  const api = await cliClient();
260
285
  const r = await api.tasks.postTasks({ body });
261
286
  if (!r.ok) {
@@ -612,21 +637,30 @@ async function main() {
612
637
  const title = titleAns || idea.title;
613
638
 
614
639
  // task 1003071 (R13): a triaged promotion NAMES its goal. The default
615
- // comes from the stored hint, else the R07 suggester; '-' opts out
616
- // explicitly, which falls through to the version's catch-all "general"
617
- // bucket now a choice someone typed rather than the silent default.
618
- // The EFFECTIVE title, not idea.title: the suggester matches on title
619
- // terms, and the operator may just have retitled this.
640
+ // comes from the stored hint, else the R07 suggester. The EFFECTIVE
641
+ // title, not idea.title: the suggester matches on title terms, and the
642
+ // operator may just have retitled this.
643
+ //
644
+ // BV1.R22 (task 1003609): the opt-out is gone, so this RE-ASKS instead of
645
+ // falling through. Skipping is still available and is now spelled 's' —
646
+ // it leaves the idea open for the next pass rather than filing it
647
+ // somewhere it does not belong, which is what the general bucket was.
620
648
  const gd = await resolveGoalDefault({ ...idea, title }, versionId);
621
649
  if (gd.source === 'suggester') console.log(` suggested goal: ${gd.label}`);
622
- if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id, or "-" to accept the general bucket)');
623
- const goalPrompt = gd.goalId != null
624
- ? ` goal [${gd.goalId}] ("-" for the version's general bucket): `
625
- : ' goal (blank/"-" for the version\'s general bucket): ';
626
- const goalAns = (await prompt(rl, goalPrompt)).trim();
627
- const picked = applyGoalAnswer(gd.goalId, goalAns);
628
- if (picked.warn) console.log(` ! ${picked.warn}`);
629
- const goalId = picked.goalId;
650
+ if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id yourself; GET /goals?version=<id> lists them)');
651
+ let goalId = null;
652
+ let skipIdea = false;
653
+ for (;;) {
654
+ const goalPrompt = gd.goalId != null
655
+ ? ` goal [${gd.goalId}] ("s" to skip this idea): `
656
+ : ' goal id ("s" to skip this idea): ';
657
+ const goalAns = (await prompt(rl, goalPrompt)).trim();
658
+ if (goalAns.toLowerCase() === 's') { skipIdea = true; break; }
659
+ const picked = applyGoalAnswer(gd.goalId, goalAns);
660
+ if (picked.ok) { goalId = picked.goalId; break; }
661
+ console.log(` ! ${picked.warn}`);
662
+ }
663
+ if (skipIdea) { console.log(' – skipped (still open in the inbox)'); summary.skipped++; continue; }
630
664
 
631
665
  try {
632
666
  const task = await createTask({
@@ -644,7 +678,7 @@ async function main() {
644
678
  });
645
679
  summary.promoted++;
646
680
  promotedIds.push({ ideaId: idea.id, taskId: task.id });
647
- console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}${goalId != null ? `, goal ${goalId}` : ', → general bucket'})`);
681
+ console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}, goal ${goalId})`);
648
682
  } catch (err) {
649
683
  console.error(` ! ${err.message}`);
650
684
  }
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.592'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.593'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,198 @@
1
+ // tests/goal_archive_hall.mjs — the goal page tells the truth about what is
2
+ // holding a goal open, and walks the archive two-step without curl
3
+ // (BV1.R24, task 1003611, goal 1000086, ADR 0250 D2/D3).
4
+ //
5
+ // TWO THINGS THIS PAGE COULD NOT SAY BEFORE.
6
+ //
7
+ // 1. WHY A GOAL IS STILL OPEN. Until R08/R09 (tasks 1003595, 1003596) a goal
8
+ // closed on its criteria alone, so "N of M criteria satisfied" was the whole
9
+ // answer. ADR 0250 D2 added "AND no task in it is non-terminal" — and a task
10
+ // linked to NO criterion appears nowhere in that rollup. A builder could read
11
+ // 5-of-5 satisfied, watch the goal stay open, and find nothing on the page
12
+ // explaining it. That reads as a bug in the gate rather than the gate working.
13
+ //
14
+ // 2. HOW TO RETIRE ONE. R10 made archive refuse and return the work; R14 made it
15
+ // apply a disposition. Both were API-only, so the only way to retire a goal
16
+ // holding work was hand-written JSON — which is why the ADR 0264 goal cut
17
+ // needed a terminal.
18
+ //
19
+ // Static assertions over the shipped browser source, the promote_goal_id.mjs
20
+ // precedent: these are DOM strings a unit test cannot invoke, and the properties
21
+ // worth pinning are structural — which read feeds the panel, what the move-target
22
+ // filter excludes, and that the server's refusal is shown rather than paraphrased.
23
+ //
24
+ // Run: node tests/goal_archive_hall.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { readFileSync } from 'node:fs';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ const { test, summary } = makeRunner();
31
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
32
+ const RENDER = src('modules/hall-ui/public/goals-render.js');
33
+ const CSS = src('modules/hall-ui/public/goals.css');
34
+ const ROUTES = src('modules/lifecycle/routes/goals.js');
35
+
36
+ // ---- the server hands the page the same facts the refusal uses --------------
37
+
38
+ await test('GET /goals/:id carries the open tasks, from the SAME read the refusal returns', () => {
39
+ const detail = ROUTES.slice(ROUTES.indexOf("router.get('/goals/:id'"), ROUTES.indexOf("router.get('/goals/:id/suggest-criteria'"));
40
+ assert.match(detail, /db\.openTasksInGoal\(id\)/,
41
+ 'the page must not compute "what is in the way" a second way');
42
+ assert.match(detail, /open_tasks: openTasks/, 'and it must reach the client');
43
+ // If the page used a different read, it could show a builder a list the archive
44
+ // refusal then disagrees with — the exact confusion the two-step exists to end.
45
+ const archive = ROUTES.slice(ROUTES.indexOf("router.post('/goals/:id/archive'"));
46
+ assert.match(archive, /db\.openTasksInGoal\(id/, 'the refusal reads it too');
47
+ });
48
+
49
+ // ---- 1. why the goal is open ------------------------------------------------
50
+
51
+ await test('the completion band names the unfinished work, with each task status', () => {
52
+ const fn = RENDER.slice(RENDER.indexOf('function heldOpenHtml'), RENDER.indexOf('function completionSummaryHtml'));
53
+ assert.match(fn, /holding this goal open/, 'it has to say what the tasks are doing, not merely list them');
54
+ assert.match(fn, /held__status/,
55
+ 'the status is the surprising part — `completed` and `confirmed` still hold a goal open');
56
+ assert.match(fn, /#\/task\//, 'and each is reachable, so the next move is one click');
57
+ });
58
+
59
+ await test('it is SILENT when nothing is held, and on a closed goal', () => {
60
+ const fn = RENDER.slice(RENDER.indexOf('function heldOpenHtml'), RENDER.indexOf('function completionSummaryHtml'));
61
+ assert.match(fn, /if \(!openTasks \|\| !openTasks\.total \|\| g\.status !== 'open'\) return '';/,
62
+ 'an empty "0 tasks" row on the common goal is noise, and this band renders on every goal');
63
+ });
64
+
65
+ await test('the summary line stops claiming criteria are the only thing left', () => {
66
+ const fn = RENDER.slice(RENDER.indexOf('function completionSummaryHtml'), RENDER.indexOf('function critListHtml'));
67
+ assert.match(fn, /const held = openTasks && openTasks\.total \? openTasks\.total : 0;/);
68
+ assert.match(fn, /still will not close while work is unfinished/,
69
+ 'satisfied criteria plus an open goal is exactly the state that reads as a bug unexplained');
70
+ // The old sentence must survive for the case it is still true for.
71
+ assert.match(fn, /a criterion becomes confirmable once every task linked to it has shipped/);
72
+ });
73
+
74
+ await test('both the band and the full view are passed the open tasks', () => {
75
+ // A detail view that renders the summary WITHOUT them silently drops the
76
+ // explanation on the surface a builder opens to investigate.
77
+ const calls = RENDER.match(/completionSummaryHtml\(g, crit, sat, \d+, d\.criterionless_task_count, d\.open_tasks\)/g) || [];
78
+ assert.equal(calls.length, 2, 'the compact band and the full view');
79
+ });
80
+
81
+ // ---- 2. the archive two-step ------------------------------------------------
82
+
83
+ await test('the Archive control appears only for someone who can manage an OPEN goal', () => {
84
+ const fn = RENDER.slice(RENDER.indexOf('function renderActions'), RENDER.indexOf('function archiveRowHtml'));
85
+ assert.match(fn, /if \(canManage && g\.status === 'open'\) \{/,
86
+ 'a Xenos sees the state, not the control — the server enforces either way');
87
+ assert.match(fn, /id="goal-archive-btn"/);
88
+ });
89
+
90
+ await test('a goal holding NOTHING archives in one step, with an optional reason', () => {
91
+ const fn = RENDER.slice(RENDER.indexOf('async function openArchivePanel'), RENDER.indexOf('async function submitArchive'));
92
+ assert.match(fn, /held\.total === 0/, 'the common case must not become a two-step');
93
+ assert.match(fn, /Why \(optional\)/);
94
+ assert.match(fn, /reversible/, 'and say so — an archive that reads as permanent is one nobody uses');
95
+ });
96
+
97
+ await test('move targets exclude closed goals AND the goal being archived', () => {
98
+ const fn = RENDER.slice(RENDER.indexOf('async function openArchivePanel'), RENDER.indexOf('async function submitArchive'));
99
+ assert.match(fn, /o\.status === 'open' && !sameId\(o\.id, g\.id\)/,
100
+ 'a closed goal takes no new work, and moving INTO the goal being archived is the silent discard renamed');
101
+ });
102
+
103
+ await test('every task needs a decision before the panel will submit', () => {
104
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
105
+ assert.match(fn, /undecided\.push\(id\)/);
106
+ assert.match(fn, /a decision: \$\{undecided\.map/, 'and the refusal names WHICH');
107
+ assert.match(fn, /if \(!reason\) return say\(/, 'an abandon without a reason is the silent discard D3 forbids');
108
+ });
109
+
110
+ await test('the client check is a courtesy — the server refusal is shown VERBATIM', () => {
111
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
112
+ assert.match(fn, /say\(\(e && e\.message\)/,
113
+ 'the server knows things this panel does not — a task claimed since load, a target that just closed');
114
+ // A UI that believes it is the enforcement is how a second, drifting copy of a
115
+ // rule gets written — the panel's own header says so, above the flow.
116
+ const flow = RENDER.slice(RENDER.indexOf('// ---- BV1.R24: the archive two-step'));
117
+ assert.match(flow, /COURTESY, never the gate/);
118
+ });
119
+
120
+ await test('the disposition payload is exactly the shape the route validates', () => {
121
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
122
+ assert.match(fn, /\{ verb: 'abandon' \}/);
123
+ assert.match(fn, /\{ verb: 'move', goal_id: Number\(v\) \}/);
124
+ assert.match(fn, /body\.dispositions = dispositions;/);
125
+ // The route declares `reason` and `dispositions` and rejects anything else
126
+ // (ADR 0118), so an extra field here would 400 the whole call.
127
+ const sent = fn.slice(0, fn.indexOf('postJSON'));
128
+ assert.equal(/body\.(?!dispositions|reason)[a-z_]+ =/.test(sent), false, 'no field the route does not declare');
129
+ });
130
+
131
+ await test('archiving re-reads the goal rather than patching the page from the response', () => {
132
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
133
+ assert.match(fn, /loadDetail\(g\.id, \{ force: true \}\)/,
134
+ 'the archive moved tasks between goals — a local patch would leave the page lying about both');
135
+ });
136
+
137
+ await test('the panel uses the page\'s own tokens, not colours of its own', () => {
138
+ const block = CSS.slice(CSS.indexOf('.arch-panel {'));
139
+ assert.equal(/#[0-9a-fA-F]{6}/.test(block.slice(0, block.indexOf('.arch-panel__err'))), false,
140
+ 'a panel that announces itself is one people click by accident');
141
+ assert.match(CSS, /\.held-open \{/);
142
+ assert.match(CSS, /\.arch-row__verb \{/);
143
+ });
144
+
145
+ // ---- 3. it actually RENDERS ------------------------------------------------
146
+ //
147
+ // The assertions above prove the source contains the right strings. These run the
148
+ // two pure builders and check the MARKUP — grammar, escaping, the overflow line,
149
+ // and that no verb is preselected. A static regex passes just as happily on a
150
+ // function that throws.
151
+
152
+ const escapeHtmlFn = (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
153
+ const fmtNumFn = (n) => String(n);
154
+ const lift = (name, end, args, vals) => new Function(...args, `${RENDER.slice(RENDER.indexOf(`function ${name}`), RENDER.indexOf(end))}; return ${name};`)(...vals);
155
+ const heldOpenHtml = lift('heldOpenHtml', 'function completionSummaryHtml', ['escapeHtml', 'fmtNum'], [escapeHtmlFn, fmtNumFn]);
156
+ const archiveRowHtml = lift('archiveRowHtml', 'async function openArchivePanel', ['escapeHtml'], [escapeHtmlFn]);
157
+ const OPEN = { status: 'open' };
158
+
159
+ await test('RENDERS: a goal holding nothing produces no band at all', () => {
160
+ assert.equal(heldOpenHtml(OPEN, { total: 0, tasks: [] }), '');
161
+ assert.equal(heldOpenHtml(OPEN, null), '');
162
+ });
163
+
164
+ await test('RENDERS: an archived goal shows no band even while holding work', () => {
165
+ assert.equal(heldOpenHtml({ status: 'archived' }, { total: 3, tasks: [{ id: 1, title: 'x', status: 'ready' }] }), '');
166
+ });
167
+
168
+ await test('RENDERS: held tasks come out linked, status-tagged and ESCAPED', () => {
169
+ const html = heldOpenHtml(OPEN, { total: 2, tasks: [
170
+ { id: 1001, title: 'The <b>unescaped</b> one', status: 'confirmed' },
171
+ { id: 1002, title: 'second', status: 'ready' },
172
+ ] });
173
+ assert.match(html, /2 unfinished tasks are holding this goal open/);
174
+ assert.match(html, /#\/task\/1001/);
175
+ assert.match(html, /confirmed/, 'the mid-ship status is the surprising part and must show');
176
+ assert.doesNotMatch(html, /<b>unescaped<\/b>/, 'a task title is user input on a page everyone reads');
177
+ assert.match(html, /&lt;b&gt;/);
178
+ });
179
+
180
+ await test('RENDERS: the grammar is singular for one', () => {
181
+ const one = heldOpenHtml(OPEN, { total: 1, tasks: [{ id: 5, title: 'solo', status: 'active' }] });
182
+ assert.match(one, /1 unfinished task is holding this goal open/);
183
+ });
184
+
185
+ await test('RENDERS: a long list is capped and says how many are hidden', () => {
186
+ const many = heldOpenHtml(OPEN, { total: 30, tasks: Array.from({ length: 8 }, (_, i) => ({ id: i, title: 't', status: 'ready' })) });
187
+ assert.match(many, /and 22 more/, 'a truncated list that does not say it is truncated is a wrong answer');
188
+ });
189
+
190
+ await test('RENDERS: an archive row preselects NOTHING and offers each open goal', () => {
191
+ const row = archiveRowHtml({ id: 77, title: 'row', status: 'blocked' }, [{ id: 42, title: 'survivor' }]);
192
+ assert.match(row, /value="" selected/, 'a preselected verb is a decision nobody made');
193
+ assert.match(row, /value="abandon"/);
194
+ assert.match(row, /move → #42 survivor/);
195
+ assert.match(row, /data-task="77"/, 'the row carries its id, which is how the map is collected');
196
+ });
197
+
198
+ summary();
@@ -231,6 +231,13 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
231
231
  const REPLACED_ARCHON = [
232
232
  'dependency.cross_tier.manage', 'gate_approval.decide',
233
233
  'task.confirm.grade_bypass', 'access_request.review',
234
+ // BV1.R21 (task 1003608): `version.create` RETURNED here. ADR 0157 had lowered it
235
+ // to metic on "authoring delegates, disposition does not"; ADR 0250 §6 amends that
236
+ // for this one key, because D1 makes the version boundary the scope gate and
237
+ // cutting a version is then the way AROUND the gate rather than authoring. Moving
238
+ // it back is an owner decision, which is exactly what this hand-written oracle
239
+ // exists to require — a floor that moved without one still reds the build.
240
+ 'version.create',
234
241
  ];
235
242
  // ADR 0157 (owner decision, 2026-08-05) — "the Archon rank is responsible for rank and
236
243
  // identity only". Each of these replaced a requireRank('archon') gate in R104/R105 and
@@ -249,9 +256,6 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
249
256
  'task.peer_votes.tally', 'override_request.decide', 'memory.read.any',
250
257
  'project.curate', 'provisioning.fleet.manage', 'security.report.adjudicate',
251
258
  'session.search',
252
- // Minted by R105 (POST /versions had no atom); ADR 0157 then lowered it to metic.
253
- // Its sibling `version.close` stayed archon — authoring delegates, disposition does not.
254
- 'version.create',
255
259
  // Minted BY ADR 0157, splitting the Gate page's READ off `gate_approval.decide` so a
256
260
  // Metic can open /gate while approving a held PR stays Archon.
257
261
  'gate_approval.read',
@@ -74,6 +74,53 @@ function grantBlocksForRole(role) {
74
74
  return blocks;
75
75
  }
76
76
 
77
+ // REVOCATIONS (BV1.R21, task 1003608). The union model below was append-only, and
78
+ // the guard said in advance what would happen if that stopped being true: "A
79
+ // future migration that REVOKES (raising a floor) would make the union overstate
80
+ // what the DB holds." `government_012_version_create_archon.sql` is the first one
81
+ // — ADR 0250 §6 raises `version.create` back to the Archon floor, reversing the
82
+ // ADR 0157 delegation — so the model now subtracts.
83
+ //
84
+ // Matches BOTH table spellings on purpose. The old revoke-detector watched only
85
+ // `governance_role_permissions`, but government_001_rename_from_governance renamed
86
+ // it to `government_rank_permissions` with no shim, so a DELETE written against
87
+ // the CURRENT table name would have sailed straight past the tripwire that exists
88
+ // to catch exactly this. A guard that only recognises the historical spelling is
89
+ // not a guard.
90
+ const REVOKE_RE =
91
+ /DELETE\s+FROM\s+govern(?:ance_role|ment_rank)_permissions\s+WHERE\s+(?:rank_key|role_key)\s*=\s*'([^']+)'\s+AND\s+permission_key\s*=\s*'([^']+)'/gi;
92
+
93
+ function revokeBlocksForRole(role) {
94
+ const blocks = [];
95
+ for (const [file, sql] of grantSqlByFile) {
96
+ const code = sql.replace(/--[^\n]*/g, '');
97
+ for (const m of code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))) {
98
+ if (m[1] === role) blocks.push({ file, key: m[2] });
99
+ }
100
+ }
101
+ return blocks;
102
+ }
103
+
104
+ // The EFFECTIVE grant set: walk the migrations in filename order, adding each
105
+ // file's grants and removing its revocations, so a key granted early and revoked
106
+ // later ends up absent — and one revoked and then deliberately re-granted ends up
107
+ // present. A set-difference over the whole union would get the first case right
108
+ // and the second silently wrong.
109
+ function effectiveKeysForRole(role) {
110
+ const grantRe = new RegExp(`SELECT '${role}', unnest\\(ARRAY\\[([\\s\\S]*?)\\]\\)`, 'g');
111
+ const held = new Set();
112
+ for (const [, sql] of grantSqlByFile) {
113
+ const code = sql.replace(/--[^\n]*/g, '');
114
+ for (const m of code.matchAll(grantRe)) {
115
+ for (const k of [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1])) held.add(RENAMED_KEYS.get(k) || k);
116
+ }
117
+ for (const m of code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))) {
118
+ if (m[1] === role) held.delete(RENAMED_KEYS.get(m[2]) || m[2]);
119
+ }
120
+ }
121
+ return held;
122
+ }
123
+
77
124
  test('the union of every grant migration matches RANK_SEED exactly (no drift)', () => {
78
125
  for (const role of RANK_ORDER) {
79
126
  const blocks = grantBlocksForRole(role);
@@ -82,26 +129,64 @@ test('the union of every grant migration matches RANK_SEED exactly (no drift)',
82
129
  assert.equal(b.keys.length, new Set(b.keys).size, `${role}: duplicate permission in the ${b.file} array`);
83
130
  }
84
131
  const granted = applyRenames(blocks.flatMap((b) => b.keys));
132
+ // The EFFECTIVE set — grants minus revocations, applied in file order. Since
133
+ // BV1.R21 (task 1003608) this is no longer the same thing as the union of the
134
+ // INSERTs: `version.create` is granted to metic by governance_007 and taken
135
+ // back by government_012.
136
+ const effective = effectiveKeysForRole(role);
85
137
  // set-equality (order in SQL is illustrative; the grant set is what matters)
86
- assert.deepEqual(new Set(granted), new Set(RANK_SEED[role]),
138
+ assert.deepEqual(effective, new Set(RANK_SEED[role]),
87
139
  `${role}: grant migrations drift from catalog RANK_SEED`);
88
140
  // no key re-granted in a LATER migration (a no-op INSERT that muddies the ledger)
89
141
  assert.equal(granted.length, new Set(granted).size, `${role}: the same permission is granted twice across migrations`);
90
- assert.equal(new Set(granted).size, RANK_SEED[role].length, `${role}: grant count mismatch`);
142
+ assert.equal(effective.size, RANK_SEED[role].length, `${role}: grant count mismatch`);
91
143
  }
92
144
  });
93
145
 
94
- // The union model above is only faithful while grant migrations are ADDITIVE. A future
95
- // migration that REVOKES (raising a floor) would make the union overstate what the DB
96
- // holds, so fail loudly here rather than let the guard quietly go wrong.
97
- test('no grant migration revokes the union model stays faithful', () => {
146
+ // This test used to assert that NO migration revokes, with the note: "teach
147
+ // grantBlocksForRole to subtract before allowing this". BV1.R21 (task 1003608) is
148
+ // the first revocation and the subtraction is now taught, so the tripwire becomes
149
+ // its own follow-through: every DELETE in a grant migration must be in the exact
150
+ // shape effectiveKeysForRole can read. A revoke the model cannot parse is worse
151
+ // than one it forbids — it would leave the union overstating the DB while every
152
+ // test stayed green, which is the failure the original note was written to avoid.
153
+ test('every revoking migration is in the shape the union model subtracts', () => {
98
154
  for (const [file, sql] of grantSqlByFile) {
99
155
  const code = sql.replace(/--[^\n]*/g, '');
100
- assert.ok(!/DELETE\s+FROM\s+governance_role_permissions/i.test(code),
101
- `${file} revokes a grant teach grantBlocksForRole to subtract before allowing this`);
156
+ // Only DELETEs against the PERMISSION tables matter here. A migration may
157
+ // legitimately delete from the ASSIGNMENT tables governance_005 re-syncs
158
+ // `governance_builder_roles`, which is who holds a rank, not what a rank
159
+ // holds — and that has nothing to do with the grant union.
160
+ const deletes = [...code.matchAll(/DELETE\s+FROM\s+(govern(?:ance_role|ment_rank)_permissions)\b/gi)];
161
+ if (deletes.length === 0) continue;
162
+ const parsed = [...code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))];
163
+ assert.equal(parsed.length, deletes.length,
164
+ `${file}: ${deletes.length} DELETE(s) but only ${parsed.length} parseable by REVOKE_RE — `
165
+ + 'the union model would silently overstate the grant set. Match the '
166
+ + "`DELETE FROM government_rank_permissions WHERE rank_key = '…' AND permission_key = '…'` shape.");
167
+ for (const m of parsed) {
168
+ assert.ok(RANK_ORDER.includes(m[1]), `${file}: revokes from unknown rank '${m[1]}'`);
169
+ }
102
170
  }
103
171
  });
104
172
 
173
+ test('a revocation actually removes the key from the effective set', () => {
174
+ // The specific fact BV1.R21 turned on, asserted directly rather than only as a
175
+ // consequence of the drift comparison: governance_007 grants `version.create`
176
+ // to metic, government_012 takes it back, and the effective set must not hold
177
+ // it — while archon, which was granted it by governance_006, keeps it.
178
+ assert.equal(effectiveKeysForRole('metic').has('version.create'), false,
179
+ 'ADR 0250 §6 raised version.create to the archon floor — a metic grant row would make that floor decorative');
180
+ assert.equal(effectiveKeysForRole('archon').has('version.create'), true,
181
+ 'archon must still hold it, or POST /versions is unreachable by anyone');
182
+ // …and the raw union still CONTAINS it, which is the whole reason the model
183
+ // needed to change: if this ever stops being true the revoke migration was
184
+ // edited away rather than superseded.
185
+ const rawUnion = applyRenames(grantBlocksForRole('metic').flatMap((b) => b.keys));
186
+ assert.ok(rawUnion.includes('version.create'),
187
+ 'the historical grant must remain in governance_007 — migrations are run-once and are never rewritten');
188
+ });
189
+
105
190
  test('the seed inserts exactly the four seeded rank-roles', () => {
106
191
  const roleRows = [...seedSql.matchAll(/\('(xenos|thetes|metic|archon)',\s*'[^']+',/g)].map((m) => m[1]);
107
192
  assert.deepEqual(new Set(roleRows), new Set(RANK_ORDER));
@@ -140,7 +225,14 @@ test('later grant migrations are idempotent + transactional', () => {
140
225
  const code = stripComments(sql);
141
226
  const inserts = code.match(/INSERT INTO/g) || [];
142
227
  const onConflict = code.match(/ON CONFLICT/g) || [];
143
- assert.ok(inserts.length > 0, `${file}: a grant migration must INSERT something`);
228
+ const revokes = [...code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))];
229
+ // A migration must DO something to the grant set — but since BV1.R21 (task
230
+ // 1003608) "something" includes taking a permission away, not only adding
231
+ // one. A REVOKE-only migration inserts nothing by design and needs no ON
232
+ // CONFLICT: deleting a row that is not there affects zero rows and raises
233
+ // nothing, so it is already naturally re-runnable.
234
+ assert.ok(inserts.length > 0 || revokes.length > 0,
235
+ `${file}: a grant migration must INSERT or REVOKE something`);
144
236
  assert.equal(inserts.length, onConflict.length, `${file}: every INSERT must be ON CONFLICT DO NOTHING`);
145
237
  assert.ok(/BEGIN;/.test(code) && /COMMIT;/.test(code), `${file}: must be transactional`);
146
238
  }