docs(14.2.1): add research and validation strategy

This commit is contained in:
Jakub Zych
2026-10-06 10:57:17 +02:00
parent d2609e9499
commit 964145628a
2 changed files with 914 additions and 0 deletions

View File

@@ -0,0 +1,828 @@
# 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.
### Cookie and session keys
| 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
```
### Recommended plugin structure
```
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)
```go
// 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
1. **Proof fixture location**
- What we know: D-17 wants a fixture model; host checkout is empty/missing.
- What's unclear: host app plugin vs test-only fixture.
- Recommendation: plugin integration test + host boot without Journal; no third production plugin.
2. **Admin session for Translator**
- What we know: PHP uses Laravel `Session::put(SESSION_LOCALE)`.
- What's unclear: SummerCMS public requests may not have a session store yet (admin uses JWT cookie).
- Recommendation: signed cookie `golem15.translate.locale` plus `locale_manually_set`; document as the Go analog of session+cookie.
3. **Phase 15 ROADMAP depends_on**
- Deferred idea: `$gsd-phase --edit 15` not done in discuss.
- Recommendation: planner notes it; do not block 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

View File

@@ -0,0 +1,86 @@
---
phase: "14.2.1"
slug: "translate-plugin"
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
status: draft
nyquist_compliant: false
wave_0_complete: false
created: "2026-10-06"
---
# Phase 14.2.1 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| 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 ./...` |
| **Estimated runtime** | ~60 seconds (short); ~180 seconds (full, Postgres) |
---
## Sampling Rate
- **After every task commit:** Run `go test ./... -short -count=1` in the repo that changed
- **After every plan wave:** Run `go test ./... -count=1` and `go vet ./...` in that repo
- **Before `$gsd-verify-work`:** Full suite must be green in plugin, `summercms.go`, and host
- **Max feedback latency:** 60 seconds (short)
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 14.2.1-W0 | 04 | 0 | D-05 | T-14.2.1-01 | Locales admin requires `golem15.translate.manage_locales` | integration | `go test ./... -run TestLocalesAdminForbidden -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-06 | — | `type: mltext` and `type: mlmarkdown` compile; unknown type fails boot | unit | `go test ./modules/cabana -run TestMLFieldTypes -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-07 | T-14.2.1-02 | URL prefix wins; invalid code ignored; session/cookie flag; not header-only | unit | `go test ./classes -run TestTranslatorResolve -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-08 | — | Seed inserts `en` (default, enabled) and `pl` (enabled, not default); not `de` | integration | `go test ./updates -run TestSeedEnPl -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-09/D-12 | — | `Translatable()` + get/set + `WithLocale` | unit | `go test ./classes -run TestTranslatableGetSet -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-10 | — | Tables `winter_translate_locales\|attributes\|indexes\|messages` exist after migrate | integration | `go test ./updates -run TestTranslateTables -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-11 | — | Missing `pl` falls back to default locale column | unit | `go test ./classes -run TestFallbackDefaultLocale -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | D-17 | — | Host/plugin Activate with user+translate | smoke | `go test ./... -run TestBootUserTranslate -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | T-SEC-01 | T-14.2.1-03 | Unlisted locale never stored | unit | `go test ./classes -run TestInvalidLocaleRejected -count=1` | ❌ W0 | ⬜ pending |
| 14.2.1-W0 | 04 | 0 | T-SEC-02 | T-14.2.1-04 | Nested ML body not dropped | unit | `go test ./modules/cabana -run TestMLNestedSave -count=1` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `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
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Create Gitea remote `git@git.golem15.com:golem15/sm-translate-plugin.git` | D-15 | Requires user credentials on git.golem15.com | User creates public repo; executor verifies clone URL exists |
| Locales admin works in the proof-host SPA | D-17 | Needs running host + admin login | Boot `sm-grzybyfunkcjonalne-app`; open Locales; create/edit locale as permitted user |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending