Repository navigation
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Central YAML (inherited) Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Amber reviewStatus: Complete |
amber-review-bot
left a comment
There was a problem hiding this comment.
Verdict
REQUEST_CHANGES. The feature is well-structured and thoroughly tested, but the new GET /users/stats endpoint is not covered by an explicit authorization rule, so a sensitive platform-wide statistics view is gated by a catch-all default, and login recording is wired into the authentication hot path in a way that turns a transient write failure into a lockout.
I reviewed the API server (users plugin, migration, model, DAO), the RBAC authorization path, the OpenAPI/SDK generation, and the dashboard UI adapter changes against the HyperShell conventions, security spec, and control-plane conventions. Findings below are grouped by severity with confidence levels.
Blocker
1. /users/stats has no explicit authorization rule and falls through to the gateway:creator catch-all - Security / Authorization (components/api-server/plugins/users/plugin.go:49)
The route is added, but pkg/rbac/authorization.go (unchanged) has no rule for it. extractResourceInfo resolves this path to resource "stats" (last path segment, no {id}), so isAuthorized matches none of the explicit cases and returns the catch-all return hasGatewayCreator(bindings).
Consequences:
- Any principal holding a
gateway:creatorbinding is authorized to read platform-wide user registration/activity statistics, even though the endpoint is intended to be admin-only (see the OpenAPI grouping with/usersand the integration tests assertinghypershell-adminsallowed /gateway:creatorforbidden). - Conversely, a legitimate
hypershell-adminsoperator with nogateway:creatorbinding would be denied, because the admin check (hasUsersInventoryAccess, which honors thehypershell-adminsJWT role) is never reached for resource"stats".
The two new authz integration tests (TestUserActivityStats_AllowedForHypershellAdmin, TestUserActivityStats_ForbiddenForGatewayCreator) appear inconsistent with this code path (they require a live DB to run); please confirm they were executed against Postgres. Fix: add an explicit rule so the stats sub-resource is gated by hasUsersInventoryAccess(bindings, jwtRoles) (the same gate as /users), independent of the gateway:creator default, and add a denial test that grants gateway:creator as an actual binding rather than only a realm role. Confidence: High on the structural gap; Medium on the exact runtime status code without executing the integration suite.
Critical
2. RecordLogin failure aborts user provisioning and locks the caller out - Reliability / Availability (components/api-server/plugins/users/service.go:76)
UpsertByUsername now returns the RecordLogin error. That method is on the authentication hot path: UserProvisioningMiddleware -> UpsertFromJWT -> UpsertByUsername. When it errors, the middleware logs a warning and continues without setting the user ID in context, so AuthorizeApi then sees an empty user ID and returns 403 for an otherwise valid request. A transient failure on the user_login_days insert (a non-critical metric write) therefore denies access even though the user upsert itself succeeded. Record login best-effort: log and continue, do not fail the upsert. Confidence: High.
Major
3. last_login_at is written on every authenticated request - Performance (components/api-server/plugins/users/dao.go:119)
RecordLogin issues an UPDATE users SET last_login_at = ... plus an upsert into user_login_days on every authenticated call (provisioning runs per request). The daily-day insert becomes a no-op after the first request per day (ON CONFLICT DO NOTHING), but the last_login_at update is an unconditional write on the read hot path. Consider skipping the last_login_at update when the last recorded value is already within a short window, or gating it behind the day-change like the login-day row. Confidence: Medium.
Minor
4. DAO error returns are not wrapped with context - Convention (components/api-server/plugins/users/dao.go:126, 135, 161, 167, 173, 180, 187, 197, 207)
New DAO methods return bare err. Per CLAUDE.md/conventions, wrap with fmt.Errorf("record login: %w", err) / fmt.Errorf("get activity stats: %w", err) so failures carry context. Confidence: High.
5. Window helpers in production code are exercised only by tests - Maintainability (components/api-server/plugins/users/stats.go:44)
registeredIn7DayWindow / registeredIn30DayWindow are referenced only from stats_test.go; the real DAO expresses the 7/30-day windows directly in SQL (created_at >= last7DayStart). The tests validate the helpers, not the SQL that actually runs, so the two can drift. Either use the helpers in the DAO or move them into the test file. Confidence: Medium.
Cross-PR coordination
There is a material interaction with the open pull request that assigns gateway:creator to every user by default on provisioning (HYPERSHELL-262, "assign gateway:creator by default on user provisioning"). This PR's /users/stats endpoint is authorized through the catch-all hasGatewayCreator(bindings) default (Blocker #1). If both merge, every authenticated user receives a gateway:creator binding and would therefore be authorized to read platform-wide user registration/activity statistics, contradicting this endpoint's admin-only intent. Maintainers should decide the authorization model for /users/stats (an explicit admin/hasUsersInventoryAccess gate that does not depend on the gateway:creator default) and coordinate merge order so the stats endpoint does not inherit the universal gateway:creator grant. This is a design/authorization decision, not a file conflict (the two PRs touch different files).
Findings Summary (ordered by severity, highest first):
- [Blocker]
/users/statsauthorized via catch-allgateway:creatordefault; no explicit admin rule - Security (plugin.go:49; authorization.go isAuthorized) - [Critical]
RecordLoginfailure aborts provisioning and returns 403 to the caller - Reliability (service.go:76) - [Major]
last_login_atwritten on every authenticated request - Performance (dao.go:119) - [Minor] DAO error returns not wrapped with
fmt.Errorfcontext - Convention (dao.go:126+) - [Minor] Window helpers only used by tests, may drift from DAO SQL - Maintainability (stats.go:44)
Convention Checklist:
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf context |
Fail |
errors.IsNotFound handled for 404 scenarios |
N/A |
| No secrets in logs or responses | Pass |
| Input validated | Pass |
| Reconcile / update-or-create pattern | Pass |
| Authorization rule for new endpoint | Fail |
| OpenAPI client generated (not hand-edited) | Pass |
| Image references consistent | N/A |
| Conventional commit messages | Pass |
| Test Diff Scrutiny (modified assertions justified) | Pass |
fe046e2 to
769e01a
Compare
Amber reviewStatus: Complete |
amber-review-bot
left a comment
There was a problem hiding this comment.
Verdict
COMMENT - This is a well-structured, well-tested feature (UTC-boundary math, RBAC, and adapter behavior all have focused tests, conventions are followed, no secrets leaked, no panic()). One correctness concern in the login-recording path is worth addressing before merge, plus a minor authorization-shape nit; neither is a hard blocker, so I am leaving this as COMMENT.
Amber Analysis
The PR adds a GET /users/stats activity-stats API, records per-day login activity on authenticated requests, and surfaces the data through a redesigned dashboard widget and a regenerated SDK. The implementation is high quality; my main reservation is that the best-effort login-recording write can mark the request transaction for rollback, which quietly contradicts the "log-and-continue" intent of its caller.
Findings
[Major] Best-effort RecordLogin can roll back the whole request transaction (silent partial failure)
plugins/users/service.go:78-79 calls RecordLogin and, by design, only logs a warning on failure so provisioning/login is not blocked. But RecordLogin (plugins/users/dao.go:131,140) calls db.MarkForRollback(ctx, ...) on failure. Since UpsertByUsername runs inside the per-request transaction (invoked from UserProvisioningMiddleware via provisioner_adapter.go), marking the shared transaction for rollback means a failed activity write can silently undo the user upsert (and any other writes in that request, e.g. default role bindings) even though the service returns success and a 200 is sent. This violates the "never silently swallow partial failures" convention: the error is swallowed but its rollback side effect is not. Recommend removing MarkForRollback from RecordLogin (it is best-effort telemetry) or performing it in a separate, independently-committed session so a tracking-write failure cannot poison the primary request. Confidence: Medium (depends on the request-scoped transaction being active, which the surrounding DAOs' use of MarkForRollback indicates it is).
[Minor] stats authorization keyed on a coincidental resource shape
pkg/rbac/authorization.go:251 authorizes on resource == "stats". Route-based extraction yields resource="stats", while the path fallback yields resource="users", resourceID="stats" (both currently resolve to hasUsersInventoryAccess, so behavior is correct today). The resource == "stats" branch, however, would gate any future /<other>/stats collection subpath behind user-inventory admin, which is likely not intended. Consider matching on resource == "users" && resourceID == "stats" for an unambiguous rule. Confidence: Medium.
Cross-PR coordination
Another open pull request independently modifies the shared SDK generator (scripts/sdk-generator/parser.go, parser_test.go) and commits a fully regenerated components/sdk-go and components/sdk-typescript tree, exactly as this PR does (this PR adds collection-operation generation; the other adds an x-sdk-exclude extension). These are two concurrent evolutions of the same generator plus its committed generated output. Maintainers should decide a merge order and require the second PR to rebase onto the first, re-apply its generator change, and re-run SDK generation so the committed SDK reflects both features; merging one and then blindly regenerating in the other would silently drop the first PR's generated methods/types. This is the affected pull request: #237.
Convention checks
- No
panic()in production code: Pass - Errors wrapped with
fmt.Errorf("context: %w", err): Pass - No secrets in logs or responses: Pass (stats contain only counts;
last_login_atnot exposed via presenter) - Input validated: Pass (fixed route, no free-form user input reaches the query)
- Reconcile/best-effort semantics: Concern (see Major finding)
- Conventional commits: Pass
- OpenAPI client regenerated, not hand-edited: Pass (generator change + regeneration)
- No em dashes: Pass
Findings Summary (ordered by severity, highest first):
- [Major] Best-effort
RecordLoginmarks the request transaction for rollback, silently undoing provisioning on failure - Reconciliation / Error Handling (dao.go L131, L140; service.go L78) - [Minor]
statsauthorization keyed on a coincidental resource shape - Security / API design (authorization.go L251)
Convention Checklist:
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf context |
Pass |
| No secrets in logs or responses | Pass |
| Input validated | Pass |
| Never silently swallow partial failures | Fail |
| Conventional commit messages | Pass |
| OpenAPI/SDK generated, not hand-edited | Pass |
| No em dashes | Pass |
Amber reviewStatus: Complete |
amber-review-bot
left a comment
There was a problem hiding this comment.
Verdict
This PR cleanly adds a GET /users/stats activity-stats API, per-day login tracking, an SDK-generator enhancement for collection sub-operations, and a redesigned registered-users dashboard widget, with strong test coverage across the stack. The main item for author attention is whether the "best-effort" login-tracking fix actually holds inside the shared request transaction; the rest are minor/scope observations.
Summary
Registered-user activity statistics are computed with clear, inclusive UTC calendar-day windows and thorough boundary tests. RBAC gating for the new endpoint is correct (platform:admin / hypershell-admins only; gateway:creator denied), error paths are wrapped, no secrets are logged, no panic(), and context is propagated. LastLoginAt is nullable and the new user_login_days table is additive, so there is no optional->required tightening and no backfill gap. The one substantive question is the transaction semantics of the best-effort RecordLogin fix (see inline).
Findings
[Major] Best-effort RecordLogin may still poison the request transaction - components/api-server/plugins/users/dao.go:131
The commit removes MarkForRollback so a login-tracking failure does not undo provisioning. But RecordLogin uses the request-scoped session (sessionFactory.New(ctx)), and in PostgreSQL any statement error aborts the entire transaction, so a real DB error (not covered by OnConflict{DoNothing}) still fails the outer commit and undoes the user upsert. The common duplicate path is safe; genuine errors are not. Consider running login tracking on a separate session/transaction to truly decouple it, or document the residual limitation. (Confidence: Medium - New(ctx) transaction-sharing is inferred from the MarkForRollback pattern used throughout this DAO.)
[Minor] Extra unconditional write on the authenticated hot path - components/api-server/plugins/users/service.go:78
RecordLogin fires on every authenticated request via UpsertByUsername; the daily INSERT ... ON CONFLICT DO NOTHING is unconditional even though last_login_at updates are throttled to once/day. Likely fine at current scale; worth confirming.
[Minor] resource == "stats" authorization branch reachability - components/api-server/pkg/rbac/authorization.go:251
Correct but potentially confusing; a comment noting the route-template vs URL-path resolution would help.
[Minor] Undocumented dashboard widget removal - packages/operational-dashboard-ui/src/dashboard/dashboard-layout-persistence.ts:7
managed-clusters, managed-cluster-status, and managed-databases widgets are removed and stripped from saved layouts; this scope is not mentioned in the PR description.
Test Diff Scrutiny
Modified assertions in pre-existing tests (dashboard-control-plane.test.ts, metric-trend-change.test.ts, dashboard-layout-persistence.test.ts) are consistent refactors that follow the users.list -> users.activityStats migration or add new cases; none flip an accepted case to rejected or an optional field to required. No removed guarantees found.
Cross-PR coordination
Another open pull request changes the same per-request user-provisioning flow and the RBAC model by assigning gateway:creator as a default global binding to every authenticated user. This intersects with this PR in two ways that need a maintainer decision:
-
Privilege-tier coherence. This PR deliberately gates
GET /users/statsabovegateway:creator(hasUsersInventoryAccess= platform:admin / hypershell-admins only, with explicit deny tests for creator). Oncegateway:creatorbecomes the universal default, every authenticated user gains whatevergateway:creatoralready unlocks - notably thehasDashboardInventoryAccesspath (managed-clusters/managed-databases listing) that this PR's dashboard still relies on. Maintainers should confirm the intended two-tier boundary (all users can see inventory metrics, but not user-activity stats) is what both PRs assume, so the RBAC design stays coherent when both land. -
Shared provisioning-path transaction/error semantics. Both PRs add work to the same first-request provisioning path (this PR adds
RecordLogin; the other adds default-role binding sync). This PR's head commit is specifically about not letting a tracking-write failure roll back provisioning. The best-effort concern raised above (a login-tracking error can still abort the shared request transaction) would also roll back the other PR's default-role binding. The owners should coordinate the transaction/error-handling contract for this path and decide review/merge order together.
Findings Summary (ordered by severity, highest first)
- [Major] Best-effort
RecordLoginmay still abort the shared request transaction on a real DB error - Reconciliation / Data Integrity (dao.go:131) - [Minor] Unconditional per-request write on the authenticated hot path - Performance (service.go:78)
- [Minor]
resource == "stats"branch reachability could use a clarifying comment - Readability (authorization.go:251) - [Minor] Dashboard widget removal not documented in the PR description - Spec/Scope Completeness (dashboard-layout-persistence.ts:7)
Convention Checklist
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf context |
Pass |
errors.IsNotFound / 404 handling |
N/A (no direct-get 404 path added) |
| No secrets in logs or responses | Pass |
| Input validated | Pass (no free-form user input on stats endpoint) |
| RBAC enforced on new endpoint | Pass |
| Reconcile / update-or-create pattern | Pass (OnConflict{DoNothing} + conditional update) |
| Optional->required / migration & backfill | Pass (nullable column, additive table) |
| OpenAPI/SDK generated, not hand-edited | Pass (regenerated) |
| Conventional commit messages | Pass |
| Test diff scrutiny (no removed guarantees) | Pass |
…tats Teach trex-sdk-generator to detect collection-level subpaths and emit typed client methods plus response models, so regenerating the SDK from OpenAPI produces activityStats() without manual edits. Co-authored-by: Cursor <cursoragent@cursor.com>
RecordLogin is best-effort telemetry and UpsertByUsername already logs and continues on failure, so MarkForRollback must not run there or a tracking write error would silently undo the user upsert in the same request transaction. Co-authored-by: Cursor <cursoragent@cursor.com>
Use an independent session for login tracking so a telemetry write error cannot abort the shared provisioning transaction and undo the user upsert. Co-authored-by: Cursor <cursoragent@cursor.com>
c6c22cd to
1cc5725
Compare
Amber reviewStatus: Complete |
amber-review-bot
left a comment
There was a problem hiding this comment.
Verdict
APPROVE-worthy in spirit, but posting as COMMENT: this is a well-structured feature (new GET /users/stats API, login recording, SDK-generator support for collection operations, and a redesigned dashboard widget) with solid test coverage and correct partial-failure handling. The findings below are all Minor - a formatting fix that will trip the lint gate, an unreachable authorization branch, and two performance/robustness observations - none are blockers.
I reviewed the API-server changes (RBAC, DAO, migration, service, handler), the SDK generator, the generated SDKs, and the operational-dashboard UI. Error wrapping, secret handling, input surface, migration additivity, and the dashboard Promise.allSettled partial-failure path all look good.
Strengths
RecordLogincorrectly runs on an independent session so a tracking failure cannot abort the request/provisioning transaction, and the service logs-and-continues (best-effort telemetry, not a swallowed hard failure).- The migration is additive (nullable
last_login_at+ newuser_login_daystable) with a matching rollback, so no backfill is required for pre-existing rows. - Authorization for the new endpoint reuses
hasUsersInventoryAccess, and the integration tests provegateway:creator(JWT role and global binding) receive403whileplatform:admin/hypershell-adminssucceed. - Dashboard aggregation uses
Promise.allSettled, recordsfailedSources, re-throws on abort, and omitsregistered-userson failure instead of showing a misleading0. - Test changes are additive:
getMetricTrendChangeis preserved as a thin wrapper over the newgetTrendChange, and no pre-existing assertion was flipped from accept->reject.
Findings
- [Minor]
scripts/sdk-generator/model.go:35- theCollectionOperationsfield is not gofmt-aligned with the rest of the struct block, sogofmt/make lintwill report it. Rungofmt -w scripts/sdk-generator/model.go. - [Minor]
components/api-server/pkg/rbac/authorization.go:251- the newresource == "stats"branch is unreachable:/users/statsresolves toresource == "users"(confirmed byTestExtractResourceInfoFromPath_UserActivityStats), which the earlierresource == "users"branch already authorizes. The unit test exercises a resource value the router never produces. Not a security gap (access is still enforced), but the branch and its test are misleading. - [Minor]
components/api-server/plugins/users/model.go:19-20/dao.go:187,194-user_login_dayshas a composite primary key(user_id, login_date), so theWHERE login_date >= ?andGROUP BY login_datequeries cannot use the PK index efficiently and will scan as the table grows. Consider an index onlogin_date. - [Minor]
components/api-server/plugins/users/dao.go:121-RecordLogindiscards the request context (_ = ctx) and usescontext.Background(). The independent-session rationale is sound, but dropping the context also drops deadlines/cancellation/tracing on a per-request write. Consider a detached context that still carries request-scoped values, or note the trade-off explicitly.
Cross-PR coordination
Another open pull request introduces POST /api/hypershell/v1/managed_clusters/registration, a collection-level (non-{id}) sub-path on a resource collection - the same shape of operation this PR teaches trex-sdk-generator to auto-emit (its motivating example being GET /users/stats). That other PR predates this generator capability: it relies on the openapi-generator DefaultAPI client and a hand-written control-plane registration client, and its sdk-go/sdk-typescript diffs only bump the spec-hash header without emitting a registration collection method. Once both merge, regenerating the SDKs through the new generator will begin emitting a collection operation (and response model) for /managed_clusters/registration that the other PR neither added nor intended.
Maintainers should decide a merge order and owner for the regeneration: whichever lands second must re-run the generator against the combined spec and confirm the managed-cluster registration operation is either intentionally emitted into the SDKs or explicitly excluded, and reconcile the resulting spec-hash headers. This is a design/assumption and change-order decision, not a mechanical merge conflict.
Findings Summary (ordered by severity, highest first):
- [Minor]
CollectionOperationsstruct field not gofmt-aligned; trips lint gate - Convention / Formatting (model.go L35) - [Minor] Unreachable
resource == "stats"authorization branch and test - Dead Code / Correctness (authorization.go L251) - [Minor] No index on
user_login_days.login_datefor range/group-by queries - Performance (model.go L19-20, dao.go L187/L194) - [Minor]
RecordLogindrops request context deadlines/tracing - Observability / Robustness (dao.go L121)
Convention Checklist:
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf context |
Pass |
errors.IsNotFound handled where applicable |
Pass |
| No secrets in logs or responses | Pass |
| Input validated | Pass |
| Reconcile / no silent partial-failure swallowing | Pass |
| Migration additive / no missing backfill | Pass |
| OpenAPI + SDK regenerated (not hand-edited) | Pass |
| Test diff scrutiny (no flipped guarantees) | Pass |
| Conventional commit messages | Pass |
gofmt applied |
Fail |
| HasPatch bool | ||
| HasStatusPatch bool | ||
| Actions []string | ||
| CollectionOperations []CollectionOperation |
There was a problem hiding this comment.
[Minor - Convention/Formatting] This field isn't gofmt-aligned with the rest of the struct block. CollectionOperations (20 chars) is longer than the current alignment column, so gofmt will re-align the whole block and make lint will flag it. Run gofmt -w scripts/sdk-generator/model.go.
| return hasUsersInventoryAccess(bindings, jwtRoles) | ||
| } | ||
|
|
||
| if resource == "stats" && method == http.MethodGet { |
There was a problem hiding this comment.
[Minor - Dead code/Correctness] This branch is unreachable. /api/hypershell/v1/users/stats resolves to resource == "users" (see TestExtractResourceInfoFromPath_UserActivityStats), which the earlier if resource == "users" branch already authorizes with the same hasUsersInventoryAccess check. So resource is never "stats" for the real route, and TestIsAuthorized_UserActivityStatsRequiresDashboardOperator exercises a value the router never produces. Access is still enforced correctly via the users branch, but consider removing this branch (and adjusting the test) to avoid a misleading dead path.
|
|
||
| type UserLoginDay struct { | ||
| UserID string `gorm:"primaryKey"` | ||
| LoginDate time.Time `gorm:"primaryKey;type:date"` |
There was a problem hiding this comment.
[Minor - Performance] The composite primary key (user_id, login_date) has login_date as the trailing column, so the activity-stats queries that filter/group on login_date alone (WHERE login_date >= ?, GROUP BY login_date, COUNT(DISTINCT user_id)) can't use this index efficiently and will trend toward sequential scans as the table grows. Consider adding a secondary index on login_date.
| } | ||
|
|
||
| func (d *sqlUserDao) RecordLogin(ctx context.Context, userID string, loginTime time.Time) error { | ||
| _ = ctx |
There was a problem hiding this comment.
[Minor - Observability/Robustness] RecordLogin discards the request context (_ = ctx) and uses context.Background(). Using an independent session to avoid aborting the request transaction is the right call, but dropping the context entirely also drops deadlines, cancellation, and tracing on a write that runs on every authenticated request. Consider a detached context that still carries request-scoped values (or document the trade-off inline).


HYPERSHELL-279: Registered user activity stats on the operational dashboard
Summary
Adds registered-user activity statistics to the operational dashboard so platform administrators can see total registrations, recent sign-ups, and daily active users over the last 30 days. Delivers a new
GET /api/hypershell/v1/users/statsAPI, records login activity on authenticated requests, and surfaces the data through a redesigned Registered users widget with trend sparklines and 7/30-day summary rows.Also extends
trex-sdk-generatorso collection-level subpaths like/users/statsare generated automatically into the TypeScript and Go SDKs (activityStats()), removing the need for hand-edited client files after OpenAPI changes.Commits
HYPERSHELL-279 Better user stats- API, dashboard UI, web-console adapter, specs, and initial SDK updates for user activity statistics.feat(sdk-generator): generate collection operations like GET /users/stats- Generator support for collection sub-operations; regenerates Go and TypeScript SDKs soactivityStats()and related types are emitted frommake generate-sdk-ts.What changed
API server
GET /api/hypershell/v1/users/statsreturningUserActivityStats(total registered, 7/30-day registration counts, 7/30-day active counts, and 30-day daily histograms for registrations and logins).user_login_daystable and migration;RecordLoginon authenticated API access to track distinct active users per UTC day.openapi.users.yamland regenerated Go OpenAPI client.SDK
UserAPI.activityStats(),UserActivityStats,UserDailyCounttypes and index exports.UserAPI.ActivityStats()and matching types./users/stats), derives method names fromoperationId, and emits client methods plus nested response models.Web console and operational dashboard UI
client.users.activityStats()and maps to theregistered-usersmetric (OP-DASH-19 independent source).RegisteredUsersCard/DashboardStatPanelwith total, addition/login rows, and active-user sparkline.hypershell.operational-dashboard.layout.v32for the taller two-column registered-users widget.registered-users; the dashboard does not show0as a fallback.Specs and reconciliation
specs/platform/registered-users.spec.md- activity stats API, login recording, adapter contract.specs/web-console/operational-dashboard.spec.md- OP-DASH-19 source mapping, layout key v32.skills/RECONCILE.md- coverage updates for RU-W3.Dashboard