@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
@@ -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
  }
@@ -141,14 +141,31 @@ test('the default status is planning, so an unqualified create hits the gate', (
141
141
  assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
142
142
  });
143
143
 
144
- test('the accepted race is described as unmitigated, because it is', () => {
145
- // An earlier draft of this comment said the race was "left to a partial unique
146
- // index" which reads as though one exists. It does not, and a future reader
147
- // taking that at face value would believe two concurrent creates cannot both
148
- // slip through. Pinned so the honest wording cannot quietly regress.
149
- assert.match(ROUTE, /RACE, ACCEPTED AND UNMITIGATED/);
150
- assert.match(ROUTE, /no database\s*\n?\s*\/\/ constraint backs it/,
151
- 'the comment must say plainly that nothing catches it');
144
+ test('the planning race is now BACKED, and the comment says so honestly', () => {
145
+ // WHAT THIS TEST USED TO ASSERT, AND WHY IT FLIPPED. Until BV1.R19 (task
146
+ // 1003606) this pinned the words "RACE, ACCEPTED AND UNMITIGATED", because an
147
+ // earlier draft of the comment claimed the race was "left to a partial unique
148
+ // index" when no such index existed a future reader taking that at face
149
+ // value would have believed two concurrent creates could not both slip
150
+ // through. R19 shipped the index (migration core_233), so the old wording is
151
+ // now the dishonest one. The TEST'S PURPOSE is unchanged: the comment must
152
+ // describe the mitigation that actually exists, no more and no less.
153
+ assert.match(ROUTE, /THE RACE IS CLOSED/);
154
+ assert.match(ROUTE, /versions_one_planning_idx/,
155
+ 'the comment must name the index that backs it, so the claim is checkable');
156
+ assert.equal(/RACE, ACCEPTED AND UNMITIGATED/.test(ROUTE), false,
157
+ 'the planning race is mitigated as of R19 — the old wording would now under-claim');
158
+ });
159
+
160
+ test('the BUILDING half is still unmitigated, and the comment does not pretend otherwise', () => {
161
+ // The other half of the same honesty rule, and the one that matters now. R19
162
+ // could only ship ONE of ADR 0263 §9's two indexes: the live instance has two
163
+ // `building` versions, so `versions_one_building_idx` cannot be created until
164
+ // task 1003614 (R27) folds them. The building slot is therefore guarded by a
165
+ // check-then-insert with nothing behind it — exactly the state the planning
166
+ // slot was in before — and a reader who assumed symmetry would be wrong.
167
+ assert.match(ROUTE, /NOT BACKED BY AN INDEX YET/);
168
+ assert.match(ROUTE, /1003614/, 'the comment must name the task that makes the index legal');
152
169
  });
153
170
 
154
171
  // ---- the rule is one half of a pair ----------------------------------------
@@ -122,29 +122,61 @@ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unav
122
122
  assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
123
123
  });
124
124
 
125
- await test('BEHAVIOUR: blank ACCEPTS the default; only "-"/"none" declines', () => {
126
- assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
127
- assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
125
+ await test('BEHAVIOUR: blank ACCEPTS the default when there is one', () => {
126
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { ok: true, goalId: 1000071, warn: null });
127
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { ok: true, goalId: 1000071, warn: null },
128
128
  'whitespace is a bare Enter, not a decision');
129
- assert.deepEqual(triage.applyGoalAnswer(1000071, '-'), { goalId: null, warn: null });
130
- assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
131
- assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
132
- assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
129
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { ok: true, goalId: 1000058, warn: null },
133
130
  'an explicit id overrides the default');
134
131
  });
135
132
 
136
- await test('BEHAVIOUR: junk clears the goal and SAYS SO rather than landing on a wrong one', () => {
133
+ // BV1.R22 (task 1003609). These three used to resolve to `goalId: null`, which was
134
+ // fine while a goal-less create fell into the version's "<version> — general"
135
+ // catch-all. R11 (task 1003598) deleted that bucket and made goal_id required, so
136
+ // null is now a server 400 arriving AFTER the operator has typed a title, a
137
+ // description and a priority — and losing all of it. Each is a local refusal now.
138
+
139
+ await test('BEHAVIOUR: "-"/"none" no longer opt out — the bucket they opted into is gone', () => {
140
+ for (const decline of ['-', 'none', 'NONE']) {
141
+ const r = triage.applyGoalAnswer(1000071, decline);
142
+ assert.equal(r.ok, false, `"${decline}" must not resolve`);
143
+ assert.equal(r.goalId, null);
144
+ assert.match(r.warn, /no longer exists|must name a real goal/,
145
+ `"${decline}" must say the bucket is gone, not merely refuse`);
146
+ assert.doesNotMatch(r.warn, /falling back/, 'nothing falls back any more');
147
+ }
148
+ });
149
+
150
+ await test('BEHAVIOUR: blank with NO default refuses rather than sending null', () => {
151
+ const r = triage.applyGoalAnswer(null, '');
152
+ assert.equal(r.ok, false, 'no default and no answer is not a decision');
153
+ assert.match(r.warn, /a goal is required/);
154
+ });
155
+
156
+ await test('BEHAVIOUR: junk REFUSES rather than downgrading a typo into a decision', () => {
137
157
  for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
138
158
  const r = triage.applyGoalAnswer(1000071, junk);
139
- assert.equal(r.goalId, null, `"${junk}" must not resolve to a goal`);
140
- assert.match(r.warn, /general bucket/, `"${junk}" must say where it will land instead`);
159
+ assert.equal(r.ok, false, `"${junk}" must not resolve to a goal`);
160
+ assert.equal(r.goalId, null);
161
+ assert.match(r.warn, /not a goal id/, `"${junk}" must name the problem`);
162
+ assert.doesNotMatch(r.warn, /general bucket/, 'and must not offer a bucket that no longer exists');
141
163
  }
142
164
  // Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
143
165
  // without the blank check above running first. Guarding the pair together.
144
166
  assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
145
167
  });
146
168
 
147
- await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirely when null', async () => {
169
+ await test('BEHAVIOUR: createTask refuses to post without a goal, at its own call site', async () => {
170
+ // The prompt loop re-asks until it has a real id, so the only route here with
171
+ // null is a NEW call site that forgot — which should fail at its author rather
172
+ // than as a 400 in production.
173
+ await assert.rejects(
174
+ () => triage.createTask({ versionId: 'BV1', title: 't', description: '', priority: 3, sourceRef: null }),
175
+ /goal_id is required/,
176
+ );
177
+ });
178
+
179
+ await test('BEHAVIOUR: createTask puts goal_id on the body, and REFUSES when it is null', async () => {
148
180
  const seen = [];
149
181
  const fakeClient = {
150
182
  tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
@@ -157,12 +189,18 @@ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirel
157
189
  // object cannot reach it — re-require in a fresh registry instead.
158
190
  const fresh = freshTriage();
159
191
  await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
160
- await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
161
192
  assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
162
- assert.ok(!('goal_id' in seen[1]),
163
- 'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
164
193
  assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
165
194
  assert.equal(seen[0].status, 'backlog');
195
+ // BV1.R22 (task 1003609): omitting the goal used to send no goal_id key and let
196
+ // the server file it in the version catch-all. R11 deleted that catch-all, so a
197
+ // goal-less call is now a caller bug — and it must fail HERE, not as a 400 after
198
+ // the operator has typed everything.
199
+ await assert.rejects(
200
+ () => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' }),
201
+ /goal_id is required/,
202
+ );
203
+ assert.equal(seen.length, 1, 'and nothing was posted for the goal-less call');
166
204
  } finally {
167
205
  cliLib.cliClient = realCliClient;
168
206
  }
@@ -192,8 +230,10 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
192
230
  // ---- 4. both paths send goal_id -------------------------------------------
193
231
 
194
232
  await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
195
- assert.match(TRIAGE, /if \(goalId != null\) body\.goal_id = Number\(goalId\);/,
233
+ assert.match(TRIAGE, /body\.goal_id = Number\(goalId\);/,
196
234
  'CLI: goal_id must reach the create body');
235
+ assert.doesNotMatch(TRIAGE, /if \(goalId != null\) body\.goal_id/,
236
+ 'CLI: and unconditionally — a conditional send is what made a goal-less create look survivable');
197
237
  assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
198
238
  'CLI: createTask must accept it rather than ignore the caller');
199
239
  assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
@@ -234,31 +274,46 @@ await test('the suggester default shows score + matched terms so it can be judge
234
274
 
235
275
  // ---- 2. opting out is explicit ------------------------------------------
236
276
 
237
- await test('OPT-OUT: the prompt itself offers the way out, and the branch delegates the meaning', () => {
277
+ // BV1.R22 (task 1003609) removed the opt-out from BOTH surfaces, because the
278
+ // bucket it opted into no longer exists. These three tests pinned the affordance;
279
+ // they now pin its ABSENCE, which is the assertion that actually protects the
280
+ // invariant — a re-added opt-out would silently start producing 400s again.
281
+
282
+ await test('OPT-OUT: the CLI prompt RE-ASKS instead of offering a bucket that is gone', () => {
238
283
  const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
239
- assert.match(branch, /general bucket/, 'the prompt has to say where opting out lands');
284
+ assert.doesNotMatch(branch, /general bucket/, 'there is no general bucket to offer');
240
285
  assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
241
286
  'the branch must delegate to the tested helper, not re-implement the parsing inline');
242
- assert.match(branch, /picked\.warn/, 'and surface its warning');
287
+ assert.match(branch, /picked\.ok/, 'and act on its verdict');
288
+ assert.match(branch, /picked\.warn/, 'and surface its reason');
289
+ assert.match(branch, /for \(;;\)/, 'a refusal re-asks rather than falling through');
290
+ assert.match(branch, /'s'/, "and skipping stays available, so one unplaceable idea cannot wedge the run");
243
291
  assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
244
292
  'the suggester must see the title the operator just confirmed, not the filed one');
245
- // The semantics themselves are covered by the BEHAVIOUR block above — this
246
- // only pins that main() is actually wired to them.
247
293
  });
248
294
 
249
- await test('OPT-OUT: the hall names where opting out actually lands, and only defaults to it last', () => {
295
+ await test('OPT-OUT: the hall offers no empty goal, and refuses one before it posts', () => {
250
296
  const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
251
- // "— no goal —" would be a lie: the server files a goal_id-less create under
252
- // the version's catch-all. The label has to say so.
253
- assert.match(fn, /general bucket/);
254
- assert.ok(!/— no goal —/.test(fn), 'the honest label replaced the misleading one');
255
- assert.match(fn, /opts\.length === 0 \? ' selected' : ''/, 'the opt-out is the fallback, not the default');
256
- assert.match(fn, /a choice rather than the silent default/, 'and the intent is stated');
297
+ // Scoped to the emitted <option> markup: the code comment above it explains WHY
298
+ // the bucket is gone and must be allowed to name it.
299
+ const optionMarkup = fn.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
300
+ assert.doesNotMatch(optionMarkup, /general bucket/, 'the bucket is gone from the label too');
301
+ assert.ok(!/— no goal —/.test(fn), 'and no misleading "no goal" option came back');
302
+ // What remains when nothing is suggested must be a PLACEHOLDER, not a value:
303
+ // `disabled` is what stops a blank-looking select from being submittable.
304
+ assert.match(fn, /selected disabled/, 'the empty row is an unsubmittable placeholder');
305
+ const submit = HALL.slice(HALL.indexOf('async function submitPromote'), HALL.indexOf('function renderMerge'));
306
+ assert.match(submit, /form\.goal\.value === ''/, 'and the submit refuses an empty goal');
307
+ assert.match(submit, /payload\.goal_id = Number\(form\.goal\.value\)/,
308
+ 'goal_id is sent unconditionally now, not behind an if');
309
+ assert.doesNotMatch(submit, /if \(form\.goal && form\.goal\.value !== ''\) payload\.goal_id/,
310
+ 'the conditional send is what made an empty select look survivable');
257
311
  });
258
312
 
259
- await test('the CLI result line names the goal, or says the task fell into the general bucket', () => {
260
- assert.match(TRIAGE, /goal \$\{goalId\}` : ', → general bucket'/,
261
- 'where it landed must be visible in the output, both ways');
313
+ await test('the CLI result line names the goal it landed in', () => {
314
+ assert.match(TRIAGE, /P\$\{priority\}, goal \$\{goalId\}/,
315
+ 'where it landed must be visible in the output');
316
+ assert.doesNotMatch(TRIAGE, /→ general bucket/, 'and there is no second way for it to land');
262
317
  });
263
318
 
264
319
  // ---- 3. degrades, never blocks -----------------------------------------
@@ -1651,13 +1651,43 @@ test('vector 2: shipped_at is stamped ONLY by shipTask, and shipTask requires co
1651
1651
  // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
1652
1652
  const dbSrc = lifecycleDbSource();
1653
1653
 
1654
- // Every statement that writes shipped_at, ignoring SELECT projections and comments.
1655
- const writes = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
1656
- assert.equal(writes.length, 1,
1657
- `shipped_at is written in ${writes.length} places a second stamp site is how the grader-bypass vector reopens`);
1654
+ // Every statement that writes a TASK's shipped_at, ignoring SELECT projections
1655
+ // and comments.
1656
+ //
1657
+ // THE TABLE MATTERS, and this guard learned that the hard way (BV1.R12, task
1658
+ // 1003599). `versions` has a `shipped_at` too, and the version-close route
1659
+ // stamps it — a legitimate write on a different table that has nothing to do
1660
+ // with the grader-bypass vector. A bare /shipped_at = now()/ over the whole
1661
+ // lifecycle db family counted it as a second TASK stamp site and reddened main.
1662
+ //
1663
+ // Widening the count to 2 would have been the wrong repair: it buys silence
1664
+ // now and re-opens the hole the moment a third site appears anywhere. What this
1665
+ // guard is actually about is stated in its own comment above — "no other write
1666
+ // path can stamp shipped_at ON A TASK" — so the fix is to say `tasks`, not to
1667
+ // raise the number. Each match is attributed to the nearest preceding UPDATE
1668
+ // target, and only `tasks` counts.
1669
+ const stamps = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
1670
+ const taskStamps = stamps.filter((m) => {
1671
+ const before = dbSrc.slice(0, m.index);
1672
+ const lastUpdate = before.lastIndexOf('UPDATE ');
1673
+ if (lastUpdate === -1) return false;
1674
+ return /^UPDATE\s+tasks\b/.test(before.slice(lastUpdate));
1675
+ });
1676
+ assert.equal(taskStamps.length, 1,
1677
+ `a task's shipped_at is written in ${taskStamps.length} places — a second stamp site is how the grader-bypass vector reopens`);
1678
+ // And the attribution itself must be working: if the regex above ever stopped
1679
+ // matching the real site, `taskStamps` would be 0 and the assert would still be
1680
+ // meaningful — but a silent 0 total would mean the family moved and this guard
1681
+ // is now watching nothing at all.
1682
+ assert.ok(stamps.length >= 1, 'no shipped_at stamp found anywhere — the lifecycle db family moved and this guard is blind');
1658
1683
 
1659
1684
  // …and that one site is guarded on the confirmed state.
1660
- const stampIdx = dbSrc.indexOf('shipped_at = now()');
1685
+ // Anchored on the ATTRIBUTED task stamp, not `indexOf` with two shipped_at
1686
+ // sites in the family, "the first one in file order" is whichever file the
1687
+ // carve happens to concatenate first, and that is not a property anyone is
1688
+ // maintaining. The versions stamp has no `status = 'confirmed'` guard and
1689
+ // should not: it is a different table with a different rule.
1690
+ const stampIdx = taskStamps[0].index;
1661
1691
  const window = dbSrc.slice(stampIdx, stampIdx + 500);
1662
1692
  assert.match(window, /WHERE id = \$1 AND status = 'confirmed'/,
1663
1693
  'the stamp must refuse any task not already confirmed — that is what stops a confirm-time ship');
@@ -259,7 +259,7 @@ await test('opening Promote loads open versions only, and preselects the idea\'s
259
259
  assert.equal(vsel.value, 'BV2', 'the idea\'s suggested_version is honoured');
260
260
  });
261
261
 
262
- await test('the goal picker leads with the STORED hint, then the suggester, then the opt-out', async () => {
262
+ await test('the goal picker leads with the STORED hint, then the suggester and offers NO opt-out', async () => {
263
263
  const h = await boot();
264
264
  await h.clickGo('promote');
265
265
  const opts = h.goalSelect().querySelectorAll('option');
@@ -268,7 +268,14 @@ await test('the goal picker leads with the STORED hint, then the suggester, then
268
268
  assert.match(opts[0].textContent, /carried from the filing/);
269
269
  assert.equal(opts[1].attrs.value, '1000062', 'the suggester fills in below it');
270
270
  assert.match(opts[1].textContent, /matched: promote, merge/, 'with the evidence for judging it');
271
- assert.equal(opts[opts.length - 1].attrs.value, '', 'the opt-out is always available');
271
+ // BV1.R22 (task 1003609) DELETED the trailing opt-out, and this assertion
272
+ // flipped with it. R11 (task 1003598) removed db.createTask's catch-all
273
+ // fallback and made goal_id required, so an empty selection stopped meaning
274
+ // "file it in the version's general bucket" and started meaning a server 400
275
+ // landing on a builder who had already filled the form. A choice that always
276
+ // fails is worse than no choice.
277
+ assert.equal(opts.filter((o) => o.attrs.value === '').length, 0,
278
+ 'no empty-valued option may be offered once a goal is required — it could only ever 400');
272
279
  });
273
280
 
274
281
  await test('with no stored hint the top suggestion is preselected instead', async () => {
@@ -277,12 +284,19 @@ await test('with no stored hint the top suggestion is preselected instead', asyn
277
284
  assert.equal(h.goalSelect().value, '1000062', 'the suggester supplies the default when nothing was named');
278
285
  });
279
286
 
280
- await test('a suggester outage degrades to the opt-out rather than blocking the verdict', async () => {
287
+ await test('a suggester outage degrades to a DISABLED placeholder that says what to do', async () => {
281
288
  const h = await boot({ suggestThrows: true, idea: { ...DEFAULT_IDEA, suggested_goal_id: null } });
282
289
  await h.clickGo('promote');
283
290
  const opts = h.goalSelect().querySelectorAll('option');
284
- assert.equal(opts.length, 1, 'only the opt-out remains');
285
- assert.equal(h.goalSelect().value, '', 'and it is selected, so the form is still submittable');
291
+ assert.equal(opts.length, 1, 'only the placeholder remains');
292
+ // Before BV1.R22 this row was a submittable opt-out and this test said so. It
293
+ // is now a PLACEHOLDER, not a value: `disabled`, so the select can never be
294
+ // blank-but-valid-looking, and the text tells the builder where to go instead.
295
+ // The old assertion passed either way, because it never checked `disabled` —
296
+ // which is exactly how a contract change hides inside a green test.
297
+ assert.ok('disabled' in opts[0].attrs, 'the placeholder must not be submittable');
298
+ assert.match(opts[0].textContent, /open the goal board and pick one/,
299
+ 'a builder with no suggestion is told what to do, not handed a choice that fails');
286
300
  });
287
301
 
288
302
  await test('promote with a goal chosen POSTs goal_id, then marks the idea promoted', async () => {
@@ -320,14 +334,27 @@ await test('the title survives escaping — what the form renders is what gets c
320
334
  'quotes and angle brackets must round-trip through the value attribute unmangled');
321
335
  });
322
336
 
323
- await test('promote opting OUT of a goal sends no goal_id key at all', async () => {
337
+ await test('there is no way to promote WITHOUT a goal any more', async () => {
338
+ // THIS TEST'S PREMISE INVERTED at BV1.R22 (task 1003609). It used to assert
339
+ // that clearing the picker sent no `goal_id` key, because an absent key meant
340
+ // "the version's general bucket". R11 (task 1003598) deleted that bucket and
341
+ // made `goal_id` required, so the behaviour it pinned is now a 400 — and the
342
+ // test itself crashed with a TypeError once the option it selected stopped
343
+ // existing, which is what surfaced this.
344
+ //
345
+ // What replaces it is the invariant that actually matters now: the form cannot
346
+ // be made to submit without a goal. Deleting the test outright would leave that
347
+ // unguarded.
324
348
  const h = await boot();
325
349
  await h.clickGo('promote');
326
- h.goalSelect().value = '';
350
+ const opts = h.goalSelect().querySelectorAll('option');
351
+ const submittableBlank = opts.filter((o) => o.attrs.value === '' && !('disabled' in o.attrs));
352
+ assert.equal(submittableBlank.length, 0, 'no blank option may be both offered and submittable');
353
+ // And the picker's default is a real goal, so an untouched form is already valid.
354
+ assert.ok(h.goalSelect().value, 'the picker opens on a real goal, never on nothing');
327
355
  await h.submitForm('promote');
328
356
  const body = h.requestsTo('POST', /\/tasks$/)[0].body;
329
- assert.ok(!('goal_id' in body),
330
- 'the version\'s general bucket applies only on an ABSENT key — sending null or "" would be a different create');
357
+ assert.ok('goal_id' in body && body.goal_id, 'every promote now names a goal');
331
358
  });
332
359
 
333
360
  await test('an empty title is refused in the browser, before a doomed request', async () => {