Files
summercms/.planning/phases/14.2.1-translate-plugin/14.2.1-RESEARCH.md
2026-10-06 11:22:45 +02:00

62 KiB
Raw Blame History

Phase 14.2.1: Translate plugin - Research

Researched: 2026-10-06 Domain: PHP-to-Go plugin port (WinterCMS Translate → compiled sm-translate-plugin) plus cabana ML field types Confidence: HIGH

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

PHP source of truth

  • D-01: The port contract is github.com/golem15com/wn-translate-plugin at git SHA 725d547 (2026-08-26), the pin currently checked out in Płytarium. That tree is newer than grzybyfunkcjonalne.pl (6e0c146). — Reversibility: costly — Locale tables, attribute storage and Translator behaviour are copied from this tree.
  • D-02: Freeze at 725d547. Planner and researcher must not silently pick a newer master. — Reversibility: reversible — a later discussion can re-pin.
  • D-03: Downstream agents read the frozen source from /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate (already at that SHA). Do not clone a second copy unless that path is missing or the SHA does not match.
  • D-04: This phase does not edit or PR wn-translate-plugin. An urgent PHP upgrade on a live Winter site before that site migrates is allowed as operations. If it lands before plan or execute, re-pin the SHA rather than mixing trees.

What ships this phase

  • D-05: Lean only: Locale model + Locales admin (golem15.translate.manage_locales), and a TranslatableModel behavior Journal can implement. Messages catalogue, locale-picker components, AI/theme commands and message import/export are deferred. — Reversibility: reversible — later plans can add the rest without rewriting Locale.
  • D-06: Cabana gains ML field types (mltext, mlmarkdown) matching PHP's ml* widgets, so a Journal form stays one screen with a locale switch. Phase 15's markdown field (15 D-11) and these ML types must compose (mlmarkdown is markdown plus locale). — Reversibility: costly — new field types in the typed admin schema and generated TS types.
  • D-07: Port PHP Translator locale resolution: URL prefix / session / cookie / default. Do not reduce this phase to Accept-Language or a query param only. — Reversibility: costly — public blog URLs and the Journal API will depend on this order.
  • D-08: The plugin seeds English and Polish locales, matching Journal 15 D-06.

Model API

  • D-09: A Go model declares translatable attributes with a Translatable() []string (or equivalent tagged list) the way PHP uses $translatable. Journal copies the PHP Post/Category lists. — Reversibility: costly — every translatable model in shared plugins will call this.
  • D-10: Storage uses the same tables and columns as this PHP pin (golem15_translate_* attributes/indexes/locales — researcher confirms exact names from the frozen updates). No JSON-column alternative. — Reversibility: one-way — Winter import and Journal cutover expect those tables.
  • D-11: A missing translation falls back to the default locale, as PHP does.
  • D-12: The exported API other plugins call this phase is the minimum: WithLocale on queries, get/set an attribute in a locale, and current locale from Translator. Do not 1:1 every PHP TranslatableModel / Translator method name unless research shows Journal needs it.

Where it boots

  • D-13: The host is sm-grzybyfunkcjonalne-app (same as Phase 15 D-18). This phase mounts sm-user-plugin + sm-translate-plugin; Journal is added in Phase 15. — Reversibility: one-way — submodule layout lands in that app.
  • D-14: Local app checkout: /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/ (sibling of summercms.go).
  • D-15: The user creates the public Gitea remote git@git.golem15.com:golem15/sm-translate-plugin.git as a manual plan step (it does not exist yet). Module path git.golem15.com/golem15/sm-translate-plugin, package translate, plugin ID golem15.translate.
  • D-16: Local plugin checkout: /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/ (sibling of summercms.go). The host mounts it as a submodule at plugins/golem15/translate with a go.work replace.
  • D-17: Phase proof: the app boots with user+translate; Locales admin works; a fixture model can save and read en+pl through ML fields and WithLocale.

the agent's Discretion

  • Exact golem15_translate_* table/column names and migration IDs, taken from the frozen PHP updates (D-10), not invented.
  • ML field YAML type names and SPA locale-switcher chrome, provided they follow cabana fail-loud rules and update the module README, docs/, admin OpenAPI, generated TS types and committed dist/ in the same change.
  • How mlmarkdown shares implementation with Phase 15's markdown field (land the shared primitive here if Journal is otherwise blocked).
  • Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan".
  • Run the security-review agent: locale switching on public requests and admin ML writes are authorization-adjacent.

Deferred Ideas (OUT OF SCOPE)

  • Messages catalogue and golem15.translate.manage_messages admin.
  • Locale picker, alternate hreflang, and locale-suggestion-banner CMS components (Phase 16 views may need a successor).
  • AI/theme console commands and message import/export.
  • Remaining PHP locales beyond the en+pl seed (D-08 is seed rows, not phrasebook files).
  • Phase 15 should list Depends on: Phase 14.2.1 in ROADMAP ($gsd-phase --edit 15); not done in this discussion.

Reviewed Todos (not folded)

  • sitemap-plugin-port.md, mailblocker-with-mailing.md, and the other keyword matches from todo.match-phase 14.2.1: unrelated housekeeping. </user_constraints>

Project Constraints (from CLAUDE.md)

  • Lean planning: few larger plans; unit tests are always the last plan of a phase; earlier plans may smoke-test but must not be blocked on coverage.
  • Plan-count checkpoint before writing PLAN.md files.
  • Stdlib first (net/http ServeMux, html/template, encoding/json). Add a dependency only when this research or a phase decision names it. go vet and go test ./... green at every commit.
  • Compiled plugins only. No runtime plugin loading.
  • API parity is the acceptance test for ported contracts; do not "improve" shapes.
  • GORM on Postgres only; gormigrate (not goose/atlas); AutoMigrate is never the schema source (DATA-02).
  • Two-repo rule: summercms.go is framework only and knows nothing about Płytarium. This phase writes framework ML types in summercms.go, the plugin in sm-translate-plugin, and the proof host in sm-grzybyfunkcjonalne-app. Planning docs stay in summercms.go/.planning.
  • Per-request state (including locale) travels in context.Context; no package-level globals hold request state (KERN-07).
  • Framework READMEs never name a consuming application. Neutral examples (blog, acme).
  • A change to a module's exported API, config keys or CLI commands updates that module's README.md and the affected docs/ pages in the same change. Cabana field types also update admin OpenAPI, generated TS types, and committed boardwalk dist/.
  • PHP originals are not changed. Core plugin contracts stay non-breaking.
  • Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.

Summary

Phase 14.2.1 ports the lean core of Golem15.Translate at SHA 725d547ec839f02b5fdc0f0a6faaed601a414d50 so Journal (Phase 15) can keep translatable fields. The frozen tree is present at /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate and matches the pin. Do not clone a second copy.

Primary recommendation: Create sm-translate-plugin on the user-plugin mount pattern; squash PHP's rainlab→winter rename history into four final Postgres tables named winter_translate_* (not golem15_translate_*); export Translatable() []string, WithLocale, get/set-in-locale, and Translator current locale; land cabana markdown plus mltext/mlmarkdown in this phase so Phase 15 is not blocked; replace surf's Accept-Language-only house locale middleware with a backpack-published Translator when the plugin is mounted.

PHP table names in this pin are winter_translate_locales, winter_translate_attributes, winter_translate_indexes, and winter_translate_messages. CONTEXT D-10's golem15_translate_* placeholder is wrong against the frozen source. Use the verified winter_translate_* names so a Winter import and Journal cutover see the same tables.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Locale rows, attribute JSON, indexes, messages table DDL Database / Storage API / Backend gormigrate owns schema; plugin models own rows
Translatable get/set, default-locale fallback, WithLocale API / Backend Database / Storage GORM hooks + attribute table; request locale from context
Translator resolution (URL prefix / session / cookie flag / default) API / Backend — Request middleware writes towel.WithLocale; no process-wide singleton
Locales admin CRUD API / Backend Browser / Client cabana JSON admin; SPA renders compiled schema
mltext / mlmarkdown schema + save API / Backend Browser / Client cabana fail-loud types; SPA locale switcher posts all locales
Phrasebook UI strings (golem15.translate::lang.*) API / Backend Browser / Client Distinct from model translations
Proof host boot (user + translate) API / Backend — sm-grzybyfunkcjonalne-app binary

Standard Stack

No new libraries. Use the already-decided stack already in summercms.go go.mod and sm-user-plugin.

Core

Library Version Purpose Why Standard
Go 1.27.0 Language Project pin; go version go1.27.0-X:nodwarf5 on this machine
GORM v1.31.2 Models Decided; user plugin already requires it
gorm.io/driver/postgres v1.6.3 Postgres Decided
go-gormigrate/gormigrate/v2 v2.1.7 Plugin migrations Decided; user plugin uses this
goldmark v1.8.6 Markdown primitive under mlmarkdown Already a direct framework dep (go.mod); Phase 4 D-07 mail renderer. Do not add a second Markdown library.
goccy/go-yaml v1.19.2 fields.yaml / columns.yaml Cabana already parses with this
Vue 3 + TypeScript admin SPA mltext / mlmarkdown controls Existing admin/ tree; types from OpenAPI

Supporting

Library Version Purpose When to Use
testcontainers-go + modules/postgres v0.44.0 Real Postgres for migration/behavior tests Last plan; -short skip
stretchr/testify v1.12.1 Assertions Last plan only; keep func Test...(*testing.T)
go-i18n/v2 v2.6.1 Phrasebook CLDR (already wired) Plugin HasLang catalogs; not model translations

Alternatives Considered

Do not use these. CONTEXT locked the storage, locale order, and YAML types.

Instead of Could Use Why rejected
winter_translate_* tables JSON columns on host models D-10 forbids it
winter_translate_* Invented golem15_translate_* Frozen models use winter_translate_*
URL/session/cookie/default Accept-Language only D-07 forbids reducing to it
Cabana mltext/mlmarkdown Plugin type: widget Field types belong in cabana (12.1 pattern)

Installation: none. Plugin go.mod copies the user-plugin require set (GORM, gormigrate, summercms replace). Cabana ML types add no new Go module.

Version verification: read summercms.go/go.mod and sm-user-plugin/go.mod this session. Goldmark v1.8.6 is already a direct require.

Package Legitimacy Audit

This phase installs no new external packages. Goldmark, GORM, gormigrate, goccy/go-yaml, testcontainers and testify are already in the framework / user-plugin graphs.

Package Registry Verdict Disposition
(none new) — — —

Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none

Verified PHP pin

git rev-parse HEAD in /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate is 725d547ec839f02b5fdc0f0a6faaed601a414d50 (2026-08-26). Matches D-01/D-02/D-03. Do not read a newer tree.

1. Tables, columns, indexes, migration IDs

PHP created rainlab_translate_* then renamed to winter_translate_*. Go squashes to the final names. Do not replay the rainlab era.

Final table names (use these)

Table Model $table Role this phase
winter_translate_locales "public $table = 'winter_translate_locales';" [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Locale.php:24] Ship Locale admin + seed
winter_translate_attributes "public $table = 'winter_translate_attributes';" [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Attribute.php:15] Ship Translatable storage
winter_translate_indexes used as 'winter_translate_indexes' [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/behaviors/TranslatableModel.php:74] Ship — Journal slugs use index => true
winter_translate_messages "public $table = 'winter_translate_messages';" [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Message.php:20] Create empty, do not break — Messages admin is out of scope

Translator configuration check: "Schema::hasTable('winter_translate_locales')" [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/classes/Translator.php:113].

Columns (final, after all PHP updates)

winter_translate_locales (create + sort_order):

Column Source
id increments "$table->increments('id');" [VERIFIED: updates/v1.0.1/create_locales_table.php:14]
code string, indexed "$table->string('code')->index();" [VERIFIED: updates/v1.0.1/create_locales_table.php:15]
name string, indexed, nullable "$table->string('name')->index()->nullable();" [VERIFIED: updates/v1.0.1/create_locales_table.php:16]
is_default boolean default 0 "$table->boolean('is_default')->default(0);" [VERIFIED: updates/v1.0.1/create_locales_table.php:17]
is_enabled boolean default 0 "$table->boolean('is_enabled')->default(0);" [VERIFIED: updates/v1.0.1/create_locales_table.php:18]
sort_order integer default 0 "$table->integer('sort_order')->default(0);" [VERIFIED: updates/v1.3.1/add_sort_order.php:20]

No timestamps on Locale ("$this->timestamps = false;" [VERIFIED: models/Locale.php:43]).

winter_translate_attributes: id, locale (indexed), model_id (indexed, nullable string), model_type (indexed, nullable string), attribute_data (mediumText, nullable) [VERIFIED: updates/v1.0.1/create_attributes_table.php:13-18]. Storage of translations is a JSON object in attribute_data, one row per (locale, model_id, model_type) [VERIFIED: behaviors/TranslatableModel.php:226-241].

winter_translate_indexes: id, locale (indexed), model_id (indexed, nullable), model_type (indexed, nullable), item (nullable, indexed), value (mediumText, nullable) [VERIFIED: updates/v1.2.4/create_indexes_table.php:13-19]. Written when $translatable entry is ['slug', 'index' => true].

winter_translate_messages (must exist, unused this phase): id, code (indexed, nullable), message_data (mediumText, nullable) [VERIFIED: updates/v1.0.1/create_messages_table.php:13-16]; plus found boolean default 1 [VERIFIED: updates/v1.6.10/update_messages_table.php:16-19]; plus code_pre_2_1_0 string indexed nullable from 2.1.0 [VERIFIED: updates/v2.1.0/migrate_message_code.php:16-19]. Message jsonable is message_data [VERIFIED: models/Message.php:38].

PHP migration IDs (do not replay; squash)

From updates/version.yaml [VERIFIED: updates/version.yaml:1-155]:

PHP version.yaml key Files
"1.0.1" v1.0.1/create_messages_table.php, create_attributes_table.php, create_locales_table.php
"1.2.4" v1.2.4/create_indexes_table.php
"1.3.1" v1.3.1/add_sort_order.php, v1.3.1/seed_all_tables.php
"1.6.6" v1.6.6/migrate_morphed_attributes.php
"1.6.7" v1.6.7/migrate_morphed_indexes.php
"1.6.10" v1.6.10/update_messages_table.php
"2.0.0" v2.0.0/rename_tables.php (rainlab_translate_* → winter_translate_*)
"2.0.1" v2.0.1/rename_indexes.php
"2.1.0" v2.1.0/migrate_message_code.php
"2.4.0" v2.4.0/seed_additional_locales.php

Go migration IDs: follow the user-plugin pattern "202609170001_create_users" [VERIFIED: ../fonoteka.go/plugins/golem15/user/updates/00_base.go:13]. Suggested squash IDs (planner may adjust date prefix, not the table names):

  • 202610060001_create_winter_translate_locales
  • 202610060002_create_winter_translate_attributes
  • 202610060003_create_winter_translate_indexes
  • 202610060004_create_winter_translate_messages
  • 202610060005_seed_en_pl_locales

Postgres types: SERIAL PK, TEXT for strings, BOOLEAN, INTEGER for sort_order, TEXT for attribute_data/message_data/value (PHP mediumText). Add btree indexes on the PHP-indexed columns (code, name, locale, model_id, model_type, item).

Do not run morph-map data migrations (1.6.6/1.6.7); there is no legacy rainlab data in a new Go database. Do not rename rainlab→winter at runtime.

2. Locale model, permissions, YAML, seed

Permission

"golem15.translate.manage_locales" [VERIFIED: Plugin.php:71] with tab 'golem15.translate::lang.plugin.tab' and label 'golem15.translate::lang.plugin.manage_locales' [VERIFIED: Plugin.php:72-74]. Developer role in PHP. Go: pact.HasPermissions with Code: "golem15.translate.manage_locales", Roles: []string{"developer"}, labels via phrasebook — copy user plugin [VERIFIED: ../fonoteka.go/plugins/golem15/user/admin_permissions.go:14-21].

Do not register "golem15.translate.manage_messages" [VERIFIED: Plugin.php:76] this phase (deferred).

Locales controller: "public $requiredPermissions = ['golem15.translate.manage_locales'];" [VERIFIED: controllers/Locales.php:21].

Locale fields and fillable

Fillable: 'code', 'name', 'is_enabled' [VERIFIED: models/Locale.php:37-41]. is_default is not fillable; makeDefault() writes it [VERIFIED: models/Locale.php:99-109]. Rules: 'code' => 'required', 'name' => 'required' [VERIFIED: models/Locale.php:29-32].

Guards: cannot delete default (beforeDelete) [VERIFIED: models/Locale.php:77-81]; cannot unset default (beforeUpdate) [VERIFIED: models/Locale.php:84-92]; cannot make a disabled locale default [VERIFIED: models/Locale.php:101-103]. Port these in Locale hooks / admin Form hooks.

Admin YAML (port into embedded AdminFS)

controllers/locales/config_form.yaml: form: ~/plugins/golem15/translate/models/locale/fields.yaml, modelClass: Golem15\Translate\Models\Locale [VERIFIED: controllers/locales/config_form.yaml:5-7].

controllers/locales/config_list.yaml: list: ~/plugins/golem15/translate/models/locale/columns.yaml, title: golem15.translate::lang.locale.label_plural, recordsPerPage: 20, defaultSort.column: sort_order, defaultSort.direction: asc [VERIFIED: controllers/locales/config_list.yaml:6-34]. PHP recordOnClick is Winter AJAX; Go uses cabana recordUrl to the update form.

models/locale/fields.yaml fields: name, code, is_enabled (type: checkbox), is_default (type: checkbox) [VERIFIED: models/locale/fields.yaml:6-24].

models/locale/columns.yaml: name searchable, code searchable, is_default type: switch, is_enabled type: switch invisible: true, sort_order type: number invisible: true [VERIFIED: models/locale/columns.yaml:6-26]. Cabana list column types are "text", "datetime", "switch", "date", "time" [VERIFIED: modules/cabana/list_schema.go:22-24]. Map number → omit type (text) or drop sort_order from visible columns (already invisible).

PHP ReorderController (config_reorder.yaml nameFrom: name [VERIFIED: controllers/locales/config_reorder.yaml:8-12]) has no cabana analog. Keep sort_order column, seed en=1 pl=2, list defaultSort sort_order asc. Do not invent a drag-reorder UI this phase.

PHP registerSettings() points at the Locales list controller [VERIFIED: Plugin.php:90-98]. Cabana HasSettings is a singleton settings screen [VERIFIED: modules/pact/capabilities.go:402-404]. Locales is CRUD. Register it as HasAdminControllers + HasNavigation like Users [VERIFIED: ../fonoteka.go/plugins/golem15/user/admin.go:33-37 and admin_navigation.go:11-19]. Navigation code e.g. translate, permission golem15.translate.manage_locales, controller id golem15.translate.locales (path {backend.uri}/api/v1/golem15/translate/locales).

Phrasebook (UI strings, not model translations)

Port lang/en/lang.php keys under plugin.* and locale.* into lang/en/lang.yaml and lang/pl/lang.yaml via pact.HasLang / LangFS() [VERIFIED user analog: ../fonoteka.go/plugins/golem15/user/plugin.go:180]. PHP English: 'manage_locales' => 'Manage locales', 'label_plural' => 'Languages', 'title' => 'Manage languages' [VERIFIED: lang/en/lang.php:8-22]. Do not load PHP unsupported_lang/ (deferred remaining locales).

Seed en + pl (D-08)

English from v1.3.1/seed_all_tables.php if count === 0: 'code' => 'en', 'name' => 'English', 'is_default' => true, 'is_enabled' => true [VERIFIED: updates/v1.3.1/seed_all_tables.php:16-22].

Polish from v2.4.0/seed_additional_locales.php: 'code' => 'pl', 'name' => 'Polski', 'is_default' => false, 'is_enabled' => true, 'sort_order' => 2 [VERIFIED: updates/v2.4.0/seed_additional_locales.php:13-20]. The same seeder also inserts 'de' / 'Deutsch' / sort_order => 3 [VERIFIED: updates/v2.4.0/seed_additional_locales.php:21-27]. Do not seed de (D-08 is en+pl only; remaining locales deferred). Set en sort_order to 1.

Idempotent: insert by code if missing, matching PHP $exists check [VERIFIED: updates/v2.4.0/seed_additional_locales.php:33-36].

3. TranslatableModel behavior and minimum Go API

PHP storage model (port this, not Eloquent magic)

  • Default locale values live on the host model's own columns. isTranslatable is false when context equals default [VERIFIED: classes/TranslatableBehavior.php:141-144].
  • Other locales: JSON in winter_translate_attributes.attribute_data keyed by attribute name [VERIFIED: behaviors/TranslatableModel.php:224-243].
  • Missing key + $translatableUseFallback = true (default): return default-locale column [VERIFIED: classes/TranslatableBehavior.php:211-216].
  • setTranslatableUseFallback(false) returns empty/null [VERIFIED: tests/unit/behaviors/TranslatableModelTest.php:80-82].
  • Indexed attributes also write winter_translate_indexes (item = attribute, value = translated string) [VERIFIED: behaviors/TranslatableModel.php:251-294].
  • model_type is getMorphClass() [VERIFIED: behaviors/TranslatableModel.php:345-348]. Fixture models may use their Go type string. Phase 15 Journal import from PHP must later use PHP class strings (Golem15\Journal\Models\Post); document MorphName() string on the translatable interface so Journal can pin that.

PHP declaration Journal will copy:

DATA_k7m2p9qx_START

public $translatable = [
    'title', 'content', 'content_html', 'excerpt', 'metadata',
    ['slug', 'index' => true],
];

DATA_k7m2p9qx_END

[VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php:58-65]

Category: 'name', 'description', ['slug', 'index' => true] [VERIFIED: journal/models/Category.php:36-40].

Minimum exported Go API (D-09, D-12)

Do not 1:1 every PHP method. Journal needs:

Go API PHP analog Why
Translatable() []string on the model $translatable names (ignore options in the slice) D-09
TranslatableIndexes() []string or options on the same method ['slug', 'index' => true] Journal slug lookups
MorphName() string getMorphClass() attribute model_type
WithLocale(ctx, db, locale) *gorm.DB (and/or context locale) translateContext / lang() + transWhere D-12 queries
Translated(ctx, model, field, locale) (value, ok) getAttributeTranslated($key, $locale) get in a locale
SetTranslated(ctx, model, field, locale, value) setAttributeTranslated($key, $value, $locale) set in a locale
Translator.Locale(ctx) string Translator::instance()->getLocale() current locale

PHP query scopes scopeTransWhere / scopeTransOrderBy join winter_translate_indexes [VERIFIED: behaviors/TranslatableModel.php:89-137]. Journal public posts-by-slug in a locale needs WithLocale + index lookup (fallback to the host slug column when no index row, same as PHP else { $query->where($index, ...) } [VERIFIED: behaviors/TranslatableModel.php:104-108]).

Do not port this phase: addTranslatableAttributes, CMS page/url behaviors, eager translations relation name as a required public API, Redis cache keys translation:%s:%s:%s [VERIFIED: behaviors/TranslatableModel.php:312-336]. Cache is optional; correctness first.

KERN-07: PHP Translator is a Singleton [VERIFIED: classes/Translator.php:21]. Go must not store active locale on a process-wide struct. Put it on context.Context via existing towel.WithLocale / towel.Locale [VERIFIED: modules/towel/context.go:55-62]. A Translator service may be published on backpack; its methods take ctx.

Phrasebook towel.WithLocale is UI locale. Model WithLocale is content locale. They usually match after Translator runs, but the Translatable helpers must take an explicit locale argument so Journal API can request pl while the admin SPA UI stays en.

4. Translator locale resolution (D-07)

Do not invent a simpler order. Port LocaleMiddleware plus URL-prefix registration.

Request order (CMS / public HTML)

LocaleMiddleware::handle [VERIFIED: classes/LocaleMiddleware.php:19-45]:

  1. URL prefix — Translator::loadLocaleFromRequest() reads Request::segment(1) and accepts it only if Locale::isValid [VERIFIED: classes/Translator.php:129-138].
  2. Else authenticated user's preferred_locale if valid [VERIFIED: classes/LocaleMiddleware.php:54-83].
  3. Else session loadLocaleFromSession() [VERIFIED: classes/Translator.php:204-213].
  4. Else Accept-Language, only if browser detection is enabled and the manual-selection cookie is absent [VERIFIED: classes/LocaleMiddleware.php:33-36, 96-100, 214-228].
  5. Else default locale setLocale($translator->getDefaultLocale()) [VERIFIED: classes/LocaleMiddleware.php:38-41].

D-07 says do not reduce to Accept-Language. The PHP tree does use Accept-Language as step 4. Port the full chain; do not drop URL/session; do not make Accept-Language first.

Key What it stores
golem15.translate.locale Session locale (const SESSION_LOCALE = 'golem15.translate.locale';) [VERIFIED: classes/Translator.php:23]
golem15.translate.configured Cache/session "plugin is configured" (SESSION_CONFIGURED) [VERIFIED: classes/Translator.php:25]
locale_manually_set Cookie flag only ('manualSelectionCookie' => 'locale_manually_set') [VERIFIED: config/config.php:82]. Value '1', expiry 525600 minutes [VERIFIED: config/config.php:85 and routes.php:31-35]. It does not contain a locale code.

URL prefix visit queues that cookie so browser detection will not override [VERIFIED: routes.php:29-35].

setLocale($locale, $remember = true) calls Locale::isValid then App::setLocale and optionally session [VERIFIED: classes/Translator.php:58-72]. Invalid codes return false; never persist them.

API locale (Journal)

ApiLocaleMiddleware is not URL/session: (1) user preferred_locale, (2) Accept-Language, (3) default; setLocale($locale, false) — no session [VERIFIED: classes/ApiLocaleMiddleware.php:15-59]. Journal /_journal/api/v1 in Phase 15 should use this chain (or Translator current locale already set on ctx). This phase still ships Translator so both chains can call it.

URL prefix mechanics

routes.php registers a {locale}/… CMS catch-all unless golem15.translate::disableLocalePrefixRoutes [VERIFIED: routes.php:11-13, 40-44]. getPathInLocale prefixes the path; prefixDefaultLocale defaults true [VERIFIED: config/config.php:26 and classes/Translator.php:160-193]. Go proof host has no CMS router yet (Phase 16). Still implement:

  • Parse first path segment; if it is an enabled locale code, strip it and set Translator locale, set manual-selection cookie.
  • Config keys under plugin namespace: forceDefaultLocale, prefixDefaultLocale (default true), disableLocalePrefixRoutes (default false), browserDetection.enabled (default true), browserDetection.manualSelectionCookie (locale_manually_set), browserDetection.manualSelectionExpiry (525600) [VERIFIED: config/config.php:15-86].

Where it hooks (Go seam)

PHP attaches LocaleMiddleware to CmsController [VERIFIED: Plugin.php:269-271] and URL prefix in routes.php.

Go surf already wraps every route with house locale that does only towel.WithLocale(r.Context(), r.Header.Get("Accept-Language")) [VERIFIED: modules/surf/router.go:439 and 707-710], then LocaleFromPrincipal may overlay PreferredLocale [VERIFIED: modules/surf/locale_from_principal.go:10-18]. That is the reduction D-07 forbids if left as the only resolver on the proof host.

Prescription: Publish a translate.Resolver (or Translator) on backpack from plugin Boot. Change surf house locale in summercms.go to: if a Resolver is published, call it (URL prefix → user preferred → session → cookie-gated Accept-Language → default) and write towel.WithLocale; else keep today's Accept-Language behavior so fonoteka.go (no translate plugin) does not change. Session storage: signed cookie or existing session mechanism; key golem15.translate.locale. Validate with enabled-locale list on every request (never trust cookie/session blindly).

isValid = code in Locale::listEnabled() [VERIFIED: models/Locale.php:228-232].

5. PHP ml* widgets and cabana mltext / mlmarkdown

Widgets that exist (lean ships two YAML types)

registerFormWidgets [VERIFIED: Plugin.php:145-157]: mlblocks, mlmarkdowneditor, mlmediafinder, mlnestedform, mlrepeater, mlricheditor, mltext, mltextarea, mlurl.

EventRegistry auto-replaces translatable fields [VERIFIED: classes/EventRegistry.php:147-157]: text→mltext, textarea→mltextarea, markdown→mlmarkdowneditor, plus repeater/richeditor/etc.

Go has no runtime widget swap. Journal YAML will declare type: mltext / type: mlmarkdown on translatable fields. PHP Journal content is type: Golem15\Journal\FormWidgets\JournalMarkdown [VERIFIED: journal/models/post/fields.yaml:35-37] wrapped by MLJournalMarkdown whose alias is 'mlmarkdowneditor' [VERIFIED: journal/formwidgets/MLJournalMarkdown.php:22].

Locked YAML names: mltext, mlmarkdown (D-06). Map PHP mlmarkdowneditor → Go mlmarkdown. Do not register mltextarea unless a fixture needs it; mltext covers title/slug; mlmarkdown covers content. A title field that was text becomes mltext.

Locale switcher chrome (reproduce)

MLControl [VERIFIED: traits/MLControl.php]:

  • Hidden inputs per locale: name="<?= $field->getName('RLTranslate['.$code.']') ?>" with data-locale-value [VERIFIED: traits/mlcontrol/partials/_locale_values.htm:7-13].
  • Dropdown data-switch-locale [VERIFIED: _locale_selector.htm:20].
  • Optional copy-from-locale [VERIFIED: _locale_copy.htm:15].
  • Save: post('RLTranslate') then setAttributeTranslated($key, $value, $locale) for each; return value is the default locale entry [VERIFIED: traits/MLControl.php:186-217].
  • getLocaleValue uses setTranslatableUseFallback(false) so empty translations stay empty in hidden fields [VERIFIED: traits/MLControl.php:158-159].
  • Ctrl/Cmd-click switches all ML controls on the form [VERIFIED: assets/js/multilingual.js:66-68].
  • data-default-locale on the wrapper [VERIFIED: formwidgets/mltext/partials/_mltext.htm:9].

SPA prescription: one locale switcher per ML field (and form-wide Ctrl-equivalent: switching one ML field switches all). Visible editor shows the active locale; save body includes every locale.

Cabana landmines (must change in summercms.go)

formFieldTypes currently "text", "textarea", "number", "checkbox", "switch", "dropdown", "relation", "relation-manager", "widget", "partial", "fileupload", "datepicker", "password", "permissioneditor" [VERIFIED: modules/cabana/form_schema.go:24-29]. Unknown type error: "unsupported type %s" [VERIFIED: modules/cabana/form_schema.go:477-478]. Unknown YAML key: "unknown field %s" [VERIFIED: modules/cabana/form_schema.go:465-466].

ProjectWritableFields drops nested objects (if !ok || nestedValue(val) { continue }) [VERIFIED: modules/cabana/crud.go:154-171]. scalarFormField is only text/textarea/number/checkbox/switch/dropdown/datepicker [VERIFIED: modules/cabana/crud.go:1203-1208]. BindWritableFields skips non-scalar types [VERIFIED: modules/cabana/crud.go:201].

Prescription: Add mltext and mlmarkdown (and markdown) to formFieldTypes. Treat ML save values as map[string]string (locale→text) or default-locale scalar plus a sibling translations object. Do not drop those maps. After projecting, call SetTranslated for non-default locales; fill the host column with the default-locale value only (PHP getLocaleSaveValue return [VERIFIED: traits/MLControl.php:216]).

SPA admin/src/components/form/registry.ts registerers today: text, textarea, number, dropdown, switch, checkbox, relation, widget, partial, fileupload, datepicker, password, permissioneditor [VERIFIED: admin/src/components/form/registry.ts:49-63]. Add markdown, mltext, mlmarkdown. Same change: cabana README, docs/backend/forms.md field-types table (currently lists those types and says markdown editor is not provided [VERIFIED: docs/backend/forms.md:86-101]), OpenAPI, openapi-typescript, committed modules/boardwalk/dist/.

Share markdown with Phase 15 D-11

Land the shared markdown primitive in this phase. mlmarkdown = markdown editor + ML chrome. Phase 15 Journal content field is type: mlmarkdown (translate required) or type: markdown if someone boots Journal without translate — Journal 15 D-09/D-10 requires translate, so YAML can be mlmarkdown only.

Goldmark v1.8.6 already in framework go.mod [VERIFIED: go.mod:30]. Preview HTML: reuse Phase 4 mail rules (no html.WithUnsafe; reject leftover script/iframe). Admin preview can be client-side for the SPA and server-side on the preview screen later; minimum this phase is edit+save of markdown source per locale, not a second WYSIWYG.

Do not port mlblocks, mlrepeater, mlricheditor, mlmediafinder, mlnestedform, mlurl (out of lean / Journal YAML).

6. Go analog: user plugin mount, cabana registry, phrasebook

Mount pattern (copy)

Piece User plugin fact Translate
Module path module git.golem15.com/golem15/sm-user-plugin [VERIFIED: ../fonoteka.go/plugins/golem15/user/go.mod:1] git.golem15.com/golem15/sm-translate-plugin
Package package user [VERIFIED: plugin.go:1] package translate
Plugin ID return "golem15.user" [VERIFIED: plugin.go:58] golem15.translate
init party.Register(&Plugin{}) [VERIFIED: plugin.go:236-238] same
summer.yaml - id: golem15.user / module: git.golem15.com/golem15/sm-user-plugin [VERIFIED: ../fonoteka.go/summer.yaml:4-5] add golem15.translate
Submodule path = plugins/golem15/user [VERIFIED: ../fonoteka.go/.gitmodules:1-3] plugins/golem15/translate
go.work ./plugins/golem15/user [VERIFIED: ../fonoteka.go/go.work:10] ./plugins/golem15/translate
Host go.mod replace replace git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user [VERIFIED: sm-bm-app/go.mod:16] same shape
Plugin go.mod replace replace git.golem15.com/golem15/summercms => ../../../../summercms.go [VERIFIED: sm-user-plugin/go.mod:120] identical relative path when mounted at plugins/golem15/translate
Admin HasAdminControllers, AdminFS, HasPermissions, HasNavigation [VERIFIED: admin.go:33-37] Locales controller
Capabilities HasMigrations, HasModels, HasLang, HasConfig same; plus Translator publish in Boot

Sibling host precedent: sm-bm-app (summer.yaml + go.work + .gitmodules + replace … => ../summercms.go [VERIFIED: sm-bm-app/go.mod:7]). Proof host should clone sm-grzybyfunkcjonalne-app remote and copy this layout, not fonoteka.go.

Decision note: .planning/notes/core-plugins-own-repos.md — module path equals repo path; README never names a consuming app [VERIFIED: .planning/notes/core-plugins-own-repos.md:12-15, 36].

Cabana field registry pattern

New types go in formFieldTypes, SPA registry.ts, docs table, OpenAPI — same as permissioneditor (field_permission.go) and datepicker / fileupload. Fail-loud: unknown keys/types stop cabana.Activate [VERIFIED: docs/backend/admin-controllers.md:161].

Phrasebook vs model translations (do not conflate)

phrasebook: "phrasebook owns every translatable string in a SummerCMS application" — keys namespace::group.dot.path, YAML under lang/<locale>/ [VERIFIED: modules/phrasebook/README.md:3-9]. That is plugin UI (Locales labels).

Model translations: winter_translate_attributes JSON per record. I18N-01 is phrasebook. This plugin is the missing model layer. Translator request locale should set towel.WithLocale so phrasebook and content usually share a code, but storage APIs stay separate.

7. Docs / OpenAPI / TS / dist (same change)

When adding markdown, mltext, mlmarkdown:

  1. modules/cabana/README.md Features list + API identifiers that exist in the package.
  2. docs/backend/forms.md field-types table (replace the sentence that the markdown editor is not provided [VERIFIED: docs/backend/forms.md:101]).
  3. docs/backend/admin-controllers.md only if controller activation rules change.
  4. swag annotations / admin/openapi/admin.json / openapi-typescript → admin/src types.
  5. admin/src/components/form/registry.ts + Vue controls + tests.
  6. Rebuild committed modules/boardwalk/dist/.
  7. go test ./cmd/summer -run TestDocsTree and summer docs:build --check.
  8. Neutral names only (acme, blog).

ML-specific YAML keys (if any, e.g. none beyond type) must be added to formFieldKeys or boot fails [VERIFIED: modules/cabana/form_schema.go:36-47]. Prefer no extra keys: ML types reuse label, comment, span, size, required, tab, context.

8. Proof-host layout

Local dirs do not exist this session:

  • /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/ — missing
  • /media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/ — missing
  • git@git.golem15.com:golem15/sm-translate-plugin.git — user creates (D-15)
  • git@git.golem15.com:golem15/sm-grzybyfunkcjonalne-app.git — already created (CONTEXT remotes)

Plan Wave 0: (1) user creates Gitea remote; (2) git clone host; (3) init plugin repo; (4) submodule add at plugins/golem15/user and plugins/golem15/translate.

Boot without Journal: summer.yaml lists only golem15.user and golem15.translate. Requires() on translate: empty (or golem15.user only if locale-from-user needs it). Locale from user is optional (Auth::check in PHP); Translator works without user.

Fixture model for en+pl proof

PHP test fixture Country [VERIFIED: tests/fixtures/models/Country.php:10-27]: table translate_test_countries, $translatable = [['name', 'index' => true], 'states'].

Go: test-only model Article (neutral) in the plugin test package or a tiny pact.HasModels fixture compiled only in tests (Phase 5 DATA-11: production binary must not load test plugins). Proof host D-17 needs a model the booted app can save through admin ML fields. Prescription:

  • Plugin testdata model is not enough for "Locales admin works" on the host.
  • Add a proof-only model in the host app package (not the shared plugin): e.g. acme.demo.posts with Translatable() []string{ "title", "body" }, YAML mltext/mlmarkdown, gated behind app plugin golem15.grzyby stub or document UAT against plugin integration tests that boot party.Activate([]{user, translate, fixture}) like 12.1 newAdminEnv.

Prefer: plugin integration test (httptest + testcontainers) that boots user+translate+in-process fixture plugin, plus host smoke migrate/serve with empty extra models. D-17 "fixture model" is satisfied by the test fixture if the host has no third plugin yet. Planner should state that explicitly so executors do not block on a host demo model.

Proof steps: seed en+pl; create Locale rows visible in admin; SetTranslated title en/pl; WithLocale readback; missing pl falls back to en.

9. Security landmines

Run the security-review agent (CONTEXT discretion).

Threat PHP / Go fact Mitigation
Forged locale in URL/session/cookie setLocale requires Locale::isValid [VERIFIED: Translator.php:60-62]; cookie is a flag not a locale [VERIFIED: config.php:82] Re-validate enabled codes every request; ignore unknown first segments
Accept-Language injection PHP takes first two letters and checks enabled list [VERIFIED: LocaleMiddleware.php:126-132] Same allow-list; do not pass raw header into towel.WithLocale (today surf does pass the raw header [VERIFIED: surf/router.go:709] — fix when Resolver is present)
manage_locales bypass controller $requiredPermissions [VERIFIED: Locales.php:21] cabana AdminPermissioned on Locales; 403 otherwise
Delete/unset default locale beforeDelete / beforeUpdate [VERIFIED: Locale.php:77-92] Port as validation 422
Mass-assign is_default / sort_order fillable is only code, name, is_enabled [VERIFIED: Locale.php:37-41] cabana Fillable / BindWritableFields; is_default via hook
Attribute mass-assign $fillable locale, model_type, model_id, attribute_data [VERIFIED: Attribute.php:20-24]; $guarded = ['*'] [VERIFIED: Attribute.php:30] No public Attribute controller; writes only through Translatable helpers
Admin ML write as confused deputy PHP widgets call setAttributeTranslated inside the form save of the host controller ML save runs inside cabana CRUD after the host controller's permissions and FormExtendQuery; never a public unauthenticated translate endpoint
Session locale forging session value still passes isValid on setLocale same
XSS in translated admin fields SPA text inputs; markdown preview must not html.WithUnsafe Goldmark safe + CSP already on admin
Messages import path traversal PathTraversalTest [VERIFIED: tests/security/PathTraversalTest.php] Out of scope (import deferred) — do not port ImportCommand
Message $guarded = [] AccessControlTest TRANSLATE-001 [VERIFIED: tests/security/AccessControlTest.php:39-66] Create messages table with a Go model that uses an explicit fillable if the struct exists; or omit Message model entirely and only DDL the table

Do not log Accept-Language payloads as a security event that echoes header text to clients.

10. Test landmines (lean subset)

PHP tests/ that still apply:

PHP test Lean Go equivalent
testGetTranslationValue fallback to default [VERIFIED: TranslatableModelTest.php:59-69] missing pl returns en column
testGetTranslationValueUseFallbackFalse [VERIFIED: :74-83] optional; D-11 is fallback-on. Skip noFallback unless Journal needs it
testSetTranslationValue default vs fr isolation [VERIFIED: :100-121] save en+pl; default column unchanged when setting pl
testTranslateWhere needs locale + index [VERIFIED: :146-162] WithLocale + indexed slug
testTranslateOrderBy [VERIFIED: :164-191] skip unless Journal lists by translated title this phase
morphMap eager load [VERIFIED: :193-218] skip (no morph map)
testAddTranslatableAttributes [VERIFIED: :220-234] skip (not in D-12 API)
TRANSLATE-002 Attribute fillable [VERIFIED: MassAssignmentTest.php:19-56] if Attribute model exists
TRANSLATE-001 / 003 / 004 Messages skip (deferred)
CMS page/url tests skip

Add Go tests the PHP suite lacks: Translator order (URL wins over session; invalid prefix ignored; cookie flag skips Accept-Language; unlisted code rejected); manage_locales 403; cabana boot fails on type: mlunknown; ML save nested map not dropped; house locale without plugin still Accept-Language (fonoteka).

11. Suggested plan split (recommendation, not PLAN.md)

Four plans. Present this count at the plan-count checkpoint. Unit tests last.

Plan One-line scope
14.2.1-01 Plugin repo + squashed winter_translate_* schema (all four tables) + Locale model + en/pl seed + Translator + surf Resolver seam. Manual: Gitea remote + local checkouts.
14.2.1-02 Translatable API (Translatable, WithLocale, get/set, fallback, indexes) + Locales admin (manage_locales, YAML, phrasebook) + fixture save/read.
14.2.1-03 Cabana markdown + mltext + mlmarkdown (SPA switcher, nested save, docs/OpenAPI/TS/dist/) + proof host sm-grzybyfunkcjonalne-app boots user+translate.
14.2.1-04 Unit/integration tests last: Translator order, Translatable fallback, admin authz, ML save, migrations up/down.

Do not put coverage gates on 01–03; smoke the host boot in 03.

Architecture Patterns

System Architecture Diagram

Public/Admin HTTP
    → surf recover / body limit
    → house locale: backpack Translator.Resolver? (URL prefix | user preferred | session | cookie-gated Accept-Language | default)
         else Accept-Language (today)
    → towel.WithLocale(ctx, code)
    → admin guard / public handler

Admin Locales CRUD
    → cabana.CRUDService (permission golem15.translate.manage_locales)
    → winter_translate_locales

Admin Journal-like form (fixture / Phase 15)
    → mltext / mlmarkdown controls
    → default locale → host table columns
    → other locales → winter_translate_attributes.attribute_data JSON
    → indexed fields → winter_translate_indexes

Journal query (Phase 15)
    → Translator.Locale(ctx) or WithLocale(..., "pl")
    → transWhere via indexes, fallback to host column
sm-translate-plugin/
├── go.mod                          # git.golem15.com/golem15/sm-translate-plugin
├── plugin.go                       # ID golem15.translate, party.Register
├── README.md
├── admin.go                        # HasAdminControllers, AdminFS, nav, permissions
├── config/config.yaml              # prefixDefaultLocale, browserDetection, …
├── lang/en/lang.yaml
├── lang/pl/lang.yaml
├── models/locale.go
├── models/attribute.go             # optional; helpers may use table SQL
├── classes/translator.go
├── classes/translatable.go
├── controllers/locales.go
├── controllers/locales/config_list.yaml
├── controllers/locales/config_form.yaml
├── models/locale/fields.yaml
├── models/locale/columns.yaml
└── updates/                        # gormigrate squash

Framework touches: modules/surf/router.go locale seam; modules/cabana/form_schema.go + CRUD fill; admin/src/components/form/*; docs/backend/forms.md; modules/boardwalk/dist/.

Pattern 1: Default locale on the row, others in attributes

What: Host columns are the default locale. Other locales are one JSON blob per row/locale. When: Every translatable model (Journal Post/Category). Do not: Add title_pl columns or a JSON column on golem15_journal_posts.

Pattern 2: Optional Translator on backpack

What: Plugin Boot publishes Resolver; surf locale uses it if present. When: Any host that mounts golem15.translate. Do not: Change fonoteka's resolution when the plugin is absent.

Anti-Patterns to Avoid

  • JSON-column "simpler" storage: violates D-10 and Winter import.
  • Renaming tables to golem15_translate_*: frozen PHP uses winter_translate_*.
  • Translator singleton holding the request locale: violates KERN-07.
  • Accept-Language as the only resolver: violates D-07; also stop stuffing the raw header into context when Resolver runs.
  • Plugin-owned type: widget ML controls: field types belong in cabana.
  • Conflating phrasebook keys with attribute JSON.
  • Shipping Messages admin / locale picker (deferred).
  • Editing the PHP plugin.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Admin list/form Custom Vue Locales app cabana YAML + CRUDService 12.1 pattern; permissions, CSRF, schema
UI strings Copy PHP Lang::get into Translator phrasebook HasLang I18N-01 already ships
Markdown parse New library goldmark v1.8.6 Already in go.mod
Request locale bag var currentLocale towel.WithLocale KERN-07
Plugin mount Ad-hoc GOPATH submodule + go.work replace core-plugins-own-repos.md
Postgres schema AutoMigrate gormigrate squash DATA-02

Key insight: The expensive parts are the attribute-table shape and the locale resolver order. Reusing cabana/phrasebook/surf is mandatory; inventing a parallel admin or i18n stack is not.

Common Pitfalls

Pitfall 1: Wrong table prefix

What goes wrong: Creating golem15_translate_locales because CONTEXT guessed that name. Why: D-10 asked the researcher to confirm; models say winter_translate_*. How to avoid: Use the verified names in every migration and query. Warning signs: Schema::hasTable('winter_translate_locales') in Translator.

Pitfall 2: Nested ML save dropped

What goes wrong: SPA posts {title: {en, pl}}; cabana drops maps; only default locale saves. Why: ProjectWritableFields skips nestedValue. How to avoid: Special-case mltext/mlmarkdown before the nested drop. Warning signs: Fixture pl read returns en after a two-locale save.

Pitfall 3: Default locale duplicated into attributes

What goes wrong: Writing en into winter_translate_attributes as well as posts.title. Why: PHP isTranslatable is false in default context; storeTranslatableBasicData is for other locales. How to avoid: Only persist attribute rows for non-default locales.

Pitfall 4: Surf Accept-Language leftover

What goes wrong: Proof host still keys locale off the raw header. Why: House locale() is hardcoded. How to avoid: Resolver lookup in surf; tests for plugin-present vs plugin-absent.

Pitfall 5: Phrasebook en/pl files vs Locale rows

What goes wrong: Seeding Locale rows from phrasebook presence, or vice versa. Why: D-08 is seed rows, not the 19 PHP unsupported_lang files. How to avoid: Two Locale rows; two phrasebook YAML trees for plugin UI only.

Pitfall 6: Host/plugin directories missing

What goes wrong: Executor assumes checkouts exist. Why: Both paths missing this session. How to avoid: Plan 01 starts with clone/init and the manual Gitea remote.

Code Examples

Locale model table (PHP source of truth)

DATA_n4w8c1vz_START

public $table = 'winter_translate_locales';

DATA_n4w8c1vz_END

[VERIFIED: models/Locale.php:24]

Minimum Go translatable surface (planner skeleton; identifiers below are prescribed)

// Source: this RESEARCH (maps PHP $translatable / translateContext / getAttributeTranslated)
type Translatable interface {
	Translatable() []string
	MorphName() string
}

func WithLocale(ctx context.Context, db *gorm.DB, locale string) *gorm.DB
func Translated(ctx context.Context, db *gorm.DB, m Translatable, field, locale string) (any, error)
func SetTranslated(ctx context.Context, db *gorm.DB, m Translatable, field, locale string, value any) error

en and pl must appear as Locale code values [VERIFIED: updates/v1.3.1/seed_all_tables.php:18 and updates/v2.4.0/seed_additional_locales.php:15].

House locale seam (current, to wrap)

DATA_r3t6y0ab_START

func locale(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))))
	})
}

DATA_r3t6y0ab_END

[VERIFIED: modules/surf/router.go:707-710]

State of the Art

Old Approach (PHP) Current Approach (Go) When Impact
RainLab then Winter table names Squash to winter_translate_* 2.0.0 rename Do not emit rainlab tables
Eloquent $implement behavior Go interface + GORM callbacks this port Explicit Translatable()
Winter formwidget swap at runtime Explicit YAML mltext/mlmarkdown compiled admin Journal YAML names ML types
Translator singleton towel.WithLocale + backpack service KERN-07 No request globals
CMS URL prefix routes Optional first-segment strip + cookie flag Phase 16 still needs prefix Implement resolver now

Deprecated/outdated: PHP noFallbackLocale() (deprecated 2.1.5) [VERIFIED: TranslatableBehavior.php:169-173] — do not export it. PHP setTranslateAttribute already removed in 2.0.1 notes.

Assumptions Log

# Claim Section Risk if Wrong
A1 Proof D-17 may be satisfied by an in-process fixture plugin rather than a host demo model §8 Planner may need a host stub model after all
A2 Cabana has no ReorderController; skipping drag-reorder is acceptable §2 Operators cannot reorder locales in admin until a later phase
A3 Session locale can live in a signed cookie named golem15.translate.locale if the host has no PHP-style session store §4 May need to align with whatever session cabana/admin already uses
A4 de must not be seeded even though PHP 2.4.0 inserts it §2 Matches D-08 / deferred remaining locales; confirm if a live Winter DB import expects de

A1–A3 are execution details, not stack choices. A4 follows locked D-08.

Open Questions (RESOLVED)

  1. Proof fixture location — RESOLVED

    • What we know: D-17 wants a fixture model; host checkout is empty/missing.
    • Resolution: use a test-only fixture plugin/model in the translate plugin integration harness, plus a real sm-grzybyfunkcjonalne-app host boot with user+translate and no third production plugin. Plans 02–04 implement this split.
  2. Admin session for Translator — RESOLVED

    • What we know: PHP uses Laravel Session::put(SESSION_LOCALE).
    • Resolution: use a signed remembered-locale cookie named golem15.translate.locale, with locale_manually_set remaining a separate flag-only cookie; validate the remembered code against enabled locales on every request. Plan 01 implements this Go session analog.
  3. Phase 15 ROADMAP depends_on — RESOLVED

    • Resolution: keep the ROADMAP dependency edit deferred and out of Phase 14.2.1. The plans document Phase 15 as the consumer without mutating its roadmap metadata or blocking this phase.

Environment Availability

Dependency Required By Available Version Fallback
Go plugin + framework ✓ go1.27.0-X:nodwarf5 —
Git repos/submodules ✓ 2.55.0 —
PostgreSQL migrations, tests ✓ pg_isready accepting on 5432 —
Docker testcontainers last plan ✓ 29.7.2 -short skip slower tests
sm-translate-plugin dir D-16 ✗ — Plan 01 creates it
sm-grzybyfunkcjonalne-app dir D-14 ✗ — Plan 01 clones remote
Gitea sm-translate-plugin.git D-15 ✗ (does not exist yet) — Manual user step

Missing dependencies with no fallback: Gitea remote creation (human). Local dirs are created by the plan once the remote exists.

Missing dependencies with fallback: none besides testcontainers -short.

Graphify: disabled (gsd_run graphify status → not enabled). No graph context.

Nyquist: workflow.nyquist_validation is true in .planning/config.json.

Validation Architecture

Test Framework

Property Value
Framework Go testing + testcontainers-go v0.44.0 (Postgres)
Config file none — go test ./...
Quick run command go test ./... -short -count=1 (plugin + ./modules/cabana + ./modules/surf)
Full suite command go test ./... -count=1 in sm-translate-plugin, summercms.go, and host go test ./...

Phase Requirements → Test Map

No REQUIREMENTS.md IDs were mapped (TBD). Map locked decisions:

Req ID Behavior Test Type Automated Command File Exists?
D-05 Locales admin requires golem15.translate.manage_locales integration go test ./... -run TestLocalesAdminForbidden -count=1 ❌ Wave 0
D-06 type: mltext and type: mlmarkdown compile; unknown type fails boot unit go test ./modules/cabana -run TestMLFieldTypes -count=1 ❌ Wave 0
D-07 URL prefix wins; invalid code ignored; session/cookie flag; not header-only unit go test ./classes -run TestTranslatorResolve -count=1 ❌ Wave 0
D-08 Seed inserts en (default, enabled) and pl (enabled, not default); not de integration go test ./updates -run TestSeedEnPl -count=1 ❌ Wave 0
D-09/D-12 Translatable() + get/set + WithLocale unit go test ./classes -run TestTranslatableGetSet -count=1 ❌ Wave 0
D-10 Tables winter_translate_locales|attributes|indexes|messages exist after migrate integration go test ./updates -run TestTranslateTables -count=1 ❌ Wave 0
D-11 Missing pl falls back to default locale column unit go test ./classes -run TestFallbackDefaultLocale -count=1 ❌ Wave 0
D-17 Host/plugin Activate with user+translate smoke go test ./... -run TestBootUserTranslate -count=1 ❌ Wave 0
T-SEC-01 Unlisted locale never stored unit go test ./classes -run TestInvalidLocaleRejected -count=1 ❌ Wave 0
T-SEC-02 Nested ML body not dropped unit go test ./modules/cabana -run TestMLNestedSave -count=1 ❌ Wave 0

Sampling Rate

  • Per task commit: go test ./... -short -count=1 in the repo that changed
  • Per wave merge: full go test ./... -count=1 in that repo + go vet ./...
  • Phase gate: all three repos green before $gsd-verify-work

Wave 0 Gaps

  • sm-translate-plugin module and test files listed above
  • modules/cabana ML compile/save tests
  • modules/surf Resolver-present vs absent locale tests
  • Plugin Postgres harness (copy user plugin TestMain / testcontainers)
  • No new test framework install

Security Domain

security_enforcement absent in .planning/config.json → enabled.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no (no new login) existing cabana/backend + user plugin
V3 Session Management yes signed locale cookie + locale_manually_set; isValid on load
V4 Access Control yes golem15.translate.manage_locales; host controller perms for ML writes
V5 Input Validation yes locale code allow-list; cabana fillable; goldmark safe preview
V6 Cryptography no do not hand-roll tokens; reuse existing cookie signing if any

Known Threat Patterns

Pattern STRIDE Standard Mitigation
Locale code injection via URL/header/cookie Tampering Enabled-locale allow-list; cookie is flag-only
Privilege on Locales admin Elevation RequiredPermissions + cabana 403
Nested ML JSON smuggling extra columns Tampering Project only translatable field names; Attribute writes via helpers
Stored XSS in translated markdown Information disclosure / Tampering Goldmark without unsafe HTML
Mass assignment on Locale/Attribute Tampering fillable allow-lists (PHP TRANSLATE-002)
Translator process-wide locale leak across requests Information disclosure context.Context only (KERN-07)

Sources

Primary (HIGH confidence)

  • Frozen PHP plugin /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate at 725d547ec839f02b5fdc0f0a6faaed601a414d50 — migrations, models, Translator, Locales, ML widgets, tests
  • Journal PHP /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php and Category.php — $translatable lists
  • summercms.go modules/cabana, modules/surf, modules/towel, modules/phrasebook, modules/pact, docs/backend/forms.md, docs/backend/admin-controllers.md, admin/src/components/form/registry.ts
  • ../fonoteka.go/plugins/golem15/user/ mount analog; sm-bm-app host analog
  • .planning/notes/core-plugins-own-repos.md, 15-CONTEXT.md D-09–D-11, D-18–D-21

Secondary (MEDIUM confidence)

  • None required for stack versions; go.mod read directly

Tertiary (LOW confidence)

  • A1–A3 in Assumptions Log

Metadata

Confidence breakdown:

  • Standard stack: HIGH — no new packages; versions from go.mod
  • Architecture: HIGH — PHP storage and resolver read in full; Go seams read in full
  • Pitfalls: HIGH — cabana nested drop and surf Accept-Language verified in source

Research date: 2026-10-06 Valid until: 2026-11-05 (PHP pin frozen; re-research if SHA changes)

Graphify: disabled — no graph queries Documentation lookup: in-repo PHP + Go; Context7 not applicable