@bongos/core 1.19.639 → 1.19.641

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.639",
6
- "core_contract": "1.19.639",
7
- "source_commit": "01a3b3c69f079cd730ec88a36b12d3bf30562abd",
5
+ "core_version": "1.19.641",
6
+ "core_contract": "1.19.641",
7
+ "source_commit": "7af21a0086bc6cbc069a459b0a30b90f1646f82e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-10T16:31:46.801Z",
9
+ "built_at": "2026-09-10T17:12:24.520Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 470,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2104,
14
+ "functional_verbatim": 2106,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2598,
20
- "tree_sha256": "03b9b58d7185ba54ca84475a26d2b95870ba16c25ab83697ea91aadf90959e1d",
19
+ "file_count": 2600,
20
+ "tree_sha256": "1fd37a4de38d4265a2f0956b03da63ce66a9e84de7ce8a24e1c66ae4465ea028",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "352910d55c713789e1ff42c1adf06d47097296419000ceeb7a794b25dca46d38"
2775
+ "sha256": "967b335b7e06715fb8c07cea7af1141fa2dd3acb0da7873017048bb63aad15d0"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -3867,7 +3867,7 @@
3867
3867
  {
3868
3868
  "path": "modules/builder-settings/builder-needs.js",
3869
3869
  "mode": "0000644",
3870
- "sha256": "0e660777b249d85ecf5024f0f505aca6372a1305dff7cb62c48f73ed5b6cc902"
3870
+ "sha256": "fb576eb616b62b6616ee4875745f062ff01c8be2c31e88b11cf058bee3a6b71a"
3871
3871
  },
3872
3872
  {
3873
3873
  "path": "modules/builder-settings/db.js",
@@ -4267,7 +4267,7 @@
4267
4267
  {
4268
4268
  "path": "modules/discord/board-broadcast.js",
4269
4269
  "mode": "0000644",
4270
- "sha256": "36906948331be98d0e14b7d0a02d0c86533b2d6247a3506e88c5c52827615411"
4270
+ "sha256": "1d3cfd6ef66c5dfb40d8e39cad9a2683d0a7cfd692c6da85f0a008008bfd4185"
4271
4271
  },
4272
4272
  {
4273
4273
  "path": "modules/discord/bug-attachments.js",
@@ -4297,7 +4297,7 @@
4297
4297
  {
4298
4298
  "path": "modules/discord/discord-bugs.js",
4299
4299
  "mode": "0000644",
4300
- "sha256": "52b463955cd7e57f8d194c161a76e2ade83481f3ee0a16759019a5b1f873b374"
4300
+ "sha256": "000c766b483804e00ac523850e5cd8bc06a8f25aecf622a8629e1987014447b7"
4301
4301
  },
4302
4302
  {
4303
4303
  "path": "modules/discord/discord-channels.js",
@@ -4307,7 +4307,7 @@
4307
4307
  {
4308
4308
  "path": "modules/discord/discord-inbound.js",
4309
4309
  "mode": "0000644",
4310
- "sha256": "86099070386ff606ec14330375ac8c3145365307ae2ac354539731aebcc16563"
4310
+ "sha256": "4779118709c5e5365c1acc7b7348bf2d812726530876ac99d9f28777db0477b2"
4311
4311
  },
4312
4312
  {
4313
4313
  "path": "modules/discord/discord-message-buffer.js",
@@ -4317,13 +4317,18 @@
4317
4317
  {
4318
4318
  "path": "modules/discord/discord-reaction.js",
4319
4319
  "mode": "0000644",
4320
- "sha256": "010766b857bd4c6727acdb2272de0442febaf81da6ba2da497b525a3e9d07df6"
4320
+ "sha256": "6d5cd39f63c1f8d6d84e713606844eead96fc2366e720523067f46d845c10c8d"
4321
4321
  },
4322
4322
  {
4323
4323
  "path": "modules/discord/discord-role-sync.js",
4324
4324
  "mode": "0000644",
4325
4325
  "sha256": "852abc58bdec011b6d3bf222a35e198c6ea98a761d57a76be85477ef85ae54f5"
4326
4326
  },
4327
+ {
4328
+ "path": "modules/discord/lifecycle-port.js",
4329
+ "mode": "0000644",
4330
+ "sha256": "f5c7bb703d5c230f334322128129dfc02c6cc03fa9d8d37e00b67493de80a25d"
4331
+ },
4327
4332
  {
4328
4333
  "path": "modules/discord/migrations/discord_001_board_messages.sql",
4329
4334
  "mode": "0000644",
@@ -7717,12 +7722,12 @@
7717
7722
  {
7718
7723
  "path": "package-lock.json",
7719
7724
  "mode": "0000644",
7720
- "sha256": "8764369ffd2116e5dba8ddb06c801c496e68275a450528ad0bde450fa251bb89"
7725
+ "sha256": "9a655dfca70f3edf6083c6533004860a8a6da53455d732526dea6c0c37f3fcf1"
7721
7726
  },
7722
7727
  {
7723
7728
  "path": "package.json",
7724
7729
  "mode": "0000644",
7725
- "sha256": "c6f51339c4fe0264cc3b5a2feb1fb25f4d1a50460fa51413ae47a184255c1c07"
7730
+ "sha256": "80d6ffe532ebbd4d2165171ec5e2887487599e42c53e5411bb4048074d2eb0fb"
7726
7731
  },
7727
7732
  {
7728
7733
  "path": "public-docs/index.html",
@@ -8802,7 +8807,7 @@
8802
8807
  {
8803
8808
  "path": "scripts/gds/sequence.js",
8804
8809
  "mode": "0000644",
8805
- "sha256": "08b2165b899aa71983826789613d7eadd1bdded7f4ef12b05d7c1a60bb79337e"
8810
+ "sha256": "966e2caee9fff17d57d523402af8f39b02572270b1f4437377eb4b64a8bb494d"
8806
8811
  },
8807
8812
  {
8808
8813
  "path": "scripts/gds/session-cost.js",
@@ -9477,7 +9482,7 @@
9477
9482
  {
9478
9483
  "path": "src/module-api.js",
9479
9484
  "mode": "0000644",
9480
- "sha256": "4b836d646ba65252f11dea1b5dd4872e44835b94247bba3f7cb9e98b05742b54"
9485
+ "sha256": "cc274e241228f0ad4bb534674500d4daf228abcf6434241fed7152e49590f43f"
9481
9486
  },
9482
9487
  {
9483
9488
  "path": "src/module-loader/catalog.js",
@@ -10362,7 +10367,7 @@
10362
10367
  {
10363
10368
  "path": "tests/discord_board_broadcast.mjs",
10364
10369
  "mode": "0000644",
10365
- "sha256": "f3ac413d7653d3027516eae052523b98206cb54452cb3410602e1bb85c4d4c19"
10370
+ "sha256": "df417a2907c9436d2645dc63121ddb7b7152bf9d3aeb0adeffcc67febb7e43ef"
10366
10371
  },
10367
10372
  {
10368
10373
  "path": "tests/discord_bot.mjs",
@@ -10389,6 +10394,11 @@
10389
10394
  "mode": "0000644",
10390
10395
  "sha256": "c4e01ac01a3f01fc1a7aece34457a1660034164ab7cb65039f26d4a72d92a403"
10391
10396
  },
10397
+ {
10398
+ "path": "tests/discord_lifecycle_port.mjs",
10399
+ "mode": "0000644",
10400
+ "sha256": "eaa289f4e1fa413b30f23cc23a0aea166bd1d9c18a1992d07a0694b28c9fa3c2"
10401
+ },
10392
10402
  {
10393
10403
  "path": "tests/discord_message_buffer.mjs",
10394
10404
  "mode": "0000644",
@@ -10757,7 +10767,7 @@
10757
10767
  {
10758
10768
  "path": "tests/government_board_hash_redirect.mjs",
10759
10769
  "mode": "0000644",
10760
- "sha256": "072ae4535449315072118dbe4c904bfd7bacef849fb439095125f545a88b79f2"
10770
+ "sha256": "8dbd98e30dc2ece5896a2f412f6ad8a677ee37025f66f3e21c92b90da5bb192d"
10761
10771
  },
10762
10772
  {
10763
10773
  "path": "tests/government_board_majority.mjs",
@@ -10782,7 +10792,7 @@
10782
10792
  {
10783
10793
  "path": "tests/government_board_summons.mjs",
10784
10794
  "mode": "0000644",
10785
- "sha256": "349bf135c0c38fb23d40142e3aaa2a5aada9484a4cead9c5b312450a9f818c07"
10795
+ "sha256": "2fc96deb1fb0521befd18b8f2c569076f0047ffe8da49a8129a24b5eb8fd0fcf"
10786
10796
  },
10787
10797
  {
10788
10798
  "path": "tests/government_board_view.mjs",
@@ -12362,7 +12372,7 @@
12362
12372
  {
12363
12373
  "path": "tests/sequence.mjs",
12364
12374
  "mode": "0000644",
12365
- "sha256": "68e8478af48abc5d3a114f74d1f0bba6cb527c9b457a9f2d291a2ed45cf971dc"
12375
+ "sha256": "5f5933b629b39b90ce3a3ebb5e44b42d573dcb58985716fdcd1a50190754e7a5"
12366
12376
  },
12367
12377
  {
12368
12378
  "path": "tests/served_paths_shipped.mjs",
@@ -1727,5 +1727,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1727
1727
  landed since 1.19.637 with no explicit bump. run 34438897721. (task 1002620)
1728
1728
  1.19.639 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1729
1729
  landed since 1.19.638 with no explicit bump. run 34502609272. (task 1002620)
1730
+ 1.19.640 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1731
+ landed since 1.19.639 with no explicit bump. run 34506041899. (task 1002620)
1732
+ 1.19.641 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1733
+ landed since 1.19.640 with no explicit bump. run 34506718627. (task 1002620)
1730
1734
  ---------------------------------------------------------------------------
1731
1735
  ```
@@ -174,10 +174,13 @@ function boardVotesNeed(ctx) {
174
174
  if (n <= 0) return null;
175
175
  const firstId = pending && pending.first_item_id;
176
176
  // Deep-link the named sitting when we know it (?item=<id> — the Board Room
177
- // panel scrolls to and highlights it; the hash stays the tab), else the room.
177
+ // page scrolls to and highlights it), else the room itself. The Board Room is
178
+ // its own page since ADR 0266; the retired hash form it replaced is a fragment
179
+ // the server never sees, so it survives only as government.js's client-side
180
+ // redirect and nothing here emits it any more.
178
181
  const href = firstId
179
- ? `/government?item=${encodeURIComponent(firstId)}#board-room`
180
- : '/government#board-room';
182
+ ? `/board-room?item=${encodeURIComponent(firstId)}`
183
+ : '/board-room';
181
184
  const subject = n === 1 ? 'An idea is' : `${n} ideas are`;
182
185
  return {
183
186
  id: 'board_votes',
@@ -151,11 +151,18 @@ function subjectLabel(subjectType) {
151
151
  return subjectType === 'constitutional_amendment' ? 'A constitutional amendment' : 'A Full Idea';
152
152
  }
153
153
 
154
- // Straight to THAT item: ?item=<id> — the Board Room panel reads it and scrolls
155
- // to / highlights the named sitting (the hash stays the tab).
154
+ // Straight to THAT item: ?item=<id> — the Board Room page reads it and scrolls
155
+ // to / highlights the named sitting.
156
+ //
157
+ // POINTS AT `/board-room`, the room's own page since ADR 0266. This is the link
158
+ // a member is most likely to click (ADR 0175 §8 makes the mention the "vote
159
+ // within X" notice), and it is also the one that outlives the code: an
160
+ // announcement already in channel history keeps whatever URL it was posted with
161
+ // forever. That is why government.js's `#board-room` redirect stays a release
162
+ // regardless — it serves the old messages, not this writer.
156
163
  function itemLink(itemId) {
157
164
  const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
158
- return `${origin ? origin.replace(/\/$/, '') : ''}/government?item=${encodeURIComponent(itemId)}#board-room`;
165
+ return `${origin ? origin.replace(/\/$/, '') : ''}/board-room?item=${encodeURIComponent(itemId)}`;
159
166
  }
160
167
 
161
168
  // ── the close (BV1.R22 of goal 1000069) ──────────────────────────────────────
@@ -57,6 +57,7 @@ const { auditDiscordBot } = require('./discord-audit');
57
57
  const auth = require('./auth');
58
58
  const attachments = require('./bug-attachments');
59
59
  const { combineParts, createMessageAggregator } = require('./discord-message-buffer');
60
+ const { lifecycleMethod } = require('./lifecycle-port');
60
61
  const api = require('../../src/module-api');
61
62
  const { LIMITS } = api;
62
63
  const log = api.logger('discord');
@@ -134,14 +135,10 @@ const realDeps = {
134
135
  // module rule, ADR 0083/0093). `ideas.capture` is the same door #ideas uses;
135
136
  // `lifecycle` answers which goal, on which still-open version.
136
137
  captureIdea: (...a) => api.resolve('ideas.capture')(...a),
137
- // resolveOptional, not resolve: an instance running without the lifecycle module
138
- // has no goals to suggest, and that is a reason to file the report WITHOUT a
139
- // home — never a reason to lose it. Returns null, which lands it in the inbox.
140
- suggestGoalForCapture: async (...a) => {
141
- const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
142
- if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
143
- return lifecycle.suggestGoalForCapture(...a);
144
- },
138
+ // Optional, not required: an instance running without the lifecycle module has
139
+ // no goals to suggest, and that is a reason to file the report WITHOUT a home
140
+ // — never a reason to lose it. Resolves null, which lands it in the inbox.
141
+ suggestGoalForCapture: lifecycleMethod('suggestGoalForCapture', () => null),
145
142
  insertAuditLog: db.insertAuditLog,
146
143
  persistImages: attachments.persistImages,
147
144
  };
@@ -49,6 +49,7 @@ const { auditDiscordBot } = require('./discord-audit');
49
49
  const auth = require('./auth');
50
50
  const attachments = require('./bug-attachments');
51
51
  const { createMessageAggregator } = require('./discord-message-buffer');
52
+ const { lifecycleMethod } = require('./lifecycle-port');
52
53
  const { LIMITS } = api;
53
54
  const log = api.logger('discord');
54
55
 
@@ -88,21 +89,13 @@ const realDeps = {
88
89
  // Lazy: resolve the `ideas.capture` port at CALL time, so the ideas module's
89
90
  // registration (at its route-factory mount) need not precede this module's load.
90
91
  captureIdea: (...a) => api.resolve('ideas.capture')(...a),
91
- // resolveOptional, not resolve: a vanilla instance without the lifecycle
92
- // module has no goals to suggest, and that is a reason to skip the hint —
93
- // never a reason to lose the idea (the discord-bugs.js precedent).
94
- suggestGoalForCapture: async (...a) => {
95
- const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
96
- if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
97
- return lifecycle.suggestGoalForCapture(...a);
98
- },
99
- // Same resolveOptional posture — a vanilla instance without lifecycle has no
100
- // versions to default to, which is a reason to skip the default, never the idea.
101
- listVersions: async () => {
102
- const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
103
- if (!lifecycle || !lifecycle.listVersions) return [];
104
- return lifecycle.listVersions();
105
- },
92
+ // Optional, not required: a vanilla instance without the lifecycle module has
93
+ // no goals to suggest, and that is a reason to skip the hint — never a reason
94
+ // to lose the idea (the discord-bugs.js precedent).
95
+ suggestGoalForCapture: lifecycleMethod('suggestGoalForCapture', () => null),
96
+ // Same posture — a vanilla instance without lifecycle has no versions to
97
+ // default to, which is a reason to skip the default, never the idea.
98
+ listVersions: lifecycleMethod('listVersions', () => []),
106
99
  insertAuditLog: db.insertAuditLog,
107
100
  persistImages: attachments.persistImages,
108
101
  };
@@ -32,14 +32,13 @@ const { pool } = require('../../src/module-api');
32
32
  const db = require('./db');
33
33
  const { auditDiscordBot } = require('./discord-audit');
34
34
  const auth = require('./auth');
35
- const api = require('../../src/module-api');
36
- // VOTABLE_TASK_STATUSES is the lifecycle module's votable-status set, resolved via
35
+ const { lifecycleMember } = require('./lifecycle-port');
36
+ // VOTABLE_TASK_STATUSES is the lifecycle module's votable-status set, read via
37
37
  // the `lifecycle` kernel PORT (the doorway leak retired at BV1.R86) — a vote only
38
- // lands on confirmed/shipped work. Resolved at call time (this file runs under the
39
- // discord bot poller, well after the lifecycle port registers at boot). A
40
- // #ship-feed message is always about a shipped task, but we re-check rather than
41
- // trust the channel.
42
- const votableStatuses = () => api.resolveOptional('lifecycle')?.VOTABLE_TASK_STATUSES || new Set();
38
+ // lands on confirmed/shipped work. lifecycle-port.js keeps the resolve late and
39
+ // the absent-module fallback empty. A #ship-feed message is always about a
40
+ // shipped task, but we re-check rather than trust the channel.
41
+ const votableStatuses = () => lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
43
42
  const VOTING_RANKS = new Set(['metic', 'archon']);
44
43
 
45
44
  // PURE — extract the GDS task id from a #ship-feed message. The producer
@@ -0,0 +1,69 @@
1
+ // modules/discord/lifecycle-port.js — the one place this module reaches the
2
+ // OPTIONAL `lifecycle` kernel port.
3
+ //
4
+ // Why this file exists: four call sites across discord-bugs.js,
5
+ // discord-inbound.js and discord-reaction.js each hand-rolled the same
6
+ // resolveOptional('lifecycle') dance — resolve the port at call time, check the
7
+ // member is actually there, fall back to an empty value when it is not. The
8
+ // duplication was a grader nit on task 1003068 and the goal 1000071 R11 ship.
9
+ //
10
+ // THE POSTURE — resolveOptional, never resolve. `lifecycle` is an optional
11
+ // module: a vanilla Cloud Bongos instance runs without it, so it has no goals
12
+ // to suggest and no versions to default to. That is a reason to skip the hint,
13
+ // NEVER a reason to lose the idea or drop the bug report. Every accessor here
14
+ // degrades to an empty value, and nothing in this file throws.
15
+ //
16
+ // LATE BINDING IS LOAD-BEARING. The port is resolved inside each accessor, not
17
+ // once at require time, because this module's files are loaded before the
18
+ // lifecycle module registers its provider at boot. Hoisting the resolve to
19
+ // module scope would pin the fallback forever — which is why every accessor
20
+ // below is a function, and why none of them caches.
21
+ //
22
+ // A module may import only the published doorway (src/module-api.js), so this
23
+ // helper lives beside its callers rather than in core (ADR 0083/0093).
24
+
25
+ const api = require('../../src/module-api');
26
+
27
+ // The port itself, or null. The `api.resolveOptional &&` guard is kept because
28
+ // three of the four original copies carried it; discord-reaction.js was the odd
29
+ // one out, so routing every caller through here also settles that inconsistency
30
+ // in favour of the guard.
31
+ function lifecyclePort() {
32
+ return (api.resolveOptional && api.resolveOptional('lifecycle')) || null;
33
+ }
34
+
35
+ // Read one member off the `lifecycle` port, or produce an empty stand-in.
36
+ //
37
+ // `makeFallback` is a FACTORY, not a value, and deliberately so: the fallbacks
38
+ // in play are mutable (`[]`, `new Set()`), and handing every caller one shared
39
+ // instance would turn a mutation by any one of them into a cross-call leak.
40
+ // Each call gets a fresh fallback, exactly as the hand-rolled copies did.
41
+ //
42
+ // Reading as `lifecycle[member]` also keeps the port's GETTERS working —
43
+ // VOTABLE_TASK_STATUSES is one (`get VOTABLE_TASK_STATUSES()`), so it must be
44
+ // read off the object rather than destructured ahead of time.
45
+ function lifecycleMember(member, makeFallback) {
46
+ const lifecycle = lifecyclePort();
47
+ const value = lifecycle ? lifecycle[member] : undefined;
48
+ return value === undefined || value === null ? makeFallback() : value;
49
+ }
50
+
51
+ // Wrap an optional lifecycle METHOD as an always-callable async function: it
52
+ // forwards to the port when the module is present, and resolves to a fresh
53
+ // fallback when either the module or that method is absent. The caller cannot
54
+ // tell the two absences apart, and does not need to.
55
+ //
56
+ // `fn.call(lifecycle, …)`, not `fn(…)`: the call sites this replaced invoked
57
+ // `lifecycle.method(...)`, so the port was the receiver. Every member the port
58
+ // exports today is a delegating arrow that ignores `this`, so nothing currently
59
+ // depends on it — which is exactly why dropping it would go unnoticed until a
60
+ // provider that DOES use `this` broke silently. Preserve it.
61
+ function lifecycleMethod(member, makeFallback) {
62
+ return async (...args) => {
63
+ const lifecycle = lifecyclePort();
64
+ const fn = lifecycle ? lifecycle[member] : undefined;
65
+ return typeof fn === 'function' ? fn.call(lifecycle, ...args) : makeFallback();
66
+ };
67
+ }
68
+
69
+ module.exports = { lifecycleMember, lifecycleMethod };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.639",
3
+ "version": "1.19.641",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.639",
9
+ "version": "1.19.641",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.639",
3
+ "version": "1.19.641",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -164,13 +164,62 @@ function chainFrom(headId, byId, { limit = DEFAULT_LIMIT } = {}) {
164
164
  return picked;
165
165
  }
166
166
 
167
+ // Does the task's OWN declared file surface rule a schema change out? (task 1003699)
168
+ //
169
+ // tasks.needs_migration is set server-side from a keyword scan of the title and
170
+ // description (migration 081; detectsMigration in
171
+ // modules/lifecycle/routes/task-route-helpers.js), so the flag means "this task
172
+ // SAYS the word migration", not "this task changes the schema". Task 1003697
173
+ // carried it while its description said, in capitals, not to change any
174
+ // migration; the row for THIS task is flagged too, for the same reason. A false
175
+ // halt is not free — it is a task the runner will never offer.
176
+ //
177
+ // So the flag alone no longer halts. It is overridden only by positive evidence
178
+ // pointing the other way: a NON-EMPTY touches[] that names no path under
179
+ // migrations/. That is the author stating the file surface and leaving
180
+ // migrations/ out of it.
181
+ //
182
+ // The asymmetry is deliberate. An absent or empty touches[] tells us nothing, so
183
+ // it keeps halting — and it must, because the rank floor cannot be leaned on
184
+ // here: task 1003697 sat at requires_rank 'xenos' with the flag set, so ADR
185
+ // 0270's needs_migration→floor coupling did not raise it and would not have
186
+ // caught a real schema change either.
187
+ //
188
+ // Note this is NARROWER than what the task proposed. "touches[] hitting
189
+ // migrations/, or a declared migration number" would have re-read the prose, and
190
+ // 1003697's description contains a literal migrations/… path while its touches[]
191
+ // contains none — so the number/path branch would have preserved the very false
192
+ // halt the task was filed to remove, and failed its own acceptance criterion.
193
+ // touches[] is the only signal here that is structural rather than vocabulary.
194
+ // A `migrations` DIRECTORY anywhere in the path, not only at the repo root.
195
+ // This codebase ships MODULE-OWNED schema migrations at
196
+ // modules/<key>/migrations/NNN_*.sql (ADR 0083; docs/modules-contract.md §
197
+ // "Module-owned migrations"), and ten such directories exist today — so an
198
+ // anchored /^migrations\// test would have called every one of them "not a
199
+ // migration" and suppressed the halt on a real schema change.
200
+ //
201
+ // That mistake would have been worse than the bug this task fixes, because
202
+ // nothing else catches it: permission-path-check's protected-surfaces registry
203
+ // carries a `migrations/` glob that IS a second gate for root migrations, but it
204
+ // is root-anchored too, so a module-owned migration has no other halt at all.
205
+ //
206
+ // Matching a whole path SEGMENT is what keeps docs/migrations-guide.md — a file
207
+ // with the word in its name and no migrations directory — from counting.
208
+ const MIGRATION_DIR_RE = /(?:^|\/)migrations\//i;
209
+
210
+ function declaredSurfaceExcludesMigrations(task) {
211
+ const touches = Array.isArray(task.touches) ? task.touches : [];
212
+ if (!touches.length) return false;
213
+ return !touches.some((raw) => MIGRATION_DIR_RE.test(String(raw).trim().replace(/\\/g, '/')));
214
+ }
215
+
167
216
  // The halt matrix. Facts only — every entry is something the runner can read
168
217
  // off the task row, never a judgement about the work itself.
169
218
  function haltReasons(task, { rank, claimableIds = null } = {}) {
170
219
  const reasons = [];
171
220
  if (!task) return [{ code: 'not_found', detail: 'no such task' }];
172
221
 
173
- if (task.needs_migration) {
222
+ if (task.needs_migration && !declaredSurfaceExcludesMigrations(task)) {
174
223
  reasons.push({ code: 'needs_migration', detail: HALT_REASONS.needs_migration });
175
224
  }
176
225
  if (task.security_sensitive) {
@@ -286,9 +335,64 @@ async function fetchChain(api, headId, limit) {
286
335
  return byId;
287
336
  }
288
337
 
338
+ // GOAL_PAGE is deliberately below the route's limit cap (1000): the cap is what
339
+ // made the old single read look complete, and paging in smaller bites means the
340
+ // "did I get everything?" question is answered by a short last page rather than
341
+ // by trusting one number. GOAL_MAX_ROWS is a runaway guard, not a window — it is
342
+ // far above any real goal, and hitting it throws rather than truncating.
343
+ const GOAL_PAGE = 500;
344
+ const GOAL_MAX_ROWS = 20000;
345
+
346
+ // Every task row in one goal, paged until the server runs out (task 1003699).
347
+ //
348
+ // THE BUG THIS REPLACES. The old body asked for `?limit=1000` with NO goal
349
+ // filter and matched goal_id in JS. Past a thousand rows in that ordering — this
350
+ // instance crossed it long ago — a task in the goal simply fell outside the
351
+ // window, and the planner printed "No open tasks in goal N". That is
352
+ // indistinguishable from a finished chain, which is the dangerous part: the
353
+ // runner concludes the goal is DONE and stops. Raising the limit only moves the
354
+ // cliff, so the fix is to filter server-side (?goal_id=, ADR 0086 §3) and page.
355
+ //
356
+ // ANY unreadable page throws, for the same reason the bug was invisible: "I
357
+ // could not look" and "there is nothing left" must never render as the same
358
+ // sentence — and a dropped middle page would say the second while meaning the
359
+ // first.
360
+ async function fetchGoalTaskRows(api, goalId) {
361
+ const rows = [];
362
+ for (let offset = 0; ; offset += GOAL_PAGE) {
363
+ const query = { goal_id: Number(goalId), limit: GOAL_PAGE };
364
+ // The route rejects offset=0 as "must be a positive integer", so page one
365
+ // sends no offset at all.
366
+ if (offset) query.offset = offset;
367
+ const r = await api.tasks.getTasks({ query });
368
+ if (!r.ok) {
369
+ // ANY failed page throws, not just the first. Returning the rows read so
370
+ // far would hand back a short list that looks like a complete goal — the
371
+ // same "silent incompleteness reads as done" mistake this task exists to
372
+ // remove, just relocated from a fixed window to a transient failure. A
373
+ // caller that wants partial data can ask again; one that is told nothing
374
+ // cannot know to.
375
+ throw new Error(
376
+ `could not read the tasks in goal ${goalId} at offset ${offset} `
377
+ + `(HTTP ${r.status || '?'}) — refusing to report a partial goal as a complete one`,
378
+ );
379
+ }
380
+ const page = rowsOf(r.data) || [];
381
+ rows.push(...page);
382
+ if (page.length < GOAL_PAGE) break;
383
+ if (rows.length >= GOAL_MAX_ROWS) {
384
+ throw new Error(`goal ${goalId} returned ${rows.length}+ rows — refusing to page further`);
385
+ }
386
+ }
387
+ return rows;
388
+ }
389
+
289
390
  async function fetchGoalTasks(api, goalId, limit) {
290
- const r = await api.tasks.getTasks({ query: { limit: 1000 } });
291
- const rows = (r.ok ? rowsOf(r.data) : null) || [];
391
+ const rows = await fetchGoalTaskRows(api, goalId);
392
+ // The goal_id match stays even though the server filtered: a core pinned older
393
+ // than ADR 0086 §3 ignores an unknown query param instead of rejecting it, so
394
+ // without this line an un-upgraded instance would silently hand back the whole
395
+ // table and the planner would plan someone else's goal.
292
396
  const mine = rows
293
397
  .filter((t) => Number(t.goal_id) === Number(goalId) && !TERMINAL.has(t.status))
294
398
  .slice(0, limit);
@@ -472,6 +576,10 @@ module.exports = {
472
576
  readArgv,
473
577
  edgeIds,
474
578
  rowsOf,
579
+ fetchGoalTasks,
580
+ fetchGoalTaskRows,
581
+ declaredSurfaceExcludesMigrations,
582
+ GOAL_PAGE,
475
583
  topoOrder,
476
584
  chainFrom,
477
585
  haltReasons,
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.639'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.641'; // 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');
@@ -50,7 +50,8 @@ test('linked members are @mentioned on the allowlist; unlinked members are named
50
50
  assert.ok(posted.text.includes('<@111222333>'));
51
51
  assert.ok(posted.text.includes('unlinked-two'), 'the unlinked member is still named');
52
52
  assert.ok(posted.text.includes('within 90 minutes'), 'the mention IS the vote-within-X notice');
53
- assert.ok(posted.text.includes('/government?item=11#board-room'), 'deep-links THAT item — the panel scrolls to it');
53
+ assert.ok(posted.text.includes('/board-room?item=11'), 'deep-links THAT item — the page scrolls to it');
54
+ assert.ok(!posted.text.includes('#board-room'), 'never emits the retired hash form (ADR 0266)');
54
55
  });
55
56
 
56
57
  test('a clockless sitting says so, and an amendment names its subject honestly', async () => {
@@ -221,7 +222,8 @@ test('the close is EDITED onto the same message, and the edit is stamped', async
221
222
  assert.equal(edited.messageId, '555000111');
222
223
  assert.ok(edited.text.includes('RATIFIED'), 'the summons now reads as the outcome');
223
224
  assert.ok(!edited.text.includes('your vote is wanted'), 'nothing stale survives the edit');
224
- assert.ok(edited.text.includes('/government?item=11#board-room'), 'the record is still one click away');
225
+ assert.ok(edited.text.includes('/board-room?item=11'), 'the record is still one click away');
226
+ assert.ok(!edited.text.includes('#board-room'), 'never emits the retired hash form (ADR 0266)');
225
227
  assert.equal(stamped, '11', 'edited_at answers "is this window still shown as open?" without asking Discord');
226
228
  });
227
229
 
@@ -0,0 +1,153 @@
1
+ // tests/discord_lifecycle_port.mjs — the optional `lifecycle` port accessor
2
+ // (task 1003630).
3
+ //
4
+ // The four hand-rolled resolveOptional('lifecycle') wrappers this helper
5
+ // replaced were never covered: every existing discord test injects `deps`, so
6
+ // the real `realDeps` wrappers — and specifically their absent-module branch —
7
+ // ran only in production. That branch is the whole point of the file (a vanilla
8
+ // Cloud Bongos instance has no lifecycle module), so it is what these pin:
9
+ //
10
+ // 1. present module → the port's own member/method is used
11
+ // 2. absent module → a fresh empty fallback, and NO throw
12
+ // 3. absent MEMBER on a present module → the same fallback (the caller cannot
13
+ // tell the two absences apart, and must not need to)
14
+ // 4. the fallback is a FACTORY — each call gets its own instance, so one
15
+ // caller mutating an empty [] or Set() cannot leak into the next
16
+ // 5. binding is LATE — a provider registered after require() is still seen
17
+ // 6. the RECEIVER survives — `lifecycle.method(...)` was the original call
18
+ // shape, so `this` inside a method must still be the port, and a getter
19
+ // member must still fire
20
+ //
21
+ // DB-free and bot-free: it drives the seam registry directly.
22
+ //
23
+ // WHY IT LIVES HERE and not in modules/discord/tests/: module-owned tests are
24
+ // discovered but gated by isModuleEnabled('discord'), and `discord` is OFF in
25
+ // the vanilla pack (config/modules.neutral.json) with no config/modules.json in
26
+ // the repo — so a module-owned copy is skipped in CI and would never actually
27
+ // run. The runner's MODULE_TESTS map is curated and does not list discord, so
28
+ // top-level tests/discord_*.mjs run unconditionally. This sits beside its three
29
+ // siblings (discord_reaction, discord_inbound, discord_bugs) for that reason.
30
+
31
+ import { strict as assert } from 'node:assert';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const seams = require('../src/module-seams.js');
36
+ const { lifecycleMember, lifecycleMethod } = require('../modules/discord/lifecycle-port.js');
37
+
38
+ let passed = 0;
39
+ let failed = 0;
40
+ async function test(name, fn) {
41
+ try { await fn(); passed++; console.log(` ok ${name}`); }
42
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
43
+ }
44
+
45
+ console.log('\ndiscord: the optional lifecycle port degrades instead of throwing');
46
+
47
+ // ---- 1. the module is present ---------------------------------------------
48
+
49
+ await test('lifecycleMember reads the real member off a registered port', () => {
50
+ seams._reset();
51
+ const statuses = new Set(['confirmed', 'shipped']);
52
+ seams.registerProvider('lifecycle', { VOTABLE_TASK_STATUSES: statuses });
53
+ assert.equal(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), statuses);
54
+ });
55
+
56
+ await test('lifecycleMethod forwards args to the real method and returns its value', async () => {
57
+ seams._reset();
58
+ const seen = [];
59
+ seams.registerProvider('lifecycle', {
60
+ suggestGoalForCapture: async (draft, extra) => { seen.push([draft, extra]); return { goal_id: '1000091' }; },
61
+ });
62
+ const suggest = lifecycleMethod('suggestGoalForCapture', () => null);
63
+ assert.deepEqual(await suggest({ title: 'x' }, 7), { goal_id: '1000091' });
64
+ assert.deepEqual(seen, [[{ title: 'x' }, 7]], 'every argument is forwarded, in order');
65
+ });
66
+
67
+ // ---- 2. the module is absent (the vanilla instance) ------------------------
68
+
69
+ await test('lifecycleMember falls back when NO provider is registered', () => {
70
+ seams._reset();
71
+ const out = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
72
+ assert.ok(out instanceof Set);
73
+ assert.equal(out.size, 0, 'an empty votable set, so no vote can land');
74
+ });
75
+
76
+ await test('lifecycleMethod resolves the fallback rather than throwing', async () => {
77
+ seams._reset();
78
+ assert.equal(await lifecycleMethod('suggestGoalForCapture', () => null)({ title: 'x' }), null);
79
+ assert.deepEqual(await lifecycleMethod('listVersions', () => [])(), []);
80
+ });
81
+
82
+ // ---- 3. present module, absent member -------------------------------------
83
+
84
+ await test('a present port MISSING the member falls back the same way', async () => {
85
+ seams._reset();
86
+ seams.registerProvider('lifecycle', { somethingElse: () => 'nope' });
87
+ assert.deepEqual(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), new Set());
88
+ assert.equal(await lifecycleMethod('suggestGoalForCapture', () => null)({}), null);
89
+ });
90
+
91
+ await test('a member present but NOT a function still falls back', async () => {
92
+ seams._reset();
93
+ seams.registerProvider('lifecycle', { listVersions: 'not callable' });
94
+ assert.deepEqual(await lifecycleMethod('listVersions', () => [])(), []);
95
+ });
96
+
97
+ // ---- 4. the fallback is a factory, not a shared instance -------------------
98
+
99
+ await test('each fallback is a FRESH instance — a mutation cannot leak forward', async () => {
100
+ seams._reset();
101
+ const first = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
102
+ first.add('poisoned');
103
+ const second = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
104
+ assert.equal(second.size, 0, 'the second caller must not see the first caller\'s write');
105
+ assert.notEqual(first, second);
106
+
107
+ const listVersions = lifecycleMethod('listVersions', () => []);
108
+ const a = await listVersions();
109
+ a.push('poisoned');
110
+ assert.deepEqual(await listVersions(), [], 'the same holds across method calls');
111
+ });
112
+
113
+ // ---- 6. the receiver and getters survive the indirection -------------------
114
+
115
+ await test('a method still sees the PORT as `this` (not undefined)', async () => {
116
+ seams._reset();
117
+ const port = {
118
+ marker: 'the-port',
119
+ // A plain method, so `this` is meaningful — unlike the delegating arrows the
120
+ // real lifecycle module exports today.
121
+ async suggestGoalForCapture() { return this && this.marker; },
122
+ };
123
+ seams.registerProvider('lifecycle', port);
124
+ assert.equal(
125
+ await lifecycleMethod('suggestGoalForCapture', () => null)({}),
126
+ 'the-port',
127
+ 'fn.call(lifecycle, …) — dropping the receiver would return undefined here',
128
+ );
129
+ });
130
+
131
+ await test('a GETTER member is read off the object, so it actually fires', () => {
132
+ seams._reset();
133
+ let reads = 0;
134
+ seams.registerProvider('lifecycle', {
135
+ get VOTABLE_TASK_STATUSES() { reads += 1; return new Set(['shipped']); },
136
+ });
137
+ assert.deepEqual(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), new Set(['shipped']));
138
+ assert.equal(reads, 1, 'the real port defines this member as a getter');
139
+ });
140
+
141
+ // ---- 5. binding is late ----------------------------------------------------
142
+
143
+ await test('a provider registered AFTER require() is still picked up', async () => {
144
+ seams._reset();
145
+ const suggest = lifecycleMethod('suggestGoalForCapture', () => null);
146
+ assert.equal(await suggest({}), null, 'nothing registered yet — the fallback');
147
+ seams.registerProvider('lifecycle', { suggestGoalForCapture: async () => ({ goal_id: '42' }) });
148
+ assert.deepEqual(await suggest({}), { goal_id: '42' }, 'the late registration is seen without a re-require');
149
+ });
150
+
151
+ seams._reset();
152
+ console.log(`\n ${passed} passed, ${failed} failed`);
153
+ if (failed > 0) process.exit(1);
@@ -127,3 +127,63 @@ test('the government page no longer declares a board-room tab to redirect INTO',
127
127
  const ids = [...GOV_SRC.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
128
128
  assert.ok(!ids.includes('board-room'), `board-room is a page, not a tab (found ${ids})`);
129
129
  });
130
+
131
+ // ── the redirect is the ONLY thing that knows the old shape (task 1003736) ────
132
+ //
133
+ // The carve moved the room; task 1003736 moved the WRITERS. Its done-when clause
134
+ // is a statement about the whole tree — "nothing outside the government.js
135
+ // compatibility redirect emits the hash form" — and a claim of that shape rots
136
+ // the moment someone adds a fourth writer by copying an older one. Grepping once
137
+ // at review time proves it for that afternoon only, so it is pinned here instead.
138
+ //
139
+ // TWO TRAPS THIS ENCODES, both of which make the naive sweep wrong:
140
+ // 1. `#board-room` is ALSO the name of a Discord CHANNEL (channels.json, BV1
141
+ // R20). modules/government/CLAUDE.md and the BV1 spec name that channel, not
142
+ // a URL — repointing them would be a straightforward mistake, so the sweep
143
+ // hunts the URL form specifically and the quoted literal separately.
144
+ // 2. ADRs and session logs RECORD the old form on purpose; they are history and
145
+ // may not be rewritten to match today's code.
146
+ const SWEEP_DIRS = ['modules', 'src', 'scripts', 'docs/recipes', 'docs/packs', '.claude/skills'];
147
+ const SWEEP_EXTS = new Set(['.js', '.mjs', '.cjs', '.html', '.css', '.json', '.md']);
148
+
149
+ function sweepFiles() {
150
+ const out = [];
151
+ const walk = (rel) => {
152
+ const abs = path.join(ROOT, rel);
153
+ if (!fs.existsSync(abs)) return;
154
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
155
+ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
156
+ const childRel = `${rel}/${e.name}`;
157
+ if (e.isDirectory()) walk(childRel);
158
+ else if (SWEEP_EXTS.has(path.extname(e.name))) out.push(childRel);
159
+ }
160
+ };
161
+ for (const d of SWEEP_DIRS) walk(d);
162
+ return out;
163
+ }
164
+
165
+ test('no writer anywhere in the tree still emits the retired hash URL', () => {
166
+ // The emitted form, not the bare fragment: `/government…#board-room`. This is
167
+ // what a link WRITER produces, and it is the thing task 1003736 repointed in
168
+ // builder-needs.js (hall banner + CLI nudge) and board-broadcast.js (Discord).
169
+ const offenders = sweepFiles().filter((rel) =>
170
+ /\/government[^\s'"`)\]]*#board-room/.test(fs.readFileSync(path.join(ROOT, rel), 'utf8')));
171
+ assert.deepEqual(offenders, [],
172
+ `these still emit the retired deep link — point them at /board-room?item=<id>:\n ${offenders.join('\n ')}`);
173
+ });
174
+
175
+ test('the hash literal survives only in the redirect that consumes it', () => {
176
+ // The QUOTED literal is the runtime value — the thing a branch compares
177
+ // against. Comments discussing the retired tab are documentation and are left
178
+ // alone (they use backticks, not quotes), which is why this looks for quotes.
179
+ const allowed = 'modules/hall-ui/public/government.js';
180
+ const offenders = sweepFiles().filter((rel) => rel !== allowed
181
+ && /['"]#board-room['"]/.test(fs.readFileSync(path.join(ROOT, rel), 'utf8')));
182
+ assert.deepEqual(offenders, [],
183
+ `only ${allowed}'s redirect may still know the old shape, found in:\n ${offenders.join('\n ')}`);
184
+
185
+ // And the allowlisted file must actually still contain it — an allowlist that
186
+ // guards a string nobody has any more is a rule quietly protecting nothing.
187
+ assert.match(GOV_SRC, /['"]#board-room['"]/,
188
+ 'the compatibility redirect itself is gone — old Discord links now dead-end');
189
+ });
@@ -199,14 +199,18 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
199
199
  const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
200
200
  assert.equal(n.id, 'board_votes');
201
201
  assert.equal(n.state, 'action_needed');
202
- assert.equal(n.action.href, '/government?item=1#board-room');
203
- // The hash is the TAB (makeTabs owns it); the item is a query param.
204
- assert.match(n.action.href, /#board-room$/);
202
+ assert.equal(n.action.href, '/board-room?item=1');
203
+ // The item is a query param, because `/board-room` reads `?item=` and a
204
+ // FRAGMENT would never reach the server (ADR 0266). The old
205
+ // `/government…#board-room` form survives only as government.js's
206
+ // compatibility redirect — no writer may emit it.
207
+ assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
205
208
  });
206
209
 
207
210
  test('a count with no item id still links the room', () => {
208
211
  const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
209
- assert.equal(n.action.href, '/government#board-room');
212
+ assert.equal(n.action.href, '/board-room');
213
+ assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
210
214
  });
211
215
 
212
216
  test('the need reaches computeNeeds as an action_needed item', () => {
@@ -16,10 +16,15 @@ const {
16
16
  pickNext,
17
17
  summarize,
18
18
  MIN_SPEC_CHARS,
19
+ fetchGoalTasks,
20
+ fetchGoalTaskRows,
21
+ declaredSurfaceExcludesMigrations,
22
+ GOAL_PAGE,
19
23
  } = require('../scripts/gds/sequence.js');
20
24
 
21
25
  let passed = 0, failed = 0;
22
26
  const t = (n, f) => { try { f(); passed++; console.log(` PASS ${n}`); } catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); } };
27
+ const ta = async (n, f) => { try { await f(); passed++; console.log(` PASS ${n}`); } catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); } };
23
28
 
24
29
  // A description long enough to clear the thin-spec gate, so fixtures testing
25
30
  // OTHER halt reasons don't trip it incidentally.
@@ -327,5 +332,157 @@ t('pickNext on an empty plan returns no pick and nothing skipped', () => {
327
332
  assert.equal(r.head, null);
328
333
  });
329
334
 
335
+ // ------------------------------------------------- goal paging (task 1003699)
336
+
337
+ // A fake board big enough to have the cliff the bug fell off. The UNFILTERED
338
+ // listing is capped at UNFILTERED_CAP rows exactly as the real route caps
339
+ // ?limit at 1000, so a row past it is genuinely unreachable that way — which is
340
+ // what makes the acceptance test meaningful rather than a small-instance smoke.
341
+ const UNFILTERED_CAP = 1000;
342
+
343
+ function fakeBoard({ goalId = 42, rowsInGoal = 3, targetIndex = 1100, total = 1200, failPages = [] } = {}) {
344
+ const table = [];
345
+ for (let i = 0; i < total; i += 1) {
346
+ table.push({ id: 10000 + i, title: `filler ${i}`, status: 'ready', goal_id: 999, touches: [] });
347
+ }
348
+ // The task under test sits PAST the unfiltered cap, and is in the goal.
349
+ table[targetIndex] = { id: 777, title: 'the task past the window', status: 'ready', goal_id: goalId, touches: [] };
350
+ for (let k = 1; k < rowsInGoal; k += 1) {
351
+ table[targetIndex - k] = { id: 800 + k, title: `sibling ${k}`, status: 'ready', goal_id: goalId, touches: [] };
352
+ }
353
+
354
+ const calls = [];
355
+ const api = {
356
+ tasks: {
357
+ async getTasks({ query } = {}) {
358
+ const q = query || {};
359
+ calls.push({ ...q });
360
+ if (q.offset === 0) return { ok: false, status: 400 }; // the real route's rule
361
+ const page = calls.length - 1;
362
+ if (failPages.includes(page)) return { ok: false, status: 500 };
363
+ const rows = q.goal_id === undefined
364
+ ? table.slice(0, UNFILTERED_CAP)
365
+ : table.filter((r) => Number(r.goal_id) === Number(q.goal_id));
366
+ const off = Number(q.offset) || 0;
367
+ const lim = Number(q.limit) || rows.length;
368
+ return { ok: true, data: { tasks: rows.slice(off, off + lim) } };
369
+ },
370
+ async getTasksId({ id }) {
371
+ const row = table.find((r) => Number(r.id) === Number(id));
372
+ return row ? { ok: true, data: { task: { ...row, dependencies: [], dependents: [] } } } : { ok: false };
373
+ },
374
+ },
375
+ };
376
+ return { api, calls, table };
377
+ }
378
+
379
+ await ta('the unfiltered listing genuinely cannot see the task — the premise of the bug', async () => {
380
+ const { api } = fakeBoard();
381
+ const r = await api.tasks.getTasks({ query: { limit: 1000 } });
382
+ const ids = (r.data.tasks || []).map((x) => Number(x.id));
383
+ assert.equal(ids.length, UNFILTERED_CAP);
384
+ assert.ok(!ids.includes(777), 'task 777 must sit outside the unfiltered window, or this suite proves nothing');
385
+ });
386
+
387
+ await ta('--goal finds a task that is NOT in the first page of the unfiltered listing', async () => {
388
+ const { api } = fakeBoard();
389
+ const byId = await fetchGoalTasks(api, 42, 50);
390
+ assert.ok(byId.has(777), 'the goal-scoped read must return the task past the 1000-row window');
391
+ assert.equal(byId.size, 3, 'and its two siblings, and nothing from another goal');
392
+ });
393
+
394
+ await ta('a goal larger than one page is paged until exhausted, and offset=0 is never sent', async () => {
395
+ const { api, calls } = fakeBoard({ rowsInGoal: GOAL_PAGE + 7, targetIndex: 1100, total: 2000 });
396
+ const rows = await fetchGoalTaskRows(api, 42);
397
+ assert.equal(rows.length, GOAL_PAGE + 7, 'every row in the goal, across pages');
398
+ assert.ok(calls.length >= 2, 'more than one page was requested');
399
+ assert.equal(calls[0].offset, undefined, 'page one sends no offset (the route rejects offset=0)');
400
+ assert.equal(calls[1].offset, GOAL_PAGE, 'page two continues from the page size');
401
+ assert.ok(calls.every((c) => c.goal_id === 42), 'every page is scoped server-side');
402
+ });
403
+
404
+ await ta('an unreadable FIRST page throws — it must never read as an empty chain', async () => {
405
+ const { api } = fakeBoard({ failPages: [0] });
406
+ await assert.rejects(() => fetchGoalTaskRows(api, 42), (e) => {
407
+ assert.match(e.message, /refusing to report a partial goal/);
408
+ assert.match(e.message, /goal 42 at offset 0/, 'the message names which page failed');
409
+ return true;
410
+ });
411
+ });
412
+
413
+ await ta('a LATER page failing also throws — a partial goal must not read as a whole one', async () => {
414
+ const { api } = fakeBoard({ rowsInGoal: GOAL_PAGE + 7, targetIndex: 1100, total: 2000, failPages: [1] });
415
+ await assert.rejects(() => fetchGoalTaskRows(api, 42), /refusing to report a partial goal/);
416
+ });
417
+
418
+ await ta('the goal_id match is kept as a guard against a core that ignores the param', async () => {
419
+ const { api } = fakeBoard();
420
+ // Simulate a pinned older core: the goal_id filter is silently ignored.
421
+ const inner = api.tasks.getTasks;
422
+ api.tasks.getTasks = ({ query } = {}) => inner({ query: { ...query, goal_id: undefined } });
423
+ const byId = await fetchGoalTasks(api, 42, 50);
424
+ assert.equal(byId.size, 0, 'a whole-table answer must not become someone else\'s plan');
425
+ });
426
+
427
+ // -------------------------------------------- needs_migration (task 1003699)
428
+
429
+ await ta('needs_migration no longer halts on the word alone (task 1003697\'s shape)', async () => {
430
+ // The real 1003697: the flag set from prose, four touched files, none of them a
431
+ // migration — and a description that DOES contain a literal migrations/ path,
432
+ // which is why keying on the prose would not have fixed anything.
433
+ const t1003697 = task({
434
+ id: 1003697,
435
+ needs_migration: true,
436
+ touches: ['config/rename-history-baseline.json', 'scripts/gds/gds-literal-scan.js',
437
+ 'scripts/gds/rename-history-check.js', 'tests/rename_history_restraint.mjs'],
438
+ description: `${SPEC} see migrations/163_goal_dep_trigger.sql — DO NOT change any migration`,
439
+ });
440
+ assert.ok(declaredSurfaceExcludesMigrations(t1003697));
441
+ const reasons = haltReasons(t1003697, { rank: 'metic', claimableIds: new Set([1003697]) });
442
+ assert.ok(!reasons.some((r) => r.code === 'needs_migration'), reasons.map((r) => r.code).join(','));
443
+ });
444
+
445
+ await ta('a task that genuinely touches migrations/ still halts', async () => {
446
+ const real = task({ id: 5, needs_migration: true, touches: ['migrations/214_add_column.sql', 'src/x.js'] });
447
+ assert.equal(declaredSurfaceExcludesMigrations(real), false);
448
+ const reasons = haltReasons(real, { rank: 'metic', claimableIds: new Set([5]) });
449
+ assert.ok(reasons.some((r) => r.code === 'needs_migration'));
450
+ });
451
+
452
+ await ta('a flagged task with NO declared surface keeps halting — silence is not evidence', async () => {
453
+ for (const touches of [[], undefined]) {
454
+ const unknown = task({ id: 6, needs_migration: true, touches });
455
+ assert.equal(declaredSurfaceExcludesMigrations(unknown), false);
456
+ const reasons = haltReasons(unknown, { rank: 'metic', claimableIds: new Set([6]) });
457
+ assert.ok(reasons.some((r) => r.code === 'needs_migration'), `touches=${JSON.stringify(touches)}`);
458
+ }
459
+ });
460
+
461
+ await ta('a MODULE-OWNED migration counts — modules/<key>/migrations/ is real schema work here', async () => {
462
+ // ADR 0083 / docs/modules-contract.md: ten modules ship their own migrations.
463
+ // An anchored ^migrations/ test called these "not a migration" and suppressed
464
+ // the halt — and nothing else would have caught it, because the protected-path
465
+ // registry's own migrations/ glob is root-anchored too.
466
+ for (const p of [
467
+ 'modules/economy/migrations/012_add_col.sql',
468
+ 'modules/lifecycle/migrations/163_goal_dep_trigger.sql',
469
+ 'src/db/migrations/x.sql',
470
+ ]) {
471
+ const real = task({ id: 7, needs_migration: true, touches: [p, 'src/x.js'] });
472
+ assert.equal(declaredSurfaceExcludesMigrations(real), false, p);
473
+ const reasons = haltReasons(real, { rank: 'metic', claimableIds: new Set([7]) });
474
+ assert.ok(reasons.some((r) => r.code === 'needs_migration'), `${p} must still halt`);
475
+ }
476
+ });
477
+
478
+ await ta('the migrations match reads a path SEGMENT, so a lookalike filename does not count', async () => {
479
+ assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['./migrations/9.sql'] })), false);
480
+ assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['Migrations/9.sql'] })), false);
481
+ assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['modules\\economy\\migrations\\012.sql'] })), false);
482
+ // The word in a FILE name, with no migrations directory, is not schema work.
483
+ assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['docs/migrations-guide.md'] })), true);
484
+ assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['docs/recipes/migrations.md'] })), true);
485
+ });
486
+
330
487
  console.log(`\nsequence: ${passed} passed, ${failed} failed`);
331
488
  process.exit(failed ? 1 : 0);