docs(32): pattern map

This commit is contained in:
2026-10-26 20:07:10 +01:00
parent e0cf189ad8
commit 62bcecf704

View File

@@ -0,0 +1,540 @@
# Phase 32: SEO foundation - Pattern Map
**Mapped:** 2026-10-26
**Files analyzed:** 24 (framework seams, new `sm-seo-plugin`, `sm-journal-plugin` changes, `sm-demo-app` wiring)
**Analogs found:** 21 / 24
Paths are relative to the meta repo root `/media/nvme/dev/golem15/summercms.io/summercms/`. Every analog below is git-tracked in its own repo (checked with `git ls-files`; sibling repos need `git -c safe.directory='*'`).
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `summercms.go/modules/pact/capabilities.go` (AdminActionExtension + Form, RecordSaved/RecordDeleted, MenuItemType.Content) | model (API types) | n/a | same file: `AdminActionExtension` (~429-439), `MCPSubjectChanged`, `MenuItemType` (~798-808) | exact |
| `summercms.go/modules/cabana/form_extensions.go` (new: placement compile pass) | service (boot compiler) | transform | `summercms.go/modules/cabana/action_extensions.go` | exact |
| `summercms.go/modules/cabana/http.go` (Activate wiring) | config | boot | same file lines 135-143 | exact |
| `summercms.go/modules/cabana/crud.go`, `extension.go`, `field_file.go`, `field_ml.go`, `form_schema.go` (extension-owned branch, lift values, Load/Save in tx) | service | CRUD | same files (owner-only path today) | exact (in-place) |
| `summercms.go/modules/cabana/record_events.go` (new: emit + EmitRecordSaved/Deleted helpers) | service | event-driven | `summercms.go/modules/cabana/subject_events.go` | exact |
| `summercms.go/modules/signpost/signpost.go` (+ ContentTypes/TypeForModel/MatchPath) | service (registry) | request-response | same file `Activate`/`add` (40-89) | exact |
| framework READMEs + docs (`modules/{pact,cabana,signpost,party}/README.md`, `docs/backend/admin-controllers.md`, `docs/plugins/*.md`) | docs | n/a | existing sections | exact |
| framework tests (`modules/cabana/form_extensions_test.go`, `record_events_test.go`, `modules/signpost/*_test.go`) | test | n/a | existing `action_extensions` tests in cabana | role-match |
| `sm-seo-plugin/plugin.go` | provider (plugin entry) | boot | `sm-sitemap-plugin/plugin.go` | exact |
| `sm-seo-plugin/admin.go` | config (admin registration) | n/a | `sm-sitemap-plugin/admin.go` | exact |
| `sm-seo-plugin/admin_permissions.go` | config | n/a | `sm-sitemap-plugin/admin_permissions.go` | exact |
| `sm-seo-plugin/extensions.go` (AdminActionExtensions with Form) | provider | boot | `sm-paymentgateway-plugin/controllers/orders.go` 106-155 (record action shape) + cabana `action_extensions.go` (consumer) | partial (no plugin implements extensions yet) |
| `sm-seo-plugin/field_types.go` (+ `classes/field_types.go`) | component (field types) | transform | `sm-journal-plugin/field_types.go` + `classes/blog_editor.go` 80-109 | exact |
| `sm-seo-plugin/listeners.go` | service | event-driven | `subject_events.go` (emitter) + `modules/festival/bus.go` Listen | role-match |
| `sm-seo-plugin/routes.go` (`/robots.txt`, `/_seo/api/v1/*`) | route | request-response | `sm-journal-plugin/routes.go` + `sm-sitemap-plugin/routes.go` | exact |
| `sm-seo-plugin/controllers/api/*.go` (resolve, management handlers) | controller | request-response | `sm-journal-plugin/controllers/api/posts.go` + `auth.go` | exact |
| `sm-seo-plugin/mcp.go` | config (MCP tools) | request-response | `sm-journal-plugin/mcp.go` | exact |
| `sm-seo-plugin/jobs.go` (GenerateEntries) | service (job) | batch | `sm-chat-plugin/plugin.go` 85-100 | exact |
| `sm-seo-plugin/controllers/{redirects,rules,notfound,history}.go` | controller | CRUD | `sm-paymentgateway-plugin/controllers/orders.go` | exact |
| `sm-seo-plugin/controllers/{settings,robots,transfer}.go` (AdminSingleton) | controller | CRUD | `sm-sitemap-plugin/controllers/definitions.go` | exact |
| `sm-seo-plugin/models/*.go` + `models/*/fields.yaml`, `columns.yaml` | model | CRUD | `sm-sitemap-plugin/models/settings.go` + `models/settings/fields.yaml` | exact |
| `sm-seo-plugin/updates/*.go` | migration | n/a | `sm-sitemap-plugin/updates/registry.go` + `202610110002_create_golem15_sitemap_settings.go` | exact |
| `sm-seo-plugin/classes/*` (resolver, redirect graph, normaliser, robots, history, batch, csv) | service | transform | none (pure functions) | no analog |
| `sm-seo-plugin/assets/src/*` (E1-E6 elements, Vite) | component | client | `sm-journal-plugin/assets/src/blogeditor/*`, `vite.config.ts`, `package.json` | exact |
| `sm-seo-plugin/testmain_test.go` | test | n/a | `sm-sitemap-plugin/testmain_test.go` | exact |
| `sm-journal-plugin/classes/menu_item_types.go` (+ Content descriptors, `journal-tag` type) | service | request-response | same file 98-140 | exact |
| `sm-journal-plugin/controllers/api/posts.go` (Store/Update/Destroy emit record events) | controller | CRUD | same file 240-320 (existing `purgeSiteCache` after-commit call) | exact |
| `sm-demo-app/summer.yaml`, `.gitmodules` (mount `plugins/golem15/seo`) | config | n/a | sitemap entries in same files | exact |
## Pattern Assignments
### `summercms.go/modules/pact/capabilities.go` (API types)
**Analog:** same file. Extend with keyed-field additions only, so existing keyed literals keep compiling.
Current struct (429-439):
```go
type AdminActionExtension struct {
Controller string
RecordActions []AdminRecordAction
}
// HasAdminActionExtensions is implemented by plugins (not admin controllers)
// that add actions to admin controllers by controller ID.
type HasAdminActionExtensions interface {
AdminActionExtensions() []AdminActionExtension
}
```
The doc comment above it (418-428) lists every boot error in prose; the new `Form` field's comment must list the D-91 boot errors the same way (unknown anchor, duplicate name, cycle, anchor outside `Requires()`).
`MenuItemType` (798-808) uses `json:"-"` on func fields; new `Content *ContentDescriptor` follows that:
```go
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"`
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
```
Event types sit next to `pact.MCPSubjectChanged` (fired as a pointer, `&pact.MCPSubjectChanged{...}`), so `RecordSaved`/`RecordDeleted` are fired and listened to as pointers too.
---
### `summercms.go/modules/cabana/form_extensions.go` (boot compiler, transform)
**Analog:** `summercms.go/modules/cabana/action_extensions.go` 25-65
```go
func compileActionExtensions(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasAdminActionExtensions)
if !ok || src == nil {
continue
}
pluginID := plugin.ID()
for _, ext := range src.AdminActionExtensions() {
if len(ext.RecordActions) == 0 {
continue
}
cc, ok := reg.byID[ext.Controller]
if !ok || cc == nil {
return fmt.Errorf("cabana: plugin %s adds record actions to unknown admin controller %q", pluginID, ext.Controller)
}
...
// A new slice and map: the compiled backing array may be
// shared with another schema value.
names := make([]string, 0, len(cc.Form.recordActions)+1)
```
Copy: iterate `plugins` in party order (already topo-sorted), type-assert the capability, error messages of form `cabana: plugin %s ... controller %q`, and copy-on-write of compiled slices/maps. Skip the extension when `ext.Form == nil` (mirrors `len(ext.RecordActions) == 0`).
**Wiring** (`modules/cabana/http.go` 135-143): call `compileFormExtensions(reg, plugins)` before `compileActionExtensions`:
```go
// Contributed record actions are merged before compileContributions,
// which validates every record action's permissions.
if err := compileActionExtensions(reg, plugins); err != nil {
return nil, err
}
```
Owner-only checks that need an "extension-owned" branch (RESEARCH Pitfalls 3-7): `extension.go:132,145,150` (widget prefix, fill, AdminClientAssets), `extension.go:231-233` (partials from owner FS), `field_file.go:309-326` (fileupload), `crud.go:256` (`BindWritableFields` "is not a model column"), `http.go:857-866` (`hiddenSchemaFields`, schema-only — apply to save too).
---
### `summercms.go/modules/cabana/record_events.go` (event-driven)
**Analog:** `summercms.go/modules/cabana/subject_events.go` 17-44
```go
func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adminIDs ...uint) {
if app == nil || app.Events == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
tx, _ := TxFromContext(ctx)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
for _, id := range adminIDs {
...
event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: id, Kind: kind}
if err := app.Events.Fire(ctx, event); err != nil {
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", kind, "admin_id", id, "error", err)
}
}
})
}
```
Copy exactly: nil app/bus is a no-op, fire after commit, listener error only logged (never fails the save). Exported `EmitRecordSaved/EmitRecordDeleted(ctx, app, tx, ev)` take an explicit `tx` (journal handlers have no cabana tx in ctx). Doc comment style is the 8-line paragraph above `fireSubjectChanged`.
---
### `summercms.go/modules/signpost/signpost.go` (registry)
**Analog:** same file, `Activate` (40-66) and `add` (68-89). Validation of the new `Content` descriptor goes into `add` with the same message shape:
```go
if t.Resolve == nil {
return fmt.Errorf("signpost: menu item type %q of plugin %s has no Resolve", t.Key, owner)
}
```
New read helpers follow `Types()` (132) / `Type(key)` (140) style; `MatchPath` compiles patterns via the existing `Pattern(key, override)` (161).
---
### `sm-seo-plugin/plugin.go` (plugin entry)
**Analog:** `sm-sitemap-plugin/plugin.go` 1-80
```go
import (
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/sm-sitemap-plugin/updates"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
)
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasRoutes = (*Plugin)(nil)
...
)
type Plugin struct {
app *backpack.App
...
}
func (p *Plugin) ID() string { return "golem15.sitemap" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
// Boot ... a bad config fails the boot with "golem15.sitemap: %w"
```
Copy: package doc comment, compile-time interface assertions block, `p.app` stored in Register/Boot, boot errors prefixed `golem15.seo:`. `Requires()` lists only what the seams need (not journal). Add `pact.HasJobs`, `pact.HasMCP`, `pact.HasFormFieldTypes`, `pact.HasAdminActionExtensions` assertions.
---
### `sm-seo-plugin/admin.go` + `admin_permissions.go`
**Analog:** `sm-sitemap-plugin/admin.go` (whole file) and `admin_permissions.go`
```go
//go:embed controllers/definitions/config_form.yaml models/definition/fields.yaml models/settings/fields.yaml
var adminFS embed.FS
//go:embed lang
var langFS embed.FS
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App {
if p == nil {
return nil
}
return p.app
})
}
func (p *Plugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{
{
Code: "sitemap",
Label: "golem15.sitemap::lang.plugin.name",
...
Permissions: []string{controllers.PermissionAccessDefinitions},
Controller: controllers.DefinitionsControllerID,
},
```
Use the **link-entry** form (`Controller:`), never `Form:`: the SEO settings need widgets/partials/plugin field types, which `cabana/settings.go:96-104` refuses (RESEARCH Pattern 4). Embed YAML file by file (controllers dir also holds Go). Permissions:
```go
func (*Plugin) Permissions() []pact.Permission {
return []pact.Permission{{
Code: controllers.PermissionAccessDefinitions,
Tab: "golem15.sitemap::lang.plugin.name",
Label: "golem15.sitemap::lang.plugin.permissions.access_definitions",
Roles: []string{"developer"},
}}
}
```
One permission `golem15.seo.manage` (D-15).
---
### `sm-seo-plugin/controllers/{settings,robots,transfer}.go` (AdminSingleton)
**Analog:** `sm-sitemap-plugin/controllers/definitions.go` 19-80, 155-157
```go
const (
DefinitionsControllerID = "golem15.sitemap.definitions"
PermissionAccessDefinitions = "golem15.sitemap.access_definitions"
)
type definitionsAdminController struct {
app func() *backpack.App
}
func (definitionsAdminController) ID() string { return DefinitionsControllerID }
func (definitionsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Definition` }
func (definitionsAdminController) ConfigDir() string { return "controllers/definitions" }
func (definitionsAdminController) RequiredPermissions() []string { return []string{PermissionAccessDefinitions} }
func (definitionsAdminController) NewRecord() any { return &models.Definition{} }
func (definitionsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.sitemap: database is not configured")
}
... find first, else create ...
}
var (
_ pact.AdminPermissioned = definitionsAdminController{}
_ pact.AdminSingleton = definitionsAdminController{}
)
```
`controllers/admin_registry.go` `AdminControllers(app func() *backpack.App) []pact.AdminController` is the list constructor to copy. Per-type tabs on the settings form come from the plugin's own D-91 extension on this controller.
---
### `sm-seo-plugin/controllers/{redirects,rules,notfound,history}.go` (list/form/preview + record actions)
**Analog:** `sm-paymentgateway-plugin/controllers/orders.go` 24-50, 106-155, 299-312
```go
func (c ordersAdminController) AdminRecordActions() []pact.AdminRecordAction {
...
out = append(out, pact.AdminRecordAction{
Name: a.Name,
Label: lang + a.Label,
Confirm: lang + a.Confirm,
Permissions: []string{PermissionManageOrders},
Danger: a.Danger,
Applies: func(ctx context.Context, record any) (bool, error) { ... },
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.runTransition(ctx, in, a)
},
})
```
Plus `FilterOptions(scope)`, `ListExtendQuery`, `FormBeforeUpdate/FormBeforeDelete`, `FormRules`. 404 log "Create redirect…" adds `Prompt` (see `pact/capabilities.go:418-422`). Every hook delegates to the one service package in `classes/` (history, loop check, purge).
---
### `sm-seo-plugin/models/*` and `updates/*`
**Analog:** `sm-sitemap-plugin/models/settings.go` 35-70; `updates/registry.go` 1-17; `updates/202610110002_create_golem15_sitemap_settings.go`
```go
type Settings struct {
ID uint `gorm:"column:id;primaryKey"`
FrontendURL *string `gorm:"column:frontend_url"`
...
}
func (Settings) TableName() string { return "golem15_sitemap_settings" }
func (Settings) Fillable() []string { return []string{"frontend_url", ...} }
```
```go
var createSettings = []*gormigrate.Migration{
{
ID: "202610110002_create_golem15_sitemap_settings",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{ `CREATE TABLE ...`, `INSERT INTO ... (id) VALUES (1)` } {
if err := tx.Exec(stmt).Error; err != nil { return err }
}
return nil
},
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS ...`).Error },
},
}
func init() { Register(createSettings...) }
```
Raw SQL DDL, timestamped IDs, one file per table, table prefix `golem15_seo_`. Nullable columns as pointers. Validation in `Rules()`/`BeforeValidate` with phrase-key errors (`errFrontendURL` const pattern, settings.go:13-14). fields.yaml header comment block as in `models/settings/fields.yaml` 1-9.
---
### `sm-seo-plugin/routes.go` + `controllers/api/*.go`
**Analog:** `sm-journal-plugin/routes.go` 9-36 (groups) and `sm-sitemap-plugin/routes.go` 43-60 (root route + bucket)
```go
func (p *Plugin) Routes(r pact.Router) error {
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
g.Get("/posts", apicontrollers.Index(p.app))
...
})
r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) {
g.Put("/posts/{id}", apicontrollers.Update(p.app))
g.Where("id", `[0-9]+`)
})
return nil
}
```
```go
r.Get("/sitemap.xml", p.sitemapXML, "throttle:"+BucketSitemap)
...
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil { trusted = surf.TrustedProxies(p.app.Config) }
```
`GET /robots.txt` copies the sitemap root-route shape (no backend middleware, throttled, never 5xx). Do not register `GET /{name}` (verification files; RESEARCH anti-pattern).
Handler shape: `sm-journal-plugin/controllers/api/posts.go` 240-291 and `auth.go` 83-115:
```go
func Update(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r, pr, ok := requireBackendPrincipal(app, w, r)
if !ok { return }
if !requireAccessPosts(w, pr) { return }
db, err := gdb(app)
...
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": ...})
}
}
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
...
}
func gdb(app *backpack.App) (*gorm.DB, error) {
db, ok := app.Lookup[*gorm.DB]()
```
Permission check via `cabana.Allows(pr, []string{"golem15.seo.manage"})`. MCP detection for history `via`: `bouncer.Dispatched(r.Context(), "backend")`.
---
### `sm-seo-plugin/mcp.go`
**Analog:** `sm-journal-plugin/mcp.go` 12-110
```go
var _ pact.HasMCP = (*Plugin)(nil)
const mcpGuard = "backend"
type mcpUpdatePostInput struct {
ID uint `json:"id" path:"id" jsonschema:"the post id"`
Title *string `json:"title,omitempty" jsonschema:"the post title"`
}
func (p *Plugin) MCP() pact.MCP {
destructive := true
return pact.MCP{
Tools: []pact.MCPTool{
{
Name: "list_posts",
Title: "List posts",
Description: "...",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_journal/api/v1/posts",
Input: mcpListPostsInput{},
Permissions: []string{mcpAccessPosts},
},
```
Tools bind only to `/_seo/api/v1/*` routes (pier requires exact method+path; `modules/pier/catalog.go:178-182`). Batch tools carry `dry_run` in the input struct.
---
### `sm-seo-plugin/jobs.go`
**Analog:** `sm-chat-plugin/plugin.go` 85-100
```go
func (p *Plugin) Jobs() []pact.Job {
return []pact.Job{
conga.Job(p.maintainThreadSummary, conga.OnQueue(jobs.Queue), conga.MaxAttempts(1)),
}
}
func (p *Plugin) maintainThreadSummary(ctx context.Context, args jobs.MaintainThreadSummaryArgs) error {
if p.service == nil { return errServiceMissing }
return jobs.Work(ctx, p.service.DB(), args)
}
```
---
### `sm-seo-plugin/field_types.go` + `classes/field_types.go` (E1, E2, E4 types)
**Analog:** `sm-journal-plugin/field_types.go` 1-21 and `classes/blog_editor.go` 80-109
```go
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
return classes.BlogEditorTypes(func() *backpack.App { if p == nil { return nil }; return p.app })
}
{
Name: "mlblogeditor",
Element: BlogEditorElement,
Translatable: true,
Scripts: []string{"assets/dist/blogeditor.js"},
Styles: []string{"assets/dist/blogeditor.css"},
Options: []string{"htmlColumn", "uploadFolder"},
CheckOptions: checkBlogOptions,
Decode: decodeBlogHTML,
Load: loadBlogHTML,
ListValue: blogListValue,
},
```
Pairs `seotext`/`mlseotext`, `seorobots`/`mlseorobots`, plus `robotstxt`. Bundle listed in `mlseotext.Scripts` so it loads on other plugins' forms. Note Pitfall 6: as extension fields, storage diverts to the extension's Save, not the host column.
---
### `sm-seo-plugin/listeners.go`
**Analog:** emitter `cabana/subject_events.go` (above); bus signature `modules/festival/bus.go:43-46`:
```go
app.Events.Listen("golem15.seo", func(ctx context.Context, ev *pact.RecordSaved) error {
return p.service.OnRecordSaved(ctx, ev)
})
```
Register in `Boot`.
---
### `sm-seo-plugin/testmain_test.go`
**Analog:** `sm-sitemap-plugin/testmain_test.go` 19-70 (copy verbatim, rename DB/user to `golem15_seo`): `flag.Parse()` then `testing.Short()`, one `postgres:16-alpine` container, `pgx` stdlib driver.
---
### `sm-journal-plugin/classes/menu_item_types.go` (D-93 descriptors, `journal-tag`)
**Analog:** same file 19-140. Add `MenuTypeTag = "journal-tag"` to the key const block (21-27), a label const, a `DefaultTagPattern`, and `Content: &pact.ContentDescriptor{...}` on post/category/tag entries using `m := menuTypes{app: app}` methods, keyed literals as at 101-127.
### `sm-journal-plugin/controllers/api/posts.go` (record events)
**Analog:** same file. Store/Update/Destroy already call `purgeSiteCache(r, app, post)` → `cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, ...)` (line 400). Add `cabana.EmitRecordSaved/EmitRecordDeleted` next to that call (after `Save`/`Delete`, same nil-tx semantics). Capture old slug/URL before `applyPostUpdate` (line 268) for the `Changes` list.
### `sm-demo-app/summer.yaml` + `.gitmodules`
**Analog:** sitemap entries:
```yaml
- id: golem15.sitemap
module: git.golem15.com/golem15/sm-sitemap-plugin
```
```
[submodule "plugins/golem15/sitemap"]
path = plugins/golem15/sitemap
url = git@git.golem15.com:golem15/sm-sitemap-plugin.git
branch = main
```
Add `golem15.seo` after sitemap and journal (it requires sitemap per D-95 in Phase 33).
### `sm-seo-plugin/assets/src/*`
**Analog:** `sm-journal-plugin/assets/src/blogeditor/` (incl. `labels.ts` locale dictionary), `sm-journal-plugin/vite.config.ts`, `package.json` (pins typescript 5.9.3, vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6), `scripts/` dist check. Light DOM, CSS scoped under the element tag, `--c-*` variables only.
## Shared Patterns
### Error messages and boot failures
**Source:** `cabana/action_extensions.go:44`, `signpost/signpost.go:68-82`, `sm-sitemap-plugin/plugin.go` Boot
**Apply to:** all seams and plugin Boot. Prefix with module/plugin ID (`cabana:`, `signpost:`, `golem15.seo:`), name the plugin, controller and offending key; `fmt.Errorf("...: %w", err)` wrapping.
### After-commit side effects
**Source:** `cabana/subject_events.go:25-44`, `cooler.PurgeSiteCacheAfterCommit` (journal posts.go:400)
**Apply to:** record events, site-cache purge after any SEO write, history-dependent notifications. Listener errors logged, never fail the write.
### Lazy app accessor
**Source:** `sm-sitemap-plugin/admin.go` AdminControllers, `sm-journal-plugin/field_types.go`
**Apply to:** controllers, field types, API handlers: pass `func() *backpack.App`, read DB via `app.Lookup[*gorm.DB]()` at request time.
### Auth for plugin JSON API
**Source:** `sm-journal-plugin/controllers/api/auth.go:83-115`
**Apply to:** every `/_seo/api/v1` write/management handler; one permission `golem15.seo.manage`.
### Phrase keys
**Source:** sitemap `golem15.sitemap::lang.*` constants
**Apply to:** all labels, comments and errors: `golem15.seo::lang.*`, `lang/{en,pl}/lang.yaml`, keys as listed in 32-UI-SPEC.md.
### Tests
**Source:** `sm-sitemap-plugin/testmain_test.go`; framework `go test -short` loop
**Apply to:** last plan; pure-function tests (placement, redirect graph, normaliser, resolver precedence) need no DB.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `sm-seo-plugin/classes/{resolver,redirects,normalize}.go` | service | transform | New algorithms (precedence resolver, redirect graph flatten/loop simulation, URL normalisation); use RESEARCH Patterns 5, 6, Pitfalls 10, 11, 13 and the wildcard-regexp example |
| `sm-seo-plugin/classes/robots.go` | service | transform | No robots.txt code exists; grobotstxt pending decision note (RESEARCH Standard Stack) |
| `sm-seo-plugin/classes/{batch,history,csv}.go` | service | batch | Dry-run-by-rollback and HMAC apply token are new (RESEARCH Pattern 8); use `lagoon.Transaction` + `encoding/csv` |
Also note: no plugin implements `HasAdminActionExtensions` today, so `sm-seo-plugin/extensions.go` is the first consumer; use the framework test fixtures in `modules/cabana` for shape.
## Metadata
**Analog search scope:** `summercms.go/modules/{pact,cabana,signpost,festival,boardwalk}`, `sm-sitemap-plugin`, `sm-journal-plugin`, `sm-paymentgateway-plugin`, `sm-chat-plugin`, `sm-demo-app`
**Files scanned:** ~30
**Pattern extraction date:** 2026-10-26