@bongos/core 1.19.591 → 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 (42) hide show
  1. package/.bongos-core.json +72 -42
  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 +4 -0
  14. package/migrations/core_233_versions_one_planning_idx.sql +47 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
  16. package/modules/government/catalog.js +20 -11
  17. package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
  18. package/modules/hall-ui/public/goals-render.js +182 -6
  19. package/modules/hall-ui/public/goals.css +51 -0
  20. package/modules/hall-ui/public/roadmap.css +5 -0
  21. package/modules/hall-ui/public/roadmap.js +25 -0
  22. package/modules/hall-ui/public/task-detail.js +22 -11
  23. package/modules/lifecycle/db-goals.js +34 -2
  24. package/modules/lifecycle/db-versions.js +195 -3
  25. package/modules/lifecycle/db.js +4 -1
  26. package/modules/lifecycle/routes/goals.js +15 -1
  27. package/modules/lifecycle/routes/version-route-authz.js +171 -1
  28. package/modules/lifecycle/routes/versions.js +130 -10
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/scripts/gds/triage.js +60 -26
  32. package/src/module-api.js +1 -1
  33. package/tests/goal_archive_hall.mjs +198 -0
  34. package/tests/government_require_permission.mjs +7 -3
  35. package/tests/government_seed.mjs +101 -9
  36. package/tests/one_planning_version.mjs +25 -8
  37. package/tests/promote_goal_id.mjs +85 -30
  38. package/tests/publish_reconciler.mjs +35 -5
  39. package/tests/task_detail_ui.mjs +36 -9
  40. package/tests/version_build_slot.mjs +157 -0
  41. package/tests/version_close_route.mjs +261 -0
  42. package/tests/version_override_visibility.mjs +176 -0
@@ -0,0 +1,157 @@
1
+ // tests/version_build_slot.mjs — exactly one version builds at a time
2
+ // (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9).
3
+ //
4
+ // THE INVARIANT EVERY OTHER RULE IN THIS GOAL STANDS ON. R03 refuses a goal on a
5
+ // version that is not `planning`, which presumes "the version that is building"
6
+ // names ONE row. With two, it does not: `currentBuildingVersionId` resolves the
7
+ // ambiguity with `ORDER BY started_at DESC ... LIMIT 1`, so it picks one and says
8
+ // nothing, and R03's gate silently has two answers depending on which row won.
9
+ // The live instance is in exactly that state today (BONGOS-V1 and CB-V1 both
10
+ // `building`) — task 1003614 (R27) resolves it; this file pins that a third can
11
+ // never be created.
12
+ //
13
+ // WHY THE PREDICATE IS TESTED, NOT THE ROUTE. authorizeVersionBuild is pure — no
14
+ // express, no DB, no pool — so its whole truth table runs here, including the
15
+ // shapes a live-Postgres test would never bother to set up: a null in the rows, a
16
+ // non-array, an absent argument. The ROUTE's job is only to gather the facts and
17
+ // render the verdict verbatim, and the source assertions at the bottom pin that
18
+ // it does exactly that and nothing else. Inlining the rule in the handler would
19
+ // leave it testable only by string-matching the source, which passes just as
20
+ // happily on a wrong branch.
21
+ //
22
+ // Run: node --test tests/version_build_slot.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { readFileSync } from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+
29
+ process.env.NODE_ENV = 'test';
30
+ const require = createRequire(import.meta.url);
31
+ const { authorizeVersionBuild, authorizeVersionCreate } =
32
+ require('../modules/lifecycle/routes/version-route-authz.js');
33
+
34
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
35
+ const ROUTES = src('modules/lifecycle/routes/versions.js');
36
+ const MIGRATION = src('migrations/core_233_versions_one_planning_idx.sql');
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // The rule
40
+ // ---------------------------------------------------------------------------
41
+
42
+ test('an empty building slot allows the version', () => {
43
+ assert.deepEqual(authorizeVersionBuild({ building: [] }), { ok: true });
44
+ });
45
+
46
+ test('no argument at all is allowed — the default is "nothing is building"', () => {
47
+ assert.deepEqual(authorizeVersionBuild({}), { ok: true });
48
+ });
49
+
50
+ test('one building version refuses, with a named 409', () => {
51
+ const v = authorizeVersionBuild({ building: [{ id: 'BONGOS-V1', name: 'Cloud Bongos — platform (V1)' }] });
52
+ assert.equal(v.ok, false);
53
+ assert.equal(v.status, 409);
54
+ assert.equal(v.body.error, 'building_version_exists');
55
+ });
56
+
57
+ test('the refusal NAMES the version holding the slot, in the message and the payload', () => {
58
+ const v = authorizeVersionBuild({ building: [{ id: 'BONGOS-V1', name: 'platform' }] });
59
+ // The whole reason the rows are passed instead of a boolean: a caller told only
60
+ // "no" has to go look up which version to close, and a caller who looks it up
61
+ // is one step from cutting a version to get around the gate anyway.
62
+ assert.match(v.body.message, /BONGOS-V1/);
63
+ assert.deepEqual(v.body.building, [{ id: 'BONGOS-V1', name: 'platform' }]);
64
+ });
65
+
66
+ test('the live two-building-version violation refuses, and names the first', () => {
67
+ const v = authorizeVersionBuild({
68
+ building: [{ id: 'BONGOS-V1', name: 'platform' }, { id: 'CB-V1', name: 'dogfood' }],
69
+ });
70
+ assert.equal(v.ok, false);
71
+ assert.equal(v.body.building.length, 2);
72
+ assert.match(v.body.message, /BONGOS-V1/);
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Shapes a live-Postgres test would not set up
77
+ // ---------------------------------------------------------------------------
78
+
79
+ test('nulls in the rows do not count as a version', () => {
80
+ assert.deepEqual(authorizeVersionBuild({ building: [null, undefined] }), { ok: true });
81
+ });
82
+
83
+ test('a non-array building is treated as empty, not as truthy', () => {
84
+ // A caller that hands over a bare count, or a bad DB read that returns an
85
+ // object, must not be able to LOOSEN the rule by accident — but neither should
86
+ // it hard-refuse a legitimate create. Both degrade to "nothing is building",
87
+ // which is the state the pre-check will then re-derive from the real table.
88
+ for (const bad of [1, 'yes', {}, null, undefined]) {
89
+ assert.deepEqual(authorizeVersionBuild({ building: bad }), { ok: true }, `building=${JSON.stringify(bad)}`);
90
+ }
91
+ });
92
+
93
+ test('the two slot rules are independent — building says nothing about planning', () => {
94
+ // Guards against a future refactor collapsing them into one status-branching
95
+ // function: the close flow (task 1003607, R20) promotes a version to `building`
96
+ // with no caller-supplied status to branch on.
97
+ assert.deepEqual(authorizeVersionBuild({ building: [] }), { ok: true });
98
+ assert.equal(authorizeVersionCreate({ status: 'planning', planning: [{ id: 'X' }] }).ok, false);
99
+ assert.equal(authorizeVersionCreate({ status: 'building', planning: [{ id: 'X' }] }).ok, true);
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // The wiring: the route gathers the facts and renders the verdict
104
+ // ---------------------------------------------------------------------------
105
+
106
+ test('POST /versions consults the building slot, and only when status is building', () => {
107
+ assert.match(ROUTES, /authorizeVersionBuild\(\{/);
108
+ // The read is CONDITIONAL on the requested status, mirroring the planning one:
109
+ // creating a `shipped` or `frozen` row is archival bookkeeping and must not pay
110
+ // for a query, nor be refused by a rule about a slot it is not entering.
111
+ assert.match(ROUTES, /status === 'building' \? await db\.versionsWithStatus\('building'\) : \[\]/);
112
+ });
113
+
114
+ test('the route renders the refusal verbatim — it does not restate the rule', () => {
115
+ assert.match(ROUTES, /if \(!buildDecision\.ok\) \{\s*return res\.status\(buildDecision\.status\)\.json\(buildDecision\.body\);/);
116
+ // No second copy of the message anywhere in the route.
117
+ assert.equal(ROUTES.includes('One version builds at a time'), false);
118
+ });
119
+
120
+ test('a 23505 on the planning index renders as the planning rule, not "id taken"', () => {
121
+ // The race-loser's pre-check passed because the winner had not committed. If
122
+ // this fell through to `version_exists` the caller would be told to rename a
123
+ // version that was never the problem, and would rename and retry forever.
124
+ assert.match(ROUTES, /err\.constraint\s*\|\|\s*''\)\s*===\s*'versions_one_planning_idx'/);
125
+ // And it renders that refusal BY RE-RUNNING THE GATE, never by writing the
126
+ // wording a second time — tests/one_planning_version.mjs pins the absence of
127
+ // the literal `planning_version_exists` in this handler, and an earlier draft
128
+ // of this very test asserted its presence, which is how the two suites caught
129
+ // each other. The gate owns the words; the route owns only the plumbing.
130
+ assert.match(ROUTES, /const raced = authorizeVersionCreate\(\{/);
131
+ assert.match(ROUTES, /return res\.status\(raced\.status\)\.json\(raced\.body\);/);
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // The migration
136
+ // ---------------------------------------------------------------------------
137
+
138
+ test('core_233 creates the planning index and NOT the building one', () => {
139
+ assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS versions_one_planning_idx/);
140
+ assert.match(MIGRATION, /WHERE status = 'planning'/);
141
+ // The deliberate omission ADR 0263 §9 asked for and the live data forbids. If a
142
+ // later edit adds it here, this test is the reminder that the migration will
143
+ // fail on any instance with two building versions — and that a skipped-with-a-
144
+ // NOTICE variant marks itself applied and never arms.
145
+ assert.equal(MIGRATION.includes('versions_one_building_idx'), false,
146
+ 'the building index ships with R27 (task 1003614), in the migration that makes it legal');
147
+ });
148
+
149
+ test('the migration records WHY the building index is not here', () => {
150
+ // The reasoning is the artifact: without it the next reader files this as an
151
+ // oversight and "fixes" it into a wedged deploy.
152
+ assert.match(MIGRATION, /1003614/);
153
+ });
154
+
155
+ test('the route points at the same reason', () => {
156
+ assert.match(ROUTES, /1003614/);
157
+ });
@@ -0,0 +1,261 @@
1
+ // tests/version_close_route.mjs — a version can finally close
2
+ // (BV1.R12, task 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
3
+ //
4
+ // THE HOLE. Tasks close, criteria close, goals close — and a version only ever
5
+ // moved by hand-run SQL on the droplet. `scripts/gds/version-close.js` still
6
+ // prints that the API exposes no version status transitions, and the
7
+ // `version.close` permission has sat at the archon floor since BV1.R105 with
8
+ // NOTHING referencing it. A thing with no closing move does not close, which is
9
+ // how two versions ended up building on one project at the same time.
10
+ //
11
+ // Two things this file is careful about, because both are places a correct
12
+ // planner and a correct writer still add up to a broken endpoint:
13
+ //
14
+ // THE MAINTENANCE GOAL IS FILTERED BY THE READ, not by the planner. It is
15
+ // exempt from the close count and always carries forward (ADR 0263 §7), so it
16
+ // must never appear in a refusal asking the caller to decide about it — asking
17
+ // someone to disposition a goal they are not allowed to disposition is worse
18
+ // than not asking.
19
+ //
20
+ // ROLL_FORWARD WITHOUT A PLANNING VERSION IS A REFUSAL, never an auto-create.
21
+ // Cutting the successor implicitly inside a close is exactly the "fake hotfix
22
+ // version" ADR 0250 §3 built the override counter to prevent.
23
+ //
24
+ // Run: node tests/version_close_route.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import { readFileSync } from 'node:fs';
29
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
30
+
31
+ process.env.NODE_ENV = 'test';
32
+ const require = createRequire(import.meta.url);
33
+ const { planVersionClose, rollForwardNeedsPlanningVersion } = require('../modules/lifecycle/routes/version-route-authz.js');
34
+ const { closeVersion, maintenanceGoalExemptSql, openNonMaintenanceGoals } = require('../modules/lifecycle/db.js');
35
+ const { test, summary } = makeRunner();
36
+
37
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
38
+ const BUILDING = { id: 'BONGOS-V1', status: 'building' };
39
+ const GOALS = (...n) => n.map((i) => ({ id: String(i), title: `goal ${i}`, open_tasks: 0 }));
40
+ const plan = (over = {}) => planVersionClose({ version: BUILDING, openGoals: GOALS(1, 2), reason: 'V1 is done', ...over });
41
+
42
+ function txPool(handler) {
43
+ const c = makeSqlAwareClient(handler);
44
+ return { connect: c.connect, query: (sql, p) => c._client.query(sql, p), queries: c._client.queries };
45
+ }
46
+ const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
47
+
48
+ // ---- only a building version closes ----------------------------------------
49
+
50
+ await test('a missing version is a 404, not a refusal about its state', () => {
51
+ const r = planVersionClose({ version: null });
52
+ assert.equal(r.ok, false); assert.equal(r.status, 404); assert.equal(r.body.error, 'version_not_found');
53
+ });
54
+
55
+ await test('only a BUILDING version closes — every other status is refused BY NAME', () => {
56
+ for (const status of ['planning', 'shipped', 'frozen', 'nonsense', null]) {
57
+ const r = planVersionClose({ version: { id: 'V', status } });
58
+ assert.equal(r.ok, false, `'${status}' must not close`);
59
+ assert.equal(r.body.error, 'version_not_building');
60
+ assert.equal(r.body.status, status, 'the refusal says WHICH status refused, rather than asserting one');
61
+ }
62
+ });
63
+
64
+ await test('a building version holding nothing open closes in ONE call', () => {
65
+ const r = planVersionClose({ version: BUILDING, openGoals: [] });
66
+ assert.equal(r.ok, true);
67
+ assert.deepEqual(r.plan, [], 'and needs no reason — nothing is being cut');
68
+ });
69
+
70
+ // ---- the two-step ----------------------------------------------------------
71
+
72
+ await test('open goals refuse with a NAMED 409 that RETURNS the goals', () => {
73
+ const r = plan({ dispositions: null });
74
+ assert.equal(r.status, 409);
75
+ assert.equal(r.body.error, 'version_holds_open_goals');
76
+ assert.deepEqual(r.body.details.goals.map((g) => g.id), ['1', '2'],
77
+ 'the caller decides per goal, so the goals travel with the refusal');
78
+ });
79
+
80
+ await test('a partial map is refused — nothing is carried or cut by omission', () => {
81
+ const r = plan({ dispositions: { 1: { verb: 'abandon' } } });
82
+ assert.equal(r.ok, false);
83
+ assert.equal(r.body.error, 'disposition_incomplete');
84
+ assert.deepEqual(r.body.details.goals.map((g) => g.id), ['2'], 'and it names which');
85
+ });
86
+
87
+ await test('cutting a goal without a reason is refused — the silent discard, one tier up', () => {
88
+ for (const reason of ['', ' ', null, undefined]) {
89
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } }, reason });
90
+ assert.equal(r.ok, false, `reason ${JSON.stringify(reason)} must not pass`);
91
+ assert.equal(r.body.error, 'close_reason_required');
92
+ }
93
+ });
94
+
95
+ await test('only roll_forward and abandon are verbs', () => {
96
+ for (const verb of ['carry', 'ROLL_FORWARD', 'delete', '', null, 7]) {
97
+ const r = plan({ dispositions: { 1: { verb }, 2: { verb: 'abandon' } } });
98
+ assert.equal(r.ok, false, `verb ${JSON.stringify(verb)} must not pass`);
99
+ assert.equal(r.body.error, 'bad_disposition');
100
+ }
101
+ });
102
+
103
+ await test('a complete map plans one step per goal, in the order the goals came in', () => {
104
+ const r = plan({ dispositions: { 1: { verb: 'roll_forward' }, 2: { verb: 'abandon' } } });
105
+ assert.equal(r.ok, true);
106
+ assert.deepEqual(r.plan, [{ goalId: 1, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }]);
107
+ });
108
+
109
+ // ---- roll_forward needs somewhere to go ------------------------------------
110
+
111
+ await test('roll_forward with NO planning version is refused, never auto-created', () => {
112
+ const r = rollForwardNeedsPlanningVersion({ plan: [{ goalId: 1, verb: 'roll_forward' }], planning: [] });
113
+ assert.ok(r, 'it must refuse');
114
+ assert.equal(r.status, 409);
115
+ assert.equal(r.body.error, 'no_planning_version');
116
+ assert.match(r.body.message, /Scope the next version first/,
117
+ 'and say what to do — auto-cutting one here is the escape hatch ADR 0250 closed');
118
+ });
119
+
120
+ await test('an all-abandon close needs no planning version at all', () => {
121
+ assert.equal(rollForwardNeedsPlanningVersion({ plan: [{ goalId: 1, verb: 'abandon' }], planning: [] }), null);
122
+ });
123
+
124
+ await test('roll_forward WITH a planning version proceeds', () => {
125
+ assert.equal(rollForwardNeedsPlanningVersion({
126
+ plan: [{ goalId: 1, verb: 'roll_forward' }], planning: [{ id: 'BONGOS-V2' }],
127
+ }), null);
128
+ });
129
+
130
+ // ---- the maintenance exemption ---------------------------------------------
131
+
132
+ await test('the exemption has ONE definition, and the open-goals read is its only caller', () => {
133
+ // R18 (task 1003605) swaps this body for `NOT g.is_maintenance` once the column
134
+ // exists. One call site is what makes that a one-line change instead of a hunt.
135
+ assert.equal(maintenanceGoalExemptSql('$1'), "g.title <> ($1 || ' — maintenance')");
136
+ const dbv = src('modules/lifecycle/db-versions.js');
137
+ const uses = (dbv.match(/maintenanceGoalExemptSql\(/g) || []).length;
138
+ assert.equal(uses, 3, 'declaration + the open-goals read + the post-apply re-count, and nothing else');
139
+ });
140
+
141
+ await test('the open-goals read excludes the maintenance goal and counts unfinished work', async () => {
142
+ const pool = txPool(() => ({ rows: [] }));
143
+ await openNonMaintenanceGoals('BONGOS-V1', { pool });
144
+ const q = flat(pool.queries[0]);
145
+ assert.match(q, /maintenance/, 'the maintenance goal must not be offered for disposition');
146
+ assert.match(q, /status = 'open'/);
147
+ assert.match(q, /open_tasks/, 'each goal carries how much work it still holds — most of the decision');
148
+ assert.match(q, /NOT IN \('shipped', 'abandoned'\)/, 'and counts it with the shared terminal set');
149
+ });
150
+
151
+ // ---- the write is one transaction ------------------------------------------
152
+
153
+ function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } = {}) {
154
+ return txPool((sql) => {
155
+ const q = String(sql).replace(/\s+/g, ' ');
156
+ if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
157
+ if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
158
+ if (/UPDATE tasks SET status = 'abandoned'/.test(q)) return { rows: [{ id: 11 }, { id: 12 }] };
159
+ if (/count\(\*\)::int AS n FROM goals/.test(q)) return { rows: [{ n: remaining }] };
160
+ if (/UPDATE versions SET status = 'shipped'/.test(q)) return { rows: [{ id: 'V1', status: 'shipped' }] };
161
+ return { rows: [] };
162
+ });
163
+ }
164
+
165
+ await test('the whole close runs inside ONE transaction, version locked first', async () => {
166
+ const pool = closePool();
167
+ const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'done' }, { pool });
168
+ assert.equal(out.version.status, 'shipped');
169
+ assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'abandon', result: 'archived', tasks_abandoned: 2 }]);
170
+ const sqls = pool.queries.map(flat);
171
+ assert.equal(sqls.filter((q) => q === 'BEGIN').length, 1);
172
+ assert.equal(sqls.filter((q) => q === 'COMMIT').length, 1);
173
+ const lock = sqls.findIndex((q) => /FROM versions WHERE id = \$1 FOR UPDATE/.test(q));
174
+ const firstWrite = sqls.findIndex((q) => /^UPDATE tasks/.test(q));
175
+ assert.ok(lock !== -1 && lock < firstWrite, 'lock the version, then apply — a goal must not be created mid-close');
176
+ assert.ok(sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q)) < sqls.indexOf('COMMIT'));
177
+ });
178
+
179
+ await test('an abandoned goal takes its open tasks with it, stamped with the close reason', async () => {
180
+ const pool = closePool();
181
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'cut in the ten-areas review' }, { pool });
182
+ const kill = pool.queries.find((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q)));
183
+ assert.ok(kill, 'the tasks must not survive their goal');
184
+ assert.equal(kill.params[1], 'Abandoned: cut in the ten-areas review',
185
+ 'the same stamp R14 writes — the ledger reads identically whichever door retired the work');
186
+ assert.match(flat(kill), /NOT IN \('shipped', 'abandoned'\)/, 'and only the unfinished ones');
187
+ });
188
+
189
+ await test('a roll_forward abandons NOTHING and says the successor is still pending', async () => {
190
+ const pool = closePool();
191
+ const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
192
+ assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'roll_forward', result: 'carried_pending_successor' }]);
193
+ assert.equal(pool.queries.some((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q))), false,
194
+ 'carrying a goal forward must never cut its work');
195
+ assert.equal(pool.queries.some((q) => /UPDATE goals SET status = 'archived'/.test(flat(q))), false);
196
+ });
197
+
198
+ await test('a carried goal is EXCLUDED from the post-apply re-count, or a close could never carry anything', async () => {
199
+ const pool = closePool();
200
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
201
+ const recount = pool.queries.find((q) => /count\(\*\)::int AS n FROM goals/.test(flat(q)));
202
+ assert.match(flat(recount), /NOT \(g\.id = ANY\(\$2::bigint\[\]\)\)/);
203
+ assert.deepEqual(recount.params[1], [1], 'the carried goal is expected to still be open');
204
+ });
205
+
206
+ await test('the re-check runs against committed state and rolls back rather than half-closing', async () => {
207
+ const pool = closePool({ remaining: 2 });
208
+ await assert.rejects(
209
+ closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool }),
210
+ (e) => e.code === 'CLOSE_INCOMPLETE_AFTER_APPLY' && e.remaining === 2
211
+ );
212
+ const sqls = pool.queries.map(flat);
213
+ assert.ok(sqls.includes('ROLLBACK'));
214
+ assert.equal(sqls.some((q) => /UPDATE versions SET status = 'shipped'/.test(q)), false, 'the version is not closed');
215
+ });
216
+
217
+ await test('a version that stopped building mid-call rolls back', async () => {
218
+ const pool = closePool({ status: 'shipped' });
219
+ await assert.rejects(
220
+ closeVersion({ versionId: 'V1', plan: [], reason: 'r' }, { pool }),
221
+ (e) => e.code === 'VERSION_NOT_BUILDING' && e.versionStatus === 'shipped'
222
+ );
223
+ assert.ok(pool.queries.map(flat).includes('ROLLBACK'));
224
+ });
225
+
226
+ await test('the promotion seam runs INSIDE the transaction, after the flip', async () => {
227
+ // R20 (task 1003607) hooks here. ensureMaintenanceGoal returns null for a
228
+ // non-building version, so a carry-over run before the flip silently carries
229
+ // nothing — the ordering is load-bearing, not stylistic (ADR 0263 §8).
230
+ const pool = closePool();
231
+ let sawStatus = null; let ranAt = -1;
232
+ await closeVersion({ versionId: 'V1', plan: [], reason: 'r' }, {
233
+ pool,
234
+ onClosed: async (client) => { ranAt = pool.queries.length; sawStatus = 'called'; await client.query('SELECT 1 AS seam'); return { promoted: null }; },
235
+ });
236
+ const sqls = pool.queries.map(flat);
237
+ assert.equal(sawStatus, 'called', 'the seam must be offered');
238
+ assert.ok(sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q)) < ranAt, 'after the flip');
239
+ assert.ok(sqls.indexOf('SELECT 1 AS seam') < sqls.indexOf('COMMIT'), 'and before the commit');
240
+ });
241
+
242
+ // ---- the route is wired to all of it ---------------------------------------
243
+
244
+ await test('the route declares its body, so a typo cannot read as "no map given"', () => {
245
+ const V = src('modules/lifecycle/routes/versions.js');
246
+ const route = V.slice(V.indexOf("router.post('/versions/:id/close'"));
247
+ assert.match(route, /validateOrRespond\(req, res, \{[\s\S]*?dispositions: \{ type: 'object' \}/);
248
+ assert.match(route, /requirePermission\('version\.close'\)/, 'behind the permission nothing referenced until now');
249
+ assert.match(route, /db\.openNonMaintenanceGoals\(versionId\)/, 'the maintenance goal is filtered by the READ');
250
+ assert.match(route, /rollForwardNeedsPlanningVersion/);
251
+ const planIdx = route.indexOf('planVersionClose(');
252
+ const writeIdx = route.indexOf('db.closeVersion(');
253
+ assert.ok(planIdx !== -1 && writeIdx !== -1 && planIdx < writeIdx, 'decide, then write');
254
+ });
255
+
256
+ await test('version-close.js no longer being the only door is R23; this route exists for it to call', () => {
257
+ const V = src('modules/lifecycle/routes/versions.js');
258
+ assert.match(V, /router\.post\('\/versions\/:id\/close'/, 'the route the CLI will drive');
259
+ });
260
+
261
+ summary();
@@ -0,0 +1,176 @@
1
+ // tests/version_override_visibility.mjs — an override nobody can count is
2
+ // indistinguishable from no gate at all (BV1.R15, task 1003602, goal 1000086,
3
+ // ADR 0250 §3).
4
+ //
5
+ // THE RULE R03 CREATED, AND THE HOLE R15 CLOSES. A goal may only be added to a
6
+ // version still in `planning`; an Archon may override that and admit one into a
7
+ // version already building, recording a reason. R07 (task 1003594) built the
8
+ // record and put the count on `GET /versions/:id/progress`. That route is not a
9
+ // surface anyone opens — the roadmap reads the ROLLUP, the board reads
10
+ // `listGoals`, and neither carried a single bit about admissions. So the
11
+ // override existed, was recorded, and was invisible everywhere a person looks,
12
+ // which is the same accountability as not recording it.
13
+ //
14
+ // WHAT THIS FILE PINS: the count rides the rollup the hall actually reads; the
15
+ // board can tell an admitted goal from a normal one; the detail names WHO and
16
+ // WHY; and the zero case renders nothing, so the line is a signal rather than
17
+ // chrome.
18
+ //
19
+ // Run: node --test tests/version_override_visibility.mjs
20
+
21
+ import assert from 'node:assert/strict';
22
+ import { test } from 'node:test';
23
+ import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+ import { makeSqlAwareClient } from './helpers.mjs';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+ const { listGoals, listVersionAdmissions, versionProgress } = require('../modules/lifecycle/db.js');
30
+
31
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
32
+ const GOALS_RENDER = src('modules/hall-ui/public/goals-render.js');
33
+ const ROADMAP = src('modules/hall-ui/public/roadmap.js');
34
+
35
+ function fakePool(handler) {
36
+ const c = makeSqlAwareClient(handler);
37
+ return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // The rollup — the surface the roadmap reads
42
+ // ---------------------------------------------------------------------------
43
+
44
+ test('versionProgress asks for the admission count', async () => {
45
+ let seen = null;
46
+ const pool = fakePool((s) => { seen = s; return { rows: [] }; });
47
+ await versionProgress({ pool });
48
+ assert.match(seen, /lifecycle_goal_version_admissions/,
49
+ 'the rollup must carry the override count — R07 put it only on /versions/:id/progress, which the roadmap never calls');
50
+ assert.match(seen, /COALESCE\(adm\.admission_count, 0\)/,
51
+ 'a version nobody widened has no row in the aggregate, so the zero comes from COALESCE, not from a manufactured join row');
52
+ });
53
+
54
+ test('versionProgress returns admission_count as a number, defaulting to 0', async () => {
55
+ const pool = fakePool(() => ({
56
+ rows: [{
57
+ version_id: 'BONGOS-V1', name: 'platform', status: 'building',
58
+ shipped_count: 1, total_count: 2, shipped_weight: 1, total_weight: 2,
59
+ done_when: null, criteria_count: 3,
60
+ lifecycle_completed_count: 0, lifecycle_confirmed_count: 0, lifecycle_shipped_count: 1,
61
+ admission_count: 2,
62
+ }],
63
+ }));
64
+ const [row] = await versionProgress({ pool });
65
+ assert.equal(row.admission_count, 2);
66
+ assert.equal(typeof row.admission_count, 'number',
67
+ 'the client compares it to 0; a string "2" from the pg driver would render but never equal 0');
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // The board — which goals were admitted
72
+ // ---------------------------------------------------------------------------
73
+
74
+ test('listGoals carries an `admitted` flag', async () => {
75
+ let seen = null;
76
+ const pool = fakePool((s) => { seen = s; return { rows: [] }; });
77
+ await listGoals({ versionId: 'BONGOS-V1' }, { pool });
78
+ assert.match(seen, /EXISTS \(SELECT 1 FROM lifecycle_goal_version_admissions/);
79
+ assert.match(seen, /AS admitted/);
80
+ });
81
+
82
+ test('the board flag is EXISTS, not a join that could duplicate a goal', async () => {
83
+ // A LEFT JOIN would return one row per admission. There is one today, but the
84
+ // table has no unique constraint on goal_id, so a second admission row would
85
+ // silently make the goal appear twice in the board's list.
86
+ let seen = null;
87
+ const pool = fakePool((s) => { seen = s; return { rows: [] }; });
88
+ await listGoals({}, { pool });
89
+ assert.equal(/LEFT JOIN lifecycle_goal_version_admissions/.test(seen), false);
90
+ });
91
+
92
+ test('listGoals still filters and orders as before', async () => {
93
+ let seen = null;
94
+ const pool = fakePool((s) => { seen = s; return { rows: [] }; });
95
+ await listGoals({ versionId: 'X', status: 'open', limit: 5 }, { pool });
96
+ assert.match(seen, /version_id = \$1/);
97
+ assert.match(seen, /status = \$2/);
98
+ // Unqualified, exactly as before: the admissions EXISTS names `goals.id` in
99
+ // full so no other line of this query had to change. tests/goal_tier.mjs pins
100
+ // this literal ordering text.
101
+ assert.match(seen, /ORDER BY version_id, sort_order, created_at, id/);
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // The detail — by whom, and why
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test('listVersionAdmissions resolves the admitter to a NAME', async () => {
109
+ let seen = null;
110
+ const pool = fakePool((s) => { seen = s; return { rows: [] }; });
111
+ await listVersionAdmissions('BONGOS-V1', { pool });
112
+ // ADR 0250 §3 asks "by whom". A numeric builder id does not answer it, and
113
+ // making every caller issue a second lookup per row is how a counter stops
114
+ // being read.
115
+ assert.match(seen, /admitted_by_login/);
116
+ assert.match(seen, /admitted_by_name/);
117
+ });
118
+
119
+ test('an admission whose admitter was offboarded still appears', async () => {
120
+ // The one failure mode that matters: an inner JOIN would DROP the row, hiding
121
+ // exactly the history someone with an interest in hiding it would target.
122
+ let seen = null;
123
+ const pool = fakePool((s) => { seen = s; return { rows: [] }; });
124
+ await listVersionAdmissions('BONGOS-V1', { pool });
125
+ assert.match(seen, /LEFT JOIN builders b ON b\.id = a\.admitted_by/);
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // The hall
130
+ // ---------------------------------------------------------------------------
131
+
132
+ test('the goal detail renders WHO admitted it', () => {
133
+ assert.match(GOALS_RENDER, /goal-badge--admitted/);
134
+ assert.match(GOALS_RENDER, /admitted_by_name \|\| adm\.admitted_by_login \|\| \('builder ' \+ adm\.admitted_by\)/,
135
+ 'falls back through name → login → id so an offboarded admitter still renders as someone');
136
+ });
137
+
138
+ test('the REASON is rendered, not hidden in a title attribute', () => {
139
+ // A tooltip is invisible on touch and to anyone not already suspicious. An
140
+ // override you have to hover to justify is the same unaccountable override.
141
+ assert.match(GOALS_RENDER, /why\.className = 'goal-admission';/);
142
+ assert.match(GOALS_RENDER, /adm\.reason \? ` — \$\{escapeHtml\(String\(adm\.reason\)\)\}`/);
143
+ });
144
+
145
+ test('an admission with no recorded reason says so rather than rendering blank', () => {
146
+ assert.match(GOALS_RENDER, /no reason recorded/);
147
+ });
148
+
149
+ test('a normally-created goal renders nothing at all', () => {
150
+ // `admitted` is null for every goal that went through the gate — the positive
151
+ // fact "this did not skip it", not missing data.
152
+ assert.match(GOALS_RENDER, /const adm = d\.admitted;/);
153
+ assert.match(GOALS_RENDER, /const admittedSpan = adm\s*\n?\s*\?/);
154
+ assert.match(GOALS_RENDER, /if \(adm\) \{/);
155
+ });
156
+
157
+ test('the board renders one chip, and does not leak the reason onto every row', () => {
158
+ assert.match(GOALS_RENDER, /fact-chip--admitted/);
159
+ assert.match(GOALS_RENDER, /g\.admitted\s*\n?\s*\?/);
160
+ // The chip's own markup must not interpolate a reason: listGoals deliberately
161
+ // returns a boolean, and an Archon's free text does not belong on a list row.
162
+ const chip = GOALS_RENDER.slice(GOALS_RENDER.indexOf('const admittedChip'), GOALS_RENDER.indexOf('const factsHtml'));
163
+ assert.equal(/reason/.test(chip), false);
164
+ });
165
+
166
+ test('the roadmap renders the count only when it is above zero', () => {
167
+ assert.match(ROADMAP, /function admissionNote\(v\)/);
168
+ assert.match(ROADMAP, /if \(n === 0\) return '';/,
169
+ 'a permanent "0 overrides" line is chrome a reader learns to skip — and would go on skipping when it became 2');
170
+ assert.match(ROADMAP, /admissions: Number\(p\.admission_count\) \|\| 0/);
171
+ assert.match(ROADMAP, /\$\{admissionNote\(v\)\}/, 'the helper must actually be called from the goals block');
172
+ });
173
+
174
+ test('the roadmap note pluralises, so "1 goals" never ships', () => {
175
+ assert.match(ROADMAP, /n === 1 \? '' : 's'/);
176
+ });