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

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