Problem
The cross-operator guardian-dashboard reports, per Guardian server, account activity and composition plus assets under guard. Guardian does not expose these as a complete aggregate API, so the dashboard reconstructs them client-side:
- it pages the full account list (
GET /dashboard/accounts) to compute accounts updated within 7/30 days, account composition, and paused/released counts (app/api/accounts/stats/route.ts);
- it calls
GET /dashboard/accounts/{id}/snapshot once for every account whose updated_at falls within the last seven days, then aggregates the decoded vault (app/api/accounts/asset-totals/route.ts).
On a Guardian holding 2,379 accounts, this is a full-list walk plus approximately 1,086 snapshot requests per refresh.
The code-default DEV HTTP limits are 10 requests/second burst and 60 requests/minute sustained (GUARDIAN_RATE_BURST_PER_SEC and GUARDIAN_RATE_PER_MIN). Production Terraform currently sets 200/second and 5,000/minute. On a Guardian enforcing the 60/minute sustained budget, operators observed the walk being cut off after approximately 57 requests—the remaining requests in the window having been consumed by authentication and inventory reads—with 429 and Retry-After: 60.
The dashboard compensates with adaptive fetch ceilings and does not publish a new total while snapshot reads remain unattempted because of its ceiling or a 429. This leaves operators seeing states such as:
Assets (7d active): Calculating… 25 of 1,086
Under sufficiently tight limits, completion can take many minutes or may never converge across cold serverless instances. Non-rate-limit snapshot failures are currently treated as attempted and omitted, so the client also lacks authoritative coverage information for deciding whether a total is complete.
Raising rate limits is a per-operator mitigation. The underlying problem is the request volume: only Guardian can aggregate its stored states efficiently and report authoritative coverage.
A related failure already appears at this scale. Above the configured aggregate threshold, /dashboard/info declines to compute accounts_by_auth_method and includes it in degraded_aggregates. On a 2,379-account Guardian, the overview consequently reports that the server cannot compute the account breakdown precisely where server-side aggregation matters most.
Current API surface
| Endpoint |
Provides |
Gap |
GET /dashboard/info |
total_account_count, accounts_by_auth_method, delta status counts, in-flight proposal count, and latest activity |
No asset data or activity-window/lifecycle counts; auth-method aggregation degrades above a threshold |
GET /dashboard/accounts |
Paginated account summaries |
Consumers must walk the inventory to compute cross-account counts |
GET /dashboard/accounts/{id}/snapshot |
One decoded Miden vault |
Requires one request per account to aggregate assets |
Proposal
Add GET /dashboard/stats, protected by the operator session and dashboard:read permission, returning account and asset aggregates in one request.
This issue specifies observable requirements rather than implementation. Guardian may maintain incremental aggregates, serve a periodically refreshed snapshot, or use another bounded mechanism. The existing background-refresher pattern in crates/server/src/metrics/refresher.rs may be useful, but is not required.
Functional requirements
FR-1 — Asset totals
Return totals across eligible Miden account snapshots:
- fungible base-unit sums grouped by
faucet_id;
- non-fungible asset counts grouped by
faucet_id.
Fungible sums must be serialized as base-10 decimal strings and computed without u64 or JavaScript safe-integer overflow.
Guardian must not perform decimals normalization, token pricing, or fiat valuation. Those remain consumer concerns.
FR-2 — Activity filter
Accept an optional RFC3339 updated_since query parameter for the asset aggregation.
For compatibility with the current dashboard, this filter refers to the account metadata updated_at exposed as DashboardAccountSummary.updatedAt, not the stored state row’s timestamp. Accounts are eligible when:
account.updated_at >= updated_since
Omitting the parameter aggregates all accounts.
Invalid timestamps return the standard 400 error envelope with a stable error code.
FR-3 — Account counts
Return the following unfiltered account counts in the same response:
- total accounts;
- by mutually exclusive lifecycle state:
released when released_at is present;
- otherwise
paused when paused_at is present;
- otherwise
active;
- by stable auth-method label;
- by auth-method label and authorized-signer count, so the dashboard can reproduce its current account-shape heuristic without Guardian claiming that the shape identifies a particular client;
- accounts whose metadata
updated_at falls within the last 7 and 30 days, anchored to the response’s as_of.
The endpoint must not silently omit auth-method counts above an inventory threshold.
FR-4 — Coverage and degradation
The response must include:
as_of: the timestamp represented by the published aggregate;
- the applied
updated_since, or null;
- the number of accounts eligible for asset aggregation;
- the number of accounts covered;
- the number of accounts skipped, grouped by a stable reason such as unavailable or undecodable state;
- an explicit
complete indicator;
- stable names for any aggregate the server declined to compute.
Coverage must satisfy:
covered + skipped = eligible
Any skipped eligible account makes the asset aggregate incomplete.
A degraded or unavailable aggregate must be represented explicitly. It must not be serialized as an empty map or zero that could be mistaken for a valid result.
FR-5 — One-call answer
GET /dashboard/stats?updated_since=... must answer the assets-under-guard and account-count questions without per-account follow-up requests.
FR-6 — Bounded serving cost
Serving the endpoint must not decode every eligible account vault on each request. Request-time work must remain bounded independently of inventory size at steady state.
Any background refresh must also be bounded or batched so that a large inventory cannot monopolize server resources. Staleness is acceptable when reported accurately through as_of.
FR-7 — Freshness
The refresh cadence or maximum expected staleness must be documented. The initial target should be no worse than the dashboard’s existing 60-second cache unless maintainers explicitly choose a different budget.
Consumers must be able to determine the age of the result from as_of.
FR-8 — Authentication and errors
- operator session required;
dashboard:read permission required;
401 and 403 behavior identical to existing dashboard read endpoints;
- standard Guardian error envelope;
- ordinary HTTP rate limiting applies—one aggregate request replaces approximately 1,100 inventory/snapshot requests.
FR-9 — Existing /dashboard/info degradation
The implementation must prevent the existing accounts_by_auth_method threshold from continuing to report the server as degraded when the new stats aggregate is available.
This may be implemented by reusing the same maintained aggregate in /dashboard/info, or by moving that aggregate authoritatively to /dashboard/stats and updating consumers accordingly. The two endpoints must not report contradictory values.
FR-10 — Contract propagation
Update together:
- server handler, wire types, and tests;
#[utoipa::path] and ToSchema/IntoParams declarations;
- generated
docs/openapi-dashboard.json;
packages/guardian-operator-client, including strict response parsing and tests;
examples/operator-smoke-web;
docs/DASHBOARD.md;
- relevant behavioral documentation in
spec/api.md.
Acceptance criteria
On a Guardian containing approximately 2,400 accounts, including approximately 1,000 whose metadata was updated within seven days:
GET /dashboard/stats?updated_since=<7-days-ago> returns asset totals, coverage, account totals, activity-window counts, lifecycle counts, and auth/account-shape counts.
- A dashboard refresh requires at most two Guardian requests:
/dashboard/stats and, if still needed, /dashboard/info.
- No per-account snapshot requests are required for the overview.
- The refresh succeeds under the code-default HTTP rate limits without
429 or a multi-minute calculating state.
- Asset totals match a reference full-inventory snapshot aggregation for every covered account.
- Missing or undecodable snapshots produce
complete: false and explicit skipped coverage; they never appear as valid zero balances.
- The operator TypeScript client and operator smoke harness exercise the new endpoint.
- Filesystem and Postgres backends produce equivalent response semantics.
Follow-ups
- Per-account totals in list summaries: the accounts table and CSV still need per-row totals. Adding fungible totals to
DashboardAccountSummary or introducing a bulk endpoint should be tracked separately.
- True client attribution: the dashboard’s “wallet” classification is currently a heuristic based on auth scheme and signer count. A server-recorded client-attribution field at registration would be a separate contract change.
Out of scope
- Changing the default rate limits.
- EVM accounts or asset aggregation.
- Token metadata, decimals normalization, asset pricing, or fiat valuation.
- Per-account asset totals for the accounts table and CSV.
- Introducing authoritative wallet/client attribution.
Problem
The cross-operator guardian-dashboard reports, per Guardian server, account activity and composition plus assets under guard. Guardian does not expose these as a complete aggregate API, so the dashboard reconstructs them client-side:
GET /dashboard/accounts) to compute accounts updated within 7/30 days, account composition, and paused/released counts (app/api/accounts/stats/route.ts);GET /dashboard/accounts/{id}/snapshotonce for every account whoseupdated_atfalls within the last seven days, then aggregates the decoded vault (app/api/accounts/asset-totals/route.ts).On a Guardian holding 2,379 accounts, this is a full-list walk plus approximately 1,086 snapshot requests per refresh.
The code-default DEV HTTP limits are 10 requests/second burst and 60 requests/minute sustained (
GUARDIAN_RATE_BURST_PER_SECandGUARDIAN_RATE_PER_MIN). Production Terraform currently sets 200/second and 5,000/minute. On a Guardian enforcing the 60/minute sustained budget, operators observed the walk being cut off after approximately 57 requests—the remaining requests in the window having been consumed by authentication and inventory reads—with429andRetry-After: 60.The dashboard compensates with adaptive fetch ceilings and does not publish a new total while snapshot reads remain unattempted because of its ceiling or a
429. This leaves operators seeing states such as:Under sufficiently tight limits, completion can take many minutes or may never converge across cold serverless instances. Non-rate-limit snapshot failures are currently treated as attempted and omitted, so the client also lacks authoritative coverage information for deciding whether a total is complete.
Raising rate limits is a per-operator mitigation. The underlying problem is the request volume: only Guardian can aggregate its stored states efficiently and report authoritative coverage.
A related failure already appears at this scale. Above the configured aggregate threshold,
/dashboard/infodeclines to computeaccounts_by_auth_methodand includes it indegraded_aggregates. On a 2,379-account Guardian, the overview consequently reports that the server cannot compute the account breakdown precisely where server-side aggregation matters most.Current API surface
GET /dashboard/infototal_account_count,accounts_by_auth_method, delta status counts, in-flight proposal count, and latest activityGET /dashboard/accountsGET /dashboard/accounts/{id}/snapshotProposal
Add
GET /dashboard/stats, protected by the operator session anddashboard:readpermission, returning account and asset aggregates in one request.This issue specifies observable requirements rather than implementation. Guardian may maintain incremental aggregates, serve a periodically refreshed snapshot, or use another bounded mechanism. The existing background-refresher pattern in
crates/server/src/metrics/refresher.rsmay be useful, but is not required.Functional requirements
FR-1 — Asset totals
Return totals across eligible Miden account snapshots:
faucet_id;faucet_id.Fungible sums must be serialized as base-10 decimal strings and computed without
u64or JavaScript safe-integer overflow.Guardian must not perform decimals normalization, token pricing, or fiat valuation. Those remain consumer concerns.
FR-2 — Activity filter
Accept an optional RFC3339
updated_sincequery parameter for the asset aggregation.For compatibility with the current dashboard, this filter refers to the account metadata
updated_atexposed asDashboardAccountSummary.updatedAt, not the stored state row’s timestamp. Accounts are eligible when:Omitting the parameter aggregates all accounts.
Invalid timestamps return the standard
400error envelope with a stable error code.FR-3 — Account counts
Return the following unfiltered account counts in the same response:
releasedwhenreleased_atis present;pausedwhenpaused_atis present;active;updated_atfalls within the last 7 and 30 days, anchored to the response’sas_of.The endpoint must not silently omit auth-method counts above an inventory threshold.
FR-4 — Coverage and degradation
The response must include:
as_of: the timestamp represented by the published aggregate;updated_since, ornull;completeindicator;Coverage must satisfy:
Any skipped eligible account makes the asset aggregate incomplete.
A degraded or unavailable aggregate must be represented explicitly. It must not be serialized as an empty map or zero that could be mistaken for a valid result.
FR-5 — One-call answer
GET /dashboard/stats?updated_since=...must answer the assets-under-guard and account-count questions without per-account follow-up requests.FR-6 — Bounded serving cost
Serving the endpoint must not decode every eligible account vault on each request. Request-time work must remain bounded independently of inventory size at steady state.
Any background refresh must also be bounded or batched so that a large inventory cannot monopolize server resources. Staleness is acceptable when reported accurately through
as_of.FR-7 — Freshness
The refresh cadence or maximum expected staleness must be documented. The initial target should be no worse than the dashboard’s existing 60-second cache unless maintainers explicitly choose a different budget.
Consumers must be able to determine the age of the result from
as_of.FR-8 — Authentication and errors
dashboard:readpermission required;401and403behavior identical to existing dashboard read endpoints;FR-9 — Existing
/dashboard/infodegradationThe implementation must prevent the existing
accounts_by_auth_methodthreshold from continuing to report the server as degraded when the new stats aggregate is available.This may be implemented by reusing the same maintained aggregate in
/dashboard/info, or by moving that aggregate authoritatively to/dashboard/statsand updating consumers accordingly. The two endpoints must not report contradictory values.FR-10 — Contract propagation
Update together:
#[utoipa::path]andToSchema/IntoParamsdeclarations;docs/openapi-dashboard.json;packages/guardian-operator-client, including strict response parsing and tests;examples/operator-smoke-web;docs/DASHBOARD.md;spec/api.md.Acceptance criteria
On a Guardian containing approximately 2,400 accounts, including approximately 1,000 whose metadata was updated within seven days:
GET /dashboard/stats?updated_since=<7-days-ago>returns asset totals, coverage, account totals, activity-window counts, lifecycle counts, and auth/account-shape counts./dashboard/statsand, if still needed,/dashboard/info.429or a multi-minute calculating state.complete: falseand explicit skipped coverage; they never appear as valid zero balances.Follow-ups
DashboardAccountSummaryor introducing a bulk endpoint should be tracked separately.Out of scope