docs(09): capture phase context

This commit is contained in:
Jakub Zych
2026-09-23 10:24:22 +02:00
parent d20f99f2e6
commit 8268ff780d
2 changed files with 306 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
# Phase 9: Backend admin authentication and schema pipeline - Context
**Gathered:** 2026-09-23
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 9 delivers two things. First, a backend admin identity separate from frontend users: `backend_users` with roles, a permissions registry declared by plugins, an admin login on a second bouncer guard, and gating of both navigation and admin controller access. Second, the schema pipeline: each admin controller's `config_form.yaml`, `config_list.yaml`, `config_filter.yaml` and `config_relation.yaml` (pointing at the model's `fields.yaml` and `columns.yaml`) are parsed with goccy/go-yaml into typed JSON form, list, filter and relation-manager schemas served over a new framework-wide admin API, together with admin CRUD endpoints that expose the Winter extension hooks, bulk delete through lifecycle hooks, and a settings screen bound to a singleton settings model.
Framework code (guard, tables, permissions, navigation, schema parsers, admin API package, `admin:*` commands, scaffold layout change) lands in `summercms.go`. The five Płytarium controllers (Albums, Artists, Collections, Genres, Styles), their YAML, `HasPermissions`, `HasNavigation`, `HasSettings` and the Albums scoping hooks land in `fonoteka.go`. The Vue SPA that renders these schemas is Phase 10 and is out of scope here; this phase's acceptance is the JSON contract plus tests.
Requirements: AUTH-08, ADMIN-01, ADMIN-02, ADMIN-03, ADMIN-04, ADMIN-05. Security-load-bearing: apply the security-review agent to the admin guard, permission enforcement and mass-assignment on admin saves.
</domain>
<decisions>
## Implementation Decisions
### Admin identity and login
- **D-01:** Backend admins live in Winter-shaped tables: `backend_users` (login, email, password bcrypt, first_name, last_name, is_activated, is_superuser, role_id, last_login, timestamps, soft delete) and `backend_user_roles` (name, code, description, permissions JSON, is_system). Table and column names match Winter so an existing WinterCMS `backend_users` table can be copied straight in at cutover. `backend_user_groups` is not ported. Role codes `developer` and `publisher` are seeded as system roles, matching `UserRole::CODE_DEVELOPER` / `CODE_PUBLISHER`.
- **D-02:** Admin sessions are JWTs issued by a second named guard, `backend`, registered in the Phase 6 bouncer registry beside `jwt`. It has its own secret (`admin.jwt.secret`, fail-loud when empty, as Phase 3 did for the user secret) and an audience claim so a frontend token can never authenticate as an admin and vice versa. Mint, verify, TTL config, sliding refresh and the Postgres jti blacklist from Phase 7 are reused; Phase 7's bcrypt helpers hash and check admin passwords. No cookie session store and no new dependency (`alexedwards/scs` is not adopted).
- **D-03:** Permissions keep Winter semantics. Plugins implement `HasPermissions` returning entries in the `registerPermissions()` shape (code, tab, label, roles). Checks: `is_superuser` passes everything; otherwise the admin's role `permissions` JSON is consulted; a code ending in `.*` matches by prefix, so `golem15.fonoteka.*` on the top navigation item works unchanged. An admin controller declares `RequiredPermissions() []string` and the framework enforces it (403 with the standard error envelope) before any list, form, relation or bulk-delete handler runs.
- **D-04:** The first admin is created by a framework console command, `summer admin:create --email --password [--login] [--superuser] [--role=code]`, plus `summer admin:reset-password <login|email>`. Scaffolded the Phase 4 way. No boot-time seed from config and no web setup wizard.
### Schema pipeline shape
- **D-05:** YAML layout is Winter's, not the Phase 4 scaffold's. `controllers/<name>/config_form.yaml` and `config_list.yaml` name the model and point at `models/<model>/fields.yaml` and `columns.yaml`; `config_filter.yaml` and `config_relation.yaml` sit beside them when present. The five Płytarium YAML sets are copied across unchanged apart from the one `partial` field (D-15). `summer make:admin-controller` is changed to emit this layout, and `pact.AdminController.ConfigDir()` points at the `controllers/<name>/` directory; all config is embedded through the plugin's `fs.FS`.
- **D-06:** The JSON schema is typed: Go structs per field type and per column type, decoded with goccy/go-yaml `DisallowUnknownField` so an unsupported key or field type fails loudly at boot. JSON keys keep Winter's spelling (`nameFrom`, `emptyOption`, `span`, `tab`, `context`, `attributes`, `recordsPerPage`, `showSearch`, `toolbarButtons`) so the served schema reads like the YAML that produced it. Field types in scope: text, textarea, number, checkbox, switch, dropdown, relation, relation-manager. Column features in scope: searchable, sortable, `relation` + `select`, `type: datetime`, `type: switch`.
- **D-07:** Labels, comments, tabs, `emptyOption` and navigation labels are resolved server-side through phrasebook in the admin's locale (Accept-Language, then `app.locale`). The SPA receives display strings only. A missing key falls back to the raw key, as phrasebook already does. Every schema endpoint carries the locale it resolved in `meta`.
- **D-08:** `options: <methodName>` on a dropdown maps to an options-provider interface on the model: `DropdownOptions(field string) []Option` where `Option` is value plus label key (resolved per D-07). The pipeline calls it for any dropdown whose `options:` is a string and fails at boot if the model does not implement the interface. A YAML `options:` map is also accepted and served as-is. Album ports `getFormatOptions` this way.
### Admin CRUD API contract
- **D-09:** The admin API is a framework-wide, consistent layer for every non-parity API, owned by a new framework package and separate from the Phase 6 parity helpers in `fonoteka.go`. Paths: `/_admin/api/v1/{vendor}/{plugin}/{controller}` for records, `/_admin/api/v1/{vendor}/{plugin}/{controller}/schema/{form|list|filter}` and `.../schema/relation/{name}` for schemas, `/_admin/api/v1/auth/{login,refresh,logout,me}`, `/_admin/api/v1/navigation`, `/_admin/api/v1/settings/{code}` and `.../settings/{code}/schema`. Every handler carries swag v1 annotations so the OpenAPI document Phase 10 generates types from is complete by construction.
- **D-10:** One envelope on every endpoint. Success: `{"data": ..., "meta": {...}}`. Error: `{"error": {"code": "...", "message": "...", "details": {...}}}` with a fixed vocabulary: `unauthenticated` 401, `forbidden` 403, `not_found` 404, `validation_failed` 422 (details is field to messages), `conflict` 409. The group is mounted raw-style under the `backend` guard, so no fonoteka house middleware applies.
- **D-11:** One list contract for controller lists and relation-manager lists alike: query `search` (across `searchable` columns), `sort` and `dir` (only `sortable` columns, otherwise 422), `page`, `per_page` (default `recordsPerPage`, capped), and `filter[<scope>]=...`. Response meta is `{page, per_page, total, last_page}`. Relation columns join the related table and select the named column.
- **D-12:** Filters come from `config_filter.yaml`, referenced by `filter:` in `config_list.yaml`. Three scope shapes are ported now, because those are the shapes the Golem15 user and journal plugins use: `switch` with two conditions on a named column, `daterange` on a named column, and a model-backed scope (`modelClass`, `nameFrom`, `scope`). Raw SQL `conditions:` strings are not supported: a scope either names a model scope method (`scope: filterByGroup` resolved through a `FilterScope(name string, db *gorm.DB, value any) *gorm.DB` interface on the model) or uses the built-in switch/daterange handling on a declared column. A YAML `conditions:` key is a boot error with a message pointing at this rule. Remaining Winter scope types are deferred.
- **D-13:** Hooks are optional interfaces on the admin controller, type-asserted by the framework in the same style as pact's `Has*` capabilities: `ListExtendQuery(ctx, *gorm.DB) *gorm.DB`, `FormExtendQuery(ctx, *gorm.DB) *gorm.DB`, `FormBeforeCreate(ctx, model) error`, `FormBeforeUpdate(ctx, model) error`, `RelationExtendManageQuery(ctx, relation string, *gorm.DB) *gorm.DB`, `RelationBeforeLink(ctx, relation string, parent, related, pivot map) error`. The context carries the authenticated admin principal. Bulk delete (`POST .../bulk-delete` with `{ids}`) loads each record through `ListExtendQuery` and deletes it individually so Phase 5 lifecycle hooks and soft-delete cascades fire; it never issues a single `DELETE ... WHERE id IN`.
- **D-14:** The Albums scoping hooks resolve the admin's active collection exactly as the PHP `BackendAlbumCollectionResolver` does: the backend admin's email, lower-cased and trimmed, must match exactly one frontend user, and that user's active collection (Phase 3 resolver) is used. Zero or two matches return 422 with the PHP messages. `FormBeforeUpdate` rejects a cross-collection update with the `backend::lang.form.not_found` message, as PHP does. No collection picker in the SPA.
### Relation manager and settings
- **D-15:** `config_relation.yaml` is parsed into a typed relation-manager schema (label, `view.list.columns`, `manage.list.columns`, `toolbarButtons`, `showSearch`) served at the relation schema endpoint. In `fields.yaml` the Collections `editors` entry becomes `type: relation-manager` with `relation: editors`, `tab`, `span: full`, `context: update`; `type: partial` does not exist in Go and is a boot error. Endpoints: `GET /{id}/relations/{name}` (linked, view columns), `GET /{id}/relations/{name}/candidates` (manage columns, search, `RelationExtendManageQuery` applied, so the owner is excluded), `POST .../link` and `POST .../unlink` with `{ids}`.
- **D-16:** Link writes the pivot through the Phase 5 join-table contract and calls `RelationBeforeLink` so the app stamps pivot columns; for editors that is `role='editor'`, `granted_at=now`, `granted_by=<admin id>`. Unlink deletes the pivot row. The framework never hardcodes pivot column names.
- **D-17:** Settings pages are a framework capability: plugins implement `HasSettings` returning entries in the `registerSettings()` shape (code, label, description, category, icon, model, order, keywords, permissions). The model is a singleton-row GORM model with `Fillable()`, `Rules()` and a `models/<name>/fields.yaml`; the framework serves that form schema at `/settings/{code}/schema` and `GET`/`PUT /settings/{code}` reads and upserts the single row through `lagoon.Fill` and `lagoon.Validate`. Fonoteka binds its typed `golem15_fonoteka_settings` model (Phase 5) under code `fonoteka` gated by `golem15.fonoteka.manage_settings`.
- **D-18:** Navigation is declared through `HasNavigation` in the `registerNavigation()` shape (label, icon, permissions, order, `sideMenu`), except that `url` is replaced by `controller` holding the admin controller ID (`golem15.fonoteka.albums`); the SPA derives its route from that ID. `GET /navigation` returns only items the admin's permissions allow (D-03 wildcard rules), with labels resolved (D-07), plus a separate `settings` list built from `HasSettings` entries the admin may manage.
### Claude's Discretion
- Package name and internal layout of the admin API and schema packages, following the existing beach-themed naming.
- When YAML is parsed: recommended at boot with fail-loud errors naming plugin, controller and file, cached for the process lifetime.
- Whether `rules:` keys in `fields.yaml` are honoured in addition to the model's `Rules()`; at minimum `required: true` must produce a 422.
- Admin login throttling and password policy; reuse Phase 7's throttle shape if cheap, otherwise note as a Phase 10 or later hardening item.
- Whether soft-deleted rows appear in admin lists (recommended: hidden, matching Winter's default).
- Whether the settings row is created on first read or first write.
- Pagination of relation candidates (recommended: same list contract, D-11).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Roadmap and requirements
- `.planning/ROADMAP.md` §Phase 9 — goal, success criteria, repos.
- `.planning/REQUIREMENTS.md` — AUTH-08, ADMIN-01 through ADMIN-05.
- `.planning/research/ARCHITECTURE.md` §Admin Form Save Flow and the capability-interface listing — the `HasAdminControls`/`HasNavigation`/`HasPermissions` sketch this phase makes real (its `scs` cookie-session suggestion is superseded by D-02).
- `.planning/research/FEATURES.md` §Admin / forms — field and column feature inventory.
- `.planning/research/STACK.md` — goccy/go-yaml, swag v1, validator.
### Prior phase decisions this phase builds on
- `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md` — D-06 guard registry, D-16 raw groups, D-17 response conventions stay app-side.
- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md` — D-06 to D-10 JWT lifecycle and blacklist, D-19 bcrypt.
- `.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md` — Fillable/Hidden discipline, lagoon.Fill and lagoon.Validate, join-table contract, typed Settings table.
### PHP reference (fonoteka, read-only)
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php` — `registerPermissions()`, `registerSettings()`, `registerNavigation()` bodies to port.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/{albums,artists,collections,genres,styles}/` — `config_form.yaml`, `config_list.yaml`, Collections' `config_relation.yaml`, and the controller PHP with the hooks.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/{album,artist,collection,genre,style,settings}/fields.yaml` and `columns.yaml` — the YAML the pipeline must parse.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php` §getFormatOptions — the one model-method dropdown.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/BackendAlbumCollectionResolver.php` — email mapping for D-14.
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/config_filter.yaml` and `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/controllers/posts/config_filter.yaml` — the three filter scope shapes D-12 ports.
- `/media/nvme/dev/golem15/fonoteka/modules/backend/database/migrations/` — Winter's `backend_users` and `backend_user_roles` migrations, the column source for D-01.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `bouncer.Registry`, `Guard`, `CredentialGuard`, `UnauthorizedWriter` (`bouncer/registry.go`, `bouncer/guard.go`): the `backend` guard registers here under a new name with its own `UserProvider`.
- `bouncer.NewJWTGuard`, `Mint`, `VerifyClaims`, `NewPostgresBlacklist`, `HashPassword`, `CheckPassword` (`bouncer/jwt.go`, `mint.go`, `password.go`): admin auth reuses them; the guard constructor may need an audience parameter.
- `pact.AdminController` and `HasAdminControllers` (`pact/capabilities.go:100-112`): the only admin surface today; `HasNavigation` and `HasPermissions` exist as a comment at lines 140-142 and become real interfaces here.
- `internal/build/artifact.go:230-304` and `internal/build/stubs/artifacts.tmpl`: the `make:admin-controller` generator whose layout D-05 changes.
- `lagoon.Fill`, `lagoon.Validate`, `lagoon.Paginate`, `RegisterJoinTable` (Phase 5): admin saves, 422s, list meta and pivot writes go through these.
- `phrasebook` per-locale bundles (Phase 4): label resolution for D-07.
- `fonoteka.go/scripts/swagger2openapi.go` and `docs/openapi.json`: the swag v1 to OpenAPI 3 path the admin annotations feed.
- `fonoteka.go/plugins/golem15/fonoteka/models/settings.go`: the typed singleton D-17 binds.
- `fonoteka.go/plugins/golem15/fonoteka/models/collection.go:22`: the `Editors` many2many already declared.
### Established Patterns
- Optional capability interfaces type-asserted by the consumer package (pact); D-13, D-17, D-18 follow it.
- Fail-loud boot on missing secrets and malformed plugin assets (Phase 3 secret, Phase 4 mail catalog validation); D-02, D-06, D-08, D-12 follow it.
- Framework stays app-agnostic: no Fonoteka table names or pivot columns in `summercms.go` (D-16, D-14 resolver lives in `fonoteka.go`).
- Parameterized middleware and raw groups in surf (Phase 6 D-05, D-16): the admin group is a raw group under the `backend` guard.
### Integration Points
- `party.Activate` / surf assembly: where `HasPermissions`, `HasNavigation`, `HasSettings` and `HasAdminControllers` are collected and the admin routes mounted.
- `bouncer.Registry` shared through `backpack.App` Lookup/Publish as the user plugin does in `plugins/golem15/user/plugin.go:64-97`.
- `lagoon` migrations: two new framework-owned tables (`backend_users`, `backend_user_roles`) plus their seed of system roles.
- Phase 10 SPA consumes `/navigation`, the schema endpoints and the list/form/relation endpoints exactly as specified in D-09 to D-11.
</code_context>
<specifics>
## Specific Ideas
- The user explicitly wants this phase to be "our chance to introduce a nice and consistent API layer": the admin API contract in D-09 to D-11 is a framework convention meant to outlive Płytarium, not a one-off for five controllers.
- Filters must work the Winter way (`config_filter.yaml`), even though Płytarium has none; the user and journal plugins are the reference consumers.
- Model scope methods instead of SQL strings in YAML were accepted as a deliberate deviation from Winter.
</specifics>
<deferred>
## Deferred Ideas
- Remaining Winter filter scope types (`checkbox`, `dropdown`, `group`, `text`, `number`, `numberrange`, `date`) are added when a ported plugin needs them; the schema types should leave room for them. Note this in the roadmap when the user or journal plugin ports are planned.
- `backend_user_groups` and Winter's user-preference and access-log tables: not needed by any Golem15 plugin yet.
- Policy-based row or field-level admin permissions (Directus style) remain a post-v1 differentiator per FEATURES.md.
- A boot-time admin seed from environment variables for containers, if deployment needs it later.
- Admin login throttling and password policy hardening if not folded in under Claude's discretion.
</deferred>
---
*Phase: 09-backend-admin-authentication-and-schema-pipeline*
*Context gathered: 2026-09-23*

View File

@@ -0,0 +1,170 @@
# Phase 9: Backend admin authentication and schema pipeline - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-09-23
**Phase:** 09-backend-admin-authentication-and-schema-pipeline
**Areas discussed:** Admin identity and login, Schema pipeline shape, Admin CRUD API contract, Relation manager and settings
---
## Admin identity and login
| Option | Description | Selected |
|--------|-------------|----------|
| Winter-shaped tables | backend_users + backend_user_roles, groups skipped, is_superuser and role codes kept | ✓ |
| Lean admin model | One summer_admins table with role string and permissions JSON | |
| Reuse frontend users table with a flag | is_backend + role on users | |
**User's choice:** Winter-shaped tables
| Option | Description | Selected |
|--------|-------------|----------|
| JWT via a `backend` bouncer guard | Second guard beside `jwt`, own secret and audience, reuses Phase 7 mint/verify/blacklist | ✓ |
| Cookie sessions with scs | HttpOnly cookie, Postgres session store, CSRF | |
| You decide | | |
**User's choice:** JWT via a `backend` bouncer guard
| Option | Description | Selected |
|--------|-------------|----------|
| Winter strings with wildcard | HasPermissions in registerPermissions shape, is_superuser, role JSON, `.*` prefix match, RequiredPermissions() enforced by framework | ✓ |
| Flat permission list, no wildcards | Exact match only | |
| You decide | | |
**User's choice:** Winter strings with wildcard
| Option | Description | Selected |
|--------|-------------|----------|
| Console command | `summer admin:create` + `admin:reset-password` | ✓ |
| Seeded from config | Boot-time superuser from YAML/env | |
| Both | Command canonical, env seed opt-in | |
**User's choice:** Console command
---
## Schema pipeline shape
| Option | Description | Selected |
|--------|-------------|----------|
| Winter layout | config_form/config_list point at models/<model>/fields.yaml and columns.yaml; scaffold updated | ✓ |
| Scaffold layout | Keep controllers/<name>/fields.yaml as Phase 4 emits | |
| You decide | | |
**User's choice:** Winter layout
| Option | Description | Selected |
|--------|-------------|----------|
| Typed schema, Winter key names | Go structs per field type, unknown keys fail, Winter spelling in JSON | ✓ |
| Pass-through map | Generic map served as-is | |
| Typed schema, normalized names | snake_case house style | |
**User's choice:** Typed schema, Winter key names
| Option | Description | Selected |
|--------|-------------|----------|
| Resolved server-side | phrasebook resolves labels in the admin's locale; SPA gets strings | ✓ |
| Raw keys plus a lang bundle endpoint | SPA resolves locally | |
| Both fields | label and labelKey side by side | |
**User's choice:** Resolved server-side
| Option | Description | Selected |
|--------|-------------|----------|
| Options provider interface on the model | DropdownOptions(field) []Option; static YAML maps also work | ✓ |
| Registry keyed by method name | Controller registers a map of func by name | |
| You decide | | |
**User's choice:** Options provider interface on the model
---
## Admin CRUD API contract
| Option | Description | Selected |
|--------|-------------|----------|
| /_admin/api/v1, plain JSON, no envelope | Plain resource bodies, `{message, errors}` errors | |
| Reuse the fonoteka house envelope | Same helpers as /_fonoteka/api/v1 | |
| You decide | | |
**User's choice:** Free text: "Do you really recommend 1? Our chance to introduce nice and consistent api layer." Claude proposed a framework-wide layer (plugin-namespaced paths, one envelope with a fixed error-code vocabulary, one list contract, one verb set, relation endpoints under the record, swag-annotated framework package). User: "Yes, let's proceed with that."
**Notes:** Recorded as D-09 to D-11.
| Option | Description | Selected |
|--------|-------------|----------|
| Winter list features only | search, sort, dir, page, recordsPerPage, relation columns | |
| Add per-column filters | filter[column]=value exact matches | |
| You decide | | |
**User's choice:** Free text: "Filters in Winter are done with config_filters.yaml we need filters like that here." Claude found the user and journal plugins' config_filter.yaml files (switch, daterange, model-backed scope) and asked about scope coverage and raw SQL `conditions:`. User: "Port 3 shapes now, note for future phases that this will be extended and all shapes will be introduced in time. Model scope methods instead of SQL strings are fully acceptable."
**Notes:** Recorded as D-12; remaining scope types deferred.
| Option | Description | Selected |
|--------|-------------|----------|
| Optional interfaces on the controller | Type-asserted hooks with admin principal in context; bulk delete per record | ✓ |
| Struct of func fields | Optional func fields on AdminController | |
| You decide | | |
**User's choice:** Optional interfaces on the controller
| Option | Description | Selected |
|--------|-------------|----------|
| Port the email mapping | Admin email must match exactly one frontend user; reuse its active collection; 422 otherwise | ✓ |
| Explicit collection picker | SPA header on every request | |
| Both | Mapping default, header override for superusers | |
**User's choice:** Port the email mapping
---
## Relation manager and settings
| Option | Description | Selected |
|--------|-------------|----------|
| config_relation.yaml as a first-class schema | Typed relation schema; `type: relation-manager` replaces `partial` | ✓ |
| Keep `partial` as an alias | Map partial to relation manager | |
| You decide | | |
**User's choice:** config_relation.yaml as a first-class schema
| Option | Description | Selected |
|--------|-------------|----------|
| Pivot fields stamped by the framework hook | RelationBeforeLink stamps role, granted_at, granted_by; unlink deletes pivot | ✓ |
| Bare pivot insert | Only the two foreign keys | |
| You decide | | |
**User's choice:** Pivot fields stamped by the framework hook
| Option | Description | Selected |
|--------|-------------|----------|
| HasSettings capability + singleton model | registerSettings shape, /settings/{code} GET/PUT via Fillable | ✓ |
| Settings as an ordinary admin controller | Form with no list, hand-written per page | |
| You decide | | |
**User's choice:** HasSettings capability + singleton model
| Option | Description | Selected |
|--------|-------------|----------|
| HasNavigation in Winter's shape, URL becomes a route key | url replaced by controller ID; /navigation filtered by permissions | ✓ |
| Winter shape with literal URLs | Keep url strings | |
| You decide | | |
**User's choice:** HasNavigation in Winter's shape, URL becomes a route key
---
## Claude's Discretion
- Package naming and layout for the admin API and schema packages.
- YAML parse timing (boot vs lazy), `rules:` in fields.yaml vs model Rules().
- Admin login throttling and password policy.
- Soft-deleted rows in admin lists; settings row creation timing; relation candidate pagination.
## Deferred Ideas
- Remaining Winter filter scope types.
- backend_user_groups, preferences, access logs.
- Policy-based row/field-level permissions.
- Boot-time admin seed from environment.