Compare commits

..

11 Commits

Author SHA1 Message Date
10992ea1a5 docs(quick-261019-hgw): Translate hook ValidationError phrase keys in the request locale 2026-10-19 12:54:34 +02:00
ce8d7efb67 fix(cabana): translate hook ValidationError phrase keys in the request locale
- One shared localizer for ValidationError and ForbiddenError, tested in
  writeCRUDError's order; replaces the ForbiddenError-only one.
- Applied on the CRUD and relation service transactions, the detached bulk
  branch, runAction, the record read (ShowRecord), and the reorder,
  singleton and relation-options routes, which localized neither type.
- Only strings containing "::" are looked up, so translated text passes
  through unchanged and logs no missing-key warning.
- Copy-on-write: the plugin's error value is never modified.
- README and backend docs describe the localized ValidationError.
2026-10-19 12:53:17 +02:00
d785958df2 docs(state): Phase 17 UAT and re-verification complete 2026-10-19 12:33:06 +02:00
1fca32042b docs(17): re-verify phase 17 — passed, release gap closed 2026-10-19 12:32:43 +02:00
c5b6a83299 test(17): complete UAT - 40 passed, 0 issues 2026-10-19 12:24:39 +02:00
0827865796 docs(todo): sitemap browser view without XSLT 2026-10-19 12:23:06 +02:00
96595633c0 docs(quick-261019-05g): Phase 17 verify-work unblock and console docs example 2026-10-19 00:16:46 +02:00
86c5a80496 docs(console): show the application binary run bare and with --help 2026-10-19 00:15:28 +02:00
59f57819cc docs(17): shorten the COVERAGE.md opt-out reason and state the goal as a user story 2026-10-19 00:13:45 +02:00
eb7e39d335 docs(g15office): map Phase 20 demo, 30 cutover and 31 chat polish tasks 2026-10-18 23:16:37 +02:00
4fb9f142df docs(roadmap): reorder — demo at 20, Płytarium cutover 20→30, chat polish 19.2→31
New Phase 20 is a public demo instance on sm-demo-app with every shared
plugin. Phases 21-29 stay off the roadmap until their goals firm up.
The cutover (closing v1.0) moves to 30; WavePath work starts at 31 with
the former 19.2, Horoskopia follows at 36-40. Seeds, notes and the
30-CONTEXT now point at the new numbers.
2026-10-18 23:16:08 +02:00
38 changed files with 1728 additions and 132 deletions

View File

@@ -37,13 +37,13 @@ A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`.
| 10.1 | hwr4istb | 18.1 | 2vriy9pw |
| 10.2 | 6nwcght4 | 19 | 6nojs2go |
| 11 | sz63omvi | 19.1 | uakdpkc8 |
| 11.1 | 9crhrf7k | 19.2 | hgol7otp |
| 11.2 | pvwvnvvi | 19.3 | lpybyjdh |
| 12 | odwb6onw | 19.4 | l7t8dbyl |
| 12.1 | c5wcij0a | 19.5 | onunbz3l |
| 12.2 | hkoccgbx | 19.6 | avoroeb4 |
| 13 | nkbr2ybg | 20 | 0rkcboo0 |
| 14 | cwlaryop | | |
| 14.1 | v0j5lwvq | 19.7 | dcnv4mha |
| 11.1 | 9crhrf7k | 19.3 | lpybyjdh |
| 11.2 | pvwvnvvi | 19.4 | l7t8dbyl |
| 12 | odwb6onw | 19.5 | onunbz3l |
| 12.1 | c5wcij0a | 19.6 | avoroeb4 |
| 12.2 | hkoccgbx | 19.7 | dcnv4mha |
| 13 | nkbr2ybg | 20 | xo463rwk |
| 14 | cwlaryop | 30 | 0rkcboo0 |
| 14.1 | v0j5lwvq | 31 | hgol7otp |
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.

View File

@@ -4,6 +4,8 @@
v1 ports the Płytarium (fonoteka) headless PHP backend to a single Go binary without vue-fonoteka-app or fonoteka-mcp noticing. The journey starts with the smallest possible kernel plus a day-one parity harness, proves the whole stack on one real endpoint (`GET /_fonoteka/api/v1/genres`) before any further kernel design, then broadens outward: full data-layer fidelity, the three-auth-group HTTP layer, the user plugin, OAuth2.1 for MCP/ChatGPT, the admin schema pipeline and its Vue SPA, jobs/realtime/search infrastructure, the 154-route API surface (split into two delivery slices), domain jobs and external integrations, a local Nuxt-on-Go dress rehearsal, the Journal plugin and a first blog (grzybyfunkcjonalne.pl) on reusable views, and finally production cutover of Płytarium where the parity harness is green on every manifest route and both real clients run unchanged against the Go backend.
Reordered 2026-10-18 (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): the remaining 19.x phases polish the framework and shared plugins, Phase 20 is a public demo instance with every shared plugin, Phases 21 to 29 are added as their goals firm up (security, more plugin ports, backend fixes), and the Płytarium cutover at Phase 30 closes v1. The WavePath work starts at Phase 31 (formerly 19.2).
## Phases
**Phase Numbering:**
@@ -34,13 +36,14 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 18: Payment Gateway** - Port Golem15.PaymentGateway and its Stripe operator, with Horoskopia as the acceptance consumer (completed 2026-10-14)
- [x] **Phase 19: Chat and ChatChannels** - Port Golem15.Chat and Golem15.ChatChannels on lighthouse (completed 2026-10-17)
- [x] **Phase 19.1: Chat satellites** - Port chatbots, chatvideo, forum, herald and userfriends (completed 2026-10-18)
- [ ] **Phase 19.2: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
- [ ] **Phase 19.3: Framework transport and infrastructure for WavePath** - Cookie refresh, credentialed CORS, cache and Redis rate limits, S3 option, OpenAPI page, Turnstile
- [ ] **Phase 19.4: OIDC provider on wristband** - Discovery, JWKS with key rotation, id_token, userinfo, consent and pairwise subjects
- [ ] **Phase 19.5: Social login in sm-user-plugin** - Google and Facebook login with the SPA one-time-code handoff
- [ ] **Phase 19.6: Two-factor auth and device flow in sm-user-plugin** - TOTP, WebAuthn, email and recovery codes, trusted devices, device authorization
- [ ] **Phase 19.7: Generic MCP module** - Opt-in MCP endpoint, plugin-declared tools filtered by permissions, one connector per person via OAuth and a verified account link
- [ ] **Phase 20: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
- [ ] **Phase 20: Demo instance with every shared plugin** - Public showcase site on sm-demo-app: chat, video calls, presentations, journal and the rest; backend by request
- [ ] **Phase 30: Płytarium cutover** - Parity harness green, both real clients run unchanged in production
- [ ] **Phase 31: Chat polish against WavePath clients** - Replay WavePath's chat traffic through `tide` against the ported chat plugins until green
## Phase Details
@@ -652,7 +655,7 @@ Plans:
**Requirements**: TBD
**Depends on:** Phase 12 (user groups tables and the `Groups` relation from 12-01)
**Repos:** `sm-user-plugin` (mounted in fonoteka.go at `plugins/golem15/user`); `summercms.go` only if the admin pipeline is missing a feature the screens need
**Ordering:** independent of Phase 13; must land before Phase 20 (Płytarium cutover)
**Ordering:** independent of Phase 13; must land before Phase 30 (Płytarium cutover)
**Success Criteria** (what must be TRUE):
1. Users, User Groups and Organisations each have a list (columns, search, filters as in the PHP `config_filter.yaml`) and a create/update form ported from the PHP model YAML, reachable from admin navigation and gated by backend permissions.
@@ -1062,7 +1065,7 @@ Plans:
### Phase 17: Blog companions
**Goal:** The sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
**Goal:** As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app.
**Mode:** mvp
**Depends on:** Phase 15, Phase 16
**Repos:** `sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin` (new, under `git.golem15.com/golem15/`, see `.planning/notes/core-plugins-own-repos.md`); `summercms.go` for the menu-item type registry; `sm-journal-plugin` for an additive registration of its item types; `sm-grzybyfunkcjonalne-app` to move its FAQ and menu into the plugins
@@ -1250,25 +1253,6 @@ Plans:
**Wave 3** *(blocked on Waves 1–2)*
- [x] 19.1-06-PLAN.md — PHP method maps, goldens, scripts/check-phase19.1.sh
### Phase 19.2: Chat polish against WavePath clients (INSERTED)
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 19.2 to break down)
### Phase 19.3: Framework transport and infrastructure for WavePath (INSERTED)
**Goal:** The framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
@@ -1377,11 +1361,30 @@ Plans:
Plans:
- [ ] TBD (run /msd-plan-phase 19.7 to break down)
### Phase 20: Płytarium cutover
### Phase 20: Demo instance with every shared plugin
**Goal:** `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. The admin backend is not public; the site carries a call to action to contact Golem15 for a backend tour.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1, Phases 19.3 to 19.7
**Repos:** `sm-demo-app` and its `vue-demo-app`; the shared plugin repos and `summercms.go` only for gaps the demo exposes
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. `sm-demo-app` builds with every shared plugin, including `chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald` and `userfriends`, and `demo:seed` fills each one with showcase data.
2. The public Nuxt site has pages that show chat and channels with realtime updates, video calls, presentations, the journal and the other plugins working through the shared layers.
3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 20 to break down)
### Phase 30: Płytarium cutover
**Goal**: The parity harness is green on every manifest route and `vue-fonoteka-app` and `fonoteka-mcp` run unchanged against the Go backend in daily use in production — the Płytarium definition of done.
**Mode:** mvp
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phases 19 to 19.6 (decided 2026-10-11: the 19.x WavePath readiness work lands before the cutover)
**Depends on**: Phase 2, Phase 8, Phase 9, Phase 10, Phase 12, Phase 12.1, Phase 13, Phase 14, Phase 14.1, Phase 14.2, Phase 15, Phase 16, Phase 19, Phase 19.1, Phases 19.3 to 19.7, Phase 20 (this phase was Phase 20 until 2026-10-18. The 19.x readiness work still lands first, as decided 2026-10-11; chat polish against WavePath moved out to Phase 31)
**Repos:** fonoteka.go, summercms.go
**Requirements**: API-09, QA-05
**Success Criteria** (what must be TRUE):
@@ -1393,10 +1396,29 @@ Plans:
**Plans**: TBD
### Phase 31: Chat polish against WavePath clients
**Goal:** The ported chat and chat satellites carry WavePath's chat traffic exactly as PHP does. The chat routes that WavePath's clients call are recorded with `tide`, replayed against the Go plugins and fixed until green, so that chat, the core feature of WavePath, is not the risk in the WavePath migration.
**Mode:** mvp
**Depends on:** Phase 19, Phase 19.1, Phase 30 (formerly Phase 19.2; moved 2026-10-18 so the `tide` recording is made just before the WavePath migration, while the PHP routes still move)
**Repos:** `sm-chat-plugin`, `sm-chatchannels-plugin` and the satellite repos; `summercms.go` only for `lighthouse` or `tide` gaps
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
1. A `tide` corpus of the chat, channel, thread, video-room and push routes, recorded from the PHP backend through `vue-wavepath-app`, `wavepath-mcp` and `hubot-wavepath`, replays green against the Go plugins.
2. Realtime events on the `chat`, `video` and `presence:` namespaces match the PHP broadcasts recorded by the `tide` Centrifugo recorder.
3. Behaviour stays neutral to the other consumers of these plugins (QuestStream, Horoskopia, Portal). Framework READMEs and docs still name no application.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 31 to break down)
## Progress
**Execution Order:**
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.2 → 19.3 → 19.4 → 19.5 → 19.6 → 19.7 → 20
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 14.1 → 14.2 → 14.2.1 → 14.3 → 15 → 16 → 17 → 18 → 18.1 → 19 → 19.1 → 19.3 → 19.4 → 19.5 → 19.6 → 19.7 → 20 → (21 to 29, added as planned) → 30 → 31
(Phase 2 depends on Phase 1's command kernel; the two are no longer parallel.)
| Phase | Plans Complete | Status | Completed |
@@ -1435,13 +1457,14 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 18.1. Payment Gateway Subscriptions | 9/9 | Complete | 2026-10-16 |
| 19. Chat and ChatChannels | 7/7 | Complete | 2026-10-17 |
| 19.1. Chat satellites | 6/6 | Complete | 2026-10-18 |
| 19.2. Chat polish against WavePath clients | 0/TBD | Not started | - |
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
| 19.4. OIDC provider on wristband | 0/TBD | Not started | - |
| 19.5. Social login in sm-user-plugin | 0/TBD | Not started | - |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
| 19.7. Generic MCP module | 0/TBD | Not started | - |
| 20. Płytarium cutover | 0/TBD | Not started | - |
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
| 30. Płytarium cutover | 0/TBD | Not started | - |
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
## Backlog
@@ -1449,7 +1472,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
**Goal:** Visitors to summercms.io subscribe for updates through the initial Go version of the Golem15 Newsletter plugin, which confirms each email by double opt-in. The signup widget is added to the Phase 11.2 landing page, and the site gains Polish alongside English.
**Requirements**: TBD
**Deferred:** 2026-10-02, formerly Phase 11.3. It is one more Golem15 plugin port; Journal is Phase 15 and Płytarium cutover is Phase 20. Before planning, re-check what 11.2 shipped since: the landing-page design has no signup slot, nginx on rome blocks `/backend` and non-GET methods, and rome has no mail relay configured.
**Deferred:** 2026-10-02, formerly Phase 11.3. It is one more Golem15 plugin port; Journal is Phase 15 and Płytarium cutover is Phase 30 (Phase 20 when this was written). Before planning, re-check what 11.2 shipped since: the landing-page design has no signup slot, nginx on rome blocks `/backend` and non-GET methods, and rome has no mail relay configured.
**Repos:** `sm-newsletter-plugin` (new, under `git.golem15.com/golem15/`, Go package `newsletter`), plus changes to the Phase 11.2 repos (`vue-summercmsio-app` for the widget and Polish locale, `sm-summercmsio-app` to wire the plugin). `summercms.go` changes only if a framework feature is missing.
**Port source:** Golem15.Newsletter, `github.com/golem15com/wn-newsletter-plugin`, checked out at `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/newsletter`. The PHP plugin is work in progress. Its audience comes from registered users (it requires `Golem15.User`), and its only public routes are unsubscribe. It has no public subscriber model or signup widget, so this phase adds those in Go. The PHP original is not changed.
**Success Criteria** (what must be TRUE):

View File

@@ -1,20 +1,19 @@
---
msd_state_version: "1.0"
milestone: v1.0
current_phase: "19.2"
current_phase_name: Chat polish against WavePath clients
current_phase: "19.4"
current_phase_name: OIDC provider on wristband
status: planning
stopped_at: Phase 19.1 complete, ready to plan Phase 19.2
last_updated: "2026-10-18T17:25:43.450Z"
last_activity: 2026-10-18
last_activity_desc: Phase 19.1 complete, transitioned to Phase 19.2
state_head: f1ff2f940bc60281ec0f58cb07978516df7c1a8f
stopped_at: Roadmap reordered 2026-10-18; ready to plan Phase 19.4
last_updated: "2026-10-19T10:54:33.888Z"
last_activity: 2026-10-19
last_activity_desc: "Completed quick task 261019-hgw: Translate hook ValidationError phrase keys in the request locale"
state_head: ce8d7efb675a15840b5ee274d11625d920827147
progress:
total_phases: 41
total_phases: 42
completed_phases: 34
total_plans: 204
completed_plans: 204
percent: 83
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -30,10 +29,10 @@ See: .planning/PROJECT.md (updated 2026-10-12)
## Current Position
Phase: 19.2 — Chat polish against WavePath clients
Phase: 19.4 — OIDC provider on wristband
Plan: Not started
Status: Ready to plan
Last activity: 2026-10-18 — Phase 19.1 complete, transitioned to Phase 19.2
Last activity: 2026-10-19 - Completed quick task 261019-hgw: Translate hook ValidationError phrase keys in the request locale
Progress: [████████░░] 83%
@@ -291,6 +290,7 @@ Progress: [████████░░] 83%
- Phase 19.5 inserted after Phase 19.4: Social login in sm-user-plugin (WavePath framework asks, 2026-10-11)
- Phase 19.6 inserted after Phase 19.5: Two-factor auth and device flow in sm-user-plugin (WavePath framework asks, 2026-10-11)
- Phase 19.7 inserted after Phase 19.6: Generic MCP module: opt-in endpoint, plugin-declared tools, one connector per person (from /msd-explore 2026-10-17)
- Phase 20 reordered: Roadmap reordered (/msd-explore 2026-10-18): new Phase 20 Demo instance with every shared plugin; Płytarium cutover moved 20 → 30; Chat polish against WavePath clients moved 19.2 → 31; 21–29 added as their goals firm up
### Decisions
@@ -947,6 +947,8 @@ Recent decisions affecting current work:
| 261018-k9s | add the D-17 module in summercms.go: modules/breakwater Render(markdown)/Purify(html) with the forum bluemonday policy | 2026-10-18 | 98bc6de | [261018-k9s-add-the-d-17-module-in-summercms-go](./quick/261018-k9s-add-the-d-17-module-in-summercms-go/) |
| 40 | Goldens secret scan in check-phase19.1.sh (closes T-19.1-12) | 2026-10-18 | 8643195 | — |
| 261018-qbs | Fix 19.1 review WR-04 (chatbots dispatch checks IsAvailable) and WR-05 (chatvideo user.Deleting heir + LiveKit cleanup); extending.md optional-dependency go.mod note | 2026-10-18 | 97daec5 | [261018-qbs-fix-19-1-review-findings-wr-04-chatbots-](./quick/261018-qbs-fix-19-1-review-findings-wr-04-chatbots-/) |
| 261019-05g | Phase 17 verify-work unblock (COVERAGE.md reason, goal as user story, sm-sitemap-plugin requires v0.2.0) and console docs example of the application binary's --help | 2026-10-19 | 86c5a80 | [261019-05g-phase-17-verify-work-unblock-coverage-md](./quick/261019-05g-phase-17-verify-work-unblock-coverage-md/) |
| 261019-hgw | Translate hook ValidationError phrase keys in the request locale | 2026-10-19 | ce8d7ef | [261019-hgw-translate-hook-validationerror-phrase-ke](./quick/261019-hgw-translate-hook-validationerror-phrase-ke/) |
## Deferred Items
@@ -959,5 +961,5 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-18T14:01:53.356Z
Stopped at: Phase 19.1 complete, ready to plan Phase 19.2
Stopped at: Roadmap reordered 2026-10-18; ready to plan Phase 19.4
Resume file: None

View File

@@ -0,0 +1,32 @@
---
title: Roadmap reorder — demo at 20, Płytarium release at 30, WavePath from 31, Horoskopia from 36
date: 2026-10-18
context: /msd-explore after Phase 19.1 closed. Revises the phase numbers set in `wavepath-framework-asks-division.md` (2026-10-11) and the port order in `core-plugin-port-inventory.md` (2026-10-07).
---
# Roadmap reorder: demo at 20, Płytarium release at 30, WavePath from 31, Horoskopia from 36
## Decisions
- **The remaining 19.x phases come first** (19.3 verification, then 19.4 to 19.7). They polish the framework and the shared plugins before anything is shown or released.
- **Phase 20 is a demo instance with every shared plugin.** It builds on the existing `sm-demo-app` (14 plugins, `demo:seed`, Nuxt site `vue-demo-app`, no deployment yet). The phase adds the chat family (`chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald`, `userfriends`), frontend pages that show chat, video calls, presentations, journal and the rest, and a public deployment.
- The frontend is public. The admin backend is not; the site carries a "want to see the backend? contact Golem15" call to action.
- **Phases 21 to 29 are not on the roadmap.** Security phases, more plugin ports and backend fixes are expected over the next 2 to 3 weeks, and each is added when its goal is clear. `phase add` numbers a new phase as the highest number plus one, so these need an explicit id: `msd_run phase add "<description>" --id 21` (then move the section to sit between 20 and 30 in ROADMAP.md).
- **Phase 30 is the Płytarium cutover** (was Phase 20; its CONTEXT and decisions D-01 to D-13 moved unchanged to `30-cutover/`). It closes milestone v1.0.
- **Phase 31 opens the WavePath work** (was 19.2, chat polish against WavePath's clients). Recording the `tide` corpus right before the migration is worth more than recording now, because WavePath's PHP routes are not frozen.
- **Phases 31 to 35: WavePath** off WinterCMS onto Go, and WavePath's community off Mattermost. Target: this winter. Expected to be a v1.1 milestone.
- **Phases 36 to 40: Horoskopia-driven development.** Expected to be a v1.2 milestone. WavePath before Horoskopia matches the 2026-10-11 change to the seeds.
## Milestones
All phases currently sit in the v1.0 roadmap. When v1.0 closes at Phase 30, Phase 31 moves into the next milestone with `/msd-new-milestone`.
## Pointers for later
- **Mattermost exit (31 to 35):** WavePath's own roadmap has the PHP-side pattern, milestone v1.13 "Mattermost Import" (Phases 99 to 103 in `/media/nvme/dev/jz/wavepath.org/.planning/`). It is a generic `wn-mattermostimport-plugin`: a resumable, re-runnable import with delta runs and a ledger, which never writes to the Mattermost source. Host seams live in the app plugin. Not scoped here.
- **WavePath `import:winter` stress cases** stay in `wavepath-framework-asks-division.md` and apply to the Phase 30 runner.
## Open for the Phase 20 discussion
- Can visitors sign up and use chat and video calls themselves, or do they browse seeded content through prepared demo accounts? This decides how much abuse protection is needed (Turnstile from 19.3, rate limits), whether LiveKit usage needs caps, and whether the demo data is reset on a schedule.
- Where the demo is hosted and under which domain.

View File

@@ -56,6 +56,8 @@ Third-party plugins in many projects: `winter/location` is in 19, `winter/blocks
5. **Phase 20, Płytarium cutover.**
6. **After Phase 20:** the Horoskopia port comes first, then the Wavepath port. Neither is planned yet. This stretch is about preparing the shared plugins, not migrating apps, and a stabilization phase may come first. See the seeds for both ports.
**Superseded 2026-10-18** (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): Phase 20 is a demo instance with every shared plugin, Phases 21 to 29 are added as needed, the Płytarium cutover is Phase 30, the WavePath phases are 31 to 35 (from 2026-10-11 WavePath comes before Horoskopia), and Horoskopia-driven development is 36 to 40.
## Related
- `core-plugins-own-repos.md`: each shared plugin gets an `sm-<name>-plugin` repo

View File

@@ -10,6 +10,8 @@ WavePath's agent wrote a list of what it needs from SummerCMS (section A) and wh
## Decisions
> **Updated 2026-10-18** (`.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`): the Płytarium cutover is now Phase 30, after a demo instance at Phase 20. Chat parity against WavePath's clients moved from 19.2 to Phase 31 and opens the WavePath phases (31 to 35). The other 19.x phases still land before the cutover. Phase numbers below are as of 2026-10-11.
- **The 19.x block comes before the cutover.** Chat first, then chat polish, then the other framework and user-plugin asks. Phase 20 waits for 19 to 19.6. The WavePath migration milestone opens after Phase 20.
- **Chat is ported from the WavePath copy**, with `root_id` threading and `thread_summaries`. This settles the open chat-source question for Phase 19.
- **The seed's trigger is now "Phase 20 complete".** The WavePath milestone no longer waits for the Horoskopia port.
@@ -20,19 +22,19 @@ WavePath's agent wrote a list of what it needs from SummerCMS (section A) and wh
|---|---|
| A9: chat and chatchannels | Phase 19 |
| A9: chatbots, chatvideo (LiveKit, reconcile job), forum, herald (web push), userfriends | Phase 19.1 |
| Chat parity against WavePath's clients | Phase 19.2 |
| Chat parity against WavePath's clients | Phase 19.2, now Phase 31 |
| A3 cookie refresh and CORS, A6 cache and Redis `surf.Store`, A8 S3 option, `/api-docs`, Turnstile | Phase 19.3 (`summercms.go`) |
| A1 OIDC provider | Phase 19.4 (`wristband`) |
| A2 social login | Phase 19.5 (`sm-user-plugin`) |
| A4 2FA, A5 device flow | Phase 19.6 (`sm-user-plugin`) |
| A7 `summer import:winter` | Phase 20 (D-02 runner), with the WavePath stress cases below |
| A7 `summer import:winter` | Phase 20, now Phase 30 (D-02 runner), with the WavePath stress cases below |
## Corrections to the hand-off list
- **The user plugin is already shared.** `sm-user-plugin` (`git.golem15.com/golem15/sm-user-plugin`) is its own repo, checked out by both fonoteka.go and sm-bm-app. Phases 19.5 and 19.6 add to it directly. Both consuming apps must still build after each change.
- **Plugin-registrable token scopes (A8) are dropped for now.** `sm-user-plugin` already mints scoped tokens (`MintAPIToken`, `HasScope`). Its allowed list (`read`, `write`, `ai`) is a static package variable. WavePath's `wave.scope.public:*` scopes belong to its own `wave_token` guard and `golem15_wavepath_api_tokens` table, which section B ports on the WavePath side. Add a `RegisterAPITokenScope` hook only if WavePath confirms that its user-plugin tokens carry custom scopes.
## `import:winter` stress cases from WavePath (for Phase 20)
## `import:winter` stress cases from WavePath (for Phase 30, formerly 20)
- The source is MariaDB. That is the same MySQL wire protocol, but check type coercion.
- About 90 tables, including large chat and channel message tables. Batching must handle them.

View File

@@ -0,0 +1,289 @@
---
status: complete
phase: 17-blog-companions
source: [17-01-SUMMARY.md, 17-02-SUMMARY.md, 17-03-SUMMARY.md, 17-04-SUMMARY.md, 17-05-SUMMARY.md, 17-06-SUMMARY.md]
started: 2026-10-19T10:02:15Z
updated: 2026-10-19T10:24:39Z
---
## Current Test
[testing complete]
## Tests
<!-- MVP user flow. Story: As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app. -->
### 1. Cold Start Smoke Test
expected: On the UAT server, restart grzybyfunkcjonalne-go and the Nuxt service. Both come up without errors in the journal, and https://uat.grzybyfunkcjonalne.pl shows the homepage with the header menu and the FAQ block, and /gfadmin shows the admin login.
result: pass
### 2. Header menu on the site
expected: Open https://uat.grzybyfunkcjonalne.pl. The header shows the PHP site's menu (main-menu). The categories entry opens a dropdown on hover and on click, listing the Journal categories, each linking to /<slug>. Esc closes the dropdown. The link to the current page is marked active. At phone width the menu collapses behind a toggle that opens and closes it.
result: pass
coverage_id: 17-05/D7
### 3. Homepage FAQ
expected: On the homepage, the FAQ block "what-mushrooms-can-give-you" lists the PHP site's questions. Clicking a question opens its answer and clicking again closes it. The answers read as formatted text, with no stray markup.
result: pass
coverage_id: 17-05/D7
### 4. Edit a menu entry in the admin
expected: In /gfadmin open Menus, then Menu entries of main-menu. Edit an entry: change its title, and for a Journal type pick the reference from the dropdown that loads after you choose the type. Save. Reload the site: the header shows the new title (the save purges the Nuxt cache). Known, not part of this check: a refused save, such as a javascript: URL, shows its error as a raw phrase key (deferred item 1 of 17-04).
result: pass
note: "First run failed against the 2026-10-12 UAT build (no save purge, 300 s page cache); passed after the 2026-10-19 redeploy."
coverage_id: 17-04/D6
### 5. Reorder menu entries
expected: From the Menu entries list, click Reorder. Move an entry with the mouse and with the keyboard; the page announces each move and the tree keeps parents and children together. Reload the site: the header shows the new order.
result: pass
note: "First run failed against the 2026-10-12 UAT build (no save purge, 300 s page cache); passed after the 2026-10-19 redeploy."
coverage_id: 17-02/D4
### 6. Edit a FAQ answer with the rich editor
expected: In /gfadmin open FAQ and the "what-mushrooms-can-give-you" entry. The questions show as repeater rows you can add, collapse, move and remove. Edit an answer in the rich editor: the toolbar (bold, italic, lists, link) works, the link popover sets a link, and the HTML view shows the markup. Type a script element in the HTML view and save: a notice says what was removed. The homepage FAQ shows the edited answer.
result: pass
coverage_id: 17-02/D1, 17-04/D6
### 7. Sitemap definition and settings in the admin
expected: In /gfadmin open Settings, then Sitemap. The definition form lists the items as repeater rows; add a URL item (for example /kontakt) and save. Sitemap settings open and save. https://uat.grzybyfunkcjonalne.pl/sitemap.xml lists the new URL, and in a browser it renders through the XSL stylesheet as a readable table.
result: pass
coverage_id: 17-03/D6
<!-- Technical checks, after the user flow -->
### 8. UAT sitemap matches the live PHP sitemap
expected: The loc set of the UAT /sitemap.xml equals the live grzybyfunkcjonalne.pl sitemap's with the host removed: all 71 live paths are present on UAT, and the only extra is /blog/uat-16-2-blog-editor, a post that exists only in UAT data. You accept that one extra as UAT data, not a difference in the plugin.
result: pass
coverage_id: 17-05/D6
### 9. Plugin READMEs
expected: The READMEs of sm-journal-plugin (menu item types section), sm-sitemap-plugin, sm-menu-plugin and sm-faq-plugin read correctly: API paths, example and error bodies, rate-limit buckets, host CORS paths, the Winter import workflow and the Winter adaptations are documented, and none of them names a consuming application.
result: pass
coverage_id: 17-03/D7, 17-04/D7
<!-- Auto-passed by passing tests in each SUMMARY's coverage block (#1602), not presented -->
### 10. Typed menu item type registry: HasMenuItemTypes collected at boot by signpost.Activate in party, duplicate/empty/invalid types fail boot, layered patterns incl. PatternFrom, Resolve and ReferenceOptions
expected: Typed menu item type registry: HasMenuItemTypes collected at boot by signpost.Activate in party, duplicate/empty/invalid types fail boot, layered patterns incl. PatternFrom, Resolve and ReferenceOptions
result: pass
source: automated
coverage_id: 17-01/D1
### 11. Dependent dropdown (dependsOn) with GET fields/{field}/dependent-options fed by signpost.ReferenceOptions, depths, applies false for a type without references, 401 anonymous
expected: Dependent dropdown (dependsOn) with GET fields/{field}/dependent-options fed by signpost.ReferenceOptions, depths, applies false for a type without references, 401 anonymous
result: pass
source: automated
coverage_id: 17-01/D2
### 12. richeditor field type sanitized through seaglass on every save, clean HTML byte-identical, removals in meta.notices, non-string 422
expected: richeditor field type sanitized through seaglass on every save, clean HTML byte-identical, removals in meta.notices, non-string 422
result: pass
source: automated
coverage_id: 17-01/D3
### 13. repeater field type: nested keys only, per-row required (rows.1.title), nested richeditor sanitized, nested and top-level dependent recheck (form.dependent_invalid), row bounds
expected: repeater field type: nested keys only, per-row required (rows.1.title), nested richeditor sanitized, nested and top-level dependent recheck (form.dependent_invalid), row bounds
result: pass
source: automated
coverage_id: 17-01/D4
### 14. Tree reorder: config_reorder.yaml, GET/POST reorder with partition, both columns written, 409 reorder_stale for omitted or foreign nodes, 422 for cycles
expected: Tree reorder: config_reorder.yaml, GET/POST reorder with partition, both columns written, 409 reorder_stale for omitted or foreign nodes, 422 for cycles
result: pass
source: automated
coverage_id: 17-01/D5
### 15. Singleton controller: GET singleton first-or-create returns a stable id, list/create/delete 404, form schema singleton
expected: Singleton controller: GET singleton first-or-create returns a stable id, list/create/delete 404, form schema singleton
result: pass
source: automated
coverage_id: 17-01/D6
### 16. Every new route has swag annotations, regenerated admin.json/schema.d.ts, inventory entries, token classes, CSRF walk and conformance cases; docs and READMEs pass the docs checker
expected: Every new route has swag annotations, regenerated admin.json/schema.d.ts, inventory entries, token classes, CSRF walk and conformance cases; docs and READMEs pass the docs checker
result: pass
source: automated
coverage_id: 17-01/D7
### 17. Repeater field: add, edit nested, reorder and remove rows change form state only
expected: Repeater field: add, edit nested, reorder and remove rows change form state only
result: pass
source: automated
coverage_id: 17-02/D2
### 18. Dependent dropdown loads options for the sibling value and clears a stale value
expected: Dependent dropdown loads options for the sibling value and clears a stale value
result: pass
source: automated
coverage_id: 17-02/D3
### 19. Dist, types and phrase keys ship with the features; framework tagged v0.1.23 after human approval
expected: Dist, types and phrase keys ship with the features; framework tagged v0.1.23 after human approval
result: pass
source: automated
coverage_id: 17-02/D5
### 20. Journal registers its five PHP menu item types with DefaultPattern/PatternFrom, references, layered patterns, ordered per-locale alternates, nesting subtree, published filters and the x-default rule
expected: Journal registers its five PHP menu item types with DefaultPattern/PatternFrom, references, layered patterns, ordered per-locale alternates, nesting subtree, published filters and the x-default rule
result: pass
source: automated
coverage_id: 17-03/D1
### 21. /sitemap.xml from a stored definition (url item + registry item) equals the PHP golden byte for byte with Content-Type application/xml; 404 without a definition, empty 200 without items
expected: /sitemap.xml from a stored definition (url item + registry item) equals the PHP golden byte for byte with Content-Type application/xml; 404 without a definition, empty 200 without items
result: pass
source: automated
coverage_id: 17-03/D2
### 22. Url::to port (58 PHP cases), escaping, per-alternate list expansion, nesting, tiers (default rules, custom settings, off), manifest lastmod, frontend_url rewrite, BeforeAddItem skip, AddItem extra, priority precision, 50000 URL cap; tier config refusals and host replacement; manifest fail-safe
expected: Url::to port (58 PHP cases), escaping, per-alternate list expansion, nesting, tiers (default rules, custom settings, off), manifest lastmod, frontend_url rewrite, BeforeAddItem skip, AddItem extra, priority precision, 50000 URL cap; tier config refusals and host replacement; manifest fail-safe
result: pass
source: automated
coverage_id: 17-03/D3
### 23. sitemap:import-winter maps and drops items with a report, refuses a non-empty definition without --force, copies settings; /sitemap.xml rewrite with frontend_url; /sitemap.xsl bytes and headers; BeforeAddItem skip; sitemap:import PHP semantics
expected: sitemap:import-winter maps and drops items with a report, refuses a non-empty definition without --force, copies settings; /sitemap.xml rewrite with frontend_url; /sitemap.xsl bytes and headers; BeforeAddItem skip; sitemap:import PHP semantics
result: pass
source: automated
coverage_id: 17-03/D4
### 24. Sitemap admin API: singleton created once, repeater rows stored with PHP keys, 422 on a reference not offered for its type, settings form saves, 403 without golem15.sitemap.access_definitions
expected: Sitemap admin API: singleton created once, repeater rows stored with PHP keys, 422 on a reference not offered for its type, settings form saves, 403 without golem15.sitemap.access_definitions
result: pass
source: automated
coverage_id: 17-03/D5
### 25. FAQ admin: repeater with richeditor answers, script and onclick stripped on save with a notice under content.1.answer, locale column in the list, locale required (422, nothing written), 403 without golem15.faq.faqs
expected: FAQ admin: repeater with richeditor answers, script and onclick stripped on save with a notice under content.1.answer, locale column in the list, locale required (422, nothing written), 403 without golem15.faq.faqs
result: pass
source: automated
coverage_id: 17-04/D1
### 26. Menu admin: PHP beforeSave rules, D-05 type set, dependent reference recheck, url_scheme and target 422s, parent cycle, menu-scoped reorder (409 for a foreign entry), menu filter
expected: Menu admin: PHP beforeSave rules, D-05 type set, dependent reference recheck, url_scheme and target 422s, parent cycle, menu-scoped reorder (409 for a foreign entry), menu filter
result: pass
source: automated
coverage_id: 17-04/D2
### 27. Winter imports: FAQ locale remap by code (unknown aborts), ids kept, sequence bumped, --force/--dry-run/empty file; menu link->url and blog_categories->all-journal-categories, page subtree dropped and reported, ids and parent_id kept, sequences bumped, dry run writes nothing
expected: Winter imports: FAQ locale remap by code (unknown aborts), ids kept, sequence bumped, --force/--dry-run/empty file; menu link->url and blog_categories->all-journal-categories, page subtree dropped and reported, ids and parent_id kept, sequences bumped, dry run writes nothing
result: pass
source: automated
coverage_id: 17-04/D3
### 28. FAQ API: default locale, ?locale=en/pl, raw-SQL unsafe HTML served sanitized, null description, duplicate slug serves lowest id, 404 JSON for inactive, unknown slug, malformed slug, unknown and disabled locale, 429 after 120 requests per client
expected: FAQ API: default locale, ?locale=en/pl, raw-SQL unsafe HTML served sanitized, null description, duplicate slug serves lowest id, 404 JSON for inactive, unknown slug, malformed slug, unknown and disabled locale, 429 after 120 requests per client
result: pass
source: automated
coverage_id: 17-04/D4
### 29. Menu API: 404 JSON for unknown, inactive and malformed codes; unpublished subtree absent; sort_order then id; javascript: URL and bad target null; single type resolved title/URL; url_pattern override; list type keeps stored title/URL with stored child first then generated children (id null, parent type); ?locale=en alternates; unregistered and unresolvable entries left out; 429 after 120 requests
expected: Menu API: 404 JSON for unknown, inactive and malformed codes; unpublished subtree absent; sort_order then id; javascript: URL and bad target null; single type resolved title/URL; url_pattern override; list type keeps stored title/URL with stored child first then generated children (id null, parent type); ?locale=en alternates; unregistered and unresolvable entries left out; 429 after 120 requests
result: pass
source: automated
coverage_id: 17-04/D5
### 30. Host boots the three plugins from their pushed repositories, imports fixture exports, and serves the menu with Journal categories at /<slug>, the sanitized FAQ and the sitemap
expected: Host boots the three plugins from their pushed repositories, imports fixture exports, and serves the menu with Journal categories at /<slug>, the sanitized FAQ and the sitemap
result: pass
source: automated
coverage_id: 17-05/D1
### 31. vue-menu-layer and vue-faq-layer: typed clients, MenuNav and FaqBlock, URL allowlist and rel noopener tests, SSR smokes, tag v0.1.0 on origin
expected: vue-menu-layer and vue-faq-layer: typed clients, MenuNav and FaqBlock, URL allowlist and rel noopener tests, SSR smokes, tag v0.1.0 on origin
result: pass
source: automated
coverage_id: 17-05/D2
### 32. Site app renders MenuNav in the header and FaqBlock on the homepage, app menu and FAQ data deleted, Nuxt sitemap disabled
expected: Site app renders MenuNav in the header and FaqBlock on the homepage, app menu and FAQ data deleted, Nuxt sitemap disabled
result: pass
source: automated
coverage_id: 17-05/D3
### 33. UAT serves /sitemap.xml, /sitemap.xsl, /_menu/ and /_faq/ from Go through nginx with the server-level security headers, GET and HEAD only, and the full smoke passes
expected: UAT serves /sitemap.xml, /sitemap.xsl, /_menu/ and /_faq/ from Go through nginx with the server-level security headers, GET and HEAD only, and the full smoke passes
result: pass
source: automated
coverage_id: 17-05/D4
### 34. Winter menu, FAQ and sitemap rows exported read-only and imported on UAT by the ordered import step
expected: Winter menu, FAQ and sitemap rows exported read-only and imported on UAT by the ordered import step
result: pass
source: automated
coverage_id: 17-05/D5
### 35. Fail-closed phase gate over eight repositories with detectors proven on planted output; --all ends with 'Phase 17 gate passed'
expected: Fail-closed phase gate over eight repositories with detectors proven on planted output; --all ends with 'Phase 17 gate passed'
result: pass
source: automated
coverage_id: 17-06/D1
### 36. Framework unit tests: signpost activation, pattern layers and ReferenceOptions; seaglass byte stability, report and bypass corpus; pact contracts; the party hook; cabana repeater, dependent dropdown, richeditor, reorder and singleton compile and routes; conformance of the new routes' success and error answers
expected: Framework unit tests: signpost activation, pattern layers and ReferenceOptions; seaglass byte stability, report and bypass corpus; pact contracts; the party hook; cabana repeater, dependent dropdown, richeditor, reorder and singleton compile and routes; conformance of the new routes' success and error answers
result: pass
source: automated
coverage_id: 17-06/D2
### 37. Admin SPA specs for the UI-SPEC backstops: richeditor untouched value, link popover, notices, chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch, tree rules; singleton form
expected: Admin SPA specs for the UI-SPEC backstops: richeditor untouched value, link popover, notices, chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch, tree rules; singleton form
result: pass
source: automated
coverage_id: 17-06/D3
### 38. Plugin unit tests: Journal menu item types, sitemap parity (Url::to goldens, escaping, fillable, tiers, rewrite, hooks, manifest, item JSON, cap, backend URL), menu links, tree and model, FAQ decode, model and serializer; Postgres APIs, imports and beforeSave
expected: Plugin unit tests: Journal menu item types, sitemap parity (Url::to goldens, escaping, fillable, tiers, rewrite, hooks, manifest, item JSON, cap, backend URL), menu links, tree and model, FAQ decode, model and serializer; Postgres APIs, imports and beforeSave
result: pass
source: automated
coverage_id: 17-06/D4
### 39. Host wiring and layers: TestCompanionsHost with CORS preflight and sitemap headers; menu and FAQ layer link rules, clients and composable bases
expected: Host wiring and layers: TestCompanionsHost with CORS preflight and sitemap headers; menu and FAQ layer link rules, clients and composable bases
result: pass
source: automated
coverage_id: 17-06/D5
### 40. Filled 17-VALIDATION.md: every row with task IDs, gate commands, File Exists and status; Wave 0 ticked; manual rows keep the plan 05 human check
expected: Filled 17-VALIDATION.md: every row with task IDs, gate commands, File Exists and status; Wave 0 ticked; manual rows keep the plan 05 human check
result: pass
source: automated
coverage_id: 17-06/D6
## Summary
total: 40
passed: 40
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
- gap_id: G-17-4
truth: "In /gfadmin open Menus, then Menu entries of main-menu. Edit an entry: change its title, and for a Journal type pick the reference from the dropdown that loads after you choose the type. Save. Reload the site: the header shows the new title (the save purges the Nuxt cache). Known, not part of this check: a refused save, such as a javascript: URL, shows its error as a raw phrase key (deferred item 1 of 17-04)."
status: resolved
resolved_by: UAT redeploy 2026-10-19 (host master, cache purge from 19.3-04)
resolved_at: 2026-10-19
reason: "User reported: didn't work. changed Grzyby Funkcjonalne to Grzyby and uat.grzybyfunkcjonalne.pl still shows Grzyby Funkcjonalne. two options - actually nothing got deployed and I'm testing old hardcoded faq and navigation, or there is this painful 60s cache"
severity: major
test: 4
root_cause: "Not a code gap: UAT runs the 2026-10-12 build (17-05). The save worked (menu API returned the new title) and the page showed it once the Nuxt swr cache (s-maxage=300) expired, as the user later confirmed. The save-triggered purge exists since 19.3-04 (sm-menu-plugin 559d96b, sm-faq-plugin c60ae6e, host d604509/85939c3) but was never deployed to UAT."
artifacts:
- path: "../sm-grzybyfunkcjonalne-app (UAT /srv/grzybyfunkcjonalne/go/bin, 2026-10-12)"
issue: "deployed binary predates the site-cache purge"
missing:
- "Redeploy UAT from current host master, then re-run tests 4 and 5"
- gap_id: G-17-5
truth: "From the Menu entries list, click Reorder. Move an entry with the mouse and with the keyboard; the page announces each move and the tree keeps parents and children together. Reload the site: the header shows the new order."
status: resolved
resolved_by: UAT redeploy 2026-10-19 (host master, cache purge from 19.3-04)
resolved_at: 2026-10-19
reason: "User reported: failed, nothing happened, and my name change from previous step still doesn't appear. switching you to manual mode, confirm that I'm testing proper deployment"
severity: major
test: 5
root_cause: "Same as G-17-4: the reorder saved and appeared on the site after the swr TTL; the deployed UAT build has no save-triggered purge."
artifacts: []
missing:
- "Redeploy UAT from current host master, then re-run tests 4 and 5"

View File

@@ -0,0 +1,228 @@
---
phase: 17-blog-companions
verified: 2026-10-19T10:31:07Z
status: passed
score: 6/6 must-haves verified
covered_files:
- .planning/phases/17-blog-companions/17-01-PLAN.md
- .planning/phases/17-blog-companions/17-01-SUMMARY.md
- .planning/phases/17-blog-companions/17-02-PLAN.md
- .planning/phases/17-blog-companions/17-02-SUMMARY.md
- .planning/phases/17-blog-companions/17-03-PLAN.md
- .planning/phases/17-blog-companions/17-03-SUMMARY.md
- .planning/phases/17-blog-companions/17-04-PLAN.md
- .planning/phases/17-blog-companions/17-04-SUMMARY.md
- .planning/phases/17-blog-companions/17-05-PLAN.md
- .planning/phases/17-blog-companions/17-05-SUMMARY.md
- .planning/phases/17-blog-companions/17-06-PLAN.md
- .planning/phases/17-blog-companions/17-06-SUMMARY.md
- .planning/phases/17-blog-companions/17-REVIEW-DISPOSITION.md
- .planning/phases/17-blog-companions/17-REVIEW-FIX.md
- .planning/phases/17-blog-companions/17-REVIEW.md
- .planning/phases/17-blog-companions/17-VALIDATION.md
- admin/src/views/ListView.vue
- admin/src/views/ReorderView.vue
- admin/src/views/SettingsIndexView.vue
- modules/cabana/field_dependent.go
- modules/cabana/field_repeater.go
- modules/cabana/field_richeditor.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/pact/capabilities.go
- modules/party/registry.go
- modules/seaglass/seaglass.go
- modules/signpost/signpost.go
- scripts/check-phase17.sh
covered_digest: "v2:sha256:a4fca52795435a44b393e2a0bd4fe7b2bd59103ef04ae72df6d68de0b7bb72fc"
behavior_unverified: 0
overrides_applied: 0
re_verification:
previous_status: gaps_found
previous_score: 5/6
gaps_closed:
- "Release discipline (REL): the framework fixes are in pushed tags (v0.1.24 and later), the host requires v0.1.24, every host plugin gitlink contains the review fixes, and sm-sitemap-plugin main requires v0.2.0 and builds against it without the local replace"
gaps_remaining: []
regressions: []
gaps: []
deferred: []
advisory:
- finding: "The host requires summercms v0.1.24, but its pinned journal, menu and faq commits (19.3-04) import modules/cooler, which first appears in v0.1.27. The host builds only through its committed `replace => ../summercms.go`; resolving from tags alone, Go has to raise the requirement to v0.2.3. The pinned plugin commits still declare `require summercms v0.1.23`."
category: other
reason: "Release chain of phase 19.3-04, not phase 17. Phase 17 code needs nothing beyond v0.1.24 (signpost.ValidItemPattern is in v0.1.24). To fix: bump the host require (and the plugin requires on their next bump) to a tag that contains cooler, e.g. v0.2.0 or later."
evidence_status: "reproduced: GOWORK=off go build -modfile=<host go.mod without the summercms replace> -mod=mod printed 'found .../modules/cooler in summercms v0.2.3'"
- finding: "Gap-closure host commit 4bab6f1 bumps the faq, journal, menu, sitemap and user gitlinks and go.mod in one commit, against the 17-05 rule that every gitlink bump is its own host commit"
category: other
reason: "Process deviation in pushed history; the content is correct (all fixes are ancestors of the pins). Nothing to fix retroactively; keep one gitlink per commit on future bumps."
evidence_status: "git show --stat 4bab6f1 in sm-grzybyfunkcjonalne-app"
behavior_unverified_items: []
coincidental_reliance_items: []
human_verified:
- test: "Header menu on the site"
uat_row: 2
recorded_at: "2026-10-19T10:24:39Z"
- test: "Homepage FAQ"
uat_row: 3
recorded_at: "2026-10-19T10:24:39Z"
- test: "Edit a menu entry in the admin"
uat_row: 4
recorded_at: "2026-10-19T10:24:39Z"
- test: "Reorder menu entries"
uat_row: 5
recorded_at: "2026-10-19T10:24:39Z"
- test: "Edit a FAQ answer with the rich editor"
uat_row: 6
recorded_at: "2026-10-19T10:24:39Z"
- test: "Sitemap definition and settings in the admin"
uat_row: 7
recorded_at: "2026-10-19T10:24:39Z"
- test: "UAT sitemap matches the live PHP sitemap"
uat_row: 8
recorded_at: "2026-10-19T10:24:39Z"
---
# Phase 17: Blog companions Verification Report
**Phase Goal:** As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app.
**Verified:** 2026-10-19T10:31:07Z
**Status:** passed
**Re-verification:** Yes. The previous report (2026-10-12, gaps_found, 5/6) had one gap, release coherence (REL). It is closed.
## Summary
The REL gap is closed:
- The framework fixes are in pushed tags.
- The host requires a tag that contains them.
- Every host plugin gitlink contains the review fixes, and every pinned commit is on origin.
- The UAT binary was built from that host master with those checkouts.
The recorded human UAT (17-UAT.md, 40/40, 2026-10-19T10:24:39Z, after the redeploy) settles every human item from the previous report. `verification.uat-evidence` reports no covered file changed since then.
The strict phase gate passed again here, Docker stage included, after the later phases (18, 18.1, 19.3 and quick tasks) touched cabana, reorder and the admin views. That rules out regressions.
There are two non-blocking release notes (advisory) and one known editor-facing divergence (warning). None of them blocks the goal.
## User Flow Coverage
Story: As a **blog site editor**, I want to **manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins**, so that **they are no longer hardcoded in the Nuxt app**. (`user-story.validate`: valid.)
| Step | Expected | Evidence | Status |
| ---- | -------- | -------- | ------ |
| 1. Editor opens Menus / Menu entries in /gfadmin and edits an entry, picking a Journal reference from the dependent dropdown | Save persists, site shows it | UAT row 4 pass (after the 2026-10-19 redeploy); `TestMenuAdminSmoke` (pg) pass | VERIFIED (human UAT, row 4) |
| 2. Editor reorders menu entries by mouse and keyboard | Tree saved, site shows new order | UAT row 5 pass; `TestCompanionsReorderSmoke`, ReorderView WR-02 specs pass | VERIFIED (human UAT, row 5) |
| 3. Editor edits an FAQ answer in the rich editor | Toolbar works, unsafe markup stripped with a notice, homepage shows the edit | UAT row 6 pass; `TestFaqAdminSmoke`, `TestCompanionsRichEditorSmoke` pass | VERIFIED (human UAT, row 6) |
| 4. Editor adds a sitemap URL item and saves sitemap settings | /sitemap.xml lists it, renders through the XSL | UAT row 7 pass; `TestSitemapAdminSmoke` pass | VERIFIED (human UAT, row 7) |
| 5. Outcome: the Nuxt app no longer hardcodes menu/FAQ/sitemap | Header from MenuNav over /_menu, FAQ from FaqBlock over /_faq, sitemap from Go | Host pin `vue-grzybyfunkcjonalne-app@0ee97d0`: `SiteHeader.vue:15` `<MenuNav`, `FaqSection.vue:21` `<FaqBlock`, `nuxt.config.ts:15` extends the menu and FAQ layers, `app/data/` holds only `contact.ts`, robots points at the Go `/sitemap.xml`; UAT rows 2, 3, 8 pass | VERIFIED |
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
| - | ----- | ------ | -------- |
| SC-1 | The three plugins port their PHP schema, models, admin screens and import commands 1:1, each in its own repo with a README | VERIFIED | Regression check: repos pushed (`main` local = origin for sitemap 46898c2, menu f7eddd5, faq 2efe351), gate postgres stage `TestSitemapAdminSmoke`, `TestMenuAdminSmoke`, `TestFaqAdminSmoke`, the import smokes pass; READMEs checked by UAT row 9. Details unchanged from the 2026-10-12 report |
| SC-2 | Typed successor of `pages.menuitem.*`; Journal registers through it; menu and sitemap consume it | VERIFIED | Gate go stage (`TestRegistryDuplicateKey`, `TestRegistryActivate`, `TestActivateMenuItemTypesOrder`, `TestMenuItemTypeContracts`) and pg `TestMenuItemTypes`, `TestMenuItemTypesSmoke` pass; UAT row 10 |
| SC-3 | sitemap.xml/xsl match PHP incl. frontend_url rewrite and hreflang; menu and FAQ public read APIs | VERIFIED | `TestSitemapGolden`, `TestURLTo`, `TestRewrite`, `TestXMLEscaping` pass; pg `TestMenuAPISmoke`, `TestFaqAPISmoke` pass; layer TAP 37 + 24 pass; UAT rows 7, 8 (loc set equals live modulo the one UAT-only post) |
| SC-4 | grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config | VERIFIED (human UAT, rows 2, 3) | Host wiring plus `TestCompanionsHost` (pg) pass against the host's pinned submodules; site app uses MenuNav/FaqBlock with the data files removed; UAT rows 1-3 on the 2026-10-19 build |
| SC-5 | The new code has unit tests, delivered in the phase's last plan | VERIFIED | `PHASE17_REQUIRE_POSTGRES=1 bash scripts/check-phase17.sh --all` rerun here: exit 0, "Phase 17 gate passed" (self-test, go, plugins, admin 1431 vitest in 93 files + 7 browser, layers, docs, postgres 11+2+5+6+5+1) |
| REL | Release discipline: plugins and host require a released framework tag containing what the phase code calls; the host commits the pushed, fixed plugin heads | VERIFIED (previously FAILED) | See "REL re-verification" below |
**Score:** 6/6 truths verified (0 present, behavior-unverified)
### REL re-verification (the previous gap)
| Check | Command | Result |
| ----- | ------- | ------ |
| Framework fixes in a pushed tag | `git tag --contains` for 964f1a7, 6312929, e1ac983, 9d7c21d, 27bb13b, d9d6b26 | each is in v0.1.24, v0.1.25, v0.1.26, v0.1.27, v0.2.0 .. v0.2.3; all on origin (`git ls-remote --tags origin`) |
| Host requires a tag with the fixes | `grep summercms ../sm-grzybyfunkcjonalne-app/go.mod` | `require summercms v0.1.24` (contains `signpost.ValidItemPattern`; `git grep` over the tags) |
| Host gitlinks include the review fixes | `git submodule status`; `git merge-base --is-ancestor <fix> <pin>` per plugin repo | sitemap `1bb89ce` contains c42bc26 (CR-01), 433202b (WR-03), cf5c40e (WR-05); menu `559d96b` contains 7255d34 (WR-01), 64215f2 (WR-03), 9c0f833 (WR-06); journal `d7943fb` contains 31d901c (WR-04); faq `c60ae6e` contains 919cf06, 1585f06. All ancestors |
| Pinned commits published | `git branch -r --contains <pin>` | every pin is on `origin/main`; host master `129c0df` equals origin |
| Submodule checkouts match the pins | `git -C plugins/golem15/<p> rev-parse HEAD`, `status --short` | at the pinned commit, clean; `AllowedItemURL`, `ValidItemPattern`, `BucketSitemap`, `publishedPosts` present in the checkouts |
| sitemap main builds against its required tag | `go vet ./...` (committed setup) and `GOWORK=off go build -modfile=<go.mod without the summercms replace>` | both exit 0; module resolves to `summercms v0.2.0` |
| Host builds as committed | `go vet . ./plugins/golem15/{sitemap,menu,faq,journal}/...` in the host | exit 0 |
| UAT binary | `sha256sum bin/grzybyfunkcjonalne`; `go version -m` | sha256 `31a5e3662966dd6d...`, built 2026-10-19 12:12; `vcs.revision=129c0df` (host master); the plugins are built from the submodule checkouts. vcs.modified=true comes from untracked `.gsd/` and a stray nested `sm-grzybyfunkcjonalne-app/` directory, not from tracked changes |
The pinned sitemap commit `1bb89ce` still declares `require summercms v0.1.23` and on its own would not build. The host does not build it on its own: the host requires v0.1.24, so the module graph selects a version that contains `ValidItemPattern`. sitemap `main` (46898c2) is fixed to require v0.2.0. This matches the acceptance criterion given for the re-verification.
### Required Artifacts (regression check)
| Artifact | Status | Details |
| -------- | ------ | ------- |
| `modules/signpost`, `modules/seaglass`, cabana field types, reorder, singleton | VERIFIED | Gate go, docs and postgres stages pass; `check-admin-dist.sh` matches a fresh build; openapi check passes |
| `../sm-sitemap-plugin`, `../sm-menu-plugin`, `../sm-faq-plugin`, Journal menu item types | VERIFIED | Pushed; gate plugins and postgres stages pass |
| `../vue-menu-layer`, `../vue-faq-layer` | VERIFIED | TAP 37/37, 24/24; site app pins `#v0.2.0` (moved on from v0.1.0 in later work) |
| `../sm-grzybyfunkcjonalne-app` wiring and pins | VERIFIED | `TestCompanionsHost` passes; pins contain the fixes |
| `scripts/check-phase17.sh` | VERIFIED | Strict run exit 0; now requires the review-fix tests by name (6e5a8e7) |
### Key Link Verification
| From | To | Via | Status |
| ---- | -- | --- | ------ |
| party.Activate | signpost registry | `signpost.Activate` before first Boot | WIRED |
| Journal | registry | `pact.HasMenuItemTypes` | WIRED |
| menu / sitemap | registry | `app.Lookup[*signpost.Registry]()` | WIRED |
| Site header / FAQ | /_menu, /_faq APIs | MenuNav, FaqBlock | WIRED |
| Host | fixed plugin commits | submodule gitlinks | WIRED (was NOT_WIRED) |
| Host | tagged framework | `require summercms v0.1.24` (+ dev replace) | WIRED for phase 17 code; see advisory 1 for the 19.3 cooler import |
### Data-Flow Trace (Level 4)
Unchanged from the previous report: menu API, FAQ API and sitemap generator query the database and the registry (FLOWING). UAT rows 4-7 show admin edits reaching the public site end to end.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
| -------- | ------- | ------ | ------ |
| Whole phase gate, strict | `PHASE17_REQUIRE_POSTGRES=1 bash scripts/check-phase17.sh --all` | exit 0, "Phase 17 gate passed" | PASS |
| sitemap main standalone against v0.2.0 | `GOWORK=off GOFLAGS=-mod=mod go build -modfile=$SCRATCH/sitemap.mod ./...` | exit 0, summercms v0.2.0 | PASS |
| Host vet as committed | `go vet . ./plugins/golem15/...` | exit 0 | PASS |
| Live UAT endpoints | `curl https://uat.grzybyfunkcjonalne.pl/sitemap.xml` etc. | 401 (basic auth in front of UAT) | SKIP, covered by the recorded UAT |
### Probe Execution
Step 7c: SKIPPED. No `scripts/*/tests/probe-*.sh` exists and no plan declares a probe; `scripts/check-phase17.sh` is the declared gate and was run.
### Requirements Coverage
ROADMAP lists `Requirements: TBD`; REQUIREMENTS.md maps nothing to Phase 17, so nothing is ORPHANED. Plan IDs SC-1..SC-5 and D-01..D-19 remain SATISFIED as in the previous report (regression gate green).
### Anti-Patterns Found
No `TBD|FIXME|XXX` in the covered framework files or the host-pinned sitemap, menu and faq trees.
### Warnings (non-blocking)
| # | Finding | Severity | Notes |
| - | ------- | -------- | ----- |
| W-1 | Hook `ValidationError` details reach the admin as phrase keys (`writeCRUDError`, `modules/cabana/crud.go:831-834`, writes `ve.Details` untranslated). An editor whose save is refused (javascript: URL, missing FAQ locale, parent from another menu) sees e.g. `golem15.menu::lang.errors.url_scheme` | WARNING | Known: deferred-items.md 17-04 #1, explicitly excluded from UAT row 4. The refusal itself works. Only tracked in deferred-items.md; no todo or roadmap entry found. Suggest a todo |
| W-2 | Host requires v0.1.24 while the 19.3-04 plugin pins import `modules/cooler` (v0.1.27+) | Advisory | Out of phase 17 scope; see `advisory` frontmatter |
| W-3 | Gap-closure commit 4bab6f1 bundles five gitlinks and go.mod | Advisory | Process only; see `advisory` frontmatter |
### Advisory (New Scope, Unevidenced)
None unevidenced. The two advisory entries in the frontmatter are evidenced, but they are outside this phase (W-2) or historical process (W-3), so they do not block.
### Human Verification
All seven human items from the previous report are settled by the recorded UAT (no covered file changed since 2026-10-19T10:24:39Z):
| Previous item | Settled by |
| ------------- | ---------- |
| Header menu desktop/mobile/Esc/active | UAT row 2 |
| Homepage FAQ | UAT row 3 |
| Admin menu entries: edit, reorder, cross-menu move with children (WR-01), type and reference dropdown | UAT rows 4, 5; the cross-menu subtree move and refusal are exercised by `TestMenuAdminSmoke` (menu_smoke_test.go:382-400, required by the gate, pass) |
| Reorder quick moves then reload (WR-02) | UAT row 5; ReorderView WR-02 specs pass |
| FAQ richeditor, sitemap screens, singleton in a new tab (WR-07) | UAT rows 6, 7; WR-07 by spec "the list route of a singleton, opened directly (a new tab), opens its record" (SingletonForm.test.ts:115, pass in the admin stage) |
| Refusal text shown as phrase keys | Not a verification question any more: code and UAT row 4 confirm raw keys are shown. Carried as warning W-1 |
| /sitemap.xml in a browser after the rebuild | UAT rows 7 (XSL renders) and 8 (loc set equals live) |
No human verification items remain.
### Gaps Summary
None. The REL gap from 2026-10-12 is closed: the tags, host require, gitlinks and UAT build are all in place. The phase goal (editors manage menus, FAQs and sitemap in the admin; the Nuxt app no longer hardcodes them) is met in code, by the gate and by the recorded human UAT.
---
_Verified: 2026-10-19T10:31:07Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -1 +1 @@
No external API integration: this phase ports internal WinterCMS plugins (sitemap, menu, FAQ) to SummerCMS and publishes its own read APIs (/_menu/api/v1, /_faq/api/v1, /sitemap.xml) consumed by first-party Nuxt layers; no third-party service, SDK or remote API is called.
No external API integration: ports the internal sitemap, menu and FAQ plugins and serves first-party read APIs (/_menu, /_faq, /sitemap.xml) to our own Nuxt layers; no third-party service is called.

View File

@@ -19,7 +19,7 @@ Out-of-scope findings logged during execution. None blocks the plan that found i
## From 17-04 (FAQ and menu plugins)
1. **Hook validation messages reach the admin as phrase keys.** A `cabana.ValidationError` returned from a form hook (`FormBeforeCreate`, `FormAfterCreate`, …) is written as-is by `writeCRUDError` (`modules/cabana/crud.go`), while `cabana.ForbiddenError` messages and details are translated in the request locale. The admin SPA prints field errors raw (`FormField.vue` renders `{{ text }}`; `FormErrorBanner.vue` lists orphans raw), so an editor sees `golem15.menu::lang.errors.url_scheme` instead of the sentence. Affects the FAQ `errors.locale_required` and the menu `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required` refusals. Follow-up in the framework: translate `ValidationError.Details` phrase keys in the request locale like `ForbiddenError` (server side), so every client gets text.
1. **Hook validation messages reach the admin as phrase keys.** A `cabana.ValidationError` returned from a form hook (`FormBeforeCreate`, `FormAfterCreate`, …) is written as-is by `writeCRUDError` (`modules/cabana/crud.go`), while `cabana.ForbiddenError` messages and details are translated in the request locale. The admin SPA prints field errors raw (`FormField.vue` renders `{{ text }}`; `FormErrorBanner.vue` lists orphans raw), so an editor sees `golem15.menu::lang.errors.url_scheme` instead of the sentence. Affects the FAQ `errors.locale_required` and the menu `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required` refusals. Follow-up in the framework: translate `ValidationError.Details` phrase keys in the request locale like `ForbiddenError` (server side), so every client gets text. **Resolved 2026-10-19 by ce8d7ef (quick 261019-hgw).**
2. **Public API misses log once per request.** The menu API logs each left-out entry (unregistered type, unresolvable reference) at warn level on every request; with a stale entry in a busy menu that repeats per request (bounded by the 120 per minute throttle per client). Acceptable for now; a later change could log once per entry and boot.
## From 17-05 (site integration)

View File

@@ -179,7 +179,7 @@ Repos: `sm-mfa-plugin` (new), `sm-user-plugin`, `sm-sociallogin-plugin` (it call
- Per-group 2FA exemptions (settings on user groups), replacing PHP's child-account exemption. Long term.
- Notification mails on 2FA enabled/disabled or a new trusted device (not in PHP).
- Winter import of the 2FA and device tables (Phase 20 `import:winter`, hashing plaintext tokens per D-06).
- Winter import of the 2FA and device tables (Phase 30 `import:winter`, numbered 20 when this was written, hashing plaintext tokens per D-06).
</deferred>

View File

@@ -1,4 +1,4 @@
# Phase 20: Płytarium cutover - Context
# Phase 30: Płytarium cutover - Context
**Gathered:** 2026-10-04
**Status:** Ready for planning
@@ -52,7 +52,7 @@ Not in scope: anything on the PHP side after the swap. The user handles the PHP
### "All routes green" gate
- **D-11:** The acceptance set is every route in `fonoteka.go/parity/manifest.yaml`: 175 entries today, covering the fonoteka, user, oauth, realtime and feedback routes. Every entry must be `ported`, with passing recorded cases, zero `pending` entries, and `routes.snapshot` matching PHP on path, method and auth group. API-09, QA-05 and the ROADMAP success criteria are reworded at plan time from "154 routes" to "every manifest route".
- **D-12:** Fixtures: the whole corpus is re-recorded from the current PHP backend (`summer parity:record`) shortly before the freeze, and the Go replay must be green on it. A drift report lists every fixture whose PHP response changed since it was committed, and each drift is resolved (a Go fix, or an accepted normalizer change) before the swap. In addition, a **read-only smoke diff runs on the rehearsal import**. A scripted set of authenticated GET requests (as real users, via minted JWTs and personal tokens) is replayed against PHP on the restored MariaDB and against Go on the Postgres imported from the same dump, and both are diffed with the tide normalizer. The smoke diff runs locally on the dump only.
- **D-13 (prerequisites):** Phase 14.1 (the 3 pending routes), Phase 12.1 (the user admin screens) and Phase 14.2 (local Nuxt against Go) are hard prerequisites, and they are on Phase 20's depends-on in ROADMAP. Phase 14's open UAT items must also be closed: the CR-01 decision record, and the WR-02/WR-05 dispositions, which `fonoteka.go` commits suggest are already fixed. Phase 20's first plan opens with a preflight gate that fails unless all of this holds and the manifest has zero pending routes. Planning may start before the prerequisites close.
- **D-13 (prerequisites):** Phase 14.1 (the 3 pending routes), Phase 12.1 (the user admin screens) and Phase 14.2 (local Nuxt against Go) are hard prerequisites, and they are on Phase 30's depends-on (numbered Phase 20 when this was written) in ROADMAP. Phase 14's open UAT items must also be closed: the CR-01 decision record, and the WR-02/WR-05 dispositions, which `fonoteka.go` commits suggest are already fixed. Phase 30's first plan opens with a preflight gate that fails unless all of this holds and the manifest has zero pending routes. Planning may start before the prerequisites close.
### Daily-use acceptance
- **D-14:** The soak is 7 days of normal household use on Go with no rollback-worthy issue. During it, the wishlist digest and the notification prune schedules each run at least once, and at least one CSV import and one Discogs match complete. The end of the soak is the phase sign-off and closes the rollback window.
@@ -85,7 +85,7 @@ Not in scope: anything on the PHP side after the swap. The user handles the PHP
### Prior decisions this phase builds on
- `.planning/PROJECT.md`: Postgres only, the out-of-scope MySQL support, the core value.
- `.planning/REQUIREMENTS.md`: API-09 and QA-05, which are reworded per D-11.
- `.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md`: D-10 (Laravel decrypt is import-only), D-14/D-17 (`system_files` shape and thumb reuse), and the cutover import deferred to Phase 20.
- `.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md`: D-10 (Laravel decrypt is import-only), D-14/D-17 (`system_files` shape and thumb reuse), and the cutover import deferred to Phase 20 (now Phase 30).
- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md`: D-06 (wire-compatible JWTs, blacklist not migrated) and D-15 (reset codes).
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md`: D-01 (Winter-shaped `backend_users`, copied straight in at cutover) and WR-17 (user-level permissions).
- `.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md`: D-18 (golem/feedback settings importers), D-20 (`GOLEM15_SSRF_ALLOWED_HOSTS`), and D-09 (the routes now in 14.1).
@@ -151,5 +151,5 @@ Not in scope: anything on the PHP side after the swap. The user handles the PHP
---
*Phase: 20-cutover*
*Phase: 30-cutover*
*Context gathered: 2026-10-04*

View File

@@ -1,10 +1,10 @@
# Phase 20: Płytarium cutover - Discussion Log
# Phase 30: Płytarium cutover - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md. This log preserves the alternatives considered.
**Date:** 2026-10-04
**Phase:** 20-cutover
**Phase:** 30-cutover
**Areas discussed:** MariaDB → Postgres data move, production switch and rollback, the "all routes green" gate, daily-use acceptance
Before the discussion started, the scout found that production runs on MariaDB (with a Redis queue), not Postgres. It also found that the parity manifest holds 175 routes (172 ported, 3 pending), not 154, and that Phases 14.1 and 12.1 have not started.

View File

@@ -0,0 +1,187 @@
---
phase: quick-261019-05g
plan: 01
quick_id: 261019-05g
slug: phase-17-verify-work-unblock-coverage-md
type: execute
mode: quick
wave: 1
depends_on: []
files_modified:
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/17-blog-companions/COVERAGE.md
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/ROADMAP.md
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/go.mod
- /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/go.sum
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/docs/console/introduction.md
autonomous: true
requirements: [17-VERIFY-GATE-COVERAGE, 17-VERIFY-GATE-USER-STORY, 17-GAP-SITEMAP-REQUIRE, DOCS-CONSOLE-BINARY-HELP]
source:
- orchestrator brief (items 1-4, wording of COVERAGE.md line and Phase 17 goal approved by the user)
- .planning/phases/17-blog-companions/COVERAGE.md (current single opt-out line, reason 278 chars, gate limit 200)
- .planning/ROADMAP.md line 1068 (Phase 17 **Goal:**, **Mode:** mvp on line 1069)
- .planning/phases/17-blog-companions/17-VERIFICATION.md (untracked, read-only here; last open gap is the sitemap require)
- docs/console/introduction.md line 16 (insertion point)
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 3
confidence: low
must_haves:
truths:
- "`node ~/.claude/msd-core/bin/msd-tools.cjs check api-coverage.verify-pre .planning/phases/17-blog-companions --raw` reports \"block\": false; COVERAGE.md is exactly the one user-approved opt-out line (reason 169 chars, under the 200-char limit)."
- "`roadmap.get-phase 17 --pick goal` returns the user-approved story and `user-story.validate` on it returns true; no other ROADMAP.md line changes."
- "sm-sitemap-plugin requires git.golem15.com/golem15/summercms v0.2.0 (the release its siblings sm-menu/faq/journal-plugin require), keeps `replace git.golem15.com/golem15/summercms => ../summercms.go`, and is green on go vet and go test -short."
- "sm-sitemap-plugin also compiles and vets against the published v0.2.0 with the replace removed (checked in a throwaway copy; the repo itself is not left modified)."
- "The sm-sitemap-plugin commit `chore: require summercms v0.2.0` is on origin/main (fast-forward push, no force); no consuming app's gitlink is bumped."
- "docs/console/introduction.md shows, right after the 'The application binary is what you deploy...' paragraph, running `./bin/acme` bare and with `--help`, followed by REAL output captured from a fresh build of examples/hello with only the binary name rewritten to acme; `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass."
- "summercms.go gets exactly two new commits (planning docs; docs(console)), staged by explicit path, not pushed; untracked .msd/ and 17-VERIFICATION.md stay untouched and untracked."
artifacts:
- "summercms.go/.planning/phases/17-blog-companions/COVERAGE.md — one line: the approved `No external API integration: ...` opt-out"
- "summercms.go/.planning/ROADMAP.md — Phase 17 **Goal:** line replaced by the approved user story"
- "sm-sitemap-plugin/go.mod, go.sum — summercms require v0.1.23 -> v0.2.0, tidied"
- "summercms.go/docs/console/introduction.md — new lead-in sentence, an sh fence (`./bin/acme`, `./bin/acme --help`) and a text fence with captured output"
key_links:
- "verify-work 17 gates: api-coverage.verify-pre reads COVERAGE.md (REASON_MAX_LEN = 200 in ~/.claude/msd-core/bin/lib/api-coverage.cjs); the mvp-mode gate reads the ROADMAP `**Goal:**` line through roadmap.get-phase and validates /^As a .+, I want to .+, so that .+\\.$/."
- "sm-sitemap-plugin calls signpost.ValidItemPattern (controllers/definitions.go:143, console/import_winter.go:337); it is absent at summercms v0.1.23 and present at v0.2.0 (modules/signpost/signpost.go:187). Without the bump the plugin only builds through the replace."
- "Docs checker (internal/docsite/check_commands.go): sh/shell/bash/console fences and code spans are scanned for `summer` and `./bin/<app>` command words; `./bin/acme` bare and `./bin/acme --help` yield no command word, and `text` fences are not scanned, so captured output belongs in a `text` fence."
- "examples/hello/main.go hardcodes the root name (`bonfire.NewRoot(\"hello\", ...)`) and loads `config/` relative to the working directory, so the binary must be run from examples/hello and its printed name rewritten to acme for the page."
---
<objective>
Unblock `/msd-verify-work 17` and close the last open item of the orphaned 17-VERIFICATION.md gap, then fold in one small console docs addition.
1. Shorten the Phase 17 COVERAGE.md opt-out reason under the 200-char gate limit and restate the Phase 17 ROADMAP goal as a user story (Phase 17 is `**Mode:** mvp`), using the exact user-approved wording.
2. Make sm-sitemap-plugin require summercms v0.2.0 (it uses signpost.ValidItemPattern, first tagged in v0.1.24) so it builds without the development replace, commit and push it (push approved by the user).
3. Show real console output of an application binary run bare and with `--help` in docs/console/introduction.md.
Purpose: verify-work for Phase 17 currently stops at the api-coverage gate and would stop again at the user-story gate; the sitemap plugin's go.mod lies about its framework dependency.
Output: two summercms.go commits (planning docs, docs(console)), one pushed sm-sitemap-plugin commit.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@docs/console/introduction.md
Live state observed at planning time (2026-10-19), re-check where a task says so:
- COVERAGE.md is tracked, one line, reason 278 chars; the gate currently returns "block": true.
- ROADMAP.md line 1068 is the Phase 17 goal (not a user story); ROADMAP.md had no uncommitted changes.
- sm-sitemap-plugin: branch main, clean, `## main...origin/main`, HEAD 1bb89ce; go.mod requires summercms v0.1.23 with `replace git.golem15.com/golem15/summercms => ../summercms.go`; tracked go.work also replaces summercms and sm-translate-plugin. No README/yaml/script mentions a summercms version. Siblings committed the same bump as `chore: require summercms v0.2.0` (sm-menu-plugin f7eddd5, sm-faq-plugin 2efe351).
- Tag v0.2.0 exists on origin (d5f754a) and resolves with the default GOPROXY; a throwaway copy of sm-sitemap-plugin with the replace dropped, go.work removed and the require at v0.2.0 passed `GOWORK=off GOFLAGS=-mod=mod go build ./...` and `go vet ./...`.
- examples/hello/bin/ is gitignored; examples/hello is in the root go.work. A stale build of it printed, both bare and with --help (byte-identical, exit 0, nothing on stderr): `Usage:` / `hello [command]`, 19 `Available Commands` rows (admin:create ... serve, including the example plugin's greeter:hello and cobra's help), `Flags:` `-h, --help help for hello`, and `Use "hello [command] --help" for more information about a command.` The task must capture from a fresh build, not reuse this.
Repo rules that apply (CLAUDE.md): no co-author trailers in any commit (user instruction overrides any attribution reminder); one logical change per commit, planning docs and code/docs separate; stage by explicit path, never `git add -A`; leave untracked `.msd/` and `.planning/phases/17-blog-companions/17-VERIFICATION.md` alone; do not push summercms.go; framework docs never name a consuming application; every identifier and command on a docs page must exist.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Unblock verify-work 17 end to end — COVERAGE.md reason and Phase 17 goal as user story</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/17-blog-companions/COVERAGE.md, /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/ROADMAP.md</files>
<precondition>`git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet -- .planning/ROADMAP.md .planning/phases/17-blog-companions/COVERAGE.md` exits 0 (no other session has uncommitted edits in these two files); if it does not, stop and report instead of committing someone else's hunk.</precondition>
<action>Work in /media/nvme/dev/golem15/summercms.io/summercms/summercms.go.
COVERAGE.md: replace the whole file content with exactly this single line followed by one newline, nothing else (wording approved by the user; the reason after the colon is 169 chars):
No external API integration: ports the internal sitemap, menu and FAQ plugins and serves first-party read APIs (/_menu, /_faq, /sitemap.xml) to our own Nuxt layers; no third-party service is called.
ROADMAP.md: in the section `### Phase 17: Blog companions`, use a scoped Edit (never a whole-file Write) to replace only the `**Goal:**` line (line 1068, currently starting "**Goal:** The sitemap, menu and FAQ plugins run on SummerCMS as shared") with exactly:
**Goal:** As a blog site editor, I want to manage menus, FAQs and the sitemap in the SummerCMS admin through the shared sitemap, menu and FAQ plugins, so that they are no longer hardcoded in the Nuxt app.
Leave `**Mode:** mvp`, Depends on, Repos, Port sources, Requirements and Success Criteria untouched.
Run both gates (see verify). Then stage exactly these two paths and commit them together as one planning-docs commit, message: `docs(17): shorten the COVERAGE.md opt-out reason and state the goal as a user story`. No co-author trailer. Do not stage .msd/, 17-VERIFICATION.md, STATE.md or anything else.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && node ~/.claude/msd-core/bin/msd-tools.cjs check api-coverage.verify-pre .planning/phases/17-blog-companions --raw | grep -q '"block": *false' && G=$(node ~/.claude/msd-core/bin/msd-tools.cjs query roadmap.get-phase 17 --pick goal) && test "$(node ~/.claude/msd-core/bin/msd-tools.cjs query user-story.validate --story "$G" --pick valid)" = true && C=$(git log -1 --format=%H -F --grep='docs(17): shorten the COVERAGE.md opt-out reason') && test -n "$C" && N=$(git diff --numstat "$C^..$C" -- .planning/ROADMAP.md) && test "$(printf '%s\n' "$N" | cut -f1,2)" = "$(printf '1\t1')" && F=$(git show --name-only --format= "$C") && test "$(printf '%s\n' "$F" | sort | tr '\n' ' ')" = ".planning/ROADMAP.md .planning/phases/17-blog-companions/COVERAGE.md " && echo OK</automated>
</verify>
<done>The api-coverage.verify-pre gate returns "block": false for Phase 17; the Phase 17 goal validates as a user story; the docs(17) commit (found by subject, not by HEAD offset, since other sessions commit here) touches only COVERAGE.md and ROADMAP.md (ROADMAP: 1 line added, 1 removed); no co-author trailer; .msd/ and 17-VERIFICATION.md still untracked.</done>
</task>
<task type="auto">
<name>Task 2: sm-sitemap-plugin requires summercms v0.2.0, verified without the replace, committed and pushed</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/go.mod, /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin/go.sum</files>
<precondition>In /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin, after `git fetch origin`: `git status --porcelain` is empty, the branch is main and `git rev-parse HEAD` equals `git rev-parse origin/main` (observed at planning: HEAD 1bb89ce, in sync); if not, stop and report.</precondition>
<action>Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin (module git.golem15.com/golem15/sm-sitemap-plugin). The plugin calls signpost.ValidItemPattern, which v0.1.23 lacks; v0.2.0 (the version sm-menu/faq/journal-plugin require) has it.
1. Bump the require only: `go mod edit -require=git.golem15.com/golem15/summercms@v0.2.0`. Keep the go.mod line `replace git.golem15.com/golem15/summercms => ../summercms.go` and leave the tracked go.work unchanged.
2. Tidy deterministically against the module's own go.mod: `GOWORK=off go mod tidy`.
3. `go vet ./...` and `go test -short ./...` (normal workspace mode) must be green.
4. Prove it builds WITHOUT the replace, in a throwaway copy so the repo is never modified: create a temp dir with `mktemp -d`, `rsync -a --exclude .git ./ "$T/"`, then in "$T": remove go.work and go.work.sum if present, `go mod edit -dropreplace=git.golem15.com/golem15/summercms`, and run `GOWORK=off GOFLAGS=-mod=mod go build ./...` and `GOWORK=off GOFLAGS=-mod=mod go vet ./...` (the copy sits outside the meta repo, so ../summercms.go cannot be picked up by accident). Remove "$T" afterwards. If this fails because the plugin needs an API added after v0.2.0, stop and report the missing identifier and the first tag that has it instead of choosing another version (v0.2.0 is the user-approved target).
5. Confirm `git diff --name-only` lists only go.mod and/or go.sum and that the replace line is still present.
6. Stage go.mod and go.sum by path and commit with message `chore: require summercms v0.2.0` (mirrors the sibling commits). No co-author trailer.
7. Push: `git push origin main` (fast-forward only; never --force; push approved by the user). Do NOT bump the sm-sitemap-plugin gitlink in any consuming application.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-sitemap-plugin && grep -qE '^[[:space:]]+git\.golem15\.com/golem15/summercms v0\.2\.0$' go.mod && grep -q '^replace git.golem15.com/golem15/summercms => ../summercms.go$' go.mod && go vet ./... && go test -short ./... && test -z "$(git status --porcelain)" && git fetch origin && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" && S=$(git log -1 --format=%s) && test "$S" = "chore: require summercms v0.2.0" && B=$(git log -1 --format=%B) && ! printf '%s\n' "$B" | grep -qi '^co-authored-by' && F=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$F" | grep -vxE 'go\.(mod|sum)')" && echo OK</automated>
</verify>
<done>go.mod requires summercms v0.2.0 with the replace kept; go vet and go test -short green; the no-replace build and vet against v0.2.0 passed in a removed temp copy; one commit `chore: require summercms v0.2.0` touching only go.mod/go.sum, no co-author trailer, pushed so HEAD equals origin/main; working tree clean; no app gitlink changed.</done>
</task>
<task type="auto">
<name>Task 3: Console docs show a bare and --help run of the application binary with real output</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/docs/console/introduction.md</files>
<precondition>`git -C /media/nvme/dev/golem15/summercms.io/summercms/summercms.go diff --quiet -- docs/console/introduction.md` exits 0 (no in-flight edit from another session).</precondition>
<action>Work in /media/nvme/dev/golem15/summercms.io/summercms/summercms.go.
Capture real output (do not invent or hand-edit command rows):
- Build the workspace example into a temp dir without regenerating tracked files: `T=$(mktemp -d)`, then from examples/hello run `go build -o "$T/hello" .` (use go build, not `summer build`, so the committed examples/hello/main.go and plugins.gen.go are not rewritten).
- From examples/hello (the binary loads `config/` relative to the working directory) run it bare and with `--help`, stdout and stderr to separate files in "$T", and record both exit codes. `diff` the two stdout captures.
- Remove "$T" when done; `git status --short examples/` must stay empty.
Edit docs/console/introduction.md: insert after the paragraph on line 16 ("The application binary is what you deploy, ... rather than of `summer`.") and before `## Getting help`, separated by blank lines:
- One lead-in sentence. If the two captures are identical (as observed at planning time), say that run with no arguments the binary prints its usage and every command it has, the framework's and those its plugins add, and that `--help` prints the same. If they differ, describe each and show both outputs.
- An `sh` fence with two lines: `./bin/acme` and `./bin/acme --help` (the docs checker finds no command word in either, so nothing new needs registering).
- A `text` fence (not sh/console: those are scanned as commands) with the captured stdout, where the only change is the binary name: the root name printed in the Usage line, the `help for ...` flag line and the closing `Use "... [command] --help"` line becomes `acme`, matching the `./bin/acme` name used throughout the page. Keep column alignment, row order and descriptions verbatim, including the example plugin's own command row, which shows that plugin commands join the list.
- Trimming: the stale build had 19 command rows (about 26 lines); keep the full list if the fresh capture has at most 22 rows. Otherwise keep the first and last few rows, replace the middle with a single ` ...` line, and add a short sentence after the fence saying the list is shortened.
- Do not touch the rest of the page, the module READMEs or any other docs page; name no consuming application.
Run the docs checks (see verify), then stage docs/console/introduction.md by path and commit as `docs(console): show the application binary run bare and with --help`. No co-author trailer. Do not push summercms.go.</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check && grep -q 'acme \[command\]' docs/console/introduction.md && grep -q 'help for acme' docs/console/introduction.md && C=$(git log -1 --format=%H -F --grep='docs(console): show the application binary run bare and with --help') && test -n "$C" && N=$(git diff --numstat "$C^..$C" -- docs/console/introduction.md) && test "$(printf '%s\n' "$N" | cut -f2)" = 0 && F=$(git show --name-only --format= "$C") && test "$F" = docs/console/introduction.md && E=$(git status --short examples/) && test -z "$E" && go vet ./... && echo OK</automated>
</verify>
<done>The page shows, between the deploy paragraph and `## Getting help`, a lead-in sentence, an sh fence with `./bin/acme` and `./bin/acme --help`, and a text fence holding output captured from a fresh examples/hello build with the binary name rewritten to acme (trimmed only per the rule, with a note if so); TestDocsTree and `docs:build --check` pass; the commit adds lines only to docs/console/introduction.md; examples/ is clean; go vet is green; nothing pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| sm-sitemap-plugin go.mod -> module proxy / git.golem15.com | the require now resolves summercms v0.2.0 from the network when the replace is absent |
| local sm-sitemap-plugin -> origin main | a commit is published to the shared plugin repo that apps consume as a submodule |
| local binary output -> public docs site | captured console output is published on the docs page |
## STRIDE Threat Register
Numbering continues after the highest Phase 17 ID in use (T-17-41).
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-42 | Tampering | sm-sitemap-plugin go.mod/go.sum (summercms v0.1.23 -> v0.2.0) | medium | mitigate | Target is our own tag v0.2.0 (d5f754a on origin), already required by the sibling plugins; go.sum hashes come from `go mod tidy` with the default GOPROXY and checksum DB; Task 2 verify refuses a commit touching anything but go.mod/go.sum; the -mod=mod no-replace build runs only in a removed temp copy. |
| T-17-43 | Tampering | push of sm-sitemap-plugin main | low | mitigate | Precondition requires a clean tree in sync with origin/main before the bump; push is fast-forward only (no --force); verify checks HEAD == origin/main and the single expected subject; consuming apps' gitlinks are deliberately not moved. |
| T-17-44 | Information disclosure | captured binary output in docs/console/introduction.md | low | accept | `--help` prints only usage, command names and descriptions (observed); stderr is captured separately and not pasted; the only rewrite is the binary name to acme, and `docs:build --check` refuses consuming-application names. |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager install and no new dependency: Task 2 changes the version of an existing first-party requirement only; Task 3 builds an in-repo example with the existing module graph. |
</threat_model>
<verification>
- `node ~/.claude/msd-core/bin/msd-tools.cjs check api-coverage.verify-pre .planning/phases/17-blog-companions --raw` -> "block": false.
- `roadmap.get-phase 17 --pick goal` piped into `user-story.validate --pick valid` -> true.
- sm-sitemap-plugin: go vet and go test -short green; HEAD == origin/main; subject `chore: require summercms v0.2.0`.
- summercms.go: `go vet ./...`, `go test ./cmd/summer -run TestDocsTree`, `go run ./cmd/summer docs:build --check` green.
- summercms.go history contains the docs(17) and docs(console) commits (located by subject; other sessions may have committed in between); `git status --short` still lists only the untracked `.msd/` and `.planning/phases/17-blog-companions/17-VERIFICATION.md` (plus any other session's own edits, untouched); summercms.go not pushed.
- No commit in either repo carries a Co-Authored-By trailer.
</verification>
<success_criteria>
`/msd-verify-work 17` is no longer blocked by the api-coverage or user-story gates; the sitemap plugin's declared framework dependency (v0.2.0) is the one it actually needs and is published on origin/main; the console introduction shows what a bare application binary and `--help` really print.
</success_criteria>
<output>
Create `.planning/quick/261019-05g-phase-17-verify-work-unblock-coverage-md/261019-05g-SUMMARY.md` when done, listing: the two summercms.go commit hashes, the sm-sitemap-plugin commit hash and the pushed origin/main ref, the go.sum delta (lines added/removed), the result of the no-replace check, whether the bare and --help captures were identical and whether the command list was trimmed, and the new threat rows T-17-42 .. T-17-44 for later registration in Phase 17's security record. Note that consuming apps still pin the previous sm-sitemap-plugin gitlink (bump left for a separate task).
</output>

View File

@@ -0,0 +1,87 @@
---
phase: quick-261019-05g
plan: 01
quick_id: 261019-05g
subsystem: planning, sm-sitemap-plugin, docs/console
tags: [phase-17, verify-work, api-coverage, user-story, go-mod, docs]
status: complete
requires: []
provides:
- Phase 17 COVERAGE.md under the 200-char opt-out gate
- Phase 17 goal as a user story (mvp gate)
- sm-sitemap-plugin requires summercms v0.2.0 (pushed)
- console docs show real bare / --help output of an application binary
affects: [phase-17 verify-work, apps consuming sm-sitemap-plugin (gitlink not bumped)]
tech-stack:
added: []
patterns: []
key-files:
created: []
modified:
- .planning/phases/17-blog-companions/COVERAGE.md
- .planning/ROADMAP.md
- docs/console/introduction.md
- ../sm-sitemap-plugin/go.mod
- ../sm-sitemap-plugin/go.sum
decisions:
- "sm-sitemap-plugin go.mod tidied with the development replace in effect (GOWORK=off); the resulting indirect set (aws-sdk-go-v2, go-redis, otter, wire, atomic) matches the sibling sm-menu/sm-faq plugins, and summercms.go go.mod is unchanged since v0.2.0"
metrics:
duration: ~3min
completed: 2026-10-19
actuals:
tokens: 1600
tasks: 3
commits: 2
commits: 2
plan_head_before: eb7e39d335d32983b0ca461725cd01f561189643
plan_head_after: 86c5a8049667dcdcf6229ba51961405c0ea776c8
---
# Phase quick-261019-05g Plan 01: Unblock verify-work 17, sitemap plugin require, console help output Summary
Phase 17's api-coverage and user-story gates now pass, sm-sitemap-plugin declares (and is pushed with) the summercms v0.2.0 it actually needs, and the console introduction shows the real usage output of an application binary.
## Commits
| Repo | Task | Commit | Subject |
|------|------|--------|---------|
| summercms.go | 1 | 59f5781 | docs(17): shorten the COVERAGE.md opt-out reason and state the goal as a user story |
| sm-sitemap-plugin | 2 | 46898c2 | chore: require summercms v0.2.0 |
| summercms.go | 3 | 86c5a80 | docs(console): show the application binary run bare and with --help |
`commits: 2` counts summercms.go only (measured `git rev-list --count eb7e39d..HEAD`); the sm-sitemap-plugin commit is in its own repo. No commit carries a co-author trailer. summercms.go not pushed.
## Task results
**Task 1 (tracer).** COVERAGE.md is the single approved line (reason 169 chars). `check api-coverage.verify-pre` returns `"block": false` (`none_declared: true`, overriding 2 detected signals). `roadmap.get-phase 17 --pick goal` returns the approved story and `user-story.validate` returns `true`. Commit touches only COVERAGE.md and ROADMAP.md (ROADMAP 1+/1-). Tracer gate re-ran the verify end to end and passed.
**Task 2.** go.mod require v0.1.23 -> v0.2.0, replace `=> ../summercms.go` kept, go.work untouched. go.mod +25/-1 (require bump plus 23 new indirect lines pulled in by v0.2.0's dependency graph), go.sum +24/-0. `go vet ./...` and `go test -short ./...` green. No-replace check in a removed temp copy (go.work dropped, replace dropped, `GOWORK=off GOFLAGS=-mod=mod`): `go build ./...` and `go vet ./...` passed, resolving `git.golem15.com/golem15/summercms v0.2.0`. Pushed fast-forward: `1bb89ce..46898c2 main -> main`; HEAD == origin/main == 46898c2c754da280481d3a992ebc05ae3dc7c89e.
**Task 3.** Fresh `go build` of examples/hello into a temp dir, run from examples/hello: bare and `--help` both exit 0, stderr empty, stdout byte-identical. 18 command rows (under the 22-row limit), so the list is shown in full, not trimmed. The page's `text` block was diffed against the capture with only `hello` -> `acme` rewritten in the Usage, `help for` and closing `Use "..."` lines: match. Commit adds 37 lines, removes 0. `TestDocsTree`, `docs:build --check` and `go vet ./...` pass; `git status --short examples/` empty.
## Deviations from Plan
**1. [Rule 1 - Verify script] Locale-dependent sort in Task 1 verify**
- **Found during:** Task 1 tracer gate
- **Issue:** The automated verify compares `sort` output to a C-locale ordering; under the shell's locale, `sort` placed `.planning/phases/...` before `.planning/ROADMAP.md`, so the check failed although the commit was correct.
- **Fix:** Re-ran the same verify with `LC_ALL=C`; it printed OK. No file change.
Otherwise the plan executed as written.
## Threat Flags (for Phase 17's security record)
| Threat ID | Category | Component | Severity | Disposition | Mitigation |
|-----------|----------|-----------|----------|-------------|------------|
| T-17-42 | Tampering | sm-sitemap-plugin go.mod/go.sum (summercms v0.1.23 -> v0.2.0) | medium | mitigate | Own tag v0.2.0, same as siblings; go.sum from `go mod tidy` with default GOPROXY and checksum DB; commit touches only go.mod/go.sum; no-replace build only in a removed temp copy. |
| T-17-43 | Tampering | push of sm-sitemap-plugin main | low | mitigate | Clean tree in sync with origin/main before the bump; fast-forward push, no force; HEAD == origin/main verified; consuming apps' gitlinks not moved. |
| T-17-44 | Information disclosure | captured binary output in docs/console/introduction.md | low | accept | Only usage, command names and descriptions; stderr empty and not pasted; only the binary name rewritten to acme. |
## Follow-ups
- Consuming apps still pin the previous sm-sitemap-plugin gitlink (1bb89ce); bumping it to 46898c2 is left for a separate task.
- Untracked `.msd/` and `.planning/phases/17-blog-companions/17-VERIFICATION.md` were left untouched.
## Self-Check: PASSED
- FOUND: .planning/phases/17-blog-companions/COVERAGE.md, .planning/ROADMAP.md, docs/console/introduction.md, ../sm-sitemap-plugin/go.mod, ../sm-sitemap-plugin/go.sum
- FOUND commits: 59f5781, 86c5a80 (summercms.go), 46898c2 (sm-sitemap-plugin, also on origin/main)

View File

@@ -0,0 +1,225 @@
---
phase: quick-261019-hgw
plan: 01
quick_id: 261019-hgw
slug: translate-hook-validationerror-phrase-ke
type: execute
mode: quick
wave: 1
depends_on: []
files_modified:
- modules/cabana/crud.go
- modules/cabana/relation.go
- modules/cabana/actions.go
- modules/cabana/field_type.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/cabana/relation_field.go
- modules/cabana/localize_error_test.go
- modules/cabana/phase121_fixture_test.go
- modules/cabana/phase121_forbidden_test.go
- modules/cabana/reorder_hook_test.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/backend/partials-and-widgets.md
- docs/backend/lists-and-filters.md
autonomous: true
requirements: [17-W-1]
source:
- orchestrator brief (Phase 17 deferred item 1 / verifier W-1)
- .planning/phases/17-blog-companions/17-VERIFICATION.md row W-1 (raw `golem15.menu::lang.errors.url_scheme` shown on refused saves)
- .planning/phases/17-blog-companions/deferred-items.md item 1
- modules/cabana/crud.go:64-135 (ValidationError, ForbiddenError, the ForbiddenError-only localizer, CRUDService.transaction), :369-373 (detached bulk), :825-860 (writeCRUDError)
- modules/cabana/form_schema.go:403-433 (schemaLocale, translateKey), modules/phrasebook/translator.go:153-160 and loader.go:126,233-243 (GetIn returns the key unchanged when absent; every catalog key is `vendor.plugin::group.path`)
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "A form hook that returns a cabana.ValidationError whose Details hold a phrase key answers 422 validation_failed with that message translated in the request locale: the roster fixture's PUT of name Rejected gives `This name is reserved.` under Accept-Language en and `Ta nazwa jest zastrzeżona.` under pl (Postgres test TestValidationErrorLocalized)."
- "The same translation applies on every route that carries plugin-authored errors: form hooks and relation hooks (both service transactions), locked and Detached bulk actions, record actions, toolbar and widget actions (runAction), the pact.ReorderAfterSave hook, the pact.AdminSingleton SingletonRecord call and the cabana.RelationLockProvider on the relation-options route; the reorder and singleton routes now also localize a cabana.ForbiddenError, which they wrote raw before."
- "Text that is not a phrase key (lagoon rule messages already translated in the request locale, framework strings such as `The request body is invalid.`) reaches the client byte-identical and is never looked up in the catalog, so the development log gets no `phrasebook: missing translation key` warning for it."
- "The plugin's error value is never modified: a shared `*cabana.ValidationError` or `*cabana.ForbiddenError` variable keeps its keys after any number of requests in any locale (unit test and the roster Postgres test)."
- "cabana.ForbiddenError behaviour is unchanged: TestForbiddenLocalized, TestForbiddenFromEveryHook, TestForbiddenEmptyMessage and TestForbiddenSmoke stay green; an empty Message stays empty; every other error is still the opaque 500."
- "modules/cabana/README.md and docs/backend/admin-controllers.md, partials-and-widgets.md and lists-and-filters.md say that ValidationError messages may be translation keys translated in the request locale; `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass."
- "`go vet ./...` and `go test -short ./...` are green; the change lands as one fix(cabana) commit staged by explicit path, without a Co-Authored-By trailer; no plugin, application, tag or push."
artifacts:
- "modules/cabana/crud.go — localizeControllerError (ValidationError then ForbiddenError, same order as writeCRUDError), localizeDetails (one shared copy-on-write loop) and localizeDetail (translates only strings containing `::`); the ForbiddenError-only localizer is gone"
- "modules/cabana/localize_error_test.go — Docker-free unit tests (package cabana) on a two-locale fstest catalog"
- "modules/cabana/phase121_fixture_test.go — shared rosterInvalid ValidationError returned by FormBeforeUpdate for the name Rejected"
- "modules/cabana/phase121_forbidden_test.go — TestValidationErrorLocalized (Postgres)"
- "modules/cabana/reorder_hook_test.go — hookShelfPlugin.LangFS and TestReorderHookRefusalLocalized (Postgres)"
key_links:
- "CRUDService.transaction (crud.go:133) and RelationService.transaction (relation.go:873) wrap lagoon.Transaction; every form, relation, bulk and record hook error leaves the services through them, so switching both wrappers to localizeControllerError covers the menu (FormBeforeCreate/FormBeforeUpdate) and FAQ refusals named in W-1."
- "writeCRUDError (crud.go:825) has no context or translator and checks *ValidationError before *ForbiddenError; localization therefore happens at the service and route boundaries, and the helper must test the two types in the same order so the error it localizes is the one writeCRUDError writes."
- "reorderPost (reorder.go:319) and singletonRoute (singleton.go:~90) open their own transactions (lagoon.Transaction, db.Transaction) and pass hook errors through lifecycleFailure straight to writeCRUDError: these are the two boundaries that localized neither error type before this task."
- "The `::` guard is output-preserving: phrasebook builds every key as `pluginID + \"::\" + group` (loader.go:126) or `namespace::group` (overrides), and Catalog.lookup is an exact map lookup, so a string without `::` can never resolve; skipping it only avoids Translator.logMissing."
---
<objective>
Translate the phrase keys in a plugin-authored `cabana.ValidationError` in the request locale before the admin API writes it, exactly as `cabana.ForbiddenError` already is, on every route that can carry such an error.
Purpose: shared plugins (menu: `errors.url_scheme`, `errors.target`, `errors.type_unknown`, `errors.parent_cycle`, `errors.menu_required`; FAQ: `errors.locale_required`) refuse saves with `&cabana.ValidationError{Details: map[string]any{"field": []string{"golem15.menu::lang.errors.url_scheme"}}}`. writeCRUDError writes `ve.Details` as given and the admin SPA renders error strings raw (FormField.vue, FormErrorBanner.vue), so editors see `golem15.menu::lang.errors.url_scheme` instead of the sentence (17-VERIFICATION W-1). The fix belongs in the framework, server side, so every client gets text.
Output: one shared localizer for both refusal types in cabana, applied at every boundary (two service transactions, the detached bulk branch, runAction, reorder, singleton, relation options), Docker-free unit tests plus two Postgres tests, README and docs updates, all in one fix(cabana) commit.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@modules/cabana/crud.go
@modules/cabana/README.md
@docs/backend/admin-controllers.md
Live state observed at planning time (2026-10-19), HEAD d785958, re-check where a task says so:
- `git status --short -- modules/cabana docs` was empty; only `.msd/` is untracked in the repo.
- crud.go:86-127 is the ForbiddenError-only localizer: it sets the locale with `towel.WithLocale(ctx, schemaLocale(ctx, tr))`, builds a NEW ForbiddenError (Message through translateKey; Details copied with string -> []string{t}, []string -> new slice, []any -> new slice translating strings only, anything else passed through) and never writes to the plugin's value. Its callers are exactly four: crud.go:134 (CRUDService.transaction), crud.go:372 (detached bulk branch), relation.go:873 (RelationService.transaction), actions.go:290 (runAction). field_type.go:775 names it in a comment.
- actions.go:279-292 runAction: a `*ValidationError` goes to writeCRUDError untranslated; a `*ForbiddenError` is localized first.
- reorder.go:317-363: `ctx := towel.WithLocale(...)` then `err = lagoon.Transaction(ctx, db, ...)`; the ReorderAfterSave hook error goes through lifecycleFailure; `s.writeReorderError(w, r, cc, err)` ends in writeCRUDError. Neither error type is localized there. The menu plugin implements ReorderAfterSave.
- singleton.go:~62-105: `ctx := r.Context()`, `db.WithContext(ctx).Transaction(attempt)`; a SingletonRecord error goes through lifecycleFailure and then `writeCRUDError(w, err)`. Neither type is localized.
- relation_field.go:~389: CRUDService.RelationOptions returns the relationLocks error (AdminRelationLocks provider, lifecycleFailure-wrapped) unlocalized; the save path's checkRelationLocks runs inside CRUDService.transaction and is covered by it.
- field_file.go and field_type_upload.go open their own transactions but run no hook that can return a plugin ValidationError (plugin field types answer with pact.FormFieldError, already localized by fieldTypeFailure; other plugin errors become the opaque 500). account*.go errors are framework-authored and already translated.
- Translator.GetIn returns the key unchanged when no locale in the fallback chain has it, and in non-production calls logMissing (one slog.Warn per distinct key per translator). lagoon.Validate messages are already translated text; without a guard, every distinct rule message would be logged as a missing key in development.
- Test fixtures: the roster fixture (phase121_fixture_test.go) loads lang/en and lang/pl from testdata/roster; keys `acme.roster::lang.people.refused_name` = `This name is reserved.` / `Ta nazwa jest zastrzeżona.`; FormBeforeUpdate switches on the name (Reserved, Silent, Boom); the name Rejected is unused in cabana tests. rosterLocale sets Accept-Language; rosterError decodes cabana.ErrorEnvelope into cabana.ErrorBody {Code, Message, Details}. The shelf hook env (reorder_hook_test.go) assembles through paymentsAssemble, which calls phrasebook.Activate(app, plugins), so a `LangFS() fs.FS` method on hookShelfPlugin loads `acme.shelf::lang.*` keys; the app locale is en. TestReorderHookErrorRollsBack already covers plain-text ValidationError ("Refused.") and ForbiddenError ("Locked.").
- account_preferences_internal_test.go shows the Docker-free translator pattern: phrasebook.NewCatalog, cat.Load(pluginID, fstest.MapFS{...}), phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}).
Repo rules (CLAUDE.md): framework code only, no plugin or application change; a behaviour change to an exported type updates the module README and docs pages in the same commit; every identifier on a docs page must exist (docs checker); no unexported helper names in README/docs; stage by explicit path, never `git add -A`; leave other sessions' edits (STATE.md, state.json, `.msd/`) alone; no Co-Authored-By trailer (user instruction overrides the attribution reminder); no push, no tag; Docker-backed tests skip under -short, and if the Docker socket is denied run them through `sudo -u golem`.
</context>
<!-- planner-discipline-allow: localizeForbidden -->
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Shared refusal localizer, wired end-to-end on the form-hook save path</name>
<files>modules/cabana/crud.go, modules/cabana/relation.go, modules/cabana/actions.go, modules/cabana/field_type.go, modules/cabana/localize_error_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_forbidden_test.go</files>
<precondition>`git status --short -- modules/cabana docs` prints nothing (no other session has in-flight edits in cabana or docs); if it does, stop and report instead of editing.</precondition>
<behavior>
- Unit (localize_error_test.go, package cabana, translator built from fstest.MapFS for plugin acme.menu with lang/en/lang.yaml and lang/pl/lang.yaml defining errors.url_scheme and errors.target): a ValidationError whose Details are {"url": []string{"acme.menu::lang.errors.url_scheme"}, "target": "acme.menu::lang.errors.target" (bare string), "mixed": []any{"acme.menu::lang.errors.url_scheme", 7}, "title": []string{"The title field is required."}, "nested": map[string]any{"pl": []string{"acme.menu::lang.errors.url_scheme"}}} comes back, under towel.WithLocale "pl" and then "en", as url -> []string{translated}, target -> []string{translated}, mixed -> []any{translated, 7}, title unchanged, nested unchanged (passed through like the ForbiddenError loop always did).
- Unit: an unknown phrase key (acme.menu::lang.errors.missing) comes back unchanged.
- Unit: the input value is not modified (deep-compare against a copy taken before the call; the result is a different pointer, and its slices are new); localizing the same shared value twice in different locales gives each locale's text.
- Unit: nil -> nil; errors.New("x") -> the identical value; a ValidationError with nil or empty Details -> the identical value; fmt.Errorf("save: %w", ve) -> a localized *ValidationError.
- Unit (regression): a ForbiddenError with Message and Details phrase keys is localized in en and pl as before; an empty Message stays empty; the plugin's value is untouched.
- Unit (envelope): writeCRUDError on an httptest.ResponseRecorder with the localized pl error answers 422, code validation_failed, details.url == ["<pl text>"].
- Unit (guard): with slog.SetDefault swapped for a text handler on a bytes.Buffer (restored in t.Cleanup; this test does not call t.Parallel) and a fresh translator, localizing a ValidationError whose only message is plain text leaves no `missing translation key` in the buffer, while one with an unknown `::` key does log it (positive control proving the capture works).
- Postgres (TestValidationErrorLocalized in phase121_forbidden_test.go): PUT name Rejected under Accept-Language en answers 422, code validation_failed, details {"name": ["This name is reserved."]}; under pl, ["Ta nazwa jest zastrzeżona."]; afterwards the shared rosterInvalid still holds the key.
</behavior>
<action>
RED first: write localize_error_test.go and the Postgres test from the behavior list, run them, and confirm they fail (the unit file fails to compile until the helper exists; the Postgres test sees the raw key).
Then in crud.go replace the ForbiddenError-only localizer (lines 86-127) with three unexported functions, keeping its doc-comment guarantees:
- localizeControllerError(ctx context.Context, tr *phrasebook.Translator, err error) error. nil and any error that is neither type are returned unchanged. Test the types in writeCRUDError's order: first errors.As to *ValidationError (non-nil pointer, len(Details) > 0, else return err as is) and return a NEW &ValidationError{Details: localizeDetails(...)}; then *ForbiddenError, built exactly as today (Message through translateKey so an empty Message stays empty, Details through localizeDetails when non-empty, nil otherwise). Keep the nil-ctx guard and set the locale once with towel.WithLocale(ctx, schemaLocale(ctx, tr)).
- localizeDetails(ctx, tr, details map[string]any) map[string]any: the one shared copy-on-write loop both types use (string -> []string{localized}; []string -> new slice; []any -> new slice, strings localized, other items kept; any other value passed through untouched). It allocates a new map and new slices and never writes to the input.
- localizeDetail(ctx, tr, text string) string: returns text unchanged unless it contains "::", else translateKey. Its comment states why: every phrasebook key is vendor.plugin::group.path and lookup is exact, so text without "::" can never resolve; skipping it keeps already-translated lagoon rule messages byte-identical and keeps the development log free of a missing-key warning per rule message. The guard is output-identical for ForbiddenError.
The plugin's error value is never modified (it may be a shared package variable used by concurrent requests); say so in the comment.
Update the ValidationError doc comment (crud.go:64): Details maps a field to messages, each a phrase key or text; keys are translated in the request locale before the response is written, like ForbiddenError.
Switch all four existing callers to localizeControllerError and update their comments: CRUDService.transaction (crud.go:130-135, now "localizes a *ValidationError or *ForbiddenError that comes out of it"), the Detached bulk branch (crud.go:372), RelationService.transaction (relation.go:871-874). In actions.go runAction (lines 272-292) collapse the two branches into one: when err is a *ValidationError or a *ForbiddenError, writeCRUDError(w, localizeControllerError(ctx, tr, err)) and return; update the doc comment above runAction (a 422 and a 403, both localized). Update the comment at field_type.go:775 to name localizeControllerError.
Fixture: in phase121_fixture_test.go add, next to rosterRefused, a shared `rosterInvalid = &cabana.ValidationError{Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}}}` with a comment (the framework must localize a copy, never this value), and a `case "Rejected": return rosterInvalid` in rosterController.FormBeforeUpdate; extend that method's doc comment. Reuse the existing refused_name key; do not touch testdata lang files.
Postgres test: TestValidationErrorLocalized in phase121_forbidden_test.go with newRosterEnv, rosterInsert, rosterLocale(env, PUT, rosterPath(id, ""), `{"name":"Rejected"}`, locale) and rosterError, modelled on TestForbiddenLocalized; finish by asserting rosterInvalid.Details["name"] still equals []string{"acme.roster::lang.people.refused_name"}.
GREEN: run the unit tests and the Postgres tests until they pass. Do NOT commit; Task 3 commits the whole change together with the docs (one logical change, CLAUDE.md docs-in-the-same-commit rule).
</action>
<verify>
<automated>go vet ./modules/cabana && go test -short ./modules/cabana -run 'TestLocalizeControllerError|TestWriteCRUDErrorLocalized' -count=1 && go test ./modules/cabana -run 'TestValidationErrorLocalized|TestForbiddenLocalized|TestForbiddenFromEveryHook|TestForbiddenEmptyMessage|TestForbiddenSmoke' -count=1 && ! grep -rn --include=*.go 'localizeForbidden' modules/ && test "$(grep -c 'localizeControllerError(' modules/cabana/actions.go)" -ge 1</automated>
</verify>
<done>The unit tests (every behavior bullet, en and pl) and TestValidationErrorLocalized pass; the four ForbiddenError Postgres tests stay green (if the Docker socket is denied they were run through `sudo -u golem`); no Go file mentions the old localizer name; runAction localizes both error types; nothing committed yet.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Localize refusals on the reorder, singleton and relation-options routes, and audit every remaining path</name>
<files>modules/cabana/reorder.go, modules/cabana/singleton.go, modules/cabana/relation_field.go, modules/cabana/reorder_hook_test.go</files>
<behavior>
- Postgres (TestReorderHookRefusalLocalized in reorder_hook_test.go): with hookShelfPlugin serving lang/en/lang.yaml for acme.shelf (errors.order, errors.locked), a ReorderAfterSave hook returning &cabana.ValidationError{Details: {"nodes": []string{"acme.shelf::lang.errors.order"}}} answers 422 whose body carries the English sentence and not the key; one returning &cabana.ForbiddenError{Message: "acme.shelf::lang.errors.locked"} answers 403 with the English sentence as message; the sort values are unchanged in both cases.
- TestReorderHookErrorRollsBack, TestReorderHookRunsInTx and TestReorderWithoutHookUnchanged stay green (the plain-text "Refused." and "Locked." still come back as written).
</behavior>
<action>
RED first: add `LangFS() fs.FS` to hookShelfPlugin in reorder_hook_test.go returning an fstest.MapFS with lang/en/lang.yaml defining errors.order and errors.locked (two short English sentences), then write TestReorderHookRefusalLocalized from the behavior list with newShelfHookEnv and env.swap; run it and see the raw keys come back.
Then wire the three boundaries that localize nothing today (each a one-line change plus a comment line):
- reorder.go reorderPost: right after the lagoon.Transaction call returns, set err = localizeControllerError(ctx, s.translator(), err) before the existing `if err != nil { s.writeReorderError(...) }`; ctx is the locale context declared above the transaction. writeReorderError keeps its logging rules unchanged. Update the reorderPost doc comment: a ValidationError or ForbiddenError from ReorderAfterSave is answered localized.
- singleton.go singletonRoute: in the final error branch, write writeCRUDError(w, localizeControllerError(ctx, s.translator(), err)); the slog.Error line stays as is.
- relation_field.go CRUDService.RelationOptions: when relationLocks returns an error, return localizeControllerError(ctx, s.tr, err) in its place.
Audit (report-only unless a gap is found): list every `Transaction(` call in modules/cabana/*.go outside _test.go, and every lifecycleFailure or actionFailure call whose argument is the error of a plugin-implemented method (pact.* hook, AdminAction Run/Applies, RelationLockProvider, AdminSingleton). For each, record in the SUMMARY which boundary localizes it (CRUDService.transaction, RelationService.transaction, the detached branch, runAction, reorderPost, singletonRoute, RelationOptions) or why it cannot carry a plugin-authored ValidationError/ForbiddenError (framework-only error, pact.FormFieldError path, opaque 500). If a path is found that writes a plugin-authored ValidationError or ForbiddenError to the client unlocalized and has a translator in reach, wrap it with localizeControllerError in this task and list it; a read-only path where that would need new plumbing is listed as a finding instead.
Do NOT commit; Task 3 commits.
</action>
<verify>
<automated>go vet ./modules/cabana && go test -short ./modules/cabana -count=1 && go test ./modules/cabana -run 'TestReorderHook|TestReorderWithoutHookUnchanged' -count=1 && for f in reorder singleton relation_field; do test "$(grep -c 'localizeControllerError(' modules/cabana/$f.go)" -ge 1 || { echo "missing in $f.go"; exit 1; }; done</automated>
</verify>
<done>reorderPost, singletonRoute and RelationOptions pass their hook errors through localizeControllerError; TestReorderHookRefusalLocalized and the existing reorder hook tests pass on Postgres; the audit table (path -> covering boundary or reason) is ready for the SUMMARY; nothing committed yet.</done>
</task>
<task type="auto">
<name>Task 3: Document the localized ValidationError, run every gate, commit once</name>
<files>modules/cabana/README.md, docs/backend/admin-controllers.md, docs/backend/partials-and-widgets.md, docs/backend/lists-and-filters.md</files>
<action>
Docs (no unexported helper names, only identifiers that exist; keep the existing voice and sentence style):
- modules/cabana/README.md line 55 (Refusals bullet): add that a cabana.ValidationError returned from the same places, and from a pact.ReorderAfterSave hook or a pact.AdminSingleton SingletonRecord call, is answered 422 validation_failed with each Details message that is a translation key (`vendor.plugin::group.key`) translated in the request locale, text passing through unchanged; add the reorder hook and singleton record to the places a ForbiddenError is localized. Table row at line 311 (ValidationError / ListValidationError): a hook or an action may return a cabana.ValidationError, whose Details messages may be translation keys, translated in the request locale. Testing section (line 378 on): one sentence naming localize_error_test.go (Docker-free unit tests of the shared localization) and the Postgres tests TestValidationErrorLocalized and TestReorderHookRefusalLocalized.
- docs/backend/admin-controllers.md, section "Refusing a write" (lines 265-278): the ValidationError table row gains "each message that is a translation key is translated in the request locale"; the last paragraph's "A cabana.ForbiddenError works the same from ..." covers both errors and adds the reorder hook (pact.ReorderAfterSave) and the singleton record (pact.AdminSingleton). Add one short example sentence: a hook can return a field message as a phrase key such as `acme.blog::lang.errors.slug_taken` and the administrator sees the sentence in their language. Line 444 (bulk action Run): a cabana.ValidationError is a 422 with its messages translated.
- docs/backend/partials-and-widgets.md line 73: after "An action may return a cabana.ValidationError to answer 422 on a field." add that its messages may be translation keys, translated in the request locale.
- docs/backend/lists-and-filters.md line 287: after "A returned error rolls the whole reorder back." add that a cabana.ValidationError is answered 422 and a cabana.ForbiddenError 403, both with their messages translated in the request locale, and any other error is the opaque 500.
Use neutral names (acme, blog); never name a consuming application or the menu/FAQ plugins.
Gates, in order, fixing anything red before moving on: go vet ./... ; go test -short ./... ; go test ./cmd/summer -run TestDocsTree ; go run ./cmd/summer docs:build --check ; the Postgres set from Tasks 1 and 2 once more (TestValidationErrorLocalized, TestForbidden*, TestReorderHook*, TestReorderWithoutHookUnchanged), through `sudo -u golem` if the Docker socket is denied (if Docker is unavailable entirely, say so in the SUMMARY; the -short gates still must pass).
Commit: re-run `git status --short` and stage ONLY the files of this plan's files_modified list that actually changed, by explicit path (never `git add -A`/`git add .`; leave `.msd/`, STATE.md, state.json and any other session's edits alone). One commit, subject `fix(cabana): translate hook ValidationError phrase keys in the request locale`, a short body (shared localizer for ValidationError and ForbiddenError; applied on the service transactions, actions, reorder, singleton and relation-options routes; text without `::` passes through unchanged; plugin values never modified; docs). No Co-Authored-By or other attribution trailer. No push, no tag.
</action>
<verify>
<automated>go vet ./... && go test -short ./... && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && grep -q 'translation key' docs/backend/admin-controllers.md && C=$(git log -1 --format=%H --fixed-strings --grep='fix(cabana): translate hook ValidationError phrase keys in the request locale') && test -n "$C" && BODY=$(git log -1 --format=%B "$C") && ! printf '%s\n' "$BODY" | grep -qi 'co-authored-by' && FILES=$(git diff --name-only "$C^..$C") && test -n "$FILES" && test -z "$(printf '%s\n' "$FILES" | grep -v -E '^(modules/cabana/|docs/backend/)')"</automated>
</verify>
<done>README and the three docs pages describe the localized ValidationError (and the reorder and singleton refusals); vet, short tests, docs tree test and docs:build --check pass; the Postgres set passed (or Docker unavailability is recorded); the single fix(cabana) commit (found by subject) touches only modules/cabana/ and docs/backend/ files, with no attribution trailer; nothing pushed or tagged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| plugin controller code -> cabana error writer | plugin-authored ValidationError/ForbiddenError values (often shared package variables) cross into the framework's response path on concurrent requests |
| admin API response -> admin client | localized messages are written into the 422/403 JSON envelope read by the admin SPA and any other client |
| request Accept-Language -> translator | the client chooses the locale the messages are translated into |
## STRIDE Threat Register
Numbering continues after the highest Phase 17 ID in use (T-17-44, registered by quick-261019-05g).
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-17-45 | Tampering | localizeControllerError / localizeDetails on shared plugin error values | medium | mitigate | Copy-on-write only: a new error, new map and new slices per call, the input never written; unit test deep-compares the input before/after and localizes one shared value in two locales; the roster Postgres test asserts rosterInvalid keeps its key. Prevents a data race and one request's locale leaking into another's response. |
| T-17-46 | Information disclosure | runAction, reorderPost, singletonRoute, RelationOptions error paths | medium | mitigate | Only *ValidationError and *ForbiddenError are rebuilt; every other error is returned unchanged and still becomes the opaque 500 in writeCRUDError, so no foreign error text reaches the client; unit test asserts a plain error is returned as the identical value; existing TestForbiddenSmoke and TestReorderHookErrorRollsBack 500 assertions stay green. |
| T-17-47 | Information disclosure | translating a `::` string a plugin echoes from user input | low | accept | The catalog holds UI strings the admin already downloads through the lang endpoint; a lookup can only return such text or the input unchanged. |
| T-17-48 | Denial of service | development-log volume from missing-key warnings | low | mitigate | localizeDetail looks up only strings containing `::`, so already-translated rule messages and framework text never reach Translator.logMissing; guard test asserts no warning for plain text with a positive control. Production translators do not log missing keys at all. |
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager install and no new Go dependency: the change uses only phrasebook, towel, the standard library and existing test helpers. |
</threat_model>
<verification>
- `go vet ./...` and `go test -short ./...` green.
- Docker: TestValidationErrorLocalized, TestReorderHookRefusalLocalized, TestForbiddenLocalized, TestForbiddenFromEveryHook, TestForbiddenEmptyMessage, TestForbiddenSmoke, TestReorderHookErrorRollsBack, TestReorderHookRunsInTx, TestReorderWithoutHookUnchanged green (through `sudo -u golem` if the socket is denied).
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` green.
- `grep -rn --include=*.go 'localizeForbidden' modules/` prints nothing; localizeControllerError appears in crud.go, relation.go, actions.go, reorder.go, singleton.go and relation_field.go.
- The `fix(cabana): translate hook ValidationError phrase keys in the request locale` commit (located by subject, not by HEAD; other sessions may commit in between) touches only modules/cabana/ and docs/backend/ files and carries no attribution trailer; `git status --short` shows no leftover change from this plan (other sessions' files untouched); nothing pushed or tagged; no change outside summercms.go.
</verification>
<success_criteria>
An editor whose save, reorder or action a shared plugin refuses with a phrase-key ValidationError sees the sentence in their admin language instead of `vendor.plugin::lang.errors.*`, with no plugin, application or SPA change; already-translated messages are unchanged; ForbiddenError behaves as before and is now also localized on the reorder and singleton routes.
</success_criteria>
<output>
Create `.planning/quick/261019-hgw-translate-hook-validationerror-phrase-ke/261019-hgw-SUMMARY.md` when done, listing: the commit hash; the audit table from Task 2 (each Transaction/hook path -> covering boundary or reason it cannot carry a plugin refusal) and any additional path wrapped or left as a finding; whether the Postgres tests ran directly or through `sudo -u golem` (or not at all, with the reason); and the new threat rows T-17-45 .. T-17-48 for later registration in Phase 17's security record. Note that consuming applications pick the fix up with their next framework version bump (no tag in this task) and that W-1 in 17-VERIFICATION.md / deferred-items.md item 1 can be marked resolved by that commit.
</output>

View File

@@ -0,0 +1,154 @@
---
phase: quick-261019-hgw
plan: 01
quick_id: 261019-hgw
subsystem: cabana
status: complete
tags: [cabana, phrasebook, i18n, validation, refusals]
requires: [phrasebook.Translator, towel.WithLocale]
provides: [localized cabana.ValidationError details on every admin route that carries plugin refusals]
affects: [modules/cabana, docs/backend]
tech-stack:
added: []
patterns: [copy-on-write error localization at service/route boundaries, "::" phrase-key guard]
key-files:
created:
- modules/cabana/localize_error_test.go
modified:
- modules/cabana/crud.go
- modules/cabana/relation.go
- modules/cabana/actions.go
- modules/cabana/field_type.go
- modules/cabana/reorder.go
- modules/cabana/singleton.go
- modules/cabana/relation_field.go
- modules/cabana/phase121_fixture_test.go
- modules/cabana/phase121_forbidden_test.go
- modules/cabana/reorder_hook_test.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/backend/partials-and-widgets.md
- docs/backend/lists-and-filters.md
decisions:
- "ValidationError Details messages that contain `::` are translated in the request locale at the same boundaries as ForbiddenError; text without `::` is never looked up (output-identical, no missing-key log noise)."
- "ShowRecord's read transaction also localizes refusals (FormRecordTitle, record-action Applies, RelationLockProvider), found in the Task 2 audit."
requirements: [17-W-1]
duration: 9min
completed: 2026-10-19
commits: 1
plan_head_before: d785958df239a463b68bc4570f487f320fe722a9
plan_head_after: ce8d7efb675a15840b5ee274d11625d920827147
actuals:
tokens: 12350
tasks: 3
commits: 1
---
# Quick 261019-hgw: Translate hook ValidationError phrase keys Summary
cabana now translates the phrase keys in a plugin's `cabana.ValidationError` Details into the request locale before writing the 422, the same way it already handled `cabana.ForbiddenError`. One copy-on-write localizer (`localizeControllerError`) handles both error types. It runs at every boundary where a plugin can return an error: the two service transactions, the detached bulk branch, runAction, ShowRecord, reorder, singleton and relation options.
## Commit
- `ce8d7ef` fix(cabana): translate hook ValidationError phrase keys in the request locale (15 files, modules/cabana/ and docs/backend/ only, no attribution trailer, not pushed, not tagged)
## What changed
- `crud.go`: `localizeForbidden` is replaced by:
- `localizeControllerError`, which checks ValidationError first and then ForbiddenError, the same order writeCRUDError uses.
- `localizeDetails`, a shared copy-on-write loop.
- `localizeDetail`, which looks up only strings that contain `::`.
The ValidationError doc comment now describes the localization. CRUDService.transaction, the Detached bulk branch and ShowRecord use the new localizer.
- `relation.go`: RelationService.transaction uses `localizeControllerError`.
- `actions.go`: runAction now has a single branch that localizes both error types.
- `reorder.go`: reorderPost localizes the transaction error before writeReorderError. Neither error type was localized here before.
- `singleton.go`: singletonRoute localizes before writeCRUDError. Neither error type was localized here before.
- `relation_field.go`: RelationOptions localizes the RelationLockProvider error.
- `field_type.go`: the comment now names the new helper.
## Tests
- Unit tests in `localize_error_test.go` (Docker-free, two-locale `fstest` catalog for acme.menu):
- TestLocalizeControllerErrorValidation covers each Details shape (`[]string`, bare string, `[]any` with a non-string item, plain text, a nested map, an unknown key) in pl, en and pl again on one shared value. It checks the result is a different pointer with new slices and that the input is deep-equal before and after.
- TestLocalizeControllerErrorUnchanged covers nil, a plain error, nil or empty Details, a typed-nil `*ValidationError`, a wrapped `%w`, and a nil translator.
- TestLocalizeControllerErrorForbidden is the ForbiddenError regression test (en and pl, empty Message, value untouched).
- TestWriteCRUDErrorLocalized checks the 422 envelope in pl.
- TestLocalizeControllerErrorPlainTextNotLookedUp swaps slog. Plain text logs nothing; the positive control (an unknown `::` key) does log.
- Postgres tests:
- TestValidationErrorLocalized (roster PUT name `Rejected` gives `This name is reserved.` in en and `Ta nazwa jest zastrzeżona.` in pl; the shared `rosterInvalid` keeps its key).
- TestReorderHookRefusalLocalized (hookShelfPlugin.LangFS; ValidationError gives 422 with the sentence, ForbiddenError gives 403 with the sentence; sort values unchanged).
- RED was observed first. The unit file failed to compile, and the Postgres tests returned `acme.roster::lang.people.refused_name` / `acme.shelf::lang.errors.*` raw.
## Gates
- `go vet ./...`: green
- `go test -short ./...`: green
- `go test ./cmd/summer -run TestDocsTree`: green
- `go run ./cmd/summer docs:build --check`: no problems found
- Postgres: ran **directly** (Docker socket accessible, no `sudo -u golem` needed):
- the full `go test ./modules/cabana`: ok, 97s
- the named set: TestValidationErrorLocalized, TestReorderHookRefusalLocalized, TestForbiddenSmoke/FromEveryHook/Localized/RollsBack/EmptyMessage, TestReorderHookRunsInTx, TestReorderHookErrorRollsBack (plain text "Refused."/"Locked." unchanged), TestReorderWithoutHookUnchanged. All PASS.
## Audit: Transaction and plugin-hook paths (Task 2)
| Path | Plugin-authored error source | Covered by / reason |
|------|------------------------------|---------------------|
| crud.go CRUDService.transaction (Delete, BulkDelete, BulkAction locked, RecordAction, save) | Form* hooks (lifecycleFailure), bulk/record action Run and Applies (actionFailure), checkRelationLocks, FormRecordTitle on save | CRUDService.transaction |
| crud.go detachedBulkAction | bulk action Run (Detached) | Detached branch in BulkAction |
| crud.go:512 ShowRecord (`s.DB.Transaction`) | FormRecordTitle, record-action Applies (offeredRecordActions), RelationLockProvider via projectRelationFields | **Gap found and wrapped in this task**: ShowRecord now returns `localizeControllerError(ctx, s.tr, err)` |
| relation.go RelationService.transaction (Link, Unlink, CreateChild/ShowChild/UpdateChild/DeleteChildren, ShowPivot/UpdatePivot) | Relation* hooks, RelationBeforeLink | RelationService.transaction |
| relation.go:625 RelationService.query (Linked/Candidates) | none: RelationExtendManageQuery and FormExtendQuery return `*gorm.DB`, not an error | cannot carry a plugin refusal |
| actions.go runAction (toolbar and widget actions) | AdminAction Run | runAction |
| reorder.go:321 reorderPost (`lagoon.Transaction`) | ReorderAfterSave | reorderPost (new) |
| reorder.go:279 reorderGet | none: ListExtendQuery returns `*gorm.DB` | cannot carry a plugin refusal |
| singleton.go:93/96 singletonRoute | AdminSingleton.SingletonRecord | singletonRoute (new) |
| relation_field.go RelationOptions | RelationLockProvider (AdminRelationLocks) | RelationOptions (new) |
| query.go list (ListExtendRecords, ListRowStates) | plugin hooks returning error | list route writes only the framework `ListValidationError` as 422; every other error is the opaque 500 and is never written raw |
| field_dependent.go dependent options | DependentOptionsProvider | opaque 500 (logged) |
| partial_render.go PartialData | AdminPartialData | opaque 500 (logged) |
| field_file.go:664/733/1004, field_type_upload.go:102 | none: plugin field types answer with pact.FormFieldError (fieldTypeFailure, already localized) | cannot carry a plugin ValidationError/ForbiddenError |
| settings.go:230 SettingsService save | none: no plugin hook in the transaction; ValidationErrors are framework-authored text | text passes through unchanged |
| account.go:322/600/664, account_preferences.go:112 | framework-authored, already translated | not a plugin path |
| commands.go:70/124 | console commands, not HTTP | not an admin API response |
There are no open findings. The one extra gap (ShowRecord) had a translator in reach and is wrapped.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical functionality] ShowRecord read path wrote plugin refusals raw**
- **Found during:** Task 2 audit
- **Issue:** FormRecordTitle, record-action Applies and RelationLockProvider errors on the record GET reached writeCRUDError without localization. The translator `s.tr` was in reach.
- **Fix:** ShowRecord wraps its transaction error with `localizeControllerError` and its doc comment says so. The plan's Task 2 instruction covers this case ("wrap it ... and list it").
- **Files modified:** modules/cabana/crud.go (already in files_modified)
- **Commit:** ce8d7ef
Minor: the ForbiddenError Message now goes through `localizeDetail` (the `::` guard) instead of `translateKey` directly. This is output-identical, because a string without `::` can never resolve, and an empty Message stays empty. The regression tests confirm it.
## Threat Flags / Threat rows for Phase 17 security record
| Threat ID | Category | Component | Disposition | Status |
|-----------|----------|-----------|-------------|--------|
| T-17-45 | Tampering | localizeControllerError / localizeDetails on shared plugin error values | mitigate | Mitigated: new error, map and slices on every call. Covered by the unit deep-compare, two-locale reuse on one shared value, and the `rosterInvalid` Postgres assertion. |
| T-17-46 | Information disclosure | runAction, reorderPost, singletonRoute, RelationOptions, ShowRecord error paths | mitigate | Mitigated: only the two refusal types are rebuilt; any other error is returned as the identical value (unit test) and stays the opaque 500 (TestForbiddenSmoke, TestReorderHookErrorRollsBack plain_error). |
| T-17-47 | Information disclosure | translating a `::` string echoed from user input | accept | Accepted: lookup returns only catalog UI text or the input. |
| T-17-48 | Denial of service | dev-log volume from missing-key warnings | mitigate | Mitigated: `::` guard. TestLocalizeControllerErrorPlainTextNotLookedUp has a positive control. |
No new security surface outside the threat model.
## Known Stubs
None.
## Follow-ups
- Consuming applications get the fix when they next bump the framework version. No tag was made in this task.
- W-1 in `.planning/phases/17-blog-companions/17-VERIFICATION.md` and item 1 in `deferred-items.md` can be marked resolved by `ce8d7ef`.
## Self-Check: PASSED
- FOUND: modules/cabana/localize_error_test.go
- FOUND: commit ce8d7ef (subject matches, files limited to modules/cabana/ and docs/backend/, no co-authored-by)
- `grep -rn localizeForbidden modules/ --include=*.go`: empty. `localizeControllerError(` appears in crud.go, relation.go, actions.go, reorder.go, singleton.go and relation_field.go.

View File

@@ -3,7 +3,7 @@ id: SEED-261007-0tb
status: dormant
planted: 2026-10-07
planted_during: Phase 16 (context gathered)
trigger_when: Phase 20 (Płytarium cutover) is complete. Phases 19 to 19.6 already deliver the framework and shared-plugin work WavePath needs.
trigger_when: Phase 30 (Płytarium cutover) is complete. The WavePath work then runs as Phases 31 to 35, starting with chat polish against WavePath's clients (Phase 31, formerly 19.2) and including the move off Mattermost.
scope: Large
---
@@ -15,7 +15,7 @@ Wavepath (`/media/nvme/dev/jz/wavepath.org`) is the largest Golem15 app and the
## When to Surface
**Trigger:** once Phase 20 is complete. Phases 19 to 19.6 come first and cover WavePath's framework asks (chat, chat polish, transport and infrastructure, OIDC, social login, 2FA and device flow). The milestone no longer waits for the Horoskopia port (changed 2026-10-11).
**Trigger:** once Phase 30 (Płytarium cutover) is complete. Phases 19 to 19.7 come first and cover WavePath's framework asks (chat, transport and infrastructure, OIDC, social login, 2FA and device flow); chat polish against WavePath's clients moved to Phase 31 and opens the WavePath phases (31 to 35, including the move off Mattermost). The milestone no longer waits for the Horoskopia port (changed 2026-10-11). Reordered 2026-10-18, see `.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`.
## Scope Estimate

View File

@@ -3,7 +3,7 @@ id: SEED-261007-j2b
status: dormant
planted: 2026-10-07
planted_during: Phase 16 (context gathered)
trigger_when: Phase 20 (Płytarium cutover) is complete, plus the stabilization phase if one is added. It comes before the Wavepath port.
trigger_when: The WavePath phases (31 to 35) are complete. Horoskopia-driven development takes Phases 36 to 40.
scope: Large
---
@@ -15,7 +15,7 @@ Horoskopia (`/media/nvme/dev/golem15/horoskopia.eu`) is the second app port afte
## When to Surface
**Trigger:** Phase 20 (Płytarium cutover) is complete, plus the stabilization phase if one is added. This port comes before the Wavepath port (SEED-261007-0tb).
**Trigger:** the WavePath phases (31 to 35, SEED-261007-0tb) are complete. Horoskopia-driven development takes Phases 36 to 40. This port used to come before WavePath; the order flipped on 2026-10-11 and the numbers were set on 2026-10-18, see `.planning/notes/2026-10-18-roadmap-reorder-demo-release-wavepath.md`.
## Scope Estimate

View File

@@ -0,0 +1,19 @@
---
title: Sitemap - replace the browser XSLT view before Chromium drops XSLT
date: 2026-10-19
priority: normal
area: sm-sitemap-plugin
g15office: w8kupqi3
---
Chromium now warns on `/sitemap.xml` (seen on UAT during the Phase 17 UAT, test 7): "This site uses XSLT; that functionality is being removed from this browser very soon…". The sitemap plugin serves the PHP plugin's `<?xml-stylesheet type="text/xsl" href=".../sitemap.xsl"?>` and `/sitemap.xsl` to show a readable table in the browser.
Crawlers read the XML and are unaffected; only the in-browser view breaks.
Options:
- Drop the stylesheet processing instruction and `/sitemap.xsl`. This breaks byte parity with the PHP golden, so the parity tests change too.
- Keep the XML for crawlers and serve an HTML view elsewhere (for example `/sitemap.html`, or in the admin).
- A JS-based XSLT polyfill on the XSL page.
Decide, then update the plugin, its goldens and README.

View File

@@ -269,13 +269,15 @@ A hook or an action stops a write by returning an error. Which error decides wha
| Error | Answer |
|-------|--------|
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field. |
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field; each message that is a translation key is translated in the request locale. |
| `cabana.ForbiddenError` | 403 `forbidden`, with its `Message` and its `Details`. |
| any other error | The opaque 500. The error is logged on the server and its text never reaches the client. |
Return a `cabana.ForbiddenError` when the signed-in administrator may open the screen but may not make this particular change, for example editing a record that needs a higher permission. `Message` is a translation key or text; cabana translates it, and every `Details` message, in the request locale. `Details` maps a field name to a list of messages, as a `cabana.ValidationError` does, and may be left out. With an empty `Message` the admin shows its own text.
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. A `cabana.ForbiddenError` works the same from the form hooks, the relation hooks, and bulk, record, toolbar and widget actions.
A `cabana.ValidationError` message may be a translation key too. A hook that returns `acme.blog::lang.errors.slug_taken` on the `slug` field shows the administrator the sentence in their language; text that is not a key, such as an already translated message, is sent as written. cabana translates a copy, so the error can be a shared package variable.
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. Both errors work the same, translated in the request locale, from the form hooks, the relation hooks, bulk, record, toolbar and widget actions, the reorder hook (`pact.ReorderAfterSave`) and the singleton record (`pact.AdminSingleton`).
## Toolbar actions
@@ -441,7 +443,7 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se
- The ids are resolved and row-locked through the controller's `pact.ListExtendQuery` scope inside one transaction. `Run` receives the loaded records in `pact.AdminBulkActionInput`, never the ids, so an id outside the scope cannot reach plugin code.
- A selection that matches no row in the scope answers `affected: 0` without calling `Run`. A selection of which only a part is in the scope answers 409 `conflict` and changes nothing.
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500.
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 with its messages translated in the request locale, and any other error the opaque 500.
- The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
- `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`.
- An action with `Detached: true` runs outside the framework transaction. Its records are loaded through the same scope, with the same empty and 409 rules, but without row locks, and `cabana.TxFromContext` reports no transaction. `Run` opens the short transactions it needs (`lagoon.Transaction`). Set it on an action that calls an external service, so that no transaction or row lock is held across the call. What the action saved before a later failure stays saved.

View File

@@ -284,7 +284,7 @@ A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../re
- A duplicate id, a parent outside the set, a cycle and a node deeper than `maxDepth` are 422 `validation_failed` on `nodes`; a partitioned page without `partition` is a 422 on `partition`.
- Otherwise the parent and sort columns of every node are written, without running the model's save hooks, and the answer is the new tree.
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. A `cabana.ValidationError` is answered 422 and a `cabana.ForbiddenError` 403, both with their messages translated in the request locale; any other error is the opaque 500. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).

View File

@@ -70,7 +70,7 @@ lookup:
- `action` names an action the controller registers through `pact.HasAdminActions`.
- `fill` lists the writable scalar fields of the same form the action may write back.
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field.
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field; its messages may be translation keys, translated in the request locale.
### Widget element contract

View File

@@ -15,6 +15,43 @@ WinterCMS has one console entry point, `php artisan`. SummerCMS has two programs
The application binary is what you deploy, so everything that must run in production, such as migrations and workers, is a command of the binary rather than of `summer`.
Run with no arguments, the binary prints its usage and every command it has, the framework's and those its plugins add; `--help` prints the same:
```sh
./bin/acme
./bin/acme --help
```
```text
Usage:
acme [command]
Available Commands:
admin:create Create an activated backend administrator
admin:reset-password Reset a backend administrator password and revoke existing tokens
admin:token:create Create a personal API token for a backend administrator
admin:token:list List personal API tokens for a backend administrator (or all)
admin:token:revoke Revoke a personal API token by id
admin:token:scopes List available personal API token scopes
deferred:purge Delete expired deferred bindings with their pending uploads and created records
greeter:hello Print the configured application name
help Help about any command
key:generate Print a fresh 32-byte base64 app.key
migrate Run plugin migrations in dependency order
migrate:rollback Roll back the last migration of a plugin
migrate:status Show per-plugin migration history
queue:clear Clear all queued jobs, by deleting all pending jobs.
queue:work Run background job workers in the foreground
route:list List registered HTTP routes
schedule:run Run the scheduler in the foreground (or once for system cron)
serve Serve the application HTTP API
Flags:
-h, --help help for acme
Use "acme [command] --help" for more information about a command.
```
## Getting help
Both programs list their commands with `--help`, and every command accepts `--help` for its arguments and flags:

View File

@@ -52,7 +52,7 @@ Use cases:
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) significant fraction digits (trailing zeros beyond the scale are accepted unchanged), or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
- Tag lists: a `type: taglist` field in `fields.yaml` edits a list of tags (WinterCMS TagList). `mode: array` binds a JSON column (a type with `sql.Scanner` and `driver.Valuer`, such as `lagoon.Jsonable[[]string]`) and travels as a JSON array of strings; `mode: string`, the default, binds a string column. `options` is a YAML list of suggested tags; `separator` (`comma` or `space`, string mode only) and `customTags` are refused on other types, and an unknown mode or separator, options that are not a list, the money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset` or a column of the wrong Go type fails boot naming the field. A save trims every tag, drops empty ones and keeps a repeated tag once; in array mode null or an empty list stores `[]`, in string mode the tags are split on the separator and stored joined by `,` or a single space (null stores an empty string). A value of the wrong shape is a 422 on the field with `backend::lang.taglist.invalid`, and with `customTags: false` a tag outside `options` is a 422 with `backend::lang.taglist.not_allowed` (`:tag`), on controller forms, settings forms and relation forms alike; a taglist in a repeater row fails boot. The record GET serves the stored list (array mode) or string (string mode). The schema serves `mode`, `options`, `separator` and `customTags` on the field; the admin edits the tags as chips.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Refusals: a lifecycle hook, a relation hook, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
@@ -308,7 +308,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.FieldPreset` | A text or mltext field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
| `cabana.FormField.Separator` / `cabana.FormField.CustomTags` | A `type: taglist` field's string-mode `separator` (`comma` or `space`, empty in array mode) and its `customTags` as `fields.yaml` declares it (nil means the default, true); both omitted from the JSON when unset. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A hook or a `pact.AdminAction` may return a `cabana.ValidationError` to answer 422; its `Details` messages may be translation keys, translated in the request locale. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
| `cabana.AdminActionRequest` | Body of an action route: optional `record_id`, the widget's `values` and an optional `payload` (any JSON value, at most 64 KiB, widget route only). Unknown keys are refused. |
| `cabana.AdminActionResult` | Answer of an action route: the localized `message`, the filtered `fill` object and, when the action returned one, `data` exactly as returned. |
@@ -388,3 +388,5 @@ The Phase 18 pieces (money field and column, write-only settings secrets, riched
The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Docker-free unit tests in `taglist_field_test.go` (TestTaglistFieldCompile, TestTaglistFieldValidate for the save check in both modes, TestTaglistFieldHydrate for the record GET) and `reorder_flat_test.go` (TestReorderFlatCompile, which also checks the localized list schema, and TestReorderFlatNodes for the save's node check), and by Postgres tests in `catalog_smoke_test.go` (TestTaglistFieldSmoke, TestReorderFlatSmoke with a partitioned subtest) and `reorder_test.go` (TestReorderPostgres for the tree reorder). The admin specs are `admin/tests/form/TaglistField.test.ts` and `admin/tests/views/ReorderView.test.ts`. `scripts/check-phase18.1.sh` runs them by name.
A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage.
The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`).

View File

@@ -269,7 +269,8 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
}
// runAction calls the plugin's Run and writes the D-10 envelope. A
// *ValidationError is a 422 and a *ForbiddenError a 403; any other error is
// *ValidationError is a 422 and a *ForbiddenError a 403, both with their
// messages localized in the request locale; any other error is
// logged and answered with the generic 500 body, never the error text. The
// result's Fill passes the fill filter; its Data bypasses that filter but not
// the size cap: it is encoded once and embedded as-is, and a value above
@@ -279,15 +280,12 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
result, err := action.Run(ctx, input)
if err != nil {
// A validation failure is a 422 and a refusal (D-27) a 403, both
// with the action's messages localized.
var invalid *ValidationError
if errors.As(err, &invalid) {
writeCRUDError(w, err)
return
}
// A refusal (D-27) is a 403 with the action's localized message.
var refused *ForbiddenError
if errors.As(err, &refused) {
writeCRUDError(w, localizeForbidden(ctx, tr, err))
if errors.As(err, &invalid) || errors.As(err, &refused) {
writeCRUDError(w, localizeControllerError(ctx, tr, err))
return
}
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)

View File

@@ -61,7 +61,12 @@ type BulkActionResult struct {
Affected int `json:"affected"`
}
// ValidationError is a D-10 validation_failed failure.
// ValidationError is a D-10 validation_failed failure, answered 422. Details
// maps a field name to its messages (a string or a list), each a phrase key
// or text. A key returned by controller code (a lifecycle hook, an action, a
// reorder hook, a singleton record) is translated in the request locale
// before the response is written, like a ForbiddenError; text is written as
// given.
type ValidationError struct {
Details map[string]any
}
@@ -83,55 +88,93 @@ type ForbiddenError struct {
func (e *ForbiddenError) Error() string { return "forbidden" }
// localizeForbidden returns err with a *ForbiddenError's Message and Details
// strings translated in the request locale; any other error, and a nil one,
// is returned unchanged. The plugin's error value is never modified: it may
// be a shared variable.
func localizeForbidden(ctx context.Context, tr *phrasebook.Translator, err error) error {
var refused *ForbiddenError
if err == nil || !errors.As(err, &refused) || refused == nil {
return err
// localizeControllerError returns err with the messages of a plugin-authored
// refusal translated in the request locale: the Details of a
// *ValidationError, or the Message and Details of a *ForbiddenError. The two
// types are tested in writeCRUDError's order, so the error localized is the
// one written. Any other error, a nil one, and a ValidationError without
// Details are returned unchanged. The plugin's error value is never modified:
// it may be a shared package variable read by concurrent requests in other
// locales, so a new error, map and slices are built.
func localizeControllerError(ctx context.Context, tr *phrasebook.Translator, err error) error {
if err == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
var invalid *ValidationError
if errors.As(err, &invalid) {
if invalid == nil || len(invalid.Details) == 0 {
return err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
return &ValidationError{Details: localizeDetails(ctx, tr, invalid.Details)}
}
var refused *ForbiddenError
if !errors.As(err, &refused) || refused == nil {
return err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: translateKey(ctx, tr, refused.Message)}
out := &ForbiddenError{Message: localizeDetail(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = make(map[string]any, len(refused.Details))
for field, value := range refused.Details {
switch messages := value.(type) {
case string:
out.Details[field] = []string{translateKey(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = translateKey(ctx, tr, text)
}
out.Details[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = translateKey(ctx, tr, text)
} else {
list[i] = item
}
}
out.Details[field] = list
default:
out.Details[field] = value
out.Details = localizeDetails(ctx, tr, refused.Details)
}
return out
}
// localizeDetails is the copy-on-write loop over a refusal's Details: a
// string becomes a one-message list, a []string or []any becomes a new slice
// with its strings localized, and any other value is passed through. The
// input map and its slices are never written to.
func localizeDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
out := make(map[string]any, len(details))
for field, value := range details {
switch messages := value.(type) {
case string:
out[field] = []string{localizeDetail(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = localizeDetail(ctx, tr, text)
}
out[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = localizeDetail(ctx, tr, text)
} else {
list[i] = item
}
}
out[field] = list
default:
out[field] = value
}
}
return out
}
// localizeDetail translates one refusal message when it is a phrase key and
// returns any other text unchanged. Every phrasebook key is
// vendor.plugin::group.path and the lookup is exact, so text without "::"
// can never resolve; skipping it keeps already-translated messages (lagoon
// rule messages, framework text) byte-identical and keeps the development
// log free of a missing-key warning per message. An empty message stays
// empty.
func localizeDetail(ctx context.Context, tr *phrasebook.Translator, text string) string {
if !strings.Contains(text, "::") {
return text
}
return translateKey(ctx, tr, text)
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError that comes out of it, so a refusal leaves the
// service ready to be written.
// localizes a *ValidationError or *ForbiddenError that comes out of it, so a
// refusal leaves the service ready to be written.
func (s CRUDService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
return localizeControllerError(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
@@ -369,7 +412,7 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
if action.Detached {
result, err := s.detachedBulkAction(ctx, cc, name, action, ids)
return result, localizeForbidden(ctx, s.tr, err)
return result, localizeControllerError(ctx, s.tr, err)
}
var result BulkActionResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
@@ -456,7 +499,9 @@ func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (
return res.Data, nil
}
// ShowRecord is Show plus the relation labels of the record (D-18).
// ShowRecord is Show plus the relation labels of the record (D-18). A
// ValidationError or ForbiddenError from a controller hook it runs is
// returned with its messages localized in the request locale.
func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id any) (RecordResult, error) {
if s.DB == nil {
return RecordResult{}, errors.New("cabana: database is not configured")
@@ -493,7 +538,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
return err
})
if err != nil {
return RecordResult{}, err
// FormRecordTitle, a record action's Applies and the
// RelationLockProvider may refuse the read with phrase keys.
return RecordResult{}, localizeControllerError(ctx, s.tr, err)
}
return result, nil
}

View File

@@ -772,12 +772,12 @@ func derefValue(v any) any {
}
// localizeNotices turns the notices of a save into the response's
// meta.notices in the request locale, the way localizeForbidden localizes a
// refusal: Message is a phrase key or text, resolved with Choice when Count
// is above zero and Get otherwise; without a translator the text is kept. At
// most maxFieldNotices are kept per field and locale and each message is cut
// to maxNoticeRunes. The result is nil when there is no notice, so the
// response carries no notices key.
// meta.notices in the request locale, the way localizeControllerError
// localizes a refusal: Message is a phrase key or text, resolved with Choice
// when Count is above zero and Get otherwise; without a translator the text
// is kept. At most maxFieldNotices are kept per field and locale and each
// message is cut to maxNoticeRunes. The result is nil when there is no
// notice, so the response carries no notices key.
func localizeNotices(ctx context.Context, tr *phrasebook.Translator, notices []pluginNotice) []FieldNotice {
if len(notices) == 0 {
return nil

View File

@@ -0,0 +1,189 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
const (
menuURLScheme = "acme.menu::lang.errors.url_scheme"
menuTarget = "acme.menu::lang.errors.target"
menuMissing = "acme.menu::lang.errors.missing"
)
var menuTexts = map[string][2]string{
"en": {"The link must start with http or https.", "Choose where the link opens."},
"pl": {"Link musi zaczynać się od http lub https.", "Wybierz, gdzie otwiera się link."},
}
// menuTranslator has the acme.menu error keys in en and pl.
func menuTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
cat := phrasebook.NewCatalog()
files := fstest.MapFS{}
for locale, texts := range menuTexts {
files["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: []byte(fmt.Sprintf("errors:\n url_scheme: %q\n target: %q\n", texts[0], texts[1]))}
}
if err := cat.Load("acme.menu", files); err != nil {
t.Fatal(err)
}
return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
}
// menuInvalid is a ValidationError with every Details shape a plugin uses.
func menuInvalid() *ValidationError {
return &ValidationError{Details: map[string]any{
"url": []string{menuURLScheme},
"target": menuTarget,
"mixed": []any{menuURLScheme, 7},
"title": []string{"The title field is required."},
"nested": map[string]any{"pl": []string{menuURLScheme}},
"absent": []string{menuMissing},
}}
}
func TestLocalizeControllerErrorValidation(t *testing.T) {
tr := menuTranslator(t)
shared := menuInvalid()
before := menuInvalid()
for _, locale := range []string{"pl", "en", "pl"} {
ctx := towel.WithLocale(context.Background(), locale)
got := localizeControllerError(ctx, tr, shared)
var ve *ValidationError
if !errors.As(got, &ve) {
t.Fatalf("%s: got %T, want *ValidationError", locale, got)
}
if ve == shared {
t.Fatalf("%s: the plugin's value was returned, not a copy", locale)
}
want := map[string]any{
"url": []string{menuTexts[locale][0]},
"target": []string{menuTexts[locale][1]},
"mixed": []any{menuTexts[locale][0], 7},
"title": []string{"The title field is required."},
"nested": map[string]any{"pl": []string{menuURLScheme}},
"absent": []string{menuMissing},
}
if !reflect.DeepEqual(ve.Details, want) {
t.Fatalf("%s: details = %#v, want %#v", locale, ve.Details, want)
}
// New slices: writing into the result does not reach the plugin's value.
ve.Details["url"].([]string)[0] = "changed"
ve.Details["title"].([]string)[0] = "changed"
ve.Details["mixed"].([]any)[0] = "changed"
if !reflect.DeepEqual(shared, before) {
t.Fatalf("%s: the plugin's value was modified: %#v", locale, shared.Details)
}
}
}
func TestLocalizeControllerErrorUnchanged(t *testing.T) {
tr := menuTranslator(t)
ctx := towel.WithLocale(context.Background(), "pl")
if got := localizeControllerError(ctx, tr, nil); got != nil {
t.Fatalf("nil error = %v", got)
}
plain := errors.New("x")
if got := localizeControllerError(ctx, tr, plain); got != plain {
t.Fatalf("plain error = %#v, want the identical value", got)
}
empty := &ValidationError{}
if got := localizeControllerError(ctx, tr, empty); got != empty {
t.Fatalf("nil details = %#v, want the identical value", got)
}
noDetails := &ValidationError{Details: map[string]any{}}
if got := localizeControllerError(ctx, tr, noDetails); got != noDetails {
t.Fatalf("empty details = %#v, want the identical value", got)
}
var nilVE *ValidationError
if got := localizeControllerError(ctx, tr, nilVE); got != error(nilVE) {
t.Fatalf("nil *ValidationError = %#v", got)
}
// A wrapped ValidationError is found and localized.
got := localizeControllerError(ctx, tr, fmt.Errorf("save: %w", &ValidationError{Details: map[string]any{"url": []string{menuURLScheme}}}))
ve, ok := got.(*ValidationError)
if !ok || !reflect.DeepEqual(ve.Details, map[string]any{"url": []string{menuTexts["pl"][0]}}) {
t.Fatalf("wrapped = %#v", got)
}
// Without a translator the keys stay.
got = localizeControllerError(nil, nil, &ValidationError{Details: map[string]any{"url": menuURLScheme}})
if ve, ok := got.(*ValidationError); !ok || !reflect.DeepEqual(ve.Details, map[string]any{"url": []string{menuURLScheme}}) {
t.Fatalf("nil translator = %#v", got)
}
}
func TestLocalizeControllerErrorForbidden(t *testing.T) {
tr := menuTranslator(t)
shared := &ForbiddenError{Message: menuTarget, Details: map[string]any{"url": []string{menuURLScheme}, "plain": "Text."}}
for _, locale := range []string{"en", "pl"} {
got := localizeControllerError(towel.WithLocale(context.Background(), locale), tr, shared)
fe, ok := got.(*ForbiddenError)
if !ok || fe == shared {
t.Fatalf("%s: got %#v", locale, got)
}
want := &ForbiddenError{Message: menuTexts[locale][1], Details: map[string]any{"url": []string{menuTexts[locale][0]}, "plain": []string{"Text."}}}
if !reflect.DeepEqual(fe, want) {
t.Fatalf("%s: got %#v, want %#v", locale, fe, want)
}
}
if shared.Message != menuTarget || !reflect.DeepEqual(shared.Details, map[string]any{"url": []string{menuURLScheme}, "plain": "Text."}) {
t.Fatalf("the plugin's value was modified: %#v", shared)
}
// An empty Message stays empty and no Details stay nil.
got := localizeControllerError(context.Background(), tr, &ForbiddenError{})
if fe, ok := got.(*ForbiddenError); !ok || fe.Message != "" || fe.Details != nil {
t.Fatalf("empty refusal = %#v", got)
}
}
func TestWriteCRUDErrorLocalized(t *testing.T) {
tr := menuTranslator(t)
err := localizeControllerError(towel.WithLocale(context.Background(), "pl"), tr, menuInvalid())
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
var env ErrorEnvelope
if jerr := json.Unmarshal(rec.Body.Bytes(), &env); jerr != nil {
t.Fatal(jerr)
}
if rec.Code != http.StatusUnprocessableEntity || env.Error.Code != "validation_failed" {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !reflect.DeepEqual(env.Error.Details["url"], []any{menuTexts["pl"][0]}) {
t.Fatalf("details.url = %#v", env.Error.Details["url"])
}
}
// TestLocalizeControllerErrorPlainTextNotLookedUp: text without "::" is
// never looked up, so the development log has no missing-key warning for it;
// an unknown key does warn, which proves the capture works. Not parallel: it
// swaps the default logger.
func TestLocalizeControllerErrorPlainTextNotLookedUp(t *testing.T) {
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
tr := menuTranslator(t)
ctx := towel.WithLocale(context.Background(), "en")
localizeControllerError(ctx, tr, &ValidationError{Details: map[string]any{"title": []string{"The title field is required."}, "body": "Too long."}})
if strings.Contains(buf.String(), "missing translation key") {
t.Fatalf("plain text was looked up: %s", buf.String())
}
localizeControllerError(ctx, tr, &ValidationError{Details: map[string]any{"url": []string{menuMissing}}})
if !strings.Contains(buf.String(), "missing translation key") {
t.Fatalf("an unknown key logged nothing; the capture does not work: %q", buf.String())
}
}

View File

@@ -561,6 +561,10 @@ const rosterLocked = "Locked"
// copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// rosterInvalid is a shared validation failure whose message is a phrase
// key: the framework must localize a copy and never write into it.
var rosterInvalid = &cabana.ValidationError{Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}}}
// FormVirtualFields lists the form fields that are not columns of the form:
// the password pair and the create-only notify checkbox.
func (rosterController) FormVirtualFields() []string {
@@ -622,8 +626,9 @@ func (rosterController) FormBeforeDelete(_ context.Context, model any) error {
}
// FormBeforeUpdate stores a submitted password, refuses the reserved name
// with a ForbiddenError naming the field, and fails with a plain error for
// the name Boom.
// with a ForbiddenError naming the field, rejects the name Rejected with the
// shared rosterInvalid ValidationError, and fails with a plain error for the
// name Boom.
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
switch model.(*rosterPerson).Name {
@@ -634,6 +639,8 @@ func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error
}
case "Silent":
return &cabana.ForbiddenError{}
case "Rejected":
return rosterInvalid
case "Boom":
return fmt.Errorf("the roster database said hunter2")
}

View File

@@ -148,6 +148,27 @@ func TestForbiddenLocalized(t *testing.T) {
}
}
// TestValidationErrorLocalized: a hook's ValidationError whose message is a
// phrase key is a 422 with the message in the request locale, and the
// plugin's shared value keeps its key.
func TestValidationErrorLocalized(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
for locale, want := range map[string]string{
"en": "This name is reserved.",
"pl": "Ta nazwa jest zastrzeżona.",
} {
rec := rosterLocale(env, http.MethodPut, rosterPath(id, ""), `{"name":"Rejected"}`, locale)
got := rosterError(t, rec)
if rec.Code != http.StatusUnprocessableEntity || got.Code != "validation_failed" || !reflect.DeepEqual(got.Details, map[string]any{"name": []any{want}}) {
t.Fatalf("%s: status=%d error=%+v", locale, rec.Code, got)
}
}
if !reflect.DeepEqual(rosterInvalid.Details, map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}}) {
t.Fatalf("the plugin's error value was modified: %+v", rosterInvalid.Details)
}
}
// TestForbiddenRollsBack: nothing of a refused request stays, also when the
// refusal comes after rows were written.
func TestForbiddenRollsBack(t *testing.T) {

View File

@@ -868,9 +868,10 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError a relation hook returned (D-27).
// localizes a *ValidationError or *ForbiddenError (D-27) a relation hook
// returned.
func (s RelationService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
return localizeControllerError(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// Link links eligible related records to the parent and never restamps

View File

@@ -388,7 +388,8 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController
}
locked, _, err := relationLocks(ctx, cc, field)
if err != nil {
return nil, ListMeta{}, err
// A refusal from the RelationLockProvider carries plugin phrase keys.
return nil, ListMeta{}, localizeControllerError(ctx, s.tr, err)
}
markLocked(rows, locked)
last := 1

View File

@@ -297,7 +297,9 @@ func (s *service) reorderGet(w http.ResponseWriter, r *http.Request) {
// every node, runs the controller's optional ReorderAfterSave hook (pact) with
// the partition (its error rolls the reorder back) and answers the new tree.
// The transaction is a lagoon.Transaction, so a hook can register work with
// lagoon.AfterCommit that runs only once the new order is committed.
// lagoon.AfterCommit that runs only once the new order is committed. A
// ValidationError (422) or ForbiddenError (403) from the hook is answered with
// its messages localized in the request locale.
func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.reorder == nil || cc.singleton {
@@ -359,6 +361,8 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
tree, err = s.reorderTree(txCtx, tx, cc, in.Partition, false)
return err
})
// A refusal from ReorderAfterSave carries plugin phrase keys.
err = localizeControllerError(ctx, s.translator(), err)
if err != nil {
s.writeReorderError(w, r, cc, err)
return

View File

@@ -9,6 +9,7 @@ import (
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -35,6 +36,12 @@ func (hookShelfPlugin) Requires() []string { return nil }
func (hookShelfPlugin) Register(*backpack.App) error { return nil }
func (hookShelfPlugin) Boot(*backpack.App) error { return nil }
func (hookShelfPlugin) AdminFS() fs.FS { return shelfFS }
// LangFS serves the acme.shelf refusal messages of
// TestReorderHookRefusalLocalized.
func (hookShelfPlugin) LangFS() fs.FS {
return fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("errors:\n order: \"This order is not allowed.\"\n locked: \"This shelf is locked.\"\n")}}
}
func (p hookShelfPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{hookedPins{hook: p.hook}}
}
@@ -217,6 +224,35 @@ func TestReorderHookErrorRollsBack(t *testing.T) {
}
}
// TestReorderHookRefusalLocalized: a ValidationError or ForbiddenError from
// ReorderAfterSave whose messages are phrase keys is answered with the
// sentences in the request locale, never the keys, and the reorder is rolled
// back.
func TestReorderHookRefusalLocalized(t *testing.T) {
for _, tc := range []struct {
name string
err error
status int
want string
key string
}{
{"validation error", &cabana.ValidationError{Details: map[string]any{"nodes": []string{"acme.shelf::lang.errors.order"}}}, http.StatusUnprocessableEntity, `"nodes":["This order is not allowed."]`, "acme.shelf::lang.errors.order"},
{"forbidden", &cabana.ForbiddenError{Message: "acme.shelf::lang.errors.locked"}, http.StatusForbidden, `"message":"This shelf is locked."`, "acme.shelf::lang.errors.locked"},
} {
t.Run(tc.name, func(t *testing.T) {
env := newShelfHookEnv(t, hookShelfPlugin{hook: func(context.Context, *uint64) error { return tc.err }})
before := sorts(t, env.db)
code, body := env.swap(t)
if code != tc.status || !strings.Contains(body, tc.want) || strings.Contains(body, tc.key) {
t.Fatalf("status=%d want %d body=%s", code, tc.status, body)
}
if after := sorts(t, env.db); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("sort values changed: before %v after %v", before, after)
}
})
}
}
func TestReorderWithoutHookUnchanged(t *testing.T) {
plain := newShelfHookEnv(t, shelfPlugin{})
code, plainBody := plain.swap(t)

View File

@@ -103,7 +103,8 @@ func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) {
if !errors.As(err, &missing) {
slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err)
}
writeCRUDError(w, err)
// A refusal from SingletonRecord carries plugin phrase keys.
writeCRUDError(w, localizeControllerError(ctx, s.translator(), err))
return
}
WriteData(w, http.StatusOK, result, nil)