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.
29 KiB
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:
// 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.Runajson:"-"` tag the same way. Context-taking hook shape (205-208):
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):
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):
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):
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"`tolistDocument(30-42) —decodeStrict` rejects unknown keys automatically.
Decode-time check that must move (Pitfall 1), lines 288-310:
// 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:
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).
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):
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:
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:
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:
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"}):
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.
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):
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.
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:
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.
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):
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:
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):
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:
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:
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):
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:
{
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
{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:
{"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
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
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