@bongos/core 1.19.659 → 1.19.661

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.
@@ -221,7 +221,7 @@ const PERMISSIONS = [
221
221
  // route reads is INERT; a route reading a key with no grant rows 403s everyone.
222
222
  // ADR 0174's Consequences record that failure landing on the Government tab
223
223
  // itself. So grants lead, routes follow — inside this goal, not across releases.
224
- { key: 'board.item.open', system: false, floor: 'metic', guards: 'POST /board/items — put a subject to the board (ADR 0175; a Full Idea window opens automatically on grade pass, so in practice this guards an amendment)' },
224
+ { key: 'board.item.open', system: false, floor: 'metic', guards: 'POST /government/board/items — put a subject to the board (ADR 0175; a Full Idea window opens automatically on grade pass, so in practice this guards an amendment)' },
225
225
  // floor 'xenos' is NOT a mistake and must not be "tightened". Who may vote is
226
226
  // the CONSTITUTION's membership predicate, resolved per request from config
227
227
  // (ADR 0175 §6) — today `rank:archon`. Encoding that rank here too would fork
@@ -230,7 +230,7 @@ const PERMISSIONS = [
230
230
  // is. So the atom says only "this route exists for authenticated builders" and
231
231
  // the predicate does the real gating — the `criterion.create` pattern above,
232
232
  // where the floor is real but explicitly not the whole gate.
233
- { key: 'board.vote.cast', system: false, floor: 'xenos', guards: 'POST /board/items/:id/vote — floor is the coarse gate; board membership is the configured predicate (ADR 0175 §6), checked in-handler' },
233
+ { key: 'board.vote.cast', system: false, floor: 'xenos', guards: 'POST /government/board/items/:itemId/votes — floor is the coarse gate; board membership is the configured predicate (ADR 0175 §6), checked in-handler' },
234
234
  { key: 'task.newcomer_restock', system: false, floor: 'metic', guards: 'POST /tasks/newcomer-restock (ADR 0157, was archon)' },
235
235
  { key: 'task.peer_votes.tally', system: false, floor: 'metic', guards: 'POST /tasks/peer-votes/tally (ADR 0157, was archon)' },
236
236
  { key: 'goal.reorder', system: false, floor: 'metic', guards: 'POST /goals/reorder (ADR 0157, was archon)' },
@@ -247,7 +247,25 @@ const PERMISSIONS = [
247
247
  // ── ownership-scoped + baseline own-work (floor thetes / xenos) ───────────────
248
248
  { key: 'task.claim.any', system: false, floor: 'thetes', guards: 'claim the general queue (per-task requires_rank floor still applies)' },
249
249
  { key: 'page.view.builder', system: false, floor: 'thetes', guards: 'authenticated hall pages (requireNonXenosPage / requireBuilderPage)' },
250
- { key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer owner/manager, in-handler (ADR 0106)' },
250
+ // floor 'metic' is real but NOT the whole gatethe `criterion.create` pattern
251
+ // above, and here the floor is the LOOSER half. Both routes carry requireBuilder
252
+ // and decide in-handler (authorizeMembershipKindChange / authorizeOwnershipTransfer,
253
+ // modules/lifecycle/routes/goal-route-authz.js): rank is consulted ONLY as an
254
+ // Archon bypass, so `actorIsOwner || actorIsManager` admits at ANY rank.
255
+ //
256
+ // THE LIVE POPULATION, recorded because a migration must not silently evict it:
257
+ // a goal's owner is its `created_by` (goal-authz.js isGoalOwner), so a builder
258
+ // DEMOTED below metic keeps owner authority over goals they created; and a
259
+ // sub-metic member can be promoted to manager ('lead') on any unprotected goal,
260
+ // reaching this permission without ever holding metic.
261
+ //
262
+ // So when R104-R107 migrates these routes, the gate stays requireBuilder +
263
+ // resolver.authorizeOwned(key, owner) — a bare permission gate on this key would
264
+ // 403 exactly the owners and managers the routes admit today. The floor is
265
+ // deliberately NOT lowered to xenos here (the board.vote.cast option): a floor
266
+ // feeds RANK_SEED and the live reset route, so widening it is an authority change
267
+ // that wants its own decision, not a note in a hardening batch.
268
+ { key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — requireBuilder + in-handler owner|manager|Archon (ADR 0106); floor is not the whole gate, and sub-metic owners/managers hold it today' },
251
269
  { key: 'task.claim.newcomer', system: false, floor: 'xenos', guards: 'claim a newcomer_friendly task (xenosClaimAllowed; xenos = one active claim)' },
252
270
  { key: 'task.ship', system: false, floor: 'xenos', guards: '/builder-ship own claim' },
253
271
  { key: 'task.act.own', system: false, floor: 'xenos', scope: 'own', resource: 'claim', guards: 'act on OWN active claim (release, cost, notes) — ownership-scoped' },
@@ -261,7 +279,7 @@ const PERMISSIONS = [
261
279
  // marker here. Exactly why scope is declared data: the suffix rule only runs the
262
280
  // safe direction ('.own' ⇒ scope 'own'), never the converse.
263
281
  { key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
264
- { key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /costs' },
282
+ { key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /cost' },
265
283
  { key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
266
284
  { key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
267
285
  { key: 'idea.file', system: false, floor: 'xenos', guards: 'POST /inbox (own idea capture)' },
@@ -281,7 +299,8 @@ const OWNERSHIP_SCOPES = Object.freeze(['own', 'any']);
281
299
  const _byKey = new Map();
282
300
  const _byPrincipal = new Map();
283
301
  const _byResource = new Map();
284
- for (const p of PERMISSIONS) {
302
+ for (let i = 0; i < PERMISSIONS.length; i++) {
303
+ const p = PERMISSIONS[i];
285
304
  if (typeof p.key !== 'string' || !p.key) throw new Error(`government catalog: permission missing a key`);
286
305
  if (typeof p.system !== 'boolean') throw new Error(`government catalog: ${p.key} missing boolean 'system'`);
287
306
  if (p.floor !== null && !RANK_ORDER.includes(p.floor)) throw new Error(`government catalog: ${p.key} has invalid floor "${p.floor}"`);
@@ -320,6 +339,16 @@ for (const p of PERMISSIONS) {
320
339
  // floor would seed the key into a rank that the substrate then still denies.
321
340
  if (p.floor !== 'archon') throw new Error(`government catalog: ${p.key} is substrate-enforced and must floor at 'archon' (the substrate compares against the top of the ladder)`);
322
341
  }
342
+ // A system:true key is trust-boundary authority (ADR 0016): it is never grantable
343
+ // to a custom rank, so the ONLY way a builder holds it is by sitting on a seeded
344
+ // rank at or above its floor. That makes the floor the whole wall, and the wall
345
+ // belongs at the top of the ladder. Without this, {system:true, floor:'metic'}
346
+ // loads clean and governance_002's reset route seeds Archon-only power to Metic.
347
+ // The two floor:null shapes are the exception and are already partitioned above:
348
+ // a machine principal is held by NO rank at all.
349
+ if (p.system && principal === null && p.floor !== 'archon') {
350
+ throw new Error(`government catalog: ${p.key} is system:true and must floor at 'archon' (a system key is not grantable, so its floor is the whole wall) — got "${p.floor}"`);
351
+ }
323
352
  // ── the OWNERSHIP axis (R100) ─────────────────────────────────────────────
324
353
  const scope = p.scope ?? null;
325
354
  const resource = p.resource ?? null;
@@ -347,7 +376,15 @@ for (const p of PERMISSIONS) {
347
376
  if (scope !== null && p.floor === null) {
348
377
  throw new Error(`government catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a RANK permission, never an identity one`);
349
378
  }
379
+ // ONE object per permission, shared by every reader. The frozen copy goes back
380
+ // into PERMISSIONS[i] as well as the lookup maps, because the alternative —
381
+ // freezing a COPY and leaving the original mutable in the array — splits the
382
+ // catalog into two views that can disagree: `Object.freeze(PERMISSIONS)` below
383
+ // seals the array's SHAPE but not its entries, so a runtime mutation of
384
+ // PERMISSIONS[i] would change the floor seedForRank() reads (and with it the
385
+ // live POST /government/ranks/:rankKey/reset) while byKey() kept the old value.
350
386
  const frozen = Object.freeze({ ...p, principal, scope, resource, substrate });
387
+ PERMISSIONS[i] = frozen;
351
388
  _byKey.set(p.key, frozen);
352
389
  if (principal !== null) _byPrincipal.set(principal, frozen);
353
390
  if (resource !== null) {
@@ -197,6 +197,12 @@
197
197
  font-variant-numeric: tabular-nums; min-width: 3.2em;
198
198
  }
199
199
  .history__title { flex: 1 1 auto; color: var(--ink); min-width: 0; }
200
+ /* task 1003828 — the goal a backlog row belongs to, on its own line under the
201
+ title. The SUB-LINE voice (the .scroll__lede tone), not a tag: a tag would read
202
+ as one more equal chip beside version/discipline/rank, and where a task LIVES
203
+ is the parent fact those chips sit inside. Wraps rather than truncating —
204
+ a goal title a person cannot finish reading is the state this row is fixing. */
205
+ .history__goal { display: inline-block; margin-top: 3px; color: var(--ink-soft); font-size: 12.5px; }
200
206
  .history__when { color: var(--ink-soft); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; }
201
207
 
202
208
  @media (max-width: 640px) {
@@ -52,6 +52,24 @@
52
52
  Deep links: /work#in-progress etc. -->
53
53
  <nav id="work-tabs" aria-label="Task sections"></nav>
54
54
 
55
+ <!-- task 2170: the filter bar is built by OTBKit.makeFilterBar —
56
+ Search / Sort / Version / Discipline primary (task 1003828 promoted
57
+ Discipline out of the overflow: a craft is the axis this platform
58
+ organises work by), the rest behind a "Filters" disclosure; state
59
+ rides the URL query. Control ids are unchanged (f-search, f-sort,
60
+ f-version, f-goal, f-kind, f-discipline, f-newcomer, f-fit) so
61
+ work.js reads stay stable.
62
+
63
+ task 1003828 moved it OUT of #claim-scroll and above the panels. It
64
+ lived inside the Ready section, which makeTabs hides — so on the
65
+ Backlog tab the filters were not merely inert, they were not on the
66
+ screen at all. It drives the two QUEUE tabs (Ready, Backlog) through
67
+ the one matchesBoardFilters predicate, and work.js hides it on In
68
+ Progress and Completed, which render different objects (live claims,
69
+ shipped history) that these lenses do not describe. A filter bar
70
+ above a list it cannot narrow is the bug this task is fixing. -->
71
+ <div id="work-filters"></div>
72
+
55
73
  <!-- READY — the claimable queue: the page's primary object, above the
56
74
  fold as the default tab. -->
57
75
  <!-- task 1003304: BARE, and that is the whole re-skin. In v3 a claimable
@@ -64,12 +82,6 @@
64
82
  <h2 id="claim-h" class="scroll__h">Ready — tasks to do<span class="count-pill" id="ready-count" hidden></span></h2>
65
83
  <p class="scroll__lede">Click <span class="kbd">Claim</span> to take a task right here — no terminal needed. Then click <span class="kbd">📋</span> to copy the task prompt and paste it into a fresh Claude Code session to begin.</p>
66
84
 
67
- <!-- task 2170: the filter bar is built by OTBKit.makeFilterBar —
68
- Search / Sort / Version primary, the rest behind a "Filters"
69
- disclosure; state rides the URL query. Control ids are unchanged
70
- (f-search, f-sort, f-version, f-goal, f-kind, f-discipline,
71
- f-newcomer, f-fit) so work.js reads stay stable. -->
72
- <div id="work-filters"></div>
73
85
  <p class="claim-count-line" id="claim-count" aria-live="polite"></p>
74
86
 
75
87
  <div class="task-list" id="task-list">
@@ -62,15 +62,23 @@
62
62
  // forever. Its own function because Creator/Age re-fetch it, same as the
63
63
  // claimable feed. Best-effort: on failure the section stays as it was.
64
64
  function loadBacklog() {
65
- const q = serverFilterQuery().replace(/^\?/, '&');
65
+ const q = backlogFilterQuery().replace(/^\?/, '&');
66
66
  return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
67
- .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
67
+ .then((r) => {
68
+ taskState.backlog = r.tasks || [];
69
+ renderBacklog(taskState.backlog);
70
+ // task 1003828 — the Goal filter draws on this feed too and races it.
71
+ // Re-offer the options now; populateGoalFilter keeps the selection.
72
+ populateGoalFilter();
73
+ })
68
74
  .catch(() => { /* backlog stays as it was */ });
69
75
  }
70
76
 
71
- // The last server-facing filter query we actually fetched with. Compared on
72
- // every filter change to decide re-fetch vs re-render.
77
+ // The last server-facing filter queries we actually fetched with one per
78
+ // feed, because the two routes answer DIFFERENT questions (see below).
79
+ // Compared on every filter change to decide re-fetch vs re-render.
73
80
  let lastServerFilterQuery = '';
81
+ let lastBacklogQuery = '';
74
82
 
75
83
  function serverFilterQuery() {
76
84
  const parts = [];
@@ -81,6 +89,24 @@
81
89
  return parts.length ? `?${parts.join('&')}` : '';
82
90
  }
83
91
 
92
+ // task 1003828 — the BACKLOG's server query, deliberately NOT shared with the
93
+ // claimable feed's. The routes differ (GET /tasks answers version/kind/
94
+ // discipline/goal_id; /tasks/claimable answers only version+discipline), and the
95
+ // backlog read is CAPPED at BACKLOG_LIMIT while the Ready feed is not — so a
96
+ // client-only narrow here would lie, hiding the artist tasks at row 501.
97
+ // '__none__' on Goal has no server predicate and is never sent.
98
+ function backlogFilterQuery() {
99
+ const f = boardFilterValues();
100
+ const parts = [];
101
+ if (f.creator) parts.push(`created_by=${encodeURIComponent(f.creator === '__none__' ? 'none' : f.creator)}`);
102
+ if (f.stale) parts.push(`stale_days=${encodeURIComponent(f.stale)}`);
103
+ if (f.version) parts.push(`version=${encodeURIComponent(f.version)}`);
104
+ if (f.kind) parts.push(`kind=${encodeURIComponent(f.kind)}`);
105
+ if (f.discipline) parts.push(`discipline=${encodeURIComponent(f.discipline)}`);
106
+ if (f.goal && f.goal !== '__none__') parts.push(`goal_id=${encodeURIComponent(f.goal)}`);
107
+ return parts.length ? `?${parts.join('&')}` : '';
108
+ }
109
+
84
110
  // task 1003278 — the ceiling on the Backlog read. Comfortably above today's
85
111
  // ~300 rows so nothing visibly changes now, and low enough that the page can
86
112
  // never be asked to hold the whole table as the project grows.
@@ -289,54 +315,76 @@
289
315
  return false;
290
316
  }
291
317
 
318
+ // task 1003828 — the board's filter lenses, read once from the DOM. ONE reader,
319
+ // so a control that exists can never be one a tab forgot to consult: before
320
+ // this, every dropdown was read inside renderClaimable and nowhere else, which
321
+ // left all six silently inert on the Backlog tab. ('__none__' on Creator
322
+ // isolates the unattributed — task 1003278.)
323
+ function boardFilterValues() {
324
+ const val = (id) => ($(id) ? $(id).value : '');
325
+ return {
326
+ search: searchQuery(),
327
+ version: val('#f-version'),
328
+ goal: val('#f-goal'),
329
+ kind: val('#f-kind'),
330
+ discipline: val('#f-discipline'),
331
+ newcomer: val('#f-newcomer'),
332
+ creator: val('#f-creator'),
333
+ stale: val('#f-stale'),
334
+ };
335
+ }
336
+
337
+ // Does one task survive the current lenses? Pure over (task, values) so both
338
+ // feeds narrow alike and a new filter cannot reach one tab alone.
339
+ function matchesBoardFilters(t, f) {
340
+ if (!matchesSearch(t, f.search)) return false;
341
+ if (f.version && t.version_id !== f.version) return false;
342
+ // task 1755: goal filter. '__none__' isolates tasks with no goal.
343
+ if (f.goal === '__none__' && t.goal_id != null) return false;
344
+ if (f.goal && f.goal !== '__none__' && String(t.goal_id) !== f.goal) return false;
345
+ if (f.kind && t.kind !== f.kind) return false;
346
+ if (f.discipline && (t.discipline || 'unclassified') !== f.discipline) return false;
347
+ if (f.newcomer === 'yes' && !t.newcomer_friendly) return false;
348
+ // Creator + Age were applied by the SERVER, so they are re-checked here only
349
+ // as a belt on a stale render — cheap, and it keeps the view honest if a poll
350
+ // lands between a control change and its re-fetch.
351
+ if (f.creator === '__none__' && t.created_by != null) return false;
352
+ if (f.creator && f.creator !== '__none__' && String(t.created_by) !== f.creator) return false;
353
+ if (f.stale && !isStale(taskActivityMs(t), f.stale)) return false;
354
+ return true;
355
+ }
356
+
357
+ // task 1756: run-mode — a task is "parallel-safe" when its touches[] don't
358
+ // overlap any OTHER task in the visible set. Set-RELATIVE, which is why it runs
359
+ // after matchesBoardFilters rather than inside it: it answers "of what I'm
360
+ // looking at, what can run together." O(n²) over touches — fine at board scale.
361
+ function applyRunMode(list, fitFilter) {
362
+ if (fitFilter !== 'parallel' && fitFilter !== 'conflict') return list;
363
+ return list.filter((t) => {
364
+ const conflicts = list.some((o) => o !== t && touchesConflict(t.touches, o.touches));
365
+ return fitFilter === 'parallel' ? !conflicts : conflicts;
366
+ });
367
+ }
368
+
369
+ // task 1003828 — the bar sits above the four panels now, so it must say which it
370
+ // speaks for. Ready and Backlog are the QUEUE tabs; In Progress (live claims)
371
+ // and Completed (shipped history) render other objects, so the bar hides there
372
+ // rather than offering a control that does nothing — this task's whole defect.
373
+ const FILTERED_TABS = new Set(['ready', 'backlog']);
374
+ function syncFilterBarVisibility(tabId) {
375
+ const bar = $('#work-filters');
376
+ if (bar) bar.hidden = !FILTERED_TABS.has(String(tabId));
377
+ }
378
+
292
379
  function renderClaimable() {
293
380
  const root = $('#task-list');
294
381
  if (!root) return;
295
- const vFilter = $('#f-version').value;
296
- const gFilter = $('#f-goal') ? $('#f-goal').value : '';
297
- const kFilter = $('#f-kind').value;
298
- const dFilter = $('#f-discipline').value;
299
- const nFilter = $('#f-newcomer') ? $('#f-newcomer').value : '';
300
- // task 1003278 — creator + staleness. '__none__' on Creator isolates the
301
- // unattributed (created before tasks.created_by existed, or by a system path)
302
- // — the goals owner filter's 'system / unattributed' convention.
303
- const cFilter = $('#f-creator') ? $('#f-creator').value : '';
304
- const staleFilter = $('#f-stale') ? $('#f-stale').value : '';
382
+ const filters = boardFilterValues();
305
383
  const sortBy = $('#f-sort').value;
306
- const sQuery = searchQuery();
307
-
308
- let list = taskState.all.filter((t) => {
309
- if (!matchesSearch(t, sQuery)) return false;
310
- if (vFilter && t.version_id !== vFilter) return false;
311
- // task 1755: goal filter. '__none__' isolates tasks with no goal.
312
- if (gFilter === '__none__' && t.goal_id != null) return false;
313
- if (gFilter && gFilter !== '__none__' && String(t.goal_id) !== gFilter) return false;
314
- if (kFilter && t.kind !== kFilter) return false;
315
- if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
316
- if (nFilter === 'yes' && !t.newcomer_friendly) return false;
317
- // Creator + Age were applied by the SERVER (serverFilterQuery), so they are
318
- // re-checked here only as a belt on a stale render — never as the primary
319
- // filter. Re-applying them is cheap and keeps the view honest if a poll
320
- // lands between a control change and its re-fetch.
321
- if (cFilter === '__none__' && t.created_by != null) return false;
322
- if (cFilter && cFilter !== '__none__' && String(t.created_by) !== cFilter) return false;
323
- if (staleFilter && !isStale(taskActivityMs(t), staleFilter)) return false;
324
- return true;
325
- });
326
384
 
327
- // task 1756: run-mode filter a task is "parallel-safe" when its touches[]
328
- // don't overlap any OTHER task in the currently-visible set; "conflict" is the
329
- // complement (would collide, so run them sequentially). Computed over the
330
- // already-filtered list so it answers "of what I'm looking at, what can run
331
- // together." O(n²) over touches — fine at board scale.
332
- const fitFilter = $('#f-fit') ? $('#f-fit').value : '';
333
- if (fitFilter === 'parallel' || fitFilter === 'conflict') {
334
- const base = list;
335
- list = base.filter((t) => {
336
- const conflicts = base.some((o) => o !== t && touchesConflict(t.touches, o.touches));
337
- return fitFilter === 'parallel' ? !conflicts : conflicts;
338
- });
339
- }
385
+ let list = taskState.all.filter((t) => matchesBoardFilters(t, filters));
386
+
387
+ list = applyRunMode(list, $('#f-fit') ? $('#f-fit').value : '');
340
388
 
341
389
  if (sortBy === 'cpm') {
342
390
  list = list.slice().sort((a, b) => creditsPerMinute(b) - creditsPerMinute(a));
@@ -508,7 +556,10 @@
508
556
  if (!sel) return;
509
557
  const cur = sel.value;
510
558
  const titles = taskState.goalTitles || {};
511
- const ids = [...new Set(taskState.all.map((t) => t.goal_id).filter((x) => x != null).map(String))];
559
+ // task 1003828 options come from BOTH feeds: built from the claimable one
560
+ // alone, a goal whose only work sits at backlog was unofferable.
561
+ const rows = [...taskState.all, ...(taskState.backlog || [])];
562
+ const ids = [...new Set(rows.map((t) => t.goal_id).filter((x) => x != null).map(String))];
512
563
  ids.sort((a, b) => String(titles[a] || `Goal #${a}`).localeCompare(String(titles[b] || `Goal #${b}`)));
513
564
  sel.length = 1; // keep the leading "— all goals —" option
514
565
  for (const id of ids) {
@@ -517,7 +568,7 @@
517
568
  opt.textContent = titles[id] || `Goal #${id}`;
518
569
  sel.appendChild(opt);
519
570
  }
520
- if (taskState.all.some((t) => t.goal_id == null)) {
571
+ if (rows.some((t) => t.goal_id == null)) {
521
572
  const opt = document.createElement('option');
522
573
  opt.value = '__none__';
523
574
  opt.textContent = 'No goal';
@@ -1055,17 +1106,44 @@
1055
1106
  root.innerHTML = '<div class="profile__placeholder">Nothing in the backlog right now.</div>';
1056
1107
  return;
1057
1108
  }
1058
- const q = searchQuery();
1059
- const list = q ? backlogRows.filter((t) => matchesSearch(t, q)) : backlogRows;
1109
+ // task 1003828 — the SAME lenses the Ready tab uses. This read used to be
1110
+ // search-only, so six controls silently did nothing on the tab holding the
1111
+ // most rows. The server already narrowed this feed; re-applying the predicate
1112
+ // answers the two client-only lenses (search, Goal's '__none__') and keeps the
1113
+ // view honest between a control change and its re-fetch.
1114
+ const filters = boardFilterValues();
1115
+ let list = backlogRows.filter((t) => matchesBoardFilters(t, filters));
1116
+ list = applyRunMode(list, $('#f-fit') ? $('#f-fit').value : '');
1060
1117
  const countEl = $('#backlog-count');
1061
1118
  if (countEl) countEl.textContent = `${list.length} ${list.length === 1 ? 'task' : 'tasks'}`;
1119
+ if (!list.length) {
1120
+ root.innerHTML = '<div class="profile__placeholder">No backlog tasks match these filters. Try widening them.</div>';
1121
+ pagers.backlog.page = 1;
1122
+ return;
1123
+ }
1062
1124
  const shown = paginate(list, pagers.backlog);
1063
1125
  root.innerHTML = shown.map((t) => {
1064
1126
  const ver = t.version_id ? `<span class="task__tag">${escapeHtml(t.version_id)}</span>` : '';
1127
+ // task 1003828 — the row says WHOSE CRAFT and WHICH GOAL. A Ready card has
1128
+ // carried its discipline tag since 1755; a backlog row carried neither, so a
1129
+ // correctly-goaled task (an auto-filed artist review, say) read as
1130
+ // uncategorised in the one place a person goes looking for it. Same
1131
+ // vocabulary as the card, so the two tabs describe a task alike.
1132
+ const discipline = t.discipline || 'unclassified';
1133
+ const disciplineTag = discipline !== 'unclassified'
1134
+ ? `<span class="task__tag">${escapeHtml(discipline)}</span>`
1135
+ : '';
1136
+ // The goal's TITLE, from the map the Goal filter already loads, falling back
1137
+ // to its id before that resolves. A goal-less row says so rather than
1138
+ // rendering a gap — "no goal" is a real, findable state.
1139
+ const goalTitle = t.goal_id != null
1140
+ ? ((taskState.goalTitles || {})[String(t.goal_id)] || `Goal #${t.goal_id}`)
1141
+ : 'No goal';
1142
+ const goalTag = `<span class="history__goal" title="${escapeHtml(`Goal: ${goalTitle}`)}">${escapeHtml(goalTitle)}</span>`;
1065
1143
  return `
1066
1144
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1067
1145
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1068
- <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${rankTagHtml(t.requires_rank)}</span>
1146
+ <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1069
1147
  <span class="history__when">${escapeHtml(t.kind || '')}</span>
1070
1148
  </div>`;
1071
1149
  }).join('');
@@ -1182,6 +1260,10 @@
1182
1260
  taskState.goalTitles[g.id] = g.title;
1183
1261
  taskState.goalCategories[g.id] = g.category || null;
1184
1262
  });
1263
+ // task 1003828 — loadBacklog() fires ABOVE this await, so its first paint
1264
+ // has no titles and every row would read "Goal #1000089" until something
1265
+ // else repainted it. Harmless if the feed has not landed yet.
1266
+ renderBacklog();
1185
1267
  } catch (_) { /* filter labels fall back to 'Goal #id' */ }
1186
1268
 
1187
1269
  // Claimable + active claims in one call. A 403 means the primer gate — show
@@ -1242,7 +1324,10 @@
1242
1324
  { id: 'backlog', label: 'Backlog', panel: 'backlog-scroll' },
1243
1325
  { id: 'completed', label: 'Completed', panel: 'history-scroll' },
1244
1326
  ],
1245
- onChange: () => { if (selectionTotal()) clearSelection(); },
1327
+ onChange: (id) => {
1328
+ if (selectionTotal()) clearSelection();
1329
+ syncFilterBarVisibility(id);
1330
+ },
1246
1331
  });
1247
1332
 
1248
1333
  // task 2170: filter diet — Search/Sort/Version primary, the rest behind
@@ -1266,6 +1351,16 @@
1266
1351
  ],
1267
1352
  },
1268
1353
  { id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1354
+ // task 1003828 — Discipline is PRIMARY, not overflow. A craft is the axis
1355
+ // this platform organises people by (CLAUDE.md's role packs; a task's work
1356
+ // type IS its discipline), so "show me my craft's work" is a first-class
1357
+ // question, not one to find behind a disclosure. The mixed option form is
1358
+ // kit-supported — pinned by tests/goal_sort_staleness.mjs against this id.
1359
+ {
1360
+ id: 'f-discipline',
1361
+ label: 'Discipline',
1362
+ options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1363
+ },
1269
1364
  ],
1270
1365
  overflow: [
1271
1366
  { id: 'f-goal', label: 'Goal', options: [{ value: '', label: '— all goals —' }] },
@@ -1276,11 +1371,6 @@
1276
1371
  'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
1277
1372
  'cleanup', 'decision', 'blocker-resolution', 'unclassified'],
1278
1373
  },
1279
- {
1280
- id: 'f-discipline',
1281
- label: 'Discipline',
1282
- options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1283
- },
1284
1374
  {
1285
1375
  id: 'f-newcomer',
1286
1376
  label: 'Newcomer',
@@ -1310,18 +1400,23 @@
1310
1400
  // changed id (hall-kit.js:269), and comparing the query is the direct
1311
1401
  // question anyway — a control that does not appear in it can never cost
1312
1402
  // a round-trip, which is what keeps the other filters instant.
1403
+ //
1404
+ // task 1003828 — decided SEPARATELY per feed now, because the backlog asks
1405
+ // the server more than claimable can answer. Each side re-fetches (ending
1406
+ // in its own render) or re-renders in place; the old early return left the
1407
+ // other tab's client-side lenses unapplied until the next poll.
1313
1408
  const q = serverFilterQuery();
1314
- if (q !== lastServerFilterQuery) {
1315
- lastServerFilterQuery = q;
1316
- refreshLive();
1317
- loadBacklog();
1318
- return;
1319
- }
1320
- renderClaimable();
1321
- renderBacklog();
1409
+ const bq = backlogFilterQuery();
1410
+ if (q !== lastServerFilterQuery) { lastServerFilterQuery = q; refreshLive(); } else { renderClaimable(); }
1411
+ if (bq !== lastBacklogQuery) { lastBacklogQuery = bq; loadBacklog(); } else { renderBacklog(); }
1322
1412
  },
1323
1413
  });
1324
1414
 
1415
+ // task 1003828 — makeTabs fires onChange only on a CHANGE and restores a
1416
+ // deep-linked tab before this point, so the opening state is set explicitly:
1417
+ // without it, a reload onto #completed shows a filter bar over ship history.
1418
+ syncFilterBarVisibility(workTabs && workTabs.active ? workTabs.active() : 'ready');
1419
+
1325
1420
  $('#task-list')?.addEventListener('click', (e) => {
1326
1421
  // task 1759: the multi-select checkbox. Toggle the Set + the card's
1327
1422
  // highlight class directly (cheaper than a full renderClaimable) and
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.659",
3
+ "version": "1.19.661",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.659",
9
+ "version": "1.19.661",
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.659",
3
+ "version": "1.19.661",
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",
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.659'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.661'; // 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');
@@ -178,3 +178,16 @@ test('byKey returns a frozen record or null', () => {
178
178
  assert.throws(() => { p.system = false; }, /Cannot assign|read only|frozen/i);
179
179
  assert.equal(byKey('nope'), null);
180
180
  });
181
+
182
+ // Freezing the ARRAY only seals its shape. Before task 1003194 the entries inside it
183
+ // stayed mutable and byKey() handed out a frozen COPY, so the catalog had two views
184
+ // that could disagree: mutating PERMISSIONS[i].floor moved what seedForRank() reads —
185
+ // and with it the live POST /government/ranks/:rankKey/reset — while byKey() kept the
186
+ // old value. Identity is the assertion that actually forbids that, not depth.
187
+ test('the catalog is ONE immutable object per permission — array, entries, and byKey identity', () => {
188
+ assert.equal(Object.isFrozen(PERMISSIONS), true, 'the PERMISSIONS array must be frozen');
189
+ for (const p of PERMISSIONS) {
190
+ assert.equal(Object.isFrozen(p), true, `PERMISSIONS entry ${p.key} must be frozen, not just the array`);
191
+ assert.equal(p, byKey(p.key), `${p.key}: PERMISSIONS[i] and byKey() must be the SAME object, not equal copies`);
192
+ }
193
+ });
@@ -163,6 +163,13 @@ const normRoute = (key) => key.replace(/:[A-Za-z0-9_]+/g, ':p'); // :id vs :crit
163
163
  // Leading `METHOD /path` of a guards note. Deliberately conservative: a note that
164
164
  // lists several routes or wraps them in prose/braces simply doesn't participate.
165
165
  const GUARD_ROUTE_RE = /^(GET|POST|PATCH|PUT|DELETE)\s+(\/[^\s,(]+)/;
166
+ // The conservative intent above is enforced HERE, not by the character class: a
167
+ // path carrying `{a,b}` (a brace set), `[/x]` (an optional segment) or `*` (a
168
+ // wildcard) is a PATTERN standing for several routes, not one concrete route, so
169
+ // it cannot be looked up and does not participate. Without this the regex happily
170
+ // captured `/blockers/:id/{resolve` and seven siblings, which then dangled in B0
171
+ // and drowned the three real typos it exists to catch (task 1003194).
172
+ const ROUTE_PATTERN_RE = /[{[*]/;
166
173
  // A route's middleware rank → the LOOSEST floor still consistent with it. Only the
167
174
  // two rank-gated classifications constrain a floor; 'any-builder' / 'public' /
168
175
  // 'unknown' / 'bfg-principal' carry no rank middleware to compare against.
@@ -190,12 +197,17 @@ function crossReference() {
190
197
  const live = liveRoutes();
191
198
  const floorPinned = [];
192
199
  const atomPinned = [];
200
+ const dangling = [];
193
201
  for (const p of FLOORED) {
194
202
  const m = GUARD_ROUTE_RE.exec(p.guards);
195
203
  if (!m) continue;
204
+ if (ROUTE_PATTERN_RE.test(m[2])) continue;
196
205
  const routeKey = `${m[1]} ${m[2]}`;
197
206
  const r = live.get(normRoute(routeKey));
198
- if (!r) continue;
207
+ // A note that names ONE concrete route which does not exist is a typo, and a
208
+ // silent `continue` here is how it stayed invisible: the entry simply dropped
209
+ // out of B1 and B3 while all 27 tests stayed green. B0 asserts this is empty.
210
+ if (!r) { dangling.push(`${p.key} → ${routeKey}`); continue; }
199
211
  if (r.perms) {
200
212
  atomPinned.push({ permission: p.key, floor: p.floor, routeKey, perms: r.perms });
201
213
  continue;
@@ -204,9 +216,27 @@ function crossReference() {
204
216
  if (!minFloor) continue;
205
217
  floorPinned.push({ permission: p.key, floor: p.floor, routeKey, routeRank: r.rank, minFloor });
206
218
  }
207
- return { floorPinned, atomPinned };
219
+ return { floorPinned, atomPinned, dangling };
208
220
  }
209
221
 
222
+ // The oracles B1 and B3 can only judge a permission whose guards note RESOLVES to a
223
+ // live route. A note naming a route that does not exist therefore fails open — it is
224
+ // dropped, not reported, and the permission silently leaves both oracles while the
225
+ // suite stays green. That is not hypothetical: `cost.log` said 'POST /costs' against
226
+ // a real 'POST /cost', and both Board Room keys omitted the '/government' prefix, so
227
+ // the two newest requirePermission routes sat outside B3 — the very oracle written
228
+ // for them. This test is the fail-loud half (task 1003194).
229
+ //
230
+ // There is deliberately NO allowlist. A note that genuinely cannot name one concrete
231
+ // route does not reach here at all: it either fails GUARD_ROUTE_RE (prose, a surface
232
+ // name like 'planning-session surface') or is a pattern caught by ROUTE_PATTERN_RE.
233
+ // An allowlist would be a place to park the next typo.
234
+ test('B0: every guards note that names a concrete route names one that EXISTS', () => {
235
+ const { dangling } = crossReference();
236
+ assert.deepEqual(dangling, [],
237
+ `guards notes point at routes that do not exist — fix the note (or the route), never ignore it:\n ${dangling.join('\n ')}`);
238
+ });
239
+
210
240
  // ONE-DIRECTIONAL, and deliberately so. A floor STRICTER than the route middleware
211
241
  // is legitimate and common: the wall lives in the handler (task.confirm.grade_bypass
212
242
  // on an any-builder POST /tasks/:id/confirm; goal.manage.own's owner|manager check;