@bongos/core 1.19.1042 → 1.19.1044

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 (38) hide show
  1. package/.bongos-core.json +64 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +129 -3
  8. package/docs/api-reference.md +5 -3
  9. package/docs/copy-inventory.md +38 -38
  10. package/docs/copy-registry.json +41 -41
  11. package/docs/file-map.md +1 -1
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/modules-contract.md +4 -1
  14. package/migrations/core_256_blocker_unlink_releases_task.sql +84 -0
  15. package/modules/hall-ui/public/style.css +4 -0
  16. package/modules/hall-ui/public/task.css +6 -0
  17. package/modules/hall-ui/public/task.html +1 -1
  18. package/modules/hall-ui/public/task.js +17 -0
  19. package/modules/hall-ui/records/record-family.md +2 -0
  20. package/modules/ideas/blockers.js +60 -0
  21. package/modules/ideas/routes/blockers.js +41 -0
  22. package/modules/npm-release/module.json +8 -2
  23. package/modules/npm-release/public/task-widget.js +79 -0
  24. package/modules/npm-release/routes/task-where.js +48 -0
  25. package/modules/npm-release/work.js +70 -9
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/scripts/gds/exec-path-guard.js +1 -1
  30. package/scripts/gds/seam-wiring-guard.js +4 -2
  31. package/scripts/hall-preview/server.js +14 -1
  32. package/src/bongos/serve-internal.js +18 -4
  33. package/src/module-api.js +1 -1
  34. package/src/module-loader/loader.js +13 -3
  35. package/tests/blocker_unlink.mjs +251 -0
  36. package/tests/hall_record_world.mjs +23 -1
  37. package/tests/module_loader.mjs +27 -0
  38. package/tests/npm_release_where.mjs +323 -0
@@ -7553,5 +7553,17 @@
7553
7553
  "id": "1001433",
7554
7554
  "text": "The nightly security check of the main code line no longer trusts the name typed on each change, which anyone can fake. It now checks that every change to a sensitive file carries the server's own sign-off from a senior builde"
7555
7555
  }
7556
+ ],
7557
+ "1.19.1043": [
7558
+ {
7559
+ "id": "1004301",
7560
+ "text": "A task's own page now says where its code is — which version carries it, whether it is running on Bongos, whether it is released — and says plainly when a task marked done never actually landed."
7561
+ }
7562
+ ],
7563
+ "1.19.1044": [
7564
+ {
7565
+ "id": "1003518",
7566
+ "text": "A blocker that was linked to the wrong task — a duplicate, or a mistake — can now be unlinked, and the task goes back to being claimable if nothing else was holding it. Before this the only way out was to mark the blocker reso"
7567
+ }
7556
7568
  ]
7557
7569
  }
@@ -103,7 +103,7 @@ const EXEMPTIONS = [
103
103
  + 'absorbed into this check.',
104
104
  },
105
105
  {
106
- at: 'src/module-loader/loader.js:299',
106
+ at: 'src/module-loader/loader.js:309',
107
107
  why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
108
108
  + 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
109
109
  + 'change to either call re-opens only that one.',
@@ -95,11 +95,13 @@ const SEAM_CONSUMERS = {
95
95
  artifact: (dir, entry) => path.join(dir, 'rooms', `${entry}.js`),
96
96
  },
97
97
  uiSections: {
98
- // Per-MODULE, not per-entry: hallWidgetScripts looks for one hall-widget.js
99
- // however many sections the module names.
98
+ // Per-MODULE, not per-entry: hallWidgetScripts looks for one widget file per page
99
+ // however many sections the module names — hall-widget.js for the index, and since
100
+ // task 1004301 task-widget.js for a task's page. Either one wires the declaration.
100
101
  consumer: 'src/module-loader/loader.js hallWidgetScripts',
101
102
  kind: 'file',
102
103
  artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
104
+ alternates: (dir) => [path.join(dir, 'public', 'task-widget.js')],
103
105
  },
104
106
  skills: {
105
107
  // moduleSkillSources resolves a declared name against the module's OWN tree or
@@ -77,7 +77,8 @@ let express;
77
77
  try { express = require(path.join(ROOT, 'node_modules', 'express')); }
78
78
  catch (_) { express = require('express'); }
79
79
  const { buildStampedHtml } = require(path.join(ROOT, 'src', 'asset-version.js'));
80
- const { injectBrandingGlobal, mountInternalSurfaces } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
80
+ const { injectBrandingGlobal, mountInternalSurfaces, injectModuleHallWidgets } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
81
+ const { hallWidgetScripts } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
81
82
  const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
82
83
  const { overrideKey, resolveLiveIdentity } = require('./live-identity');
83
84
 
@@ -128,6 +129,9 @@ const FIXTURE_FALLBACKS = [
128
129
  // without it /deploy?project=… only ever rendered its "could not read" state, which
129
130
  // is the trap this directory's README calls out by name.
130
131
  [/^provisioning\/instances\/\d+\/core-upgrade$/, 'provisioning__instances__core-upgrade'],
132
+ // the npm-release module's line on a task's page (task 1004301): one reading serves any
133
+ // id, as the core-upgrade preview above does — the page shows one task.
134
+ [/^npm-release\/task\/\d+$/, 'npm-release__task'],
131
135
  ];
132
136
 
133
137
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -283,6 +287,15 @@ for (const key of MODULES_OFF) {
283
287
  for (const key of MODULES_ON) {
284
288
  (mods.enabled = mods.enabled || {})[key] = true;
285
289
  }
290
+ // Module page widgets, injected at boot exactly as serve-internal.js does for prod: the
291
+ // hall index's hall-widget.js and a task page's task-widget.js (task 1004301), for the
292
+ // modules THIS harness runs with. Without it a module's line on a page could never be
293
+ // previewed — the page would only ever render its empty slot.
294
+ {
295
+ const isOn = (k) => !!(mods.enabled && mods.enabled[k]);
296
+ if (stamped['index.html']) stamped['index.html'] = injectModuleHallWidgets(stamped['index.html'], hallWidgetScripts({ isEnabled: isOn }));
297
+ if (stamped['task.html']) stamped['task.html'] = injectModuleHallWidgets(stamped['task.html'], hallWidgetScripts({ isEnabled: isOn, file: 'task-widget.js' }), 'task');
298
+ }
286
299
 
287
300
  function sendPage(res, name) {
288
301
  const html = stamped[name];
@@ -443,14 +443,17 @@ function clientBundleScriptTag() {
443
443
  // module's widget is never injected (it vanishes); contributeHallWidget is the second
444
444
  // gate (it no-ops a widget whose module is off). `scripts` is the loader projection
445
445
  // [{ key, src }]; default reads the live enabled set. Pure + exported for the test.
446
- function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled })) {
446
+ function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled }), consumer = 'builders') {
447
447
  if (!Array.isArray(scripts) || scripts.length === 0) return html;
448
448
  const tags = scripts
449
449
  .map((s) => `<script src="${s.src}" defer></script>`)
450
450
  .join('');
451
- // Anchor on builders.js's <script> regardless of its ?v= stamp so the module
452
- // widgets always land immediately before the consumer that mounts them.
453
- const anchor = html.match(/<script src="\/builders\/builders\.js[^"]*"[^>]*><\/script>/);
451
+ // Anchor on the consuming page script's <script> (builders.js on the index, task.js on
452
+ // a task's page — task 1004301) regardless of its ?v= stamp, so the module widgets
453
+ // always land immediately before the consumer: both are `defer`, so they run in
454
+ // document order, and a widget's listener exists before the page first announces.
455
+ const name = /^[a-z-]+$/.test(consumer) ? consumer : 'builders';
456
+ const anchor = html.match(new RegExp(`<script src="\\/builders\\/${name}\\.js[^"]*"[^>]*><\\/script>`));
454
457
  if (anchor) return html.replace(anchor[0], tags + anchor[0]);
455
458
  return html.includes('</body>') ? html.replace('</body>', `${tags}</body>`) : html + tags;
456
459
  }
@@ -1070,6 +1073,17 @@ function mountInternalSurfaces(app) {
1070
1073
  if (buildersHtml['index.html']) {
1071
1074
  buildersHtml['index.html'] = injectModuleHallWidgets(buildersHtml['index.html']);
1072
1075
  }
1076
+ // task 1004301: the same seam for a task's own page. A module that ships
1077
+ // public/task-widget.js is loaded just before task.js, and fills the slot task.js
1078
+ // renders and announces (`otb:task-shown`). The page names no module; with none
1079
+ // enabled, nothing is injected and the slot stays empty.
1080
+ if (buildersHtml['task.html']) {
1081
+ buildersHtml['task.html'] = injectModuleHallWidgets(
1082
+ buildersHtml['task.html'],
1083
+ hallWidgetScripts({ isEnabled: isModuleEnabled, file: 'task-widget.js' }),
1084
+ 'task',
1085
+ );
1086
+ }
1073
1087
 
1074
1088
  // Serve enabled modules' browser assets at /modules/<key>/public/* — the URL the
1075
1089
  // loader's hallWidgetScripts projection points at. Mounted BEFORE the /builders
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1042'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1044'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -213,15 +213,25 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
213
213
  // never injected), and contributeHallWidget itself no-ops a stale widget whose module
214
214
  // is off. No-op (empty list) until a module ships a hall widget — same SAFE-BY-
215
215
  // CONSTRUCTION posture as every other seam here. Returns [{ key, src }].
216
- function hallWidgetScripts({ isEnabled } = {}, opts) {
216
+ //
217
+ // ONE SEAM, ONE FILE PER PAGE (task 1004301). `file` names which page's widget is being
218
+ // asked for: hall-widget.js for the hall index (the default, and the only caller until
219
+ // then), task-widget.js for a task's own page. Same declaration (uiSections), same
220
+ // enabled-only asset mount, same "a disabled module simply is not there" — so a module
221
+ // adds a line to the task page without the page's own code ever naming it. The name is
222
+ // matched against PAGE_WIDGET_FILES rather than trusted, so no caller can turn this into
223
+ // a way to serve an arbitrary file.
224
+ const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js']);
225
+ function hallWidgetScripts({ isEnabled, file = 'hall-widget.js' } = {}, opts) {
226
+ if (!PAGE_WIDGET_FILES.includes(file)) return [];
217
227
  const out = [];
218
228
  for (const { key, dir, manifest } of discovered(opts).loaded) {
219
229
  if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
220
230
  const sections = (manifest.contributes && manifest.contributes.uiSections) || [];
221
231
  if (sections.length === 0) continue;
222
- const scriptPath = path.join(dir, 'public', 'hall-widget.js');
232
+ const scriptPath = path.join(dir, 'public', file);
223
233
  if (!fs.existsSync(scriptPath)) continue; // declarative-only uiSections (e.g. status-page section)
224
- out.push({ key, src: `/modules/${key}/public/hall-widget.js` });
234
+ out.push({ key, src: `/modules/${key}/public/${file}` });
225
235
  }
226
236
  return out;
227
237
  }
@@ -0,0 +1,251 @@
1
+ // tests/blocker_unlink.mjs
2
+ //
3
+ // Task 1003518 — a blocker can be UNLINKED from a task. Linking is one-way and
4
+ // Metic+, and post-core_223 a task is released only when its LAST open blocker
5
+ // resolves, so a wrong link (wrong task id, a duplicate filed twice, a blocker
6
+ // later understood to be irrelevant) was permanent: the only escape was to
7
+ // resolve a blocker that was never real, putting a falsehood in the ledger.
8
+ //
9
+ // TWO HALVES, mirroring tests/blocker_resolve_guard.mjs:
10
+ // 1. the JS is an OBSERVER — it deletes and reads back, it never promotes;
11
+ // 2. the promotion itself is core_256's trigger, pinned as SQL TEXT so a
12
+ // later edit cannot quietly drop the guard or "simplify" it into a bug.
13
+ //
14
+ // The JS half is behavioural, not shape assertions. The module-level `pool` is
15
+ // replaced through require.cache BEFORE blockers.js loads, so the default
16
+ // (zero-injection) path is the one under test — the shape a `deps.pool || pool`
17
+ // shadowing bug would break while a harness that always injects stayed green.
18
+ //
19
+ // Run: node tests/blocker_unlink.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { readFileSync, readdirSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const repoRoot = join(__dirname, '..');
30
+ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ // -- the fake pool, installed as the module's OWN pool ----------------------
35
+
36
+ let current = null; // the pool the stubbed module-api hands out this test
37
+ let releases = 0; // how many times client.release() was called
38
+
39
+ const apiPath = require.resolve('../src/module-api.js');
40
+ require.cache[apiPath] = {
41
+ id: apiPath,
42
+ filename: apiPath,
43
+ loaded: true,
44
+ exports: { pool: { connect: (...a) => current.connect(...a) } },
45
+ };
46
+
47
+ const blockers = require('../modules/ideas/blockers.js');
48
+
49
+ // before/after: the task's status either side of the DELETE (the trigger is what
50
+ // changes it in production, so the fake simply reports what it would have done).
51
+ // deleted: rows the DELETE returns (0 = there was no such link).
52
+ // failOn: a substring; the first matching statement throws.
53
+ function scenario({ before = 'blocked', after = 'ready', deleted = 1, failOn = null } = {}) {
54
+ let seenDelete = false;
55
+ const pool = makeSqlAwareClient((sql) => {
56
+ if (failOn && sql.includes(failOn)) throw new Error('boom');
57
+ if (/^DELETE FROM task_blockers/.test(sql)) {
58
+ seenDelete = true;
59
+ return { rows: Array.from({ length: deleted }, () => ({ task_id: 7 })) };
60
+ }
61
+ if (/^SELECT status FROM tasks/.test(sql)) {
62
+ return { rows: [{ status: seenDelete ? after : before }] };
63
+ }
64
+ return { rows: [] };
65
+ });
66
+ const realRelease = pool._client.release.bind(pool._client);
67
+ pool._client.release = () => { releases += 1; realRelease(); };
68
+ current = pool;
69
+ releases = 0;
70
+ return pool;
71
+ }
72
+
73
+ const sqlOf = (pool) => pool.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
74
+ const unlink = (opts) => blockers.unlinkBlockerFromTask({ blockerId: 93, taskId: 7 }, opts);
75
+
76
+ // == 1. the JS observes =====================================================
77
+
78
+ await test('the link row is deleted, keyed on BOTH ids', async () => {
79
+ const pool = scenario();
80
+ await unlink();
81
+ const del = pool.queries.find((q) => /DELETE FROM task_blockers/.test(q.sql));
82
+ assert.ok(del, 'no DELETE was issued');
83
+ // Order matters: the SQL is (task_id, blocker_id), so a swap deletes a
84
+ // different edge — or silently none.
85
+ assert.deepEqual(del.params, [7, 93]);
86
+ });
87
+
88
+ await test('the JS never promotes — that is core_256s job', async () => {
89
+ // The same assertion blocker_resolve_guard.mjs makes about the resolve path,
90
+ // restated for unlink so the next person to "just fix it here" trips a test.
91
+ const src = read('modules', 'ideas', 'blockers.js');
92
+ assert.doesNotMatch(src, /UPDATE\s+tasks[\s\S]{0,200}?status\s*=\s*'ready'/);
93
+ });
94
+
95
+ await test('the whole unlink is one transaction', async () => {
96
+ const pool = scenario();
97
+ await unlink();
98
+ const s = sqlOf(pool);
99
+ assert.equal(s[0], 'BEGIN');
100
+ assert.equal(s[s.length - 1], 'COMMIT');
101
+ });
102
+
103
+ await test('the before-status is read FOR UPDATE so the read-back cannot be raced', async () => {
104
+ const pool = scenario();
105
+ await unlink();
106
+ const s = sqlOf(pool);
107
+ const first = s.find((q) => q.startsWith('SELECT status FROM tasks'));
108
+ assert.match(first, /FOR UPDATE/);
109
+ // …and it is read BEFORE the delete, or "did this unlink move it?" is unanswerable.
110
+ assert.ok(s.indexOf(first) < s.findIndex((q) => q.startsWith('DELETE FROM task_blockers')));
111
+ });
112
+
113
+ await test('released:true when the task went blocked -> ready', async () => {
114
+ scenario({ before: 'blocked', after: 'ready' });
115
+ assert.deepEqual(await unlink(), { unlinked: true, released: true });
116
+ });
117
+
118
+ await test('released:false while another blocker is still open', async () => {
119
+ // The trigger's NOT EXISTS fails, so the task stays blocked. The unlink still
120
+ // happened — but saying released:true here is the task-1002559 lie.
121
+ scenario({ before: 'blocked', after: 'blocked' });
122
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
123
+ });
124
+
125
+ await test('released:false when the task was never blocked in the first place', async () => {
126
+ // A link can land on a task that LINK_BLOCKABLE_STATUSES never parks (active,
127
+ // backlog, shipped). Reporting "released" for one of those would claim a
128
+ // promotion that never happened — and would be exactly what a naive
129
+ // "is it ready now?" read-back returns.
130
+ scenario({ before: 'active', after: 'active' });
131
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
132
+ });
133
+
134
+ await test('released:false for a task that was ALREADY ready before the unlink', async () => {
135
+ // THE DISCRIMINATING CASE. A legacy blocker_ref link, or one that landed while
136
+ // the task was not blockable, leaves a 'ready' task carrying a link. Removing
137
+ // it changes nothing — but a naive "is it ready NOW?" read-back reports a
138
+ // release that never happened. Only comparing against the BEFORE status can
139
+ // tell the two apart, which is why that status is read at all.
140
+ scenario({ before: 'ready', after: 'ready' });
141
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
142
+ });
143
+
144
+ await test('a link that is not there refuses with LINK_NOT_FOUND and rolls back', async () => {
145
+ const pool = scenario({ deleted: 0 });
146
+ await assert.rejects(() => unlink(), (e) => e.code === 'LINK_NOT_FOUND');
147
+ const s = sqlOf(pool);
148
+ assert.ok(s.includes('ROLLBACK'), 'must roll back');
149
+ assert.ok(!s.includes('COMMIT'), 'must not commit');
150
+ });
151
+
152
+ await test('an unexpected failure rolls back exactly once', async () => {
153
+ const pool = scenario({ failOn: 'DELETE FROM task_blockers' });
154
+ await assert.rejects(() => unlink());
155
+ const s = sqlOf(pool);
156
+ assert.equal(s.filter((q) => q === 'ROLLBACK').length, 1);
157
+ assert.ok(!s.includes('COMMIT'));
158
+ });
159
+
160
+ await test('the client is returned to the pool on EVERY path', async () => {
161
+ scenario();
162
+ await unlink();
163
+ assert.equal(releases, 1, 'success path');
164
+
165
+ scenario({ deleted: 0 });
166
+ await assert.rejects(() => unlink());
167
+ assert.equal(releases, 1, 'refusal path');
168
+
169
+ scenario({ failOn: 'DELETE FROM task_blockers' });
170
+ await assert.rejects(() => unlink());
171
+ assert.equal(releases, 1, 'error path');
172
+ });
173
+
174
+ await test('an injected pool overrides the module-level one', async () => {
175
+ scenario({ before: 'active', after: 'active' }); // module-level: would say false
176
+ let sawDelete = false;
177
+ const other = makeSqlAwareClient((sql) => {
178
+ if (/^DELETE FROM task_blockers/.test(sql)) { sawDelete = true; return { rows: [{ task_id: 7 }] }; }
179
+ if (/^SELECT status FROM tasks/.test(sql)) return { rows: [{ status: sawDelete ? 'ready' : 'blocked' }] };
180
+ return { rows: [] };
181
+ });
182
+ assert.equal((await unlink({ pool: other })).released, true, 'the injected pool must be the one used');
183
+ assert.equal(current.queries.length, 0, 'the module-level pool saw none');
184
+ });
185
+
186
+ // == 2. core_256 — the trigger that actually releases =======================
187
+
188
+ const MIG = 'core_256_blocker_unlink_releases_task.sql';
189
+ const mig = read('migrations', MIG);
190
+ const migN = mig.replace(/\s+/g, ' ');
191
+ // Comments carry the rationale; strip them when asserting on the executable
192
+ // body, so a claim in prose can never stand in for the real SQL.
193
+ const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
194
+
195
+ await test('256 installs an AFTER DELETE row trigger on task_blockers', () => {
196
+ assert.match(body, /CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink\(\)/);
197
+ assert.match(body, /AFTER DELETE ON task_blockers/);
198
+ assert.match(body, /FOR EACH ROW/);
199
+ });
200
+
201
+ await test('the release targets ONLY a blocked task', () => {
202
+ assert.match(body, /UPDATE tasks SET status = 'ready'/);
203
+ assert.match(body, /WHERE id = OLD\.task_id/);
204
+ // Without this, an unlink would launder any task straight to 'ready' — past
205
+ // the metic+archon promote gate.
206
+ assert.match(body, /AND status = 'blocked'/);
207
+ });
208
+
209
+ await test("core_223's NOT EXISTS over the task's remaining open blockers is carried", () => {
210
+ assert.match(body, /AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id/);
211
+ assert.match(body, /WHERE tb\.task_id = OLD\.task_id/);
212
+ assert.match(body, /AND b\.status = 'open'/);
213
+ });
214
+
215
+ await test("and deliberately has NO `b.id <> …` exclusion — AFTER DELETE, the row is gone", () => {
216
+ // core_223 needs that clause because it runs BEFORE UPDATE, where the blocker
217
+ // being resolved still reads 'open' and would count itself, stalling forever.
218
+ // Here the link row is already deleted when the body runs, so the NOT EXISTS
219
+ // sees only what remains. Copying the clause across would be cargo-cult.
220
+ assert.doesNotMatch(body, /b\.id <>/);
221
+ });
222
+
223
+ await test('256 does NOT also gate on dependencies — that would strand the task', () => {
224
+ // The dep-promotion triggers (163) only flip backlog -> ready, so a task held
225
+ // in 'blocked' for unshipped deps would have no release path at all.
226
+ assert.doesNotMatch(body, /task_is_fully_unblocked/);
227
+ });
228
+
229
+ await test('256 is transactional and re-appliable', () => {
230
+ assert.match(migN, /BEGIN;.*COMMIT;/);
231
+ // A trigger has no CREATE OR REPLACE before PG 14, so the drop is what makes
232
+ // a re-run clean rather than a duplicate-object error.
233
+ assert.match(body, /DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers/);
234
+ assert.doesNotMatch(body, /DROP FUNCTION/i);
235
+ });
236
+
237
+ await test('256 is the ONLY definition of the unlink-release function', () => {
238
+ const defs = readdirSync(join(repoRoot, 'migrations'))
239
+ .filter((f) => f.endsWith('.sql'))
240
+ .filter((f) => /FUNCTION trg_release_task_on_blocker_unlink/.test(read('migrations', f)))
241
+ .sort();
242
+ // migrate.sh applies in filename order, so a second, lower-numbered
243
+ // definition landing later would silently reinstate whatever it said.
244
+ assert.deepEqual(defs, [MIG]);
245
+ });
246
+
247
+ await test('unlinkBlockerFromTask is exported', () => {
248
+ assert.equal(typeof blockers.unlinkBlockerFromTask, 'function');
249
+ });
250
+
251
+ summary();
@@ -305,6 +305,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
305
305
  const requests = [];
306
306
  const writes = [];
307
307
  const toasts = [];
308
+ const dispatched = []; // task 1004301: what the page announced (otb:task-shown)
308
309
  const els = new Map();
309
310
  const makeEl = () => ({
310
311
  dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
@@ -326,6 +327,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
326
327
  querySelectorAll() { return []; },
327
328
  createElement() { return makeEl(); },
328
329
  addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
330
+ dispatchEvent(e) { dispatched.push(e); return true; },
329
331
  };
330
332
  const client = {
331
333
  request: async (method, p, opts) => {
@@ -393,7 +395,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
393
395
  confirm: () => true,
394
396
  navigator: { clipboard: { writeText: async () => {} } },
395
397
  };
396
- const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams };
398
+ class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
399
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams, CustomEvent };
397
400
  sandbox.globalThis = sandbox;
398
401
  vm.createContext(sandbox);
399
402
  if (withReader) vm.runInContext(MD_SRC, sandbox, { filename: 'md-reader.js' });
@@ -407,6 +410,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
407
410
  requests,
408
411
  writes,
409
412
  toasts,
413
+ dispatched,
414
+ win: windowObj,
410
415
  };
411
416
  }
412
417
 
@@ -1052,3 +1057,20 @@ test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim"
1052
1057
  `the server's own sentence must reach the toast, not a generic fallback — got: ${r.toasts.join(' | ')}`);
1053
1058
  assert.equal(task.goal_id, 1000071, 'a refused PATCH must not move the task locally');
1054
1059
  });
1060
+
1061
+ // task 1004301 — a slot a MODULE may fill, and the announcement that tells it which task.
1062
+ // The page names no module: it draws an empty #task-contrib under the lifecycle rail and
1063
+ // announces each render, and a module's task-widget.js does the rest.
1064
+ test('the task page renders an empty module slot and announces every render', async () => {
1065
+ const r = renderTask({ ...BASE_TASK });
1066
+ r.boot();
1067
+ for (let i = 0; i < 5; i++) await settle();
1068
+ const html = r.html();
1069
+ assert.match(html, /<div class="tk-contrib" id="task-contrib" aria-live="polite"><\/div>/, 'an empty slot');
1070
+ assert.ok(html.indexOf('id="task-contrib"') > html.indexOf('class="tk-rail"'), 'under the lifecycle rail');
1071
+ const shown = r.dispatched.filter((e) => e.type === 'otb:task-shown');
1072
+ assert.equal(shown.length, 1);
1073
+ assert.deepEqual({ ...shown[0].detail }, { id: '1003305', status: 'shipped' }, 'only the id and the status travel');
1074
+ assert.deepEqual({ ...r.win.OTBTaskShown }, { id: '1003305', status: 'shipped' }, 'left on window for a widget that loads late');
1075
+ assert.doesNotMatch(read('task.js'), /npm-release/, 'the core page names no module');
1076
+ });
@@ -330,6 +330,33 @@ test('hallWidgetScripts: a module with no uiSections is never listed', () => {
330
330
  loader._reset();
331
331
  });
332
332
 
333
+ // task 1004301 — the same seam, one file per page: task-widget.js for a task's own page.
334
+ test('hallWidgetScripts: file task-widget.js lists the modules that ship one, and only those', () => {
335
+ loader._reset();
336
+ const root = fixtureRoot({
337
+ alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/task-widget.js': '// task line' } },
338
+ beta: { manifest: validManifest('beta', { contributes: { uiSections: ['b'] } }), files: { 'public/hall-widget.js': '// index only' } },
339
+ });
340
+ const opts = { roots: [root], core: '1.0.0' };
341
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file: 'task-widget.js' }, opts),
342
+ [{ key: 'alpha', src: '/modules/alpha/public/task-widget.js' }]);
343
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true }, opts),
344
+ [{ key: 'beta', src: '/modules/beta/public/hall-widget.js' }], 'the default is still the index widget');
345
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: (k) => k !== 'alpha', file: 'task-widget.js' }, opts), [], 'disabled: never listed');
346
+ loader._reset();
347
+ });
348
+
349
+ test('hallWidgetScripts: a file name outside the page allowlist lists nothing — it is not a file server', () => {
350
+ loader._reset();
351
+ const root = fixtureRoot({
352
+ alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/secrets.js': '//', 'public/task-widget.js': '//' } },
353
+ });
354
+ for (const file of ['secrets.js', '../module.json', 'public/task-widget.js', 'TASK-WIDGET.JS']) {
355
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file }, { roots: [root], core: '1.0.0' }), [], file);
356
+ }
357
+ loader._reset();
358
+ });
359
+
333
360
  // --- moduleWebSurfaces: an enabled module's host-routed static surface resolves,
334
361
  // a disabled one vanishes (BV1.R84 / task 1635, ADR 0093 §1 / 02-module-map §6) ---
335
362