docs(32): create phase plan
Six plans per D-98: form extensions, record events and content descriptors with Journal adoption, sm-seo-plugin core, redirects and robots.txt, management API with MCP, batch, undo and CSV, then unit tests and the patch release. COVERAGE.md declares no external API integration.
This commit is contained in:
@@ -1595,10 +1595,15 @@ Plans:
|
||||
4. Every SEO operation in the admin is available as an MCP tool, and a batch change can be previewed with dry-run and undone from history.
|
||||
5. The framework release with the seams is tagged, and the new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 32 to break down)
|
||||
- [ ] 32-01-PLAN.md — D-91 cross-plugin admin form extensions (pact types, cabana placement and save/show wiring, docs, SPA backstop)
|
||||
- [ ] 32-02-PLAN.md — D-92 record events and D-93 content descriptors; Journal descriptors, journal-tag, API event emission, translated slugs
|
||||
- [ ] 32-03-PLAN.md — sm-seo-plugin core: SEO tab, URL rules, settings with per-type tabs, resolver, history, generate job, elements E1-E3/E7
|
||||
- [ ] 32-04-PLAN.md — redirects and address-change 301, 404 log, normalisation, strict URLs, robots.txt with AI bots, verification files
|
||||
- [ ] 32-05-PLAN.md — /_seo/api/v1 management API and MCP tools, batch dry-run, undo, CSV, demo mount proof, check-phase32.sh
|
||||
- [ ] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog
|
||||
|
||||
### Phase 33: SEO output, audit and content
|
||||
|
||||
|
||||
376
.planning/phases/32-seo-foundation/32-01-PLAN.md
Normal file
376
.planning/phases/32-seo-foundation/32-01-PLAN.md
Normal file
@@ -0,0 +1,376 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/form_extensions.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/form_extensions_smoke_test.go
|
||||
- modules/cabana/form_extensions_postgres_test.go
|
||||
- modules/cabana/example_form_extension_test.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/setup/coming-from-wintercms.md
|
||||
- admin/tests/form/FormView.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-5, D-91, D-101, D-90, D-98, D-100, D-104]
|
||||
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A plugin implementing pact.HasAdminActionExtensions returns an AdminActionExtension whose Form adds field C After A to another plugin's form of A, B, and the admin form schema lists A, C, B (SC-1, D-91)"
|
||||
- "A field placed Before the first owner field compiles first; a field with no anchor goes to the end of its tab, as in WinterCMS (D-91)"
|
||||
- "A new extension tab placed After one owner tab and Before the next renders between them; an owner form with no tabs plus an extension tab renders the default tab (Basic) first, then the new tab (D-91, UI-SPEC C1)"
|
||||
- "Two plugins anchored at the same field keep plugin activation order (party, by Requires()); an anchor may name another extension's field only when that plugin is in the contributor's Requires() (D-91)"
|
||||
- "An extension hides an owner field through Hide; the field is absent from the schema and from show, and a tab that loses all its fields disappears (D-91)"
|
||||
- "Unknown field or tab anchor, duplicate field name, duplicate tab key, an anchor cycle, an anchor outside Requires(), Before the default tab, any Zone other than the tab zone, Hide of an unknown or referenced field and a fileupload extension field each stop the boot with an error naming the plugin, the controller and the offending name (SC-1, D-91, D-101, D-100)"
|
||||
- "Extension Load runs on show and Save runs inside the record's save transaction after the owner row is written; a Save error rolls back the whole save and leaves the owner row unchanged; AdminFormErrors answer 422 field lines and per-field notices render as save notices (D-91)"
|
||||
- "Extension field values never reach lagoon.Fill, BindWritableFields or the owner's columns, translation rows or attachments (D-91)"
|
||||
- "An admin without the extension's Permissions, or a request where Visible answers false, gets no extension field in schema or show, and a save ignores posted extension keys and does not call Save (D-91, UI-SPEC C1 Permission)"
|
||||
- "A form that no extension targets compiles byte-identically to before this plan: its CanonicalJSON is unchanged and owner tab interleaving is not canonicalised (strictly additive)"
|
||||
- "The framework change is strictly additive: new optional struct fields and new types only; existing pact, cabana and signpost exported signatures and behaviour are unchanged and existing plugins compile and behave the same without edits"
|
||||
- "The SPA renders the compiled order as sent and adds no ordering code: admin/src is unchanged by this plan"
|
||||
- "Field span (left, right, full) keeps working next to inserted fields (D-91)"
|
||||
- "One plugin extending two controllers and two plugins extending one controller both compile deterministically (assumption-delta invariant)"
|
||||
- statement: "SPA test: a schema whose compiled order puts an extension field between two owner fields renders A, C, B, and a tab-less form plus an extension tab renders Basic, then the new tab (UI-SPEC C1 backstop)"
|
||||
verification: backstop
|
||||
prohibitions:
|
||||
- statement: "No breaking change to existing framework API: no exported pact, cabana or signpost identifier is removed or changes signature, and no existing behaviour changes for plugins that do not use the new seam"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "An extension must never write the owner plugin's model columns, translation rows or attachment relations; the owner's model and tables stay untouched (D-91)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Framework code, READMEs and docs added by this plan name no SEO concept and no consuming application (D-90)"
|
||||
status: resolved
|
||||
verification: test
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "AdminActionExtension.Form, AdminFormExtension, AdminFormTab, AdminFormField, AdminFormLoad, AdminFormSave, AdminFormSaveResult, AdminFormErrors, zone constants"
|
||||
contains: "Form *AdminFormExtension"
|
||||
- path: "modules/cabana/form_extensions.go"
|
||||
provides: "compileFormExtensions: placement pass, boot errors, extension-owned field registry"
|
||||
contains: "func compileFormExtensions("
|
||||
- path: "modules/cabana/form_extensions_postgres_test.go"
|
||||
provides: "TestFormExtensionTracer and TestFormExtensionSave (save transaction, rollback, permissions)"
|
||||
contains: "func TestFormExtensionTracer("
|
||||
- path: "modules/cabana/example_form_extension_test.go"
|
||||
provides: "the docs snippet source for the form extension section"
|
||||
contains: "AdminFormExtension"
|
||||
- path: "admin/tests/form/FormView.test.ts"
|
||||
provides: "UI-SPEC C1 backstop cases"
|
||||
contains: "extension"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/form_extensions.go"
|
||||
via: "Activate calls compileFormExtensions(reg, plugins) after compileRegistryWithTypes and before compileActionExtensions"
|
||||
pattern: "compileFormExtensions\\(reg, plugins\\)"
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/pact/capabilities.go"
|
||||
via: "CRUDService.save lifts extension values before projection and calls AdminFormExtension.Save inside s.transaction"
|
||||
pattern: "\\.Save\\(ctx, pact\\.AdminFormSave"
|
||||
- from: "docs/backend/admin-controllers.md"
|
||||
to: "modules/cabana/example_form_extension_test.go"
|
||||
via: "src= snippet kept in sync by summer docs:sync"
|
||||
pattern: "example_form_extension_test.go"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor on any SummerCMS application, **I want to** per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, **so that** every site ships correct, editable SEO without per-site work.
|
||||
|
||||
This plan delivers the first framework seam the goal rests on: a plugin can place fields and tabs inside another plugin's admin form.
|
||||
|
||||
<objective>
|
||||
Implement D-91 cross-plugin admin form extensions in the framework: `pact.AdminActionExtension` gains `Form *pact.AdminFormExtension`; cabana compiles the placement once at boot (`Before`/`After` field and tab anchors, the tab zone only per D-101), dispatches extension-owned widgets, partials, plugin field types and translatable fields to the contributing plugin, keeps extension values out of the owner's model, and runs the extension's `Load` on show and `Save` inside the record's save transaction. README, docs and an SPA backstop test land with it. The framework names no SEO concept (D-90); fileupload stays out of extensions because OG images use a plugin field type instead (D-100); the SEO plugin later dogfoods the seam on its own settings form (D-104).
|
||||
|
||||
Purpose: SC-1 ("A, B plus C after A renders A, C, B"; a tab between two tabs; clear boot errors). Every later plan of this phase (D-98 order) builds the SEO tab and the per-type settings tabs on this seam.
|
||||
|
||||
Output: the pact types, `modules/cabana/form_extensions.go`, the save/show/schema wiring, a tracer test through the admin HTTP API, docs, and the UI-SPEC C1 backstop test.
|
||||
|
||||
**Planner decisions recorded here (Claude's discretion: the exact Go shape of the seam, CONTEXT):**
|
||||
- **P-01 (placement data in Go, definitions in fields.yaml keys):** an extension declares tabs and fields as Go structs (`AdminFormTab`, `AdminFormField` with `Before`, `After`, `Zone`, matching D-91's and D-101's wording) and each field's ordinary fields.yaml keys in `AdminFormField.Config` (a `map[string]any` holding label, type, span, comment, options and the rest). cabana encodes `Config` with goccy/go-yaml and compiles it through the same `decodeFieldMapping`/`compileFieldNode` path as a fields.yaml entry, so every type, option and plugin-field-type check applies unchanged. Go declarations let a plugin generate fields at boot (the per-type settings tabs of D-104) without templating YAML. Confirmed or replaced at the Task 1 checkpoint.
|
||||
- **P-02 (canonicalisation only when extended):** the owner's field list is regrouped by tab in first-appearance order (the default tab first, as the SPA renders it) only for a controller that at least one extension targets. Every other form compiles byte-identically, so the change is strictly additive. Consequence: Journal Post renders Edit | Manage | Categories | SEO (first appearance), not the order UI-SPEC C3 wrote (RESEARCH Pitfall 2).
|
||||
- **P-03 (zones, D-101):** `AdminFormField.Zone` accepts "" and `pact.AdminFormZoneTabs`; `pact.AdminFormZoneOutside` and `pact.AdminFormZoneSecondary` exist as constants and stop the boot with "zone %q is not supported yet".
|
||||
- **P-04 (tab keys):** a tab's stable name is its raw `tab:` value (the label phrase key), exactly as owner fields.yaml uses it; the reserved name `default` names the tab of fields without `tab`. `Before: "default"` is a boot error; `After: "default"` is allowed.
|
||||
- **P-05 (anchor cursor):** several extensions anchored at the same point insert after the last field already inserted at that anchor, so plugin order is kept (RESEARCH Pattern 1 step 2).
|
||||
- **P-06 (errors and notices):** `pact.AdminFormErrors` (map of field name to messages, implements `error`) answers 422 with the existing ValidationError details; `AdminFormSaveResult.Notices` (map of field name to `[]pact.FormFieldNotice`) is merged into the save's `Meta.Notices` through the existing `pluginNotice`/`localizeNotices` path. A notice may name an owner field (the SEO plugin puts its address-change notice on the owner's slug field).
|
||||
|
||||
<assumption_delta_decision>
|
||||
Signal: pluralization of "another" in SC-1 ("a field between two fields of another plugin's admin form").
|
||||
Primary noun: the target admin controller, named by its controller ID; every compiled field is identified by its stable field name across the owner and all contributors.
|
||||
Decision: no-change. `pact.AdminActionExtension` is already keyed by `Controller` and `HasAdminActionExtensions` returns a slice, so any number of contributing plugins can target any number of controllers without a new identity model; promotion is not needed because the controller ID already is the general key.
|
||||
Invariant test: `TestFormExtensionTwoPluginsSameAnchor` and `TestFormExtensionOnePluginTwoControllers` in `modules/cabana/form_extensions_smoke_test.go` assert a deterministic compiled order for both shapes.
|
||||
</assumption_delta_decision>
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-RESEARCH.md
|
||||
@.planning/phases/32-seo-foundation/32-PATTERNS.md
|
||||
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
|
||||
@.planning/phases/32-seo-foundation/32-VALIDATION.md
|
||||
@CLAUDE.md
|
||||
@modules/pact/capabilities.go
|
||||
@modules/cabana/action_extensions.go
|
||||
@modules/cabana/http.go
|
||||
|
||||
<interfaces>
|
||||
From modules/pact/capabilities.go (verbatim, lines 429-439):
|
||||
type AdminActionExtension struct {
|
||||
Controller string
|
||||
RecordActions []AdminRecordAction
|
||||
}
|
||||
type HasAdminActionExtensions interface {
|
||||
AdminActionExtensions() []AdminActionExtension
|
||||
}
|
||||
The doc comment above it ends "Only record actions are carried for now; the struct leaves room for more kinds."
|
||||
|
||||
From modules/pact/capabilities.go (existing types this plan reuses):
|
||||
type AdminAction struct { ... } // widget actions (line 226)
|
||||
type FormFieldNotice struct { Message string; Params map[string]string; Count int }
|
||||
type FormFieldError struct { Messages []string }
|
||||
type FormFieldType struct { Name, Element string; Translatable bool; Scripts, Styles, Options []string; CheckOptions; Decode; Load; ListValue; Upload }
|
||||
|
||||
From modules/cabana/http.go Activate (lines 108-143): compileFieldTypes(plugins) → compileRegistryWithTypes(items, fieldTypes) → checkFileLimits → checkDeferredModels → compileActionExtensions(reg, plugins) → compileAccountContributions → compileContributions.
|
||||
|
||||
From modules/cabana/action_extensions.go: func compileActionExtensions(reg *Registry, plugins []party.Plugin) error — iterates plugins in party order, type-asserts pact.HasAdminActionExtensions, looks up reg.byID[ext.Controller], errors "cabana: plugin %s adds record actions to unknown admin controller %q", copies compiled slices before appending. It skips an extension with len(ext.RecordActions) == 0, so an extension carrying only Form passes through it unchanged.
|
||||
|
||||
From modules/cabana/crud.go: CRUDService.save (line 736) order: liftVirtualValues → liftMLValues → s.transaction{ loadRecord → decodePluginFields → sanitizeRichEditors → lagoon.Fill → BeforeValidate → lagoon.Validate → formBefore* → tx.Save/Create → applyMLTranslations → formAfter* → hydrateMLRecord → hydratePluginFields } → result.Meta.Notices = localizeNotices(ctx, s.tr, notices). BindWritableFields (line 233) refuses "field X is not a model column".
|
||||
|
||||
From modules/cabana/extension.go: compileExtension (line 60) checks widget tag prefix against the owner plugin, fill keys as writable scalars, AdminClientAssets JS; compilePartials (line 200) reads _<name>.htm from the owner's ConfigDir and requires pact.AdminPartialData.
|
||||
|
||||
From modules/cabana/http.go (line 857): hiddenSchemaFields(ctx, cc) — schema-only, per request.
|
||||
|
||||
From admin/src/components/form/formState.ts: DEFAULT_TAB = 'default'; tabOf(field) = field.tab ? `tab:${field.tab}` : DEFAULT_TAB. FormView.vue builds tabs by first appearance and unshifts the default tab.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Confirm the Go shape of the D-91 form-extension seam (one-way framework API)</name>
|
||||
<decision>Which Go shape does `pact.AdminActionExtension.Form` publish for cross-plugin form fields and tabs (D-91, D-101)?</decision>
|
||||
<context>D-91 is confirmed, but its exact Go shape is Claude's discretion (CONTEXT) and becomes published framework API at the phase's release tag; changing it afterwards breaks every plugin that implements it (RESEARCH A1). The shape recommended here (P-01): `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction; Form *AdminFormExtension }`; `type AdminFormExtension struct { Tabs []AdminFormTab; Fields []AdminFormField; Hide []string; Permissions []string; Visible func(ctx context.Context) bool; Dir string; Actions []AdminAction; PartialData func(ctx context.Context, name string, record any) (any, error); Load func(ctx context.Context, in AdminFormLoad) (map[string]any, error); Save func(ctx context.Context, in AdminFormSave) (AdminFormSaveResult, error) }`; `type AdminFormTab struct { Name, Before, After string }`; `type AdminFormField struct { Name, Tab, Before, After, Zone string; Config map[string]any }`; `type AdminFormLoad struct { DB *gorm.DB; Controller string; Record any; ID string }`; `type AdminFormSave struct { DB *gorm.DB; Controller string; Record any; ID string; Created bool; Values map[string]any }` (plan 32-02 adds `Changes []RecordChange`); `type AdminFormSaveResult struct { Notices map[string][]FormFieldNotice }`; `type AdminFormErrors map[string][]string`; constants `AdminFormZoneTabs = "tabs"`, `AdminFormZoneOutside = "outside"`, `AdminFormZoneSecondary = "secondary"`. `Dir` is the directory in the contributing plugin's AdminFS that holds the `_<name>.htm` files of its partial fields. Translatable extension fields carry `map[string]string` (locale to value) in Load and Save.</context>
|
||||
<options>
|
||||
<option id="go-placement">
|
||||
<name>Go-declared placement, fields.yaml keys in Config (recommended, P-01)</name>
|
||||
<pros>Before, After and Zone are struct fields as D-91 and D-101 word them; a plugin generates fields at boot (per-type settings tabs, D-104) without templating YAML; Config is compiled by the existing fields.yaml checks</pros>
|
||||
<cons>An extension's field definitions live in Go, not in a fields.yaml file next to the owner's</cons>
|
||||
</option>
|
||||
<option id="yaml-document">
|
||||
<name>A fields.yaml document per extension with before:/after:/zone: keys and a top-level tabs: list</name>
|
||||
<pros>Same file format as owner forms</pros>
|
||||
<cons>Generated fields (one tab per content type) need templated YAML bytes; Zone exists only as a YAML key, not as the struct field D-101 names</cons>
|
||||
</option>
|
||||
<option id="amend">
|
||||
<name>The recommended shape with Jakub's renames or additions</name>
|
||||
<pros>Lets Jakub fix names before they become published API</pros>
|
||||
<cons>The executor applies the amendments to every task of this plan</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Answer go-placement, yaml-document, or amend with the changes; the executor records the answer in 32-01-SUMMARY.md and uses it in Tasks 2-4</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: End-to-end "plugin B adds field C after A on plugin A's form" through the admin API — one path only</name>
|
||||
<reversibility rating="one-way">The pact types become published framework API at the release tag; plugins (sm-seo-plugin first) implement them and a later change breaks their builds.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_extensions.go, modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/form_extensions_postgres_test.go, docs/backend/admin-controllers.md</files>
|
||||
<read_first>
|
||||
- modules/pact/capabilities.go (AdminActionExtension 405-439, AdminAction 226-268, FormFieldNotice 584-590, FormFieldError 612-622)
|
||||
- modules/cabana/action_extensions.go (whole file: the pass to copy)
|
||||
- modules/cabana/http.go (Activate 90-160)
|
||||
- modules/cabana/crud.go (ProjectWritableFields 201, BindWritableFields 233, save 736-944, projectFullRecord 689)
|
||||
- modules/cabana/form_schema.go (decodeFieldMapping 496, compileFieldNode 528, FormSchema.Localize 263, CanonicalJSON 313)
|
||||
- modules/cabana/field_type_postgres_test.go (newListService harness, lines 100-130) and modules/cabana/action_extensions_postgres_test.go (HTTP-level assertions)
|
||||
- modules/pact/README.md and docs/backend/admin-controllers.md ("Adding record actions to another controller", line 497)
|
||||
- The Task 1 answer recorded by the executor
|
||||
</read_first>
|
||||
<action>
|
||||
Apply the shape chosen at Task 1 (the text below assumes go-placement; for yaml-document or amend, keep the same behaviour and change only the declaration form). Per D-91 and D-101:
|
||||
1. pact: add `Form *AdminFormExtension` as the last field of `AdminActionExtension` and the types `AdminFormExtension`, `AdminFormTab`, `AdminFormField`, `AdminFormLoad`, `AdminFormSave`, `AdminFormSaveResult`, `AdminFormErrors` (with `Error() string` joining field and messages) and the three zone constants. Rewrite the doc comment's last sentence so it says record actions and form contributions are carried, and list the D-91 boot errors in prose the way the existing comment lists the record-action ones. Only additions: no existing field, method or comment contract changes meaning.
|
||||
2. cabana `form_extensions.go`: `compileFormExtensions(reg *Registry, plugins []party.Plugin) error` iterating plugins in party order like `compileActionExtensions`; skip `ext.Form == nil`; unknown controller → `cabana: plugin %s extends the form of unknown admin controller %q`; a target with no form → error naming both. For the tracer, implement only: compile each `AdminFormField.Config` through goccy/go-yaml encoding plus `decodeFieldMapping` against the boot's field type registry, insert it `After`/`Before` a named owner field of the same tab (unknown anchor → `cabana: plugin %s: controller %q: field %s: unknown anchor %q`), record the field in a per-controller map of extension-owned field name → contributing plugin and extension, and when the controller is extended, regroup the owner list by tab in first-appearance order with the default tab first (P-02). Wire it in `http.go` `Activate` directly after `compileRegistryWithTypes` and before `checkFileLimits` with a comment naming D-91.
|
||||
3. cabana `crud.go`: before `ProjectWritableFields`, lift the submitted values of extension-owned fields out of the body (they never reach Fill or `BindWritableFields`; make `BindWritableFields` skip extension-owned fields); inside `s.transaction`, after the owner row is written and its translations applied, call each visible extension's `Save` with `pact.AdminFormSave{DB: tx, Controller, Record, ID, Created, Values}`; a returned error aborts the transaction (an `AdminFormErrors` maps to the existing `ValidationError` 422 with those field lines). On show (`ShowRecord`/`projectFullRecord`) call `Load` with the read transaction and merge its map into the record data.
|
||||
4. Tracer test `TestFormExtensionTracer` in `form_extensions_postgres_test.go`: an owner plugin `acme.blog` with controller `acme.blog.posts` (model with columns a, b; fields a, b as text), a contributor `acme.extra` whose extension adds text field `c` After `a`, storing c in its own table `acme_extra_values` (record id, value) from Save and reading it in Load. Through the admin HTTP routes: the form schema lists a, c, b; PUT with {a, b, c} stores a and b in the owner row and c in `acme_extra_values` in the same transaction; GET shows c; the owner model has no c column.
|
||||
5. Same commit (documentation rule): `modules/pact/README.md` lists the new types; `docs/backend/admin-controllers.md` gains "Adding fields to another controller's form" after "Adding record actions to another controller" with a minimal prose example using neutral names (acme). Name no SEO concept and no consuming application (D-90).
|
||||
Commit: `feat(32-01): cross-plugin admin form extensions (tracer)` staging exactly the files above.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/cabana -run 'TestFormExtensionTracer' -count=1 -v 2>&1 | tail -30</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestFormExtensionTracer" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Form \*AdminFormExtension" modules/pact/capabilities.go` prints exactly one line
|
||||
- `grep -n "func compileFormExtensions(reg \*Registry, plugins \[\]party.Plugin) error" modules/cabana/form_extensions.go` prints one line
|
||||
- `grep -n "compileFormExtensions(reg, plugins)" modules/cabana/http.go` prints one line located before the `compileActionExtensions(reg, plugins)` line
|
||||
- `go test ./modules/cabana -run TestFormExtensionTracer -count=1` passes and its schema assertion compares the field name list to exactly `[a c b]`
|
||||
- `go doc ./modules/pact AdminFormExtension` prints the struct with Tabs, Fields, Hide, Permissions, Visible, Dir, Actions, PartialData, Load and Save
|
||||
- `grep -c "AdminFormExtension" modules/pact/README.md docs/backend/admin-controllers.md` reports at least one line in each file
|
||||
</acceptance_criteria>
|
||||
<done>A contributor plugin's field C appears between owner fields A and B in the compiled schema, saves through the extension's Save inside the record's transaction and loads on show, without touching the owner's model; committed with README and docs.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Full placement rules, boot errors, permissions and the extension-owned branches (tabs, hide, widgets, partials, plugin field types, translatable fields)</name>
|
||||
<files>modules/cabana/form_extensions.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/extension.go, modules/cabana/actions.go, modules/cabana/partial_render.go, modules/cabana/field_type.go, modules/cabana/field_ml.go, modules/cabana/form_extensions_smoke_test.go, modules/cabana/form_extensions_postgres_test.go</files>
|
||||
<read_first>
|
||||
- modules/cabana/form_extensions.go (from Task 2)
|
||||
- modules/cabana/extension.go (compileExtension 60-165, compilePartials 200-251, checkWidgetTag 339)
|
||||
- modules/cabana/actions.go (widgetAction 36-90, widgetField 356, runAction 322)
|
||||
- modules/cabana/partial_render.go (refusedViewModel 287, service.partial 499)
|
||||
- modules/cabana/field_type.go (pluginNotice 369, decodePluginFields 433, hydratePluginFields 696)
|
||||
- modules/cabana/field_ml.go (liftMLValues 68, applyMLTranslations 163, hydrateMLRecord 190)
|
||||
- modules/cabana/http.go (hiddenSchemaFields 850-866 and the schema/show handlers that call it)
|
||||
- modules/cabana/field_type_smoke_test.go (ftPlugin, ftCompile fixtures 160-200)
|
||||
- modules/party/registry.go (topoSort order, Requires)
|
||||
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 1, Pitfalls 1-7)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C1)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- C After A on [A, B] gives [A, C, B]; C Before A gives [C, A, B]; C with no anchor gives [A, B, C] (end of its tab)
|
||||
- New tab T2 After tab T1 and Before T3 gives tab order T1, T2, T3; a tab-less owner plus a new tab gives default, then the new tab
|
||||
- Plugins P1 and P2 (P2 requires P1) both anchored After A give [A, C1, C2, B]; P2 anchored After C1 compiles; P1 anchored on a P2 field fails (P2 not in P1's Requires())
|
||||
- Hide of owner field B removes it from schema and show; a tab whose fields are all hidden disappears
|
||||
- Boot errors, each naming plugin, controller and name: unknown field anchor, unknown tab anchor, duplicate field name (vs owner and vs earlier extension), duplicate tab key, two tab keys whose labels localize to the same text, cycle among one extension's Before/After references, anchor outside Requires(), Before "default", Zone "outside" or "secondary" ("zone %q is not supported yet"), Hide of an unknown field, Hide of a field another field references through fill, dependsOn, trigger or preset, a fileupload extension field, an extension widget action name equal to an owner action, an extension partial name equal to an owner partial
|
||||
- Permissions/Visible false: schema and show omit the extension's fields; a save with posted extension keys leaves stored values untouched and does not call Save
|
||||
- Save returning AdminFormErrors{"c": ["validation.required"]} answers 422 with the field line, the owner row unchanged; Save returning Notices{"a": [...]} puts the notice in Meta.Notices; Save returning a plain error answers 500 and rolls back
|
||||
- An extension field of a translatable plugin field type runs the type's Decode (notices kept) and hands Save map[locale]string; nothing is written to the owner's host column or translation rows
|
||||
- An extension widget field (type: widget) with fill naming fields of the same extension compiles; its action dispatches to AdminFormExtension.Actions; an extension partial renders from the contributor's AdminFS Dir through PartialData and the view-model guard refuses a GORM model
|
||||
- A controller no extension targets: CanonicalJSON of its form schema is byte-identical with and without the contributor plugin present
|
||||
- An extension with no Tabs and no Fields but a Save (and optionally Load) hook is valid: its Save runs in the save transaction and may return AdminFormErrors or Notices naming owner fields (a save hook on another or its own controller's form)
|
||||
</behavior>
|
||||
<action>
|
||||
Complete the D-91 compile pass and runtime per the behaviour list (D-91, D-101, D-100). Placement: canonicalise (P-02), then per plugin in party order insert tabs, then fields, with the anchor cursor (P-05); topologically sort one extension's own fields by their Before/After references and report a stalled sort as a cycle. Anchor resolution across extensions uses the field-to-plugin map and the contributor's `Requires()` (direct requirement only). Tab keys are raw `tab:` values (P-04); check duplicate raw keys and localized-label collisions for every locale the phrasebook loads, when the translator is published (RESEARCH Pitfall 1). Zone per P-03.
|
||||
Extension-owned branches, keyed by the field-to-extension map compiled at boot: in `extension.go` check a widget tag against the contributing plugin's prefix, accept fill keys that are fields of the same extension, accept the contributor's field-type scripts or AdminClientAssets as the element source, and refuse a widget action name that clashes with the owner's; in `actions.go` dispatch `widgets/{field}` of an extension field to `AdminFormExtension.Actions` with the same permission checks plus the extension's Permissions; in `extension.go`/`partial_render.go` compile extension partials from the contributor's AdminFS `Dir` and render them through `AdminFormExtension.PartialData`, keeping `refusedViewModel`; in `field_type.go` run `Decode` for extension plugin-field-type values but return the decoded value to the extension instead of the host column, and on show run the extension `Load` first and the type's `Load` after it; in `field_ml.go` make `liftMLValues` skip extension ML fields (their per-locale values are lifted into the extension's Values as `map[string]string`).
|
||||
Permissions and Visible: one helper `visibleExtensions(ctx, cc, principal)` used by the schema handler (next to `hiddenSchemaFields`), show and save; on save drop the keys of invisible extensions before anything else and do not call their Save (RESEARCH Pitfall 7).
|
||||
Errors and notices per P-06.
|
||||
Tests (written first, RED then GREEN): table tests in `form_extensions_smoke_test.go` (pure compile, no DB, `ftPlugin`-style fixtures) named `TestFormExtensionPlacement`, `TestFormExtensionBootErrors`, `TestFormExtensionTwoPluginsSameAnchor`, `TestFormExtensionOnePluginTwoControllers`, `TestFormExtensionUnextendedSchemaUnchanged`; Postgres cases in `form_extensions_postgres_test.go` named `TestFormExtensionSave` (rollback on error, 422 lines, notices, invisible extension skipped, translatable values as map, owner columns untouched). These are the plan's smoke coverage; the exhaustive unit matrix is plan 32-06.
|
||||
Commit per logical change (compile pass; runtime branches), staging files by path. Before touching cabana check `git status --short modules/cabana` for other sessions' in-flight edits and leave them alone.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short ./modules/cabana -run 'TestFormExtension' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -40</automated>
|
||||
<fails_when>a "--- FAIL" or "FAIL" line, "no tests to run", or any of TestFormExtensionPlacement, TestFormExtensionBootErrors, TestFormExtensionTwoPluginsSameAnchor, TestFormExtensionOnePluginTwoControllers, TestFormExtensionUnextendedSchemaUnchanged missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go test ./modules/cabana -run 'TestFormExtensionSave|TestFormExtensionTracer' -count=1 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or "FAIL" in the output</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -short ./modules/cabana -run TestFormExtensionBootErrors -v` shows one passing subtest per boot error listed in the behaviour block (at least 14 `--- PASS` subtest lines)
|
||||
- `grep -n "is not supported yet" modules/cabana/form_extensions.go` prints at least one line (D-101 zone refusal)
|
||||
- `grep -n "fileupload" modules/cabana/form_extensions.go` prints the refusal of fileupload extension fields (D-100)
|
||||
- `TestFormExtensionUnextendedSchemaUnchanged` compares `CanonicalJSON()` bytes with and without the contributor and passes
|
||||
- `git diff --name-only "$PLAN_START"..HEAD -- admin/src` prints nothing, where PLAN_START is the plan's start SHA recorded before Task 2's first commit (no SPA change)
|
||||
- `go test ./modules/cabana -count=1` (Docker) exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Every D-91 placement rule and boot error works and is covered by smoke tests; extension widgets, partials, plugin field types and translatable fields dispatch to the contributor; invisible extensions are inert on save; unextended forms are unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Documentation, runnable example and the UI-SPEC C1 SPA backstop</name>
|
||||
<files>modules/cabana/example_form_extension_test.go, modules/cabana/README.md, modules/pact/README.md, docs/backend/admin-controllers.md, docs/setup/coming-from-wintercms.md, admin/tests/form/FormView.test.ts</files>
|
||||
<read_first>
|
||||
- modules/cabana/example_action_extension_test.go (the docs snippet pattern, function ProfilesPlugin.AdminActionExtensions)
|
||||
- docs/backend/admin-controllers.md (lines 497-530, the src= snippet convention)
|
||||
- modules/cabana/README.md (Features line 58 on record-action extensions; API reference table near line 297)
|
||||
- docs/setup/coming-from-wintercms.md (the extendFormFields mapping, if present)
|
||||
- admin/tests/form/FormView.test.ts (existing fixtures and render helpers)
|
||||
- admin/src/views/FormView.vue (tabs derivation 151-171) and admin/src/components/form/formState.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. `example_form_extension_test.go`: an `ExtrasPlugin` (neutral name, D-90) whose `AdminActionExtensions()` adds a field After an owner field, a new tab After an owner tab, hides one owner field, and implements Load and Save against a side table; an `Example...` function with `// Output:` so `go test` runs it. Reference it from `docs/backend/admin-controllers.md` "Adding fields to another controller's form" with a `src=modules/cabana/example_form_extension_test.go#ExtrasPlugin.AdminActionExtensions` block, then run `go run ./cmd/summer docs:sync`.
|
||||
2. The docs section explains: stable names (field keys, raw tab keys, `default`), Before/After, end-of-tab default, tab placement, Hide, zones (only the tab zone, D-101), plugin order and the Requires() rule for anchors on another extension's fields, each boot error, Load and Save in the save transaction, AdminFormErrors and notices, Permissions and Visible on schema/show/save, that fileupload is refused in extensions (use a plugin field type with Upload instead), that the SPA renders the compiled order, and that an unextended form is unchanged. `modules/cabana/README.md` Features gains one bullet next to the record-action one; `modules/pact/README.md` documents each new type; `docs/setup/coming-from-wintercms.md` maps WinterCMS `extendFormFields` (addFields/addTabFields/removeField) to the new seam.
|
||||
3. SPA backstop (UI-SPEC C1, no change under admin/src): in `admin/tests/form/FormView.test.ts` add two cases named "renders an extension field between two owner fields in compiled order" (schema fields a, c, b with c carrying the same tab; asserts DOM order a, c, b) and "a tab-less form with an extension tab renders Basic, then the new tab".
|
||||
4. Run `scripts/check-admin-openapi.sh --check`: the payload shape is unchanged, so it must pass without regenerating; if it fails, the plan changed a payload by mistake: fix the code, not the generated files.
|
||||
Commit docs/example as `docs(32-01): form extensions in admin-controllers, cabana and pact READMEs` and the test as `test(32-01): FormView renders compiled extension order`, staged by path.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit, "FAIL" in the test output, or docs:build reporting an unknown identifier, broken link or stale src= snippet</fails_when>
|
||||
<automated>npm --prefix admin run test -- tests/form/FormView.test.ts 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit, a "failed" count above zero, or the two new case titles missing from the passing output</fails_when>
|
||||
<automated>scripts/check-admin-openapi.sh --check</automated>
|
||||
<fails_when>non-zero exit (a committed OpenAPI or schema.d.ts file differs from a fresh generation)</fails_when>
|
||||
<automated>go test -short ./modules/cabana -run 'Example' -count=1 2>&1 | tail -3</automated>
|
||||
<fails_when>non-zero exit or "FAIL" in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "example_form_extension_test.go#ExtrasPlugin.AdminActionExtensions" docs/backend/admin-controllers.md` prints one line
|
||||
- `grep -n "extendFormFields" docs/setup/coming-from-wintercms.md` prints at least one line mentioning the new seam
|
||||
- `npm --prefix admin run test -- tests/form/FormView.test.ts` passes with the two new case titles
|
||||
- `git diff --name-only "$PLAN_START"..HEAD -- admin/src` prints nothing (PLAN_START as in Task 3)
|
||||
- `grep -niE "\bseo\b|fonoteka|plytarium|grzyb" modules/cabana/example_form_extension_test.go docs/backend/admin-controllers.md` prints nothing new from this plan (D-90)
|
||||
</acceptance_criteria>
|
||||
<done>The seam is documented with a runnable example, the docs checker is green, the SPA backstop proves compiled order rendering, and the admin OpenAPI is unchanged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| contributing plugin → owner's admin form | compiled Go code from another plugin adds fields, hooks and partials to a form it does not own |
|
||||
| admin browser → cabana save | posted values, including keys of extension fields the admin may not see |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32-01 | Tampering | cabana save: extension values reaching the owner's model | high | mitigate | Extension values are lifted before ProjectWritableFields; BindWritableFields skips them; TestFormExtensionSave asserts the owner row and columns are untouched |
|
||||
| T-32-02 | Elevation of privilege | cabana save: posted keys of an extension the admin may not see | high | mitigate | visibleExtensions applied in schema, show and save; invisible extensions' keys dropped and Save not called (RESEARCH Pitfall 7); covered by TestFormExtensionSave |
|
||||
| T-32-03 | Information disclosure | extension partials | medium | mitigate | Partials render through PartialData with the existing refusedViewModel guard and sanitizePartialNodes; a GORM model view model is refused |
|
||||
| T-32-04 | Denial of service | boot with a malformed extension | low | accept | A bad extension stops the boot with a clear error by design (D-91); no runtime path |
|
||||
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan; the admin test uses the existing locked node_modules (no npm ci unless npm ls reports an inconsistent tree) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test -short ./...` green at every commit; `go test ./modules/cabana -count=1` green with Docker.
|
||||
- `npm --prefix admin run test -- tests/form/FormView.test.ts` green; `scripts/check-admin-openapi.sh --check` green.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` green.
|
||||
- No file under admin/src changed; no SEO concept or consuming application named in framework code, READMEs or docs (D-90).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
SC-1 holds in the framework: field C after A renders A, C, B; a tab placed between two tabs renders there; unknown anchors, duplicate names and anchor cycles stop the boot with a clear error. The seam is strictly additive and documented.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces (plan 01)
|
||||
|
||||
- `pact.AdminActionExtension.Form` (new field), `pact.AdminFormExtension`, `pact.AdminFormTab`, `pact.AdminFormField`, `pact.AdminFormLoad`, `pact.AdminFormSave`, `pact.AdminFormSaveResult`, `pact.AdminFormErrors` (+ `Error()`), `pact.AdminFormZoneTabs`, `pact.AdminFormZoneOutside`, `pact.AdminFormZoneSecondary`
|
||||
- `modules/cabana/form_extensions.go`: `compileFormExtensions`, `visibleExtensions`, the extension-owned field map
|
||||
- Tests: `TestFormExtensionTracer`, `TestFormExtensionSave`, `TestFormExtensionPlacement`, `TestFormExtensionBootErrors`, `TestFormExtensionTwoPluginsSameAnchor`, `TestFormExtensionOnePluginTwoControllers`, `TestFormExtensionUnextendedSchemaUnchanged`, the example in `modules/cabana/example_form_extension_test.go`
|
||||
- Docs: "Adding fields to another controller's form" in `docs/backend/admin-controllers.md`; README entries in `modules/pact/README.md` and `modules/cabana/README.md`
|
||||
- SPA test cases in `admin/tests/form/FormView.test.ts`
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32-seo-foundation/32-01-SUMMARY.md` when done. Record the Task 1 answer, the commit SHA at the plan's start (the phase base used by scripts/check-phase32.sh in plan 32-05) and every commit SHA.
|
||||
</output>
|
||||
354
.planning/phases/32-seo-foundation/32-02-PLAN.md
Normal file
354
.planning/phases/32-seo-foundation/32-02-PLAN.md
Normal file
@@ -0,0 +1,354 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["32-01"]
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/record_events.go
|
||||
- modules/pact/content.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/record_events.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/record_events_postgres_test.go
|
||||
- modules/cabana/README.md
|
||||
- modules/signpost/signpost.go
|
||||
- modules/signpost/content.go
|
||||
- modules/signpost/content_test.go
|
||||
- modules/signpost/README.md
|
||||
- docs/plugins/extending.md
|
||||
- docs/plugins/menu-item-types.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go
|
||||
- ../sm-journal-plugin/classes/content_descriptors.go
|
||||
- ../sm-journal-plugin/controllers/api/posts.go
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/record_events_test.go
|
||||
- ../sm-journal-plugin/content_descriptors_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3, D-92, D-93, D-96, D-102, D-58, D-90, D-18, D-20c, D-11]
|
||||
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A subscriber learns a record's new public URL after a slug change: cabana's admin save fires pact.RecordSaved after commit, and signpost's content descriptor for that model describes the record with the new per-locale URL (D-92, D-93, SC-3)"
|
||||
- "pact.RecordSaved carries the controller ID, the model's morph type, the record id, Created, the request content locale and the changed attributes with old and new values per locale; translated slugs appear as per-locale changes (D-92)"
|
||||
- "Record events fire after commit only: a rolled-back save fires nothing, and a listener error is logged and never fails the save (D-92)"
|
||||
- "cabana emits RecordSaved/RecordDeleted from create, update, delete, bulk delete, tree reorder and relation-manager child writes; plugin code that writes directly calls cabana.EmitRecordSaved/EmitRecordDeleted (D-92)"
|
||||
- "Journal's /_journal/api/v1 Store, Update and Destroy emit the same events, so a slug changed through MCP is visible to subscribers (D-102, SC-3)"
|
||||
- "pact.MenuItemType gains an optional Content descriptor (controller ID, model, Records, Describe, Match); a type without Content behaves exactly as before (D-93)"
|
||||
- "signpost lists content types, maps a controller ID or a model to its type, describes a record with the host's layered pattern, pages records, and matches a public path to a record with Exact false when a segment (category, locale prefix) does not match the record (D-93, D-11, D-20c)"
|
||||
- "Journal registers descriptors for journal-post, journal-category and the new journal-tag type and imports neither sm-seo-plugin nor sm-sitemap-plugin (D-93, D-96, D-102, SC-2)"
|
||||
- "Journal's post API and post descriptor resolve translated slugs for the request locale (D-58, D-96)"
|
||||
- "AdminFormSave carries the same Changes list, so an extension's Save sees a slug change inside the save transaction (D-92, D-91)"
|
||||
- "The framework change is strictly additive: new optional fields, new types and new functions only; existing pact, cabana and signpost signatures and behaviour are unchanged, and journal, sitemap and the demo app build unchanged against it"
|
||||
prohibitions:
|
||||
- statement: "No breaking change to existing framework API: MenuItemType, AdminFormSave and every existing cabana and signpost function keep their signatures and behaviour; a plugin that registers no Content descriptor and no listener sees no difference"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Content plugins never import sm-seo-plugin or sm-sitemap-plugin; the framework names no SEO concept and no consuming application (D-90, D-93)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "A record event must never fire for a write that did not commit"
|
||||
status: resolved
|
||||
verification: test
|
||||
artifacts:
|
||||
- path: "modules/pact/record_events.go"
|
||||
provides: "RecordChange, RecordSaved, RecordDeleted"
|
||||
contains: "type RecordSaved struct"
|
||||
- path: "modules/pact/content.go"
|
||||
provides: "ContentDescriptor and its request/result types"
|
||||
contains: "type ContentDescriptor struct"
|
||||
- path: "modules/cabana/record_events.go"
|
||||
provides: "EmitRecordSaved, EmitRecordDeleted and the save snapshot diff"
|
||||
contains: "func EmitRecordSaved("
|
||||
- path: "modules/signpost/content.go"
|
||||
provides: "ContentTypes, ContentType, ContentTypeForController, ContentTypeForModel, Describe, Records, MatchPath, MatchPattern"
|
||||
contains: "func (r *Registry) MatchPath("
|
||||
- path: "../sm-journal-plugin/classes/content_descriptors.go"
|
||||
provides: "post, category and tag descriptors"
|
||||
contains: "pact.ContentDescriptor"
|
||||
key_links:
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/record_events.go"
|
||||
via: "CRUDService.save, Delete and BulkDelete register the event with lagoon.AfterCommit on the save transaction"
|
||||
pattern: "emitRecordSaved|EmitRecordSaved"
|
||||
- from: "../sm-journal-plugin/controllers/api/posts.go"
|
||||
to: "modules/cabana/record_events.go"
|
||||
via: "Store/Update/Destroy call cabana.EmitRecordSaved/EmitRecordDeleted next to purgeSiteCache"
|
||||
pattern: "cabana\\.EmitRecord(Saved|Deleted)"
|
||||
- from: "../sm-journal-plugin/classes/menu_item_types.go"
|
||||
to: "modules/pact/content.go"
|
||||
via: "Content: &pact.ContentDescriptor{...} on journal-post, journal-category, journal-tag"
|
||||
pattern: "Content: +&pact\\.ContentDescriptor"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor on any SummerCMS application, **I want to** per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, **so that** every site ships correct, editable SEO without per-site work.
|
||||
|
||||
This plan delivers the second and third framework seams and Journal's adoption: plugins hear about saved and deleted records, and content types describe their records and URLs.
|
||||
|
||||
<objective>
|
||||
Implement D-92 record events and D-93 content descriptors in the framework, and adopt them in Journal per D-96 and D-102:
|
||||
- pact gains `RecordChange`, `RecordSaved`, `RecordDeleted` (fired as pointers through `app.Events` after commit, like `pact.MCPSubjectChanged`), `AdminFormSave.Changes`, `MenuItemType.Content` and the `ContentDescriptor` types;
|
||||
- cabana emits the events from every admin write path and exports `EmitRecordSaved`/`EmitRecordDeleted` for direct writers;
|
||||
- signpost gains content-type lookups, `Describe`, `Records`, `MatchPath` and the pure `MatchPattern`;
|
||||
- Journal registers descriptors for posts, categories and a new `journal-tag` type, emits events from its `/_journal/api/v1` write handlers (D-102), and fixes the translated-slug lookup (D-58), the D-96 fix that belongs to Phase 32. D-96's H1 conversion (D-50) and schema types (D-43) are Phase 33 (see the boundary in plan 32-06).
|
||||
|
||||
Purpose: SC-3 needs a slug change from the admin, MCP or an import to reach the SEO plugin; SC-2 needs Journal posts, categories and tags to take part without importing sm-seo-plugin; D-11 and D-20c need a public path mapped to its record. The framework names no SEO concept (D-90).
|
||||
|
||||
Output: the pact types, `modules/cabana/record_events.go`, `modules/signpost/content.go`, docs, and the Journal changes committed in `../sm-journal-plugin` (branch main).
|
||||
|
||||
**Planner decisions recorded here (Claude's discretion: the exact Go shape, CONTEXT):**
|
||||
- **P-07 (event shape):** `type RecordChange struct { Attribute, Locale string; Old, New any }`; `type RecordSaved struct { Controller, Model, ID, Locale string; Created bool; Changes []RecordChange }`; `type RecordDeleted struct { Controller, Model, ID string }`. `Model` is `lagoon.MorphType(db, record)` (the attach.Owner morph name, else the table name), so the event and the descriptor agree. `ID` is the primary key as text. `Locale` is the request's content locale (towel). On create, `Changes` lists every written column with a nil Old. Fired and listened to as pointers (`app.Events.Listen("vendor.plugin", func(ctx, *pact.RecordSaved) error)`).
|
||||
- **P-08 (helpers):** `cabana.EmitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordSaved)` and `cabana.EmitRecordDeleted(ctx, app, tx, ev pact.RecordDeleted)` register the firing with `lagoon.AfterCommit(ctx, tx, ...)`; with a nil tx they fire immediately. A nil app or bus is a no-op. A listener error is logged with `moduleLog.WarnContext` and never returned (the `fireSubjectChanged` rule).
|
||||
- **P-09 (descriptor shape):** `MenuItemType.Content *ContentDescriptor` (json:"-"); `type ContentDescriptor struct { Controller, Model string; Records func(ctx, ContentList) (ContentPage, error); Describe func(ctx, ContentDescribe) (*ContentRecord, error); Match func(ctx, ContentMatch) (*ContentMatchResult, error) }`; `ContentList{Pattern, After, UnprefixedLocale string; Limit int}`; `ContentPage{Records []ContentRecord; Next string}`; `ContentDescribe{ID, Pattern, UnprefixedLocale string}`; `ContentRecord{ID string; Published bool; LastMod *time.Time; Locales []ContentLocale}`; `ContentLocale{Locale, URL, Title, Description, Image string; Words *int}` (URL "" when the locale or the host has no public page); `ContentMatch{Path, Pattern, UnprefixedLocale string}`; `ContentMatchResult{ID, Locale string; Exact bool}`. `Model` is required and `Describe` is required when `Content` is set; `Records` and `Match` are optional; two types with the same `Content.Model` or `Content.Controller` stop the boot. Descriptor funcs read through `cabana.TxFromContext(ctx)` when a transaction is in ctx, so an extension's Save sees the new slug.
|
||||
- **P-10 (signpost API):** `(*Registry).ContentTypes() []pact.MenuItemType` (registration order), `ContentType(key)`, `ContentTypeForController(id)`, `ContentTypeForModel(model)` (each `(pact.MenuItemType, bool)`), `Describe(ctx, key, id, unprefixedLocale string) (*pact.ContentRecord, error)` and `Records(ctx, key, after string, limit int, unprefixedLocale string) (pact.ContentPage, error)` (both layer the host's pattern override), `MatchPath(ctx, path, unprefixedLocale string) (key string, res *pact.ContentMatchResult, err error)` (types in registration order; the first Exact match wins, else the first non-exact match), and the pure `signpost.MatchPattern(pattern, path string) (map[string]string, bool)` content types use to read placeholder values.
|
||||
- **P-11 (tags):** `journal-tag` with `DefaultPattern "/blog/tag/:slug"` (the vue-journal-layer default route), placeholders `:slug`, `:id`. A host whose site has no tag pages sets the config key `golem15.journal.tag_pages: false` (default true): tag descriptors then answer an empty URL and tag paths never match (RESEARCH Pitfall 9).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-RESEARCH.md
|
||||
@.planning/phases/32-seo-foundation/32-PATTERNS.md
|
||||
@.planning/phases/32-seo-foundation/32-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
@modules/pact/capabilities.go
|
||||
@modules/cabana/subject_events.go
|
||||
@modules/signpost/signpost.go
|
||||
|
||||
<interfaces>
|
||||
From modules/cabana/subject_events.go (lines 25-44): fireSubjectChanged(ctx, app, kind, adminIDs...) — nil app/bus no-op; tx, _ := TxFromContext(ctx); lagoon.AfterCommit(ctx, tx, func(ctx, _ *gorm.DB){ event := &pact.MCPSubjectChanged{...}; if err := app.Events.Fire(ctx, event); err != nil { moduleLog.WarnContext(...) } }).
|
||||
From modules/lagoon/transaction.go: func AfterCommit(ctx context.Context, db *gorm.DB, fn func(ctx context.Context, db *gorm.DB)) — inside a transaction it runs after commit and never after rollback; anywhere else fn runs immediately.
|
||||
From modules/lagoon/deferred.go: func MorphType(db *gorm.DB, model any) (string, error).
|
||||
From modules/festival/bus.go: func (b *Bus) Listen[T any](owner string, fn func(context.Context, T) error); func (b *Bus) Fire[T any](ctx context.Context, event T) error — synchronous.
|
||||
From modules/signpost/signpost.go: Registry; Activate[P](app, plugins) (40); add(owner, t) validation (68-89, message shape "signpost: menu item type %q of plugin %s has no Resolve"); Types() (132); Type(key) (140); Pattern(key, itemOverride) (161); Resolve(ctx, key, reference, itemPattern, nesting, unprefixedLocale) (200).
|
||||
From modules/pact/capabilities.go: MenuItemType (798-808) with func fields tagged json:"-"; MenuItemResolve; ResolvedMenuItem.Alternates.
|
||||
From ../sm-journal-plugin/classes/menu_item_types.go: MenuType* key consts (21-27), label consts (30-35), DefaultPostPattern "/blog/:slug", DefaultCategoryPattern "/blog/category/:slug", postPlaceholders [":slug", ":category", ":id", ":year", ":month", ":day"], menuTypes{app} methods resolvePost (194), resolveCategory (472), loadMenuLocales(ctx, db, unprefixed) (655).
|
||||
From ../sm-journal-plugin/controllers/api/posts.go: Store/Update/Destroy call purgeSiteCache(r, app, post) → cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, ...) (~400); applyPostUpdate (~268).
|
||||
From ../sm-journal-plugin/controllers/api/posts_helpers.go: findPostBySlugOrID(db, slug) (55) matches only the default-locale slug, then a numeric id.
|
||||
From ../sm-journal-plugin/models/post.go: Translatable() includes "slug", TranslatableIndexes() {"slug"}; MorphName() `Golem15\Journal\Models\Post`.
|
||||
Controller IDs: golem15.journal.posts, golem15.journal.categories, golem15.journal.tags.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Confirm the Go shapes of the D-92 record events and the D-93 content descriptors (published framework API)</name>
|
||||
<decision>Do P-07 to P-10 become the published shapes of the record events (D-92) and the content descriptors (D-93)?</decision>
|
||||
<context>Both seams are confirmed decisions; their Go shapes are Claude's discretion and become framework API at the phase's release tag. D-92 is costly to change (subscribers in shared plugins depend on the event shape) and D-93 is costly (every content plugin implements the functions). The shapes are written out in this plan's objective (P-07 event types and helpers, P-09 descriptor types, P-10 signpost functions); everything is additive (new types, a new optional MenuItemType field, a new AdminFormSave field).</context>
|
||||
<options>
|
||||
<option id="as-planned">
|
||||
<name>Adopt P-07 to P-10 as written</name>
|
||||
<pros>Event Model equals the morph type the descriptor declares, so mapping an event to its content type is one lookup; descriptors read the save transaction from ctx, so an extension's Save sees the new URL inside the same transaction; MatchPath gives D-11 ownership and D-20c strict URLs one entry point</pros>
|
||||
<cons>Match is a fourth descriptor function beyond D-93's list (records, lastmod, published, defaults); it is optional</cons>
|
||||
</option>
|
||||
<option id="no-match">
|
||||
<name>As planned without Match/MatchPath; the SEO plugin matches paths by enumerating Records</name>
|
||||
<pros>Smaller published surface</pros>
|
||||
<cons>Strict URLs and URL ownership need a full record scan or a URL index kept by the SEO plugin; translated slugs and category segments are harder to check</cons>
|
||||
</option>
|
||||
<option id="amend">
|
||||
<name>As planned with Jakub's renames or additions</name>
|
||||
<pros>Names fixed before publication</pros>
|
||||
<cons>The executor applies the amendments to every task</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Answer as-planned, no-match, or amend with the changes; the executor records the answer in 32-02-SUMMARY.md</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: End-to-end "a Journal post's slug changes in the admin, a subscriber gets RecordSaved and describes the new URL" — one path only</name>
|
||||
<reversibility rating="costly">Subscribers in shared plugins depend on the event shape and every content plugin implements the descriptor functions (D-92, D-93).</reversibility>
|
||||
<files>modules/pact/record_events.go, modules/pact/content.go, modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/record_events.go, modules/cabana/crud.go, modules/cabana/README.md, modules/signpost/content.go, modules/signpost/signpost.go, modules/signpost/README.md, ../sm-journal-plugin/classes/content_descriptors.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/record_events_test.go</files>
|
||||
<read_first>
|
||||
- modules/cabana/subject_events.go (whole file)
|
||||
- modules/cabana/crud.go (save 736-944: loadRecord, Fill, tx.Save/Create, applyMLTranslations; the extension Save call added in plan 32-01)
|
||||
- modules/lagoon/transaction.go (AfterCommit 95-130) and modules/lagoon/deferred.go (MorphType)
|
||||
- modules/signpost/signpost.go (whole file, 240 lines)
|
||||
- modules/pact/capabilities.go (MenuItemType 782-860; AdminFormSave from plan 32-01)
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go (19-160, resolvePost 194-272, loadMenuLocales 655)
|
||||
- ../sm-journal-plugin/admin_harness_test.go (how Journal tests drive cabana over HTTP with Postgres)
|
||||
- The Task 1 answer recorded by the executor
|
||||
</read_first>
|
||||
<action>
|
||||
Apply the Task 1 answer (the text assumes as-planned). Per D-92 and D-93:
|
||||
1. pact: `record_events.go` with RecordChange, RecordSaved, RecordDeleted and doc comments stating after-commit, pointer firing, the Model/ID conventions (P-07); `content.go` with the P-09 types; in `capabilities.go` add `Content *ContentDescriptor` with `json:"-"` as the last field of `MenuItemType` and `Changes []RecordChange` as the last field of `AdminFormSave`, extending their doc comments.
|
||||
2. cabana `record_events.go`: `EmitRecordSaved`, `EmitRecordDeleted` (P-08) and an unexported `recordSnapshot` that captures host column values after `loadRecord` and before Fill. In `crud.go` save (update path only for the tracer): diff the snapshot against the saved row, build `Changes`, fill `AdminFormSave.Changes` before extension Save calls, and call `EmitRecordSaved(ctx, app, tx, pact.RecordSaved{Controller: controllerID(cc), Model: morph type, ID, Locale, Created, Changes})` after the write inside the transaction (it fires after commit).
|
||||
3. signpost `content.go`: `ContentTypes`, `ContentTypeForModel`, `Describe` (layering the pattern through `Pattern(key, "")`); in `signpost.go` `add` validate `Content` (Model required, Describe required). Remaining P-10 functions come in Task 4.
|
||||
4. Journal: `classes/content_descriptors.go` with the post descriptor (`Controller: controllers.PostsControllerID` value "golem15.journal.posts" — use the literal or a const in classes to avoid an import cycle; `Model` = the Post morph name; `Describe` returning per-locale URL built exactly as resolvePost builds alternates, title, plain-text excerpt as Description, featured image URL as Image, body word count as Words, Published, LastMod = updated_at), reading through `cabana.TxFromContext(ctx)` when present; set `Content:` on the journal-post entry in `menu_item_types.go`.
|
||||
5. Tracer test `TestRecordSavedDescribesNewURL` in `../sm-journal-plugin/record_events_test.go` (Postgres, Journal's admin harness): register a listener for `*pact.RecordSaved`; PUT a post with a new slug through the admin API; the listener receives exactly one event with Controller "golem15.journal.posts", Model equal to the Post morph name, a slug change old → new, and `signpost` `ContentTypeForModel(ev.Model)` + `Describe` return the new URL "/blog/<new-slug>"; a second PUT that the test forces to fail validation fires no event.
|
||||
6. Docs in the same commits (documentation rule): pact README entries; cabana README "Record events" bullet; signpost README "Content descriptors" usage. Framework commit first (`feat(32-02): record events and content descriptors (tracer)`), then the Journal commit in `../sm-journal-plugin` (`feat: post content descriptor`), both staged by path with `git -c safe.directory='*'` in the sibling repo.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestRecordSavedDescribesNewURL' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestRecordSavedDescribesNewURL" line</fails_when>
|
||||
<automated>go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Content \*ContentDescriptor" modules/pact/capabilities.go` and `grep -n "Changes \[\]RecordChange" modules/pact/capabilities.go` each print one line
|
||||
- `grep -n "func EmitRecordSaved(ctx context.Context, app \*backpack.App, tx \*gorm.DB, ev pact.RecordSaved)" modules/cabana/record_events.go` prints one line
|
||||
- `grep -n "lagoon.AfterCommit" modules/cabana/record_events.go` prints at least one line
|
||||
- `go -C ../sm-journal-plugin test ./... -run TestRecordSavedDescribesNewURL -count=1` passes, including the no-event-on-failed-save assertion
|
||||
- `grep -rn "sm-seo-plugin\|sm-sitemap-plugin" ../sm-journal-plugin --include=*.go` prints nothing
|
||||
</acceptance_criteria>
|
||||
<done>An admin slug change on a Journal post reaches a subscriber after commit and the subscriber reads the new URL through signpost, with no event on a failed save; committed in both repos with docs.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Every emission point, translated-slug changes, the direct-writer helpers and Journal's API emission (D-92, D-102)</name>
|
||||
<files>modules/cabana/record_events.go, modules/cabana/crud.go, modules/cabana/reorder.go, modules/cabana/relation_child.go, modules/cabana/record_events_postgres_test.go, modules/cabana/README.md, docs/plugins/extending.md, docs/backend/admin-controllers.md, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/record_events_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>
|
||||
- modules/cabana/crud.go (Delete 287, BulkDelete 331, BulkAction 393, save create path, applyMLTranslations call)
|
||||
- modules/cabana/field_ml.go (liftMLValues 68, applyMLTranslations 163; TranslationWriter in field_ml.go 31-33)
|
||||
- modules/cabana/reorder.go (reorderPost) and modules/cabana/relation_child.go (child create/update/delete)
|
||||
- modules/cabana/subject_events_test.go (TestFireSubjectChangedWaitsForCommit pattern)
|
||||
- docs/plugins/extending.md (Events section, line 11; Model hooks section, line 62)
|
||||
- ../sm-journal-plugin/controllers/api/posts.go (Store, Update, Destroy, purgeSiteCache call sites, applyPostUpdate)
|
||||
- ../sm-journal-plugin/journal_api_writes_test.go (API write test harness)
|
||||
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 2, Pitfalls 8 and 12)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Create through cabana: one RecordSaved with Created true and the written columns as Changes (Old nil)
|
||||
- Update changing a translated slug in locale pl only: Changes holds {Attribute "slug", Locale "pl", Old, New}, read with TranslatedExact before applyMLTranslations
|
||||
- Update that changes nothing: RecordSaved still fires with an empty Changes list (subscribers re-check URLs)
|
||||
- Delete and each row of a bulk delete: one RecordDeleted per record, after commit
|
||||
- Tree reorder changing parent_id: RecordSaved with a parent_id change; relation-manager child create/update/delete: events for the child's controller
|
||||
- Rollback (validation error, extension Save error, FormBefore* error): no event
|
||||
- A listener returning an error: the save answers 200 and the warning is logged
|
||||
- Journal PUT /_journal/api/v1/posts/{id} changing the slug: one RecordSaved with Controller "", Model the Post morph name and the slug change; POST: Created true; DELETE: RecordDeleted
|
||||
</behavior>
|
||||
<action>
|
||||
Complete D-92 per the behaviour list. In cabana add the remaining emission points (create path of save, Delete, BulkDelete, reorderPost, relation-manager child writes) through one unexported `emitRecordSaved/emitRecordDeleted` that wraps the exported helpers; per-locale old values come from the TranslationWriter's exact read for submitted ML fields before `applyMLTranslations` (RESEARCH Pitfall 12). Journal per D-102: in `controllers/api/posts.go` capture the old slug (default locale and any submitted locale) before `applyPostUpdate`, then call `cabana.EmitRecordSaved(r.Context(), app, nil, ...)` / `cabana.EmitRecordDeleted` next to `purgeSiteCache` in Store, Update and Destroy (the writes are committed when the helper runs, so a nil tx fires at once).
|
||||
Tests first: `TestRecordEvents` in `modules/cabana/record_events_postgres_test.go` (create, update, translated slug, no-op update, delete, bulk delete, reorder, child write, rollback cases, failing listener) and `TestAPIRecordEvents` in `../sm-journal-plugin/record_events_test.go` (POST, PUT slug change, DELETE). Docs: `docs/plugins/extending.md` "Record events" subsection under Events (listening with `app.Events.Listen` and the pointer types, after-commit, the emit helpers for direct writers, why GORM hooks on another plugin's model are not the way); `docs/backend/admin-controllers.md` notes which admin operations emit; cabana README API table rows for the two helpers; Journal README notes that its API emits record events.
|
||||
Framework commit(s) first, then the Journal commit, staged by path.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test ./modules/cabana -run 'TestRecordEvents' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "--- FAIL" or "FAIL" line, "no tests to run", or no "--- PASS: TestRecordEvents" line</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestAPIRecordEvents|TestRecordSavedDescribesNewURL' -count=1 2>&1 | tail -5</automated>
|
||||
<fails_when>non-zero exit or "FAIL" in the output</fails_when>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>non-zero exit, or docs:build reporting an unknown identifier or broken link</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test ./modules/cabana -run TestRecordEvents -v` shows passing subtests for create, update, translated slug, delete, bulk delete, reorder, child write, rollback and failing listener
|
||||
- `grep -c "cabana.EmitRecord" ../sm-journal-plugin/controllers/api/posts.go` prints 3 or more
|
||||
- `grep -n "Record events" docs/plugins/extending.md` prints a heading line
|
||||
- `go vet ./... && go test -short ./...` exits 0 in the framework and `go -C ../sm-journal-plugin vet ./...` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Every cabana write path and Journal's API emit after-commit record events with per-locale old and new values; direct writers have documented helpers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Complete descriptors in signpost and Journal: categories, the journal-tag type, Records, Match/MatchPath with strict segments, translated slugs (D-93, D-58)</name>
|
||||
<files>modules/signpost/content.go, modules/signpost/signpost.go, modules/signpost/content_test.go, modules/signpost/README.md, modules/pact/README.md, docs/plugins/menu-item-types.md, ../sm-journal-plugin/classes/content_descriptors.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/content_descriptors_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>
|
||||
- modules/signpost/signpost.go and modules/signpost/content.go (Task 2)
|
||||
- modules/signpost/signpost_test.go (fixture style)
|
||||
- docs/plugins/menu-item-types.md (Registering types, Resolving an item, URL patterns)
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go (categoryReferences 406, resolveCategory 472, postPlaceholders 55)
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go (findPostBySlugOrID 55-75) and ../sm-journal-plugin/models/post.go (Translatable, TranslatableIndexes)
|
||||
- ../sm-journal-plugin/models/tag.go and models/tag/fields.yaml; controllers/tags.go (TagsControllerID)
|
||||
- ../sm-journal-plugin/menu_item_types_test.go
|
||||
- ../vue-journal-layer/app/utils/routes.ts (tag route default '/blog/tag/:tag')
|
||||
</read_first>
|
||||
<behavior>
|
||||
- signpost: a type without Content is absent from ContentTypes and resolves exactly as before; Content without Model or without Describe stops the boot naming the type and plugin; two types with the same Content.Model or Content.Controller stop the boot
|
||||
- MatchPattern("/blog/:slug", "/blog/hello") = {slug: hello}, true; MatchPattern("/:category/:slug", "/news/hello") = {category: news, slug: hello}; a path with an extra segment does not match; a pattern placeholder never matches an empty segment
|
||||
- MatchPath returns the first Exact match in registration order, else the first non-exact one, else no match
|
||||
- Journal post Match: /blog/<slug> → Exact true; with pattern /:category/:slug, a wrong category for an existing post → Exact false and the post's ID; an unpublished post → no match; /pl/blog/<pl-slug> with pl prefixed → Locale pl, Exact true; the default-locale slug under the pl prefix → Exact false
|
||||
- journal-tag: References lists tags, Resolve returns /blog/tag/<slug>, Describe and Match work; with golem15.journal.tag_pages false Describe returns empty URLs and Match never matches
|
||||
- Categories: descriptor with Controller golem15.journal.categories and Describe/Match/Records
|
||||
- D-58: GET /_journal/api/v1/posts/<translated-slug> with the request locale set resolves the post; the default-locale slug still resolves; an unknown slug still answers 404
|
||||
</behavior>
|
||||
<action>
|
||||
Finish P-10 in signpost (`ContentType`, `ContentTypeForController`, `Records`, `MatchPath`, `MatchPattern`, duplicate Model/Controller checks in `add`/`Activate`) and write `TestContent*` cases in `modules/signpost/content_test.go`. In Journal (D-93, D-96, D-102, D-58): category descriptor; the new `MenuTypeTag = "journal-tag"` const, its label const `golem15.journal::lang.menuitem.journal_tag` with en "Journal tag" and pl "Tag bloga" in the lang files, `DefaultTagPattern = "/blog/tag/:slug"`, tag References/Resolve, and its descriptor with `Controller "golem15.journal.tags"` (P-11, config key `golem15.journal.tag_pages`); `Records` for all three (id order, `After` as the last id, `Limit` capped at 500); `Match` for all three using `signpost.MatchPattern` after stripping a locale prefix for the locales `loadMenuLocales` returns, comparing every placeholder segment (category of the post as resolvePost chooses it, year/month/day, id) and the locale prefix with the record (D-20c); translated slugs matched through the translate plugin's attribute rows for the matched locale. D-58: `findPostBySlugOrID` takes the request locale and, after the default slug, matches the translated slug of that locale (published posts only, as today); update its callers.
|
||||
Docs: `docs/plugins/menu-item-types.md` gains "Content descriptors" (what each function returns, the tx-from-ctx rule, MatchPattern, Exact semantics, a type with no public pages answering empty URLs) with neutral names; signpost and pact READMEs; Journal README lists the descriptors, journal-tag and tag_pages. Framework commit first, then Journal commits (descriptors + journal-tag; D-58 fix as its own commit `fix: resolve translated post slugs (D-58)`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short ./modules/signpost ./modules/pact -run 'TestContent' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestContent" line</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test ./... -run 'TestContentDescriptors|TestJournalTagType|TestTranslatedSlug' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or any of TestContentDescriptors, TestJournalTagType, TestTranslatedSlug missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output (the whole Journal suite, including its existing menu and API tests, must stay green)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func MatchPattern(pattern, path string) (map\[string\]string, bool)" modules/signpost/content.go` prints one line
|
||||
- `grep -n "\"journal-tag\"" ../sm-journal-plugin/classes/menu_item_types.go` prints one line
|
||||
- `grep -c "Content: *&pact.ContentDescriptor" ../sm-journal-plugin/classes/menu_item_types.go` prints 3
|
||||
- `grep -n "journal_tag" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each file
|
||||
- `grep -n "Content descriptors" docs/plugins/menu-item-types.md` prints a heading line; `go run ./cmd/summer docs:build --check` exits 0
|
||||
- `go -C ../sm-sitemap-plugin build ./... && go -C ../sm-sitemap-plugin test -short ./...` exits 0 with no change in sm-sitemap-plugin (existing consumer unchanged)
|
||||
</acceptance_criteria>
|
||||
<done>signpost exposes content types, descriptions, paging and strict path matching; Journal posts, categories and tags take part through descriptors without importing any SEO or sitemap plugin, translated slugs resolve, and existing consumers build unchanged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| public path → descriptor Match | an anonymous visitor's URL segments reach content plugins' queries through MatchPath (used by the public resolver in plan 32-03/04) |
|
||||
| MCP/API client → Journal write handlers → event subscribers | writes made outside cabana now notify other plugins |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32-05 | Denial of service | cabana save with a failing listener | medium | mitigate | Listener errors are logged, never returned; the save answers normally (TestRecordEvents failing-listener case) |
|
||||
| T-32-06 | Tampering | events for rolled-back writes | high | mitigate | Emission through lagoon.AfterCommit on the save transaction; rollback cases in TestRecordEvents assert no event |
|
||||
| T-32-07 | Tampering | descriptor Match with visitor-supplied segments | high | mitigate | Placeholder values are bound as query parameters (GORM Where with ?), never concatenated; MatchPattern refuses empty segments and extra segments |
|
||||
| T-32-08 | Information disclosure | Match/Describe and the D-58 slug lookup on drafts | high | mitigate | Match answers no match for unpublished records and findPostBySlugOrID keeps its published-only scope for anonymous callers; covered by TestContentDescriptors and TestTranslatedSlug |
|
||||
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Framework: `go vet ./... && go test -short ./...` at every commit; `go test ./modules/cabana -run TestRecordEvents -count=1` (Docker); `go test -short ./modules/signpost -count=1`; docs checker green.
|
||||
- Journal: `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./...` (Docker) green.
|
||||
- sm-sitemap-plugin builds and tests unchanged against the framework checkout.
|
||||
- `grep -rn "sm-seo-plugin" ../sm-journal-plugin --include=*.go` empty.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
A slug change from the admin or the Journal API reaches subscribers after commit with old and new values; Journal posts, categories and tags are described and matched through signpost without importing any SEO plugin; the change is strictly additive.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces (plan 02)
|
||||
|
||||
- `pact.RecordChange`, `pact.RecordSaved`, `pact.RecordDeleted` (`modules/pact/record_events.go`)
|
||||
- `pact.ContentDescriptor`, `pact.ContentList`, `pact.ContentPage`, `pact.ContentDescribe`, `pact.ContentRecord`, `pact.ContentLocale`, `pact.ContentMatch`, `pact.ContentMatchResult` (`modules/pact/content.go`)
|
||||
- New fields `pact.MenuItemType.Content`, `pact.AdminFormSave.Changes`
|
||||
- `cabana.EmitRecordSaved`, `cabana.EmitRecordDeleted` (`modules/cabana/record_events.go`)
|
||||
- `signpost.(*Registry).ContentTypes`, `ContentType`, `ContentTypeForController`, `ContentTypeForModel`, `Describe`, `Records`, `MatchPath`; `signpost.MatchPattern` (`modules/signpost/content.go`)
|
||||
- Journal: `classes/content_descriptors.go`, `MenuTypeTag` (`journal-tag`), `DefaultTagPattern`, config key `golem15.journal.tag_pages`, phrase `golem15.journal::lang.menuitem.journal_tag`, translated-slug lookup in `findPostBySlugOrID`
|
||||
- Tests: `TestRecordSavedDescribesNewURL`, `TestRecordEvents`, `TestAPIRecordEvents`, `TestContent*` (signpost), `TestContentDescriptors`, `TestJournalTagType`, `TestTranslatedSlug`
|
||||
- Docs: "Record events" in `docs/plugins/extending.md`, "Content descriptors" in `docs/plugins/menu-item-types.md`
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32-seo-foundation/32-02-SUMMARY.md` when done, with the Task 1 answer and every commit SHA in both repositories (the Journal commits are needed by plan 32-05's demo submodule move and plan 32-06's release).
|
||||
</output>
|
||||
440
.planning/phases/32-seo-foundation/32-03-PLAN.md
Normal file
440
.planning/phases/32-seo-foundation/32-03-PLAN.md
Normal file
@@ -0,0 +1,440 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["32-01", "32-02"]
|
||||
files_modified:
|
||||
- ../sm-seo-plugin/go.mod
|
||||
- ../sm-seo-plugin/go.sum
|
||||
- ../sm-seo-plugin/go.work
|
||||
- ../sm-seo-plugin/.gitignore
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/plugin.go
|
||||
- ../sm-seo-plugin/logger.go
|
||||
- ../sm-seo-plugin/admin.go
|
||||
- ../sm-seo-plugin/admin_permissions.go
|
||||
- ../sm-seo-plugin/admin_navigation.go
|
||||
- ../sm-seo-plugin/extensions.go
|
||||
- ../sm-seo-plugin/field_types.go
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/jobs.go
|
||||
- ../sm-seo-plugin/commands.go
|
||||
- ../sm-seo-plugin/listeners.go
|
||||
- ../sm-seo-plugin/config/config.yaml
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/models/registry.go
|
||||
- ../sm-seo-plugin/models/entry.go
|
||||
- ../sm-seo-plugin/models/revision.go
|
||||
- ../sm-seo-plugin/models/settings.go
|
||||
- ../sm-seo-plugin/models/type_setting.go
|
||||
- ../sm-seo-plugin/models/rule.go
|
||||
- ../sm-seo-plugin/models/settings/fields.yaml
|
||||
- ../sm-seo-plugin/models/rule/fields.yaml
|
||||
- ../sm-seo-plugin/models/rule/columns.yaml
|
||||
- ../sm-seo-plugin/models/revision/fields.yaml
|
||||
- ../sm-seo-plugin/models/revision/columns.yaml
|
||||
- ../sm-seo-plugin/updates/registry.go
|
||||
- ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go
|
||||
- ../sm-seo-plugin/updates/202610270002_create_golem15_seo_revisions.go
|
||||
- ../sm-seo-plugin/updates/202610270003_create_golem15_seo_settings.go
|
||||
- ../sm-seo-plugin/updates/202610270004_create_golem15_seo_type_settings.go
|
||||
- ../sm-seo-plugin/updates/202610270005_create_golem15_seo_rules.go
|
||||
- ../sm-seo-plugin/controllers/admin_registry.go
|
||||
- ../sm-seo-plugin/controllers/settings.go
|
||||
- ../sm-seo-plugin/controllers/settings/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/settings/_staging.htm
|
||||
- ../sm-seo-plugin/controllers/settings/_placeholders.htm
|
||||
- ../sm-seo-plugin/controllers/rules.go
|
||||
- ../sm-seo-plugin/controllers/rules/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/rules/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/rules/_seo_status.htm
|
||||
- ../sm-seo-plugin/controllers/rules/_seo_effective.htm
|
||||
- ../sm-seo-plugin/controllers/history.go
|
||||
- ../sm-seo-plugin/controllers/history/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/history/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/history/_history_diff.htm
|
||||
- ../sm-seo-plugin/controllers/api/resolve.go
|
||||
- ../sm-seo-plugin/controllers/api/helpers.go
|
||||
- ../sm-seo-plugin/extension/_seo_status.htm
|
||||
- ../sm-seo-plugin/extension/_seo_effective.htm
|
||||
- ../sm-seo-plugin/extension/_placeholders.htm
|
||||
- ../sm-seo-plugin/classes/service.go
|
||||
- ../sm-seo-plugin/classes/resolver.go
|
||||
- ../sm-seo-plugin/classes/templates.go
|
||||
- ../sm-seo-plugin/classes/urls.go
|
||||
- ../sm-seo-plugin/classes/robots_value.go
|
||||
- ../sm-seo-plugin/classes/entries.go
|
||||
- ../sm-seo-plugin/classes/history.go
|
||||
- ../sm-seo-plugin/classes/snippet.go
|
||||
- ../sm-seo-plugin/classes/image.go
|
||||
- ../sm-seo-plugin/classes/field_types.go
|
||||
- ../sm-seo-plugin/classes/generate.go
|
||||
- ../sm-seo-plugin/package.json
|
||||
- ../sm-seo-plugin/package-lock.json
|
||||
- ../sm-seo-plugin/tsconfig.json
|
||||
- ../sm-seo-plugin/vite.config.ts
|
||||
- ../sm-seo-plugin/scripts/check-dist.sh
|
||||
- ../sm-seo-plugin/assets/src/elements/index.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/measure.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/labels.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/text.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/robots.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/serp.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/image.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/styles.css
|
||||
- ../sm-seo-plugin/assets/dist/seo.js
|
||||
- ../sm-seo-plugin/assets/dist/seo.css
|
||||
- ../sm-seo-plugin/testmain_test.go
|
||||
- ../sm-seo-plugin/fixture_test.go
|
||||
- ../sm-seo-plugin/seo_tab_test.go
|
||||
- ../sm-seo-plugin/settings_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-2, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-15, D-21, D-22, D-23, D-90, D-91, D-92, D-93, D-94, D-95, D-100, D-104, D-13]
|
||||
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A registered content type's admin form shows the SEO tab added by sm-seo-plugin through the D-91 extension, and its values are stored in the plugin's one table keyed by model type, record id and locale; the content plugin imports nothing from sm-seo-plugin (D-01, D-93, SC-2)"
|
||||
- "GET /_seo/api/v1/resolve?url=... answers the effective SEO of a URL for published content: title, description, canonical, robots, the X-Robots-Tag value, Open Graph and Twitter values, and verification meta on the home page (D-02, D-04, D-05, D-06, D-08, SC-2)"
|
||||
- "Precedence per value is URL rule > record > per-type template or global settings > content-derived values, and the resolver records the source of every value (rule, record, template, global, content, default) (D-02, D-13)"
|
||||
- "A URL rule (exact or pattern such as /blog?tag=*) sets SEO for an address with or without a record behind it and wins over the record's tab (D-02)"
|
||||
- "Global SEO settings hold the title and home templates, default description, default social image, site name, twitter:site, verification codes and files, the query-parameter list, never-indexed and search paths, and the address options; per content type a tab holds the inject switch, title and description templates and the default robots (D-03, D-05, D-07, D-21, D-104)"
|
||||
- "Canonical is self-referencing and absolute from the configured site URL by default, keeps content parameters, drops filter and tracking parameters, and takes record or rule overrides, relative or absolute (D-06, D-07)"
|
||||
- "Robots resolve per directive through rule > record > never-indexed/search paths and filter-parameter URLs > type default > index,follow; the same directives are returned as the X-Robots-Tag value; outside production every answer is noindex,nofollow with staging true (D-08, D-21, D-22, D-23)"
|
||||
- "One permission golem15.seo.manage covers the SEO tab, URL rules, settings and history; without it there is no navigation item, no settings tile and no SEO tab, and saves leave SEO values untouched (D-15)"
|
||||
- "Every SEO write from the admin is recorded in history with author, time, via and before/after values (D-13)"
|
||||
- "Deleting a record deletes its SEO entries after commit while history keeps the values (D-92, D-94)"
|
||||
- "A GenerateEntries job run through conga creates missing entries with the current URL of every published record (D-94)"
|
||||
- "The SEO tab's anchor names seo_robots and seo_og_image are reserved for Phase 33 fields and the entry has a data jsonb column (D-43/D-44 room)"
|
||||
- "SEO tab on a new record: P1 and P2 do not render, E3 shows the address-after-first-save line and the template title, E1 shows the empty status (UI-SPEC empty)"
|
||||
- "E3 without a description anywhere shows the no-description line in muted italic (UI-SPEC empty)"
|
||||
- "E3 shows a skeleton card until the context action answers and a checking line while the duplicate action is pending (UI-SPEC loading)"
|
||||
- "Lists, forms and previews show the existing skeletons, and plugin elements show the existing PluginField/WidgetField skeleton until they are defined (UI-SPEC loading)"
|
||||
- "When an element script fails or times out the existing ExtensionFailure box shows; a failed widget action shows the danger toast and E3 still renders from fill-values alone (UI-SPEC error)"
|
||||
- "A refused save shows field lines, the count banner and the SEO tab error badge (UI-SPEC error)"
|
||||
- "SEO tab fields follow UI-SPEC C3 order and spans, with every ML value under the form's one locale switcher (UI-SPEC populated)"
|
||||
- "A record with SEO in one locale only: the other locale's values are empty (inherit), E1 shows empty, E2 shows Inherit, P2 shows template or content sources (UI-SPEC partial)"
|
||||
- "A locale without an address shows effective.no_url in P2 and noUrl in E3 (UI-SPEC partial)"
|
||||
- "A URL rule overriding a record shows the status.rule callout at the top of the SEO tab listing the overridden properties (UI-SPEC partial)"
|
||||
- "A type whose Show the SEO tab switch is off has the tab removed per request while stored values keep working (UI-SPEC partial)"
|
||||
- "Outside production the settings and robots screens show the staging callout and P2 shows the status.staging note (UI-SPEC partial)"
|
||||
- "E3 duplicate warnings: none for zero, plural dupTitle/dupDescription for one or many, at most three links plus andMore (UI-SPEC zero-one-many)"
|
||||
- "Settings show General, one tab per registered content type in registration order, then Indexing, Addresses and Verification; zero registered types leaves only the four fixed tabs (UI-SPEC zero-one-many, C11)"
|
||||
- "Settings repeaters use titleFrom and collapse per row; long URLs in P2 wrap with overflow-wrap anywhere; E3 truncates title and description at the pixel budget with an ellipsis (UI-SPEC overflow, long-text)"
|
||||
- "URL rules list shows the empty-state copy rules.list_empty, url/match/overrides/updated columns, and the form follows UI-SPEC C7 (UI-SPEC empty, populated)"
|
||||
- "History list shows history.list_empty when empty and newest first with Undone badges when populated (UI-SPEC empty, populated)"
|
||||
- "Keyphrase checks match Polish inflection by the C6 stem rule: grzyby matches grzybów, case and diacritics ignored (UI-SPEC partial, Q-4)"
|
||||
- "Every OG image field (SEO tab, URL rule, settings default) is an seoimage field that uploads its own file; extensions own no attachments (D-100)"
|
||||
prohibitions:
|
||||
- statement: "Content plugins never import sm-seo-plugin, and the SEO data never lives in content plugins' tables or columns (D-90, D-01)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "The public resolver never answers the SEO of an unpublished record and never builds absolute URLs from the request Host header"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "The non-production lock cannot be disabled from the admin or MCP; it depends on the environment only (D-22)"
|
||||
status: resolved
|
||||
verification: test
|
||||
artifacts:
|
||||
- path: "../sm-seo-plugin/plugin.go"
|
||||
provides: "Plugin golem15.seo: Register/Boot, interface assertions"
|
||||
contains: "func (p *Plugin) ID() string { return \"golem15.seo\" }"
|
||||
- path: "../sm-seo-plugin/extensions.go"
|
||||
provides: "AdminActionExtensions: the SEO tab per content type and the per-type tabs on golem15.seo.settings"
|
||||
contains: "pact.AdminFormExtension"
|
||||
- path: "../sm-seo-plugin/classes/resolver.go"
|
||||
provides: "Resolver.Resolve with per-value sources and precedence"
|
||||
contains: "func (r *Resolver) Resolve("
|
||||
- path: "../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go"
|
||||
provides: "golem15_seo_entries keyed by (model_type, record_id, locale) with data jsonb"
|
||||
contains: "golem15_seo_entries"
|
||||
- path: "../sm-seo-plugin/assets/dist/seo.js"
|
||||
provides: "E1, E2, E3, E7 custom elements"
|
||||
- path: "../sm-seo-plugin/seo_tab_test.go"
|
||||
provides: "TestSEOTabTracer, TestSEOTab"
|
||||
contains: "func TestSEOTabTracer("
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/extensions.go"
|
||||
to: "summercms.go/modules/signpost/content.go"
|
||||
via: "AdminActionExtensions reads signpost ContentTypes() and targets each Content.Controller"
|
||||
pattern: "ContentTypes\\(\\)"
|
||||
- from: "../sm-seo-plugin/controllers/api/resolve.go"
|
||||
to: "../sm-seo-plugin/classes/resolver.go"
|
||||
via: "GET /_seo/api/v1/resolve calls Resolver.Resolve"
|
||||
pattern: "Resolve\\(r\\.Context\\(\\)"
|
||||
- from: "../sm-seo-plugin/listeners.go"
|
||||
to: "summercms.go/modules/pact/record_events.go"
|
||||
via: "app.Events.Listen(\"golem15.seo\", func(ctx, *pact.RecordDeleted) error)"
|
||||
pattern: "\\*pact\\.RecordDeleted"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor on any SummerCMS application, **I want to** per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, **so that** every site ships correct, editable SEO without per-site work.
|
||||
|
||||
This plan delivers the editor-facing core: the SEO tab on every registered content type, URL rules, global settings with per-type tabs, history, and the effective-SEO answer for the frontend.
|
||||
|
||||
<objective>
|
||||
Create `sm-seo-plugin` (Golem15.SEO, module `git.golem15.com/golem15/sm-seo-plugin`, package `seo`, ID `golem15.seo`) at `../sm-seo-plugin` per D-94, on the seams of plans 32-01 and 32-02 only:
|
||||
- models and migrations: entries (D-01), revisions (history core, D-13), settings, per-type settings, URL rules (D-02);
|
||||
- the SEO tab added to every content type with a `Content.Controller` through D-91 (D-01, D-04, D-06, D-08, D-09), with the `seoimage` field type for the social image (D-100);
|
||||
- the settings screen as an `AdminSingleton` linked from a settings tile, its per-type tabs added through D-91 on itself (D-104, D-03, D-05, D-07, D-21);
|
||||
- the effective-SEO resolver with per-value sources and its public `GET /_seo/api/v1/resolve` (D-02, D-06, D-07, D-08, D-21, D-22 lock, D-23 path patterns);
|
||||
- the GenerateEntries job (D-94), entry cleanup on `RecordDeleted` (D-92), one permission (D-15);
|
||||
- UI-SPEC elements E1, E2, E3, E7 and partials P1, P2, P3, P5.
|
||||
Redirects, normalisation, strict URLs and robots.txt are plan 32-04; the management API, MCP, batch, undo and CSV are plan 32-05. `Requires()` stays empty in Phase 32: the D-95 sitemap filter interface and the `Requires` on Golem15.Sitemap arrive with the sitemap rewrite in Phase 33.
|
||||
|
||||
Purpose: SC-2 (Journal posts, categories and tags show the SEO tab without importing sm-seo-plugin; the frontend gets the effective SEO of any URL with the precedence URL rule > record > global > derived).
|
||||
|
||||
Output: the new repository with commits on branch `main`, nothing pushed (pushing waits for Jakub's go-ahead in plan 32-06).
|
||||
|
||||
**Planner decisions recorded here (Claude's discretion: table and package names, CONTEXT):**
|
||||
- **P-12 (repository):** clone `git@git.golem15.com:golem15/sm-seo-plugin.git` (it exists, empty, public) into `../sm-seo-plugin` and work on branch `main`; if cloning an empty repository is refused, `git init -b main` and `git remote add origin git@git.golem15.com:golem15/sm-seo-plugin.git`. No push in this plan. `go.mod` requires the framework at the latest existing tag from `git -C ../summercms.go tag --sort=-v:refname | head -1` (no version written into the plan); `go.work` (tracked, like the sibling plugins) uses `.` and replaces `git.golem15.com/golem15/summercms => ../summercms.go`.
|
||||
- **P-13 (tables):** prefix `golem15_seo_`; `entries` (id, model_type, record_id text, locale, type_key, url, title, description, keyphrase, canonical, robots, og_title, og_description, og_image, effective_title, effective_description, data jsonb not null default '{}', created_at, updated_at; unique (model_type, record_id, locale)); `revisions` (id, batch_id text null, author_id null, author_label, via admin|mcp|import|auto|undo, subject_kind entry|rule|redirect|robots|settings|notfound, subject_id text, what, before jsonb, after jsonb, undone_at null, undone_by_id null, created_at); `settings` (singleton row 1; site_name, twitter_site, title_template default '%title% | %site%', home_template default '%site%', default_description, default_og_image, query_params jsonb seeded with page=content, utm_*/fbclid/gclid/msclkid=tracking, sort=filter; noindex_paths, search_paths, url_host keep|www|bare, url_slash keep|add|remove, url_lowercase bool default false, verify_google, verify_bing, verify_yandex, verify_other jsonb, verify_files jsonb, csv_delimiter, robots columns added in plan 32-04); `type_settings` (type_key primary key, inject bool default true, title_template, description_template, robots); `rules` (id, url, match exact|pattern, title, description, canonical, robots, og_title, og_description, og_image, note, timestamps; unique (url, match)). Raw SQL DDL, timestamped migration IDs, one file per table (sm-sitemap-plugin pattern).
|
||||
- **P-14 (precedence per value, D-02 with the approved UI-SPEC copy):** title: rule > record > type title template, else the general template, applied to the content title (source template) > content title > site name; description: rule > record > type description template > content description (excerpt) > settings default description (source global); og:title, og:description: rule > record > effective title/description; og:image: rule > record > content image > settings default social image; canonical: rule > record > self; robots per directive: rule > record > never-indexed paths (noindex,nofollow), search paths and URLs carrying a filter parameter (noindex,follow) > type default > index,follow. The per-type template is the "global defaults" layer of D-02; the site-wide default description and default social image are last-resort fallbacks after content, as the approved copy `settings.default_description_comment` states.
|
||||
- **P-15 (resolve answer):** `GET /_seo/api/v1/resolve?url=<path or absolute URL>` (throttle bucket `seo-resolve`, no auth) answers `{"action":"ok"|"not_found","status":200|404,"url":<absolute canonical page URL>,"locale":...,"staging":bool,"seo":{"title","description","canonical","robots","x_robots_tag","og":{"title","description","image","url"},"twitter":{"card","site"},"verification":[{"name","content"}]}}`; plan 32-04 adds `redirect` and `gone`. Absolute URLs use the config key `golem15.seo.site_url` (fallback `app.url`), never the request Host. Unpublished records and unknown paths without a rule answer `not_found`.
|
||||
- **P-16 (OG image, D-100):** the `seoimage` field type (element `golem15-seo-image`, E7) stores an uploaded image in the media bucket (`*blob.Bucket`, folder `seo`, content sniffing as in Journal's editor upload) and keeps its URL; it is used for the SEO tab's `seo_og_image`, the rule form's `og_image` and the settings' `default_og_image` (one storage form for the resolver; UI-SPEC C7/C11 fileupload replaced by D-100). One image per record, shared by every locale row (Q-5).
|
||||
- **P-17 (Journal Post tab order):** the SEO tab is placed with no anchor, at the end; Journal Post renders Edit | Manage | Categories | SEO (plan 32-01 P-02), which updates UI-SPEC C3's example.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-RESEARCH.md
|
||||
@.planning/phases/32-seo-foundation/32-PATTERNS.md
|
||||
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
|
||||
@.planning/phases/32-seo-foundation/32-01-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From plan 32-01 (pact): AdminActionExtension{Controller, RecordActions, Form *AdminFormExtension}; AdminFormExtension{Tabs []AdminFormTab; Fields []AdminFormField; Hide, Permissions []string; Visible func(ctx) bool; Dir string; Actions []AdminAction; PartialData; Load func(ctx, AdminFormLoad) (map[string]any, error); Save func(ctx, AdminFormSave) (AdminFormSaveResult, error)}; AdminFormField{Name, Tab, Before, After, Zone string; Config map[string]any}; AdminFormErrors; AdminFormSaveResult{Notices map[string][]FormFieldNotice}. (If the plan 32-01 Task 1 answer changed the shape, use the shape recorded in 32-01-SUMMARY.md.)
|
||||
From plan 32-02 (pact/signpost/cabana): RecordSaved/RecordDeleted (pointer events); ContentDescriptor{Controller, Model, Records, Describe, Match}; ContentRecord{ID, Published, LastMod, Locales []ContentLocale{Locale, URL, Title, Description, Image, Words}}; signpost Registry ContentTypes(), ContentTypeForController(id), ContentTypeForModel(model), Describe(ctx, key, id, unprefixed), Records(ctx, key, after, limit, unprefixed), MatchPath(ctx, path, unprefixed), MatchPattern(pattern, path); cabana.TxFromContext(ctx); AdminFormSave.Changes.
|
||||
Analogs (PATTERNS.md): ../sm-sitemap-plugin/plugin.go (plugin entry, interface assertions, boot error prefix), admin.go (embeds, AdminControllers lazy app accessor, Settings link entry with Controller), admin_permissions.go, controllers/definitions.go (AdminSingleton SingletonRecord first-or-create), models/settings.go + models/settings/fields.yaml, updates/registry.go + 202610110002_create_golem15_sitemap_settings.go (raw SQL migrations), testmain_test.go (flag.Parse + testing.Short, postgres:16-alpine); ../sm-journal-plugin/field_types.go + classes/blog_editor.go 80-109 and 343-380 (field-type pair, Upload into *blob.Bucket); ../sm-journal-plugin/routes.go (throttled groups); ../sm-journal-plugin/controllers/api/auth.go 83-115 (backend principal); ../sm-chat-plugin/plugin.go 85-100 (conga.Job); ../sm-journal-plugin/package.json, vite.config.ts, scripts/check-dist.sh, assets/src/blogeditor/labels.ts (element bundle).
|
||||
Framework: cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, kind, slug); compass Config.Environment(); bouncer.Dispatched(ctx, "backend"); cabana.Allows(principal, []string).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an editor sets a record's SEO title in its SEO tab and the frontend's resolve answer returns it" — one path only</name>
|
||||
<precondition>`git ls-remote git@git.golem15.com:golem15/sm-seo-plugin.git` exits 0 (the repository exists; empty output is fine); plans 32-01 and 32-02 are committed in summercms.go</precondition>
|
||||
<reversibility rating="one-way">The entries table keyed by (model type, record id, locale) and the opt-in through signpost descriptors are what every content plugin builds on; confirmed by Jakub as D-01 (already decided, no checkpoint).</reversibility>
|
||||
<files>../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/go.work, ../sm-seo-plugin/.gitignore, ../sm-seo-plugin/README.md, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/logger.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/models/entry.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go, ../sm-seo-plugin/classes/service.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/entries.go, ../sm-seo-plugin/controllers/api/resolve.go, ../sm-seo-plugin/controllers/api/helpers.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/testmain_test.go, ../sm-seo-plugin/fixture_test.go, ../sm-seo-plugin/seo_tab_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-sitemap-plugin/plugin.go, admin.go, go.mod, go.work, .gitignore, testmain_test.go, models/registry.go, updates/registry.go, updates/202610110002_create_golem15_sitemap_settings.go
|
||||
- ../sm-journal-plugin/routes.go and controllers/api/posts.go (handler shape, wire.WriteJSON)
|
||||
- summercms.go/modules/pact/capabilities.go and modules/pact/content.go (seams from plans 32-01 and 32-02)
|
||||
- summercms.go/modules/signpost/content.go
|
||||
- summercms.go/modules/cabana/form_extensions_postgres_test.go (how an extension is exercised over HTTP)
|
||||
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md and 32-02-SUMMARY.md (final seam shapes)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-01, D-90, D-91, D-93, D-94 and P-12/P-13/P-15:
|
||||
1. Repository (P-12): clone into `../sm-seo-plugin`, branch `main`; `go.mod` module `git.golem15.com/golem15/sm-seo-plugin`, go 1.27.0, requiring the framework at the latest existing tag and gorm, gormigrate, pgx, testcontainers at the versions sm-sitemap-plugin pins; tracked `go.work` with the framework replace; `.gitignore` with `node_modules/` and `go.work.sum`; a README skeleton in the standard plugin structure.
|
||||
2. `plugin.go`: `Plugin{app *backpack.App; service *classes.Service}`, `ID() "golem15.seo"`, `Requires() nil`, Register stores the app, Boot builds the service (DB, signpost registry, config) and fails with `golem15.seo: %w`; compile-time assertions for party.Plugin, pact.HasConfig, HasMigrations, HasModels, HasRoutes, HasAdminActionExtensions, HasLang.
|
||||
3. `models/entry.go` + the entries migration (P-13); `classes/entries.go` upsert/load per (model_type, record_id) returning locale maps; `classes/service.go` the single service every writer calls.
|
||||
4. `extensions.go`: `AdminActionExtensions()` reads `signpost` `ContentTypes()` lazily and returns, for each type with `Content.Controller`, an extension adding tab `golem15.seo::lang.tab.seo` (no anchor, P-17) with, for the tracer, one field `seo_title` of built-in type `mltext` (replaced by `mlseotext` in Task 5); Load returns `{"seo_title": map[locale]title}`; Save upserts the entry rows inside `in.DB` with the URL snapshot from signpost `Describe` (reading through the save transaction).
|
||||
5. `classes/resolver.go` (tracer subset): `Resolver.Resolve(ctx, rawURL string) (Result, error)` normalises the input to a path, finds the owner through signpost `MatchPath`, loads the entry for the matched locale and answers the title with source `record`, else the content title with source `content`; unpublished or unmatched → `not_found`. `routes.go`: `GET /_seo/api/v1/resolve` (throttle `seo-resolve` bucket via Buckets with `surf.TrustedProxies`); `controllers/api/resolve.go` writes the P-15 JSON with `wire.WriteJSON`.
|
||||
6. Tests: `testmain_test.go` copied from sm-sitemap-plugin (DB/user `golem15_seo`, `flag.Parse()` then `testing.Short()`); `fixture_test.go`: a fake content plugin `acme.pages` with admin controller `acme.pages.pages` (model with title, slug; a fields.yaml with tabs), a menu item type `acme-page` (`/pages/:slug`) with a Content descriptor; `TestSEOTabTracer` in `seo_tab_test.go`: boot both plugins with cabana and signpost, GET the form schema of `acme.pages.pages` (contains `seo_title` in tab `golem15.seo::lang.tab.seo`), PUT a page with `seo_title` {en: "Custom"}, then `GET /_seo/api/v1/resolve?url=/pages/<slug>` answers `seo.title` "Custom" and the owner table has no SEO column.
|
||||
Commit in `../sm-seo-plugin` with `git -c safe.directory='*'`: `feat: plugin scaffold, entries and the SEO tab tracer`. Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSEOTabTracer' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestSEOTabTracer" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -c safe.directory='*' -C ../sm-seo-plugin branch --show-current` prints `main` and `git -c safe.directory='*' -C ../sm-seo-plugin remote get-url origin` prints `git@git.golem15.com:golem15/sm-seo-plugin.git`
|
||||
- `head -1 ../sm-seo-plugin/go.mod` prints `module git.golem15.com/golem15/sm-seo-plugin`
|
||||
- `grep -n "replace git.golem15.com/golem15/summercms => ../summercms.go" ../sm-seo-plugin/go.work` prints one line
|
||||
- `grep -n "UNIQUE (model_type, record_id, locale)\|unique (model_type, record_id, locale)" ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go` prints one line (case-insensitive variants accepted) and the DDL has `data jsonb NOT NULL DEFAULT '{}'`
|
||||
- `go -C ../sm-seo-plugin test ./... -run TestSEOTabTracer -count=1` passes
|
||||
- `git -c safe.directory='*' -C ../sm-seo-plugin ls-remote origin` prints nothing (the remote is still empty: nothing pushed)
|
||||
</acceptance_criteria>
|
||||
<done>The new plugin adds an SEO tab to a fake content type through D-91, stores the value in its own entries table and returns it from the public resolve endpoint; committed locally on main.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: URL rules, global and per-type settings (AdminSingleton + D-91 on itself) and the full precedence resolver</name>
|
||||
<files>../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/type_setting.go, ../sm-seo-plugin/models/rule.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/models/rule/fields.yaml, ../sm-seo-plugin/models/rule/columns.yaml, ../sm-seo-plugin/updates/202610270003_create_golem15_seo_settings.go, ../sm-seo-plugin/updates/202610270004_create_golem15_seo_type_settings.go, ../sm-seo-plugin/updates/202610270005_create_golem15_seo_rules.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/settings.go, ../sm-seo-plugin/controllers/settings/config_form.yaml, ../sm-seo-plugin/controllers/settings/_staging.htm, ../sm-seo-plugin/controllers/settings/_placeholders.htm, ../sm-seo-plugin/controllers/rules.go, ../sm-seo-plugin/controllers/rules/config_list.yaml, ../sm-seo-plugin/controllers/rules/config_form.yaml, ../sm-seo-plugin/admin.go, ../sm-seo-plugin/admin_permissions.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/templates.go, ../sm-seo-plugin/classes/urls.go, ../sm-seo-plugin/classes/robots_value.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/settings_test.go, ../sm-seo-plugin/seo_tab_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-sitemap-plugin/controllers/definitions.go (AdminSingleton), controllers/admin_registry.go, admin.go (Settings link entry), admin_permissions.go, models/settings.go, models/settings/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go 24-50, 106-155 (list/form controller hooks, FilterOptions, ListExtendRecords)
|
||||
- summercms.go/docs/backend/settings.md and docs/backend/admin-controllers.md (Singleton controllers, line 453)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C2, C7, C11, Copywriting: SEO settings, URL rules)
|
||||
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-02 to D-08, D-21, D-23)
|
||||
- ../sm-seo-plugin/classes/resolver.go and extensions.go (Task 1)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-15, D-21, D-22, D-23, D-104 and P-13/P-14/P-15:
|
||||
1. Models and migrations for settings (row 1 inserted by the migration with the seeded query parameters), type_settings, rules. Rule validation: url starts with "/", `*` only in a pattern (`rules.url_invalid`), unique (url, match) (`rules.url_taken`); settings: verification file names `^[A-Za-z0-9._-]+\.(html|txt|xml)$` (`settings.verify_filename_invalid`), unique names (`settings.verify_filename_taken`), templates with only known placeholders %title%, %description%, %site% (`settings.template_unknown`).
|
||||
2. Controllers: `golem15.seo.settings` (`AdminSingleton`, tabs General, Indexing, Addresses, Verification per UI-SPEC C11, staging partial, P3 placeholders partial); `golem15.seo.rules` (list + form per C7, delete confirms, `ListExtendRecords` builds the `overrides` column). The settings tile is a link entry (`pact.SettingsItem{Code: "seo_settings", Controller: "golem15.seo.settings", Icon: "search", Order: 100, ...}`, D-104); one permission `golem15.seo.manage` (D-15) on every controller and the tile; navigation item `seo` (icon `search`, order 600) landing on `golem15.seo.rules` for now with side-menu items URL rules and History (plan 32-04 re-points it to Redirects per C2).
|
||||
3. D-104: in `extensions.go` add one extension on `golem15.seo.settings` whose Tabs place one tab per content type (Name = the type's signpost Label key, the first After the General tab key, each next After the previous) and whose Fields are `type_<key>_inject` (switch, default on), `type_<key>_title_template`, `type_<key>_description_template`, `type_<key>_robots` (built-in dropdown until Task 5 swaps in `seorobots` with `scope: type`; defaults per D-21: a tag type `noindex,follow`, other types empty) and a `type_<key>_placeholders` partial; Load/Save read and upsert `type_settings`. The SEO tab extension's Visible reads `type_settings.inject` per request (answers true when it cannot decide).
|
||||
4. Resolver complete per P-14: templates with placeholders (`classes/templates.go`); canonical building and the D-07 parameter list with path refinement (`classes/urls.go`; site URL from `golem15.seo.site_url`, fallback `app.url`); robots per directive with the value grammar of UI-SPEC C5 (`classes/robots_value.go`: parse and serialise in the fixed order, refuse malformed input with `pact.FormFieldError`); never-indexed and search paths (one pattern per line, `*` wildcard) for D-21/D-23; the X-Robots-Tag value equal to the robots directives; OG/Twitter values (D-04) with twitter:card summary_large_image when an image exists; verification meta on the home path (D-05); the non-production lock from `app.Config.Environment()` != "production" forcing noindex,nofollow and `staging: true` (D-22). `Result` carries a source per value for P2 and plan 32-05's read tool.
|
||||
5. Tests (smoke): `TestResolverPrecedence` table in `settings_test.go` covering each P-14 chain, a pattern rule `/blog?tag=*`, the parameter list (utm stripped, page kept, sort → noindex,follow) and the staging lock; `TestSettingsTypeTabs` (two content types → General, type A, type B, Indexing, Addresses, Verification; zero types → four fixed tabs); `TestSEOTabPermission` (an admin without golem15.seo.manage sees no SEO tab and a posted seo_title changes nothing).
|
||||
Commit per logical change in `../sm-seo-plugin`, staged by path.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestResolverPrecedence|TestSettingsTypeTabs|TestSEOTabPermission|TestSEOTabTracer' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the four test names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Controller: *\"golem15.seo.settings\"\|Controller: *controllers.SettingsControllerID" ../sm-seo-plugin/admin.go` prints the settings tile link entry, and `grep -c "Form:" ../sm-seo-plugin/admin.go` prints 0 for the SettingsItem (link entry, D-104)
|
||||
- `grep -n "golem15.seo.manage" ../sm-seo-plugin/admin_permissions.go` prints one line
|
||||
- `grep -n "Environment()" ../sm-seo-plugin/classes/*.go` prints the staging-lock decision, and no admin field or config key disables it (`grep -rn "staging_lock\|disable_staging" ../sm-seo-plugin --include=*.go --include=*.yaml` prints nothing)
|
||||
- `TestResolverPrecedence` has at least one case per value chain of P-14 and passes
|
||||
- `grep -rn "r.Host\|req.Host" ../sm-seo-plugin/classes ../sm-seo-plugin/controllers` prints nothing
|
||||
</acceptance_criteria>
|
||||
<done>URL rules, the settings singleton with dynamic per-type tabs on D-91, one permission and the full precedence resolver with sources work and are smoke-tested.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: History core, entry generation job, entry cleanup on delete</name>
|
||||
<files>../sm-seo-plugin/models/revision.go, ../sm-seo-plugin/models/revision/fields.yaml, ../sm-seo-plugin/models/revision/columns.yaml, ../sm-seo-plugin/updates/202610270002_create_golem15_seo_revisions.go, ../sm-seo-plugin/classes/history.go, ../sm-seo-plugin/classes/generate.go, ../sm-seo-plugin/classes/service.go, ../sm-seo-plugin/controllers/history.go, ../sm-seo-plugin/controllers/history/config_list.yaml, ../sm-seo-plugin/controllers/history/config_form.yaml, ../sm-seo-plugin/controllers/history/_history_diff.htm, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/jobs.go, ../sm-seo-plugin/commands.go, ../sm-seo-plugin/listeners.go, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/seo_tab_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-chat-plugin/plugin.go 85-100 (conga.Job, OnQueue, MaxAttempts) and its jobs package
|
||||
- summercms.go/modules/pact/capabilities.go (HasJobs, HasCommands) and docs/plugins/scheduling.md
|
||||
- summercms.go/modules/bouncer/dispatch.go (Dispatched) and modules/towel (actor from context)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C12 History, P5, Copywriting: History)
|
||||
- ../sm-seo-plugin/classes/service.go
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-13, D-92, D-94:
|
||||
1. History core: every service write (entry, rule, settings, type settings) appends one revision row per changed object with author id/label from the towel actor, `via` = `mcp` when `bouncer.Dispatched(ctx, "backend")` answers true else `admin` (import/auto/undo set by later plans), `what` built from the UI-SPEC `history.what_*` keys, before/after jsonb of the changed fields only, all inside the caller's transaction. History screen `golem15.seo.history` per C12: list (columns, filters via/created_at/author, `history.list_empty`, row state `disabled` for undone), preview with P5 `history_diff` (Field / Before / After, DM Mono for URL values, created/deleted markers). Undo record actions are plan 32-05.
|
||||
2. GenerateEntries (D-94): `jobs.go` registers `conga.Job` for `GenerateEntriesArgs{Type string}`; `classes/generate.go` pages signpost `Records` per content type (limit 200) and upserts entry rows with each locale's URL, type key and the effective-title/description cache, never overwriting editor values; `commands.go` adds the console command `seo:generate-entries` with an optional `--type` flag that enqueues the job; the settings singleton's FormAfterUpdate enqueues it too.
|
||||
3. `listeners.go` (registered in Boot): `*pact.RecordDeleted` for a model with a content type deletes its entry rows inside a transaction and records a `deleted` revision so the values stay in history (D-92, D-94); `*pact.RecordSaved` with Created true upserts the URL snapshot of the new record (address-change redirects are plan 32-04).
|
||||
4. Tests (smoke) in `seo_tab_test.go`: `TestHistoryRecordsAdminWrite` (saving the SEO tab writes one revision with via admin and before/after), `TestEntryDeletedWithRecord`, `TestGenerateEntries` (two fake records → two entry rows with URLs; editor values untouched on a second run).
|
||||
Commit in `../sm-seo-plugin`, staged by path.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestHistoryRecordsAdminWrite|TestEntryDeletedWithRecord|TestGenerateEntries' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the three names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\*pact.RecordDeleted" ../sm-seo-plugin/listeners.go` prints one line
|
||||
- `grep -n "seo:generate-entries" ../sm-seo-plugin/commands.go` prints one line
|
||||
- `grep -n "bouncer.Dispatched" ../sm-seo-plugin/classes/history.go` prints one line
|
||||
- The three tests pass with Docker
|
||||
</acceptance_criteria>
|
||||
<done>Every admin SEO write is recorded in history with author, via and before/after; entries are generated by a conga job and removed with their record while history keeps the values.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 4: Package legitimacy gate for the element toolchain and the robots.txt library</name>
|
||||
<what-built>Nothing is installed yet. RESEARCH.md's Package Legitimacy Audit flags the npm dev dependencies vite, vitest and happy-dom as [SUS] (their latest releases are days old; the pins are older) and the Go module github.com/jimsmart/grobotstxt as [ASSUMED] (the Go ecosystem is not covered by the legitimacy seam). typescript is approved. The pins are the ones sm-journal-plugin already locks (typescript 5.9.3, vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6); grobotstxt v1.0.3 is the version D-99 names and plan 32-04 installs.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://www.npmjs.com/package/vite/v/7.3.5, https://www.npmjs.com/package/vitest/v/3.2.7 and https://www.npmjs.com/package/happy-dom/v/20.11.6: the publisher and repository match vitejs/vite, vitest-dev/vitest and capricorn86/happy-dom.
|
||||
2. Run `npm --prefix ../sm-journal-plugin ls vite vitest happy-dom typescript` and confirm the same versions are already locked in an approved sibling plugin.
|
||||
3. Open https://pkg.go.dev/github.com/jimsmart/grobotstxt@v1.0.3 and https://github.com/jimsmart/grobotstxt: Apache-2.0, a port of Google's robots.txt parser; `go list -m -json github.com/jimsmart/grobotstxt@v1.0.3` resolves through proxy.golang.org.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to install exactly these pins (Task 5 here, grobotstxt in plan 32-04), or name the package to refuse</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Plugin field types and elements E1, E2, E3, E7, partials P1/P2/P3 and the complete SEO tab (UI-SPEC C3-C6)</name>
|
||||
<precondition>Task 4 answered "approved" for typescript 5.9.3, vite 7.3.5, vitest 3.2.7 and happy-dom 20.11.6</precondition>
|
||||
<files>../sm-seo-plugin/package.json, ../sm-seo-plugin/package-lock.json, ../sm-seo-plugin/tsconfig.json, ../sm-seo-plugin/vite.config.ts, ../sm-seo-plugin/scripts/check-dist.sh, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/measure.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/text.ts, ../sm-seo-plugin/assets/src/elements/robots.ts, ../sm-seo-plugin/assets/src/elements/serp.ts, ../sm-seo-plugin/assets/src/elements/image.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/field_types.go, ../sm-seo-plugin/classes/field_types.go, ../sm-seo-plugin/classes/image.go, ../sm-seo-plugin/classes/snippet.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/extension/_seo_effective.htm, ../sm-seo-plugin/extension/_placeholders.htm, ../sm-seo-plugin/controllers/rules/_seo_status.htm, ../sm-seo-plugin/controllers/rules/_seo_effective.htm, ../sm-seo-plugin/models/rule/fields.yaml, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/admin.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/seo_tab_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/package.json, vite.config.ts, tsconfig.json, scripts/check-dist.sh, assets/src/blogeditor/ (index.ts, labels.ts, element structure), field_types.go, classes/blog_editor.go (80-109 type pair, 343-380 Upload)
|
||||
- ../sm-paymentgateway-plugin/assets/css/quote-cart.css (scoped element CSS with --c-* variables)
|
||||
- summercms.go/admin/src/components/form/fields/WidgetField.vue and PluginField.vue (summer-action, summer-change, fill-values, busy contracts)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (Component Inventory E1-E3, C1, C3, C4, C5, C6, C14, Copywriting tables for the SEO tab, P1, P2, E1, E2, E3, Typography E-1, Color E-1)
|
||||
- ../sm-seo-plugin/extensions.go and classes/resolver.go
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-04, D-06, D-08, D-09, D-100 and UI-SPEC:
|
||||
1. Element bundle: `package.json` (name `golem15-seo-elements`, private, type module, scripts typecheck/build/test, devDependencies pinned exactly as approved, no runtime dependencies); `npm --prefix ../sm-seo-plugin install --save-dev --save-exact` with those pins; `vite.config.ts` copying Journal's (entry `assets/src/elements/index.ts`, outputs `seo.js` and `seo.css`, vitest with happy-dom and `tests/**/*.test.ts`); `scripts/check-dist.sh` copying Journal's drift gate. Elements in light DOM with CSS scoped under each tag and `--c-*` variables only (E-1 palette only inside `golem15-seo-serp .serp-card`): E1 `golem15-seo-text` (C4, shared `measure.ts` canvas measurement, thresholds of Q-3), E2 `golem15-seo-robots` (C5, value grammar identical to `classes/robots_value.go`), E3 `golem15-seo-serp` (C6: context and duplicates ops, desktop/mobile, pixel truncation, keyphrase checks with the C6 stem rule, one queued request while busy), E7 `golem15-seo-image` (upload through the field type's Upload, preview, remove). `labels.ts` holds the en/pl element copy of UI-SPEC with CLDR plural maps. `npm run build` and commit `assets/dist`.
|
||||
2. Field types (`field_types.go` + `classes/field_types.go`): `seotext`/`mlseotext` (option `measure`), `seorobots`/`mlseorobots` (option `scope`, Decode refuses a malformed directive string), `seoimage` (P-16 Upload into `*blob.Bucket`, folder `seo`, image content sniffing, `FormFieldError` on a non-image). Each lists `assets/dist/seo.js` and `assets/dist/seo.css` (if cabana refuses the same asset in several types, list it on `mlseotext` only and record that in the SUMMARY). The extension's `seo_snippet` widget action lives in `AdminFormExtension.Actions` (`classes/snippet.go`: `context` answers locales with url, h1, siteName, titleTemplate, defaultTitle, defaultDescription, descriptionSource, bodyWords and host; `duplicates` answers other records' and rules' labels and admin URLs whose effective title or description equals the submitted one in that locale).
|
||||
3. Complete SEO tab per C3 (fields `seo_status` P1, `seo_snippet` E3, `seo_title` mlseotext, `seo_description` mlseotext, `seo_keyphrase` mltext left, `seo_canonical` mltext right with the `validation.url` check for a path or http(s) URL, `seo_robots` mlseorobots scope record, `seo_og_title`, `seo_og_description` mltextarea small, `seo_og_image` seoimage, `seo_effective` P2), partials from `extension/` through `Dir` and `PartialData` (P1: URL rule override and canonical-to-noindex callouts; P2: effective values per locale with source labels and the staging note); the rule form and settings use `seotext`, `seorobots` and `seoimage` where C7/C11 name text, robots and image fields; P3 placeholders partial on General and each type tab.
|
||||
4. Full en/pl `lang.yaml` for every key of the UI-SPEC sections this plan renders (navigation for rules/history, settings tile, permission, SEO tab, P1, P2, SEO settings, URL rules, History).
|
||||
5. Smoke: `TestSEOTab` in `seo_tab_test.go`: with the fake content plugin carrying a tabbed form and a tab-less form, the compiled schema lists the C3 fields in order inside the SEO tab placed last, the tab-less form has default then SEO, a malformed `seo_robots` value answers 422, and `seo_og_image` upload rejects a non-image. `npm --prefix ../sm-seo-plugin run typecheck && npm --prefix ../sm-seo-plugin run build` succeed (element unit tests are plan 32-06). README completed in the standard structure (overview, features, usage for content plugins: register a descriptor with Controller and Model, config keys `golem15.seo.site_url`, the resolve endpoint, permissions).
|
||||
Commits in `../sm-seo-plugin`: elements + dist; field types + SEO tab; lang + README.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
|
||||
<fails_when>non-zero exit, a TypeScript error line, or "assets/dist is stale" in the output</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSEOTab' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestSEOTab" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\"vite\": \"7.3.5\"\|\"vitest\": \"3.2.7\"\|\"happy-dom\": \"20.11.6\"\|\"typescript\": \"5.9.3\"" ../sm-seo-plugin/package.json` prints four lines and `grep -c "\"dependencies\"" ../sm-seo-plugin/package.json` prints 0
|
||||
- `grep -c "customElements.define" ../sm-seo-plugin/assets/src/elements/*.ts` totals 4 (E1, E2, E3, E7)
|
||||
- `grep -n "Name: *\"seoimage\"" ../sm-seo-plugin/classes/field_types.go` prints one line and `grep -n "fileupload" ../sm-seo-plugin/extensions.go ../sm-seo-plugin/models/rule/fields.yaml ../sm-seo-plugin/models/settings/fields.yaml` prints nothing (D-100)
|
||||
- `grep -n "seo_robots\|seo_og_image" ../sm-seo-plugin/extensions.go` prints both reserved anchor names
|
||||
- every `golem15.seo::lang.` key referenced in `../sm-seo-plugin` YAML and Go exists in both `lang/en/lang.yaml` and `lang/pl/lang.yaml` (a small Go test `TestLangKeysComplete` or the framework's phrase validation at boot passes)
|
||||
</acceptance_criteria>
|
||||
<done>The SEO tab matches UI-SPEC C3 with working counters, robots directives, the live result preview with duplicate and keyphrase checks, and an uploadable social image; the bundle is built, committed and drift-checked.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous frontend → GET /_seo/api/v1/resolve | public, throttled read of effective SEO by URL |
|
||||
| admin browser → SEO tab, rules, settings, seoimage upload | authenticated writes behind golem15.seo.manage |
|
||||
| stored SEO strings → admin partials and elements | editor-supplied text rendered back in the admin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32-09 | Information disclosure | resolve endpoint | high | mitigate | Answers only for records whose descriptor reports Published; drafts and unknown paths answer not_found (TestResolverPrecedence draft case) |
|
||||
| T-32-10 | Spoofing | canonical and absolute URLs | high | mitigate | Built from golem15.seo.site_url or app.url only; acceptance greps that no code reads the request Host |
|
||||
| T-32-11 | Tampering | SEO strings in P1/P2/P3/P5 and E3 | medium | mitigate | html/template escaping plus cabana's sanitizePartialNodes; elements set textContent, never innerHTML with stored values |
|
||||
| T-32-12 | Tampering | seoimage upload | medium | mitigate | Content sniffing to image types only, the framework upload body limit, a fixed folder and a sanitized file name (Journal editor upload pattern) |
|
||||
| T-32-13 | Elevation of privilege | SEO tab and SEO screens | high | mitigate | golem15.seo.manage on every controller, the tile and the extension (Permissions); posted SEO keys ignored without it (TestSEOTabPermission) |
|
||||
| T-32-14 | Denial of service | resolve endpoint | medium | mitigate | surf throttle bucket seo-resolve with trusted-proxy client IPs; URL length capped at 2048 bytes |
|
||||
| T-32-15 | Information disclosure | non-production sites indexed | high | mitigate | Environment-based lock forces noindex,nofollow in every resolve answer; no setting can disable it (D-22) |
|
||||
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | Task 4 blocking-human legitimacy checkpoint before installing vite, vitest, happy-dom (SUS) and grobotstxt (ASSUMED); exact pins already locked by an approved sibling; postinstall scripts are null |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` green with Docker; `-short` green without.
|
||||
- `npm --prefix ../sm-seo-plugin run typecheck`, `scripts/check-dist.sh` green.
|
||||
- `grep -rn "sm-seo-plugin" ../sm-journal-plugin --include=*.go` empty; framework unchanged by this plan.
|
||||
- Nothing pushed: `git -c safe.directory='*' -C ../sm-seo-plugin status -sb` shows main with no upstream or ahead of an empty origin.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Any content type registered with a descriptor gets the SEO tab without importing the plugin, editors manage URL rules and global/per-type settings, and the frontend can ask for the effective SEO of any URL with the D-02 precedence and per-value sources.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces (plan 03)
|
||||
|
||||
- Repository `../sm-seo-plugin` (module `git.golem15.com/golem15/sm-seo-plugin`, package `seo`, plugin `golem15.seo`), branch `main`
|
||||
- Tables `golem15_seo_entries`, `golem15_seo_revisions`, `golem15_seo_settings`, `golem15_seo_type_settings`, `golem15_seo_rules`; models `Entry`, `Revision`, `Settings`, `TypeSetting`, `Rule`
|
||||
- Controllers `golem15.seo.settings` (AdminSingleton), `golem15.seo.rules`, `golem15.seo.history`; permission `golem15.seo.manage`; navigation `seo`; settings tile `seo_settings`
|
||||
- Field types `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage`; elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp`, `golem15-seo-image`; widget action `seo_snippet`; partials `seo_status`, `seo_effective`, `placeholders`, `history_diff`, `staging`
|
||||
- `classes.Service`, `classes.Resolver` (`Resolve`), templates, URL/canonical builder, robots value grammar, history writer, generator
|
||||
- Route `GET /_seo/api/v1/resolve`, throttle bucket `seo-resolve`, config key `golem15.seo.site_url`
|
||||
- Job `GenerateEntriesArgs` via conga, console command `seo:generate-entries`, listeners for `*pact.RecordDeleted` and created `*pact.RecordSaved`
|
||||
- Tests `TestSEOTabTracer`, `TestSEOTab`, `TestSEOTabPermission`, `TestResolverPrecedence`, `TestSettingsTypeTabs`, `TestHistoryRecordsAdminWrite`, `TestEntryDeletedWithRecord`, `TestGenerateEntries`
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32-seo-foundation/32-03-SUMMARY.md` when done, listing the sm-seo-plugin commit SHAs, the framework tag go.mod requires, the Task 4 answer and the cabana asset-dedup observation.
|
||||
</output>
|
||||
393
.planning/phases/32-seo-foundation/32-04-PLAN.md
Normal file
393
.planning/phases/32-seo-foundation/32-04-PLAN.md
Normal file
@@ -0,0 +1,393 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["32-03"]
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md
|
||||
- .planning/research/STACK.md
|
||||
- ../sm-seo-plugin/go.mod
|
||||
- ../sm-seo-plugin/go.sum
|
||||
- ../sm-seo-plugin/plugin.go
|
||||
- ../sm-seo-plugin/admin.go
|
||||
- ../sm-seo-plugin/admin_navigation.go
|
||||
- ../sm-seo-plugin/extensions.go
|
||||
- ../sm-seo-plugin/listeners.go
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/field_types.go
|
||||
- ../sm-seo-plugin/config/config.yaml
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/models/registry.go
|
||||
- ../sm-seo-plugin/models/redirect.go
|
||||
- ../sm-seo-plugin/models/redirect/fields.yaml
|
||||
- ../sm-seo-plugin/models/redirect/columns.yaml
|
||||
- ../sm-seo-plugin/models/notfound.go
|
||||
- ../sm-seo-plugin/models/notfound/fields.yaml
|
||||
- ../sm-seo-plugin/models/notfound/columns.yaml
|
||||
- ../sm-seo-plugin/models/robots.go
|
||||
- ../sm-seo-plugin/models/robots/fields.yaml
|
||||
- ../sm-seo-plugin/updates/202610270006_create_golem15_seo_redirects.go
|
||||
- ../sm-seo-plugin/updates/202610270007_create_golem15_seo_notfound.go
|
||||
- ../sm-seo-plugin/updates/202610270008_create_golem15_seo_robots.go
|
||||
- ../sm-seo-plugin/controllers/admin_registry.go
|
||||
- ../sm-seo-plugin/controllers/redirects.go
|
||||
- ../sm-seo-plugin/controllers/redirects/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/redirects/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/notfound.go
|
||||
- ../sm-seo-plugin/controllers/notfound/config_list.yaml
|
||||
- ../sm-seo-plugin/controllers/notfound/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/notfound/_hint.htm
|
||||
- ../sm-seo-plugin/controllers/robots.go
|
||||
- ../sm-seo-plugin/controllers/robots/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/robots/_staging.htm
|
||||
- ../sm-seo-plugin/controllers/robots/_robots_served.htm
|
||||
- ../sm-seo-plugin/controllers/api/resolve.go
|
||||
- ../sm-seo-plugin/controllers/api/notfound.go
|
||||
- ../sm-seo-plugin/controllers/api/verification.go
|
||||
- ../sm-seo-plugin/controllers/api/robots.go
|
||||
- ../sm-seo-plugin/extension/_seo_status.htm
|
||||
- ../sm-seo-plugin/classes/redirects.go
|
||||
- ../sm-seo-plugin/classes/redirect_graph.go
|
||||
- ../sm-seo-plugin/classes/addresses.go
|
||||
- ../sm-seo-plugin/classes/normalize.go
|
||||
- ../sm-seo-plugin/classes/strict.go
|
||||
- ../sm-seo-plugin/classes/resolver.go
|
||||
- ../sm-seo-plugin/classes/notfound.go
|
||||
- ../sm-seo-plugin/classes/robots.go
|
||||
- ../sm-seo-plugin/classes/bots.go
|
||||
- ../sm-seo-plugin/classes/verification.go
|
||||
- ../sm-seo-plugin/classes/field_types.go
|
||||
- ../sm-seo-plugin/assets/src/elements/index.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/labels.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/robotstxt.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/robotstester.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/styles.css
|
||||
- ../sm-seo-plugin/assets/dist/seo.js
|
||||
- ../sm-seo-plugin/assets/dist/seo.css
|
||||
- ../sm-seo-plugin/redirects_test.go
|
||||
- ../sm-seo-plugin/robots_test.go
|
||||
- ../sm-seo-plugin/addresses_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-16, D-17, D-18, D-19, D-20, D-20a, D-20b, D-20c, D-22, D-24, D-25, D-05, D-08, D-06, D-99, D-103, D-105, D-92, D-102]
|
||||
|
||||
estimate:
|
||||
tokens: 170000
|
||||
raw_tokens: 170000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Changing a published record's slug or path in the admin creates a 301 from the old URL to the new one in the same transaction, flattens existing chains (A→B then B→C becomes A→C) and shows the redirects.auto_created notice; the same happens after commit for writes through Journal's API, MCP or imports (SC-3, D-18, D-92, D-102)"
|
||||
- "Deleting or unpublishing a record creates no redirect; its URL answers not_found and appears in the 404 log when reported (D-18, D-20)"
|
||||
- "Redirects support 301, 302 and 410, exact, wildcard and regex sources with ignore-case, trailing-slash and query ignore/pass options, evaluated exact, then patterns, then regex (D-16, D-17)"
|
||||
- "A redirect loop is refused from the admin with redirects.loop on target and nothing saved; a chain is saved with the redirects.chain_notice (SC-3, D-19)"
|
||||
- "Slug changed back and forth (A→B, then B→A, then A→C) ends with exactly A→C and B→C (RESEARCH Pitfall 11)"
|
||||
- "GET /_seo/api/v1/resolve answers the redirect decision: action redirect with status and location, action gone with 410, normalisation and strict-URL 301s, reaching the final address in one hop; at runtime a cycle stops after 10 hops with not_found and a log line (D-20a, D-20b, D-20c, D-103)"
|
||||
- "URL normalisation per site: https, www or bare host, trailing slash add/remove, optional ASCII lowercasing, double slashes and /index.html collapsed, all defaults Do not change and lowercase off (D-20b, UI-SPEC Q-7)"
|
||||
- "Strict URLs: a URL whose segments (category, locale prefix) do not match an existing record 301s to the record's canonical URL; a wrong URL never answers ok (D-20c)"
|
||||
- "The 404 log stores URL, hits, first seen, last hit and referer from an authenticated server-to-server report; Create redirect… (prompted 301) and Mark as gone (410, record and bulk) resolve an entry (D-20)"
|
||||
- "GET /robots.txt serves the stored rules, the AI bot groups for switched-off bots and the Sitemap line(s); outside production it serves User-agent: * / Disallow: / with X-Robots-Tag noindex whatever is stored; it never answers 5xx (D-22, D-25)"
|
||||
- "The robots.txt editor validates live (E4) and refuses over 500 KB; the tester (E5) answers allowed or blocked with the matching line through grobotstxt, including unsaved text and AI switches (D-22, D-99)"
|
||||
- "AI bot switches cover OAI-SearchBot, Claude-SearchBot, PerplexityBot, ChatGPT-User, Claude-User, GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot and Meta-ExternalAgent, all allowed by default, with search, user-fetch and training kinds (D-25, D-105)"
|
||||
- "Saving robots.txt warns when it blocks a noindex address so Googlebot cannot see the noindex (D-24)"
|
||||
- "Verification files are served by GET /_seo/api/v1/verification/{name}; no Go route is registered at the site root for them (D-05, D-103)"
|
||||
- "The canonical-redirected warning (status.canonical_redirected) shows in P1 when a canonical URL redirects (D-06)"
|
||||
- "Redirects list: empty state redirects.list_empty; six visible columns with the Chain badge on chain rows; filters status, match, origin and chain (UI-SPEC empty, populated)"
|
||||
- "404 log: empty state notfound.list_empty; sorted by hits with Resolved badges; bulk Mark as gone with a plural confirm, and one already-covered row refuses the whole selection with notfound.has_rule (UI-SPEC empty, populated, zero-one-many)"
|
||||
- "A refused record action shows the danger toast with the server Message (notfound.has_rule, redirects.loop) (UI-SPEC error)"
|
||||
- "All SEO lists use the existing pagination and horizontal scroll with long URLs wrapped by the existing cell rules; URLs in P5, E5 results and toasts wrap with overflow-wrap anywhere (UI-SPEC overflow, long-text)"
|
||||
- "E4 with empty content shows clean, 0 KB and P4 shows only the generated lines; long lines scroll horizontally and at most 10 findings plus moreFindings show (UI-SPEC empty, overflow)"
|
||||
- "E5 and robots screens outside production show stagingNote and the staging callout, and P4 shows the served and the production version (UI-SPEC partial)"
|
||||
prohibitions:
|
||||
- statement: "A redirect loop is never saved, from the admin, MCP or an import (D-19)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "/robots.txt never answers a 5xx status, and the non-production lock can never be turned off from the admin (D-22)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "A regex capture or a passed-through query never turns an internal redirect target into an external host"
|
||||
status: resolved
|
||||
verification: test
|
||||
artifacts:
|
||||
- path: "../sm-seo-plugin/classes/redirect_graph.go"
|
||||
provides: "loop simulation, chain detection, flattening"
|
||||
contains: "func "
|
||||
- path: "../sm-seo-plugin/classes/addresses.go"
|
||||
provides: "address-change 301 from the descriptor URL diff"
|
||||
contains: "func (s *Service) AddressChanged("
|
||||
- path: "../sm-seo-plugin/classes/robots.go"
|
||||
provides: "served file composition, staging lock, grobotstxt tester"
|
||||
contains: "grobotstxt"
|
||||
- path: ".planning/notes/2026-10-27-robots-txt-grobotstxt.md"
|
||||
provides: "decision note naming github.com/jimsmart/grobotstxt v1.0.3 (D-99)"
|
||||
contains: "github.com/jimsmart/grobotstxt"
|
||||
- path: "../sm-seo-plugin/redirects_test.go"
|
||||
provides: "TestSlugChange301, TestLoopRefused (admin)"
|
||||
contains: "func TestSlugChange301("
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/extensions.go"
|
||||
to: "../sm-seo-plugin/classes/addresses.go"
|
||||
via: "the SEO tab extension's Save compares Describe URLs with the entry URLs and calls AddressChanged inside the save transaction"
|
||||
pattern: "AddressChanged\\("
|
||||
- from: "../sm-seo-plugin/listeners.go"
|
||||
to: "../sm-seo-plugin/classes/addresses.go"
|
||||
via: "*pact.RecordSaved listener runs the same URL diff after commit for non-admin writers"
|
||||
pattern: "\\*pact\\.RecordSaved"
|
||||
- from: "../sm-seo-plugin/routes.go"
|
||||
to: "../sm-seo-plugin/controllers/api/robots.go"
|
||||
via: "r.Get(\"/robots.txt\", ...) throttled, outside any backend middleware"
|
||||
pattern: "/robots.txt"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
<!-- planner-discipline-allow: jimsmart/grobotstxt -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor on any SummerCMS application, **I want to** per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, **so that** every site ships correct, editable SEO without per-site work.
|
||||
|
||||
This plan delivers redirects (including the automatic 301 on an address change), the 404 log, URL normalisation, strict URLs and robots.txt with AI bots and verification files.
|
||||
|
||||
<objective>
|
||||
Extend `sm-seo-plugin` with D-16 to D-20c and D-22 to D-25:
|
||||
- the redirect engine (301/302/410; exact, wildcard, regex; options; order), loop refusal and chain notices, chain flattening (D-16, D-17, D-19);
|
||||
- the automatic 301 when a record's URL changes, from the descriptor URL diff in the SEO tab's Save (same transaction) and in a `RecordSaved` listener for Journal's API, MCP and imports (D-18, D-92, D-102);
|
||||
- the 404 log with an authenticated report endpoint and one-click redirect or 410 (D-20);
|
||||
- URL normalisation (D-20b) and strict URLs (D-20c) folded into the resolve answer as the redirect decision endpoint (D-20a, D-103; the Nuxt layer enforces it in Phase 33, no fourth framework seam);
|
||||
- the robots.txt editor (E4), tester (E5) on `github.com/jimsmart/grobotstxt` v1.0.3 with its decision note (D-99), AI bot switches including `Claude-User` (D-25, D-105), the noindex-blocked warning (D-24), `GET /robots.txt` with the non-production lock and `X-Robots-Tag` (D-22, D-08);
|
||||
- verification files through `GET /_seo/api/v1/verification/{name}` only (D-05, D-103).
|
||||
|
||||
Purpose: SC-3 (changing a record's slug creates a 301 and flattens existing chains; loops are refused from the admin; MCP and import refusal is plan 32-05 on the same service).
|
||||
|
||||
Output: commits in `../sm-seo-plugin` (main, not pushed), the decision note and STACK row in this repository as a planning-docs commit.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P-18 (one service path for redirects):** every redirect write (admin form, address change, 404 actions, and later MCP/batch/CSV) goes through `classes.Service` methods `SaveRedirect`, `AddressChanged`, `DeleteRedirect`, which run the loop simulation and flattening inside the caller's transaction and write history. The Redirects form gets its loop error and chain notice from a field-less D-91 extension the plugin registers on its own `golem15.seo.redirects` controller (Save returns `pact.AdminFormErrors{"target": ...}` or a notice on `target`), because owner controllers have no notice hook.
|
||||
- **P-19 (loop detection, RESEARCH Pitfall 10):** simulate with the candidate rule in place: take the rule's target, or a probe instance of a pattern source with `*` replaced by `seoprobe`, or, for regex, a probe string built from `regexp/syntax` literals when one can be built; run the redirect stage up to 10 hops; a repeated URL refuses with the hop path, more than one hop answers the chain notice. At runtime the same 10-hop cap answers not_found and logs once per URL.
|
||||
- **P-20 (address change, RESEARCH Pattern 6 and Pitfall 11):** for each locale whose Describe URL differs from the entry URL of a record that was published: delete rules whose exact source equals the new URL, create old → new (301, origin slug), re-point exact rules whose target is the old URL, drop self-loops, update the entry URL, write a revision with via auto. The notice `redirects.auto_created` goes on the owner field whose name equals a placeholder of the type's pattern that appears in `AdminFormSave.Changes` (for example `slug`), else on `seo_status`.
|
||||
- **P-21 (one-hop decision):** resolve runs lock flag → normalisation (all configured rewrites at once) → redirects on the normalised path, following rules to the final target → strict URL → SEO; when normalisation changed the URL or a redirect matched, the answer's location is the final target, so a visitor makes one hop.
|
||||
- **P-22 (404 report):** `POST /_seo/api/v1/notfound` with `Authorization: Bearer <golem15.seo.notfound_token>` (config; constant-time comparison; endpoint answers 404 when the token is unset), body `{url, referer}`; URL capped at 2048 bytes and referer at 1024; at most `golem15.seo.notfound_max_rows` rows (default 10000), new URLs beyond it are dropped with one warning per hour (RESEARCH A6).
|
||||
- **P-23 (Sitemap lines):** one `Sitemap:` line per entry of config `golem15.seo.robots_sitemaps` (default `["/sitemap.xml"]`), absolute on the site URL; Phase 33's sitemap rewrite replaces the default with its index.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-RESEARCH.md
|
||||
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
|
||||
@.planning/phases/32-seo-foundation/32-03-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From plan 32-03 (sm-seo-plugin): classes.Service (single writer, history), classes.Resolver.Resolve(ctx, rawURL) (Result with per-value sources), entries keyed by (model_type, record_id, locale) with url; settings columns url_host, url_slash, url_lowercase, verify_files; extensions.go SEO tab extension with Save(in pact.AdminFormSave) and in.Changes; listeners.go registering *pact.RecordDeleted and created *pact.RecordSaved; routes.go group /_seo/api/v1 and Buckets(); P-15 resolve JSON.
|
||||
From plan 32-02: signpost Registry Describe(ctx, key, id, unprefixed) and MatchPath(ctx, path, unprefixed) → (key, *pact.ContentMatchResult{ID, Locale, Exact}); pact.RecordSaved{Controller, Model, ID, Locale, Created, Changes}.
|
||||
From plan 32-01: field-less extensions with only Save are valid; AdminFormErrors → 422 field lines; Notices may name owner fields.
|
||||
pact.AdminRecordAction{Name, Label, Confirm, Permissions, Danger, Prompt *AdminActionPrompt, Applies, Run}; pact.AdminBulkAction; pact.ListRowStates / RowState; analog ../sm-paymentgateway-plugin/controllers/orders.go 106-155.
|
||||
grobotstxt (pkg.go.dev): NewRobotsMatcher(); (*RobotsMatcher).AgentAllowed(robotsBody, userAgent, uri string) bool; MatchingLine() int (0 when no rule matched).
|
||||
Route analog: ../sm-sitemap-plugin/routes.go 43-60 (r.Get("/sitemap.xml", handler, "throttle:"+bucket)).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an editor changes a record's slug and the old URL resolves to a 301 to the new one" — one path only</name>
|
||||
<files>../sm-seo-plugin/models/redirect.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610270006_create_golem15_seo_redirects.go, ../sm-seo-plugin/classes/redirects.go, ../sm-seo-plugin/classes/addresses.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/controllers/api/resolve.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/redirects_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/extensions.go, classes/service.go, classes/resolver.go, classes/entries.go (plan 32-03)
|
||||
- ../sm-seo-plugin/fixture_test.go and seo_tab_test.go (fake content plugin and admin harness)
|
||||
- summercms.go/modules/pact/record_events.go and modules/signpost/content.go
|
||||
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 6, Pitfall 11)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-18 and P-20: add the redirects table (id, source, match exact|pattern|regex, target, status 301|302|410, query ignore|pass, ignore_case bool, trailing_slash bool default true, origin manual|slug|import|mcp|notfound, note, timestamps; unique (source, match)); `classes/redirects.go` exact-source lookup only for the tracer; `classes/addresses.go` `(*Service).AddressChanged(ctx, tx, change AddressChange) (AddressResult, error)` implementing P-20; in the SEO tab extension's Save compare `Describe` URLs (read through the transaction) with the stored entry URLs and call AddressChanged per changed locale, returning the `redirects.auto_created` notice (P-20 field choice); the resolver answers `{"action":"redirect","status":301,"location":<absolute new URL>}` for an exact redirect source. `TestSlugChange301` in `redirects_test.go`: create a published fake page /pages/a with an entry, PUT slug b through the admin API (response Meta.Notices contains the auto_created notice), then resolve /pages/a → redirect 301 to the absolute /pages/b, resolve /pages/b → ok; an earlier redirect /pages/x → /pages/a now points to /pages/b. Commit `feat: address-change 301 (tracer)` in ../sm-seo-plugin.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSlugChange301' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestSlugChange301" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func (s \*Service) AddressChanged(" ../sm-seo-plugin/classes/addresses.go` prints one line
|
||||
- `grep -n "AddressChanged(" ../sm-seo-plugin/extensions.go` prints at least one line
|
||||
- `TestSlugChange301` asserts status 301, the absolute location and the flattened earlier redirect, and passes
|
||||
</acceptance_criteria>
|
||||
<done>An admin slug change creates the 301 in the save transaction, flattens the earlier redirect, tells the editor, and the resolve answer redirects the old URL.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Full redirect engine, Redirects screen with loop refusal and chain notice, 404 log, and the RecordSaved listener for non-admin writes</name>
|
||||
<files>../sm-seo-plugin/classes/redirects.go, ../sm-seo-plugin/classes/redirect_graph.go, ../sm-seo-plugin/classes/addresses.go, ../sm-seo-plugin/classes/notfound.go, ../sm-seo-plugin/models/redirect.go, ../sm-seo-plugin/models/redirect/fields.yaml, ../sm-seo-plugin/models/redirect/columns.yaml, ../sm-seo-plugin/models/notfound.go, ../sm-seo-plugin/models/notfound/fields.yaml, ../sm-seo-plugin/models/notfound/columns.yaml, ../sm-seo-plugin/updates/202610270007_create_golem15_seo_notfound.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/redirects.go, ../sm-seo-plugin/controllers/redirects/config_list.yaml, ../sm-seo-plugin/controllers/redirects/config_form.yaml, ../sm-seo-plugin/controllers/notfound.go, ../sm-seo-plugin/controllers/notfound/config_list.yaml, ../sm-seo-plugin/controllers/notfound/config_form.yaml, ../sm-seo-plugin/controllers/notfound/_hint.htm, ../sm-seo-plugin/controllers/api/notfound.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/listeners.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/redirects_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go (record actions with Applies/Run, bulk actions, FilterOptions, ListExtendQuery)
|
||||
- summercms.go/modules/pact/capabilities.go (AdminRecordAction, AdminActionPrompt 370, AdminBulkAction 287, RowState 878-906)
|
||||
- summercms.go/docs/backend/admin-controllers.md (Record actions, Bulk actions)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C2, C8, C9, Copywriting: Redirects, 404 log, Destructive actions)
|
||||
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pitfalls 10, 11; Code Examples: wildcard source to anchored regex; Security: open redirect, ReDoS, 404 flooding)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Exact source /old → 301 /new; ignore_case matches /OLD; trailing_slash matches /old/; query ignore drops ?a=1, pass appends it to the target; a source containing ? matches its query exactly (Q-9)
|
||||
- Wildcard /old/* → /new/* maps /old/x/y to /new/x/y; regex ^/p/(\d+)$ → /post/$1 maps /p/12 to /post/12; exact wins over pattern, pattern over regex
|
||||
- 410 rule: action gone, no location; 410 with a target refused (redirects.target_gone); 301 without target refused (redirects.target_required); invalid regex refused (redirects.regex_invalid); duplicate source and match refused (redirects.source_taken); source not starting with / refused (redirects.source_invalid); regex longer than 512 characters refused
|
||||
- A → B saved, then B → A refused with redirects.loop naming A → B → A; /old/* → /old/new/* refused (pattern probe); A → B with B → C existing saved with chain_notice naming the final target C
|
||||
- Back and forth: A→B, B→A (as an address change), then A→C ends with exactly A→C and B→C
|
||||
- A regex capture substituted into a relative target never yields an absolute URL to another host; an external target is allowed only when the target literal is absolute
|
||||
- 404 report with the right token upserts url, increments hits, keeps first_seen, updates last_hit and referer; wrong or missing token → 401; unset token → 404; URL over 2048 bytes → 422
|
||||
- 404 record action redirect (prompt value /new) creates a 301 with origin notfound and the row turns Resolved; gone creates a 410; bulk gone over a selection containing an already-covered URL refuses the whole selection with notfound.has_rule
|
||||
- Journal-style write outside cabana (RecordSaved with a slug change and Controller "") creates the 301 after commit; RecordSaved without a URL change creates nothing; an unpublished record's change creates nothing
|
||||
</behavior>
|
||||
<action>
|
||||
Implement D-16, D-17, D-19, D-20, D-18 for non-admin writers, per P-18, P-19, P-22 and the behaviour list. `classes/redirects.go`: compiled rule set (exact map, wildcard rules ordered by longest literal prefix then id, regex rules by id, compiled once per change with stdlib regexp; wildcard via `regexp.QuoteMeta` with `*` → `(.*)`); `classes/redirect_graph.go`: simulation, loop and chain detection, flattening. Redirects screen `golem15.seo.redirects` per C8 (list columns, filters, row state `negative` labelled `redirects.state_chain` when the target is another rule's source; form layout) and the field-less self-extension of P-18. 404 log `golem15.seo.notfound` per C9 (list by hits desc, preview with `_hint.htm`, prompted `redirect` and danger `gone` record actions, bulk `gone`, row state `disabled` labelled Resolved computed through the rule set). `controllers/api/notfound.go` + route `POST /_seo/api/v1/notfound` (throttle bucket `seo-notfound`) per P-22, config keys `golem15.seo.notfound_token` (default empty) and `golem15.seo.notfound_max_rows` (default 10000) in `config/config.yaml`. `listeners.go`: `*pact.RecordSaved` for a model with a content type re-describes the record after commit in its own transaction and calls AddressChanged per changed locale (no-op when the admin Save already updated the entry). Navigation per UI-SPEC C2: item `seo` lands on `golem15.seo.redirects`, side menu Redirects, 404 log, URL rules, robots.txt, History, Import and export (the last two screens' controllers come in Task 4 and plan 32-05; add each side-menu entry when its controller exists). Write the tests first (`TestRedirectEngine`, `TestLoopRefused` admin path, `TestNotFoundLog`, `TestAddressChangeFromEvent` in `redirects_test.go`). Commits in ../sm-seo-plugin by logical change.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestRedirectEngine|TestLoopRefused|TestNotFoundLog|TestAddressChangeFromEvent|TestSlugChange301' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the five names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "subtle.ConstantTimeCompare" ../sm-seo-plugin/controllers/api/notfound.go` prints one line
|
||||
- `grep -n "\*pact.RecordSaved" ../sm-seo-plugin/listeners.go` prints one line
|
||||
- `grep -rn "regexp.MustCompile(.*source\|dlclark\|regexp2" ../sm-seo-plugin --include=*.go` prints nothing (stdlib RE2 only, no panicking compile on stored input)
|
||||
- `TestLoopRefused` asserts a 422 with `redirects.loop` on `target` and that the redirects table row count is unchanged
|
||||
- the redirect form, list and 404 screens use only lang keys present in both lang files
|
||||
</acceptance_criteria>
|
||||
<done>The redirect engine, loop refusal, chain notices, the 404 log with its actions and the after-commit address-change path for non-admin writers work and are smoke-tested.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: URL normalisation, strict URLs and the one-hop redirect decision in resolve</name>
|
||||
<files>../sm-seo-plugin/classes/normalize.go, ../sm-seo-plugin/classes/strict.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/controllers/api/resolve.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/addresses_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/classes/resolver.go, classes/urls.go (plan 32-03) and classes/redirects.go (Task 2)
|
||||
- summercms.go/modules/signpost/content.go (MatchPath, Exact semantics)
|
||||
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-20b, D-20c) and 32-RESEARCH.md (Pitfall 13, Pitfall 15)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C11 Addresses tab, Q-7)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- With defaults (keep, keep, lowercase off): http://site/a → 301 https://site/a; //a//b → /a/b; /blog/index.html → /blog; a clean URL → ok
|
||||
- url_host www: https://site.tld/a → https://www.site.tld/a; url_slash add: /a → /a/; url_slash remove: /a/ → /a (the root / is never changed)
|
||||
- Lowercase on: /Blog/Post → /blog/post; /Łódź keeps its non-ASCII letters; %C5%81 stays equivalent (Pitfall 13)
|
||||
- A non-normalised URL whose normalised form is a redirect source answers one 301 straight to the final target
|
||||
- Strict: /wrong-category/post-slug (pattern /:category/:slug) → 301 to the post's canonical URL; /pl/<default-locale-slug> → 301 to the pl URL; a matching URL → ok
|
||||
- Runtime cycle (seeded directly in the table) stops after 10 hops with not_found
|
||||
- P1 shows status.canonical_redirected when the record's canonical URL is a redirect source
|
||||
</behavior>
|
||||
<action>
|
||||
Implement D-20a, D-20b, D-20c and D-103 per P-21 and the behaviour list: `classes/normalize.go` pure `Normalize(u *url.URL, opts NormalizeOptions) (*url.URL, bool)`; `classes/strict.go` through signpost `MatchPath` and `Describe`; the resolver pipeline order of P-21 with the runtime hop cap; the resolve answer gains `redirect` and `gone` actions (status, location absolute on the site URL, never from the request Host); P1 gains the canonical-redirected callout (D-06). Tests first: `TestNormalize`, `TestStrictURL`, `TestOneHop` in `addresses_test.go`. Commit in ../sm-seo-plugin.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestNormalize|TestStrictURL|TestOneHop' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the three names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func Normalize(" ../sm-seo-plugin/classes/normalize.go` prints one line
|
||||
- `grep -n "MatchPath(" ../sm-seo-plugin/classes/strict.go` prints one line
|
||||
- `TestOneHop` asserts that the answer's location equals the final target for a URL that needs normalisation and then a redirect
|
||||
- `TestNormalize` includes a Polish non-ASCII case
|
||||
</acceptance_criteria>
|
||||
<done>The resolve answer is the redirect decision endpoint: normalisation, redirects and strict URLs reach the final address in one hop, and a wrong URL never answers ok.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: robots.txt editor, tester, AI bots, GET /robots.txt with the non-production lock, verification files, and the grobotstxt decision note</name>
|
||||
<precondition>Plan 32-03 Task 4 answered "approved" for github.com/jimsmart/grobotstxt v1.0.3 (recorded in 32-03-SUMMARY.md)</precondition>
|
||||
<files>.planning/notes/2026-10-27-robots-txt-grobotstxt.md, .planning/research/STACK.md, ../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/models/robots.go, ../sm-seo-plugin/models/robots/fields.yaml, ../sm-seo-plugin/updates/202610270008_create_golem15_seo_robots.go, ../sm-seo-plugin/controllers/robots.go, ../sm-seo-plugin/controllers/robots/config_form.yaml, ../sm-seo-plugin/controllers/robots/_staging.htm, ../sm-seo-plugin/controllers/robots/_robots_served.htm, ../sm-seo-plugin/controllers/api/robots.go, ../sm-seo-plugin/controllers/api/verification.go, ../sm-seo-plugin/classes/robots.go, ../sm-seo-plugin/classes/bots.go, ../sm-seo-plugin/classes/verification.go, ../sm-seo-plugin/classes/field_types.go, ../sm-seo-plugin/field_types.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/admin.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/robotstxt.ts, ../sm-seo-plugin/assets/src/elements/robotstester.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/robots_test.go</files>
|
||||
<read_first>
|
||||
- .planning/notes/2026-10-20-sociallogin-goth.md (decision note format) and .planning/research/STACK.md (Supporting Libraries table, the goth row)
|
||||
- ../sm-sitemap-plugin/routes.go (root route with throttle bucket)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (E4, E5, C10, P4, Copywriting: robots.txt screen, E4, E5)
|
||||
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Standard Stack grobotstxt, Code Examples robots test, Anti-Patterns: 5xx on robots.txt, Go /{name} route; State of the Art: noarchive, crawl-delay)
|
||||
- ../sm-seo-plugin/classes/resolver.go and routes.go
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-22, D-24, D-25, D-99, D-105, D-05, D-103, D-08 and P-23:
|
||||
1. Planning docs (framework repo, separate commit `docs(32-04): grobotstxt decision note`): `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` (use the execution date in the file name if it differs) naming `github.com/jimsmart/grobotstxt` v1.0.3, Apache-2.0, why (Google's parser semantics: longest match, allow wins ties, `*`/`$`, `MatchingLine()`), the alternatives (temoto/robotstxt, hand-rolled RFC 9309), that it is plugin-only (the framework go.mod does not require it) and D-99; one row in `.planning/research/STACK.md` Supporting Libraries in the goth row's format.
|
||||
2. `go get github.com/jimsmart/grobotstxt@v1.0.3` in ../sm-seo-plugin.
|
||||
3. robots singleton table (id 1, content text, bots jsonb) and `golem15.seo.robots` (`AdminSingleton`) per C10: tab robots.txt (staging partial, `content` of field type `robotstxt` with Decode refusing more than 500 KB with `robots.too_large`, `tester` widget E5 filled from content and every bot switch, `served` P4), tab AI bots with one switch per bot from `classes/bots.go` in the C10 order plus `Claude-User` as a user-fetch bot operated by Anthropic (D-105) placed after ChatGPT-User; comments `bots.search_comment`, `bots.user_comment`, `bots.training_comment` with `:operator`.
|
||||
4. `classes/robots.go`: `ServedFile(ctx, production bool)` composes the stored rules, a `User-agent: <token>` + `Disallow: /` group per switched-off bot, and the P-23 Sitemap lines; non-production answers `User-agent: *` / `Disallow: /`. The tester action `robots_test` composes the production file from the unsaved content and switches, runs grobotstxt `NewRobotsMatcher().AgentAllowed` and `MatchingLine`, and answers allowed/blocked, the line number and text, and whether a bot switch blocked it. On save, a notice `robots.blocks_noindex` lists noindex URLs (rules with noindex and entries whose robots contain noindex) that Googlebot is blocked from (D-24).
|
||||
5. Elements E4 `golem15-seo-robotstxt` (live lint per the E4 copy: unknown directive with suggestion, rule before any User-agent, bad path, missing colon, own Sitemap line, Crawl-delay; size meter; 10 findings max) and E5 `golem15-seo-robotstester` (input row, crawler select listing Googlebot, Googlebot-Image, Bingbot, every bot token and `*`, result line, staging and unsaved notes); rebuild and commit `assets/dist`.
|
||||
6. Routes: `GET /robots.txt` (throttle bucket `seo-robots`, no backend middleware) serving text/plain; on any error it serves the last good body cached in memory, else `User-agent: *` / `Disallow:` with status 200, and logs; outside production it adds `X-Robots-Tag: noindex, nofollow`. `GET /_seo/api/v1/verification/{name}` (name pattern `[A-Za-z0-9._-]+\.(html|txt|xml)`) serves the stored file body with a matching content type, 404 otherwise. Every `/_seo/api/v1` response carries `X-Robots-Tag: noindex`. No route is registered at `/{name}` (RESEARCH anti-pattern).
|
||||
7. Tests `TestRobotsServed` (stored + bots + Sitemap; staging lock; DB failure still 200), `TestRobotsTester` (Google documented examples: longest match, allow wins ties, `$` anchor; a switched-off bot reported as blocked by its switch), `TestVerificationFile` in `robots_test.go`. README sections for robots.txt, AI bots, verification files and the new config keys.
|
||||
Plugin commits in ../sm-seo-plugin by logical change.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestRobotsServed|TestRobotsTester|TestVerificationFile' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the three names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
|
||||
<fails_when>non-zero exit, a TypeScript error, or "assets/dist is stale" in the output</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "github.com/jimsmart/grobotstxt v1.0.3" ../sm-seo-plugin/go.mod` prints one line and `grep -c "jimsmart/grobotstxt" go.mod` (framework) prints 0
|
||||
- `grep -n "github.com/jimsmart/grobotstxt" .planning/notes/*robots-txt-grobotstxt.md .planning/research/STACK.md` prints a line in each
|
||||
- `grep -n "Claude-User" ../sm-seo-plugin/classes/bots.go` prints one line (D-105)
|
||||
- `grep -rn "Get(\"/{" ../sm-seo-plugin/routes.go` prints nothing (no root catch-all for verification files)
|
||||
- `TestRobotsServed` includes the case where the robots row cannot be read and asserts status 200
|
||||
</acceptance_criteria>
|
||||
<done>robots.txt is editable with live lint and a Google-faithful tester, AI bots are switchable including Claude-User, crawlers always get a 200 robots.txt with a hard lock outside production, and verification files are available to the frontend.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous crawler → GET /robots.txt, verification API | public reads |
|
||||
| frontend server → POST /_seo/api/v1/notfound | server-to-server report with a shared token |
|
||||
| visitor URL → resolve redirect decision | untrusted path matched against admin-supplied patterns and regex |
|
||||
| editor → redirect rules | admin-supplied sources, targets and regex |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32-16 | Tampering | redirect targets (open redirect) | high | mitigate | Capture substitution only into the path of a relative target; an external host only when the rule's target literal is absolute; query pass-through appends to the query only (TestRedirectEngine case) |
|
||||
| T-32-17 | Denial of service | regex sources | high | mitigate | stdlib RE2 (linear time), sources capped at 512 characters, compiled once per change, compile errors refused at save |
|
||||
| T-32-18 | Denial of service | 404 report endpoint | medium | mitigate | Bearer token with constant-time comparison, disabled when unset, URL/referer caps, row cap with a rate-limited warning, throttle bucket seo-notfound |
|
||||
| T-32-19 | Information disclosure | staging or demo copies indexed | high | mitigate | Environment decides the lock (Disallow: / and X-Robots-Tag), no admin setting can disable it (TestRobotsServed staging case) |
|
||||
| T-32-20 | Denial of service | /robots.txt 5xx | high | mitigate | Last good body cached in memory, fallback allow-all body, always 200 (TestRobotsServed failure case) |
|
||||
| T-32-21 | Tampering | verification file names | medium | mitigate | Name pattern without slashes, validated at save and in the route pattern; content served as text with an explicit content type |
|
||||
| T-32-22 | Denial of service | redirect cycles at runtime | medium | mitigate | 10-hop cap answering not_found plus a log line; loops refused at save |
|
||||
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | grobotstxt and the element toolchain approved at plan 32-03 Task 4 (blocking-human); exact version pinned; decision note and STACK row recorded |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` green with Docker.
|
||||
- `npm --prefix ../sm-seo-plugin run typecheck` and `scripts/check-dist.sh` green.
|
||||
- Decision note and STACK row committed in this repository as their own planning-docs commit.
|
||||
- Nothing pushed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Changing a record's slug creates a 301 and flattens chains, from the admin and from Journal's API; loops are refused in the admin; the resolve answer gives the frontend a one-hop redirect decision; robots.txt, AI bots and verification files are managed in the CMS with the non-production lock.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces (plan 04)
|
||||
|
||||
- Tables `golem15_seo_redirects`, `golem15_seo_notfound`, `golem15_seo_robots`; models `Redirect`, `NotFound`, `Robots`
|
||||
- Controllers `golem15.seo.redirects`, `golem15.seo.notfound`, `golem15.seo.robots` (AdminSingleton); record actions `redirect`, `gone`; bulk action `gone`; row states Chain and Resolved
|
||||
- Field type `robotstxt`; elements `golem15-seo-robotstxt` (E4), `golem15-seo-robotstester` (E5); widget action `robots_test`; partials `robots_served`, `staging`, `hint`
|
||||
- `classes`: `Service.SaveRedirect`, `Service.AddressChanged`, `Service.DeleteRedirect`, redirect rule set and graph, `Normalize`, strict-URL check, `ServedFile`, bot list, verification lookup
|
||||
- Routes `GET /robots.txt`, `POST /_seo/api/v1/notfound`, `GET /_seo/api/v1/verification/{name}`; resolve actions `redirect` and `gone`; buckets `seo-robots`, `seo-notfound`
|
||||
- Config keys `golem15.seo.notfound_token`, `golem15.seo.notfound_max_rows`, `golem15.seo.robots_sitemaps`
|
||||
- Dependency `github.com/jimsmart/grobotstxt` v1.0.3 with `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` and a STACK.md row
|
||||
- Tests `TestSlugChange301`, `TestRedirectEngine`, `TestLoopRefused`, `TestNotFoundLog`, `TestAddressChangeFromEvent`, `TestNormalize`, `TestStrictURL`, `TestOneHop`, `TestRobotsServed`, `TestRobotsTester`, `TestVerificationFile`
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32-seo-foundation/32-04-SUMMARY.md` when done with every commit SHA in both repositories.
|
||||
</output>
|
||||
356
.planning/phases/32-seo-foundation/32-05-PLAN.md
Normal file
356
.planning/phases/32-seo-foundation/32-05-PLAN.md
Normal file
@@ -0,0 +1,356 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["32-04"]
|
||||
files_modified:
|
||||
- ../sm-seo-plugin/routes.go
|
||||
- ../sm-seo-plugin/mcp.go
|
||||
- ../sm-seo-plugin/operations.go
|
||||
- ../sm-seo-plugin/plugin.go
|
||||
- ../sm-seo-plugin/admin.go
|
||||
- ../sm-seo-plugin/admin_navigation.go
|
||||
- ../sm-seo-plugin/field_types.go
|
||||
- ../sm-seo-plugin/controllers/api/auth.go
|
||||
- ../sm-seo-plugin/controllers/api/seo.go
|
||||
- ../sm-seo-plugin/controllers/api/rules.go
|
||||
- ../sm-seo-plugin/controllers/api/redirects.go
|
||||
- ../sm-seo-plugin/controllers/api/notfound.go
|
||||
- ../sm-seo-plugin/controllers/api/robots.go
|
||||
- ../sm-seo-plugin/controllers/api/settings.go
|
||||
- ../sm-seo-plugin/controllers/api/history.go
|
||||
- ../sm-seo-plugin/controllers/api/transfer.go
|
||||
- ../sm-seo-plugin/controllers/api/batch.go
|
||||
- ../sm-seo-plugin/controllers/api/issues.go
|
||||
- ../sm-seo-plugin/controllers/admin_registry.go
|
||||
- ../sm-seo-plugin/controllers/history.go
|
||||
- ../sm-seo-plugin/controllers/history/config_form.yaml
|
||||
- ../sm-seo-plugin/controllers/transfer.go
|
||||
- ../sm-seo-plugin/controllers/transfer/config_form.yaml
|
||||
- ../sm-seo-plugin/classes/owner.go
|
||||
- ../sm-seo-plugin/classes/batch.go
|
||||
- ../sm-seo-plugin/classes/undo.go
|
||||
- ../sm-seo-plugin/classes/csv.go
|
||||
- ../sm-seo-plugin/classes/transfer.go
|
||||
- ../sm-seo-plugin/classes/issues.go
|
||||
- ../sm-seo-plugin/classes/history.go
|
||||
- ../sm-seo-plugin/models/settings/fields.yaml
|
||||
- ../sm-seo-plugin/assets/src/elements/index.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/labels.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/transfer.ts
|
||||
- ../sm-seo-plugin/assets/src/elements/styles.css
|
||||
- ../sm-seo-plugin/assets/dist/seo.js
|
||||
- ../sm-seo-plugin/assets/dist/seo.css
|
||||
- ../sm-seo-plugin/lang/en/lang.yaml
|
||||
- ../sm-seo-plugin/lang/pl/lang.yaml
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-seo-plugin/mcp_test.go
|
||||
- ../sm-seo-plugin/batch_test.go
|
||||
- ../sm-seo-plugin/transfer_test.go
|
||||
- ../sm-demo-app/seo_mount_test.go
|
||||
- ../sm-demo-app/seo_mcp_test.go
|
||||
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch
|
||||
- scripts/check-phase32.sh
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-3, SC-4, D-10, D-11, D-12, D-13, D-14, D-15, D-19, D-90, D-94, D-98]
|
||||
|
||||
estimate:
|
||||
tokens: 180000
|
||||
raw_tokens: 180000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every SEO operation of the admin (SEO tab, URL rules, redirects, 404 log actions, robots.txt and AI bots, settings, history and undo, import and export) has an MCP tool bound to a /_seo/api/v1 route, and a plugin test fails when an admin operation has no tool (SC-4, D-10)"
|
||||
- "MCP tools run under the linked admin's permissions with the one golem15.seo.manage permission; a read grant sees only GET tools (D-10, D-15)"
|
||||
- "Tools address pages by URL: a change goes into the record's SEO when a record is behind the URL, otherwise into a URL rule; the answer says where it was written, and force_rule writes a rule (D-11)"
|
||||
- "A batch of changes can be previewed with dry_run, which returns the diff and the conflicts (loops, chains, noindex on a published record's URL, canonical pointing to a redirected URL) and writes nothing; applying a batch is all-or-nothing (SC-4, D-12)"
|
||||
- "Redirect loops are refused from MCP (tool error) and from CSV import (refused row), as from the admin (SC-3, D-19)"
|
||||
- "History records author, time, via (admin, mcp, import, auto, undo) and before/after values; one change or a whole batch can be undone from the admin and through MCP; an undo that would overwrite a newer change, create a loop or touch a deleted record is refused (SC-4, D-13)"
|
||||
- "CSV export and import with a dry-run preview work in the admin (E6, 60 KB limit) and through MCP; files are UTF-8 with BOM, comma or semicolon, and exported cells starting with = + - @ tab or CR are prefixed with an apostrophe (D-13)"
|
||||
- "The effective SEO of a URL is readable with the source of every value, and the issue report lists the CMS data checks: duplicate and missing titles and descriptions, conflicting rules, redirect chains, canonicals pointing to noindex or redirected URLs (D-13, D-14 CMS part)"
|
||||
- "The demo mount is proven in a scratch worktree of sm-demo-app: Journal posts, categories and tags show the SEO tab and the pier catalog lists every SEO tool; the mount commit waits for the plugin tag in plan 32-06 because the demo's TestGoModRequiresTags refuses untagged plugins (SC-2, SC-4)"
|
||||
- "scripts/check-phase32.sh exists with stages self-test, go, plugins, consumers, docker, admin, docs and marker, and its marker stage refuses SEO names or consuming-application names in the framework lines this phase added (D-90)"
|
||||
- "Import and export screen: before a file is chosen noFile shows and Preview import is disabled; Apply is disabled with refusedBlocks when a row is refused and with nothing when the count is zero; more than 200 rows show rowCap; the preview table scrolls inside a max-height wrapper (UI-SPEC empty, error, zero-one-many, overflow)"
|
||||
- "E5 and E6 actions disable their buttons and show the busy label while pending (UI-SPEC loading)"
|
||||
- "Undo refusals show a danger toast with history.undo_stale, history.undo_loop or history.undo_gone; batch undo applies only to batched entries and uses the plural confirm (UI-SPEC error, zero-one-many)"
|
||||
- "Stale import preview answers transfer.stale and E6 keeps the preview; unreadable files answer transfer.unreadable and missing columns transfer.missing_column (UI-SPEC error)"
|
||||
prohibitions:
|
||||
- statement: "A batch or an import is never applied partially; a refused change means nothing is written (D-12)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "An MCP read grant can never change SEO data, and no /_seo/api/v1 management route answers without golem15.seo.manage (D-10, D-15)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "An undo never silently overwrites a change made after the one being undone (D-13)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Framework lines added by this phase name no SEO concept and no consuming application (D-90)"
|
||||
status: resolved
|
||||
verification: test
|
||||
artifacts:
|
||||
- path: "../sm-seo-plugin/mcp.go"
|
||||
provides: "pact.HasMCP: every SEO tool, Guard backend, Permissions golem15.seo.manage"
|
||||
contains: "func (p *Plugin) MCP() pact.MCP"
|
||||
- path: "../sm-seo-plugin/operations.go"
|
||||
provides: "AdminOperations: the admin operation → tool name list the parity test checks"
|
||||
contains: "AdminOperations"
|
||||
- path: "../sm-seo-plugin/classes/batch.go"
|
||||
provides: "dry-run by rollback, all-or-nothing apply, conflicts"
|
||||
contains: "dry"
|
||||
- path: "../sm-seo-plugin/classes/undo.go"
|
||||
provides: "undo of a change and of a batch with staleness, loop and gone guards"
|
||||
contains: "undo_stale"
|
||||
- path: "scripts/check-phase32.sh"
|
||||
provides: "phase gate"
|
||||
contains: "marker"
|
||||
- path: ".planning/phases/32-seo-foundation/32-05-demo-mount.patch"
|
||||
provides: "the demo mount, applied by plan 32-06 after the plugin tag"
|
||||
contains: "golem15.seo"
|
||||
key_links:
|
||||
- from: "../sm-seo-plugin/mcp.go"
|
||||
to: "../sm-seo-plugin/routes.go"
|
||||
via: "each MCPTool Method+Path equals a registered /_seo/api/v1 route (pier refuses unbound tools at boot)"
|
||||
pattern: "/_seo/api/v1/"
|
||||
- from: "../sm-seo-plugin/controllers/api/batch.go"
|
||||
to: "../sm-seo-plugin/classes/batch.go"
|
||||
via: "POST /_seo/api/v1/batch with dry_run runs Batch.Run in lagoon.Transaction and rolls back on dry_run"
|
||||
pattern: "lagoon\\.Transaction"
|
||||
- from: "../sm-demo-app/seo_mcp_test.go"
|
||||
to: "../sm-seo-plugin/mcp.go"
|
||||
via: "TestSEOToolsCatalog compiles the pier catalog with -tags summercms_mcp and compares it with the plugin's MCP() tools"
|
||||
pattern: "TestSEOToolsCatalog"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor on any SummerCMS application, **I want to** per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, **so that** every site ships correct, editable SEO without per-site work.
|
||||
|
||||
This plan makes the whole of SEO manageable through MCP and in bulk, with history, undo, CSV, and the phase gate.
|
||||
|
||||
<objective>
|
||||
Add the `/_seo/api/v1` management API and pier MCP tools for every SEO admin operation (D-10, D-15), URL addressing (D-11), batch dry-run and all-or-nothing apply (D-12), the read and audit tools including the CMS-data issue report (D-13, D-14 CMS part), undo of one change or a batch, CSV import/export in the admin (E6) and through MCP (D-13), loop refusal on the MCP and import paths (D-19, SC-3); prove the demo mount in a scratch worktree of sm-demo-app (the commit follows the tags in plan 32-06); write `scripts/check-phase32.sh` with the D-90 marker stage.
|
||||
|
||||
Purpose: SC-4 (every SEO operation in the admin is available as an MCP tool; a batch change can be previewed with dry-run and undone from history) and the MCP/import half of SC-3. The crawler half of D-14 is Phase 33.
|
||||
|
||||
Output: commits in `../sm-seo-plugin` (main, not pushed), the demo test files committed in `../sm-demo-app` (they skip when the plugin is not mounted), the demo mount patch in this phase directory, and `scripts/check-phase32.sh` in this repository.
|
||||
|
||||
**Planner decisions recorded here:**
|
||||
- **P-24 (routes and tools):** management routes in a `/_seo/api/v1` group with throttle bucket `seo-api`, each handler resolving the backend principal (Journal's `requireBackendPrincipal` pattern through `cabana.ResolveBackend`) and requiring `golem15.seo.manage` with `cabana.Allows`. Tools (pier prefixes them with `seo_`), all `Guard: "backend"`, `Permissions: ["golem15.seo.manage"]`: GET `get_url_seo` (/seo?url=), `list_rules`, `show_rule`, `list_redirects`, `show_redirect`, `list_not_found`, `show_robots`, `test_robots` (/robots/test?url=&agent=), `show_settings`, `list_history`, `show_history`, `export_csv` (/export?dataset=&delimiter=&page=), `list_issues`; writes `set_url_seo` (PUT /seo), `create_rule`, `update_rule`, `delete_rule` (destructive), `create_redirect`, `update_redirect`, `delete_redirect` (destructive), `redirect_not_found`, `gone_not_found` (destructive), `delete_not_found` (destructive), `update_robots`, `update_settings`, `undo_change`, `undo_batch`, `import_csv` (with dry_run), `apply_batch` (with dry_run). `operations.go` exports `AdminOperations` mapping each admin operation (form save, list delete, record action, bulk action, widget action, singleton save) to its tool name; `TestEveryAdminOperationHasTool` fails on a missing or unbound one. History `via = mcp` comes from `bouncer.Dispatched(ctx, "backend")`.
|
||||
- **P-25 (owner resolution, D-11):** `classes/owner.go` resolves a URL through normalisation, then signpost `MatchPath`: an Exact match writes the record's entry for the matched locale, anything else (no record, or force_rule) writes an exact URL rule; the answer carries `{"written_to": "record"|"rule", "type", "id", "locale", "rule_id"}`.
|
||||
- **P-26 (batch, RESEARCH Pattern 8):** `Batch.Run(ctx, changes, BatchOptions{DryRun, Via, Author})` runs every change through the Service inside one `lagoon.Transaction`, collecting per-change diff and conflicts; dry_run returns them and rolls back deliberately; any refused change (loop, invalid value) rolls back and answers 422 with per-change errors; applied changes share one `batch_id` in history. "Noindex on a page that is in a sitemap" is checked as noindex on the URL of a published content record (the records the sitemaps list); Phase 33's D-95 filter refines it.
|
||||
- **P-27 (CSV and transfer):** datasets redirects, rules, records (records address pages by URL, D-11); `encoding/csv` with BOM strip/prepend and the chosen delimiter; formula-injection prefix on export; the admin import limit is 60 KB (UI-SPEC Q-8 assumption, flagged below) and export answers at most 256 KiB per action (larger exports are paged through MCP); MCP imports at most 900 KB per call. The E6 apply token is an HMAC-SHA256 (crypto/hmac, keyed by the app key) over dataset, normalised rows and a fingerprint of the touched rows' updated_at values; apply re-runs the dry run and refuses with `transfer.stale` when the fingerprint differs.
|
||||
- **P-28 (demo mount sequencing):** the demo's `TestGoModRequiresTags` requires every mounted plugin at a vX.Y.Z tag (19.6-03 precedent), and tags wait for Jakub's go-ahead in plan 32-06. So this plan proves the mount in a disposable `git worktree` of sm-demo-app with a scratch go.work (absolute paths), stores the mount as `.planning/phases/32-seo-foundation/32-05-demo-mount.patch`, and commits only the two test files, which skip with "NOT MOUNTED" when `golem15.seo` is not registered and import no SEO package. Plan 32-06 applies the patch after tagging.
|
||||
|
||||
**Flagged assumption (UI-SPEC UI Considerations, unresolved row):** CSV files larger than the widget action caps (64 KiB payload, 256 KiB response) are not handled in the admin; `:limit` = 60 KB is the Phase 32 import limit pending UI-SPEC Q-8 (Jakub / planner). Larger imports go through MCP in chunks.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-RESEARCH.md
|
||||
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
|
||||
@.planning/phases/32-seo-foundation/32-VALIDATION.md
|
||||
@.planning/phases/32-seo-foundation/32-04-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
From modules/pact/mcp.go: type MCPTool struct { Name, Title, Description, Guard, Method, Path string; Input, Output any; Permissions []string; ReadOnly, Destructive, Idempotent, OpenWorld *bool }; pact.HasMCP { MCP() pact.MCP }.
|
||||
From modules/pier/catalog.go 178-182: a tool's method+pattern must match a registered route or the boot fails; a `path:` input field must be a required argument (220-223).
|
||||
From ../sm-journal-plugin/mcp.go 12-110 (tool declarations, input structs with json, path and jsonschema tags, destructive pointer) and controllers/api/auth.go 83-115 (requireBackendPrincipal via cabana.ResolveBackend, permission check).
|
||||
From modules/bouncer/dispatch.go: Dispatched(ctx, guard) (*Dispatch, bool).
|
||||
From modules/lagoon/transaction.go: Transaction(ctx, gdb, fn).
|
||||
From plans 32-03/32-04: classes.Service (SaveRedirect, AddressChanged, DeleteRedirect, entry/rule/settings writes with history), classes.Resolver, Normalize, redirect graph, history revisions with batch_id/via/before/after/undone_at.
|
||||
Demo: ../sm-demo-app/boot_test.go (boot helpers), mcp_e2e_test.go (//go:build summercms_mcp, pier catalog access), go_mod_test.go (TestGoModRequiresTags), summer.yaml, go.work, .gitmodules (sitemap submodule entry format), deploy/nginx/demo.summercms.io.conf (location = /sitemap.xml block ~302), DEPLOY.md, config/.
|
||||
Gate analog: scripts/check-phase19.7.sh (stage layout, go test -json detectors, marker stage, NOT RUN docker rule).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end "an MCP client sets noindex on a URL and the resolve answer and history reflect it" — one path only</name>
|
||||
<files>../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/controllers/api/auth.go, ../sm-seo-plugin/controllers/api/seo.go, ../sm-seo-plugin/classes/owner.go, ../sm-seo-plugin/mcp_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-journal-plugin/mcp.go and controllers/api/auth.go (whole files)
|
||||
- summercms.go/modules/pact/mcp.go, modules/pier/catalog.go (binding rules), modules/bouncer/dispatch.go
|
||||
- summercms.go/modules/cabana/dispatch_marker_test.go (how a dispatched MCP call is simulated in a handler test)
|
||||
- ../sm-seo-plugin/classes/service.go, classes/resolver.go, classes/history.go
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-10, D-11, D-13, D-15 and P-24/P-25: `controllers/api/auth.go` (backend principal + `golem15.seo.manage`); `PUT /_seo/api/v1/seo` with body `{url, locale?, values{title, description, keyphrase, canonical, robots, og_title, og_description, og_image}, force_rule?}` writing through `classes/owner.go` and the Service (history via mcp when dispatched); `mcp.go` with `var _ pact.HasMCP = (*Plugin)(nil)` and the first tool `set_url_seo` (Method PUT, Path "/_seo/api/v1/seo", Guard backend, Permissions golem15.seo.manage). `TestMCPSetURLSeoTracer` in `mcp_test.go`: with the fake content plugin and an admin principal marked as dispatched for guard backend, PUT robots "noindex" for /pages/a (a record) → answer `written_to: record`; resolve /pages/a answers robots noindex and x_robots_tag noindex; the history row has via mcp; PUT for /landing (no record) → `written_to: rule` and a rule row exists; an admin without golem15.seo.manage gets 403. Commit `feat: SEO by URL over the management API and MCP (tracer)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestMCPSetURLSeoTracer' -count=1 -v 2>&1 | tail -20</automated>
|
||||
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestMCPSetURLSeoTracer" line</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Path: *\"/_seo/api/v1/seo\"" ../sm-seo-plugin/mcp.go` prints one line
|
||||
- `grep -n "bouncer.Dispatched" ../sm-seo-plugin/classes/history.go ../sm-seo-plugin/controllers/api/*.go` prints at least one line
|
||||
- `TestMCPSetURLSeoTracer` covers the record, rule and 403 cases and passes
|
||||
</acceptance_criteria>
|
||||
<done>An MCP-dispatched call changes a URL's SEO in the right place, the resolver serves it, history records via mcp, and the permission gate holds.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The full management API and one MCP tool per admin operation, effective SEO with sources, and the CMS-data issue report</name>
|
||||
<files>../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/controllers/api/seo.go, ../sm-seo-plugin/controllers/api/rules.go, ../sm-seo-plugin/controllers/api/redirects.go, ../sm-seo-plugin/controllers/api/notfound.go, ../sm-seo-plugin/controllers/api/robots.go, ../sm-seo-plugin/controllers/api/settings.go, ../sm-seo-plugin/controllers/api/history.go, ../sm-seo-plugin/controllers/api/issues.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/README.md, ../sm-seo-plugin/mcp_test.go</files>
|
||||
<read_first>
|
||||
- ../sm-seo-plugin/mcp.go, routes.go, controllers/api/seo.go (Task 1)
|
||||
- ../sm-seo-plugin/controllers/*.go (every admin controller, record/bulk/widget action of plans 32-03 and 32-04: the operation inventory)
|
||||
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-10 to D-14)
|
||||
- summercms.go/docs/services/mcp.md (tool declaration rules, read/write ceilings)
|
||||
</read_first>
|
||||
<action>
|
||||
Per D-10, D-13, D-14 (CMS data checks only), D-15, D-19 and P-24: implement every route and tool listed in P-24 on the existing Service (no logic duplicated in handlers). `GET /seo?url=` answers the effective values with their sources, the owner (record type/id/locale or rule id), the redirect decision and any P1-style warnings. Writes refuse loops with 422 `{"error":"redirect_loop","path":[...]}` so the MCP tool result is an error (D-19). `GET /issues` (`classes/issues.go`) lists: missing effective titles and descriptions per locale, duplicate effective titles and descriptions across entries and rules, URL rules overriding a record, redirect chains with their final targets, canonicals pointing to a noindex or redirected URL, noindex URLs blocked by robots.txt; each issue carries url, kind, details and the tool that fixes it. `operations.go`: `AdminOperations` covering every controller, record action, bulk action, widget action and singleton save of the plugin; `TestEveryAdminOperationHasTool` (pure) checks every operation maps to a tool of `MCP()` and every tool's Method+Path is a route `Routes()` registers (use a recording `pact.Router`), and `TestMCPReadToolsAreGET` checks ReadOnly/GET consistency. README: the API and the tool list. Commit by logical change.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestIssues|TestMCPSetURLSeoTracer' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of the four names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "Guard: *mcpGuard\|Guard: *\"backend\"" ../sm-seo-plugin/mcp.go` is at least 29
|
||||
- `grep -c "golem15.seo.manage\|mcpPermission" ../sm-seo-plugin/mcp.go` is at least 1 and every tool references it
|
||||
- `TestEveryAdminOperationHasTool` fails when one tool is removed (verified once by the executor locally, then restored)
|
||||
- `grep -n "\"/issues\"" ../sm-seo-plugin/routes.go` prints one line
|
||||
</acceptance_criteria>
|
||||
<done>Every admin operation has an MCP tool on a permission-checked JSON route, the effective SEO with sources and the CMS-data issue report are readable, and a parity test guards drift.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Batch dry-run and apply, undo of a change and of a batch, CSV import/export with the E6 Import and export screen</name>
|
||||
<files>../sm-seo-plugin/classes/batch.go, ../sm-seo-plugin/classes/undo.go, ../sm-seo-plugin/classes/csv.go, ../sm-seo-plugin/classes/transfer.go, ../sm-seo-plugin/classes/history.go, ../sm-seo-plugin/controllers/api/batch.go, ../sm-seo-plugin/controllers/api/transfer.go, ../sm-seo-plugin/controllers/api/history.go, ../sm-seo-plugin/controllers/history.go, ../sm-seo-plugin/controllers/history/config_form.yaml, ../sm-seo-plugin/controllers/transfer.go, ../sm-seo-plugin/controllers/transfer/config_form.yaml, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/transfer.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/batch_test.go, ../sm-seo-plugin/transfer_test.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 8; Pitfall 14 caps; Security: CSV formula injection)
|
||||
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C12 undo actions, C13 Import and export, E6 copy, Destructive actions)
|
||||
- ../sm-seo-plugin/classes/service.go, classes/history.go, classes/redirect_graph.go
|
||||
- summercms.go/modules/lagoon/transaction.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- dry_run batch of 3 redirects and 1 set_seo: answers diff and conflicts and leaves every table row count and value unchanged
|
||||
- batch with one loop: 422 naming the change, nothing written; batch with a chain: applied with a chain warning
|
||||
- applied batch: all revisions share one batch_id and via; undo_batch restores every before value in one transaction and writes undo revisions; undo of one change restores its before value
|
||||
- undo after a newer change of the same field: refused with history.undo_stale naming the author and time; undo that would recreate a loop: history.undo_loop; undo for a deleted record: history.undo_gone
|
||||
- CSV export of redirects with semicolon: starts with a UTF-8 BOM, header row, a cell =cmd exported as 'cmd prefixed; import of the exported file previews all rows Unchanged
|
||||
- import with a missing column: transfer.missing_column; unreadable: transfer.unreadable; a looping row: Refused with transfer.row_loop and Apply refused; a records row whose URL has no record: transfer.row_rule
|
||||
- apply with a token after the data changed: transfer.stale; apply with a fresh token: one batch, via import
|
||||
- admin import over 60 KB is refused by the element before calling the action; an MCP import over 900 KB is refused with a 413-style error
|
||||
</behavior>
|
||||
<action>
|
||||
Implement D-12 and D-13 per P-26, P-27 and the behaviour list. Routes `POST /_seo/api/v1/batch` (`apply_batch`), `POST /_seo/api/v1/import` (`import_csv`), `GET /_seo/api/v1/export` (`export_csv`), `POST /_seo/api/v1/history/{id}/undo` (`undo_change`), `POST /_seo/api/v1/history/batches/{batch}/undo` (`undo_batch`). History preview gains record actions `undo` and `undo_batch` (UI-SPEC C12: non-danger confirms, refusals as 403 Message toasts, applies-conditions). `golem15.seo.transfer` (AdminSingleton on the settings row: `delimiter` dropdown defaulting to semicolon when the default locale is pl, `files` widget E6 with fill delimiter) and its widget action `seo_transfer` (ops export, preview, apply); E6 element `golem15-seo-transfer` per C13 with the E6 copy; navigation side-menu Import and export (icon download) and History entries per C2. Tests first: `TestBatch`, `TestUndo`, `TestLoopRefused` MCP and import cases (extend the admin test of plan 32-04 with `t.Run("mcp")` and `t.Run("import")`), `TestCSVTransfer`. Rebuild and commit `assets/dist`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test ./... -run 'TestBatch|TestUndo|TestLoopRefused|TestCSVTransfer' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or any of TestBatch, TestUndo, TestLoopRefused, TestCSVTransfer missing from the "--- PASS" lines, or TestLoopRefused without passing "mcp" and "import" subtests</fails_when>
|
||||
<automated>npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
|
||||
<fails_when>non-zero exit, a TypeScript error, or "assets/dist is stale" in the output</fails_when>
|
||||
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "crypto/hmac" ../sm-seo-plugin/classes/transfer.go` prints one line
|
||||
- `grep -niE "bom|feff|xef" ../sm-seo-plugin/classes/csv.go` prints at least one line (BOM strip on import and prepend on export)
|
||||
- `TestBatch` asserts identical row counts and values before and after a dry run
|
||||
- `grep -n "undo_stale\|undo_loop\|undo_gone" ../sm-seo-plugin/classes/undo.go` prints the three refusals
|
||||
- `grep -c "customElements.define" ../sm-seo-plugin/assets/src/elements/*.ts` totals 7 (E1-E7)
|
||||
</acceptance_criteria>
|
||||
<done>Bulk changes preview and apply atomically, every change and batch can be undone safely, and CSV import/export works in the admin and over MCP with loop refusal on every path.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Prove the demo mount in a scratch worktree, commit the demo's SEO tests, and write scripts/check-phase32.sh</name>
|
||||
<files>../sm-demo-app/seo_mount_test.go, ../sm-demo-app/seo_mcp_test.go, .planning/phases/32-seo-foundation/32-05-demo-mount.patch, scripts/check-phase32.sh</files>
|
||||
<read_first>
|
||||
- ../sm-demo-app/summer.yaml, go.work, .gitmodules, go_mod_test.go (TestGoModRequiresTags), boot_test.go, mcp_e2e_test.go, scripts/check-pins.sh, deploy/nginx/demo.summercms.io.conf (the /sitemap.xml location), DEPLOY.md, config/app.yaml, config/menu_items.yaml
|
||||
- scripts/check-phase19.7.sh (stage structure, detectors, NOT RUN docker, marker stage, workspace check)
|
||||
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md (the phase base SHA) and 32-02-SUMMARY.md (Journal commit SHAs)
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-04-PLAN.md (P-18 local fetch of submodules) and .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md (mounting a new plugin needs tags)
|
||||
</read_first>
|
||||
<action>
|
||||
Per P-28, D-90, SC-2, SC-4:
|
||||
1. Check `git -c safe.directory='*' -C ../sm-demo-app status --short` first and leave every existing change alone (the journal and oauthserver submodule entries already show another session's drift). Do not edit, stage or commit summer.yaml, go.work, go.mod, .gitmodules, plugins.gen.go, main.go or any submodule pointer of the real demo in this plan.
|
||||
2. Demo tests (committed in ../sm-demo-app): `seo_mount_test.go` `TestSEOTabMounted` boots the demo plugin set; when `golem15.seo` is not registered it calls `t.Skip("NOT MOUNTED golem15.seo")`; otherwise it asserts the form schemas of `golem15.journal.posts`, `golem15.journal.categories` and `golem15.journal.tags` contain the SEO tab last and the Journal post tab order Edit, Manage, Categories, SEO. `seo_mcp_test.go` (`//go:build summercms_mcp`) `TestSEOToolsCatalog` compiles the pier catalog and asserts it contains `seo_` + every tool name the registered `golem15.seo` plugin returns from `MCP()` (found through the party registry and `pact.HasMCP`, no import of the SEO package; same skip rule). Both compile and skip on the current demo: run `go -C ../sm-demo-app test -short -run 'TestSEOTabMounted' .` and the tagged variant, then commit `test: SEO mount and MCP catalog checks (skip until mounted)` staging only the two files.
|
||||
3. Scratch proof: `git -c safe.directory='*' -C ../sm-demo-app worktree add --detach "$WT" HEAD` with `WT=$(mktemp -d)/demo`; in it add `golem15.seo` to summer.yaml after golem15.sitemap; write `$WT/config/golem15.seo.yaml` (site_url of the Nuxt origin, notfound_token read from the environment); add `location = /robots.txt` to the nginx config next to `/sitemap.xml`; add a DEPLOY.md section (robots.txt moves to Go, copy the Disallow lines the Nuxt robots config writes today into SEO › robots.txt, set `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN`); write a scratch go.work outside the worktree using absolute paths: the worktree, every live `../sm-demo-app/plugins/golem15/*` checkout except journal, `../sm-journal-plugin` (plan 32-02 commits), `../sm-seo-plugin`, with the demo's replace lines and `git.golem15.com/golem15/summercms => <abs>/summercms.go`; run `go run <abs>/summercms.go/cmd/summer build` in the worktree (GOWORK set) to regenerate plugins.gen.go and main.go; run `GOWORK=<scratch> go test -run 'TestSEOTabMounted|TestBootAllPlugins' .` and `GOWORK=<scratch> go test -tags summercms_mcp -run 'TestSEOToolsCatalog' .` (both must pass, not skip). Save `git -C "$WT" diff -- summer.yaml config deploy DEPLOY.md` (plus the new config file via `git add -N`) as `.planning/phases/32-seo-foundation/32-05-demo-mount.patch`; remove the worktree (`git worktree remove --force`).
|
||||
4. `scripts/check-phase32.sh` modelled on check-phase19.7.sh: stages `self-test`, `go` (framework vet, `go test -short ./...`, required names TestFormExtensionTracer excluded as Docker, TestFormExtensionPlacement, TestFormExtensionBootErrors, TestFormExtensionUnextendedSchemaUnchanged, TestContent*), `plugins` (sm-seo-plugin and sm-journal-plugin: workspace check that the framework resolves to this checkout, vet, test -short, required names), `consumers` (sm-demo-app build and `-short` tests untagged and tagged; prints `NOT MOUNTED golem15.seo` and fails unless `PHASE32_ALLOW_UNMOUNTED=1`; a route-drift check that each Journal pattern in the demo's `config/menu_items.yaml` (journal-post, journal-category, and journal-tag when set) equals the route the demo's Nuxt app configures for the Journal layer, else the layer default in `../vue-journal-layer/app/utils/routes.ts`, because strict URLs 301 valid pages when the two drift, RESEARCH Pitfall 15), `docker` (Postgres suites by required name in the framework, both plugins and the demo; NOT RUN without Docker, a failure with `PHASE32_REQUIRE_DOCKER=1`), `admin` (FormView vitest, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh` only when admin/src changed since the phase base), `docs` (TestDocsTree, `go run ./cmd/summer docs:build --check`, the grobotstxt decision note and STACK row present, `npm --prefix ../sm-seo-plugin run typecheck` and its check-dist), `marker` (D-90: lines added since `PHASE32_BASE` — default the plan 32-01 start SHA from 32-01-SUMMARY.md — in non-test Go files, READMEs and docs under modules/ and docs/ match neither `(?i)\bseo\b` nor a consuming application name (the list check-phase19.7.sh uses); `../sm-journal-plugin` has no import of sm-seo-plugin or sm-sitemap-plugin; the framework go.mod does not require grobotstxt). `--all` runs every stage. Plan 32-06 adds the `additive` and `compat` stages. Commit `test(32-05): check-phase32 gate` and, separately as a planning-docs commit, the patch file.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase32.sh --self-test</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" or "refuse:" line in the output</fails_when>
|
||||
<automated>PHASE32_ALLOW_UNMOUNTED=1 bash scripts/check-phase32.sh --go --plugins --consumers --docs --marker</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or a marker line naming a file under modules/ or docs/</fails_when>
|
||||
<automated>go -C ../sm-demo-app vet ./... && go -C ../sm-demo-app test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output (the demo stays green with the two new skipping tests)</fails_when>
|
||||
<automated>test -s .planning/phases/32-seo-foundation/32-05-demo-mount.patch && grep -c "golem15.seo" .planning/phases/32-seo-foundation/32-05-demo-mount.patch</automated>
|
||||
<fails_when>non-zero exit, or a count of 0 (the patch is missing or does not mount the plugin)</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The executor's SUMMARY quotes the scratch run's `--- PASS: TestSEOTabMounted` and `--- PASS: TestSEOToolsCatalog` lines (not SKIP)
|
||||
- `git -c safe.directory='*' -C ../sm-demo-app show --stat HEAD` lists exactly seo_mount_test.go and seo_mcp_test.go
|
||||
- `git -c safe.directory='*' -C ../sm-demo-app worktree list` shows no leftover scratch worktree
|
||||
- `grep -n "PHASE32_BASE" scripts/check-phase32.sh` prints the default base SHA line
|
||||
- `bash scripts/check-phase32.sh --marker` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>The demo mount is proven end to end in a scratch worktree and stored as a patch for the release plan, the demo carries tests that activate once the plugin is mounted, and the phase gate exists with the D-90 marker stage.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| MCP client → pier → /_seo/api/v1 | agent-driven calls under a linked admin's identity and grant ceiling |
|
||||
| admin browser → E6 import | uploaded CSV content |
|
||||
| exported CSV → spreadsheet application | cells opened by an editor's spreadsheet program |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32-23 | Elevation of privilege | MCP read grants and management routes | high | mitigate | Writes are non-GET tools under pier's read/write ceiling; every route checks golem15.seo.manage (TestMCPReadToolsAreGET, TestMCPSetURLSeoTracer 403 case) |
|
||||
| T-32-24 | Tampering | CSV export formula injection | medium | mitigate | Cells starting with = + - @ tab or CR are prefixed with an apostrophe (TestCSVTransfer) |
|
||||
| T-32-25 | Tampering | stale or forged import apply | high | mitigate | HMAC-SHA256 token keyed by the app key over dataset, rows and a data fingerprint; apply re-runs the dry run and refuses transfer.stale |
|
||||
| T-32-26 | Tampering | partial batch application | high | mitigate | One lagoon.Transaction per batch; any refused change rolls back everything (TestBatch) |
|
||||
| T-32-27 | Tampering | undo overwriting newer changes | high | mitigate | Staleness guard compares current values with the revision's after values; loop and gone guards (TestUndo) |
|
||||
| T-32-28 | Repudiation | who changed SEO | medium | mitigate | Every revision stores author, via (mcp from bouncer.Dispatched), time and before/after; undo is itself a revision |
|
||||
| T-32-29 | Denial of service | large CSV and batch payloads | medium | mitigate | 60 KB admin import, 256 KiB export per action, 900 KB MCP import, the framework body limit, throttle bucket seo-api |
|
||||
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new package; the element toolchain was approved at plan 32-03 Task 4 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` green with Docker.
|
||||
- `npm --prefix ../sm-seo-plugin run typecheck` and check-dist green.
|
||||
- `bash scripts/check-phase32.sh --self-test` and `PHASE32_ALLOW_UNMOUNTED=1 bash scripts/check-phase32.sh --go --plugins --consumers --docs --marker` green.
|
||||
- sm-demo-app green with the two skipping tests; its other uncommitted changes untouched.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Every SEO admin operation is available as an MCP tool, batches are previewed with dry-run and applied atomically, every change and batch can be undone from history, loops are refused from the admin, MCP and imports, and the phase gate is in place.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces (plan 05)
|
||||
|
||||
- Routes under `/_seo/api/v1`: `/seo`, `/rules`, `/rules/{id}`, `/redirects`, `/redirects/{id}`, `/notfound`, `/notfound/{id}/redirect`, `/notfound/{id}/gone`, `/notfound/{id}`, `/robots`, `/robots/test`, `/settings`, `/history`, `/history/{id}`, `/history/{id}/undo`, `/history/batches/{batch}/undo`, `/export`, `/import`, `/batch`, `/issues`; bucket `seo-api`
|
||||
- MCP tools (prefixed `seo_` by pier): `get_url_seo`, `set_url_seo`, `list_rules`, `show_rule`, `create_rule`, `update_rule`, `delete_rule`, `list_redirects`, `show_redirect`, `create_redirect`, `update_redirect`, `delete_redirect`, `list_not_found`, `redirect_not_found`, `gone_not_found`, `delete_not_found`, `show_robots`, `update_robots`, `test_robots`, `show_settings`, `update_settings`, `list_history`, `show_history`, `undo_change`, `undo_batch`, `export_csv`, `import_csv`, `apply_batch`, `list_issues`
|
||||
- `operations.go` `AdminOperations`; `classes` owner resolution, `Batch`, undo, CSV, transfer token, issues
|
||||
- Controller `golem15.seo.transfer` (AdminSingleton), element `golem15-seo-transfer` (E6), widget action `seo_transfer`, history record actions `undo`, `undo_batch`
|
||||
- Demo tests `TestSEOTabMounted`, `TestSEOToolsCatalog`; `.planning/phases/32-seo-foundation/32-05-demo-mount.patch`
|
||||
- `scripts/check-phase32.sh` (stages self-test, go, plugins, consumers, docker, admin, docs, marker; env `PHASE32_BASE`, `PHASE32_ALLOW_UNMOUNTED`, `PHASE32_REQUIRE_DOCKER`)
|
||||
- Tests `TestMCPSetURLSeoTracer`, `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET`, `TestIssues`, `TestBatch`, `TestUndo`, `TestCSVTransfer`, `TestLoopRefused` (mcp, import)
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32-seo-foundation/32-05-SUMMARY.md` when done with the commit SHAs in all three repositories, the scratch-run PASS lines and the demo working-tree state found at the start.
|
||||
</output>
|
||||
373
.planning/phases/32-seo-foundation/32-06-PLAN.md
Normal file
373
.planning/phases/32-seo-foundation/32-06-PLAN.md
Normal file
@@ -0,0 +1,373 @@
|
||||
---
|
||||
phase: 32-seo-foundation
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 6
|
||||
depends_on: ["32-05"]
|
||||
files_modified:
|
||||
- scripts/check-phase32.sh
|
||||
- modules/cabana/form_extensions_test.go
|
||||
- modules/cabana/record_events_test.go
|
||||
- modules/signpost/content_test.go
|
||||
- ../sm-seo-plugin/classes/resolver_test.go
|
||||
- ../sm-seo-plugin/classes/redirect_graph_test.go
|
||||
- ../sm-seo-plugin/classes/normalize_test.go
|
||||
- ../sm-seo-plugin/classes/robots_value_test.go
|
||||
- ../sm-seo-plugin/classes/robots_test.go
|
||||
- ../sm-seo-plugin/classes/templates_test.go
|
||||
- ../sm-seo-plugin/classes/csv_test.go
|
||||
- ../sm-seo-plugin/classes/owner_test.go
|
||||
- ../sm-seo-plugin/classes/issues_test.go
|
||||
- ../sm-seo-plugin/tests/elements/text.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/robots.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/serp.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/robotstxt.test.ts
|
||||
- ../sm-seo-plugin/tests/elements/transfer.test.ts
|
||||
- ../sm-seo-plugin/go.mod
|
||||
- ../sm-seo-plugin/go.sum
|
||||
- ../sm-seo-plugin/README.md
|
||||
- ../sm-journal-plugin/content_descriptors_test.go
|
||||
- ../sm-journal-plugin/menu_item_types_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- ../sm-demo-app/summer.yaml
|
||||
- ../sm-demo-app/go.work
|
||||
- ../sm-demo-app/go.mod
|
||||
- ../sm-demo-app/go.sum
|
||||
- ../sm-demo-app/.gitmodules
|
||||
- ../sm-demo-app/plugins.gen.go
|
||||
- ../sm-demo-app/main.go
|
||||
- ../sm-demo-app/config/golem15.seo.yaml
|
||||
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
|
||||
- ../sm-demo-app/DEPLOY.md
|
||||
- ../sm-demo-app/plugins/golem15/seo
|
||||
- ../sm-demo-app/plugins/golem15/journal
|
||||
- .planning/phases/32-seo-foundation/32-VALIDATION.md
|
||||
- .planning/phases/32-seo-foundation/deferred-items.md
|
||||
autonomous: false
|
||||
requirements: [SC-5, SC-1, SC-2, SC-3, SC-4, Q-01, D-90, D-91, D-92, D-93, D-98, D-22, D-25, D-09]
|
||||
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The framework change of Phase 32 is strictly additive: every exported declaration of pact, cabana and signpost at the latest release tag exists unchanged at the release commit, and existing plugins compile and behave the same without edits (Jakub, 2026-10-26)"
|
||||
- "Existing consumers build and pass their short tests against the new framework with no change on their side: sm-sitemap-plugin at HEAD, sm-journal-plugin at its commit before Phase 32, and sm-demo-app at its commit before Phase 32 with its pinned submodules"
|
||||
- "The new code has unit tests in this last plan: cabana placement and boot errors, record events, signpost descriptors, the SEO plugin's resolver, redirect graph, normalisation, robots grammar and tester, templates, CSV, owner resolution and issues, Journal descriptors and the element backstops (SC-5)"
|
||||
- "The framework release is the next v0.5.x patch tag, computed from git tag at release time and confirmed by Jakub at the checkpoint; no version number is fixed in advance (SC-5, Q-01)"
|
||||
- "sm-seo-plugin and sm-journal-plugin require that framework tag, are tagged and pushed only after Jakub's go-ahead, and sm-demo-app mounts golem15.seo and bumps Journal in separate commits that pass TestGoModRequiresTags, TestSEOTabMounted and TestSEOToolsCatalog (SC-2, SC-4, SC-5)"
|
||||
- "scripts/check-phase32.sh --all is green with the demo mounted, including the additive and compat stages"
|
||||
- "The release tag gets a summercms.io changelog post the same day, written from changelog-post.py source output without planning IDs"
|
||||
- statement: "E1 counter line at 320px container width: status text and count wrap onto two lines without overlap (UI-SPEC long-text backstop)"
|
||||
verification: backstop
|
||||
- statement: "E2 at 360px: the two segmented groups stack and no segment label is clipped in either language (UI-SPEC long-text backstop)"
|
||||
verification: backstop
|
||||
- statement: "E1 status thresholds: with a mocked measureText, 29/30 characters and 580/581px give short/good/long for both measures (UI-SPEC populated backstop)"
|
||||
verification: backstop
|
||||
- statement: "E2 value grammar: every directive combination round-trips in fixed order, all-inherit emits an empty string, and the Go Decode refuses malformed strings (UI-SPEC populated backstop)"
|
||||
verification: backstop
|
||||
- statement: "E3 effective title: an empty SEO title applies the type template, then the general template, to defaultTitle; truncation cuts at a word boundary within 580px (UI-SPEC populated backstop)"
|
||||
verification: backstop
|
||||
- statement: "E4 lint: each lint rule fires on its sample line and none fires on a clean file (UI-SPEC populated backstop)"
|
||||
verification: backstop
|
||||
- statement: "E6 apply gating: a preview with one refused row disables Apply and shows refusedBlocks (UI-SPEC populated backstop)"
|
||||
verification: backstop
|
||||
- statement: "E3 keyphrase stem matching: grzyby/grzybów match, grzyb/grzbiet do not, diacritics and case ignored, URL hyphens equal spaces (UI-SPEC populated backstop, Q-4)"
|
||||
verification: backstop
|
||||
prohibitions:
|
||||
- statement: "No breaking change to existing framework API: no exported pact, cabana or signpost declaration of the latest tag is removed or changed, and no existing consumer needs an edit to build against the release"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Nothing is pushed, tagged, deployed or posted to the changelog without Jakub's go-ahead at the checkpoint, and no version number is hard-coded before that checkpoint computes it"
|
||||
status: resolved
|
||||
verification: judgment
|
||||
- statement: "The changelog token is never printed, copied or minted"
|
||||
status: resolved
|
||||
verification: judgment
|
||||
artifacts:
|
||||
- path: "scripts/check-phase32.sh"
|
||||
provides: "additive and compat stages added to the phase gate"
|
||||
contains: "compat"
|
||||
- path: "modules/cabana/form_extensions_test.go"
|
||||
provides: "the D-91 unit matrix"
|
||||
contains: "func TestFormExtension"
|
||||
- path: "../sm-seo-plugin/classes/redirect_graph_test.go"
|
||||
provides: "TestRedirectGraph"
|
||||
contains: "func TestRedirectGraph("
|
||||
- path: "../sm-seo-plugin/tests/elements/serp.test.ts"
|
||||
provides: "E3 backstops"
|
||||
contains: "grzybów"
|
||||
- path: ".planning/phases/32-seo-foundation/32-VALIDATION.md"
|
||||
provides: "per-task verification map, nyquist_compliant true"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase32.sh"
|
||||
to: "../sm-journal-plugin"
|
||||
via: "compat stage builds a detached worktree of Journal's pre-phase commit against this framework checkout through a scratch go.work"
|
||||
pattern: "worktree add --detach"
|
||||
- from: "../sm-demo-app/go.mod"
|
||||
to: "../sm-seo-plugin"
|
||||
via: "require git.golem15.com/golem15/sm-seo-plugin at its release tag; submodule plugins/golem15/seo at the same commit (scripts/check-pins.sh)"
|
||||
pattern: "sm-seo-plugin v"
|
||||
---
|
||||
|
||||
<!-- planner-discipline-allow: seo -->
|
||||
|
||||
## Phase Goal
|
||||
|
||||
**As a** site editor on any SummerCMS application, **I want to** per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, **so that** every site ships correct, editable SEO without per-site work.
|
||||
|
||||
This plan proves the phase with unit tests, proves the framework change is additive, and releases it.
|
||||
|
||||
<objective>
|
||||
Write the phase's unit tests (project rule: unit tests are the last plan) per the 32-VALIDATION.md map, add the `additive` and `compat` stages to `scripts/check-phase32.sh` (Jakub, 2026-10-26: the framework release is a patch and must be strictly additive), then, after Jakub's go-ahead, tag the framework with the next v0.5.x patch computed at release time (Q-01: Phase 33 builds on this tag, not on a `replace`), release sm-seo-plugin and sm-journal-plugin on that tag, mount the plugin in sm-demo-app with the plan 32-05 patch, and post the changelog. The framework names no SEO concept and no consuming application (D-90); the seams D-91, D-92 and D-93 ship as published API (D-98 plan order).
|
||||
|
||||
Purpose: SC-5 (the framework release with the seams is tagged, and the new code has unit tests delivered in the phase's last plan), and the final evidence for SC-1 to SC-4.
|
||||
|
||||
Output: test files in three repositories, the gate stages, the updated VALIDATION.md, tags and pushes (after the go-ahead), demo commits, the changelog post.
|
||||
|
||||
**Phase 33 boundary (Q-01).** These decisions are not implemented in Phase 32 and are explicitly deferred to Phase 33 ("SEO output, audit and content"); Phase 32 leaves room for them as noted:
|
||||
- D-30, D-31, D-32, D-34 (sitemap rewrite, image/News/IndexNow, HTML sitemap) and D-95 (the sitemap filter interface sm-seo-plugin will implement, with `Requires()` on Golem15.Sitemap); D-24's sitemap consistency (noindex, canonicalised and redirected pages leave the sitemaps; the "sitemap URL blocked" warning). Room: signpost content descriptors list records for sitemaps; the plugin's batch dry-run already flags noindex on published records' URLs. The reviewed todo `sitemap-xslt-browser-deprecation.md` stays with that rewrite.
|
||||
- D-33 (page registry; it registers its types through the same D-93 descriptors).
|
||||
- D-40, D-41, D-42 (pagination); D-43, D-44, D-45, D-46 (JSON-LD, schema type and custom JSON-LD fields, publisher settings, validation) and D-03's organisation/person data; room: the entry's `data` jsonb and the reserved SEO tab anchors `seo_robots` and `seo_og_image`.
|
||||
- D-50, D-51, D-52 (headings, including Journal's H1 conversion from D-96) and D-96's Journal schema types.
|
||||
- D-55, D-56, D-57 (hreflang in head and sitemaps); D-58's Journal translated-slug fix is delivered in Phase 32 (plan 32-02).
|
||||
- D-70, D-71, D-72 (SSR guarantees and sm-bm-app's noindex).
|
||||
- D-80, D-81, D-82, D-83 (internal linking), D-85, D-86 (media SEO, breadcrumbs and the primary-category field).
|
||||
- D-97 (the shared Nuxt SEO layer): it consumes the resolve answer of Phase 32, reads its route patterns from the CMS so strict URLs cannot drift from the frontend routes (RESEARCH Pitfall 15; Phase 32 only adds a drift check in the gate), enforces redirects, normalisation and strict URLs on frontend routes (D-20a, D-103), maps verification files at the site root, sends X-Robots-Tag on frontend pages, and marks signed-in routes noindex automatically (D-23's frontend half).
|
||||
- D-14's crawler half (Phase 32 ships the CMS-data issue report) and D-25's optional llms.txt.
|
||||
- Deferred ideas stay out of both phases: social card preview, per-redirect hit counters and validity dates, 307/308, e-commerce structured data.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/32-seo-foundation/32-CONTEXT.md
|
||||
@.planning/phases/32-seo-foundation/32-VALIDATION.md
|
||||
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
|
||||
@.planning/phases/32-seo-foundation/32-01-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
|
||||
@.planning/phases/32-seo-foundation/32-05-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
@../CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
Release facts: framework tags are vX.Y.Z on master (`git tag --sort=-v:refname`); other sessions may tag before this plan runs, so the version is computed at Task 4, never earlier. sm-journal-plugin is on branch main; sm-seo-plugin on main with an empty origin until its first push. Demo rules: go_mod_test.go TestGoModRequiresTags (no replace, every golem15 requirement a canonical tag, every summer.yaml plugin required); scripts/check-pins.sh (submodule commits equal the required versions). Changelog: `../sm-summercmsio-app/scripts/changelog-post.py source vX.Y.Z` prints the tag message and commits since the previous tag; `publish vX.Y.Z notes.md --excerpt "..."` posts; the token is read from ~/.config/summercms/journal-token.
|
||||
Pre-phase commits: the framework base SHA and the Journal SHA before plan 32-02's first commit are recorded in 32-01-SUMMARY.md and 32-02-SUMMARY.md; the demo SHA before plan 32-05's test commit is in 32-05-SUMMARY.md.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end compatibility proof: the additive and compat gate stages build existing consumers unchanged against the new framework — one path through every repository</name>
|
||||
<files>scripts/check-phase32.sh</files>
|
||||
<read_first>
|
||||
- scripts/check-phase32.sh (plan 32-05)
|
||||
- scripts/check-phase19.7.sh (the additive check and the scratch go.work technique for consumers)
|
||||
- ../sm-journal-plugin/go.work and ../sm-demo-app/go.work (replace lines to mirror in scratch go.work files)
|
||||
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md, 32-02-SUMMARY.md, 32-05-SUMMARY.md (pre-phase SHAs)
|
||||
</read_first>
|
||||
<action>
|
||||
Per the strictly-additive rule (Jakub, 2026-10-26), D-91, D-92, D-93:
|
||||
1. Stage `additive`: `LAST_TAG=$(git tag --sort=-v:refname | head -1)`; `git worktree add --detach "$T/base" "$LAST_TAG"`; for each of `./modules/pact`, `./modules/cabana`, `./modules/signpost` run `go -C "$T/base" doc -all <pkg>` and `go doc -all <pkg>`, keep declaration lines only (lines starting with `func `, `type `, `var `, `const `, and tab-indented member lines inside type, var and const blocks; doc-comment lines are indented with four spaces and are ignored), and refuse with `additive: removed or changed in <pkg>: <line>` for every base declaration line missing verbatim from HEAD's output; remove the worktree on exit.
|
||||
2. Stage `compat`: (a) `../sm-sitemap-plugin` must have no change since the phase base (`git status --porcelain` empty and HEAD equal to the SHA recorded at stage start of the phase, or simply no commit since the base date), then `go -C ../sm-sitemap-plugin build ./... && go -C ../sm-sitemap-plugin test -short ./...`; (b) `git -C ../sm-journal-plugin worktree add --detach "$T/journal" <pre-phase Journal SHA>` and a scratch go.work with absolute paths (use the worktree; replace summercms with this checkout, sm-translate-plugin and sm-user-plugin as in Journal's go.work), then `GOWORK=... go -C "$T/journal" build ./... && go test -short ./...`; (c) `git -C ../sm-demo-app worktree add --detach "$T/demo" <pre-phase demo SHA>`, then for each gitlink under plugins/golem15 in that commit (`git -C "$T/demo" ls-tree HEAD plugins/golem15/`) `git -C ../sm-demo-app/<path> worktree add --detach "$T/sub/<name>" <pinned SHA>`, a scratch go.work using `$T/demo` and every `$T/sub/<name>` with the demo's replace lines remapped and summercms replaced by this checkout; `go build ./...`, `go build -tags summercms_mcp ./...`, `go test -short ./...` in `$T/demo`. Every worktree is removed on exit (trap). Print `compat: ok <repo>` per consumer.
|
||||
3. Add both stages to `--all` and to `--self-test` (plant a removed declaration line in a fake doc output and expect a refusal). Run them now; if `additive` refuses a line that another session changed (not Phase 32), stop and report it to Jakub with the line and the commit that changed it (`git log -S`), because a patch tag cannot carry it.
|
||||
Commit `test(32-06): additive and compat stages in check-phase32`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase32.sh --self-test && bash scripts/check-phase32.sh --additive --compat</automated>
|
||||
<fails_when>non-zero exit, an "additive: removed or changed" line, a missing "compat: ok" line for sm-sitemap-plugin, sm-journal-plugin or sm-demo-app, or "FAIL" in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash scripts/check-phase32.sh --additive --compat 2>&1 | grep -c "compat: ok"` prints 3
|
||||
- `git -c safe.directory='*' -C ../sm-journal-plugin worktree list | wc -l` and the same for sm-demo-app print 1 after the run (no leftover worktrees)
|
||||
- The self-test proves the additive stage refuses a planted removed declaration
|
||||
</acceptance_criteria>
|
||||
<done>The gate proves end to end that the framework change is additive and that existing consumers build and test unchanged against it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Framework unit tests: form-extension matrix, record-event diff, content descriptors</name>
|
||||
<files>modules/cabana/form_extensions_test.go, modules/cabana/record_events_test.go, modules/signpost/content_test.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/32-seo-foundation/32-VALIDATION.md (test map SC-1 to SC-3)
|
||||
- modules/cabana/form_extensions.go, form_extensions_smoke_test.go, form_extensions_postgres_test.go (plan 32-01)
|
||||
- modules/cabana/record_events.go, record_events_postgres_test.go (plan 32-02)
|
||||
- modules/signpost/content.go, content_test.go (plan 32-02)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Placement table (pure, no DB): A,B + C after A; C before first; C no anchor; two tabs + new tab between; tab-less + new tab; three plugins on one anchor in Requires order; anchor on another extension's field inside and outside Requires; hide one field; hide every field of a tab; span left after left starts a new row index; an interleaved owner list canonicalised only when extended
|
||||
- Boot error table: every error of the plan 32-01 behaviour list with the exact message fragments (plugin, controller, anchor)
|
||||
- Record-event diff (pure): snapshot diff of scalar columns, nil to value on create, translated per-locale changes, unchanged columns omitted
|
||||
- Descriptors: MatchPattern edge cases (trailing slash, encoded segments, extra and missing segments, empty segment), MatchPath ordering, duplicate Model/Controller boot errors, types without Content unchanged
|
||||
</behavior>
|
||||
<action>
|
||||
Write the exhaustive unit matrices named in 32-VALIDATION.md (`TestFormExtension*`, `TestFormExtensionBootErrors`, `TestRecordEventDiff`, `TestContent*`) as table tests per the behaviour list (SC-1, D-91, D-92, D-93). Pure tests run under `-short`; Docker-backed cases stay in the existing `_postgres_test.go` files. Fix any defect the tests expose in the same task with its own `fix(32-06): ...` commit. Commit tests as `test(32-06): framework unit matrices for form extensions, record events and descriptors`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short ./modules/cabana ./modules/signpost -run 'TestFormExtension|TestRecordEventDiff|TestContent' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestFormExtensionBootErrors, TestRecordEventDiff or a TestContent test missing from the "--- PASS" lines</fails_when>
|
||||
<automated>go vet ./... && go test -count=1 ./modules/cabana ./modules/signpost ./modules/pact</automated>
|
||||
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -short ./modules/cabana -run TestFormExtensionBootErrors -v | grep -c -- "--- PASS"` is at least 15
|
||||
- `go test -short ./modules/cabana -run TestFormExtensionPlacement -v | grep -c -- "--- PASS"` is at least 11
|
||||
- `go vet ./... && go test -short ./...` exits 0 in the framework
|
||||
</acceptance_criteria>
|
||||
<done>The framework seams carry exhaustive unit coverage for placement, boot errors, event diffs and descriptors.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Plugin unit tests (sm-seo-plugin classes, element backstops, Journal) and the completed VALIDATION.md</name>
|
||||
<files>../sm-seo-plugin/classes/resolver_test.go, ../sm-seo-plugin/classes/redirect_graph_test.go, ../sm-seo-plugin/classes/normalize_test.go, ../sm-seo-plugin/classes/robots_value_test.go, ../sm-seo-plugin/classes/robots_test.go, ../sm-seo-plugin/classes/templates_test.go, ../sm-seo-plugin/classes/csv_test.go, ../sm-seo-plugin/classes/owner_test.go, ../sm-seo-plugin/classes/issues_test.go, ../sm-seo-plugin/tests/elements/text.test.ts, ../sm-seo-plugin/tests/elements/robots.test.ts, ../sm-seo-plugin/tests/elements/serp.test.ts, ../sm-seo-plugin/tests/elements/robotstxt.test.ts, ../sm-seo-plugin/tests/elements/transfer.test.ts, ../sm-journal-plugin/content_descriptors_test.go, ../sm-journal-plugin/menu_item_types_test.go, .planning/phases/32-seo-foundation/32-VALIDATION.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/32-seo-foundation/32-VALIDATION.md and 32-UI-SPEC.md (UI Considerations backstop rows, C4, C5, C6, C10, C13)
|
||||
- ../sm-seo-plugin/classes/*.go and assets/src/elements/*.ts
|
||||
- ../sm-journal-plugin/tests/ (vitest setup precedent) and ../sm-seo-plugin/vite.config.ts
|
||||
- Google's robots.txt documentation examples cited in 32-RESEARCH.md (longest match, allow wins ties, $ anchor, * wildcard)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestResolverPrecedence (full matrix): every value chain of plan 32-03 P-14 with each layer present and absent, sources asserted; staging lock; draft not found
|
||||
- TestRedirectGraph: flatten A→B,B→C ⇒ A→C; back and forth A→B→A→C ⇒ A→C and B→C; loop refusal for exact, pattern probe and regex probe; chain notice; 10-hop runtime stop
|
||||
- TestNormalizeMatrix, TestRobotsValueRoundTrip (every directive combination; malformed refused), TestRobotsMatcherGoogleExamples, TestTemplates (unknown placeholder refused), TestCSV (BOM, delimiters, quoting, formula prefix), TestOwnerResolution (record, rule, force_rule, locale prefix), TestIssues (each issue kind)
|
||||
- Element backstops: text.test.ts (thresholds 29/30 chars and 580/581 px with mocked measureText for both measures; 320px wrap without overlap), robots.test.ts (grammar round-trip; all-inherit empty; 360px stacking with en and pl labels unclipped), serp.test.ts (effective title through type template then general template; word-boundary truncation within 580px; stem matching grzyby/grzybów yes, grzyb/grzbiet no, diacritics and case ignored, hyphens equal spaces), robotstxt.test.ts (each lint rule on its sample line; clean file has none), transfer.test.ts (one refused row disables Apply and shows refusedBlocks; zero changes shows nothing)
|
||||
- Journal: descriptor matrix for post, category, tag (Describe per locale, Match exact/non-exact, Records paging), journal-tag type with tag_pages on and off, translated slug lookup
|
||||
</behavior>
|
||||
<action>
|
||||
Write the unit tests per the behaviour list (SC-2, SC-3, SC-4, D-09, D-22, D-25 and the UI-SPEC backstops); pure Go tests run under `-short`. Element tests run with `npm --prefix ../sm-seo-plugin run test` (vitest + happy-dom, `tests/**/*.test.ts`). Fix defects in their own commits. Then update `32-VALIDATION.md`: the per-task verification map (every task of plans 32-01 to 32-06 with its automated command and status), Wave 0 items checked, `nyquist_compliant: true`, `wave_0_complete: true`, `status: validated` only if the full gate is green; commit it separately as a planning-docs commit. Plugin commits in each repository staged by path.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-seo-plugin test -short ./classes -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
|
||||
<fails_when>a "FAIL" line, "no tests to run", or TestResolverPrecedence, TestRedirectGraph, TestNormalizeMatrix, TestRobotsValueRoundTrip, TestRobotsMatcherGoogleExamples, TestTemplates, TestCSV, TestOwnerResolution or TestIssues missing from the "--- PASS" lines</fails_when>
|
||||
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -15</automated>
|
||||
<fails_when>non-zero exit, a failed, skipped or todo test, or a missing passing line for any of text, robots, serp, robotstxt, transfer test files</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./... -run 'TestContentDescriptors|TestJournalTagType|TestTranslatedSlug|TestAPIRecordEvents' -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
|
||||
<fails_when>non-zero exit, a "FAIL" line, or any of the four names missing from the "--- PASS" lines</fails_when>
|
||||
<automated>grep -c "nyquist_compliant: true" .planning/phases/32-seo-foundation/32-VALIDATION.md</automated>
|
||||
<fails_when>the count is 0</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "grzybów" ../sm-seo-plugin/tests/elements/serp.test.ts` prints at least one line
|
||||
- `grep -c "it(\|test(" ../sm-seo-plugin/tests/elements/*.test.ts` totals at least 20
|
||||
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` exits 0 with Docker
|
||||
- 32-VALIDATION.md lists every plan task with an automated command
|
||||
</acceptance_criteria>
|
||||
<done>The plugin, its elements and Journal's new code are unit-tested per the validation map, and VALIDATION.md is complete.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 4: Release go-ahead: confirm the framework patch version, the plugin versions and the push list</name>
|
||||
<reversibility rating="one-way">A pushed tag is pinned by plugin go.mod files and application submodules; Phase 33 builds on it, and the seams become published API.</reversibility>
|
||||
<decision>Release Phase 32 now with the computed versions, and which remote operations are approved?</decision>
|
||||
<context>Before asking, the executor runs and reports: `git fetch --tags origin` in the framework, sm-journal-plugin and sm-seo-plugin; `git tag --sort=-v:refname | head -3` in the framework; the next patch = the newest framework tag with its patch number plus one (v0.5.x today; a later 0.5.x if another session tagged meanwhile; only the third number moves); Journal's next patch from its newest tag; sm-seo-plugin's first version following the sibling plugins' first-release convention; `bash scripts/check-phase32.sh --all` with `PHASE32_ALLOW_UNMOUNTED=1` (green except the mount, which Task 5 completes); clean trees in the three repositories apart from other sessions' known uncommitted demo entries (listed). The push list it presents: framework master and the tag; sm-journal-plugin main and its tag; sm-seo-plugin main (first push) and its tag; then local demo commits (apply the 32-05 patch, add the seo submodule, bump Journal, require the framework tag), pushed only if named; the summercms.io changelog post for the framework tag; optional Journal bumps in sm-grzybyfunkcjonalne-app (with `golem15.journal.tag_pages: false`, its layer has no tag pages) and sm-summercmsio-app. Nothing is deployed in any option.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Release with the computed versions (Jakub names or corrects each number in the reply)</name>
|
||||
<pros>Phase 33 can build on a tagged framework (Q-01); the demo mounts the plugin; SC-5 closes</pros>
|
||||
<cons>One-way: tags and the seams' API are published</cons>
|
||||
</option>
|
||||
<option id="release-no-apps">
|
||||
<name>Release framework and plugins; keep every application change local (no app pushes, no optional app bumps)</name>
|
||||
<pros>No application repository leaves this machine</pros>
|
||||
<cons>The demo stays unpushed until a later go-ahead</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold: no tag, no push</name>
|
||||
<pros>Lets Jakub review the seams' API first</pros>
|
||||
<cons>SC-5 stays open; the demo cannot mount the plugin (TestGoModRequiresTags); recorded in deferred-items.md</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Answer release, release-no-apps or hold, with the confirmed version numbers</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Tag, push and pin per the go-ahead; mount the plugin in the demo; full gate; changelog post</name>
|
||||
<precondition>Task 4 answered release or release-no-apps with confirmed version numbers; on hold, only write deferred-items.md and stop</precondition>
|
||||
<files>../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/README.md, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/.gitmodules, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/main.go, ../sm-demo-app/config/golem15.seo.yaml, ../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/DEPLOY.md, ../sm-demo-app/plugins/golem15/seo, ../sm-demo-app/plugins/golem15/journal, .planning/phases/32-seo-foundation/deferred-items.md</files>
|
||||
<read_first>
|
||||
- The Task 4 answer
|
||||
- ../CLAUDE.md and CLAUDE.md (commits, concurrency, release and changelog rules)
|
||||
- ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog")
|
||||
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch
|
||||
- .planning/phases/19.7-generic-mcp-module/19.7-04-PLAN.md (release task: pre-checks, annotated tags, pin commits)
|
||||
</read_first>
|
||||
<action>
|
||||
In this order, with the exact versions from Task 4 (SC-5, Q-01):
|
||||
1. Framework: if `admin/` changed since the last tag, `npm --prefix admin run build`, commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh` green; annotated tag `git tag -a <framework version>` with a message summarising the three seams in user-facing words; `git push origin master` and the tag.
|
||||
2. sm-journal-plugin: require the framework tag in go.mod (`GOWORK=off go get git.golem15.com/golem15/summercms@<tag>` with GOPRIVATE set as the demo's check-pins.sh does), `GOWORK=off go build ./... && GOWORK=off go test -short ./...`, commit `chore: require SummerCMS <tag>`, annotated tag, push main and the tag.
|
||||
3. sm-seo-plugin: the same require, README dependency line, commit, annotated tag, `git push -u origin main` and the tag.
|
||||
4. sm-demo-app (only paths from this plan; never other sessions' uncommitted entries): first commit `chore: require SummerCMS <tag>` (go.mod, go.sum); then the Journal bump: move `plugins/golem15/journal` to the Journal tag only when its current HEAD is an ancestor of that tag (else stop and ask: the submodule carries foreign work), require the Journal tag, commit `chore: bump journal to <version>` (go.mod, go.sum, the submodule pointer); then the mount: `git apply` the 32-05 patch, `git submodule add -b main git@git.golem15.com:golem15/sm-seo-plugin.git plugins/golem15/seo` checked out at its tag, `use ./plugins/golem15/seo` in go.work, require the sm-seo-plugin tag, `go run ../summercms.go/cmd/summer build` to regenerate plugins.gen.go and main.go, then `go build ./... && go build -tags summercms_mcp ./... && go test -short ./... && go test -short -tags summercms_mcp -run 'TestSEOToolsCatalog|TestGoModRequiresTags|TestSEOTabMounted' .` and `scripts/check-pins.sh`; commit `feat: mount golem15.seo <version>`. Push the demo only if the answer named it. Optional app bumps only if named, one commit per app.
|
||||
5. `bash scripts/check-phase32.sh --all` (mounted, Docker) must be green; record the output summary.
|
||||
6. Changelog (same day, from `../sm-summercmsio-app`): `scripts/changelog-post.py source <tag>`, write user-facing notes with `## Highlights`, `## Added`, `## Changed`, `## Fixed` (no planning IDs, no quick-task codes, no H1) to a scratch file, `scripts/changelog-post.py publish <tag> <notes> --excerpt "..."`. Never print or copy the token; if the token file is missing or refused, stop and ask Jakub.
|
||||
7. Anything not done (hold, refused push, unnamed apps) goes to `.planning/phases/32-seo-foundation/deferred-items.md` with the exact next command; STATE.md updates go through `msd_run query state.*` handlers only, and a short g15office comment on the Phase 32 task records the release (`.planning/G15OFFICE.md`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>git describe --tags --exact-match HEAD 2>/dev/null || git tag --points-at HEAD</automated>
|
||||
<fails_when>empty output (no framework tag on the release commit) when Task 4 answered release or release-no-apps</fails_when>
|
||||
<automated>bash scripts/check-phase32.sh --all</automated>
|
||||
<fails_when>non-zero exit, a "FAIL", "refuse:", "NOT MOUNTED" or "additive: removed or changed" line in the output</fails_when>
|
||||
<automated>go -C ../sm-demo-app test -short -tags summercms_mcp -run 'TestGoModRequiresTags|TestSEOTabMounted|TestSEOToolsCatalog' -count=1 -v . 2>&1 | grep -E '^(--- |FAIL|ok)'</automated>
|
||||
<fails_when>a "FAIL" or "SKIP" line, or any of the three names missing from the "--- PASS" lines</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin <framework tag>` prints one line, and the tag keeps the major and middle numbers of the previous newest tag with the last number one higher (a patch release)
|
||||
- `grep -n "git.golem15.com/golem15/summercms <framework tag>" ../sm-seo-plugin/go.mod ../sm-journal-plugin/go.mod` prints a line in each
|
||||
- `grep -n "golem15.seo" ../sm-demo-app/summer.yaml` prints one line and `bash ../sm-demo-app/scripts/check-pins.sh` prints `check-pins: ok`
|
||||
- The changelog post URL for the tag is recorded in 32-06-SUMMARY.md
|
||||
- Every demo commit of this task lists only this plan's paths (`git show --stat`)
|
||||
</acceptance_criteria>
|
||||
<done>The framework patch release carrying the three seams is tagged and announced, both plugins are released on it, the demo mounts the SEO plugin, and the full phase gate is green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| local repositories → git.golem15.com | tags and branches published to consumers |
|
||||
| release script → summercms.io changelog | authenticated post with a scoped token |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-32-30 | Tampering | published framework API (patch tag) | high | mitigate | additive stage refuses any removed or changed exported declaration of pact, cabana and signpost; compat stage builds existing consumers unchanged |
|
||||
| T-32-31 | Repudiation | pushes and tags without consent | high | mitigate | Task 4 blocking decision checkpoint; Task 5 performs exactly the named operations and records each in the SUMMARY |
|
||||
| T-32-32 | Information disclosure | changelog token | high | mitigate | changelog-post.py reads ~/.config/summercms/journal-token itself; the executor never prints, copies or mints it and asks Jakub on a missing or refused token |
|
||||
| T-32-33 | Tampering | demo submodules carrying foreign work | medium | mitigate | the Journal submodule moves only when its HEAD is an ancestor of the release tag; other sessions' uncommitted entries are never staged |
|
||||
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new package; tests use the toolchain approved at plan 32-03 Task 4 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase32.sh --all` green with Docker and the demo mounted (or the hold recorded in deferred-items.md).
|
||||
- `go vet ./... && go test ./...` green in the framework, sm-seo-plugin, sm-journal-plugin and sm-demo-app; `npm --prefix ../sm-seo-plugin run test` green.
|
||||
- 32-VALIDATION.md `nyquist_compliant: true`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
SC-5: the framework release with the seams is tagged as the next v0.5.x patch, it is strictly additive, and the new code has unit tests delivered in this last plan; SC-1 to SC-4 are proven by the gate and the demo.
|
||||
</success_criteria>
|
||||
|
||||
## Artifacts this phase produces (plan 06)
|
||||
|
||||
- `scripts/check-phase32.sh` stages `additive` and `compat`
|
||||
- Unit tests: `modules/cabana/form_extensions_test.go`, `modules/cabana/record_events_test.go`, `modules/signpost/content_test.go`; `../sm-seo-plugin/classes/*_test.go` (TestResolverPrecedence, TestRedirectGraph, TestNormalizeMatrix, TestRobotsValueRoundTrip, TestRobotsMatcherGoogleExamples, TestTemplates, TestCSV, TestOwnerResolution, TestIssues); `../sm-seo-plugin/tests/elements/{text,robots,serp,robotstxt,transfer}.test.ts`; Journal descriptor, tag-type and slug tests
|
||||
- Updated `32-VALIDATION.md`; `deferred-items.md` when anything is held
|
||||
- Tags (after the go-ahead): the framework's next v0.5.x patch, sm-journal-plugin's next patch, sm-seo-plugin's first release
|
||||
- sm-demo-app commits: framework pin, Journal bump, golem15.seo mount (summer.yaml, go.work, go.mod, .gitmodules, plugins.gen.go, main.go, config/golem15.seo.yaml, nginx `location = /robots.txt`, DEPLOY.md)
|
||||
- The summercms.io changelog post for the framework tag
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32-seo-foundation/32-06-SUMMARY.md` when done with the Task 4 answer, every version, tag, push and commit SHA, the gate output summary and the changelog URL.
|
||||
</output>
|
||||
1
.planning/phases/32-seo-foundation/COVERAGE.md
Normal file
1
.planning/phases/32-seo-foundation/COVERAGE.md
Normal file
@@ -0,0 +1 @@
|
||||
No external API integration: Phase 32 adds framework seams and a plugin that serves its own JSON API and MCP tools through the in-house pier module; its one new dependency (github.com/jimsmart/grobotstxt) is an in-process library, and the detector's "api" signal is Journal's own /_journal/api/v1, not a third-party service.
|
||||
Reference in New Issue
Block a user