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"`).