# 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 `
` 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([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() ...