@bongos/core 1.21.97 → 1.21.99

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/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.97'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.99'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -168,7 +168,7 @@ await test("restore: clears only an 'Abandoned: …' value_summary, leaving any
168
168
  assert.match(sql, /status = 'backlog'/);
169
169
  assert.match(sql, /value_summary LIKE 'Abandoned: %' THEN NULL ELSE value_summary END/,
170
170
  'the retire stamp is cleared conditionally — an unrelated summary survives');
171
- assert.equal(upd.params.length, 1, 'only the task id is bound');
171
+ assert.deepEqual(upd.params, [TASK, null], 'the task id, then the parking builder (none passed here)');
172
172
  });
173
173
 
174
174
  await test('restore: clears merged_into_task_id + triage_note (task 1001478 grade finding)', async () => {
@@ -0,0 +1,56 @@
1
+ // tests/government_walk.mjs — the government walk stays true (task 1004658, goal
2
+ // 1000111, criterion gov-proven-and-kept-proven).
3
+ //
4
+ // scripts/gds/government-walk.js walks a throwaway project's whole government —
5
+ // genesis, every form, every building's act-alone gate, every matter action — and
6
+ // writes the pass/fail table to docs/governance/government-walk.md. This runs the
7
+ // walk again in its own process (it points the instance root at a temp folder and
8
+ // swaps the government db layer for its in-memory store, so it must not share a
9
+ // module graph with other tests) and fails if ANY row differs from the committed
10
+ // table: a change that breaks a decision flips a row from PASS to FAIL here, and a
11
+ // fix to a filed bug flips one back, which asks for the table to be regenerated.
12
+ //
13
+ // Also pins the walk's own coverage, so it cannot quietly walk less: every bound
14
+ // route of every building has a request in the walk, and every router a bound route
15
+ // lives on is mounted.
16
+ //
17
+ // Run: node --test tests/government_walk.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { test } from 'node:test';
21
+ import { spawnSync } from 'node:child_process';
22
+ import path from 'node:path';
23
+ import { createRequire } from 'node:module';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'government-walk.js');
29
+ const walk = require(SCRIPT);
30
+ const dr = require(path.join(ROOT, 'modules', 'government', 'decision-rules.js'));
31
+ const decisionBoard = require(path.join(ROOT, 'modules', 'government', 'decision-board.js'));
32
+
33
+ test('the walk covers every bound route of every building', () => {
34
+ const bound = dr.AREAS.flatMap((a) => a.acts).sort();
35
+ assert.deepEqual(Object.keys(walk.REQUESTS).sort(), bound, 'a route added to decision-rules AREAS[].acts needs a request in the walk');
36
+ });
37
+
38
+ test('the walk passes a matter for every action a passed matter can carry out', () => {
39
+ const planned = walk.MATTER_PLAN.map(([area, , action]) => `${area}.${action.type}`).sort();
40
+ const actions = Object.entries(decisionBoard.MATTER_ACTIONS)
41
+ .flatMap(([area, acts]) => Object.keys(acts).map((type) => `${area}.${type}`)).sort();
42
+ assert.ok(actions.length > 0, 'the matter-action table is not empty');
43
+ assert.deepEqual(planned, actions, 'a new matter action needs a matter in the walk');
44
+ // and every planned action is one the server would accept as written
45
+ for (const [area, , action] of walk.MATTER_PLAN) {
46
+ assert.equal(decisionBoard.validateMatterAction(area, action).ok, true, `${area} ${action.type} validates`);
47
+ }
48
+ });
49
+
50
+ test('the walk re-runs and matches the committed pass/fail table', () => {
51
+ const env = { ...process.env };
52
+ delete env.DATABASE_URL;
53
+ const r = spawnSync(process.execPath, [SCRIPT, '--check'], { cwd: ROOT, env, encoding: 'utf8', timeout: 240000 });
54
+ assert.equal(r.status, 0, `government-walk --check failed (exit ${r.status}):\n${r.stdout}\n${r.stderr}`);
55
+ assert.match(r.stdout, /matches the committed table/);
56
+ });
@@ -7,7 +7,10 @@
7
7
  // Then the REAL page: studio.html's own body in a mini-DOM, with the real
8
8
  // dom-utils.js, tweak-editor-lib.js, studio-lib.js and studio.js in a vm over a
9
9
  // stub client, driven the way an artist would: the tally, Resume, the three
10
- // panels, "take it", and the studio's own words typed over in place.
10
+ // panels, "take it", and the studio's own words typed over in place. And the
11
+ // room's sky (task 1004654): the day room from 07:00 to 18:59 on the viewer's
12
+ // clock, re-checked each minute, and a greeting for the time of day that an
13
+ // artist's own words always beat.
11
14
 
12
15
  import { strict as assert } from 'node:assert';
13
16
  import { createRequire } from 'node:module';
@@ -112,9 +115,23 @@ function augment(el) {
112
115
  return el;
113
116
  }
114
117
 
115
- function bootStudio({ answers = {}, signedIn = true } = {}) {
118
+ // The viewer's clock, in LOCAL time (the rule reads getHours), held still so
119
+ // a test can name the hour. new Date() answers the held time; new Date(x) is
120
+ // the real constructor, so the composers' ISO dates still parse.
121
+ function heldClock(h, m) {
122
+ const Real = Date;
123
+ let t = new Real(2026, 9, 9, h, m, 0).getTime();
124
+ class Held extends Real {
125
+ constructor(...a) { if (a.length) super(...a); else super(t); }
126
+ static now() { return t; }
127
+ }
128
+ Held.set = (hh, mm) => { t = new Real(2026, 9, 9, hh, mm, 0).getTime(); };
129
+ return Held;
130
+ }
131
+
132
+ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY } = {}) {
116
133
  const root = augment(makeEl('body', {}));
117
- root.innerHTML = BODY;
134
+ root.innerHTML = body;
118
135
  const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
119
136
  const docListeners = {};
120
137
  const requests = [];
@@ -154,12 +171,20 @@ function bootStudio({ answers = {}, signedIn = true } = {}) {
154
171
  addEventListener() {},
155
172
  scrollTo() {},
156
173
  };
157
- const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, encodeURIComponent };
174
+ // setInterval is held, never real (a real one would keep the run alive): the
175
+ // test fires each registered tick by hand, as a minute passing.
176
+ const intervals = [];
177
+ const fakeSetInterval = (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; };
178
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, setInterval: fakeSetInterval, encodeURIComponent };
179
+ const clock = at ? heldClock(at[0], at[1]) : null;
180
+ if (clock) sandbox.Date = clock;
158
181
  sandbox.globalThis = sandbox;
159
182
  vm.createContext(sandbox);
160
183
  for (const [name, src] of SOURCES) vm.runInContext(src, sandbox, { filename: name });
161
184
  const out = {
162
- requests, navigated: null, focused: null, el: byId, html: htmlEl,
185
+ requests, navigated: null, focused: null, el: byId, html: htmlEl, body: root, intervals,
186
+ // Move the held clock and let a minute pass: every registered tick fires.
187
+ tickTo: (h, m) => { clock.set(h, m); for (const i of intervals) i.fn(); },
163
188
  writes: () => requests.filter((r) => r.method !== 'GET'),
164
189
  click: async (id) => { const el = byId(id); assert.ok(el, `no #${id}`); el.fire('click', { target: el }); await out.settle(); },
165
190
  clickIn: async (overlayId, sel) => {
@@ -404,6 +429,116 @@ try {
404
429
  assert.match(s.el('st-offer-err').textContent, /Only an artist can take a page/);
405
430
  });
406
431
 
432
+ // ── the room's sky and the greeting (task 1004654) ────────────────────────
433
+ const at = (h, m) => new Date(2026, 9, 9, h, m, 0);
434
+
435
+ await test('the sky: day from 07:00 to 18:59 on the viewer\'s clock, night either side of it', () => {
436
+ assert.equal(lib.skyAt(at(6, 59)), 'night');
437
+ assert.equal(lib.skyAt(at(7, 0)), 'day');
438
+ assert.equal(lib.skyAt(at(12, 0)), 'day');
439
+ assert.equal(lib.skyAt(at(18, 59)), 'day');
440
+ assert.equal(lib.skyAt(at(19, 0)), 'night');
441
+ assert.equal(lib.skyAt(at(0, 0)), 'night');
442
+ });
443
+
444
+ await test('the greeting\'s time of day: morning before noon by day, afternoon after, evening at night', () => {
445
+ assert.equal(lib.dayPart(at(6, 59)), 'evening', 'before seven the room is still at night');
446
+ assert.equal(lib.dayPart(at(7, 0)), 'morning');
447
+ assert.equal(lib.dayPart(at(11, 59)), 'morning');
448
+ assert.equal(lib.dayPart(at(12, 0)), 'afternoon');
449
+ assert.equal(lib.dayPart(at(18, 59)), 'afternoon');
450
+ assert.equal(lib.dayPart(at(19, 0)), 'evening');
451
+ assert.equal(lib.clockGreets('Good evening.'), true);
452
+ assert.equal(lib.clockGreets(' Good evening. '), true, 'the served markup\'s whitespace is not a tweak');
453
+ assert.equal(lib.clockGreets('Hello, you.'), false, 'an artist\'s words are not the stock greeting');
454
+ assert.match(HTML, new RegExp('id="studio-greet"[^>]*>' + lib.STOCK_GREETING.replace('.', '\\.') + '<'), 'the page is served with the stock greeting');
455
+ });
456
+
457
+ await test('the page: the body wears the sky and the greeting follows the clock at each boundary hour', async () => {
458
+ for (const [h, m, sky, words] of [
459
+ [6, 59, 'night', 'Good evening.'],
460
+ [7, 0, 'day', 'Good morning.'],
461
+ [11, 59, 'day', 'Good morning.'],
462
+ [12, 0, 'day', 'Good afternoon.'],
463
+ [18, 59, 'day', 'Good afternoon.'],
464
+ [19, 0, 'night', 'Good evening.'],
465
+ ]) {
466
+ const s = bootStudio({ answers: HOME, at: [h, m] });
467
+ await s.settle();
468
+ const hhmm = `${h}:${String(m).padStart(2, '0')}`;
469
+ assert.equal(s.body.getAttribute('data-sky'), sky, `the sky at ${hhmm}`);
470
+ assert.equal(s.el('studio-greet').textContent, words, `the greeting at ${hhmm}`);
471
+ }
472
+ });
473
+
474
+ await test('re-checked each minute: the room turns to day at 07:00 and back to night at 19:00', async () => {
475
+ const s = bootStudio({ answers: HOME, at: [6, 59] });
476
+ await s.settle();
477
+ assert.deepEqual(s.intervals.map((i) => i.ms), [60000], 'one tick, once a minute');
478
+ assert.equal(s.body.getAttribute('data-sky'), 'night');
479
+ s.tickTo(7, 0);
480
+ assert.equal(s.body.getAttribute('data-sky'), 'day');
481
+ assert.equal(s.el('studio-greet').textContent, 'Good morning.');
482
+ s.tickTo(12, 0);
483
+ assert.equal(s.el('studio-greet').textContent, 'Good afternoon.');
484
+ s.tickTo(19, 0);
485
+ assert.equal(s.body.getAttribute('data-sky'), 'night');
486
+ assert.equal(s.el('studio-greet').textContent, 'Good evening.', 'at night the page\'s own greeting is back');
487
+ });
488
+
489
+ await test('an artist\'s rewritten greeting wins at every hour; the sky still follows the clock', async () => {
490
+ const tweaked = BODY.replace(/(id="studio-greet"[^>]*>)Good evening\.</, '$1Back at the desk.<');
491
+ assert.notEqual(tweaked, BODY, 'the fixture rewrote the greeting');
492
+ const s = bootStudio({ answers: HOME, body: tweaked, at: [8, 0] });
493
+ await s.settle();
494
+ assert.equal(s.body.getAttribute('data-sky'), 'day');
495
+ assert.equal(s.el('studio-greet').textContent, 'Back at the desk.');
496
+ for (const [h, m] of [[12, 0], [19, 0], [7, 0]]) {
497
+ s.tickTo(h, m);
498
+ assert.equal(s.el('studio-greet').textContent, 'Back at the desk.', `their words at ${h}:${m}`);
499
+ }
500
+ });
501
+
502
+ await test('typing over the greeting in the morning: it opens as the page\'s own line, and the clock never types over it', async () => {
503
+ const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: true })) }, at: [9, 0] });
504
+ await s.settle();
505
+ const greet = s.el('studio-greet');
506
+ assert.equal(greet.textContent, 'Good morning.');
507
+ await s.click('studio-greet');
508
+ assert.equal(greet.getAttribute('contenteditable'), 'true', 'matched to the greeting line, not left unread');
509
+ assert.equal(greet.getAttribute('data-unread'), null);
510
+ greet.textContent = 'Morning, you.';
511
+ s.tickTo(13, 0);
512
+ assert.equal(greet.textContent, 'Morning, you.', 'a minute passing never overwrites the words being typed');
513
+ });
514
+
515
+ await test('the sheet: the day tokens sit beside the night ones, the day pieces, and motion only without a reduced-motion preference', () => {
516
+ const CSS = read('studio.css');
517
+ const dayAt = CSS.indexOf('body[data-page="studio"][data-sky="day"] {');
518
+ const nightAt = CSS.indexOf('body[data-page="studio"] {');
519
+ assert.ok(nightAt >= 0 && dayAt > nightAt, 'a day block after the night tokens');
520
+ assert.ok(CSS.slice(nightAt, dayAt).split('\n}').length === 2, 'directly after them: nothing between the two blocks but the day block\'s note');
521
+ const dayBlock = CSS.slice(dayAt, CSS.indexOf('}', dayAt));
522
+ // the mockup's [data-sky="day"] values (docs/design/mocks/day-night-rooms/index.html)
523
+ for (const [tok, v] of [['--st-room', '#efe7dc'], ['--st-frame', '#d6c9b7'], ['--st-night', '#bfdaf0'], ['--st-moon', '#ffd46e'],
524
+ ['--st-tower-1', '#9db2c7'], ['--st-tower-2', '#8ea4bb'], ['--st-tower-3', '#a8bccf'], ['--st-pane', '#e9f1f8'],
525
+ ['--st-floor', '#e2d5c3'], ['--st-ink', '#1b1a1d'], ['--st-glass', 'rgba(255, 255, 255, .52)']]) {
526
+ assert.ok(dayBlock.includes(`${tok}: ${v};`), `day ${tok} is the mockup's ${v}`);
527
+ }
528
+ assert.match(CSS, /\.st-light \{[^}]*background: var\(--st-pane\)/, 'the panes are lit by night and unlit by day through one token');
529
+ assert.match(CSS, /body\[data-sky="day"\] \.st-cloud \{ opacity: \.9; \}/);
530
+ assert.match(CSS, /body\[data-sky="day"\] \.st-sunpatch \{ opacity: \.7; \}/);
531
+ assert.match(CSS, /body\[data-sky="day"\] \.st-star,\s*body\[data-sky="day"\] \.st-glow \{ opacity: 0; \}/, 'no stars, and the lamp is off');
532
+ assert.match(HTML, /class="st-cloud st-cloud--1"/);
533
+ assert.match(HTML, /class="st-sunpatch"/);
534
+ const anims = [...CSS.matchAll(/animation:\s*[^;]+;|transition:\s*[^;]+;/g)].map((m) => m.index);
535
+ const calm = CSS.indexOf('@media (prefers-reduced-motion: no-preference) {');
536
+ const calmEnd = CSS.indexOf('\n}', calm);
537
+ assert.ok(anims.length > 0 && anims.every((i) => i > calm && i < calmEnd), 'every animation and transition sits inside the no-preference block');
538
+ assert.match(CSS, /body\[data-page="studio"\]\[data-sky="day"\] \.st-overlay \{\s*--st-scrim:/, 'the panels follow the sky');
539
+ assert.match(CSS, /body\[data-page="studio"\]\[data-sky="day"\] \.aq-overlay \{/, 'the Approval queue follows the sky');
540
+ });
541
+
407
542
  await test('the older reviews stay one quiet door away while any still wait', async () => {
408
543
  const REVIEW = { id: '1900', title: 'Artist review: the copy and visuals that shipped in task 1899', status: 'backlog', source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1899', created_at: day(20) };
409
544
  const s = bootStudio({ answers: { ...HOME, 'GET /tasks?discipline=artist&status=backlog&limit=50': ok({ tasks: [REVIEW] }) } });
@@ -0,0 +1,182 @@
1
+ // tests/parked_backlog_stays_parked.mjs
2
+ //
3
+ // A task a PERSON parked in backlog stays there until a person promotes it
4
+ // (task 1004592, migration core_274).
5
+ //
6
+ // What happened: restoring abandoned task 1003397 put it in backlog as ADR 0152
7
+ // says ("comes back un-workable and needs a second, explicit promote"), and the
8
+ // promotion sweep promoted it on the next reconciler tick. Demoting it again
9
+ // was undone one tick later the same way. The sweep could not tell "waiting on
10
+ // a dependency" from "parked on purpose", because nothing in the row said so.
11
+ //
12
+ // HOW THIS PROVES IT. The DB-free unit gate has no Postgres, so the fake below
13
+ // holds task rows in memory and answers each statement the way the database
14
+ // would GIVEN WHAT THE STATEMENT ACTUALLY SAYS: a restore or demote parks a row
15
+ // only if its UPDATE writes parked_at, and the sweep withholds a parked row
16
+ // only if its SQL carries `parked_at IS NULL`. Drop either half and the
17
+ // "still backlog" cases go red. The real restoreTask, parkTask and
18
+ // promoteUnblockedBacklogTasks run against it; nothing is re-typed here.
19
+ //
20
+ // The trigger half (the three promotion functions and the un-park trigger) is
21
+ // checked on the migration's SQL, comments stripped, as
22
+ // tests/promotion_triggers_skip_cascade.mjs does for core_250. That it applied
23
+ // is not proved here; it needs Postgres.
24
+ //
25
+ // Run: node tests/parked_backlog_stays_parked.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ import { readFileSync } from 'node:fs';
30
+ import { makeRunner } from './helpers.mjs';
31
+
32
+ process.env.NODE_ENV = 'test';
33
+ const require = createRequire(import.meta.url);
34
+ const dbShip = require('../modules/lifecycle/db-ship.js');
35
+ const dbTasks = require('../modules/lifecycle/db-tasks.js');
36
+ const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
37
+
38
+ const { test, summary } = makeRunner();
39
+
40
+ const PERSON = 42;
41
+
42
+ // One in-memory tasks table. Every row has no dependencies and no blocker, so
43
+ // the sweep's other guards pass it: the parked clause is the only thing that
44
+ // can hold a row back.
45
+ function fakeDb(rows) {
46
+ const tasks = new Map(rows.map((r) => [r.id, { parked_at: null, parked_by: null, ...r }]));
47
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ');
48
+ // core_274's BEFORE UPDATE trigger: leaving backlog un-parks.
49
+ const setStatus = (row, status) => {
50
+ if (status !== 'backlog') { row.parked_at = null; row.parked_by = null; }
51
+ row.status = status;
52
+ };
53
+ const sweepAdmits = (sql, row) => row.status === 'backlog'
54
+ && !(/parked_at IS NULL/.test(sql) && row.parked_at);
55
+ async function query(sqlIn, params = []) {
56
+ const sql = flat(sqlIn);
57
+ if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [] };
58
+ if (/^SELECT id, title, status FROM tasks WHERE id = \$1 FOR UPDATE/.test(sql)) {
59
+ const row = tasks.get(params[0]);
60
+ return { rows: row ? [{ ...row }] : [] };
61
+ }
62
+ if (/^SELECT t\.id, t\.title, t\.goal_id FROM tasks t/.test(sql)) {
63
+ return { rows: [...tasks.values()].filter((r) => sweepAdmits(sql, r)).map(({ id, title, goal_id }) => ({ id, title, goal_id })) };
64
+ }
65
+ if (/^UPDATE tasks t SET status = 'ready'/.test(sql)) {
66
+ const ids = params[0];
67
+ const hit = [...tasks.values()].filter((r) => ids.includes(r.id) && sweepAdmits(sql, r));
68
+ hit.forEach((r) => setStatus(r, 'ready'));
69
+ return { rows: hit.map(({ id, title, goal_id }) => ({ id, title, goal_id })) };
70
+ }
71
+ if (/^UPDATE tasks SET status = 'backlog'/.test(sql)) {
72
+ const row = tasks.get(params[0]);
73
+ const guard = /AND status = 'ready'/.test(sql) ? row && row.status === 'ready' : !!row;
74
+ if (!guard) return { rows: [], rowCount: 0 };
75
+ setStatus(row, 'backlog');
76
+ if (/parked_at = now\(\)/.test(sql)) { row.parked_at = new Date(); row.parked_by = params[1] ?? null; }
77
+ return { rows: [{ id: row.id, title: row.title, status: row.status }], rowCount: 1 };
78
+ }
79
+ throw new Error(`unexpected SQL in fake: ${sql.slice(0, 80)}`);
80
+ }
81
+ const client = { query, release() {} };
82
+ return { tasks, query, connect: async () => client };
83
+ }
84
+
85
+ const sweep = (pool) => dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
86
+
87
+ await test('control: an ordinary backlog row with nothing pending IS promoted', async () => {
88
+ // Without this the "still backlog" cases below could pass on a sweep that
89
+ // promotes nothing at all.
90
+ const pool = fakeDb([{ id: 1, title: 'waiting on a trigger', status: 'backlog', goal_id: 7 }]);
91
+ const out = await sweep(pool);
92
+ assert.deepEqual(out.promoted.map((r) => r.id), [1]);
93
+ assert.equal(pool.tasks.get(1).status, 'ready');
94
+ });
95
+
96
+ await test('restore, then the armed sweep: the task is still backlog', async () => {
97
+ const pool = fakeDb([{ id: 2, title: 'retired by mistake', status: 'abandoned', goal_id: 7 }]);
98
+ await dbShip.restoreTask({ taskId: 2, parkedBy: PERSON }, { pool });
99
+ assert.equal(pool.tasks.get(2).status, 'backlog');
100
+ assert.ok(pool.tasks.get(2).parked_at, 'restore must park the task');
101
+ assert.equal(pool.tasks.get(2).parked_by, PERSON, 'and record who parked it');
102
+ const out = await sweep(pool);
103
+ assert.deepEqual(out.candidates, [], 'a restored task is not a promotion candidate');
104
+ assert.equal(pool.tasks.get(2).status, 'backlog',
105
+ 'the sweep promoted a restored task: ADR 0152\'s second, explicit promote was skipped');
106
+ });
107
+
108
+ await test('demote, then the armed sweep: the task is still backlog', async () => {
109
+ const pool = fakeDb([{ id: 3, title: 'taken back off the board', status: 'ready', goal_id: 7 }]);
110
+ assert.equal(await dbTasks.parkTask(3, PERSON, pool), true, 'a ready task demotes');
111
+ assert.equal(pool.tasks.get(3).status, 'backlog');
112
+ assert.equal(pool.tasks.get(3).parked_by, PERSON);
113
+ await sweep(pool);
114
+ assert.equal(pool.tasks.get(3).status, 'backlog',
115
+ 'the sweep re-promoted a demoted task one tick later (task 1003397, 2026-10-06)');
116
+ });
117
+
118
+ await test('demote refuses a task that is no longer ready, and writes nothing', async () => {
119
+ const pool = fakeDb([{ id: 4, title: 'claimed meanwhile', status: 'active', goal_id: 7 }]);
120
+ assert.equal(await dbTasks.parkTask(4, PERSON, pool), false);
121
+ assert.equal(pool.tasks.get(4).status, 'active');
122
+ assert.equal(pool.tasks.get(4).parked_at, null);
123
+ });
124
+
125
+ await test('the sweep carries the parked clause in BOTH the read and the write', async () => {
126
+ const pool = fakeDb([{ id: 6, title: 'x', status: 'backlog', goal_id: 7 }]);
127
+ const seen = [];
128
+ const spy = { query: async (sql, p) => { seen.push(String(sql)); return pool.query(sql, p); } };
129
+ await sweep(spy);
130
+ assert.match(seen.find((s) => /SELECT/.test(s)), /t\.parked_at IS NULL/);
131
+ assert.match(seen.find((s) => /^\s*UPDATE/.test(s)), /t\.parked_at IS NULL/,
132
+ 'the UPDATE re-checks the guard rather than trusting the ids it read');
133
+ });
134
+
135
+ // ---------- the routes hand the person in ----------
136
+
137
+ const ROUTES = read('modules/lifecycle/routes/tasks.js');
138
+ const slice = (from, to) => ROUTES.slice(ROUTES.indexOf(from), ROUTES.indexOf(to, ROUTES.indexOf(from)));
139
+
140
+ await test('the restore branch of /promote parks with the caller', () => {
141
+ const promote = slice("router.post('/tasks/:id/promote'", "router.post('/tasks/:id/demote'");
142
+ assert.match(promote, /restoreTask\(\{ taskId: id, parkedBy: req\.builder\.id \}\)/);
143
+ });
144
+
145
+ await test('/demote parks with the caller instead of a bare status flip', () => {
146
+ const demote = slice("router.post('/tasks/:id/demote'", '// Phase 5 lifecycle transitions');
147
+ assert.match(demote, /db\.parkTask\(id, req\.builder\.id\)/);
148
+ assert.doesNotMatch(demote, /updateTaskStatus\(id, 'backlog'\)/,
149
+ 'a bare flip leaves no marker and the sweep undoes it');
150
+ });
151
+
152
+ // ---------- core_274: the triggers ----------
153
+
154
+ const MIG = read('migrations/core_274_parked_backlog_stays_parked.sql')
155
+ .replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
156
+ const fnBody = (name) => {
157
+ const start = MIG.indexOf(`CREATE OR REPLACE FUNCTION ${name}()`);
158
+ assert.notEqual(start, -1, `core_274 does not define ${name}`);
159
+ const rest = MIG.slice(start + 1);
160
+ const next = rest.indexOf('CREATE OR REPLACE FUNCTION');
161
+ return next === -1 ? rest : rest.slice(0, next);
162
+ };
163
+
164
+ await test('core_274 adds the two nullable marker columns, forward-safe', () => {
165
+ assert.match(MIG, /ADD COLUMN IF NOT EXISTS parked_at timestamptz;/);
166
+ assert.match(MIG, /ADD COLUMN IF NOT EXISTS parked_by bigint REFERENCES builders\(id\) ON DELETE SET NULL;/);
167
+ assert.doesNotMatch(MIG, /NOT NULL|DEFAULT/i, 'no default: an older release must never park anything');
168
+ });
169
+
170
+ await test('each promotion trigger refuses a parked row (a dependency shipping cannot free it)', () => {
171
+ for (const name of ['promote_dependents_on_ship', 'promote_dependents_on_criterion_satisfy', 'promote_dependents_on_goal_achieve']) {
172
+ assert.match(fnBody(name), /AND t\.parked_at IS NULL/, `${name} promotes a parked task`);
173
+ }
174
+ });
175
+
176
+ await test('leaving backlog un-parks, and only a real status change does', () => {
177
+ const fn = fnBody('clear_parked_on_leave_backlog');
178
+ assert.match(fn, /IF NEW\.status IS DISTINCT FROM 'backlog' THEN NEW\.parked_at := NULL; NEW\.parked_by := NULL;/);
179
+ assert.match(MIG, /BEFORE UPDATE OF status ON tasks FOR EACH ROW WHEN \(OLD\.status IS DISTINCT FROM NEW\.status\) EXECUTE FUNCTION clear_parked_on_leave_backlog\(\)/);
180
+ });
181
+
182
+ summary();
@@ -33,7 +33,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
33
33
  const repoRoot = join(__dirname, '..');
34
34
  const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
35
35
 
36
- const MIG = 'core_250_cascade_requests_do_not_auto_promote.sql';
36
+ // core_274 (task 1004592) restated all three bodies to add the parked clause,
37
+ // so it is now the latest definition and must carry core_250's guard forward.
38
+ const MIG = 'core_274_parked_backlog_stays_parked.sql';
37
39
 
38
40
  // The three promotion triggers, by the state transition each reacts to.
39
41
  const TRIGGERS = [
@@ -59,7 +61,7 @@ function fnBody(name) {
59
61
 
60
62
  const { test, summary } = makeRunner();
61
63
 
62
- await test('core_250 redefines all three promotion triggers', () => {
64
+ await test('the latest migration redefines all three promotion triggers', () => {
63
65
  for (const name of TRIGGERS) {
64
66
  assert.match(body, new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`), `${name} is redefined`);
65
67
  }
@@ -99,7 +101,7 @@ await test('nothing else was weakened while adding the guard', () => {
99
101
  }
100
102
  });
101
103
 
102
- await test('core_250 is the LAST definition of each trigger in migrations/', () => {
104
+ await test('the latest migration is the LAST definition of each trigger in migrations/', () => {
103
105
  // The drift tripwire, and the assertion that would have caught migration 177
104
106
  // on the day it landed. A later whole-body redefinition inherits only what it
105
107
  // restates, so this fails the moment someone redefines one of these functions