@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.
- package/.bongos-core.json +61 -41
- package/.claude/skills/planning-session/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +3 -1
- package/clients/bongos-client/index.cjs +3 -1
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +3 -1
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
- package/docs/api/openapi.json +103 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +135 -116
- package/docs/copy-registry.json +319 -138
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
- package/modules/government/catalog.js +20 -11
- package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
- package/modules/hall-ui/public/goals-render.js +182 -6
- package/modules/hall-ui/public/goals.css +51 -0
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/hall-ui/public/task-detail.js +22 -11
- package/modules/lifecycle/db-goals.js +34 -2
- package/modules/lifecycle/db-versions.js +195 -3
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/routes/goals.js +15 -1
- package/modules/lifecycle/routes/version-route-authz.js +131 -1
- package/modules/lifecycle/routes/versions.js +79 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +60 -26
- package/src/module-api.js +1 -1
- package/tests/goal_archive_hall.mjs +198 -0
- package/tests/government_require_permission.mjs +7 -3
- package/tests/government_seed.mjs +101 -9
- package/tests/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_close_route.mjs +261 -0
- package/tests/version_override_visibility.mjs +176 -0
package/scripts/gds/triage.js
CHANGED
|
@@ -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 === '')
|
|
226
|
-
|
|
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
|
|
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)
|
|
243
|
-
//
|
|
244
|
-
// goal-less create into
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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
|
|
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
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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}
|
|
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.
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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, /<b>/);
|
|
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(
|
|
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(
|
|
142
|
+
assert.equal(effective.size, RANK_SEED[role].length, `${role}: grant count mismatch`);
|
|
91
143
|
}
|
|
92
144
|
});
|
|
93
145
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
}
|