--- phase: 14.2.1-translate-plugin plan: 02 type: execute wave: 2 depends_on: ["14.2.1-01"] files_modified: - ../sm-translate-plugin/classes/translatable.go - ../sm-translate-plugin/classes/translatable_smoke_test.go - ../sm-translate-plugin/models/attribute.go - ../sm-translate-plugin/models/index.go - ../sm-translate-plugin/admin.go - ../sm-translate-plugin/admin_permissions.go - ../sm-translate-plugin/admin_navigation.go - ../sm-translate-plugin/controllers/admin_registry.go - ../sm-translate-plugin/controllers/locales.go - ../sm-translate-plugin/controllers/locales/config_list.yaml - ../sm-translate-plugin/controllers/locales/config_form.yaml - ../sm-translate-plugin/models/locale/fields.yaml - ../sm-translate-plugin/models/locale/columns.yaml - ../sm-translate-plugin/lang/en/lang.yaml - ../sm-translate-plugin/lang/pl/lang.yaml - ../sm-translate-plugin/locales_admin_smoke_test.go - ../sm-translate-plugin/README.md autonomous: true requirements: [D-05, D-08, D-09, D-10, D-11, D-12, D-17] must_haves: truths: - "D-09/D-12: a host model declares `Translatable() []string`, `TranslatableIndexes() []string`, and `MorphName() string`; callers use exported get/set and `WithLocale` APIs." - "D-10: default-locale values remain in host columns; each non-default locale is one JSON object row in `winter_translate_attributes`, and indexed values are mirrored to `winter_translate_indexes`." - "D-11: a missing non-default value reads the host model's default-locale field." - "D-05: Locales is ordinary cabana CRUD, permission-gated by exactly `golem15.translate.manage_locales`; no Messages controller, permission, or navigation exists." - "Locale form writes only code/name/is_enabled; is_default and sort_order cannot be mass-assigned, while a permissioned make-default action preserves disabled/default lifecycle guards." - "D-17: a neutral fixture saves and reads en/pl and performs an indexed locale lookup through the production APIs." artifacts: - path: "../sm-translate-plugin/classes/translatable.go" provides: "Translatable contracts, WithLocale, Translated, SetTranslated, indexed lookup" contains: "type Translatable interface" - path: "../sm-translate-plugin/controllers/locales.go" provides: "permissioned Locale admin controller and default guards" contains: "golem15.translate.manage_locales" - path: "../sm-translate-plugin/controllers/locales/config_list.yaml" provides: "sort_order asc Locales list" contains: "sort_order" - path: "../sm-translate-plugin/lang/pl/lang.yaml" provides: "Polish Locales admin phrasebook" contains: "locale:" key_links: - from: "../sm-translate-plugin/classes/translatable.go" to: "winter_translate_attributes" via: "non-default SetTranslated upserts one locale/model row" pattern: "attribute_data" - from: "../sm-translate-plugin/classes/translatable.go" to: "winter_translate_indexes" via: "indexed fields upsert locale/model/item/value" pattern: "TranslatableIndexes" - from: "../sm-translate-plugin/controllers/locales.go" to: "../sm-translate-plugin/models/locale.go" via: "cabana CRUD hooks enforce PHP default-locale invariants" pattern: "RequiredPermissions" --- Ship the explicit Translatable model API and the permissioned Locales admin, proven by an en/pl fixture through real Postgres storage. Purpose: give Journal a stable compiled API and let operators manage enabled/default locales without exposing raw attribute rows or Messages. Output: translatable contracts/helpers/index lookup, Locales YAML/controller/navigation/permissions/phrasebook, smoke fixture. @~/.codex/gsd-core/workflows/execute-plan.md @~/.codex/gsd-core/templates/summary.md @.planning/phases/14.2.1-translate-plugin/14.2.1-01-SUMMARY.md @.planning/phases/14.2.1-translate-plugin/14.2.1-CONTEXT.md @.planning/phases/14.2.1-translate-plugin/14.2.1-RESEARCH.md @.planning/phases/14.2.1-translate-plugin/14.2.1-PATTERNS.md @../fonoteka.go/plugins/golem15/user/admin.go @../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go @../fonoteka.go/plugins/golem15/user/admin_harness_test.go ## Spec-less probe fallback No REQUIREMENTS.md IDs are mapped to this phase. Probe predicates are intentionally omitted; D-05/D-08/D-09/D-10/D-11/D-12/D-17 and RESEARCH's validation rows are the visible source contract. ## Artifacts this phase produces - Exported `classes.Translatable`, optional indexed-field contract, `WithLocale`, `Translated`, `SetTranslated`, and locale-aware indexed lookup. - Internal `models.Attribute` and index record mapping with no public CRUD controller. - `golem15.translate.manage_locales`, Locales navigation/controller, embedded list/form/fields/columns YAML. - English and Polish phrasebook catalogs for plugin/locale admin strings, separate from model translation JSON. - Neutral fixture smoke proving en/pl save/read, fallback, and indexed lookup. Task 1: Save one fixture title in en and pl, then read pl through the exported API ../sm-translate-plugin/classes/translatable.go, ../sm-translate-plugin/classes/translatable_smoke_test.go, ../sm-translate-plugin/models/attribute.go, ../sm-translate-plugin/models/index.go, ../sm-translate-plugin/models/registry.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/behaviors/TranslatableModel.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/classes/TranslatableBehavior.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/tests/unit/behaviors/TranslatableModelTest.php, ../sm-translate-plugin/models/locale.go, ../sm-translate-plugin/updates/202610060002_create_winter_translate_attributes.go, ../sm-translate-plugin/updates/202610060003_create_winter_translate_indexes.go, ../fonoteka.go/plugins/golem15/user/models/user.go (MorphName), .planning/phases/14.2.1-translate-plugin/14.2.1-PATTERNS.md (Translatable assignment) Implement the minimum stable D-09/D-12 API without Eloquent-style magic. Define a host-model contract with `Translatable() []string` and `MorphName() string`, plus an explicit indexed-field contract `TranslatableIndexes() []string`. Reject fields not declared by the model and locale codes not enabled in `winter_translate_locales`. `SetTranslated` writes the default locale directly to the host model field/column and writes non-default values to one `(locale, model_id, model_type)` `winter_translate_attributes` row as a JSON object keyed by field. Use explicit model primary-key extraction and explicit exported-field/GORM-column mapping; do not use `reflect.Type.String()` as morph identity. Attribute rows have no public controller. `Translated` returns the host field for default locale; for another enabled locale it reads the JSON key and, when absent, returns the default host field per D-11. Keep an explicit locale argument even when context has a UI locale. Create a neutral fixture model only in the smoke test, migrate its host table explicitly, seed en/pl through Plan 01 migrations, save English and Polish title values, and read Polish back. This smoke is the production tracer, not the full test matrix reserved for Plan 04. go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestFixtureTranslatableSaveRead)$' Non-zero exit, output contains "--- FAIL", "--- SKIP", or "no tests to run", or lacks "--- PASS: TestFixtureTranslatableSaveRead". - Exported interfaces and functions compile from an external test package. - The default English value is in the fixture host row, not duplicated into `winter_translate_attributes`. - The Polish value is present under `attribute_data.title` in exactly one pl/model row. - Undeclared fields and unenabled locale codes return errors without database writes. - Test fixture is test-only and no production fixture plugin is globally registered. A neutral model persists its default title on its own row, persists Polish in Winter storage, and reads Polish through the exported API. Task 2: Add indexed locale lookup, WithLocale query scope, and default fallback ../sm-translate-plugin/classes/translatable.go, ../sm-translate-plugin/classes/translatable_smoke_test.go, ../sm-translate-plugin/README.md ../sm-translate-plugin/classes/translatable.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/behaviors/TranslatableModel.php (scopeTransWhere and index writes), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php, .planning/phases/14.2.1-translate-plugin/14.2.1-RESEARCH.md (Minimum exported Go API) Expand from the proven save/read path. `WithLocale(ctx, db, locale)` validates the explicit locale and carries it on the GORM statement context; it must not mutate global state. Add an indexed equality helper/scope that joins `winter_translate_indexes` by locale, morph name, model id, and item, while default-locale lookup uses the host column and non-default lookup falls back to that host column only when no translated index match exists, matching the frozen PHP behavior. When SetTranslated writes a declared indexed field, upsert the corresponding index row atomically with the attribute JSON update. Rewriting one locale/field must preserve other translated fields in the same JSON object. Empty translated values stay explicit empty values rather than silently borrowing fallback during admin editing; ordinary `Translated` still applies D-11 fallback. Document only the exported identifiers that exist, MorphName's import-stability requirement, and the default-row/non-default-attribute storage model. Use neutral `blog`/`acme` examples and do not mention a consuming application. go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestFixtureTranslatableSaveRead|TestFixtureTranslatedIndexSmoke)$' Non-zero exit, output contains "--- FAIL", "--- SKIP", or "no tests to run", or either named PASS line is absent. - `WithLocale` returns a GORM handle carrying an explicit validated locale on context and has no singleton mutation. - Setting an indexed Polish slug writes one matching `winter_translate_indexes` row. - Polish indexed lookup finds the fixture; default-locale lookup uses the host slug. - Missing Polish title returns English under normal reads per D-11. - README names every exported API identifier accurately and remains application-neutral. Journal can query and mutate indexed translated slugs and ordinary translated fields through the minimum public contract. Task 3: Manage Locales through permissioned YAML CRUD and separate phrasebook catalogs ../sm-translate-plugin/admin.go, ../sm-translate-plugin/admin_permissions.go, ../sm-translate-plugin/admin_navigation.go, ../sm-translate-plugin/controllers/admin_registry.go, ../sm-translate-plugin/controllers/locales.go, ../sm-translate-plugin/controllers/locales/config_list.yaml, ../sm-translate-plugin/controllers/locales/config_form.yaml, ../sm-translate-plugin/models/locale/fields.yaml, ../sm-translate-plugin/models/locale/columns.yaml, ../sm-translate-plugin/lang/en/lang.yaml, ../sm-translate-plugin/lang/pl/lang.yaml, ../sm-translate-plugin/locales_admin_smoke_test.go, ../sm-translate-plugin/README.md ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_list.yaml, ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/controllers/Locales.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/controllers/locales/config_list.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/locale/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/locale/columns.yaml Implement Locales as `HasAdminControllers` plus `HasNavigation`, never as singleton settings. Register only `golem15.translate.manage_locales` with developer role and require it on controller `golem15.translate.locales`. Do not register manage_messages. Embed exact YAML paths. Port name/code/enabled/default form fields and searchable list columns; map unsupported invisible number display safely, keep default sort by sort_order ascending and 20 records per page, and use cabana recordUrl/create/update conventions. Do not invent ReorderController. Protect server-owned state: Locale Fillable remains code/name/is_enabled, so request bodies cannot set is_default or sort_order. Controller/model hooks refuse deleting the default, unsetting the default, and making a disabled locale default with cabana validation errors. Provide an explicit `golem15.translate.manage_locales`-permissioned make-default controller action that atomically clears the previous default and sets the selected enabled locale while preserving those guards; keep direct `is_default` form input read-only. Port the plugin.* and locale.* UI phrases to `lang/en/lang.yaml` and `lang/pl/lang.yaml` via HasLang. These catalogs are UI phrasebook data and must not read or write attribute JSON. Add smoke coverage for permitted list access and a forbidden request; Plan 04 expands the matrix. go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -short -count=1 -v -run '^(TestLocalesAdminSmoke|TestLocalesAdminForbiddenSmoke)$' Non-zero exit, output contains "--- FAIL", "--- SKIP", or "no tests to run", or either named PASS line is absent. - Controller ID is `golem15.translate.locales` and RequiredPermissions contains only `golem15.translate.manage_locales`. - No production source contains `manage_messages`, a Messages controller, ReorderController, or Messages navigation. - List defaults to sort_order asc and returns en before pl from the seed. - Unknown/unprivileged admin gets 403; privileged admin gets the Locales schema/list. - A crafted body cannot persist is_default or sort_order. - The permissioned make-default action rejects disabled locales and atomically leaves exactly one enabled default locale. - English/Polish phrasebook files are independent from `winter_translate_attributes`. Authorized administrators can manage the lean Locale surface while default-state and phrasebook/model-translation boundaries remain enforced. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Host model → Translatable helpers | Shared plugin trusts only declared fields and explicit morph names | | Admin JSON → Locale CRUD | Untrusted nested/scalar writes cross permission and fillable boundaries | | Translation JSON → Postgres | Field maps and indexes must stay scoped to one model/locale | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-14.2.1-05 | Elevation of Privilege | Locales admin | high | mitigate | RequiredPermissions and 403 smoke; full tests in Plan 04 | | T-14.2.1-06 | Tampering | Locale writes | high | mitigate | Fillable excludes is_default/sort_order and hooks protect default lifecycle | | T-14.2.1-07 | Tampering | SetTranslated | high | mitigate | Allow only declared fields and enabled locale codes; atomic scoped upserts | | T-14.2.1-08 | Information Disclosure | Morph/index queries | medium | mitigate | Explicit MorphName plus model id/locale/item predicates; no broad attribute endpoint | | T-14.2.1-SC | Tampering | package installs | high | mitigate | No new packages | ASVS L1: all high threats are mitigated in production paths and receive removal/failure tests in Plan 04. Run all three task commands, then `go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./... -short -count=1`. - Fixture en/pl save/read and indexed lookup pass. - Missing translations fall back to the default host column. - Locales admin is permissioned, YAML-driven, and protects default/server-owned fields. - Messages admin, CMS components, AI/theme commands, import/export, ReorderController, and extra locale seeds remain absent. Create `.planning/phases/14.2.1-translate-plugin/14.2.1-02-SUMMARY.md` when done.