@bongos/core 1.19.660 → 1.19.662

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
@@ -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.660'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.662'; // 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');
@@ -202,7 +202,18 @@ await test('a server-side filter change RE-FETCHES; every other filter stays ins
202
202
  // direct question anyway, and means a control absent from that query can never
203
203
  // cost a round-trip.
204
204
  assert.match(WORK_SRC, /let lastServerFilterQuery = '';/);
205
- assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{[\s\S]{0,200}refreshLive\(\);\s*\n\s*loadBacklog\(\);/);
205
+ // task 1003828 split this into ONE DECISION PER FEED. It used to be a single
206
+ // comparison that re-fetched both and returned early — which was fine while the
207
+ // two asked the server the same question, and wrong once the backlog started
208
+ // asking a narrower one (version/kind/discipline/goal_id, which GET /tasks
209
+ // answers and GET /tasks/claimable does not). The early return also meant a
210
+ // change to a CLIENT-side lens skipped the other tab's re-render until the next
211
+ // poll repainted it. Each side now re-fetches or re-renders on its own; the
212
+ // property under test is unchanged — a server-facing change costs a round-trip
213
+ // and nothing else does.
214
+ assert.match(WORK_SRC, /let lastBacklogQuery = '';/);
215
+ assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{ lastServerFilterQuery = q; refreshLive\(\); \} else \{ renderClaimable\(\); \}/);
216
+ assert.match(WORK_SRC, /if \(bq !== lastBacklogQuery\) \{ lastBacklogQuery = bq; loadBacklog\(\); \} else \{ renderBacklog\(\); \}/);
206
217
  const kit = read('modules/hall-ui/public/hall-kit.js');
207
218
  assert.match(kit, /if \(onChange\) onChange\(values\(\)\);/,
208
219
  'the contract this decision depends on — if it ever hands the changed id instead, revisit');
@@ -40,6 +40,7 @@ const {
40
40
  PERMISSIONS, RANK_ORDER, RANK_SEED, RESOURCE_FAMILIES, permissionKeys, byKey,
41
41
  scopeOf, resourceOf, isOwnershipScoped, isCrossOwner, ownershipScopedKeys, crossOwnerKeys,
42
42
  crossOwnerKeysFor, isPrincipalKey, seedForRank,
43
+ OWNERSHIP_VERBS, VERB_DOMINANCE, verbDominates, verbOf,
43
44
  } = catalog;
44
45
  const {
45
46
  sameOwner, authorizeOwned, authorizeOwnedOrAny, builderHasPermissions, effectivePermissions,
@@ -106,12 +107,24 @@ test('A3: the modelled families are the ones the ownership sweep names', () => {
106
107
  // The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`.
107
108
  assert.deepEqual([...crossOwnerKeys()].sort(),
108
109
  ['box.fleet.manage', 'claim.release.any', 'memory.read.any']);
109
- assert.deepEqual(crossOwnerKeysFor('task.act.own'), ['claim.release.any']);
110
+ // The escalation path is DECLARED per key, never derived from the resource
111
+ // family (task 1003195). box is the one REAL pair in the catalog today:
112
+ // `box.fleet.manage` is the same `manage` verb over every owner's box, so it
113
+ // genuinely subsumes managing your own.
110
114
  assert.deepEqual(crossOwnerKeysFor('box.manage.own'), ['box.fleet.manage']);
115
+ // claim and memory each HAVE an `any` key in their family and still declare no
116
+ // counterpart — the distinction the family-derived version could not make.
117
+ // `claim.release.any` carries one third of `act` (release, not cost or notes);
118
+ // `memory.read.any` is a read, and reading every builder's memory is not the
119
+ // authority to manage one. A family-derived answer returned both.
120
+ assert.deepEqual(crossOwnerKeysFor('task.act.own'), []);
121
+ assert.deepEqual(crossOwnerKeysFor('memory.manage.own'), []);
111
122
  // Some resources have NO cross-owner form by design — nobody manages another
112
123
  // builder's credential, Archon included.
113
124
  assert.deepEqual(crossOwnerKeysFor('credential.manage.own'), []);
114
125
  assert.deepEqual(crossOwnerKeysFor('nope.not.a.key'), []);
126
+ // A cross-owner key is not itself own-scoped, so it has no path of its own.
127
+ assert.deepEqual(crossOwnerKeysFor('claim.release.any'), []);
115
128
  });
116
129
 
117
130
  test('A4: RESOURCE_FAMILIES is frozen and agrees with each row it lists', () => {
@@ -246,20 +259,112 @@ test('C5: builderHasPermissions REFUSES an ownership-scoped key rather than answ
246
259
 
247
260
  // ═══ D — `.any` DOES reach another builder's resource ═════════════════════════
248
261
 
249
- test('D1: the cross-owner key reaches a row the caller does not own', async () => {
250
- await holding(['task.act.own', 'claim.release.any'], async () => {
251
- assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), true,
252
- 'claim.release.any is exactly the authority to act on someone else\'s claim');
253
- assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: ME }), true);
262
+ test('D1: a DECLARED cross-owner counterpart reaches a row the caller does not own', async () => {
263
+ // box is the catalog's one real pair, and it must keep working: `box.fleet.manage`
264
+ // is the whole point of a cross-owner key. Asserted BOTH with and without the own
265
+ // key, because the counterpart is a standalone admit it does not merely widen a
266
+ // grant the builder already has.
267
+ await holding(['box.manage.own', 'box.fleet.manage'], async () => {
268
+ assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true,
269
+ 'box.fleet.manage is exactly the authority to manage someone else\'s box');
270
+ assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: ME }), true);
254
271
  });
272
+ await holding(['box.fleet.manage'], async () => {
273
+ assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true);
274
+ });
275
+ });
276
+
277
+ // ═══ D1b — a cross-owner key with a WEAKER VERB is not a counterpart ═══════════
278
+ //
279
+ // task 1003195, from the task-1002671 adversarial verification (finding 12). The
280
+ // admit branch used to hand back the resource family's whole `any` list VERB-BLIND,
281
+ // so any cross-owner key over the resource satisfied EVERY own key over it. The
282
+ // escalation is now a per-key declaration checked for verb dominance at load.
283
+ //
284
+ // ORACLE DISCIPLINE, per this file's header: these two cases are driven by holding
285
+ // the weak `any` key ALONE. The old D1 always held the own key TOO, so the admit it
286
+ // recorded could have come from either branch — which is exactly why the defect sat
287
+ // unpinned. Holding only the weak key leaves the counterpart branch as the sole
288
+ // possible source of a `true`.
289
+ test('D1b: a weaker-verb cross-owner key does not satisfy an own key', async () => {
290
+ // A READ grant over every builder's memory is not the authority to MANAGE one.
291
+ await holding(['memory.read.any'], async () => {
292
+ assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false,
293
+ 'memory.read.any reads every memory; it does not manage another builder\'s');
294
+ });
295
+ // `act` is a BUNDLE (release + cost + notes); `claim.release.any` carries the
296
+ // release third only, so it must not admit the other two against someone else.
297
+ await holding(['claim.release.any'], async () => {
298
+ assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false,
299
+ 'claim.release.any releases a claim; it does not post costs or notes on one');
300
+ });
301
+ // Holding the own key as well changes nothing about the OTHER owner's row — the
302
+ // shape the old D1 asserted as an admit.
255
303
  await holding(['memory.manage.own', 'memory.read.any'], async () => {
256
- assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), true);
304
+ assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false);
305
+ assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: ME }), true,
306
+ 'the own branch is untouched — your own row is still yours');
257
307
  });
258
- await holding(['box.manage.own', 'box.fleet.manage'], async () => {
259
- assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true);
308
+ await holding(['task.act.own', 'claim.release.any'], async () => {
309
+ assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false);
310
+ assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: ME }), true);
311
+ });
312
+ // And the strongest form: EVERY permission in the catalog still does not reach
313
+ // another owner's memory or claim, because nothing in it dominates those verbs.
314
+ await holding(EVERYTHING, async () => {
315
+ assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false,
316
+ 'no key in the catalog dominates memory.manage.own');
317
+ assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false,
318
+ 'no key in the catalog dominates task.act.own');
260
319
  });
261
320
  });
262
321
 
322
+ // The declaration itself, held against the verb rule that admits it. This is the
323
+ // catalog-level twin of D1b: it fails if a future edit declares a counterpart whose
324
+ // verb does not dominate, even before any resolver call exercises it.
325
+ test('D1c: every declared counterpart is cross-owner, same-resource, and verb-dominant', () => {
326
+ let declared = 0;
327
+ for (const p of PERMISSIONS) {
328
+ if (p.scope !== 'own') continue;
329
+ assert.ok(Array.isArray(p.counterpart),
330
+ `${p.key} is own-scoped and must declare counterpart explicitly`);
331
+ assert.ok(OWNERSHIP_VERBS.includes(p.verb), `${p.key} must declare a known verb`);
332
+ assert.equal(verbOf(p.key), p.verb, 'verbOf must agree with the row it reads');
333
+ for (const key of p.counterpart) {
334
+ declared++;
335
+ const c = byKey(key);
336
+ assert.ok(c, `${p.key} names counterpart ${key}, which must exist`);
337
+ assert.equal(c.scope, 'any', `${p.key}'s counterpart ${key} must be cross-owner`);
338
+ assert.equal(c.resource, p.resource, `${p.key}'s counterpart ${key} must share its resource`);
339
+ assert.ok(verbDominates(c.verb, p.verb),
340
+ `${p.key}'s counterpart ${key} (verb ${c.verb}) must dominate verb ${p.verb}`);
341
+ }
342
+ }
343
+ assert.ok(declared >= 1, 'expected at least the box pair to be declared');
344
+ // A verb never dominates a wider one, and an unknown verb fails closed — the
345
+ // relation is asserted directly so a future edit to the table is caught here.
346
+ assert.equal(verbDominates('manage', 'read'), true);
347
+ assert.equal(verbDominates('manage', 'act'), true);
348
+ assert.equal(verbDominates('read', 'read'), true);
349
+ assert.equal(verbDominates('read', 'manage'), false);
350
+ assert.equal(verbDominates('release', 'act'), false);
351
+ assert.equal(verbDominates('act', 'manage'), false);
352
+ assert.equal(verbDominates('manage', 'nonsense'), false);
353
+ assert.equal(verbDominates(null, 'manage'), false);
354
+ // The table is the whole relation: only `manage` subsumes anything, and it is the
355
+ // one verb nothing else dominates. Pinned so a widening needs a deliberate edit.
356
+ assert.deepEqual(Object.keys(VERB_DOMINANCE), ['manage']);
357
+ for (const v of OWNERSHIP_VERBS) {
358
+ assert.equal(verbDominates('manage', v), true, `manage must dominate ${v}`);
359
+ if (v !== 'manage') {
360
+ assert.equal(verbDominates(v, 'manage'), false, `${v} must not dominate manage`);
361
+ }
362
+ }
363
+ // An unscoped key has no verb at all — the axis only means something with a scope.
364
+ assert.equal(verbOf('cost.log'), null);
365
+ assert.equal(verbOf('nope.not.a.key'), null);
366
+ });
367
+
263
368
  test('D2: holding only the OWN key never reaches someone else, in either entry point', async () => {
264
369
  for (const [, fam] of PAIRED) {
265
370
  for (const ownKey of fam.own) {
@@ -354,7 +459,7 @@ test('F3: a cross-owner key cheaper than its own-scoped counterpart throws at mo
354
459
  assert.throws(
355
460
  () => loadMutatedCatalog('inverted floors', (s) => s.replace(
356
461
  ` { key: 'goal.manage.own',`,
357
- ` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
462
+ ` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', verb: 'manage', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
358
463
  /must never be cheaper than own-only/,
359
464
  );
360
465
  });
@@ -363,11 +468,87 @@ test('F4: a cross-owner key with no ownership-scoped counterpart throws at modul
363
468
  assert.throws(
364
469
  () => loadMutatedCatalog('lone any', (s) => s.replace(
365
470
  ` { key: 'goal.manage.own',`,
366
- ` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
471
+ ` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', verb: 'manage', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
367
472
  /with no ownership-scoped counterpart/,
368
473
  );
369
474
  });
370
475
 
476
+ // F5–F8 are task 1003195's half: the counterpart declaration and its verb rule.
477
+ // Same harness, so what is proven is the SHIPPED assertion block. Each fixture
478
+ // reintroduces the exact defect the task was filed for, or a near neighbour of it.
479
+
480
+ test('F5: a counterpart whose verb does not dominate throws at module load', () => {
481
+ // The literal finding-12 defect: memory.read.any declared as the escalation path
482
+ // for memory.manage.own — a read grant admitting a write on another owner's row.
483
+ assert.throws(
484
+ () => loadMutatedCatalog('weak verb counterpart', (s) => s.replace(
485
+ `resource: 'memory', verb: 'manage', counterpart: []`,
486
+ `resource: 'memory', verb: 'manage', counterpart: ['memory.read.any']`)),
487
+ /does not dominate/,
488
+ );
489
+ // And its sibling: release is one third of act.
490
+ assert.throws(
491
+ () => loadMutatedCatalog('partial verb counterpart', (s) => s.replace(
492
+ `resource: 'claim', verb: 'act', counterpart: []`,
493
+ `resource: 'claim', verb: 'act', counterpart: ['claim.release.any']`)),
494
+ /does not dominate/,
495
+ );
496
+ });
497
+
498
+ test('F6: an own-scoped key that declares no counterpart at all throws at module load', () => {
499
+ // The fail-loud that replaces the silent family derivation: a new own key cannot
500
+ // inherit an escalation path merely by belonging to a resource.
501
+ assert.throws(
502
+ () => loadMutatedCatalog('missing counterpart', (s) => s.replace(
503
+ `resource: 'credential', verb: 'manage', counterpart: []`,
504
+ `resource: 'credential', verb: 'manage'`)),
505
+ /must declare counterpart/,
506
+ );
507
+ });
508
+
509
+ test('F7: a counterpart that is dangling, cross-resource, or not cross-owner throws', () => {
510
+ assert.throws(
511
+ () => loadMutatedCatalog('dangling counterpart', (s) => s.replace(
512
+ `resource: 'credential', verb: 'manage', counterpart: []`,
513
+ `resource: 'credential', verb: 'manage', counterpart: ['credential.manage.any']`)),
514
+ /not a permission in this catalog/,
515
+ );
516
+ assert.throws(
517
+ () => loadMutatedCatalog('cross-resource counterpart', (s) => s.replace(
518
+ `resource: 'credential', verb: 'manage', counterpart: []`,
519
+ `resource: 'credential', verb: 'manage', counterpart: ['box.fleet.manage']`)),
520
+ /never crosses resource families/,
521
+ );
522
+ assert.throws(
523
+ () => loadMutatedCatalog('own key as counterpart', (s) => s.replace(
524
+ `resource: 'box', verb: 'manage', counterpart: ['box.fleet.manage']`,
525
+ `resource: 'box', verb: 'manage', counterpart: ['memory.manage.own']`)),
526
+ /is not cross-owner/,
527
+ );
528
+ });
529
+
530
+ test('F8: the verb field itself is partitioned with scope and checked against the vocabulary', () => {
531
+ assert.throws(
532
+ () => loadMutatedCatalog('scope without verb', (s) => s.replace(
533
+ `resource: 'builder_prefs', verb: 'set', counterpart: []`,
534
+ `resource: 'builder_prefs', counterpart: []`)),
535
+ /scope and verb must come together/,
536
+ );
537
+ assert.throws(
538
+ () => loadMutatedCatalog('unknown verb', (s) => s.replace(
539
+ `resource: 'builder_prefs', verb: 'set', counterpart: []`,
540
+ `resource: 'builder_prefs', verb: 'obliterate', counterpart: []`)),
541
+ /unknown verb/,
542
+ );
543
+ // A counterpart on a key that has no owner axis is a mis-declaration, not a no-op.
544
+ assert.throws(
545
+ () => loadMutatedCatalog('counterpart on an any key', (s) => s.replace(
546
+ `resource: 'memory', verb: 'read', guards:`,
547
+ `resource: 'memory', verb: 'read', counterpart: [], guards:`)),
548
+ /is not own-scoped/,
549
+ );
550
+ });
551
+
371
552
  // ═══ G — the R94/R95/R98 invariants this slice must not have disturbed ════════
372
553
 
373
554
  test('G1: the seed is still floor-derived and cumulative — the ownership axis adds no grants', () => {
@@ -471,3 +471,106 @@ test('the tab strip is left alone — it scrolls in its own row and never moved
471
471
  assert.ok(strip, 'the tab strip rule is present');
472
472
  assert.match(strip[1], /overflow-x:\s*auto/, 'it scrolls itself, which is why it is not the overflow cause');
473
473
  });
474
+
475
+ // ---- the board's filters drive BOTH queue tabs (task 1003828) ---------------
476
+ //
477
+ // The owner reported an auto-filed artist review as "going into uncategorized
478
+ // BONGOS-V2 backlog tasks" and asked for tasks to be "filterable by role type".
479
+ // The review was never uncategorised — cascade.js inherits its parent's goal —
480
+ // but the Backlog tab it lands on could not say so and could not be narrowed:
481
+ //
482
+ // * renderBacklog() applied the SEARCH BOX ONLY. Version, Goal, Kind,
483
+ // Discipline, Newcomer and Run mode were read inside renderClaimable and
484
+ // nowhere else, so six controls were silently inert over the tab holding the
485
+ // most rows. Nothing errored; the list simply did not change.
486
+ // * The filter bar was INSIDE #claim-scroll, which makeTabs hides. So on the
487
+ // Backlog tab those controls were not merely inert — they were off-screen.
488
+ // * A backlog row rendered id + title + version + rank + kind. No goal, no
489
+ // discipline. A correctly-goaled task genuinely read as uncategorised.
490
+ //
491
+ // Each is invisible in a browser the way this file's other entries are: the page
492
+ // renders, it just renders a control that does nothing.
493
+
494
+ const workJs = read('work.js');
495
+
496
+ test('the filter bar is above the panels, not inside the Ready one that gets hidden', () => {
497
+ const filters = workHtml.indexOf('id="work-filters"');
498
+ const tabs = workHtml.indexOf('id="work-tabs"');
499
+ const claim = workHtml.indexOf('id="claim-scroll"');
500
+ assert.ok(filters > 0 && tabs > 0 && claim > 0, 'all three mounts present');
501
+ assert.ok(filters > tabs, 'the bar follows the tab strip it belongs to');
502
+ assert.ok(filters < claim,
503
+ 'and precedes the Ready panel — inside it, makeTabs hides the filters with the tab');
504
+ });
505
+
506
+ test('the bar shows on the two QUEUE tabs and hides on the two that it cannot narrow', () => {
507
+ assert.match(workJs, /const FILTERED_TABS = new Set\(\['ready', 'backlog'\]\)/,
508
+ 'Ready and Backlog are task feeds; In Progress and Completed render other objects');
509
+ assert.match(workJs, /function syncFilterBarVisibility/);
510
+ // Wired BOTH ways: on a tab change, and once at boot — makeTabs restores a
511
+ // deep-linked tab (/work#completed) before the bar is built, and fires
512
+ // onChange only on a CHANGE, so opening state has to be set explicitly.
513
+ assert.match(workJs, /onChange: \(id\) => \{[\s\S]{0,160}syncFilterBarVisibility\(id\)/,
514
+ 'a tab change re-syncs the bar');
515
+ assert.match(workJs, /syncFilterBarVisibility\(workTabs && workTabs\.active \? workTabs\.active\(\) : 'ready'\)/,
516
+ 'and the opening tab decides the opening state');
517
+ });
518
+
519
+ test('both queue tabs narrow through ONE predicate, so a filter cannot reach only one', () => {
520
+ assert.match(workJs, /function matchesBoardFilters\(t, f\)/,
521
+ 'the predicate is shared, not copied per tab');
522
+ // Both renderers must consult it. This is the whole defect: before, only the
523
+ // claimable one did.
524
+ const claimable = workJs.slice(workJs.indexOf('function renderClaimable'), workJs.indexOf('async function populateCreatorFilter'));
525
+ const backlog = workJs.slice(workJs.indexOf('function renderBacklog'), workJs.indexOf('async function refreshLive'));
526
+ for (const [name, body] of [['renderClaimable', claimable], ['renderBacklog', backlog]]) {
527
+ assert.ok(body.length, `${name} body located`);
528
+ assert.match(body, /matchesBoardFilters\(t, filters\)/, `${name} narrows through the shared predicate`);
529
+ assert.match(body, /applyRunMode\(list, \$\('#f-fit'\)/, `${name} applies the set-relative run-mode lens too`);
530
+ }
531
+ });
532
+
533
+ test('a backlog row names its craft and its goal', () => {
534
+ const backlog = workJs.slice(workJs.indexOf('function renderBacklog'));
535
+ // The same vocabulary the Ready card has carried since 1755 — a second spelling
536
+ // would let the two tabs describe one task differently.
537
+ assert.match(backlog, /const discipline = t\.discipline \|\| 'unclassified'/);
538
+ assert.match(backlog, /disciplineTag/, 'the row renders the discipline tag');
539
+ assert.match(backlog, /taskState\.goalTitles \|\| \{\}/, 'the goal is named by TITLE, not by bare id');
540
+ assert.match(backlog, /'No goal'/, 'and a goal-less row says so rather than rendering a gap');
541
+ assert.ok(rule(workCss, '.history__goal'), '.history__goal has a rule — an unstyled sub-line inherits the title size');
542
+ });
543
+
544
+ test('the backlog asks the SERVER the narrow question, because its read is capped', () => {
545
+ // A client-side-only narrow of a capped read LIES: filtering the 500 fetched
546
+ // rows to the artist ones hides the artist tasks that sat at row 501.
547
+ assert.match(workJs, /function backlogFilterQuery/);
548
+ const q = workJs.slice(workJs.indexOf('function backlogFilterQuery'), workJs.indexOf('BACKLOG_LIMIT ='));
549
+ for (const param of ['version=', 'kind=', 'discipline=', 'goal_id=']) {
550
+ assert.ok(q.includes(param), `the backlog read sends ${param} — GET /tasks answers it`);
551
+ }
552
+ // '__none__' (ungoaled) has no server predicate, so it must not be sent.
553
+ assert.match(q, /f\.goal && f\.goal !== '__none__'/,
554
+ "the client-only 'No goal' lens is never sent to the route");
555
+ // It reads through boardFilterValues rather than re-querying the DOM, so the
556
+ // query and the client predicate can never disagree about a control's value.
557
+ assert.match(q, /const f = boardFilterValues\(\);/);
558
+ // And it stays SEPARATE from the claimable query: GET /tasks/claimable answers
559
+ // only version + discipline, so kind/goal_id there would be a silent no-op.
560
+ const claimQ = workJs.slice(workJs.indexOf('function serverFilterQuery'), workJs.indexOf('task 1003828 — the BACKLOG'));
561
+ assert.ok(!claimQ.includes('goal_id='), 'the claimable feed is not sent goal_id');
562
+ assert.ok(!claimQ.includes('kind='), 'nor kind');
563
+ });
564
+
565
+ test('each feed decides its own re-fetch, so a server change cannot strand the other tab', () => {
566
+ // The old shape compared ONE query and returned early, which left the other
567
+ // tab's client-side lenses unapplied until the next poll happened to repaint.
568
+ assert.match(workJs, /if \(q !== lastServerFilterQuery\) \{ lastServerFilterQuery = q; refreshLive\(\); \} else \{ renderClaimable\(\); \}/);
569
+ assert.match(workJs, /if \(bq !== lastBacklogQuery\) \{ lastBacklogQuery = bq; loadBacklog\(\); \} else \{ renderBacklog\(\); \}/);
570
+ });
571
+
572
+ test('the Goal filter offers goals that only have BACKLOG work', () => {
573
+ const populate = workJs.slice(workJs.indexOf('function populateGoalFilter'), workJs.indexOf('async function copyPromptForTask'));
574
+ assert.match(populate, /\[\.\.\.taskState\.all, \.\.\.\(taskState\.backlog \|\| \[\]\)\]/,
575
+ 'built from the claimable feed alone, a backlog-only goal was unofferable');
576
+ });