RFC 0004 — Admin composition, and the API surface the console is missing
| Field | Value |
|---|---|
| Status | Implemented — all five phases landed. Phase 5 delivered fifteen verdicts and executed all fifteen; the three it only partly discharged were finished by RFC 0004-bis, which records them in its §2.5 |
| Short | Admin composition |
| Settles | The API the console was missing |
| Author | Max Batleforc maxleriche.60@gmail.com |
| Co-author | Claude Opus 5 (1M context) noreply@anthropic.com |
| Created | 2026-08-12 |
| Supersedes | — |
| Complements | RFC 0003 (web console redesign) — takes over its open questions 2 and 4 |
| Touches | crates/core, crates/adapters, crates/web, server, ui, docs |
1. Summary
RFC 0003 rebuilt the console and left four things behind, three of them because they are not frontend problems at all. This RFC takes them together, because they are one problem seen from different ends: the console cannot show what the API does not describe or does not expose.
- The admin pages are laid out, not composed. Their grammar is now correct and measured at every commit — faces, both ramps, the palette, tracking, contrast, one Display-step title per view. What no gate can judge is whether a page is well cut, and three of the fifteen are over 550 lines. That judgement is editorial, so this RFC hands it to the tool that made the world it is judged against: every admin page goes through Impeccable (§4.4), with the authority to split, merge, add or remove a page — bounded by
DESIGN.md, which does not change. - 73 % of the API's documented
200responses declare no body. 126 of them say onlydescription; 47 declare a schema. The console pays for this directly: six of the twenty-one endpoints it calls are untyped, soui/src/lib/registry-types.tsre-declares their DTOs by hand. AdminDashboardcannot answer its own question.StatsResponsecounters are namedsince_startupand reset with the process, so "is the cache saving me anything" has no answer spanning a restart, and "is it getting better or worse" has no answer at all./has room for the two things a developer actually arrives with — am I near my quota and is anything I just pulled known-vulnerable — and the data for both already exists server-side with no read path for the person it concerns.
Before / after
# today
/ identity-aware, but static: counts, and a link
/admin/* 15 pages, 5 205 lines; grammar correct, composition inherited
GET /api/v1/admin/* 126 × "200, description" ← the client types these `unknown`
47 × "200, description, body = T"
GET /api/v1/admin/quota/{registry}/{user_id} ← admin-only: I cannot see my own
StatsResponse.since_startup ← resets on deploy
# with this RFC
/ + quota meter (mine), + advisories on what I recently pulled
/admin/* Impeccable pass per page: kept, updated, split, merged, removed or added
GET /api/v1/… every 200 declares a body; a CI gate refuses new ones that do not
GET /api/v1/me/quota my usage against my limits, per registry
GET /api/v1/me/downloads what I pulled, most recent first
GET /api/v1/me/advisories findings for the coordinates I pulled
GET /api/v1/admin/stats/history?window=30d persisted, survives a restart2. Motivation
2.1 Composition is the last thing RFC 0003 could not measure
RFC 0003 §13 closes with the honest limit: "whether a page's layout is well cut is not something a ramp check can answer, and no gate here claims to." The measurements it does make are all green. What is left is editorial, and it is visible in the line counts:
| Page | Lines |
|---|---|
AdminPackages.vue | 716 |
AdminConfigReload.vue | 632 |
AdminNotifications.vue | 566 |
| …11 others | 91–405 |
| Total | 5 205 |
Phase 6 of RFC 0003 split AdminConfigReload's read-only path into its own screen and replaced AdminPackages's three window.confirm() calls — both real improvements, both surgical. Neither page was re-cut around the question it answers, and AdminNotifications was never touched: it carries channels, subscriptions and inbound events on one page with a hand-rolled tab strip.
Surgical is what a hand-written phase plan produces: it fixes what was listed, and lists only what someone already noticed. The console's world was not designed that way — RFC 0003 Phase 1 built it with Impeccable and documented the result in DESIGN.md (RFC 0003 R5). Reviewing that world with a different instrument than the one that built it is what leaves fifteen pages grammatically correct and compositionally inherited. §4.4 puts the same instrument back on the pages.
2.2 The API describes 27 % of what it returns
Counted over crates/web/src/handlers/:
126 (status = 200, description = "…") ← no schema
47 (status = 200, description = "…", body = T)This is not a missing-annotation problem — the handlers are annotated with utoipa::path, and the DTOs already derive ToSchema. BlockedIpDto is the worked example: it derives ToSchema, the handler returns Vec<BlockedIpDto>, and the response declares (status = 200, description = "List of blocked IPs") with no body. The fix per endpoint is one clause.
The cost is concrete and already being paid:
ui/openapi.jsondocuments no body, so@hey-api/openapi-tsemitsunknown.ui/src/lib/registry-types.tsre-declares four of those DTOs by hand, each carrying the comment "SDK response is untyped". A hand-written mirror of a contract is a contract that can drift silently, and nothing checks it against the server.- RFC 0003's
fixtures.test.tspins the count at six of the twenty-one endpoints the console calls. The console is a small consumer; the docs site's API reference is generated from the same spec and is equally blank.
2.3 The dashboard's numbers cannot outlive a deploy
StatsResponse is:
pub struct StatsResponse {
/// When this process started (counters reset on restart).
pub since_startup: DateTime<Utc>,
pub aggregate: AggregateStats,
pub per_registry: Vec<RegistryStatsDto>,
}The doc comment states the defect plainly. RFC 0003 §6.4 rebuilt this page around the operator's two real questions — is it healthy, is it saving me anything — and the second is answered with a number that a restart sets to zero. "Is the hit rate improving since we added the warming job" is not answerable at all. RFC 0003 listed this as open question 2 and deferred it here on purpose: it is an API change, not a layout change.
2.4 The two things a developer wants on / are the two the API hides from them
- Quota.
QuotaConfigcarriesmax_storage_bytes_per_user,max_packages_per_userandwarn_threshold_pct(default 80). The server computes usage and can already emit a warning header. But the only read paths are/api/v1/admin/quota,/api/v1/admin/quota/{registry}and/api/v1/admin/quota/{registry}/{user_id}— all admin-only. A developer cannot see their own usage until a publish fails with 429. - Advisories.
VulnerabilityRepository::list_for_coordinate(registry, name, version)exists and is persisted (crates/adapters/src/db/vulnerability.rs). The only HTTP surfaces over vulnerabilities are proxy passthroughs — the NuGet vulnerability index and the security-advisories route — which serve upstream data to package managers, not this instance's findings to a person. - What was pulled.
AccessAction::Downloadis recorded per request with aPackageIdand a principal. The only reader is/api/v1/admin/audit-log, admin-only.
All three widgets are therefore read-path work, not storage work. The one exception is §2.3, which needs persistence that does not exist.
3. Goals / non-goals
Goals
- Every
200in the OpenAPI document declares a body, and a CI gate refuses new ones that do not. - Make the generated client sufficient, so no response DTO is re-declared by hand in
ui/. - Cache statistics that survive a restart, with enough history to answer "better or worse".
- A user can see their own quota and their own recent pulls, and be told when one of those pulls has a known advisory — without an admin.
- Every admin page is put through an Impeccable pass against one question, and comes out kept, updated, split, merged, removed or added on that verdict — with the pages over 550 lines the obvious starting candidates, not the whole list.
- The pass has full authority over a page's composition and its interaction detail, and none at all over the design language:
DESIGN.mdis the bound, and it is unchanged by this RFC.
Non-goals
- A new scanner, or new vulnerability data. This exposes findings the SBOM re-scan already produces (
docs/security-scanning.md). If the data is thin, that is a scanning problem. - A metrics/observability replacement.
/metricsand the Prometheus stack stay the operator's time series, and this RFC neither reshapes what they emit nor moves the console onto them. §2.3 adds the small persisted rollup the console needs, not a TSDB. Adding an on/off switch for the exporter (§6.4) is not a replacement — it is the control that was missing. - Changing the design system. RFC 0003's DESIGN.md is the authority; this RFC composes within it and adds no colour, face or step. The bound holds for the Impeccable pass as much as for a hand-written commit: it may re-cut a page, not the world. A pass that concludes the world itself is wrong produces a finding for a future RFC, not a token.
- Reworking the catalog or package pages. They were re-cut in RFC 0003 Phase 5.
- Per-user rate limiting or quota enforcement changes. Enforcement is unchanged; only the read path is added.
4. User-facing design
4.1 The API contract
Every documented response declares its body. Mechanically:
// before
responses(
(status = 200, description = "List of blocked IPs"),
(status = 403, description = "Admin role required"),
),
// after
responses(
(status = 200, description = "List of blocked IPs", body = Vec<BlockedIpDto>),
(status = 403, description = "Admin role required"),
),Two consequences the RFC treats as requirements rather than side effects:
ui/src/lib/registry-types.tsloses its four hand-written DTOs to the generated ones. Its other two exports — theVisibilityunion andVISIBILITY_OPTIONS, which are UI configuration rather than a mirror of a response — move tosrc/config/, where that kind of data already lives. Any field the hand-written version has that the generated one does not is a finding about the server, recorded, not papered over.fixtures.test.ts's undocumented count (RFC 0003 §13, pinned at 6) becomes0and the assertion flips from a ceiling to an equality.
4.2 What a developer sees on /
Two widgets, on the identity-aware home RFC 0003 §4.3 built. Both are absent for an anonymous viewer, because both are about you.
| Widget | Shows | Empty state |
|---|---|---|
| Quota | Per registry that has a quota: bytes used against the limit, versions used against the limit, and which threshold has been crossed. | Registries without a quota are not listed. If none has one, the widget does not render — an empty meter is worse than no meter. |
| Advisories | Findings on the 5 most recent coordinates you pulled in the last 7 days, and on every package you own, with the highest severity per coordinate and a link to the package page. The two are labelled, because they are different relationships: one you are exposed to, the other you can fix. | "Nothing you pulled recently, and nothing you own, has a known advisory" — a real answer, not a blank. |
The quota widget is a meter, not a number: the useful fact is the distance to the limit, and warn_threshold_pct already defines when that distance is worth colouring. The colour is --copper — "waiting rather than refused" in DESIGN.md's One Synthetic Rule — and crimson only once the limit is reached, because at that point a publish is refused.
4.3 What an operator sees on AdminDashboard
The verdict sentence RFC 0003 §6.4 introduced stays first. Below it, the hit rate gains a trend: the same number over the retained window, and the delta against the previous window. The wording follows the same rule as the verdict — a sentence a reader can act on, not a sparkline that only says "something changed".
Restart no longer resets what is shown. since_startup remains in the payload, because "counters since this process started" is still the honest label for the live counters; the history is a separate, explicitly-dated series.
4.4 Admin composition, run through Impeccable
Each page answers one question and is cut to it. Every one of the fifteen goes through an Impeccable pass — the skill is already installed and already knows this world, because it wrote it (RFC 0003 Phase 1, R5). The admin surface is Operate mode in Impeccable's terms: the visitor is completing a task, so scanability, consistency and native expectations outrank expression, and the brand lives in the detail rather than the gesture. That is the right lens for these pages, and it is not the lens a "make it look better" pass would use.
The pass has real authority. It is not a list of pre-approved edits: it may re-cut a page's composition, its information architecture, its interaction detail, its copy and its empty and error states. What it may not do is change the design language — faces, ramps, palette, spacing steps, motion — which is DESIGN.md's, and unchanged (§3, R4). Inside that bound its verdict is final; outside it, its verdict is a finding.
The verdict per page
Each page is reviewed against one question — what is the one thing an operator came here to do — and comes out with exactly one of six verdicts:
| Verdict | Meaning | Evidence required |
|---|---|---|
| Keep | The page answers one question and nothing on it fails to serve that. | The question, written down. A page kept with no stated question has not been reviewed. |
| Update | Right question, wrong composition — hierarchy, density, order, copy, states. | Before/after of what an operator can now see or do faster. |
| Split | The page answers two questions that do not depend on each other. | The dependency argument, as in R8: what one half reads that the other produces. If they share state, they share a route. |
| Merge | Two pages answer one question and force a navigation to complete one task. | The task that currently crosses a route boundary. |
| Remove | The page answers a question nobody arrives with, or one another page already answers. | Where the answer lives instead, plus a LEGACY_REDIRECTS entry (RFC 0003 §6.3) — a removed route redirects, it does not 404. |
| Add | A question operators arrive with has no page. | The question, and the data path that answers it — if the API cannot answer it, the finding belongs in §11 as a new row, not in a page. |
The three heaviest pages already have seams visible from outside, and they are the pass's starting hypothesis, not its conclusion — the review may cut them differently, or not at all, provided the verdict carries its evidence:
| Page | Question | Hypothesis |
|---|---|---|
AdminPackages (716) | "What is in this instance, and what should not be?" | The block/unblock form is a different job from the package list. The list keeps the page; blocking becomes a dialog opened from it, as DestructiveConfirm already is. |
AdminConfigReload (632) | "What is about to change, and do I accept it?" | Editor, validation report, pending diff and change history are four screens sharing one scroll. The diff and the history are the operator's decision surface; the editor is a tool. |
AdminNotifications (566) | "Where do events go, and what arrives?" | Three nouns behind a hand-rolled tab strip. Split by what actually depends on what (R8): inbound events becomes its own route, since it reads nothing the others produce; channels and subscriptions stay one route, because the subscription form's channel datalist is populated from the channel list. Routed either way, so both deep-link. |
The other twelve get the same review with no hypothesis handed to them, which is the point: three pages were noticed because they are long, and length is a symptom, not the diagnosis. A 91-line page that answers the wrong question is worse than a 400-line page that answers the right one.
How the pass runs
Per page, in one commit, so each verdict is reviewable on its own (§12 Phase 5):
/impeccable critique <route>against the rendered page — the review, scored, with the page's one question stated. This is what produces the verdict above.- The refine command the verdict calls for, with the page's own tests as the regression signal:
distillfor a page carrying more than its question,layoutfor hierarchy and rhythm,clarifyfor labels and error copy,hardenfor edge cases and empty states,adaptwhere the mobile tab-strip or a wide table is the actual complaint. - The RFC 0003 gates, unchanged and non-negotiable:
task impeccable:detectclean of non-advisory findings, the rendered detector pass at both viewports, axe over the unauthenticated routes, and the authenticated route/role matrix — extended, not bypassed, by any route this pass adds or splits (§10).
A verdict the gates contradict is not a verdict. That ordering is deliberate: the tool has authority over composition precisely because the measurements that bound it are automated and run after.
5. Architecture
5.1 What exists, and what has to be built
Three of the four new endpoints are read paths over data that is already there. Only the stats history adds storage, and it is the smallest thing that answers the question: a periodic rollup row per registry, not per-request retention.
5.2 Why a rollup and not the access log
The access log already holds every download, so "hit rate over 30 days" could in principle be derived from it. It should not be:
- The access log is an audit trail with its own retention and purge semantics (
AccessAction::AuditPurge). Deriving operational charts from it couples two lifetimes that must stay separable — purging the audit trail would silently rewrite history on a dashboard. - A hit/miss ratio is a counter question, and scanning an audit table per dashboard load is the kind of query that is fine at ten thousand rows and a problem at ten million.
A rollup writes one row per registry per interval, is cheap to read, and can be retained on its own schedule.
5.3 Endpoint shape
All four are GET, all four are scoped by the caller's identity or by admin:
| Endpoint | Auth | Returns |
|---|---|---|
/api/v1/me/quota | any authenticated | per-registry usage vs limits, only for registries with a quota |
/api/v1/me/downloads?limit= | any authenticated | recent Download entries for the caller |
/api/v1/me/advisories | any authenticated | findings for the caller's 5 most recent pulls in 7 days, and for the packages they own, each side labelled |
/api/v1/admin/stats/history?window= | admin | the rollup series |
/api/v1/me/advisories is a join the server performs, not the client: asking the browser to fetch findings per coordinate would be N requests and would leak the list of coordinates into the network log of a shared machine.
6. Detailed design
6.1 crates/web — the contract sweep
The 126 responses are fixed in one pass, per handler module. Where a response has no natural DTO because the handler returns an ad-hoc json!, that is itself the finding: it gets a named DTO deriving ToSchema, in the same module.
A gate makes the sweep stick — a test in crates/web that walks the generated ApiDoc and asserts every 200 (and every 201) has a schema. It fails on the next undocumented response rather than on a lint of the source, so it cannot be satisfied by a comment.
6.2 crates/core — ports
AccessLogRepositorygains a caller-scoped read: recent entries for one principal, filtered toAccessAction::Download. Scoping happens in the port, not in the handler, so no future caller can forget the filter.VulnerabilityRepositorygainslist_for_coordinates(&[PackageId])— one query for the join in §5.3, rather than the existing single-coordinate call in a loop.OwnershipStoregains a reverse lookup: the packages a principal owns. Today it only answers the forward question (list_ownersfor one package), which is why R7 records ownership as the expensive half of that widget. The namespace half needs nothing new — the packages under a claimed prefix are already reachable per user.- New
StatsHistoryRepository: append a rollup row; read a window.
6.3 crates/adapters — storage
- One migration in
crates/adapters/migrations/, added through themig!macro (crates/adapters/src/migrations.rs), for the rollup table: registry, window start, hits, misses, cached bytes. - An in-memory implementation alongside it, so
crates/web/tests/*keep running without Postgres, as every other port does.
6.4 server — the rollup writer, and [stats]
A periodic task writes one row per registry per hour (R9). The new [stats] block is the only configuration this RFC adds, and it governs both of the instance's statistical outputs, because an operator deciding "do I want this instance keeping numbers" is asking one question, not two:
[stats]
# The rollup behind the dashboard's trend.
history_enabled = true # default
history_retention_days = 30 # 0 disables retention pruning
# The Prometheus recorder and the /metrics endpoint.
metrics_enabled = true # default: today's behaviourThe interval is fixed at one hour rather than configured (R9): it is the resolution the data is kept at, and a deployment that wants daily figures aggregates on read. A configurable interval would make two instances' histories incomparable for no gain.
metrics_enabled closes a real gap rather than adding a preference. Today PrometheusBuilder is installed unconditionally in server/src/main.rs with no configuration consulted, and /metrics is served unauthenticated — the handler's own doc comment says so. An operator who does not run Prometheus has no way to stop publishing cache hit rates, per-registry pull volumes and upstream latencies. Wiring the flag also makes the handler's existing None branch ("metrics not configured") reachable in a real server for the first time; today it exists only for tests.
6.5 ui
src/lib/registry-types.ts: the four DTOs give way to@/client/types.gen;VisibilityandVISIBILITY_OPTIONSmove tosrc/config/. The file itself disappears.- Two new home widgets, built from the primitives RFC 0003 Phase 3 added —
EmptyStatefor the "nothing to report" case, and the meter as a newui/primitive with its own test, since a progress meter has an accessibility contract (role="meter", an accessible name, and a text alternative — a bar alone is not a value). AdminDashboardgains the trend sentence. It is also in scope for the §4.4 pass like every other admin page — the trend lands first, so the pass reviews the page an operator will actually have.- The §4.4 Impeccable pass over all fifteen admin pages. Mechanically this touches, per page that is not kept: the page components under
ui/src/pages/admin/,ui/src/config/adminSections.ts(the sidebar and mobile strip read from it, so a split, merge, removal or addition is a data change there, not a template change inAdminLayout), the router, andLEGACY_REDIRECTSfor any route that stops existing. A page whose verdict is update moves no routing at all. - New shared surface the pass extracts rather than re-inlines: anything three pages end up wanting goes to
ui/src/components/ui/with its own colocated test, as RFC 0003 Phase 3's primitives did. A fourth copy of a pattern is a finding, not a paste.
7. Security considerations
- Three new endpoints return data about the caller, and must return only that. The scoping is in the port (§6.2) rather than in the handler for exactly this reason: a handler-side filter is one forgotten
whereclause away from returning another user's download history. Each gets a test that asserts a second user's rows are absent, not merely that the caller's are present. /api/v1/me/advisoriestells you what is vulnerable in what you pulled. That is the same information the SBOM endpoints already serve for a coordinate, scoped down to the caller — it adds no new disclosure about packages, but it does disclose what this user pulled to that user. It must not accept auser_idparameter; the identity is the token's, and nothing else.- The quota endpoint discloses limits. Limits are configuration, not secrets, and the user is already told about them by a 429 and a warning header. It must not disclose other users' usage, which is why it reuses neither the admin handler nor its path.
- The rollup table holds no principal and no coordinate — registry, window, counters. It is operational data, and keeping it free of identity means its retention is not a privacy question.
/metricsis unauthenticated and, today, unconditional. It exposes cache hit rates, per-registry pull volumes and upstream latencies to anyone who can reach the port. That is a defensible default for an instance behind an ingress that does not route it, and indefensible for the self-hoster RFC 0003 R3 names as a first-class audience, who currently cannot turn it off at all.metrics_enabledis therefore a security control, not a preference — it defaults to today's behaviour so no scrape breaks, but it can now be closed.- The rollup table holds no principal and no coordinate, so
history_enabledis an operational choice rather than a privacy one. The distinction matters: turning metrics off is about exposure, turning history off is about storage. - The contract sweep is not a security change, but it removes a real hazard: a client that types a response
unknownis a client whose validation is whatever the developer remembered. RFC 0003 §13 already caught the console rendering a string as an array and dying.
8. Alternatives considered
| Alternative | Why rejected |
|---|---|
| Fix only the six undocumented endpoints the console calls | It leaves 120, and the docs site's API reference stays blank for them. The sweep is mechanical; the gate is what makes it durable, and a gate that exempts 120 endpoints is not a gate. |
| Derive the dashboard trend from the access log | §5.2: couples operational charts to audit retention, and a purge would silently rewrite the chart. |
| Scrape Prometheus from the server for the trend | Puts a second data path and a network dependency behind a page load, to answer a question a table answers. /metrics stays for operators who already run a TSDB. |
| Ship the quota widget by calling the admin endpoint | It is admin-only by design; widening it to self-service would mean one endpoint serving two authorisation rules, which is how the wrong row gets returned. |
| Let the browser join advisories per coordinate | N requests per page load, and it leaks the coordinate list into the network log of a shared machine (§5.3). |
| Re-cut the admin pages by hand, from a list written into this RFC | It is what RFC 0003 Phase 6 did, and §2.1 is the result: two surgical fixes to the two pages someone had already noticed, and a third page untouched. A list can only contain what its author spotted; the review has to happen against each page, not against the list. |
| Let the pass change the design system where a page argues for it | The grammar is correct and gate-enforced (RFC 0003 §13). Moving the world and the pages in one change puts two variables in one commit and makes a regression unattributable — and a token added for one admin page is a token every other surface inherits untested. Findings against DESIGN.md are recorded for a future RFC. |
| Cap the pass at "split the three pages over 550 lines" | Length is a symptom. It would keep the twelve shorter pages unreviewed for the one reason that has nothing to do with whether they answer their question, and it forecloses remove, merge and add entirely — the verdicts a line count cannot suggest. |
9. Rollout and compatibility
- The contract sweep is additive to the document, invisible at runtime. No response body changes; only its description does.
task dump-spec+task ui:generateregenerate the client, and the existing spec-drift gate proves the two are in step. - Emptying
registry-types.tsis the one breaking-ish change, and only insideui/: its DTOs become the generated ones and its two config exports move. Every call site is caught byvue-tsc, not at runtime. - The new endpoints are additive. A console built before them degrades to not showing the widgets, because they are the only callers.
- Both
[stats]flags default to today's behaviour.metrics_enabled = truekeeps/metricsexactly as it is, so no existing scrape breaks on upgrade;history_enabled = truestarts the rollup, andhistory_retention_days = 0turns pruning off rather than turning history off. Settinghistory_enabled = falserestores today's dashboard, trend and all. - Admin routes may move, and none may break. A split, merge or remove verdict changes a URL an operator has bookmarked or a runbook cites, so each lands with a
LEGACY_REDIRECTSentry (RFC 0003 §6.3) rather than a 404 — the mechanism already exists and already has the router wired to it.adminSections.tsis the single source for the sidebar and the mobile strip, so navigation follows the data without a second edit. - One migration, forward-only, creating one table. Rollback drops it; nothing else references it.
CURRENT_CONFIG_VERSIONmoves for the new[stats]block, per the repo's config-change rules.
10. Test plan
- Contract gate (
crates/web): every200/201in the generatedApiDochas a schema. This is the test that keeps §4.1 true; it must land with the sweep, not after it. - Scoping (
crates/web/tests/): for each of the threemeendpoints, seed two users and assert the second user's rows are absent from the first user's response. Absence, not presence, is the assertion that matters. - Rollup (
crates/adapters/tests/pg_*.rs+ in-memory): a window boundary writes exactly one row per registry; reading a window returns only rows inside it; retention deletes only rows outside it. - Quota edges: usage at 0, just under
warn_threshold_pct, just over, and at the limit — the four states the meter renders differently. ui: the meter primitive ships with its accessibility contract tested (name, value, text alternative). The two widgets get the four states of RFC 0003 §4.4.- The admin pass, per page. A split or merge moves its existing tests rather than rewriting them — the assertions describe behaviour, and a re-cut that is only a re-cut keeps them. An update may legitimately rewrite tests where the interaction itself changed; that rewrite is the review surface for the verdict, and it is read as one. A remove keeps a redirect test, not a deleted file: the route must still land somewhere. An add arrives with the same test set every other page has, including its role gating.
- The RFC 0003 gates stay green throughout, and bound the pass (§4.4) — detector at both viewports, axe over the unauthenticated routes, and the 23 authenticated route/role combinations. Every route the pass adds, splits or renames extends that authenticated list; every route it removes leaves a redirect assertion behind. The list's length changing is expected — its coverage dropping is not, so the count is asserted, not merely regenerated.
fixtures.test.tsflips from "at most 6 undocumented" to "none", and the fixtures for the four new endpoints are captured the same way the first twenty-one were.
11. Decisions and open questions
Resolved
| # | Question | Decision |
|---|---|---|
| R1 | Fix the whole contract, or only what the console calls? | The whole contract, with a gate. 126 of 173 documented 200s have no body; the console is one consumer among the docs site and every generated client. The per-endpoint fix is one clause and the DTOs already derive ToSchema. |
| R2 | Where does the dashboard trend come from? | A new rollup table, not the access log and not Prometheus (§5.2, §8). Audit retention and operational charts must not share a lifetime. |
| R3 | Do the me endpoints reuse the admin quota handler? | No. One endpoint serving two authorisation rules is how the wrong row gets returned. /api/v1/me/quota takes no user_id. |
| R4 | How much authority does the admin pass have, and who runs it? | Impeccable runs it, with full authority over the pages and none over the world. Composition, information architecture, interaction detail, copy, empty and error states are its call — including remove, merge and add, not only split. The design language is DESIGN.md's and does not move here: changing the world and the pages in one commit makes a regression unattributable, and the gates that bound the pass are gates on the world. Ran by hand instead, the review is only as wide as what someone already noticed (§2.1, §8). |
| R10 | Which pages does the pass cover — the three over 550 lines, or all fifteen? | All fifteen, with the three as a starting hypothesis only. Length is a symptom: it is what made three pages visible, and it is silent about a 91-line page answering the wrong question. Restricting the pass to the long pages also restricts the verdict to split, which is the one outcome a line count can suggest and the least likely to be the right one twelve times. |
| R11 | What stops a page from being reviewed twice, or not at all? | A stated question per page, and one commit per verdict. Every page ends Phase 5 with its one question written into the commit and a verdict against it — keep included, which is why keep still requires the question to be written down. Fifteen commits, fifteen questions; a page with no question is a page nobody reviewed. |
| R5 | Does registry-types.ts survive? | No, but it is not all SDK mirror. Four of its six exports exist only because the SDK types those responses as unknown, and they go once the responses are documented — a hand-written mirror of a contract is the thing most likely to drift. The other two (Visibility, VISIBILITY_OPTIONS) are UI configuration and move to src/config/ rather than being deleted. |
| R6 | What does "recently pulled" mean? | The 5 most recent coordinates pulled in the last 7 days. Bounded on both axes on purpose: a count alone degenerates the moment a CI job pulls twenty versions of one package, and a window alone is unbounded for a busy user. Distinct coordinates, so those twenty versions collapse to one row. |
| R7 | Does the widget cover packages the user owns, or only ones they pulled? | Both, labelled separately — they are different relationships: you are exposed to what you pulled, and you can fix what you own. Note the asymmetry in cost: "packages under a namespace my groups claim" is already answerable (GET /api/v1/me/namespaces/{registry}/{prefix}/packages), while explicit per-package ownership has no reverse index — OwnershipStore::list_owners answers "who owns this package", and nothing answers "what does this principal own". That reverse lookup is new work, called out in §6.2. |
| R8 | AdminNotifications: three routes, or one with tabs? | Split where they do not need each other. Measured rather than assumed: the subscription form's channel datalist is populated from the channel list, so channels and subscriptions share a route; inbound events read nothing the other two produce and gets its own. Two routes, not three. |
| R9 | Rollup interval, and where retention lives | Hourly, and a [stats] block that governs both statistical outputs. Hourly because it is the resolution the data is kept at — daily figures can always be aggregated on read, never recovered — and under 9 000 rows a year per registry is not a storage argument. The block carries history_enabled / history_retention_days and metrics_enabled, because "should this instance keep and publish numbers" is one operator question. Keeping it separate from the audit-log retention is what §5.2 argues for: purging the audit trail must not rewrite a dashboard. |
Decided during implementation
Rows R12–R16 were not questions this RFC opened; they are things the work turned up, recorded here per the rule below rather than settled silently in a commit message.
| # | Question | Decision |
|---|---|---|
| R12 | What does a 200 with no body at all declare? | It gains one. Twelve endpoints returned an empty 200/201 via .finish() — the four deprecate/unlist/relist routes, PUT /admin/banner, DELETE /admin/quota/{r}/{user}, the subscription test, the OIDC login probe, the inbound webhook, three Maven PUT paths and the Terraform provider binary upload — so there was nothing for §4.1 to describe. They now return {"ok": true}. This is the one place the sweep is not invisible at runtime (§9), and it was taken deliberately: exempting "no body" from the gate lets any future endpoint opt out by returning nothing, which is the hole the gate exists to close. Nothing reads these bodies — the CLI checks status.is_success(), the console ignores them. 204 No Content was the alternative and is a status change rather than a body change, so it trades one compatibility risk for another rather than avoiding it. |
| R13 | What body does a proxy passthrough declare? | A named, documented marker — not a fabricated struct. ArtifactBytes, UpstreamDocument and ProtocolDocument in crates/web/src/handlers/schemas.rs, plus OkResponse/MessageResponse for the two acknowledgements. A proxy response genuinely is the registry protocol's document — npm's packument, NuGet's registration index — and inventing a schema for it would document a shape this server does not promise. UpstreamDocument is an unconstrained JSON value rather than an object, because several of these protocols answer with a top-level array; endpoints known to return a list declare Vec<UpstreamDocument>. A generated client still sees unknown for these, but the docs site now carries prose explaining why, which is the part that was missing. Where a handler builds an ad-hoc json! of its own invention (§6.1), it gets a real DTO and serialises from it. |
| R14 | The sweep found the server typing visibility as String while the console narrowed it to three values by hand. Record, or fix? | Fix at the source. §4.1 says a field the hand-written version has and the generated one does not is "a finding about the server, recorded, not papered over". This is the mirror case — the client was more precise than the server — and core has had a Visibility enum with ToSchema all along. VisibilityResponse and NamespacePackageDto now use it; the wire format is byte-identical, so recording it and leaving the console to keep guessing would have been the papering-over. |
| R15 | What does "the 5 most recent coordinates" collapse on? | The full (registry, name, version) coordinate. R6 reads two ways — §4.2 says "coordinates", while R6's own gloss says twenty versions pulled by a CI job should "collapse to one row", which would require dropping the version from the key. §4.2 wins, because an advisory is a fact about a version: a row naming only the package leaves a reader guessing which of the versions they pulled is the affected one, which is the one thing the widget exists to tell them. Repeated pulls of the same version still collapse — that is the common CI case, a warm lockfile fetching one version many times — so the degeneration R6 worried about is mostly addressed by the same key. The residual cost is accepted: a user who genuinely pulls five versions of one package in a week sees five rows of it, and other packages fall off the list. artifact is excluded from the key and stripped from the result — it names a file within a coordinate (a tarball, a .vsix, a GitHub asset id) while findings are recorded per coordinate, so keeping it would split one version into rows carrying identical advisories. A consequence worth stating: a version you pulled and a different version you own are two coordinates and stay two rows, labelled pulled and owned respectively — you are running the first and can fix the second. |
| R17 | Does the [stats] block move CURRENT_CONFIG_VERSION? | No — the repo's rule says not to, and §9 cites that rule while reaching the opposite conclusion. docs/configuration.md is explicit: a bump is for "removing or renaming an existing field, or changing what an existing field's default means", and "what does not require a bump: adding a new optional field". [stats] is a new optional block whose every default reproduces today's behaviour, so an existing config file is unaffected whether or not the constant moves. Bumping would also cost something real: a file that then declares config_version = 2 is rejected by every older binary, which is a downgrade barrier erected for a change that breaks nothing. If a later change to this block does alter a default's meaning, that is the change that should carry the bump. |
| R16 | Does a 200 declaring two content types survive the document? | Not as two responses(...) entries — utoipa keys by status and the second silently wins. GET /api/v1/admin/audit-log/export serves JSON or CSV; written as two (status = 200, …) clauses it compiled, passed the gate, and dropped the JSON variant from openapi.json entirely. It uses utoipa's content(...) form instead. The gate cannot see this — a lost variant still leaves a documented body — so the check that caught it was reading the dumped spec, not trusting the annotation. Worth doing for any multi-format response. |
Still open
None. Every question this RFC opened is answered above; what remains is implementation, and anything discovered during it belongs in a new decision row rather than here.
The fifteen per-page verdicts are the deliberate exception: they are outputs of Phase 5, decided against the rendered page with the evidence §4.4 requires, not questions this RFC could answer in advance without becoming the hand-written list §8 rejects. They land in the commits, one question per page. A verdict that turns out to need something this RFC does not provide — an endpoint, a token — stops and becomes a row here or a finding for a future RFC; it does not widen the pass.
12. Implementation phases
Each phase leaves the tree green: cargo test --workspace, cargo clippy -- -D warnings, vue-tsc, vitest, oxlint, and every RFC 0003 design gate.
| Phase | Content | Useful on its own? |
|---|---|---|
| 1 | The contract sweep and its gate. All 126 responses declare a body; the ApiDoc test lands with them; task dump-spec + task ui:generate; registry-types.ts emptied and removed; fixtures.test.ts flipped to zero. | Yes — it fixes the docs site's API reference and every generated client, independently of anything else here. |
| 2 | The me read paths. Port-level scoping, three endpoints, the absence-based scoping tests. No UI. | Yes — the CLI and any external client can use them immediately. |
| 3 | Home widgets. The meter primitive with its accessibility contract, the two widgets, their four states, and the authenticated gate extended to cover them. | Yes — the first user-visible result. |
| 4 | Stats history and [stats]. Migration, port, in-memory and Postgres adapters, the hourly rollup task, the [stats] block wiring both history_* and metrics_enabled, CURRENT_CONFIG_VERSION bump, and the trend sentence on AdminDashboard. Making metrics_enabled real means main.rs consults config before installing the recorder, which it does not today. | Yes — the metrics switch stands alone even if the trend slips. |
| 5 | Admin composition, through Impeccable. /impeccable critique per route in Operate mode, then the refine command the verdict calls for (§4.4). The three hypothesised pages first, because their seams are already argued, then the other twelve with no hypothesis handed to them. One page per commit, each carrying its one question and its verdict — keep, update, split, merge, remove or add — plus the routing, adminSections.ts and LEGACY_REDIRECTS changes that verdict implies, and the gates green before the next page starts. | Yes — page by page, and a keep verdict is a shipped result too: it is the page's question, recorded. |
Phase 1 is deliberately first and deliberately alone: it is the only phase whose absence makes the others harder, because every endpoint added afterwards would otherwise be added to a contract that does not describe itself.