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.
This commit is contained in:
Jakub Zych
2026-09-28 22:44:34 +02:00
parent ccdc014078
commit 9b98d8409f
7 changed files with 591 additions and 28 deletions

View File

@@ -146,7 +146,7 @@ Existing seams (read, do not re-derive):
- Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it (REQUIREMENTS.md). Truths are derived from CONTEXT D-01..D-17 and the UI-SPEC.
- D-12's "POST path" is realised as the cabana-owned route `.../toolbar/{action}`: surf refuses any plugin route under the admin prefix (`TestPhase10AdminPrefixCollision`), so a controller registers the action (name, label, permissions, Go handler) and cabana owns the path, CSRF, auth and scope.
- `golang.org/x/net/html` is the dependency named by 10.1-RESEARCH (Standard Stack, Open Question 2); it is already `golang.org/x/net v0.58.0 // indirect` in go.mod, so promoting it adds no module (CLAUDE.md rule 4).
- `golang.org/x/net/html` is the dependency named by 10.1-RESEARCH (Standard Stack, Open Question 2) and approved by CONTEXT D-18; it is already `golang.org/x/net v0.58.0 // indirect` in go.mod, so promoting it adds no module (CLAUDE.md rule 4).
<assumption_delta_decision>
Signal: `chosen` ("custom"): toolbar actions change from a closed constant set to controller-registered names.
@@ -243,7 +243,7 @@ Decision: promote. The registry is the single namespace for toolbar.buttons and
(2) Boot: extension.go compiles every declared partial name (the list's headerPartial and each partial field's path) once per controller: read `{ConfigDir}/_{name}.htm` from the plugin AdminFS (missing file fails boot naming it), require that the controller implements pact.AdminPartialData (else boot error), and parse the source with `template.New(name).Funcs(template.FuncMap{"trans": <placeholder>}).Parse` from html/template (parse errors fail boot). Store the pristine templates on CompiledController in an unexported map plus the set of names declared by form partial fields. Name the new types compiledPartial, PartialNode and PartialView, away from crud.go's partialSelection (Pitfall 13).
(3) Render (D-10, D-17) in new modules/cabana/partial_render.go: `(*compiledPartial).render(ctx, tr, data)` Clones the pristine template (never executed, because Clone fails after Execute), binds `trans` with `.Funcs` to `translateKey(ctx, tr, key)`, Executes with root `map[string]any{"Data": data}` into a writer that fails past `partialMaxBytes` (64 << 10), parses the output with golang.org/x/net/html ParseFragment in a div context, and walks it into []PartialNode with a budget of `partialMaxNodes` (2000) nodes and `partialMaxDepth` (32); exceeding any cap is an error, never a truncated tree. Allowlist (RESEARCH Pattern 4, mirrored later by the SPA): tags div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img; global attributes class, title, lang, dir, role, aria-* and data-*; per tag: a[href] only when it starts with exactly one "/" (not "//" or "/\") or with "#"; img[src] only a same-origin "/" path (same rule), plus alt, width, height; td and th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max. Drop id, style and every on* attribute. Drop with their whole subtree: script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base. Unwrap any other element (keep its children). Drop comments and doctypes; text nodes become `{text}`. Model guard: when the view model's type, after dereferencing pointers and taking the element type of slices, arrays and maps, equals the type of the controller's NewRecord(), refuse (500). Run `go mod tidy` so golang.org/x/net becomes a direct requirement (already v0.58.0; the go.sum module set must not grow).
(3) Render (D-10, D-17) in new modules/cabana/partial_render.go: `(*compiledPartial).render(ctx, tr, data)` Clones the pristine template (never executed, because Clone fails after Execute), binds `trans` with `.Funcs` to `translateKey(ctx, tr, key)`, Executes with root `map[string]any{"Data": data}` into a writer that fails past `partialMaxBytes` (64 << 10), parses the output with golang.org/x/net/html ParseFragment in a div context, and walks it into []PartialNode with a budget of `partialMaxNodes` (2000) nodes and `partialMaxDepth` (32); exceeding any cap is an error, never a truncated tree. Allowlist (RESEARCH Pattern 4, mirrored later by the SPA): tags div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img; global attributes class, title, lang, dir, role, aria-* and data-*; per tag: a[href] only when it starts with exactly one "/" (not "//" or "/\") or with "#"; img[src] only a same-origin "/" path (same rule), plus alt, width, height; td and th colspan, rowspan, scope; time datetime; data value; meter value, min, max, low, high, optimum; progress value, max. Drop id, style and every on* attribute. Drop with their whole subtree: script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base. Unwrap any other element (keep its children). Drop comments and doctypes; text nodes become `{text}`. Model guard: when the view model's type, after dereferencing pointers and taking the element type of slices, arrays and maps, equals the type of the controller's NewRecord(), refuse (500). Run `go mod tidy` so golang.org/x/net becomes a direct requirement (D-18) (already v0.58.0; the go.sum module set must not grow).
(4) Route: `(*service).partial`, mounted in the backend GroupRaw as `g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)` plus constrainController(g) and `g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")`. Order: s.protect; the name must be a declared partial (else 404); query `id` absent means a nil record (header partials, and form partials on create); when present it must be a positive integer and the name must belong to a form partial field (else 404), and the record comes from readScopedRecord (out of scope is 404); call PartialData(ctx, name, record) (an error is logged and answered 500 generic); apply the model guard; render (an error, including a cap, is logged with the controller and partial name and answered 500 generic); `WriteData(w, 200, PartialView{Nodes}, nil)` with Nodes always an array. schema_types.go gains `PartialNode` (`Tag string json:"tag,omitempty"`, `Attrs map[string]string json:"attrs,omitempty"`, `Text string json:"text,omitempty"`, `Children []PartialNode json:"children,omitempty"`) and `PartialView` (`Nodes []PartialNode json:"nodes"`).

View File

@@ -29,7 +29,7 @@ must_haves:
truths:
- "Per D-01, D-03 and D-11, the Albums list declares `headerPartial: stats` in config_list.yaml and GET /plytadmin/api/v1/golem15/fonoteka/albums/partials/stats returns a statistics strip for the admin's own collection: the total, one item per stored format with count above zero in getFormatOptions order, and a no-shelf item only when that count is above zero."
- "Per D-10, every stats count runs through albumsAdminController.scopeAlbums, and the view model is a curated struct of labels and integers, never an Album model; an admin bound to one collection never sees another collection's counts."
- "Per D-01, D-02, D-04, D-06 and D-07, the Albums form declares a `year` number field after `shelf` and a last, full-width `discogs` widget (widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format]) that renders on create and update; its stub action answers the message key golem15.fonoteka::lang.discogs.stub_filled with fill {year: 1977, format: \"LP\"} and makes no outbound call; Phase 14 replaces the stub."
- "Per D-01, D-02, D-04, D-06, D-07 and D-19, the Albums form declares a `year` number field after `shelf` and a last, full-width `discogs` widget (widget golem15-fonoteka-discogs-lookup, action discogsLookup, fill [year, format]) that renders on create and update; its stub action answers the message key golem15.fonoteka::lang.discogs.stub_filled with fill {year: 1977, format: \"LP\"} and makes no outbound call; Phase 14 replaces the stub."
- "Per D-12, the Albums toolbar declares buttons [create, delete, discogsSync]; the discogsSync stub answers golem15.fonoteka::lang.discogs.stub_not_implemented and changes nothing; both Discogs actions require the existing Winter permission golem15.fonoteka.access_albums, so a limited admin gets 403."
- "Per D-13 to D-16, the controller declares assets/js/discogs-lookup.js and assets/css/albums.css through AdminJS/AdminCSS, both embedded in the plugin AdminFS and served under /plytadmin/assets/golem15/fonoteka/ with a JavaScript or CSS content type; the element is plain JS with no import, no user-facing string, no network request and no cookie or storage access."
- "UI consideration (populated S1 Albums stats strip): one .summer-stats card shows the total, then formats with count above zero in option order, then No shelf when above zero, each a 13px muted label under a 20px/600 value."
@@ -113,7 +113,7 @@ Tests pinning the current Albums shape: admin_albums_test.go (`len(schema.Fields
## Planning notes
- Spec-less probe fallback skipped: no requirement IDs were mapped for Phase 10.1 before this planning run; ADMIN-07 is introduced by it. Truths come from CONTEXT D-01..D-17 and the UI-SPEC "Application proof" rows.
- Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills `[year, format]`, which needs the new `year` field; action names `discogsLookup` and `discogsSync`; both reuse `golem15.fonoteka.access_albums` (Pitfall 12, no Winter catalog divergence); the widget renders on create and update.
- Discretion resolved per UI-SPEC and RESEARCH Open Questions 1 and 3: stats show the total, per-format counts and no-shelf; the widget fills `[year, format]`, which needs the new `year` field (D-19); action names `discogsLookup` and `discogsSync`; both reuse `golem15.fonoteka.access_albums` (Pitfall 12, no Winter catalog divergence); the widget renders on create and update.
- Albums has no real form partial; the form `type: partial` path is proven by the acme fixture in 10.1-01 (D-03 discretion).
- Tests here are smoke tests; the full Albums acceptance test is 10.1-04 (CLAUDE.md rule 3).

View File

@@ -48,6 +48,10 @@ Not in scope: the real Discogs HTTP client and jobs (Phase 14); WASM (FW-06 / v2
### Hygiene constraint (partial HTML in the SPA)
- **D-17:** Phase 10 forbids unsanitized `v-html` / `innerHTML` (T-10-16, `--hygiene`). Partial HTML still has to appear in the list/form. Researcher/planner must pick a host that does not reopen that threat (dedicated sanitized slot, iframe under the admin prefix, or equivalent). Record data must not become executable HTML.
### Resolved at planning (2026-09-28)
- **D-18:** `golang.org/x/net/html` becomes a direct dependency of the framework, used only by the cabana partial sanitizer (`html.ParseFragment` + tag/attribute allowlist). It is already in `go.sum` as an indirect dependency, so no new module enters the build. This is the decision note CLAUDE.md rule 4 asks for; `encoding/xml` was rejected as too weak on real HTML. — **Reversibility:** reversible
- **D-19:** The Albums Discogs widget uses `fill: [year, format]`. A `year` field (`type: number`) is added to the Albums admin form; the model already has `Year *int`. The `format`-only option was rejected. — **Reversibility:** reversible
### Claude's Discretion
- Exact YAML key names (`headerPartial` vs another spelling, widget `path` vs `tag`, action path convention).
- JS/CSS capability interface name (must not reuse `pact.AdminAssets`).

View File

@@ -0,0 +1,555 @@
# 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

View File

@@ -568,15 +568,14 @@ The phrase keys above are **proposals** [ASSUMED]. The `discogs:` group already
| A7 | Browser `setHTML` Sanitizer API support status | State of the Art | None. Not used |
| A8 | Promoting `golang.org/x/net` from indirect to direct is acceptable under the stdlib-first rule | Standard Stack | Medium. Fallback is `encoding/xml` non-strict |
## Open Questions
## Open Questions (RESOLVED)
1. **Which fields does the Discogs widget fill?**
1. **Which fields does the Discogs widget fill?** — RESOLVED (user, 2026-09-28): `fill: [year, format]`, and a `year` field (`type: number`) is added to the Albums form. Recorded as CONTEXT D-19; closes A6.
- Known: the Albums form has `name, artists, format, genre, shelf`. `fill` must be writable scalars, and the model has `Year *int`, `Label`, `Country` and others.
- Recommendation: add `year` (`type: number`) to the Albums form and use `fill: [year, format]`. Fallback: `fill: [format]` only, with no form change.
2. **Promote `golang.org/x/net/html`?** Recommended; needs an explicit phase-decision note (CLAUDE.md rule 4). Fallback: `encoding/xml`.
3. **Stats strip numbers.** Recommend total albums in the active collection plus counts per `format` (one `GROUP BY format` query under `scopeAlbums`). Optionally add "without shelf". Discretion.
4. **Widget `label` attribute.** D-08 lists record-id, field name, locale and fill snapshot. Should the SPA also pass the field's localized label? Recommend yes, as an additive attribute. It is not a token or cookie, so D-08's intent holds.
5. **Plan count.** Proposed 4 plans (framework Go → framework SPA → fonoteka Albums → tests + gate). Must be confirmed at the checkpoint.
2. **Promote `golang.org/x/net/html`?** — RESOLVED (user, 2026-09-28): approved. Recorded as CONTEXT D-18, the phase-decision note for CLAUDE.md rule 4; closes A8.
3. **Stats strip numbers.** — RESOLVED (Claude's discretion, per CONTEXT): total albums in the active collection plus counts per `format` (one `GROUP BY format` query under `scopeAlbums`), plus a "without shelf" count, which the UI-SPEC includes.
4. **Widget `label` attribute.** — RESOLVED (Claude's discretion): yes, the SPA passes the field's localized label as an additive attribute. It is not a token or cookie, so D-08's intent holds.
5. **Plan count.** — RESOLVED (user, at the plan-count checkpoint): 4 plans (framework Go → framework SPA → fonoteka Albums → tests + gate).
## Environment Availability