|
| 1 | +# ADR-0013 — Admin: build/security signals, an honest Reach panel, and a pagination fix |
| 2 | + |
| 3 | +- **Status:** Accepted |
| 4 | +- **Date:** 2026-07-28 |
| 5 | +- **Deciders:** agentic-kit maintainers |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +ADR-0007 shipped `ak x admin` as a loopback, deliberate-egress maintainer console answering "how is |
| 10 | +this project actually doing" from GitHub + npm. A follow-up audit (live queries against the real |
| 11 | +`pacphi/agentic-kit` repo, with the same token the admin server itself resolves) found the page |
| 12 | +answered that question less completely than it could: |
| 13 | + |
| 14 | +1. **Two of the four Reach hero tiles were permanently dead for this project.** "bundle downloads" |
| 15 | + and "newest release pulls" read from GitHub release **assets**. agentic-kit ships exclusively via |
| 16 | + npm — verified live, 0 of 29 releases carry any asset — so both tiles rendered `—` forever. That |
| 17 | + is correct behavior per the model's "unknown is not zero" rule (ADR-0007 §4.1), not a bug, but it |
| 18 | + is dead screen real estate for this project's actual distribution channel. |
| 19 | +2. **`admin-collect.mjs` capped the releases fetch at `per_page=20`** while the "bundle downloads" |
| 20 | + tile's own copy claimed "lifetime, **all releases**." The repo already has 29 releases; the oldest |
| 21 | + 9 were silently dropped from both the releases list and `totalAssetDownloads`. Harmless today only |
| 22 | + because no release carries assets — the moment one does, the label becomes false with no |
| 23 | + truncation indicator. |
| 24 | +3. **No CI/build health or security-alert signal reached the page at all**, despite `ci`/`release` |
| 25 | + GitHub Actions workflows running on every push and Dependabot maintaining the dependency tree — |
| 26 | + both directly answer "how is this project doing" and both were one more `Promise.all` entry away. |
| 27 | +4. **Two already-collected fields were invisible in the UI**: `repo.watchers` (GitHub subscriber |
| 28 | + count) was computed in the collector and never rendered; `npm.lastWeek`/`npm.lastMonth` were |
| 29 | + computed and only ever consumed as a trend-arrow input in Momentum, never shown as an absolute |
| 30 | + number anywhere. |
| 31 | +5. On point 4's npm figures specifically: `admin-view.mjs` already carried a **deliberate** decision |
| 32 | + to keep npm's absolute download count out of the Reach panel, because `npm downloads are dominated |
| 33 | + by mirrors` (CI runners, cache warmers, and registry mirrors re-pull as often as a real install, |
| 34 | + and `api.npmjs.org` cannot distinguish them). That rationale was correct but stated in one |
| 35 | + half-sentence buried in a footnote — worth stating as its own first-class, explicit entry so a |
| 36 | + maintainer reading the Gaps tab understands *why* npm never appears as a reach number, not just |
| 37 | + that it doesn't. |
| 38 | + |
| 39 | +## Decision |
| 40 | + |
| 41 | +### 1. Fix the releases pagination bug |
| 42 | + |
| 43 | +`admin-collect.mjs` now fetches `/releases?per_page=100` (GitHub's per-request ceiling), matching the |
| 44 | +same single-page-best-effort cap already used for issues/stargazers/forks in this file. This is a |
| 45 | +correctness fix, not a new pagination system — the file's existing house pattern (one page, capped at |
| 46 | +100, no further pages followed) is extended consistently rather than reinvented. The 100-item ceiling |
| 47 | +is now called out in a code comment at the fan-out site so a future reader does not mistake it for |
| 48 | +full pagination. |
| 49 | + |
| 50 | +### 2. Replace the two dead Reach tiles with real, GitHub-native people signals |
| 51 | + |
| 52 | +"bundle downloads" and "newest release pulls" are replaced with: |
| 53 | + |
| 54 | +- **contributors** — `GET /repos/{slug}/contributors`, count only. A genuinely different circle from |
| 55 | + "people who filed issues/PRs" (buildPeople's `people.contributors`): this counts who has code in |
| 56 | + the tree via GitHub's own merge history. |
| 57 | +- **watching** — `repo.watchers` (already collected, previously unrendered). A standing-interest |
| 58 | + signal distinct from a one-time visitor. |
| 59 | + |
| 60 | +Both stay inside GitHub's own numbers, so neither reopens the mirror-inflation problem npm carries. |
| 61 | +The npm absolute-download question raised by point 4 was decided explicitly: **npm downloads still do |
| 62 | +not appear in Reach.** The existing exclusion was correct; only the messaging around it was thin (see |
| 63 | +Decision 4). |
| 64 | + |
| 65 | +### 3. A new "Project health" section: latest CI run + open Dependabot alerts |
| 66 | + |
| 67 | +A new subsection in the Overview panel, fed by two more `Promise.all` entries: |
| 68 | + |
| 69 | +- `GET /repos/{slug}/actions/runs?per_page=5` → latest run's `{name, status, conclusion, at, url}` |
| 70 | + plus a failure count over the fetched page. No runs found renders `—`, never a fabricated |
| 71 | + "passing" (the same unknown-is-not-zero discipline as everywhere else in this file). |
| 72 | +- `GET /repos/{slug}/dependabot/alerts?state=open&per_page=100` → open alert count. This endpoint |
| 73 | + needs a token with `security_events` scope (classic PAT) or equivalent fine-grained access; an |
| 74 | + unconfigured or under-scoped token 403s, which `ghJson` already folds to `null` — the tile |
| 75 | + degrades to "unknown" with an explanatory `why`, never a false "0 alerts." |
| 76 | + |
| 77 | +Both are additive `Promise.all` entries in the existing single fan-out (ADR-0007 §3) — no new |
| 78 | +request pattern, no new auth flow, no new caching layer. |
| 79 | + |
| 80 | +### 4. Make the npm mirror-inflation rationale a first-class, explicit statement |
| 81 | + |
| 82 | +The Gaps tab (ADR-0007's "honesty section") gains a `design`-tagged entry stating plainly that npm |
| 83 | +download counts are shown as **trend only, never as a reach number**, and why: mirrors/CI/cache |
| 84 | +warmers are indistinguishable from real installs in npm's own data, so an absolute total would |
| 85 | +overstate reach. The Reach panel's footer note and the Momentum tile's note are both tightened to |
| 86 | +carry the same point concisely rather than as an aside. |
| 87 | + |
| 88 | +## Consequences |
| 89 | + |
| 90 | +- `admin-collect.mjs`'s single `Promise.all` grows from 9 to 12 entries: `contributors`, |
| 91 | + `actions/runs`, `dependabot/alerts` join the existing set. All three follow the file's existing |
| 92 | + discipline — internally try/caught, degrade to `null`/empty on any failure, never reject the batch, |
| 93 | + never appear in the payload if the credential is absent from the request itself (contributors and |
| 94 | + CI runs work unauthenticated on a public repo; Dependabot alerts do not and degrade honestly). |
| 95 | +- The payload contract gains `contributorsCount` (number|null), `ci: {latest, recentFailures, |
| 96 | + recentTotal}`, and `security: {dependabotAlerts}`. `defaultCollect`'s fail-soft path returns honest |
| 97 | + empty values for all three so a thrown collector error still yields a renderable payload. |
| 98 | +- The Reach panel's four tiles are now: unique repo visitors (hero), contributors, watching, opted-in |
| 99 | + installs (still an honest gap) — all either real headcounts or an explicit "not built" admission, |
| 100 | + none permanently dead for this project's npm-only distribution model. |
| 101 | +- `tests/admin.test.cjs` gained fixtures and assertions for the three new endpoints, including the |
| 102 | + same never-fabricate-a-zero discipline tested elsewhere (Dependabot 403 → `null`, no workflow runs |
| 103 | + → `ci.latest: null`, a failing contributors fetch nulls only `contributorsCount`) and an explicit |
| 104 | + assertion that the releases fetch requests `per_page=100`. |
| 105 | +- `admin-model.mjs` is unchanged — every new field renders through the existing `metric()` helper, so |
| 106 | + the pure-model import-nothing constraint (ADR-0007 §5) is untouched. |
| 107 | + |
| 108 | +## Alternatives considered |
| 109 | + |
| 110 | +- **Show npm absolute downloads as a Reach tile instead of GitHub-native signals.** Rejected: it |
| 111 | + would reintroduce exactly the mirror-inflation overstatement the original design deliberately |
| 112 | + avoided (Context, point 5). GitHub-native signals (contributors, watchers) answer the same "kill |
| 113 | + the dead tiles" goal without that risk. |
| 114 | +- **Full pagination (follow `Link` headers) for releases/issues/stargazers/forks/contributors.** |
| 115 | + Rejected for now: none of these currently exceed the 100-item cap for this repo, and full |
| 116 | + pagination is a larger, inconsistent change against the file's established one-page-best-effort |
| 117 | + house pattern. The 100-item ceiling is now documented in code rather than silently assumed; revisit |
| 118 | + if/when any list genuinely exceeds it. |
| 119 | +- **Cache GitHub/npm responses between polls (ETag/`If-None-Match`) to cut request volume on |
| 120 | + auto-refresh.** Out of scope for this ADR — freshness-over-caching is ADR-0007's explicit contract |
| 121 | + (`Cache-Control: no-store` on every `/api/*` response), and today's request volume is nowhere near |
| 122 | + GitHub's rate ceiling. Worth its own ADR if auto-refresh usage patterns change that calculus. |
| 123 | + |
| 124 | +## References |
| 125 | + |
| 126 | +- ADR-0007 (maintainer admin: loopback telemetry with deliberate egress) — this ADR extends its |
| 127 | + collector/payload/Reach-panel decisions rather than superseding them. |
| 128 | +- `src/lib/admin-collect.mjs`, `src/lib/admin-view.mjs`, `src/lib/admin-server.mjs`, |
| 129 | + `src/lib/admin-styles.mjs`, `tests/admin.test.cjs`. |
0 commit comments