Spaces:
Running
Running
| # Admin dashboard | |
| Owner/maintainer control surface launched from the Dashboard activity rail. A wide modal with a top tab strip; **Users**, **Requests**, **Reviews**, **Releases**, **Jobs**, **Announcements**, and **Permissions** ship. The modal reopens to the **last active tab** (localStorage `insp_admin_active_tab`, validated against the live tab set; `openModal()` defaults to it). The **Permissions** tab is **owner-only** (`ownerOnly` β `$isOwner`); the **Announcements** tab is **capability-gated** on `announcements.send` (owner-only by default, but owner-toggleable to maintainers β so it filters on `$canAnnounce`, not the tier). Both vanish from the tab strip when ineligible, and an active-tab guard snaps back to Users on a live demotion. | |
| ## Permissions compartment (owner-only) | |
| `PermissionsCompartment.svelte` renders the capability matrix (`GET /api/admin/permissions`) grouped by category; each capability row is an On/Off `Toggle` (`lib/components/Toggle.svelte`) per tier (anonymous / contributor / maintainer), with a static always-on **Owner** indicator. Anonymous cells render a locked **N/A** marker when the capability isn't `anon_eligible`; the `manage_permissions` row is fully locked (owner-only fixed). Toggling is optimistic + revert-on-error (mirrors `UsersCompartment.changeRole`); a modified cell shows a reset (βΊ) affordance β `POST β¦/<cap>/<tier>` with `{reset:true}`. API client `lib/api/admin-permissions.ts`; the resolved capability model + endpoints live in [auth-permissions.md](auth-permissions.md) Β§ Capabilities. | |
| Gated on `$isAdmin` (maintainer **or** owner). Read-only data for maintainers; the role picker (Users), request resolution (Requests), and reviewer overrides (Reviews β General-drawer popover) are owner-only or use existing owner-gated routes. | |
| The entry button (`AdminDashboardButton`) is badge-free β it just opens the modal. "New request" awareness moved to the per-user My Notifications rail (the owner-facing `request.received` alert, see [notifications.md](notifications.md) Β§ Owner review alerts); the Requests tab no longer carries an unviewed dot/count, and the marked-ready review notification was retired with the Releases-tab restructure. | |
| ## Frontend | |
| `tabs/dashboard/components/admin/` | |
| | File | Role | | |
| |---|---| | |
| | `AdminDashboardButton.svelte` | Entry button above the activity rail; rendered only when `$isAdmin`. Opens the modal via the store. No badge. | | |
| | `AdminDashboardModal.svelte` | `Modal size="wide"` + tab strip (`adminDashboard.activeTab`, persisted to localStorage). Renders `UsersCompartment` / `RequestsCompartment` / `reviews/ReviewsCompartment` / `releases/ReleasesCompartment` / `jobs/JobsCompartment` / `AnnouncementsCompartment` / `PermissionsCompartment`. | | |
| | `AnnouncementsCompartment.svelte` | Compose a global announcement (title + optional body) + a management list of past announcements (active + revoked) with per-row Revoke. Gated on `announcements.send`. API client `lib/api/announcements.ts`; see [notifications.md](notifications.md) Β§ Announcements. | | |
| | `RequestsCompartment.svelte` | Status facets (open / accepted / sent back / discarded + counts) over a review queue. Clicking a pending row expands an inline review (proposed-changes diff over `ProposedEdits` fields, requester note, conflict notice). Expanding a pending request marks it viewed (clears the unviewed dot/pill, decrements the badge). **Owner-only** inline reason + Send back / Discard; maintainers are read-only. Archived rows show a read-only resolution footer. Lazy-fetched on first activation + light `visiblePoll` while open. | | |
| | `reviews/ReviewsCompartment.svelte` | Slimmed read-only oversight surface β two collapsible buckets (**Under review** [claimed, not yet marked ready] / **Available for review**) split FE-side from one `/api/admin/reviews/list` fetch. Available collapsed by default (localStorage-persisted). Filter bar (Arabic + Latin search Β· riwayah/style/channel facets Β· `stalled`/`name` sort). Marked-ready / published / staleness moved to the Releases tab. | | |
| | `reviews/ReviewsRow.svelte` | One recitation row β Latin primary + Arabic muted trailing + reviewer chip; age (stale β when a claim is > 7d old) + a `Segments` deep-link (`selectedReciter` + `setActiveTab('segments')` + `adminDashboard.close()`). Row body click opens the General drawer. No Generate-TS / unread dot (retired with the restructure). | | |
| | `reviews/ReviewsGeneralDrawer.svelte` | Current-claim chip is the popover trigger for owners (subtle hover ring + caret); maintainers see static text. **Mark-ready submission** card (between Current reviewer and Reviewer history, visible only when `current_claim.mark_ready_submission` is set) renders the reviewer's checklist as a 6-row β list plus the two optional comment boxes as quoted blocks β labels come from the same `tabs/segments/copy/mark-ready` module the reviewer saw. **Bypass submissions** (`submission.bypass_used === true`) replace the checklist with an italic bypass line + an "owner bypass" pill. Also holds the reviewer-history table (closed claims), expanded vertical timeline, and a **Flagged issues** count (shown only when `flagged_issues_count > 0`). | | |
| | `reviews/ReviewerActionsPopover.svelte` | **Owner-only** mode-driven popover anchored to the current-claim chip β three views: `menu` (Change reviewer / Remove reviewer), `change` (debounced 300 ms HF login lookup β resolved user card + reason β Reassign; disables when picked login equals current reviewer), `remove` (reason β Force-release). RolePicker-style click-outside + Escape dismissal; success fires `onaction` so the drawer + parent list refetch. | | |
| | `reviews/ExpandedTimeline.svelte` | Vertical chronological timeline with tone variants (transition / admin / job dot styles). Newest-first sort. Reused by the Releases row's Timeline expand. | | |
| | `UsersCompartment.svelte` | Fetches the list once + visitor stats; people/traffic ribbon, search + role-filter toolbar, master table, detail drawer. Owns inline role-change orchestration (optimistic + revert + error banner). | | |
| | `UsersTable.svelte` | Sortable master table. Role cell is a `RolePicker` (`editable={canEditRoles}`); the cell stops click-propagation only when editable so non-owners still open the drawer. | | |
| | `UserDetailDrawer.svelte` | Lazy per-user detail (stats, role/claims/requests/activity timelines, HF profile link). Header pill is a `RolePicker`; reflects the live row role via `roleOverride`. | | |
| ### Releases compartment (FE) | |
| `tabs/dashboard/components/admin/releases/` β the single operator lifecycle home. Sections are organized **by action needed** (mutually exclusive, priority order): In progress Β· Failed to publish Β· **Ready to generate** (marked-ready, no ts) Β· **Behind edits** (ts stale) Β· **Republish to HF** (hf stale) Β· **Publish to HF** (released, first publish) Β· Published & current. `bucketOf()` mirrors the route's `_is_bucketable`. | |
| | File | Role | | |
| |---|---| | |
| | `ReleasesCompartment.svelte` | Bulk `/api/admin/releases/status` fetch + FE bucketing + filter bar; owns the single-open in-row expansion state (`expanded = {slug, mode}` β one open across the whole list), the batch-publish selection + action bar, the cut modal, and send-back on Ready-to-generate rows. Also fetches `/api/admin/release-preview` for the header what's-next. | | |
| | `ReleasesRow.svelte` | TS/HF/GH chips + reviewer chip on Ready-to-generate rows. Action cluster: Generate/Regenerate (opens the `ts` expand) Β· Timeline Β· Reviewers Β· Past jobs Β· Segments redirect; Send back on Ready-to-generate; in-flight controls on running rows. Renders `ReleasesRowExpansion` below when this row is the open one. | | |
| | `ReleasesRowExpansion.svelte` | The one shared, button-switched panel β renders `ReleasesTsSettings` (`ts`), `ExpandedTimeline` (`timeline`), `ReviewerHistoryView` (`reviewers`), or `ReleasesPastJobs` (`jobs`). Timeline + Reviewers share one lazy `fetchAdminReviewDetail`. | | |
| | `ReleasesTsSettings.svelte` | Unified generate/regenerate form β beam 50 + probe 2, collapsible Advanced, inline Full-vs-Affected scope chooser. Launches via `generateTimestamps`. | | |
| | `ReleasesPastJobs.svelte` | Job history + live `visiblePoll` log tail; on terminal success triggers a status refetch. | | |
| | `ReviewerHistoryView.svelte` | Read-only current-reviewer + mark-ready submission + closed-claim history + flagged count (no claim mutation). | | |
| | `ReleasesSummaryCard.svelte` | Current GH release + **what's-next** preview ("Adds N, refreshes M (K carried) over vX.Y.Z") + Cut / Refresh-catalog buttons + in-flight strip. | | |
| | `CutReleaseModal.svelte` Β· `ReleasesActionBar.svelte` | Cut dry-run document; sticky batch-publish bar. | | |
| Full release model: [dataset-and-releases.md](dataset-and-releases.md). TS gen/regen subsystem: [timestamps-job.md](timestamps-job.md). | |
| ### Jobs compartment (FE) | |
| `tabs/dashboard/components/admin/jobs/` β the **one** place to see every job kind (`timestamps` Β· `hf_publish` Β· `hf_publish_batch` Β· `cut_release` Β· `refresh_catalog`), running **and** historical, across all reciters. Replaces the scatter where TS history hid in a per-reciter Past-jobs expand and release/cut/publish jobs only flashed in the Releases in-flight strip. | |
| | File | Role | | |
| |---|---| | |
| | `JobsCompartment.svelte` | `visiblePoll` (~10 s) over `GET /api/admin/jobs`; filter chips (kind / status / reciter+id search); running pinned on top; row click opens the drawer. Gated `reviews.view`. | | |
| | `JobDetailDrawer.svelte` | `GET /api/admin/jobs/detail` β summary + per-kind extras (TS settings + log tail; batch/cut `members` table; `version`/`external_uri`); **Open on HF β** (the job page `url`) + **Cancel** when running (reuses `cancelReleaseJob`). | | |
| API client `lib/api/admin-jobs.ts` (`fetchJobs`, `fetchJobDetail`; re-exports `cancelReleaseJob`). | |
| ### Shared job-record store (backend) β the single source the Jobs tab reads | |
| `services/admin/jobs/records.py` is the **one writer/reader** of job records. Every kind's server-side `launch()` writes a `running` `JobRecord`; every `complete()` and every failure path (webhook non-success branches, the generic cancel route, the TS backstop) writes the terminal outcome via read-merge-write (launch fields survive). Paths reuse `base.job_record_path` β `reciters/<slug>/jobs/<kind>/<id>.json` per-slug, `jobs/_global/<kind>/<id>.json` global. The DB stays source-of-truth for *releases*; these records are the uniform observability trail (the `JobRecord` schema is a superset of the legacy `TsJobRecord`, which the TS HF-Job container still self-writes unchanged). | |
| `services/admin/jobs/registry.py` aggregates: `records.list_all()` βͺ live `base.list_in_flight_jobs(ALL_KINDS)`, deduped by `job_id` (live `running` wins), newest-first, TTL-cached (`cache.{get,set,invalidate}_all_jobs_cache`). `job_detail()` enriches a running TS job with its live log tail. Endpoints (gate `reviews.view`): `GET /api/admin/jobs`, `GET /api/admin/jobs/detail?kind=&job_id=&slug=`. Cancel reuses `POST /api/admin/release-jobs/<job_id>/cancel`. | |
| Pre-store history (cut/hf_publish that predate the store) is one-shot synthesized into records by `scripts/backfills/backfill_job_records.py` (from `gh_releases` + `per_recitation_releases(track='hf')`), so the tab reads complete history from one store. | |
| State: | |
| - `tabs/dashboard/stores/admin-dashboard.svelte.ts` (`adminDashboard`: `open`, `activeTab` [localStorage-persisted], `openModal/close/setTab`). | |
| - `lib/stores/reviews.svelte.ts` (`reviewsStore`: `selectedSlug`, `openDrawer` [`'general'`], `filters`, `sortBy`, `refreshSeq`; `open(slug)`). | |
| - `lib/stores/releases.svelte.ts` (`releasesStore`: `filters`, `sortBy`, `refreshSeq` + `requestRefresh()`). | |
| Cross-tab building blocks in `lib/components/`: `RolePill` (presentational badge), `RolePicker` (owner-editable wrapper β bare pill when `editable=false`, dropdown of role pills + click-outside/Escape when `true`), `Avatar`, `Timeline`. API clients `lib/api/admin-users.ts`, `lib/api/admin-requests.ts`, `lib/api/admin-reviews.ts`; formatters `lib/utils/admin-format.ts`. | |
| ## Backend | |
| Blueprint `admin_users_bp` at `/api/admin` (`routes/admin/users.py`), all `@require_role(Role.MAINTAINER, Role.OWNER)`: | |
| | Route | Purpose | | |
| |---|---| | |
| | `GET /users` | Master list. Cached on `db_seq` (`services/admin/users.py::list_users`, cache in `services/storage/cache.py`). | | |
| | `GET /users/<hf_user_id>` | Per-user detail (lazy, uncached) β `repo_admin_users` aggregations. | | |
| | `GET /visitor-stats` | Today's traffic rollup. Gated on env `INSPECTOR_VISITOR_ANALYTICS`; the compartment swallows failure. | | |
| | `POST /users/<hf_user_id>/role` | **Owner-only** role change (`@require_role(Role.OWNER)` + `@require_same_origin`). | | |
| Aggregation/reads: `services/db/repo_admin_users.py`, `repo_visitors.py`; visitor counting in `services/admin/visitors.py`. Schemas: `qua_shared/schemas/wire/admin_users.py` (codegen'd to FE types). | |
| ### Requests compartment | |
| Routes live in `requests_bp` (`routes/claims/requests.py`); reads in `services/admin/requests.py`; schema `qua_shared/schemas/wire/admin_requests.py` (codegen'd). | |
| | Route | Purpose | | |
| |---|---| | |
| | `GET /admin/requests?status=open\|accepted\|returned\|discarded` | Review-queue payload for one facet β catalog-joined (name/riwayah/style), proposed-changes diff over `ProposedEdits` fields, conflict flag, facet counts. Includes **slugless intake rows** (new-combo / new-reciter) alongside slug-based edit requests; intake rows carry `source` + `probe`. Maintainer+; **tier-redacted** (owners get `@login`+hf_id, maintainers role only). Base list cached on `db_seq` in `cache.py` (`get/set_admin_requests_cache`); per-caller overlay applied live. "New request" awareness is the rail's `request.received` alert β no unviewed badge / view-mark route. | | |
| Resolution of **slug-based edit requests** stays on the existing per-slug routes, now **owner-only**: `POST /admin/request/<slug>/reject-{soft,hard}` (`@require_role(Role.OWNER)`; the `reciter.request_rejected_{soft,hard}` transition handlers use `_require_owner`). Acceptance is implicit (the alignment pipeline β `reciter.alignment_completed` applies the proposed edits + resolves the request to `accepted`). | |
| #### Intake requests (new combination / new reciter) | |
| The Submit-recitation wizard's two **slugless** types β `existing_reciter_new_combo` and `new_reciter` β carry an audio **source** (direct links / playlist; a dropped CSV/JSON file is normalised into `links[]` client-side). They land in the unified `requests` table with `slug = NULL`, everything parked in `payload` (`reciter_id`, `proposed_edits` (ProposedEdits-shaped), `source`, three required `attestations` (distribution/links-verified/storage rights β recorded for audit), cached `probe`). Service: `services/admin/intake.py`; validation `intake_validation.py`; probe `intake_probe.py`; schemas `qua_shared/schemas/wire/intake_requests.py` (codegen'd). | |
| | Route | Purpose | | |
| |---|---| | |
| | `POST /requests/intake` | Submit (any signed-in user; `@require_same_origin`). Structural validation only β URL format (errors list malformed **chapter indices**), required combination + all three attestations, with 1β114 coverage / duplicates / playlist host as **warnings**. 400 carries `{errors, warnings}`. The wizard mirrors this client-side: live invalid/missing-chapter feedback in step 2, a step-4 confirm of the attestations. | | |
| | `POST /admin/requests/<id>/accept` | **Owner-only.** Approval only β **does not write the catalog.** Records the owner-confirmed canonical `reciter_id` (new_reciter only) onto the payload and flips the request to `accepted` (slug stays NULL). Source/channel/bitrate/slug are probed from the audio and can't be validly classified by a human at accept time, so the **offline ingest** reads accepted slugless requests, fetches + probes the audio, and creates the reciter + delivery (correct source/channel/slug) + state row, honoring `auto_claim` β all offline. | | |
| | `POST /admin/requests/<id>/probe` | **Owner-only.** Reachability probe of the source (ThreadPool HEAD/ranged-GET; playlist = single check). Caches `payload.probe`. Never on the submit path. | | |
| | `POST /admin/requests/<id>/{return,discard}` | **Owner-only.** Id-based resolution for slugless rows β pure request-status mutation, **no** state-machine transition (no delivery/state row exists). β₯10-char reason. | | |
| The Accept-confirm dialog (`AcceptIntakeDialog.svelte`) is deliberately thin: for a **new reciter** it confirms only the canonical `reciter_id` (the lone human decision); for a **new combination** it's a plain confirm. No source/channel/slug pickers β those are ingest's job. `request.intake_*` are audit-only events (slugless, not in `_HANDLERS`) β silently dropped from both activity rails, no classification entry needed. | |
| Request review is **no longer a notification**: `reciter.request_rejected_{soft,hard}` are `HIDDEN_EVENTS` and `reciter.requested` is public-prose-only (not `ADMIN_ONLY_EVENTS`) in `activity_classification.py`. | |
| ### Reviews compartment | |
| Routes live in `admin_reviews_bp` (`routes/admin/reviews.py`); reads + per-admin view marks in `services/admin/reviews.py`; schema `qua_shared/schemas/wire/admin_reviews.py` (codegen'd). All endpoints `@require_role(Role.MAINTAINER, Role.OWNER)`. | |
| | Route | Purpose | | |
| |---|---| | |
| | `GET /admin/reviews/list` | One whole-table JOIN over `delivery_states` + `deliveries` + `reciters` + open `claims`, filtered to the two states the slimmed tab covers (`awaiting_review`, `under_review`). Canonical rows (state + open-claim shape); the FE renders Under review [not marked-ready] + Available. Not cached β sub-second JOIN, refreshed on every admin action. | | |
| | `GET /admin/reviews/<slug>` | General-drawer payload β base + current claim + claim history + transition timeline + `timestamps_job_ids` + `flagged_issues_count` (segments carrying a `flag`, from one cached `load_detailed`). Bounded queries + one detailed read; cheap enough to fetch eagerly on every drawer open. Also fed (lazily) by the Releases row's Timeline + Reviewers expands. 404 on unknown slug. | | |
| > The marked-ready unviewed-count / per-admin view-mark surface (`/reviews/unviewed-count`, `/reviews/<slug>/view`, the `review_views` table + `repo_review_views`, the `unread` / `unviewed_marked_ready` schema fields) was **removed** with the Releases restructure β marked-ready work moved to Releases without a notification. | |
| **Timestamps-generation** endpoints (`@require_capability("reviews.generate_timestamps")`, maintainer+) β drive the Releases-tab in-row `ReleasesTsSettings` / `ReleasesPastJobs` expands. Full subsystem: [timestamps-job.md](timestamps-job.md). | |
| - `POST /api/admin/generate-timestamps/<slug>` β launch the in-container MFA (alignment-only) job. Body β `_parse_ts_settings` β `TsJobSettings` (`beam` + `probe_beams` β `beams`, optional `chapters` for affected-only regen, Advanced workers/flavor/timeout β no audio/peaks toggles). Valid from `under_review`+marked-ready (first publish β auto-release on success) or `released` (regen). 202 `{job_id, url}`; 409 if a job is already running for the slug; 400 invalid; 404 unknown. `@require_same_origin`. Does **not** transition the reciter at launch. | |
| - `GET /api/admin/jobs/<job_id>` β live status + bounded log tail (HF authoritative). | |
| - `GET /api/admin/jobs/<job_id>/record` β persisted record (settings + status + full logs) from `jobs/ts/<job_id>.json`; 404 if none. | |
| - `GET /api/admin/reciters/<slug>/ts-jobs` β persisted records for the slug (newest first), for the Past-jobs expand's history list. | |
| Reviewer-management mutations reuse existing per-slug routes: | |
| - `POST /api/admin/claim/force-release/<slug>` β **owner-only** (`_require_owner` at both route + state-machine handler level). Used by the General-drawer popover's Remove path. | |
| - `POST /api/admin/claim/reassign/<slug>` β **owner-only**. Used by the popover's Change path; resolves `to_login` server-side via `hf_users.lookup` before persisting `claim.reassigned`. | |
| - `POST /api/admin/users/lookup` β maintainer+. Lookup-only (no mutation); the popover hits it on debounced input to render the resolved-user card. | |
| - `POST /api/admin/send-back/<slug>` β maintainer+ (quality gate, not a claim mutation). Rejects marked-ready work for more review. Surfaced on the Releases **Ready-to-generate** row (the new home of marked-ready work); sending back returns the reciter to `under_review`, where it reappears in the Reviews tab. | |
| - Publish / unpublish / unlock-for-revision are listed but **disabled** (see `docs/planning/reviews-tab-deferred.md`). | |
| The owner-only tightening on force-release + reassign was a deliberate split: **owners manage who reviews; maintainers gate what ships**. Maintainers retain Send-back-to-UR but cannot eject or transfer claims. | |
| ### Jobs compartment (unified job view) | |
| The **Jobs** tab is one place for every HF-Job kind β `timestamps`, `hf_publish`, `hf_publish_batch`, `cut_release`, `refresh_catalog` β running *and* historical, across all reciters. Clicking any row opens an in-app drawer (record + settings/logs, batch/cut members, "Open on HF", Cancel-if-running). | |
| **Shared job-record store** (`services/admin/jobs/records.py`) β the single source the tab reads. Every kind's **server-side** `launch()` writes a `running` `JobRecord` and every terminal point (`complete()`, the webhook non-success branches, the cancel route) writes the `succeeded`/`failed` outcome via `record_terminal` (read-merge-write, so launch fields survive). The DB stays the source-of-truth for *releases*; these records are the uniform observability trail. Paths reuse `base.job_record_path` β `reciters/<slug>/jobs/<kind>/` (per-slug) and `jobs/_global/<kind>/` (global). `JobRecord` (`qua_shared/schemas/bucket/jobs.py`) is a superset of the legacy `TsJobRecord` the timestamps HF-Job container still self-writes, so old TS records parse uniformly (`records.read` injects `kind` from the path + normalizes status). | |
| **Aggregator** (`services/admin/jobs/registry.py`) β `list_all_jobs()` unions `records.list_all()` with the live HF list (`base.list_in_flight_jobs`), dedups by `job_id` (live `running` wins), sorts newest-first, TTL-cached (`cache.{get,set,invalidate}_all_jobs_cache`, invalidated at every launch/complete/cancel). `job_detail()` enriches a running TS job with its live log tail. | |
| | Endpoint | What | | |
| |---|---| | |
| | `GET /api/admin/jobs` | `JobsListResponse` β unified list + `running_count` (`reviews.view`). | | |
| | `GET /api/admin/jobs/detail?kind=&job_id=&slug=` | One job's full `JobRecord` for the drawer (`reviews.view`). 404 if absent. | | |
| | `POST /api/admin/release-jobs/<job_id>/cancel` | Cancel β reused (kind-generic, per-kind cap gates). | | |
| > History caveat: `hf_publish` rows that predate the store, plus `cut_release`, are synthesized from the DB by the one-shot `scripts/backfills/backfill_job_records.py` (idempotent); going forward every kind records natively, including failures. | |
| ### Role change (`services/admin/users.py::set_role`) | |
| Roles are tri-state with contributor **implicit** (no `role_assignments` row). `set_role` reads the current role and dispatches to the existing `services/auth/access.py` mutations so authz + audit + `durable_transaction` stay centralized: | |
| | From β To | Action | | |
| |---|---| | |
| | contributor β maintainer/owner | `access.grant` | | |
| | maintainer β owner | `access.update` (in-place tier change) | | |
| | maintainer/owner β contributor | `access.revoke(cascade_release=False)` β **preserves the user's open claim** (a contributor is a valid claim holder; demote β offboard) | | |
| Guard: refuse to remove the **last active owner** (`repo_access.active_owner_count`) β `LastOwnerError` β HTTP 409. Other mappings: `NotAuthorized`β403, `MemberNotFound`β404, bad input / `AccessError`β400. | |
| > The legacy `/api/admin/access/{grant,revoke,update}` endpoints (`routes/admin/access.py`) remain β maintainer-capable, and `revoke` there keeps `cascade_release=True` (offboarding: auto-releases open claims). The picker is the owner-only path; offboarding is the access endpoints. | |
| ### Sync | |
| No special wiring. `durable_transaction` bumps `db_seq` β the admin-users list cache self-invalidates on next read; `current_user()` resolves role fresh per request (no TTL), so the affected user becomes their new role on their next request. A self-demoting owner's own FE refreshes via `loadCurrentUser()` immediately (and on any reload at minimum). | |
| See [auth-permissions.md](auth-permissions.md) for roles/predicates/edit-lock and [frontend.md](frontend.md) for the dashboard tab. | |