diff --git a/.planning/phases/32-seo-foundation/32-02-SUMMARY.md b/.planning/phases/32-seo-foundation/32-02-SUMMARY.md new file mode 100644 index 00000000..c1bb0d6c --- /dev/null +++ b/.planning/phases/32-seo-foundation/32-02-SUMMARY.md @@ -0,0 +1,294 @@ +--- +phase: 32-seo-foundation +plan: 02 +subsystem: framework-seams +tags: [pact, cabana, signpost, record-events, content-descriptors, d-92, d-93, journal] + +requires: + - phase: 32-01 + provides: pact.AdminFormSave and the form extension Save in the record's save transaction (D-91) +provides: + - "pact.RecordChange, pact.RecordSaved, pact.RecordDeleted fired as pointers on app.Events after commit (D-92)" + - "pact.AdminFormSave.Changes: an extension's Save sees the owner's changes inside the save transaction" + - "cabana.EmitRecordSaved / cabana.EmitRecordDeleted for direct writers; cabana emits from create, update, delete, bulk delete, tree reorder and relation-manager child writes" + - "pact.ContentDescriptor (+ ContentList, ContentPage, ContentDescribe, ContentRecord, ContentLocale, ContentMatch, ContentMatchResult) and pact.MenuItemType.Content (D-93)" + - "signpost: ContentTypes, ContentType, ContentTypeForController, ContentTypeForModel, Describe, Records, MatchPath and the pure MatchPattern; duplicate Model/Controller and missing Model/Describe stop the boot" + - "Journal (sm-journal-plugin): descriptors for journal-post, journal-category and the new journal-tag type, record events from /_journal/api/v1 Store/Update/Destroy (D-102), translated-slug lookup (D-58), host key golem15.journal.tag_pages" +affects: [32-03, 32-04, 32-05, 32-06, sm-seo-plugin, sm-sitemap-plugin] + +actuals: + tokens: 35953 + tasks: 4 + commits: 3 +plan_head_before: 1b0421de814404d4bf904a754bf997badfd4349c +plan_head_after: 785902c612228bdf1bc0392fff05a1cbbd84a59f + +tech-stack: + added: [] + patterns: + - "Record events: snapshot the row (GORM schema columns minus PK and auto timestamps) plus TranslatedExact of submitted ML fields before the write, diff after, emit through lagoon.AfterCommit" + - "Content descriptors read through cabana.TxFromContext when a write transaction is in ctx" + - "Strict URL matching: read the locale prefix, MatchPattern the rest, find the record, compare the path with the record's own alternate URL (exact) else inexact" + +key-files: + created: + - modules/pact/record_events.go + - modules/pact/content.go + - modules/cabana/record_events.go + - modules/cabana/record_events_postgres_test.go + - modules/cabana/example_record_events_test.go + - modules/signpost/content.go + - modules/signpost/content_test.go + - modules/signpost/example_content_test.go + - ../sm-journal-plugin/classes/content_descriptors.go + - ../sm-journal-plugin/controllers/api/record_events.go + - ../sm-journal-plugin/record_events_test.go + - ../sm-journal-plugin/content_descriptors_test.go + - ../sm-journal-plugin/translated_slug_test.go + modified: + - modules/pact/capabilities.go + - modules/pact/README.md + - modules/cabana/crud.go + - modules/cabana/form_extensions.go + - modules/cabana/http.go + - modules/cabana/relation.go + - modules/cabana/relation_child.go + - modules/cabana/reorder.go + - modules/cabana/README.md + - modules/signpost/signpost.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/menu_item_types_test.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 + +key-decisions: + - "Task 1 (D-92/D-93 Go shapes), answered by Jakub before dispatch: as-planned. P-07 to P-10 are published as written, including the optional Match and signpost.MatchPath" + - "RecordChange.Locale is \"\" for the record's own row (the default locale of a translatable field) and the locale code for a translated value; values are dereferenced (nil pointer -> nil) and times compare as instants" + - "Changes leave out the primary key and GORM auto timestamps, so a no-op update fires RecordSaved with an empty Changes list; on create they list every column with a non-zero value and every non-empty submitted translation, Old nil" + - "Reorder fires one RecordSaved per moved node only (parent and/or sort column changed), values converted to the model's column types" + - "Relation-manager child events name the one admin controller whose NewRecord type is the child's model (\"\" when none or several); API and other direct writes carry Controller \"\"" + - "signpost.Registry.Records answers an empty page for a content type without Records; MatchPath returns the first exact match in registration order, else the first inexact one; a Match error is returned prefixed signpost:" + - "signpost.MatchPattern matches whole segments only; a placeholder never matches an empty segment; a placeholder used twice must match the same value; segments are compared as given (caller decodes)" + - "Journal descriptors describe drafts too (Published false); Records page in id order capped at 500; Match never matches drafts/future posts and is exact only on the record's own URL in the path's locale" + - "journal-tag with golem15.journal.tag_pages false: Describe answers empty URLs, Match never matches and Resolve resolves to nothing" + - "D-58: findPostBySlugOrID(db, slug, locale) tries the default slug, then translate's slug index for the request locale (towel, first language without region), then the id" + +patterns-established: + - "cabana.EmitRecordSaved(ctx, app, tx, ev): tx = the write's lagoon.Transaction handle, or nil after commit (fires at once)" + - "A content type's Match: signpost.MatchPattern on the path without its locale prefix, find by slug-in-locale/default slug/other-locale slug/id, exact iff path == own alternate URL" + +requirements-completed: [SC-2, SC-3, D-92, D-93, D-96, D-102, D-58, D-90, D-18, D-20c, D-11] + +coverage: + - id: D1 + description: "Tracer: an admin slug change on a Journal post fires one RecordSaved after commit (Controller, Model = Post morph, default and pl slug changes); signpost ContentTypeForModel + Describe return /blog/; a failed save fires nothing" + requirement: "SC-3" + verification: + - kind: integration + ref: "../sm-journal-plugin/record_events_test.go#TestRecordSavedDescribesNewURL" + status: pass + human_judgment: false + - id: D2 + description: "Every cabana write path emits after commit: create (all columns + translations, Old nil), update, translated slug per locale, no-op (empty Changes), rollback on validation/FormBeforeUpdate/extension Save errors fires nothing, failing listener never fails the save, reorder, child create/update/delete, delete, bulk delete (partial selection fires nothing); extension Save receives the same Changes" + requirement: "D-92" + verification: + - kind: integration + ref: "modules/cabana/record_events_postgres_test.go#TestRecordEvents" + status: pass + - kind: unit + ref: "modules/cabana/example_record_events_test.go#ExampleEmitRecordSaved" + status: pass + human_judgment: false + - id: D3 + description: "Journal /_journal/api/v1 POST/PUT/DELETE fire RecordSaved (Created, slug changes per locale) and RecordDeleted with Controller \"\"" + requirement: "D-102" + verification: + - kind: integration + ref: "../sm-journal-plugin/record_events_test.go#TestAPIRecordEvents" + status: pass + human_judgment: false + - id: D4 + description: "signpost content lookups, Records, boot errors (no Model, no Describe, duplicate Model/Controller), MatchPattern rules and MatchPath precedence; a type without Content resolves as before" + requirement: "D-93" + verification: + - kind: unit + ref: "modules/signpost/content_test.go#TestContentLookups" + status: pass + - kind: unit + ref: "modules/signpost/content_test.go#TestContentBootErrors" + status: pass + - kind: unit + ref: "modules/signpost/content_test.go#TestContentMatchPattern" + status: pass + - kind: unit + ref: "modules/signpost/content_test.go#TestContentMatchPath" + status: pass + - kind: unit + ref: "modules/signpost/example_content_test.go#ExampleRegistry_MatchPath" + status: pass + human_judgment: false + - id: D5 + description: "Journal post/category/tag descriptors: Describe per locale, Records paging, strict Match (wrong category, stale or wrong locale prefix, other-locale slug inexact; drafts never match); journal-tag type with tag_pages switch" + requirement: "D-20c" + verification: + - kind: integration + ref: "../sm-journal-plugin/content_descriptors_test.go#TestContentDescriptors" + status: pass + - kind: integration + ref: "../sm-journal-plugin/content_descriptors_test.go#TestJournalTagType" + status: pass + human_judgment: false + - id: D6 + description: "D-58: GET /_journal/api/v1/posts/ with Accept-Language pl resolves the post; default slug and id still resolve; unknown slug 404" + requirement: "D-58" + verification: + - kind: integration + ref: "../sm-journal-plugin/translated_slug_test.go#TestTranslatedSlug" + status: pass + human_judgment: false + - id: D7 + description: "Strictly additive (D-106): framework vet + go test -short green, full cabana/signpost/pact/lagoon Docker suites green, whole Journal suite green, sm-sitemap-plugin builds and tests unchanged, sm-demo-app builds unchanged; Journal imports neither sm-seo-plugin nor sm-sitemap-plugin" + requirement: "SC-2" + verification: + - kind: other + ref: "go vet ./... && go test -short ./... (summercms.go); go test ./modules/cabana ./modules/signpost ./modules/pact ./modules/lagoon/..." + status: pass + - kind: other + ref: "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./..." + status: pass + - kind: other + ref: "go -C ../sm-sitemap-plugin build ./... && go -C ../sm-sitemap-plugin test -short ./...; go -C ../sm-demo-app build ./..." + status: pass + human_judgment: false + +duration: 37min +completed: 2026-10-26 +status: complete +--- + +# Phase 32 Plan 02: Record events and content descriptors Summary + +**D-92 record events (pact.RecordSaved/RecordDeleted with per-locale old/new changes, fired after commit from every cabana write path and the Journal API) and D-93 content descriptors (pact.ContentDescriptor in signpost with Describe, Records and strict MatchPath), adopted by Journal for posts, categories and a new journal-tag type, plus the D-58 translated-slug fix** + +## Task 1 answer + +Task 1 (checkpoint:decision on the D-92/D-93 Go shapes) was answered by Jakub before dispatch: **as-planned**. P-07 to P-10 are adopted as written, including the optional `Match` and `signpost.Registry.MatchPath`. The executor did not stop at Task 1. + +## Performance + +- **Duration:** 37 min +- **Started:** 2026-10-26T21:11:36Z +- **Completed:** 2026-10-26T21:49:02Z +- **Tasks:** 4 (1 decision resolved before dispatch, 3 executed) +- **Files modified:** 22 in summercms.go, 12 in sm-journal-plugin + +## Accomplishments + +- pact: `RecordChange`, `RecordSaved`, `RecordDeleted`, `AdminFormSave.Changes`, `MenuItemType.Content` and the eight `Content*` types; every change is a new type or a new last field. +- cabana: `EmitRecordSaved`/`EmitRecordDeleted` (lagoon.AfterCommit, listener errors logged); the admin save snapshots the row and the submitted translations before the write and fires `RecordSaved` with the diff; delete, bulk delete, tree reorder and relation-manager child writes fire too; the same changes reach a form extension's `Save`. +- signpost: `ContentTypes`, `ContentType`, `ContentTypeForController`, `ContentTypeForModel`, `Describe`, `Records`, `MatchPath`, `MatchPattern`; boot checks for the descriptor. +- Journal: post, category and tag descriptors (Describe per locale with URL/title/description/image/words, Records, strict Match), the `journal-tag` type (`/blog/tag/:slug`, `golem15.journal.tag_pages`), record events from the API writes, translated-slug lookup in `GET /posts/{slug}`. +- Docs: "Record events" in docs/plugins/extending.md, "Content descriptors" in docs/plugins/menu-item-types.md, admin-controllers notes, pact/cabana/signpost and Journal READMEs; src= snippets from new example tests. + +## Task Commits + +summercms.go (master): + +1. **Task 2: record events and content descriptors (tracer)** - `753fb59` (feat) +2. **Task 3: record events from every admin write path** - `ff62906` (feat) +3. **Task 4: signpost content lookups, Records and MatchPath** - `785902c` (feat) + +../sm-journal-plugin (main): + +1. **Task 2: post content descriptor + tracer test** - `08ab153` (feat) +2. **Task 3: record events from the Journal API writes (D-102)** - `4b3a8bb` (feat) +3. **Task 4: content descriptors for posts, categories and journal-tag** - `e17cebd` (feat) +4. **Task 4: resolve translated post slugs (D-58)** - `a598a7c` (fix) +5. **Task 4: declare the content descriptors in MenuItemTypes** - `c5b7147` (refactor) + +**Plan metadata:** recorded in the docs(32-02) commit that adds this file. + +## TDD Gate Compliance + +Tasks 3 and 4 were written tests-first. RED was verified for each with `msd_run check tdd-red-evidence` (verdict `RED_EVIDENCE_OK`, go test -json converted to TAP): + +- Task 3 framework: TestRecordEvents failed on the reorder, child write, delete and bulk delete subtests ("saved [] deleted []"). +- Task 3 Journal: TestAPIRecordEvents failed ("POST events = [], want one RecordSaved"). +- Task 4 signpost: TestContent* failed on assertions after compile stubs (ContentType false, Activate accepted the descriptor, MatchPattern false, MatchPath no match). +- Task 4 Journal: TestContentDescriptors, TestJournalTagType and TestTranslatedSlug failed (ContentTypes = journal-post, journal-tag unregistered, 404). + +The RED tests were not committed on their own: project CLAUDE.md requires `go test ./...` green at every commit, so each test landed in the same `feat` commit as the code that makes it pass (no separate `test(32-02)` commits). This is a CLAUDE.md-driven deviation from the tdd.md commit pattern. + +## Decisions Made + +See key-decisions in the frontmatter. In short: event and descriptor shapes exactly as P-07 to P-10; `Locale ""` means the record's own row; no-op saves still fire; reorder fires per moved node; child events name the controller of the child's model; Records without a func is an empty page; MatchPath takes the first exact, else the first inexact; Journal describes drafts but never matches them. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] CRUDService and RelationService had no handle on the application** +- **Found during:** Task 2 +- **Issue:** The emission needs `app.Events`; the services carried only DB, bucket, translator and writer. +- **Fix:** unexported `app` field on CRUDService and `app`/`reg` on RelationService, set by `service.crud()`/`service.relations()`. A service built without them (other packages, old tests) fires nothing. +- **Files modified:** modules/cabana/crud.go, relation.go, http.go +- **Committed in:** 753fb59, ff62906 + +**2. [Rule 3 - Blocking] docs checker refuses go code blocks without src=** +- **Found during:** Task 3 and Task 4 +- **Fix:** examples `ExampleEmitRecordSaved` (cabana) and `NewsPlugin` + `ExampleRegistry_MatchPath` (signpost) back the docs snippets, refreshed with `summer docs:sync`. +- **Committed in:** ff62906, 785902c + +**3. [Rule 1 - Acceptance criterion] posts.go needed the emit calls inline** +- **Found during:** Task 3: the criterion counts `cabana.EmitRecord` in posts.go (>= 3); the helper builds the event (`postSavedEvent`) and the handlers call `cabana.EmitRecordSaved`/`EmitRecordDeleted` directly. +- **Committed in:** 4b3a8bb + +**4. [Rule 1 - Acceptance criterion] content descriptors declared inline** +- **Found during:** Task 4 verification: the criterion counts `Content: &pact.ContentDescriptor` in menu_item_types.go (3); the descriptors moved from helper funcs to literals (refactor commit, no behaviour change). +- **Committed in:** c5b7147 + +**5. [CLAUDE.md] No separate RED commits** (see TDD Gate Compliance). + +**6. [Rule 2 - Scope] journal-tag Resolve answers nothing without tag pages** +- The plan names Describe and Match for `tag_pages: false`; Resolve follows the same rule so a menu or sitemap never links a tag page the host does not have. + +**7. Test fixture adjustments in Journal** +- `TestMenuItemTypesPatterns` lists six types (journal-tag appended); D-58's test lives in its own file `translated_slug_test.go` so the descriptor commit and the fix commit are each green. + +--- + +**Total deviations:** 7 (2 blocking, 2 acceptance-criterion shape, 1 CLAUDE.md commit rule, 1 scope consistency, 1 test fixture) +**Impact on plan:** No scope creep; the published shapes are P-07 to P-10 as answered. + +## Issues Encountered + +- The translate migrations seed `en` and Journal seeds an "Uncategorized" category; the descriptor test seeds locales with `WHERE NOT EXISTS` and checks the category page by its last record. + +## Threat Flags + +None beyond the plan's threat model: T-32-05 (failing listener logged, save 200), T-32-06 (AfterCommit, rollback cases), T-32-07 (path values only as GORM `?` parameters; MatchPattern refuses empty and extra segments) and T-32-08 (drafts never match; findPostBySlugOrID keeps its scope, Show still hides drafts from anonymous callers) are covered by TestRecordEvents, TestContentMatchPattern, TestContentDescriptors and TestTranslatedSlug. + +## User Setup Required + +None. A host without tag pages sets `golem15.journal.tag_pages: false` (or `SUMMER_GOLEM15__JOURNAL__TAG_PAGES=false`). + +## Next Phase Readiness + +- sm-seo-plugin (32-03) can listen for `*pact.RecordSaved`/`*pact.RecordDeleted`, map `Model` to a content type with `ContentTypeForModel`, read URLs with `Describe` and resolve paths with `MatchPath`. +- Plan 32-05 moves the demo app's Journal submodule to `c5b7147` (or later); the demo app builds unchanged against the framework checkout today. +- Nothing pushed or tagged. + +--- +*Phase: 32-seo-foundation* +*Completed: 2026-10-26* + +## Self-Check: PASSED + +All created files exist; framework commits 753fb59, ff62906, 785902c and Journal commits 08ab153, 4b3a8bb, e17cebd, a598a7c, c5b7147 are present.