@bongos/core 1.21.125 → 1.21.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.bongos-core.json +70 -40
  2. package/docs/api/openapi.json +4 -4
  3. package/docs/copy-inventory.md +128 -126
  4. package/docs/copy-registry.json +188 -170
  5. package/docs/module-api-changelog.md +3 -1
  6. package/docs/page-inventory.json +2 -1
  7. package/docs/page-readings.json +152 -143
  8. package/modules/hall-ui/public/goal-forest-lib.js +36 -0
  9. package/modules/hall-ui/public/goal-forest-render.js +50 -10
  10. package/modules/hall-ui/public/goals-page.js +19 -22
  11. package/modules/hall-ui/public/goals-render.js +6 -16
  12. package/modules/hall-ui/public/goals.css +13 -4
  13. package/modules/hall-ui/public/goals.html +22 -24
  14. package/modules/hall-ui/public/goals.states.json +19 -1
  15. package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
  16. package/modules/platform-identity/invite-authz.js +1 -1
  17. package/modules/provisioning/catalog-bridge.js +1 -1
  18. package/modules/provisioning/provisioning.js +1 -1
  19. package/modules/provisioning/route-helpers.js +50 -0
  20. package/modules/provisioning/routes/disconnect.js +6 -5
  21. package/modules/provisioning/routes/env-manifest.js +11 -27
  22. package/modules/provisioning/routes/provisioning.js +58 -75
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/release-notes.json +14 -0
  26. package/scripts/gds/module-assess-tests.js +40 -11
  27. package/scripts/gds/module-assess-version.js +10 -2
  28. package/scripts/gds/module-test-runner/README.md +44 -0
  29. package/scripts/gds/module-test-runner/module-tests.yml +87 -0
  30. package/scripts/gds/module-test-runner.js +358 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/goal_forest_lib.mjs +28 -0
  33. package/tests/goal_forest_page.mjs +106 -11
  34. package/tests/module_assess_price_parity.mjs +2 -2
  35. package/tests/module_assess_score.mjs +13 -3
  36. package/tests/module_test_runner.mjs +294 -0
  37. package/tests/project_catalog_routes.mjs +1 -1
  38. package/tests/provisioning_module_selection.mjs +1 -1
  39. package/tests/provisioning_project_owner_gate.mjs +271 -0
@@ -5,12 +5,12 @@
5
5
  // header), so a new self-contained feature is its own file — the same
6
6
  // separation goal-map.js already keeps from goals-render.js, one file down.
7
7
  //
8
- // NOT a band column, unlike Completion/Members/Conflicts on the same detail
9
- // page: a node-link picture needs real width, and this is bounded small BY
10
- // DESIGN (ADR 0227 §3's own finding — the tasks a forest omits are exactly
11
- // the ones with nothing to connect them to), so there is no cap-and-"view
12
- // all" split here. What renders is always the whole forest returned by GET
13
- // /goals/:id/dependency-forest. The model + layout are goal-forest-lib.js
8
+ // Two surfaces (task 1004738): the band's compact Dependencies card, in the
9
+ // slot Conflicts used to hold — a summary, the tasks to ship first, the
10
+ // conflicts count — and the full picture behind it at #/goal/N/dependencies,
11
+ // where the forest needs real width. The full view always draws the whole
12
+ // forest returned by GET /goals/:id/dependency-forest (ADR 0227 §3: what it
13
+ // omits is exactly what has nothing to connect it). The model + layout are goal-forest-lib.js
14
14
  // (pure); this file only draws what it returns, with createElementNS +
15
15
  // textContent — the atlas / goal-map.js idiom — so a task title never
16
16
  // becomes markup.
@@ -18,8 +18,9 @@
18
18
  // A factory, the goals-render.js precedent, because `state` keeps changing
19
19
  // after load: `createGoalForestRender(ctx)` takes the SAME `{ API, state,
20
20
  // sameId, getJSON }` slice of ctx goals-page.js already builds, and returns
21
- // `{ loadDependencyForest, renderDependencyForest }` for goals-render.js to
22
- // call from renderDetail() and for tests/goal_forest_page.mjs to call directly.
21
+ // `{ loadDependencyForest, renderDependencyForest, renderDependencyCard }` for
22
+ // goals-render.js to call (the card again whenever the conflicts land) and for
23
+ // tests/goal_forest_page.mjs to call directly.
23
24
  (function () {
24
25
  'use strict';
25
26
 
@@ -103,7 +104,44 @@
103
104
  return svg;
104
105
  }
105
106
 
107
+ const SHIP_FIRST = 3;
108
+ const taskHref = (id) => `#/task/${encodeURIComponent(id)}`;
109
+ // The band card. Reads state.conflictPairs too, so goals-render.js calls it
110
+ // again when the conflicts land; null there (loading, or a failed read)
111
+ // draws no conflicts line rather than a guess.
112
+ function renderDependencyCard() {
113
+ const lede = $('#goal-deps-card-lede');
114
+ const body = $('#goal-deps-card-body');
115
+ if (!lede || !body) return;
116
+ const a = state.forest && !state.forestError ? lib.analyse(state.forest) : null;
117
+ const n = a ? a.order.length : 0;
118
+ const pill = $('#goal-deps-card-count');
119
+ if (pill) { pill.hidden = !n; pill.textContent = n ? String(n) : ''; }
120
+ if (state.forestError) lede.textContent = 'Couldn’t load this goal’s task dependencies right now.';
121
+ else lede.textContent = a ? lib.brief(a) : 'Reading…';
122
+ const first = a ? lib.shipFirst(a, SHIP_FIRST) : [];
123
+ const pairs = Array.isArray(state.conflictPairs) ? state.conflictPairs : null;
124
+ body.innerHTML = (first.length
125
+ ? `<p class="gf-brief__h">Ship these first</p><ul class="gf-brief">${first.map((r) => `<li class="gf-brief__row">
126
+ <a class="lb-name gf-brief__task" href="${taskHref(r.id)}" title="#${escapeHtml(r.id)} ${escapeHtml(r.title)}">#${escapeHtml(r.id)} ${escapeHtml(r.title)}</a>
127
+ <span class="gf-brief__n">unblocks ${r.unblocks}</span></li>`).join('')}</ul>`
128
+ : '')
129
+ + (pairs
130
+ ? `<p class="gf-brief__conflicts${pairs.length ? ' gf-brief__conflicts--live' : ''}">${pairs.length
131
+ ? `${escapeHtml(lib.plural(pairs.length, 'file conflict'))} between open tasks.`
132
+ : 'No file conflicts between open tasks.'}</p>`
133
+ : '');
134
+ const foot = $('#goal-deps-card-foot');
135
+ if (!foot) return;
136
+ foot.hidden = !((n || (pairs && pairs.length)) && state.current != null);
137
+ foot.innerHTML = foot.hidden ? ''
138
+ : `<a class="pill-link" href="#/goal/${encodeURIComponent(state.current)}/dependencies">See the full picture →</a>`;
139
+ }
140
+
106
141
  function renderDependencyForest() {
142
+ renderDependencyCard();
143
+ const back = $('#goal-deps-back');
144
+ if (back && state.current != null) back.setAttribute('href', `#/goal/${encodeURIComponent(state.current)}`);
107
145
  const lede = $('#goal-deps-lede');
108
146
  const body = $('#goal-deps-body');
109
147
  if (!lede || !body) return;
@@ -130,8 +168,10 @@
130
168
  setCount('#goal-deps-count', a.order.length);
131
169
  lede.innerHTML = `${escapeHtml(lib.describe(a))} An arrow runs from the task that has to ship first to the task that waits on it. `
132
170
  + 'Every task here, and every dependency, is also in the table below.';
171
+ // The picture is drawn only while its own view is open: on the goal page
172
+ // the card is all that shows, and a forest drawn hidden cannot measure its titles.
173
+ if (state.view !== 'dependencies') { body.innerHTML = ''; return; }
133
174
  const rows = lib.tableRows(a);
134
- const taskHref = (id) => `#/task/${encodeURIComponent(id)}`;
135
175
  const depList = (list) => (list.length
136
176
  ? `<ul class="gf-deps">${list.map((x) => `<li><a href="${taskHref(x.id)}">#${escapeHtml(x.id)} ${escapeHtml(x.title)}</a></li>`).join('')}</ul>`
137
177
  : '<span class="gf-none">—</span>');
@@ -178,6 +218,6 @@
178
218
  }
179
219
  }
180
220
 
181
- return { loadDependencyForest, renderDependencyForest };
221
+ return { loadDependencyForest, renderDependencyForest, renderDependencyCard };
182
222
  };
183
223
  })();
@@ -58,8 +58,8 @@
58
58
  me: null,
59
59
  goals: [], // list rows
60
60
  details: new Map(), // goal_id -> {goal, members, criteria}
61
- current: null, // open goal id (detail/conflicts view) or null (list)
62
- view: 'list', // task 1828 — 'list' | 'detail' | 'conflicts'
61
+ current: null, // open goal id (detail or a full view) or null (list)
62
+ view: 'list', // task 1828 — 'list' | 'detail' | 'dependencies' | 'members' | 'completion'
63
63
  inviteOpen: false, // task 1762 — the invite form's open/closed state
64
64
  directory: null, // task 1877 — GET /builders/directory, fetched once and cached
65
65
  // task 1828 — per-table column sort. by=null → natural order (todo: server
@@ -562,10 +562,8 @@
562
562
  // full view at #/goal/N/<view> (the shared pattern behind Conflicts, Members,
563
563
  // and Completion). Hidden when nothing is truncated.
564
564
 
565
- // task 1828 — the band column shows the first CONFLICT_CAP; the foot routes
566
- // to the dedicated full-width conflicts view. Count + amber accent on the card.
567
-
568
- // task 1828 — the standalone #/goal/N/conflicts view: the whole set, no cap.
565
+ // task 1004738 — the conflict set renders on the #/goal/N/dependencies view;
566
+ // the band's Dependencies card carries its count.
569
567
 
570
568
  /* ----- Completion (band column + #/goal/N/completion full view) ----- */
571
569
 
@@ -727,16 +725,16 @@
727
725
  setWorkTab('todo');
728
726
  workTabs?.setCount('todo', null);
729
727
  workTabs?.setCount('done', null);
730
- ['#goal-conflicts-count', '#goal-conflicts-view-count',
728
+ ['#goal-conflicts-view-count',
731
729
  '#goal-members-count', '#goal-members-view-count', '#goal-completion-count'].forEach((sel) => {
732
730
  const el = $(sel);
733
731
  if (el) { el.hidden = true; el.textContent = ''; }
734
732
  });
735
- ['#goal-todo-pager', '#goal-done-pager', '#goal-conflicts-foot', '#goal-members-foot', '#goal-completion-foot'].forEach((sel) => {
733
+ ['#goal-todo-pager', '#goal-done-pager', '#goal-members-foot', '#goal-completion-foot'].forEach((sel) => {
736
734
  const el = $(sel);
737
735
  if (el) el.hidden = true;
738
736
  });
739
- ['#goal-conflicts-card', '#goal-conflicts-view-card'].forEach((sel) => $(sel)?.classList.remove('goal-conflicts-card--live'));
737
+ $('#goal-conflicts-view-card')?.classList.remove('goal-conflicts-card--live');
740
738
  reflectSortHeaders('done');
741
739
  // task 1002425 — To do is a card list now; Completed stays a table. Show a
742
740
  // placeholder in the card list + skeleton rows in the Completed table while
@@ -749,25 +747,23 @@
749
747
  if (todoCount) todoCount.textContent = '';
750
748
  const bulkBar = $('#goal-bulk-toolbar');
751
749
  if (bulkBar) bulkBar.hidden = true;
752
- const conflicts = $('#goal-conflicts');
753
- if (conflicts) conflicts.innerHTML = '<span class="kit-skel"></span>';
754
750
  const conflictsFull = $('#goal-conflicts-full');
755
751
  if (conflictsFull) conflictsFull.innerHTML = '<span class="kit-skel"></span>';
756
752
  }
757
753
 
758
754
  // task 1828 — views sharing this page: the list, a goal's detail, and the
759
- // goal's three full views (conflicts / members / completion). `view` defaults
760
- // from goalId (null → list).
761
- const SUB_VIEWS = ['conflicts', 'members', 'completion'];
755
+ // goal's three full views (dependencies / members / completion). `view`
756
+ // defaults from goalId (null → list).
757
+ const SUB_VIEWS = ['dependencies', 'members', 'completion'];
762
758
  const VIEW_SECTION = {
763
759
  list: '#goals-list-view',
764
760
  detail: '#goal-detail-view',
765
- conflicts: '#goal-conflicts-view',
761
+ dependencies: '#goal-dependencies-view',
766
762
  members: '#goal-members-view',
767
763
  completion: '#goal-completion-view',
768
764
  };
769
765
  const SUB_SUBTITLE = {
770
- conflicts: 'Goal conflicts — every open task pair whose declared files overlap.',
766
+ dependencies: 'Goal dependencies — which tasks wait on which, and which open tasks touch the same files.',
771
767
  members: 'Goal members — who’s on this goal and what they’ve contributed to it.',
772
768
  completion: 'Goal completion — every done-when criterion, with its full detail.',
773
769
  };
@@ -787,7 +783,7 @@
787
783
  if (sub) {
788
784
  sub.textContent = view === 'list'
789
785
  ? 'Shared goals: progress, members, and the work remaining.'
790
- : (SUB_SUBTITLE[view] || 'Goal detail — completion, members, conflicts, and the work queue.');
786
+ : (SUB_SUBTITLE[view] || 'Goal detail — completion, members, dependencies, and the work queue.');
791
787
  }
792
788
  window.scrollTo({ top: 0 });
793
789
  }
@@ -804,14 +800,15 @@
804
800
  if (state.current == null) renderList();
805
801
  return;
806
802
  }
807
- // task 1828 — a goal's dedicated full views (conflicts / members /
803
+ // task 1828 — a goal's dedicated full views (dependencies / members /
808
804
  // completion). renderDetail renders all three full views + kicks
809
805
  // loadGoalTasks, so a cold load straight to one still fills in once data
810
- // lands (member stats / work / conflicts refresh async).
811
- const mSub = /^#\/goal\/(\d+)\/(conflicts|members|completion)$/.exec(hash);
806
+ // lands (member stats / work / conflicts refresh async). /conflicts is the
807
+ // pre-1004738 name, still linked from the attention inbox.
808
+ const mSub = /^#\/goal\/(\d+)\/(dependencies|conflicts|members|completion)$/.exec(hash);
812
809
  if (mSub) {
813
810
  const id = mSub[1];
814
- const view = mSub[2];
811
+ const view = mSub[2] === 'conflicts' ? 'dependencies' : mSub[2];
815
812
  try {
816
813
  await loadDetail(id);
817
814
  setView(id, view);
@@ -999,7 +996,7 @@
999
996
  }
1000
997
  $('#goal-back')?.addEventListener('click', (e) => { e.preventDefault(); window.location.hash = ''; });
1001
998
  // task 1828 — "← Back to goal" from any of the full views.
1002
- ['#goal-conflicts-back', '#goal-members-back', '#goal-completion-back'].forEach((sel) => {
999
+ ['#goal-deps-back', '#goal-members-back', '#goal-completion-back'].forEach((sel) => {
1003
1000
  $(sel)?.addEventListener('click', (e) => {
1004
1001
  e.preventDefault();
1005
1002
  if (state.current != null) window.location.hash = `#/goal/${state.current}`;
@@ -34,10 +34,9 @@
34
34
  // still draws its criteria (state by satisfied/open, no UAT controls).
35
35
  const uatUi = window.OTBCriterionUat || { isAwaiting: () => false, badgesHtml: () => '', actionHtml: () => '' };
36
36
 
37
- const CONFLICT_CAP = 3;
38
- // task 1828 — the band's Completion / Members / Conflicts columns each cap
39
- // their list and offer a "View all →" into a dedicated full view (these grow:
40
- // more members, more criteria). Conflicts uses CONFLICT_CAP above.
37
+ // task 1828 — the band's Completion / Members columns each cap their list and
38
+ // offer a "View all →" into a dedicated full view (these grow: more members,
39
+ // more criteria). The third column is the Dependencies card (task 1004738).
41
40
  const MEMBER_CAP = 5;
42
41
  const CRITERIA_CAP = 4;
43
42
  // task 1932/1956 → kit (task 2171): the goal-card grid + the goal-detail Work
@@ -620,24 +619,15 @@
620
619
  foot.innerHTML = '';
621
620
  }
622
621
  }
622
+ // task 1004738 — the whole conflict set lives on the dependencies view; the
623
+ // band's Dependencies card carries only its count.
623
624
  function renderConflicts() {
624
- const pairs = state.conflictPairs;
625
- const card = $('#goal-conflicts-card');
626
- fillConflicts($('#goal-conflicts'), pairs == null ? null : pairs.slice(0, CONFLICT_CAP));
627
- const total = pairs ? pairs.length : 0;
628
- renderViewAllFoot('#goal-conflicts-foot', total, CONFLICT_CAP, 'conflicts', 'conflicts');
629
- setCount('#goal-conflicts-count', total);
630
- card?.classList.toggle('goal-conflicts-card--live', total > 0);
631
- renderConflictsFull();
632
- }
633
- function renderConflictsFull() {
634
625
  const pairs = state.conflictPairs;
635
626
  const total = pairs ? pairs.length : 0;
636
627
  fillConflicts($('#goal-conflicts-full'), pairs);
637
628
  setCount('#goal-conflicts-view-count', total);
638
629
  $('#goal-conflicts-view-card')?.classList.toggle('goal-conflicts-card--live', total > 0);
639
- const back = $('#goal-conflicts-back');
640
- if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
630
+ forestRender.renderDependencyCard();
641
631
  }
642
632
  // task 1003480 — the per-goal task dependency forest is drawn by its own
643
633
  // module now (goal-forest-render.js, carved out to keep this file under the
@@ -87,10 +87,8 @@
87
87
  .goal-band__col + .goal-band__col { border-top: none; border-left: var(--line) solid var(--rule-soft); }
88
88
  }
89
89
 
90
- /* Live conflicts: the band COLUMN can't own the card border, so it gets a
91
- subtle amber wash; the standalone full-view card keeps the amber border.
92
- (Count pills on these headers are the shared .count-pill.) */
93
- .goal-conflicts-card--live { background: color-mix(in srgb, var(--warn) 6%, transparent); }
90
+ /* Live conflicts: the conflicts card on the dependencies view wears the amber
91
+ border (task 1004738 moved it there from the band). */
94
92
  .goal-conflicts-card--live .count-pill { background: var(--warn-weak); color: var(--warn); }
95
93
  #goal-conflicts-view-card.goal-conflicts-card--live { background: var(--bg-card); border-color: color-mix(in srgb, var(--warn) 42%, transparent); }
96
94
 
@@ -554,3 +552,14 @@
554
552
  .gf-table .gf-deps { margin: 0; padding: 0; list-style: none; min-width: 12rem; }
555
553
  .gf-table .gf-deps li { font-size: 12.5px; line-height: 1.4; }
556
554
  .gf-none { color: var(--ink-faint); }
555
+
556
+ /* task 1004738 — the band's compact Dependencies card: a third of the width,
557
+ so each "ship first" title holds one line and ellipsises (the whole title is
558
+ its tooltip and the full view's table). */
559
+ .gf-brief__h { margin: 10px 0 2px; font-size: 12px; color: var(--ink-faint); }
560
+ .gf-brief { list-style: none; margin: 0; padding: 0; }
561
+ .gf-brief__row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
562
+ .gf-brief__task { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
563
+ .gf-brief__n { flex-shrink: 0; font-size: 12px; color: var(--ink-faint); }
564
+ .gf-brief__conflicts { margin: 10px 0 0; font-size: 13px; color: var(--ink-soft); }
565
+ .gf-brief__conflicts--live { color: var(--warn); font-weight: 600; }
@@ -112,28 +112,18 @@
112
112
  <ul class="member-list" id="goal-members"></ul>
113
113
  <div class="goal-col__foot" id="goal-members-foot" hidden></div>
114
114
  </div>
115
- <div class="goal-band__col" id="goal-conflicts-card">
116
- <h2 class="scroll__h">Conflicts<span class="count-pill" id="goal-conflicts-count" hidden></span></h2>
117
- <p class="scroll__lede">Open tasks whose declared files overlap — sequence these before two builders collide.</p>
118
- <div id="goal-conflicts"></div>
119
- <!-- task 1828 — replaces the show-all pager: click into a dedicated
120
- full-width conflicts view instead of expanding this column. -->
121
- <div class="goal-col__foot" id="goal-conflicts-foot" hidden></div>
115
+ <!-- task 1004738 — the compact Dependencies card takes the slot
116
+ Conflicts held: a summary, the tasks to ship first and the
117
+ conflicts count. The forest, its table and the conflict list
118
+ are one click away on #/goal/N/dependencies. -->
119
+ <div class="goal-band__col" id="goal-deps-card">
120
+ <h2 class="scroll__h">Dependencies<span class="count-pill" id="goal-deps-card-count" hidden></span></h2>
121
+ <p class="scroll__lede" id="goal-deps-card-lede">Reading…</p>
122
+ <div id="goal-deps-card-body"></div>
123
+ <div class="goal-col__foot" id="goal-deps-card-foot" hidden></div>
122
124
  </div>
123
125
  </div>
124
126
 
125
- <!-- task 1003480 (ADR 0227 §6 item 4 + §10.5): the per-goal task
126
- dependency forest. Its own full-width section, not a band
127
- column — a node-link picture needs real width, and (unlike
128
- Completion/Members/Conflicts) this is bounded small BY DESIGN
129
- (ADR 0227 §3's own finding), so there is no "view all" split:
130
- what renders here is the whole forest, every time. -->
131
- <div class="scroll" style="margin-top:20px" id="goal-deps-scroll">
132
- <h2 class="scroll__h" id="goal-deps-h">Task dependencies<span class="count-pill" id="goal-deps-count" hidden></span></h2>
133
- <p class="scroll__lede" id="goal-deps-lede">Reading…</p>
134
- <div id="goal-deps-body"></div>
135
- </div>
136
-
137
127
  <div class="scroll" style="margin-top:20px">
138
128
  <div class="goal-work__head">
139
129
  <h2 class="scroll__h">Tasks</h2>
@@ -188,11 +178,19 @@
188
178
  </div>
189
179
  </section>
190
180
 
191
- <!-- CONFLICTS full view (hash-routed: #/goal/N/conflicts) — the whole
192
- conflict set, opened by the band's "View all" link (task 1828). -->
193
- <section id="goal-conflicts-view" hidden aria-labelledby="goal-conflicts-view-h">
194
- <p class="card-foot" style="margin:0 0 14px"><a href="#" id="goal-conflicts-back">← Back to goal</a></p>
195
- <div class="scroll" style="margin:0" id="goal-conflicts-view-card">
181
+ <!-- DEPENDENCIES full view (hash-routed: #/goal/N/dependencies; the old
182
+ #/goal/N/conflicts lands here too) — task 1004738. The per-goal task
183
+ dependency forest (task 1003480, ADR 0227 §6 item 4 + §10.5) gets
184
+ the page's full width a node-link picture needs, then the whole
185
+ conflict set (task 1828). -->
186
+ <section id="goal-dependencies-view" hidden aria-labelledby="goal-deps-h">
187
+ <p class="card-foot" style="margin:0 0 14px"><a href="#" id="goal-deps-back">← Back to goal</a></p>
188
+ <div class="scroll" style="margin:0" id="goal-deps-scroll">
189
+ <h2 class="scroll__h" id="goal-deps-h">Task dependencies<span class="count-pill" id="goal-deps-count" hidden></span></h2>
190
+ <p class="scroll__lede" id="goal-deps-lede">Reading…</p>
191
+ <div id="goal-deps-body"></div>
192
+ </div>
193
+ <div class="scroll" style="margin-top:20px" id="goal-conflicts-view-card">
196
194
  <h2 class="scroll__h" id="goal-conflicts-view-h">Conflicts<span class="count-pill" id="goal-conflicts-view-count" hidden></span></h2>
197
195
  <p class="scroll__lede">Open tasks whose declared files overlap — sequence these before two builders collide.</p>
198
196
  <div id="goal-conflicts-full"></div>
@@ -122,7 +122,25 @@
122
122
  ],
123
123
  "expect": {
124
124
  "visible": [
125
- "#goal-detail-view"
125
+ "#goal-detail-view",
126
+ "#goal-deps-card"
127
+ ]
128
+ }
129
+ },
130
+ "dependencies": {
131
+ "auth": true,
132
+ "url": "/builders/goals#/goal/25/dependencies",
133
+ "actions": [
134
+ [
135
+ "wait",
136
+ 1200
137
+ ]
138
+ ],
139
+ "expect": {
140
+ "visible": [
141
+ "#goal-dependencies-view",
142
+ "#goal-deps-scroll",
143
+ "#goal-conflicts-view-card"
126
144
  ]
127
145
  }
128
146
  },
@@ -1,9 +1,10 @@
1
- # The per-goal dependency forest — on the Goals page (tasks 1003480, 1004200)
1
+ # The per-goal dependency forest — on the Goals page (tasks 1003480, 1004200, 1004738)
2
2
 
3
3
  **THE PER-GOAL DEPENDENCY FOREST landed on the Goals page (task 1003480, goal 1000089; ADR 0227 §6 item 4 and its §10.5 amendment)** — the drill-down the goal map's own header pointed at (the map is the project sky since task 1004232): a task-level picture of which of THIS goal's tasks depend on which, from `GET /goals/:id/dependency-forest`. `goal-forest-lib.js` was the goal map's sibling — the same layered-layout idea (a task sits one column right of what it depends on, deterministic) at a smaller scale, since a per-goal forest is small BY MEASUREMENT (ADR 0227 §10.5: largest 127 tasks, median 9) rather than needing the goal map's cluster-and-shelf packing. Five things worth knowing:
4
4
 
5
- - **Its own full-width section, not a band column.** The existing Completion / Members / Conflicts band (task 1828) is three narrow columns; a node-link picture needs real width, so "Task dependencies" sits as its own `.scroll` block between the band and Tasks, drawn with `createElementNS` + `textContent` (the atlas idiom, which the retired goal-map.js shared — a task title never becomes markup) inside `.gf-scroll`, which names BOTH overflow axes (task 1004130).
6
- - **No cap, no "view all", unlike its band neighbours.** Completion/Members/Conflicts are per-item lists that can genuinely grow long, so they cap-and-link into a dedicated full view. A dependency forest cannot — ADR 0227 §3's own finding is that the tasks it omits are precisely the ones with nothing to connect them to, so what the server returns is already the honest whole picture, every time.
5
+ - **A compact card on the goal page, the whole picture one click away (task 1004738, owner's call).** The forest first shipped as its own full-width block between the band and Tasks — ~840px for a five-task forest, with a table repeating every task — and the owner called it way too long. Now the band's third column (Conflicts' old slot) is a **Dependencies card**: `brief` (one line), up to three `shipFirst` tasks (open, blocked by nothing open, ranked by how many open tasks wait on them down the chain), the conflicts count, and "See the full picture →". The forest, its table and the whole conflict list live on **`#/goal/N/dependencies`**; `#/goal/N/conflicts` still lands there because the attention inbox links it. Conflicts folded in rather than kept beside it: both answer "what order do we build in".
6
+ - **The full view is never capped.** ADR 0227 §3's finding still holds — the tasks a forest omits are precisely the ones with nothing to connect them to — so the full view draws everything the server returns; only the card summarises. The figure is drawn with `createElementNS` + `textContent` (the atlas idiom — a task title never becomes markup) inside `.gf-scroll`, which names BOTH overflow axes (task 1004130), and it is drawn only while its view is open (`state.view === 'dependencies'`): the goal page shows just the card, so drawing there would be thrown away, and a forest drawn hidden cannot measure its titles.
7
+ - **The card reads two feeds.** It renders from `state.forest` AND `state.conflictPairs`, so `renderConflicts` in `goals-render.js` calls `renderDependencyCard` again when the conflicts land; a null `conflictPairs` (loading, or a failed read) draws no conflicts line rather than a zero.
7
8
  - **The states are three, and distinct on purpose:** "Reading…" while the request is in flight, "No task in this goal depends on another task in this goal" for a real empty forest (never a blank canvas), and "Couldn't load…" only for an actual failure — a redirecting-to-auth error is swallowed silently instead, the `loadGoalTasks` precedent, because the page is already navigating away.
8
9
  - **`loadDependencyForest`/`renderDependencyForest` are exported from `createGoalsRender`'s return** specifically so `tests/goal_forest_page.mjs` can call them directly against a minimal DOM stub, without needing the much larger fixture a full `renderDetail()` boot would need for members/completion/invites this task never touches.
9
10
  - **The harness fixture is a single generic `goals__dependency-forest.json`** (task ids real, drawn from goal 25's own rows in `tasks.json`) — every goal id in the harness sees the same forest, which is fine for looking at the picture and not meant to describe any goal's real dependencies.
@@ -18,7 +18,7 @@
18
18
  // .owner_github_id` and `platform_identity_projects.owner_github_id` are ATTRIBUTION, not
19
19
  // authority — their own migration says so and ADR 0238 restates it. The authority is
20
20
  // `provisioning_instances.owner_builder_id`, the fact the manage view already trusts and
21
- // `instanceAccessDenied` already enforces. This module may not import provisioning (ADR 0083's
21
+ // `requireProjectOwner` already enforces. This module may not import provisioning (ADR 0083's
22
22
  // one-way rule), so the fact travels on the kernel port `provisioning.projectOwnedBy` —
23
23
  // resolveOptional and NOT declared in `consumes` (the `provisioning.projectPlanets`
24
24
  // precedent): provisioning is default-off, and a hub running without it simply has no
@@ -12,7 +12,7 @@
12
12
  // re-pointed address), which is the property a once-at-standup write can never have.
13
13
  //
14
14
  // IT GRANTS NOTHING. The catalog row is attribution (ADR 0238): ownership stays
15
- // `provisioning_instances.owner_builder_id`, which is what `instanceAccessDenied` already
15
+ // `provisioning_instances.owner_builder_id`, which is what `requireProjectOwner` already
16
16
  // enforces on every route that calls this. The projection carries the owner's github id only
17
17
  // so the hub can attribute the row and claim-check its own writes.
18
18
  'use strict';
@@ -1402,7 +1402,7 @@ async function instanceStateForSlug(db, slug) {
1402
1402
  // control-plane project a hub catalog row names? Lets a hub surface own-scope a project it
1403
1403
  // did not create — without importing this module (ADR 0083) and without promoting the hub's
1404
1404
  // own `owner_github_id` columns, which are attribution and never authority (ADR 0238). The
1405
- // fact returned is the one `instanceAccessDenied` already enforces on every owner-facing
1405
+ // fact returned is the one `requireProjectOwner` already enforces on every owner-facing
1406
1406
  // route here: `provisioning_instances.owner_builder_id`.
1407
1407
  //
1408
1408
  // BOTH HALVES OF THE PROJECTION MUST AGREE, which is why this takes a slug AND a host rather
@@ -0,0 +1,50 @@
1
+ // modules/provisioning/route-helpers.js — the one owner-or-archon gate every own-scoped
2
+ // /provisioning/instances/:id route mounts (task 1004159, ADR 0328 decision 2). It replaced
3
+ // three hand-written copies of the same rule, and the three routes that had only the owner
4
+ // half of it (teardown, domain, github-app).
5
+ 'use strict';
6
+
7
+ const api = require('../../src/module-api');
8
+ const { failFrom } = require('./public-refusal');
9
+
10
+ const log = api.logger('provisioning');
11
+
12
+ function isProjectOwner(inst, builder) {
13
+ return String(inst.owner_builder_id) === String(builder.id);
14
+ }
15
+
16
+ // The audit actor for a request the gate admitted: the owner acts as themselves, and an
17
+ // archon on someone else's project is named, so the override never reads as the owner's act.
18
+ function projectActor(req) {
19
+ return isProjectOwner(req.project, req.builder) ? 'api:self' : `api:archon:${req.builder.id}`;
20
+ }
21
+
22
+ // requireProjectOwner(loadFn, { archonOverride }) — mounted after requireBuilder, and spelled
23
+ // in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4). Loads
24
+ // the row with the caller's loader (`loadFn(id)`) and admits its owner or, by default, an
25
+ // archon. That is a plain RANK test on purpose: `provisioning.fleet.manage` floors at metic
26
+ // (ADR 0157), so gating on it would widen every route it guards.
27
+ //
28
+ // A missing row and someone else's row are the same 404, and the gate runs before the
29
+ // handler's body validator, so a stranger cannot tell a real id from a malformed request.
30
+ // The loaded row rides on `req.project`.
31
+ function requireProjectOwner(loadFn, { archonOverride = true } = {}) {
32
+ return async function projectOwnerGate(req, res, next) {
33
+ const id = api.parseId(req, res); // sends 400 bad_id + returns null on failure
34
+ if (!id) return;
35
+ let inst;
36
+ try {
37
+ inst = await loadFn(id);
38
+ } catch (err) {
39
+ log.error(`[provisioning] ${req.method} ${req.path} — project load`, err);
40
+ return failFrom(res, err, 'instance_read_failed');
41
+ }
42
+ const admitted = !!inst
43
+ && (isProjectOwner(inst, req.builder) || (archonOverride && req.builder.rank === 'archon'));
44
+ if (!admitted) return res.fail('instance_not_found', 404);
45
+ req.project = inst;
46
+ return next();
47
+ };
48
+ }
49
+
50
+ module.exports = { requireProjectOwner, projectActor, isProjectOwner };
@@ -12,8 +12,10 @@ const { pool, validateOrRespond, parseId } = api;
12
12
  const provisioning = require('../provisioning');
13
13
  const disconnect = require('../disconnect');
14
14
  const { failFrom } = require('../public-refusal');
15
+ const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers');
15
16
 
16
17
  const log = api.logger('provisioning');
18
+ const loadInstance = (id) => provisioning.getInstanceById(pool, id);
17
19
 
18
20
  module.exports = function buildDisconnectRouter() {
19
21
  const router = express.Router();
@@ -24,19 +26,18 @@ module.exports = function buildDisconnectRouter() {
24
26
  // this erases a database, so a replayed or scripted request must carry the same proof
25
27
  // the button does. No status gate — any state may be disconnected; the queue refuses
26
28
  // while another run is open (disconnect.enqueueDisconnect).
27
- router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, async (req, res) => {
29
+ router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
28
30
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
29
31
  if (!id) return;
30
32
  if (validateOrRespond(req, res, { confirm_slug: { required: true, type: 'string', maxLength: 40 } })) return;
31
33
  try {
32
- const inst = await provisioning.getInstanceById(pool, id);
33
- const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
34
- if (!inst || (!isOwner && req.builder.rank !== 'archon')) return res.fail('instance_not_found', 404);
34
+ const inst = req.project;
35
+ const isOwner = isProjectOwner(inst, req.builder);
35
36
  if (String(req.body.confirm_slug).trim().toLowerCase() !== inst.slug) {
36
37
  return res.fail('confirm_mismatch', { status: 400,
37
38
  message: `Type the project's name exactly — ${inst.slug} — to disconnect it. Nothing was changed.` });
38
39
  }
39
- const actor = isOwner ? 'api:self' : `api:archon:${req.builder.id}`;
40
+ const actor = projectActor(req);
40
41
  const r = await disconnect.enqueueDisconnect(pool, id, actor);
41
42
  if (r.conflict) {
42
43
  return res.fail('intent_conflict', { status: 409,
@@ -21,26 +21,14 @@
21
21
 
22
22
  const express = require('express');
23
23
  const api = require('../../../src/module-api');
24
- const { pool, validateOrRespond, parseId } = api;
24
+ const { pool, validateOrRespond } = api;
25
25
  const provisioning = require('../provisioning');
26
26
  const envManifest = require('../env-manifest');
27
27
  const { failFrom } = require('../public-refusal');
28
+ const { requireProjectOwner } = require('../route-helpers');
28
29
 
29
30
  const log = api.logger('provisioning');
30
-
31
- // Resolve the project and decide whether this caller may see it. Returns the instance, or
32
- // null having already sent the 404 — the same own-or-archon rule as ./disconnect.js.
33
- async function resolveOwnInstance(req, res) {
34
- const id = parseId(req, res); // sends 400 bad_id + returns null on failure
35
- if (!id) return null;
36
- const inst = await provisioning.getInstanceById(pool, id);
37
- const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
38
- if (!inst || (!isOwner && req.builder.rank !== 'archon')) {
39
- res.fail('instance_not_found', 404);
40
- return null;
41
- }
42
- return inst;
43
- }
31
+ const loadInstance = (id) => provisioning.getInstanceById(pool, id);
44
32
 
45
33
  // The `:varId` path segment. parseId reads `:id`, which is the PROJECT here, so the
46
34
  // declaration's id is parsed separately. A non-numeric id is a 400 naming the field
@@ -103,10 +91,9 @@ module.exports = function buildEnvManifestRouter() {
103
91
 
104
92
  // GET — the declared surface. This is the route a BUILDER SESSION reads to learn which
105
93
  // variables a project has, which is the whole reason the manifest exists.
106
- router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
94
+ router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
107
95
  try {
108
- const inst = await resolveOwnInstance(req, res);
109
- if (!inst) return;
96
+ const inst = req.project;
110
97
  const vars = await envManifest.listEnvVars(pool, inst.id);
111
98
  res.json({
112
99
  instance: { id: String(inst.id), slug: inst.slug, hosting_shape: inst.hosting_shape },
@@ -126,7 +113,7 @@ module.exports = function buildEnvManifestRouter() {
126
113
 
127
114
  // POST — declare a variable, or re-declare one that exists (an edit, not a duplicate:
128
115
  // see ../env-manifest.js's declareEnvVar).
129
- router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
116
+ router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
130
117
  if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
131
118
  if (validateOrRespond(req, res, {
132
119
  name: { required: true, type: 'string', maxLength: envManifest.ENV_NAME_MAX },
@@ -135,8 +122,7 @@ module.exports = function buildEnvManifestRouter() {
135
122
  required: { type: 'boolean' },
136
123
  })) return;
137
124
  try {
138
- const inst = await resolveOwnInstance(req, res);
139
- if (!inst) return;
125
+ const inst = req.project;
140
126
  if (badDeclaration(req, res, { nameRequired: true })) return;
141
127
  const { row, created } = await envManifest.declareEnvVar(pool, {
142
128
  instanceId: inst.id,
@@ -154,7 +140,7 @@ module.exports = function buildEnvManifestRouter() {
154
140
 
155
141
  // PATCH — edit one declaration. Merge-patch: an omitted field is left alone, so the
156
142
  // hall can fix a description without restating the environments.
157
- router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
143
+ router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
158
144
  if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
159
145
  if (validateOrRespond(req, res, {
160
146
  name: { type: 'string', maxLength: envManifest.ENV_NAME_MAX },
@@ -163,8 +149,7 @@ module.exports = function buildEnvManifestRouter() {
163
149
  required: { type: 'boolean' },
164
150
  })) return;
165
151
  try {
166
- const inst = await resolveOwnInstance(req, res);
167
- if (!inst) return;
152
+ const inst = req.project;
168
153
  const varId = parseVarId(req, res);
169
154
  if (!varId) return;
170
155
  if (badDeclaration(req, res, { nameRequired: false })) return;
@@ -182,10 +167,9 @@ module.exports = function buildEnvManifestRouter() {
182
167
  // DELETE — remove one declaration. Nothing outside this table is affected: the value,
183
168
  // wherever it lives, is untouched, and the response says so rather than letting an
184
169
  // owner believe removing the name removed the secret.
185
- router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
170
+ router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
186
171
  try {
187
- const inst = await resolveOwnInstance(req, res);
188
- if (!inst) return;
172
+ const inst = req.project;
189
173
  const varId = parseVarId(req, res);
190
174
  if (!varId) return;
191
175
  const removed = await envManifest.removeEnvVar(pool, inst.id, varId);