Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-PATTERNS.md
Jakub Zych 9b98d8409f docs(10.1): record D-18/D-19, resolve research questions, add pattern map
Plan checker iteration 1 flagged unresolved research questions and a
missing decision note for golang.org/x/net/html. D-18 approves x/net/html
for the partial sanitizer; D-19 fixes the Discogs widget fill to
[year, format]. STATE marks the phase ready to execute.
2026-09-28 22:44:34 +02:00

556 lines
29 KiB
Markdown

# Phase 10.1: Runtime admin extension point - Pattern Map
**Mapped:** 2026-09-28
**Files analyzed:** 38 (new + modified, both repos)
**Analogs found:** 35 / 38
All analog paths below are git-tracked (verified with `git ls-files` in each repo). Line numbers are from the tree at commit b2845e0 (summercms.go) and the current fonoteka.go HEAD.
## File Classification
### Plan 01 — framework Go (`summercms.go`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/pact/capabilities.go` (mod) | contract/interface | n/a | same file: `AdminAssets` 115-119, `SettingsItem` func field 163-175, `FormExtendQuery` 205-208 | exact |
| `modules/cabana/form_schema.go` (mod) | config compiler | transform (YAML → schema) | same file `compileFieldNode` 321-389, `formFieldKeys` 33-37 | exact |
| `modules/cabana/list_schema.go` (mod) | config compiler | transform | same file `toolbarButtons.UnmarshalYAML` 291-320, `compileToolbarButtons` 322-333, `listDocument` 30-42 | exact |
| `modules/cabana/registry.go` (mod) | boot wiring | batch (boot) | same file `compileRegistry` 53-98, `compileContributions` 173-192 | exact |
| `modules/cabana/extension.go` (new) | boot validator | batch (boot) | `registry.go` `compileContributions` + `validatePermissions` 212-219; `form_schema.go` `requireDropdownProvider` 186-217 | role-match |
| `modules/cabana/actions.go` (new) | controller (HTTP handler) | request-response (POST) | `http.go` `relationMutation` 442-471 + `decodeRelationMutation` 473-486; `crud.go` `loadRecord` 435-455 | exact |
| `modules/cabana/partial_render.go` (new) | service (render + sanitize) | transform | `form_schema.go` `Localize`/`translateKey` 126-160, 244-249 (per-request translation); no HTML sanitizer exists | partial |
| `modules/cabana/plugin_assets.go` (new) | static file handler | file-I/O (embed.FS) | `modules/boardwalk/boardwalk.go` `serveFile` 135-148, `contentType` 150-159, `setSecurityHeaders` 161-167 | exact |
| `modules/boardwalk/boardwalk.go` (mod: export `ContentType`, `SetSecurityHeaders`) | utility | n/a | same file 150-167 | exact |
| `modules/cabana/http.go` (mod: mount 3 API + 1 asset route) | route | request-response | same file `mount` 185-245, `constrainRelation` 253-256 | exact |
| `modules/cabana/schema_types.go` (mod: `ControllerAssets`, `ToolbarAction`, `HeaderPartial` on ListSchema/FormView, `PartialNode`, `PartialView`, `AdminActionRequest`) | model (DTO) | n/a | existing `ListSchema`/`FormView` types in same file | exact |
| `modules/cabana/admin_openapi.go` (mod) | config (swag annotations) | n/a | same file `AdminBulkDelete` 370-388 | exact |
| `modules/cabana/crud.go` (mod, only if a non-locking scoped read helper is added) | service | CRUD | `loadRecord` 435-455 (copy minus `clause.Locking`) | exact |
| `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (mod: `extension:` group) | config (i18n) | n/a | same file `form:` group (line 54, `unsupported_field` 75) | exact |
| `modules/cabana/README.md` (mod) | docs | n/a | same file (CLAUDE.md doc rule) | exact |
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts` (regen) | generated | n/a | `scripts/check-admin-openapi.sh` output | exact |
| `go.mod` (promote `golang.org/x/net` to direct) | config | n/a | — | n/a |
### Plan 02 — framework SPA (`summercms.go/admin`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `admin/src/app/pluginAssets.ts` (new) | utility (loader) | event-driven (DOM load/error) | `admin/src/app/runtime.ts` (module-level singleton, `runtime.base`) | partial |
| `admin/src/components/form/fields/WidgetField.vue` (new) | component | event-driven + request-response | `fields/UnsupportedField.vue` (failure-box geometry), `views/ListView.vue` `onDelete` 184-212 (POST → toast) | role-match |
| `admin/src/components/form/fields/PartialField.vue` (new) | component | request-response (GET) | `fields/UnsupportedField.vue` + `FieldControlProps` in `control.ts` 7-21 | role-match |
| `admin/src/components/partial/PartialHost.vue` (new) | component (h() renderer) | transform | `components/form/control.ts` `allowedAttributes`/`controlAttributes` 25-50 (client allowlist idiom) | partial |
| `admin/src/components/form/formContext.ts` (new) | provider (InjectionKey) | n/a | `components/form/control.ts` (shared-types module split from registry to avoid cycles) | partial |
| `admin/src/components/form/registry.ts` (mod) | registry | n/a | same file 27-59 | exact |
| `admin/src/components/form/formState.ts` (mod, only via registry `isRegistered`) | utility | transform | same file `editablePayload` 46-58 | exact |
| `admin/src/components/form/FormField.vue` (mod: span label + role=group for widget/partial) | component | n/a | same file (`ownsLabel` branch) | exact |
| `admin/src/components/list/ListToolbar.vue` (mod) | component | event-driven | same file 1-58 (delete button loop 236-247 of template) | exact |
| `admin/src/views/ListView.vue` (mod) | view | request-response | same file 66-68 (button split), 184-212 (`onDelete`), 216-247 (template) | exact |
| `admin/src/views/FormView.vue` (mod: provide values/patch, load assets) | view | request-response | same file 131-160 (`dirty`, `load`) | exact |
| `admin/src/api/types.ts` (mod: `Schemas['cabana.X']` aliases) | model (types) | n/a | existing aliases (gate regex `check-phase10.sh:330`) | exact |
| `admin/src/styles/main.css` (mod: `.summer-partial`, `.summer-stats` kit in `@layer components`) | config (CSS) | n/a | existing `@layer components` in same file | exact |
| `admin/vite.config.ts` (mod: `${devPrefix}/assets` proxy) | config | n/a | existing `${devPrefix}/api` proxy entry | exact |
| `modules/boardwalk/dist/**` (rebuild) | generated | n/a | `scripts/check-admin-dist.sh` | exact |
### Plan 03 — application (`fonoteka.go/plugins/golem15/fonoteka`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `controllers/albums_admin_controller.go` (mod: `AdminJS/AdminCSS`, `AdminActions`, `PartialData`) | controller | CRUD (read counts) + request-response | same file: `DropdownOptions` 42-53, `scopeAlbums` 99-108, `orderedOptions` 149-169 | exact |
| `controllers/albums/config_list.yaml` (mod) | config | n/a | same file 1-19 | exact |
| `controllers/albums/_stats.htm` (new) | template | transform | UI-SPEC recommended `<dl class="summer-stats">` markup | no code analog |
| `models/album/fields.yaml` (mod: `year`, `discogs` widget) | config | n/a | same file 1-28 | exact |
| `assets/js/discogs-lookup.js` (new) | component (custom element) | event-driven | RESEARCH "Plugin custom element" example | no code analog |
| `assets/css/albums.css` (new) | config (CSS) | n/a | UI-SPEC S3 visual contract | no code analog |
| `admin.go` (mod: extend `//go:embed` list) | config | file-I/O | same file line 12 | exact |
| `lang/{en,pl}/lang.yaml` (mod: `discogs.*`, `stats.*`, `item.year`) | config (i18n) | n/a | same files, `discogs:` group line 197, `album_format:` 161 | exact |
| `admin_albums_test.go`, `admin_phase10_copy_test.go`, `admin_phase10_controllers_test.go` (mod) | test | n/a | same files (Pitfall 3: lines 132, 357 / 48 / 100) | exact |
### Plan 04 — unit tests + gate + evidence
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/cabana/testdata/extension/**` (acme fixture tree) | test fixture | n/a | `openapi_conformance_test.go` `conformPlugin`/`conformController`/`conformFS` 365-440 | exact |
| `modules/cabana/phase101_*_test.go` (schema, toolbar, sanitizer, assets, actions) | test | n/a | `form_schema_test.go` table 215-285; `openapi_conformance_test.go` 110-135 | exact |
| `modules/cabana/form_schema_test.go`, `list_schema_test.go` (mod) | test | n/a | same files (partial cases 224-238, "bad form fails activation" 273-284) | exact |
| `modules/cabana/security_coverage_test.go` (mod: `phase09Routes`) | test (inventory) | n/a | same file 33-62, handler table ~266 | exact |
| `modules/cabana/openapi_conformance_test.go` (mod) | test | request-response | same file 118-130 | exact |
| `admin/tests/app/pluginAssets.test.ts`, `tests/form/WidgetField.test.ts`, `tests/form/PartialField.test.ts`, `tests/list/PartialHost.test.ts` (new); `tests/list/ListToolbar.test.ts`, `ListView.test.ts`, `tests/form/registry.test.ts`, `formState.test.ts` (mod) | test | n/a | `admin/tests/list/ListToolbar.test.ts` 1-40 | exact |
| `admin/tests/fixtures/extension.*.json` (new) | fixture | n/a | `admin/tests/fixtures/widgets.list-schema.json`, `widgets.form-schema.json` | exact |
| `fonoteka.go/.../admin_phase101_albums_test.go` (new) | test (Postgres) | CRUD | `admin_albums_test.go` (`TestAlbumsAdminForm` line 28) | exact |
| `scripts/check-phase10.1.sh` (new) | gate script | batch | `scripts/check-phase10.sh` (1-60 header/`phase10_detect`, 361-420 hygiene/evidence/dispatch); newest sibling `scripts/check-phase10.2.sh` | exact |
| `.planning/phases/10.1-.../10.1-SECURITY-REVIEW.md`, `10.1-VALIDATION.md` | docs | n/a | `.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md` | exact |
---
## Pattern Assignments
### `modules/pact/capabilities.go` (contract)
**Analog:** same file. Optional interfaces are one-method, doc-commented, type-asserted by cabana.
Lines 115-130:
```go
// AdminAssets is the plugin-owned embedded tree of Winter admin YAML.
// Paths are relative to the plugin root (controllers/..., models/...).
type AdminAssets interface {
AdminFS() fs.FS
}
// AdminPermissioned is the D-03 permission list enforced before schema or SQL.
type AdminPermissioned interface {
RequiredPermissions() []string
}
```
Func-field-in-struct precedent (lines 163-175): `Form string \`json:"-"\`` / `NewModel func() any \`json:"-"\``. Give `AdminAction.Run` a `json:"-"` tag the same way. Context-taking hook shape (205-208):
```go
type FormExtendQuery interface {
FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
}
```
Add `AdminClientAssets`, `AdminAction`, `AdminActionInput`, `AdminActionResult`, `HasAdminActions`, `AdminPartialData` (RESEARCH Pattern 1) right after `AdminRecordSource`. Update `modules/pact/README.md` API reference in the same commit.
---
### `modules/cabana/form_schema.go` (config compiler)
**Analog:** same file.
Key allowlist to extend (33-37): add `"widget"`, `"action"`, `"fill"`, `"path"` to `formFieldKeys`; add `"widget"`, `"partial"` to `formFieldTypes` (23-26).
Rejection to replace (344-349):
```go
if typ == "partial" {
return FormField{}, fmt.Errorf("type partial is not supported")
}
if _, ok := formFieldTypes[typ]; !ok {
return FormField{}, fmt.Errorf("unsupported type %s", typ)
}
```
Per-key decode idiom to copy for `widget`/`action`/`path` (351-356):
```go
if node, ok := values["label"]; ok {
field.Label, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("label: %w", err)
}
}
```
Key-on-wrong-type rule: after `typ` is known, reject `values["widget"|"action"|"fill"]` unless `typ == "widget"` and `values["path"]` unless `typ == "partial"`. Tag regex / `{vendor}-{plugin}-` prefix, fill ⊆ `cc.Writable` and template existence need the controller and plugin id, so they belong in `extension.go` (post-`BindWritableFields`), not in the decoder. `compileSetting` reuses `decodeFields`: reject `widget`/`partial` there (rule 9).
Controller-capability boot check to copy (186-198, `requireDropdownProvider`):
```go
if method == "" || dropdownProvider(ctl) != nil {
return nil
}
return fmt.Errorf("dropdown method %s requires DropdownOptions", method)
```
Localize (136-147): add `translateKey` for any new label-bearing fields; widget `label`/`busy-label` come from the action `Label` resolved here.
---
### `modules/cabana/list_schema.go` (config compiler)
**Analog:** same file.
Add `HeaderPartial string \`yaml:"headerPartial"\`` to `listDocument` (30-42) — `decodeStrict` rejects unknown keys automatically.
Decode-time check that must move (Pitfall 1), lines 288-310:
```go
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
...
if _, ok := toolbarActions[action]; !ok {
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
}
```
Keep `nodeString`/identifier + duplicate checks in `UnmarshalYAML`; drop the membership test. Resolve membership in `compileToolbarButtons` (322-333), which gains the controller:
```go
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
...
for _, action := range out {
if action == "delete" && !showCheckboxes {
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
}
}
```
Call site (128-131) wraps errors with `bootErr(pluginID, ctl.ID(), cfgPath, err)` — keep that. Note `registry.go` 80-83 strips `create` via `withoutAction`; custom names must survive it.
---
### `modules/cabana/registry.go` + `modules/cabana/extension.go` (boot)
**Analog:** `compileRegistry` 53-98 (per-controller compile then `BindWritableFields`), `compileContributions` 173-192 (permission validation after all plugins' permissions are known).
```go
compiled := &CompiledController{ ... }
if err := BindWritableFields(compiled); err != nil {
return nil, err
}
byID[id] = compiled
```
Insert `compileExtension(item.plugin.ID(), compiled, assets.AdminFS())` after `BindWritableFields` (fill ⊆ `cc.Writable`, widget tag, action registered, partial templates parse, asset files read + hashed). Store results on `CompiledController` (new fields: `Actions map[string]pact.AdminAction`, `Partials map[string]*compiledPartial`, `Assets ControllerAssets`).
Action permissions go next to relation permissions (177-181):
```go
for name, relation := range controller.Relations {
if err := reg.validatePermissions("relation "+id+"."+name, relation.RequiredPermissions); err != nil {
return err
}
}
```
→ `reg.validatePermissions("action "+id+"."+name, action.Permissions)`. Reserved `assets` segment already exists (line 40), no change.
---
### `modules/cabana/actions.go` (HTTP handlers, POST)
**Analog:** `http.go` `relationMutation` 442-471 and `decodeRelationMutation` 473-486.
Handler shape:
```go
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
s.protect(w, r, func(cc *CompiledController) {
...
in, err := decodeRelationMutation(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
...
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
```
Strict body decode to copy verbatim for `AdminActionRequest`:
```go
dec := json.NewDecoder(r.Body)
dec.UseNumber()
dec.DisallowUnknownFields()
var in RelationMutationInput
if err := dec.Decode(&in); err != nil {
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
```
Action-level permission (403) after `protect` — copy from `protect` 713-717:
```go
if !Allows(principal, requiredOf(cc.Controller)) {
s.logAuth(r, "denied", principal.ID)
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
```
Scoped record read: copy `crud.go` `loadRecord` 435-455 **without** `.Clauses(clause.Locking{Strength: "UPDATE"})`:
```go
q := tx.WithContext(ctx)
if cc != nil {
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
if next := ext.FormExtendQuery(ctx, q); next != nil {
q = next
}
}
}
err := q.Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).Take(dest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return recordNotFound{}
}
```
Error mapping: `writeCRUDError` (crud.go 387-404) gives 422/404/500. Name the partial type away from `partialSelection` (crud.go 398, Pitfall 13).
---
### `modules/cabana/http.go` (route mount)
**Analog:** `mount` 198-245.
```go
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
...
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
constrainController(g)
...
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
g.Get("", s.serveSPA)
g.Get("/{path...}", s.serveSPA)
```
Add a `constrainAction(g)` helper modelled on `constrainRelation` (253-256):
```go
func constrainRelation(g pact.Router) {
constrainController(g)
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
```
Mount the asset route **before** `/{path...}` in the prefix group; on allowlist miss call `s.serveSPA(w, r)` (157-163).
---
### `modules/cabana/plugin_assets.go` (static serving)
**Analog:** `modules/boardwalk/boardwalk.go` 135-167.
```go
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
body, err := fs.ReadFile(h.root, name)
...
w.Header().Set("Content-Type", contentType(name))
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
}
func setSecurityHeaders(h http.Header) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Robots-Tag", "noindex, nofollow")
}
```
Export `ContentType` and `SetSecurityHeaders` from boardwalk (update `modules/boardwalk/README.md`). Plugin handler: always `no-cache` + `ETag` (never the `immutable` branch), add `Cross-Origin-Resource-Policy: same-origin`, body from the boot-built map, not `fs.ReadFile` per request.
---
### `modules/cabana/partial_render.go` (render + sanitize)
**Analog (partial):** per-request translation idiom from `form_schema.go` 244-249:
```go
func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string {
if key == "" || tr == nil {
return key
}
return tr.Get(ctx, key, nil)
}
```
and `s.translator()` (http.go 534-543). The Clone/Funcs/ParseFragment/allowlist core has no codebase analog — use RESEARCH "Partial render + allowlist (sketch)" and Pattern 4 allowlist verbatim. Handler for `GET …/partials/{name}` follows `formSchema` (496-513): `protect` → lookup → `WriteData(w, 200, view, nil)`.
---
### `modules/cabana/admin_openapi.go` (swag)
**Analog:** lines 370-388 (`AdminBulkDelete`). Copy the block per route; POSTs keep `@Accept json`, `@Param body body AdminActionRequest true "..."`, `@Success 200 {object} Envelope[AdminActionResult]`, 401/403/404/422 failures. GET partial: `@Param id query integer false "Record id"`, `@Success 200 {object} Envelope[PartialView]`. Regenerate with `scripts/check-admin-openapi.sh`.
---
### `admin/src/components/form/registry.ts`
**Analog:** same file 27-59.
```ts
const recordBound = new Set<string>([RELATION_MANAGER])
export function isRegistered(type: string): boolean {
return renderers.has(type) && !recordBound.has(type)
}
export function needsRecord(type: string): boolean {
return recordBound.has(type)
}
```
Add `['widget', WidgetField]`, `['partial', PartialField]` to `renderers`; add `const valueless = new Set([RELATION_MANAGER, 'widget', 'partial'])` and switch `isRegistered` to it; leave `needsRecord` on `recordBound` (Pitfall 4). `editablePayload` (formState.ts 46-58) then skips them with no change. New field components import `../control`, never `../registry` (cycle note, lines 18-20).
---
### `admin/src/components/form/fields/WidgetField.vue` / `PartialField.vue`
**Analog:** `fields/UnsupportedField.vue` (props + failure box):
```vue
const props = defineProps<FieldControlProps>()
...
<div
:id="controlId"
data-unsupported-field
:aria-describedby="describedBy || undefined"
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
>
<Puzzle :size="16" aria-hidden="true" />
```
Use the same class string for the S5 failure box, swap `Puzzle` for `CircleAlert` with `text-danger`, add `role="alert"`. `FieldControlProps` (control.ts 7-21) already carries `recordId` and `source` (controller params for the POST path).
POST → toast idiom from `ListView.vue` `onDelete` 197-211:
```ts
deleting.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk-delete', { params: { path }, body: { ids } })
if (result.data) {
showToast(message(messages.value?.deleted, result.data.data.deleted))
...
return
}
showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger')
} catch {
showToast(t('backend::lang.list.delete_failed'), 'danger')
} finally {
deleting.value = false
}
```
Replace the fallback key with `backend::lang.extension.action_failed`. `api` from `../../../api/client` (the only allowed `fetch` site).
---
### `admin/src/components/list/ListToolbar.vue` + `admin/src/views/ListView.vue`
**Analog:** same files.
ListView split (66-68):
```ts
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
```
→ `toolbarButtons` keeps declared order, excludes `create`, and keeps names that are `delete` or present in `schema.toolbarActions`. ListToolbar template loop (`<template v-for="button in buttons">` with `v-if="button === 'delete'"`) gains a `v-else` outline `Button` with `data-action="{name}"` emitting `action: [name]`. Insert `<PartialHost>` between `</header>` and the card `<div class="overflow-hidden rounded-card …">` (lines 234-235). Refetch it after `loadList()` in `onDelete` and after a custom action.
---
### `admin/src/app/pluginAssets.ts`
**Analog (partial):** `admin/src/app/runtime.ts` — module-level constant state and `runtime.base` for the prefix check:
```ts
export const runtime = {
/** Admin mount path, for example /backend. */
base,
/** Admin API root, the mount path plus /api/v1. */
api: `${base}/api/v1`,
} as const
```
Loader body: RESEARCH "SPA loader (sketch)". Must not contain `fetch(` (hygiene).
---
### `fonoteka.go/.../controllers/albums_admin_controller.go`
**Analog:** same file. Value-receiver methods, db resolved per call, scope through `scopeAlbums`:
```go
func (c albumsAdminController) scopeAlbums(ctx context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
binding, err := c.resolveBinding(ctx)
if err != nil {
return db.Where("1 = 0")
}
return db.Where("golem15_fonoteka_albums.collection_id = ?", binding.CollectionID)
}
```
Nil-db guard + query idiom for the stats view model (149-163):
```go
if c.db == nil {
return nil
}
db := c.db()
if db == nil {
return nil
}
var rows []struct { ... }
if err := db.Raw(...).Scan(&rows).Error; err != nil {
```
For stats use `c.scopeAlbums(ctx, db.WithContext(ctx).Model(&models.Album{}))` then `Group("format")`; return a curated struct of ints (never `*models.Album`). Format order from `models.Album{}.DropdownOptions("format")` (line 45). `AdminActions` stub: RESEARCH "Stub action (fonoteka)", permission `golem15.fonoteka.access_albums` (already registered, `admin_permissions.go:35`).
### `fonoteka.go/.../admin.go`
Line 12 `//go:embed ...` list: append `controllers/albums/_stats.htm assets/js/discogs-lookup.js assets/css/albums.css`. `AdminFS()` (line 15) unchanged — assets are read from that FS by cabana.
### `fonoteka.go/.../controllers/albums/config_list.yaml` and `models/album/fields.yaml`
Add `headerPartial: stats` at top level and `buttons: [create, delete, discogsSync]` (line 10). In fields.yaml add after `shelf` (25-28) a `year: {label: golem15.fonoteka::lang.item.year, span: right, type: number}` and last a `discogs` widget per UI-SPEC "Application proof".
---
### Tests (Plan 04)
**Go boot-error table** — copy `form_schema_test.go` 215-284:
```go
{
name: "partial path",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n path: $/golem15/acme/controllers/collections/_editors.htm\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"},
},
...
_, err := CompileForm("acme.demo", schemaController{model: model}, formFS(tc.config, tc.fields))
if err == nil {
t.Fatal("expected boot error")
}
```
The existing `partial` and `bad form fails activation` cases (224-229, 273-284) must be rewritten: a bare `type: partial` without `path` still fails; the `$/…` case still fails but with the new hint message.
**Fixture plugin** — copy `openapi_conformance_test.go` 365-440 (`conformPlugin` with `ID/Requires/Register/Boot/AdminControllers/Permissions/AdminFS`, `conformController`, `conformFS()` built from `fstest.MapFS`). The 10.1 fixture adds `AdminJS/AdminCSS`, `AdminActions`, `PartialData` on the controller and `controllers/gadgets/_stats.htm`, `_summary.htm`, `assets/js/lookup.js`, `assets/css/gadgets.css` in the FS. Vocabulary: acme/gadgets/lookup only.
**Inventory** — `security_coverage_test.go` 33-62: append
```go
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
{key: "GET /{vendor}/{plugin}/{controller}/partials/{name}"},
{key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true},
```
plus handler-table rows near line 266 (`{"bulk-delete", (*service).bulkDelete}` pattern).
**Conformance** — `openapi_conformance_test.go` 127-130 pattern:
```go
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
...
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": ...}, true)
}, into[cabana.Envelope[cabana.BulkResult]]()},
```
**Vitest** — `admin/tests/list/ListToolbar.test.ts` 1-40: `mount(Component, { props })`, `resetState()` from `../helpers` in `beforeEach`, `data-*` selectors, `wrapper.emitted(...)`. Every new `admin/src` module must be imported by some test (hygiene `check-phase10.sh` ~350-357).
**Gate script** — `scripts/check-phase10.sh`: header/`set -euo pipefail`/`ROOT`/`APP` (13-21), `usage()` (23-37), `phase10_detect` python JSON detector (44+), `run_hygiene` (361-364), `run_evidence` python checker (366-403: change required IDs to `T-10.1-01..13` + `T-10.1-SC` and requirement to `ADMIN-07`), `case` dispatch (406+). Call `scripts/check-phase10.sh --hygiene` from the new `--hygiene` stage and add only the extra regex (`setHTML|setHTMLUnsafe|createContextualFragment|DOMParser|srcdoc|document\.write` in `admin/src`; `fetch\(|XMLHttpRequest|document\.cookie` in `$APP/plugins/**/assets/**/*.js`). `scripts/check-phase10.2.sh` is the most recent sibling for structure.
---
## Shared Patterns
### Fail-loud boot errors
**Source:** `modules/cabana/form_schema.go` 69-104, `list_schema.go` 84-131
**Apply to:** form/list/extension compilers
```go
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
```
Always wrap with `bootErr(pluginID, controllerID, file, err)` so messages name plugin, controller and file (tests assert on all three).
### Auth + CSRF on admin routes
**Source:** `http.go` 198-226 (`requireAjax` wrapper on every unsafe method) + `protect` 701-719
**Apply to:** widget and toolbar POSTs, partial GET (no `requireAjax` on GET). `TestPhase10CSRF` auto-walks new POSTs.
### Response envelope and error mapping
**Source:** `WriteData(w, status, data, meta)`, `WriteError(w, status, code, msg)`, `writeCRUDError` (crud.go 387-404)
**Apply to:** all new handlers.
### Optional capability type-assertion
**Source:** `form_schema.go` `dropdownProvider` 200-217; `crud.go` 439-443
```go
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
```
**Apply to:** `AdminClientAssets`, `HasAdminActions`, `AdminPartialData` lookups.
### Phrase keys, not literals
**Source:** `modules/phrasebook/backend/lang/en/lang.yaml` (`form:` group, line 54); SPA `t('backend::lang.…')` from `app/i18n`
**Apply to:** all SPA copy and toast fallbacks; action `Label`/`Message` go through `translateKey`.
### Framework hygiene
**Source:** `scripts/check-phase10.sh` ~315-330
**Apply to:** everything in `summercms.go`: no application names, no raw-HTML sink words (even in comments), no `fetch(` outside `api/client.ts`, `types.ts` aliases only `Schemas['cabana.X']`.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `modules/cabana/partial_render.go` (sanitizer core) | service | transform | No HTML parsing/allowlist code exists; use RESEARCH Pattern 4 + sketch |
| `fonoteka.go/.../assets/js/discogs-lookup.js` | custom element | event-driven | First plugin JS in the stack; use RESEARCH plugin custom element example + UI-SPEC S3 |
| `fonoteka.go/.../controllers/albums/_stats.htm` | html/template | transform | First server template; use UI-SPEC `.summer-stats` markup |
## Metadata
**Analog search scope:** `modules/pact`, `modules/cabana`, `modules/boardwalk`, `admin/src`, `admin/tests`, `scripts/`, `../fonoteka.go/plugins/golem15/fonoteka`
**Files scanned:** ~30
**Pattern extraction date:** 2026-09-28