--- phase: 14.2.1-translate-plugin plan: 05 type: execute wave: 5 depends_on: ["14.2.1-04"] files_modified: - modules/cabana/schema_types.go - modules/cabana/field_ml.go - modules/cabana/crud.go - modules/cabana/http.go - modules/cabana/ml_test.go - modules/cabana/ml_smoke_test.go - modules/cabana/README.md - docs/backend/forms.md - docs/backend/admin-controllers.md - admin/src/components/form/formContext.ts - admin/src/components/form/formState.ts - admin/src/views/FormView.vue - admin/src/components/form/fields/MLTextField.vue - admin/src/components/form/fields/MLMarkdownField.vue - admin/src/components/relation/RelationChildModal.vue - admin/tests/form/MLFields.test.ts - admin/tests/form/formState.test.ts - admin/openapi/admin.json - admin/src/api/schema.d.ts - modules/boardwalk/dist/ - ../sm-translate-plugin/classes/admin_writer.go autonomous: false gap_closure: true requirements: [D-06, D-17] must_haves: truths: - "Cabana markdown/mltext/mlmarkdown compose; nested locale writes are not dropped" - "The SPA exposes one locale selector per ML field, switches all ML controls together, supports copy-from-locale, and sends every locale as Record" artifacts: - path: "modules/cabana/schema_types.go" provides: "form schema meta lists enabled content locales" contains: "EnabledLocales" - path: "modules/cabana/field_ml.go" provides: "TranslationWriter.TranslatedExact and hydrateMLRecord for Show/save maps" contains: "TranslatedExact" - path: "modules/cabana/field_ml.go" provides: "GET/save ML field expansion without D-11 fallback" contains: "hydrateMLRecord" - path: "admin/src/components/form/formContext.ts" provides: "form-wide enabled locale list for ML controls" contains: "FORM_ENABLED_LOCALES" - path: "admin/src/views/FormView.vue" provides: "adopt merges hydrated ML maps instead of overwriting with a host scalar" contains: "adopt" - path: "admin/src/components/form/formState.ts" provides: "create seed maps keyed by every enabled locale" contains: "initialValues" - path: "../sm-translate-plugin/classes/admin_writer.go" provides: "plugin adapter for cabana TranslatedExact" contains: "TranslatedExact" key_links: - from: "modules/cabana/http.go" to: "modules/cabana/schema_types.go" via: "protect()'d formSchema copies TranslationWriter.EnabledLocales onto FormMeta" pattern: "EnabledLocales" - from: "modules/cabana/crud.go" to: "modules/cabana/field_ml.go" via: "ShowRecord and save hydrate ML fields after projectFullRecord" pattern: "hydrateMLRecord" - from: "../sm-translate-plugin/classes/admin_writer.go" to: "../sm-translate-plugin/classes/translatable.go" via: "AdminWriter.TranslatedExact delegates to classes.TranslatedExact" pattern: "TranslatedExact" - from: "admin/src/views/FormView.vue" to: "admin/src/components/form/fields/MLTextField.vue" via: "FORM_ENABLED_LOCALES inject drives selector options, not value keys" pattern: "FORM_ENABLED_LOCALES" --- Close CR-01 so D-06's one-screen locale switch and D-17's fixture save/read of en+pl through ML fields work on the admin form path: schema meta carries enabled locales, create seeds empty maps, GET/save hydrate via TranslatedExact, FormView.adopt merges maps, and SPA selectors read schema locales. Purpose: Journal-shaped create/update must list every enabled locale before the administrator types, survive GET of a host scalar, and POST `{en, pl}` instead of `{en}` only. Output: additive `FormMeta.EnabledLocales`, `TranslationWriter.TranslatedExact`, `hydrateMLRecord`, SPA inject/seed/adopt, regenerated OpenAPI/TS/dist, and named hydration smoke. @~/.codex/gsd-core/workflows/execute-plan.md @~/.codex/gsd-core/templates/summary.md @.planning/phases/14.2.1-translate-plugin/14.2.1-04-SUMMARY.md @.planning/phases/14.2.1-translate-plugin/14.2.1-REVIEW.md @.planning/phases/14.2.1-translate-plugin/14.2.1-VERIFICATION.md @.planning/phases/14.2.1-translate-plugin/14.2.1-CONTEXT.md @modules/cabana/field_ml.go @modules/cabana/schema_types.go @modules/cabana/crud.go @modules/cabana/http.go @admin/src/components/form/fields/MLTextField.vue @admin/src/views/FormView.vue @../sm-translate-plugin/classes/admin_writer.go @../sm-translate-plugin/classes/translatable.go ## Spec-less probe fallback The phase has no mapped requirement IDs, so no speculative requirement probes are generated. Edge coverage is CR-01 and the two failed VERIFICATION truths (D-06 / D-17 admin form path). ## Gap-closure source audit Audited only the failed VERIFICATION truths and REVIEW CR-01. Already-verified D-01..D-05 and D-07..D-16 are EXCLUDED. | Decision | Source | Status | Why this plan | |----------|--------|--------|---------------| | D-06 | CONTEXT.md; REVIEW CR-01; VERIFICATION truths 4 and 14 | NOT WIRED on create/GET/adopt | Selector options come from `Object.keys(value)` else a single English fallback; create seeds `{}`; GET is a host scalar | | D-17 | CONTEXT.md; VERIFICATION user-flow row for Journal-shaped form | NOT WIRED on the SPA form path | HTTP POST of a pre-built `{en,pl}` map still works; the form Journal will hit never produces that body | No new gormigrate file: runtime hydration and schema meta only. Skip Prisma/Payload schema-push. No new HTTP routes. `FormMeta.enabledLocales` is an additive field on the existing form-schema response. OpenAPI/TS regen is in Task 3. ## Artifacts this phase produces - `cabana.FormMeta.EnabledLocales` (`json:"enabledLocales,omitempty"`) filled from `TranslationWriter.EnabledLocales` on protect()'d `formSchema` (and copied onto that handler's envelope meta). - `cabana.TranslationWriter.TranslatedExact` plus `hydrateMLRecord`, called after `projectFullRecord` from `CRUDService.ShowRecord` and `CRUDService.save` so GET/save data for declared `mltext`/`mlmarkdown` fields is `map[locale]string` (default from the host column; other codes from exact stored values; missing non-default codes become empty strings, never D-11 fallback). - SPA `FORM_ENABLED_LOCALES`, `initialValues` seed `{[code]: ""}` for every enabled code, `FormView.adopt` / `RelationChildModal.adopt` merge of ML maps (string host values become the default-locale entry; they never wipe sibling locales), `MLTextField` / `MLMarkdownField` locale lists from the inject. - Regenerated `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, and `modules/boardwalk/dist/`. - Named `TestMLHydration`; Vitest create-empty and GET-scalar fixtures; `TestMLNestedSave` projected `title` is the hydrated map. Task 1: Confirm TranslationWriter.TranslatedExact as the hydration contract modules/cabana/field_ml.go, ../sm-translate-plugin/classes/admin_writer.go, ../sm-translate-plugin/classes/translatable.go .planning/phases/14.2.1-translate-plugin/14.2.1-REVIEW.md (CR-01 Fix), .planning/phases/14.2.1-translate-plugin/14.2.1-CONTEXT.md (D-06, D-11, D-17), modules/cabana/field_ml.go (TranslationWriter), ../sm-translate-plugin/classes/translatable.go (TranslatedExact), ../sm-translate-plugin/classes/admin_writer.go Record the hydration read contract before expanding the published cabana interface. Cabana must not import the translate plugin. classes.TranslatedExact already exists in the plugin and must not apply D-11 fallback. The locked CR-01 fix is a method on TranslationWriter so hydrateMLRecord stays on the same published adapter as DefaultLocale, EnabledLocales, and WriteTranslated. The only in-tree implementers are AdminWriter, recordingWriter, and boomWriter (embeds recordingWriter). How should cabana read exact translations for admin GET/save hydration? Adding a method to cabana.TranslationWriter is a compile break for every published adapter. An optional second interface is more reversible but splits the CR-01 contract. Importing the plugin from cabana is forbidden. test -f modules/cabana/field_ml.go && test -f ../sm-translate-plugin/classes/translatable.go && test -f ../sm-translate-plugin/classes/admin_writer.go Non-zero exit, or any of the three paths is missing. - The selection is recorded in the summary. - Task 2 proceeds only with `writer-exact`; another selection stops as a CR-01 / D-06 conflict. Select `writer-exact` to implement CR-01 hydration, or an alternative to stop. Task 2: Hydrate one mltext create/GET/save path and show en+pl on the SPA Adding TranslatedExact to the published TranslationWriter interface is a compile break for every adapter. Task 1 selected `writer-exact`. modules/cabana/schema_types.go, modules/cabana/field_ml.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/ml_test.go, modules/cabana/ml_smoke_test.go, admin/src/components/form/formContext.ts, admin/src/components/form/formState.ts, admin/src/views/FormView.vue, admin/src/components/form/fields/MLTextField.vue, admin/tests/form/MLFields.test.ts, ../sm-translate-plugin/classes/admin_writer.go modules/cabana/schema_types.go (FormMeta), modules/cabana/field_ml.go (TranslationWriter, liftMLValues), modules/cabana/crud.go (CRUDService.writer, save, ShowRecord, projectFullRecord, projectRecord), modules/cabana/http.go (formSchema, crud Lookup), admin/src/components/form/formContext.ts (FORM_LOCALE), admin/src/components/form/formState.ts (initialValues), admin/src/views/FormView.vue (load, adopt, provide), admin/src/components/form/fields/MLTextField.vue (locales computed), admin/src/components/form/mlLocale.ts (localeRecord), ../sm-translate-plugin/classes/admin_writer.go, ../sm-translate-plugin/classes/translatable.go (TranslatedExact), .planning/phases/14.2.1-translate-plugin/14.2.1-REVIEW.md (CR-01) Implement D-06/D-17 hydration for one mltext field end to end. On FormMeta in schema_types.go add EnabledLocales as a string slice with json enabledLocales,omitempty so list schemas that reuse FormMeta stay clean. Do not set it inside FormSchema.Localize (that path is cache/no-DB). In http.go formSchema, after protect and Localize, Lookup TranslationWriter the same way crud() does; when present, assign writer.EnabledLocales onto view.Meta.EnabledLocales and also copy the slice onto the WriteData envelope meta next to locale. Leave list_schema.go Meta as locale-only. Add TranslationWriter.TranslatedExact(ctx, model, field, locale) returning string, bool, error. Document that ok is false when the non-default key is missing and that the method must not apply D-11 fallback. Implement it on recordingWriter from stored attrs (default locale reads the host column via the model; missing keys return ok false). Implement AdminWriter.TranslatedExact by delegating to classes.TranslatedExact and converting the stored value to string; keep var _ cabana.TranslationWriter = AdminWriter{}. Add hydrateMLRecord in field_ml.go. After projectFullRecord in ShowRecord and at the end of save (CreateRecord/UpdateRecord path), for each declared mltext/mlmarkdown field whose context allows the current op, replace the host scalar in RecordResult.Data with a map that contains every EnabledLocales code: default from the projected host column, others from TranslatedExact, missing non-default codes as empty string. Skip when writer is nil. Do not call hydrateMLRecord from list row projection. Do not introduce a public translate-read HTTP route. Update TestMLNestedSave so the projected title after Create is the hydrated map containing Hello and Witaj, while the host column remains the English scalar and Polish still reaches recordingWriter.attrs. Add TestMLHydration that creates an mltext map, ShowRecord-loads it, and asserts both locales; a second case with only English stored must still list pl as empty, not English. In formContext.ts add FORM_ENABLED_LOCALES as an InjectionKey of a readonly string-array ref, next to FORM_LOCALE. FormView provides it from schema.meta.enabledLocales (empty array when omitted). Change initialValues to take the enabled locale list and seed mltext/mlmarkdown as a map of each code to empty string; FormView create calls it with that list. Extract a small merge helper in formState.ts used by adopt: for ML fields, start from the seeded empty map, overlay localeRecord of the incoming value, and if the incoming value is a string put it on the default/first enabled locale instead of replacing the whole field. Never assign a bare string onto an ML field. FormView.adopt uses that merge; password fields still clear after save. MLTextField locales computed reads FORM_ENABLED_LOCALES. When the inject is non-empty, that list is the selector options (D-06). Do not derive options from Object.keys of the value and do not fall back to a hardcoded English-only list. Existing Vitest mounts must provide FORM_ENABLED_LOCALES with en and pl via global provide. Add a Vitest case that starts from initialValues of an mltext field with locales en,pl (empty maps, two selector options) and a case that adopt-merges a GET host string onto that seed and still shows both locales so copy-from and a typed Polish value survive. Do not change fillChild (WR-02 is Plan 06). Do not add a gormigrate file. go test ./modules/cabana -count=1 -v -run '^(TestMLHydration|TestMLNestedSave)$' && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts Non-zero exit; Go output contains "--- FAIL", "--- SKIP", or "no tests to run", lacks "--- PASS: TestMLHydration" or "--- PASS: TestMLNestedSave"; Vitest reports no test files/tests or any failed test. - FormMeta JSON includes enabledLocales when the writer is published; omitempty leaves list schemas without the key. - formSchema fills EnabledLocales from TranslationWriter.EnabledLocales after Lookup; Localize remains cache-only. - Show/save RecordResult.Data for a declared mltext field is a locale map; host column stays the default scalar; missing pl is empty, not D-11 English. - TestMLNestedSave still proves unlifted maps never reach ProjectWritableFields and that de is rejected; projected title after Create is the hydrated map. - Create initialValues for mltext is `{en: "", pl: ""}` when those codes are enabled, not `{}`. - FormView.adopt of a host string does not drop sibling locale keys; password fields still empty after save. - MLTextField selector options equal FORM_ENABLED_LOCALES, including on an empty create value. - Cabana never imports the translate plugin; AdminWriter still satisfies TranslationWriter. A Journal-shaped mltext create lists en and pl, GET returns both codes, and the SPA can send Record<string,string> for every enabled locale (D-06, D-17). Task 3: Cover mlmarkdown, relation-child adopt, and regenerate docs/OpenAPI/TS/dist admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/relation/RelationChildModal.vue, admin/src/components/form/formState.ts, admin/tests/form/formState.test.ts, admin/tests/form/MLFields.test.ts, modules/cabana/http.go, modules/cabana/README.md, docs/backend/forms.md, docs/backend/admin-controllers.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/boardwalk/dist/ admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/relation/RelationChildModal.vue (adopt, initialValues, provide), admin/tests/form/formState.test.ts, modules/cabana/http.go (relationSchema, formSchema envelope meta), modules/cabana/README.md (TranslationWriter row), docs/backend/forms.md (Markdown and multilingual fields), docs/backend/admin-controllers.md, admin/package.json, scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh Apply the same locale inject to MLMarkdownField as MLTextField (D-06 compose). RelationChildModal create must call initialValues with FORM_ENABLED_LOCALES (parent FormView provide is visible to the modal; re-provide the same inject if the modal already re-provides FORM_VALUES). RelationChildModal.adopt must use the same ML merge helper as FormView.adopt. Pivot initialValues in RelationPickerModal may pass an empty locale list; pivot forms are not ML. Update formState.test.ts callers of initialValues. Copy enabledLocales onto relationSchema WriteData envelope meta the same way formSchema copies locale, using the same Lookup, so a child form that only sees the relation envelope still receives the list. Do not attach EnabledLocales to list schema responses. Document FormMeta.EnabledLocales, GET/save map hydration via TranslatedExact, create seed maps, and adopt merge in cabana README and docs/backend/forms.md. Mention the GET shape in admin-controllers.md only where the record payload is described. Neutral blog/acme names only. Every identifier named in README/docs must exist in the package (EnabledLocales, TranslatedExact, hydrateMLRecord, FORM_ENABLED_LOCALES is SPA-only and must not be claimed as a Go identifier). Regenerate admin OpenAPI with scripts/check-admin-openapi.sh (no args) so cabana.FormMeta in admin/src/api/schema.d.ts includes enabledLocales. Do not hand-edit admin.json or schema.d.ts. Run npm --prefix admin run build and copy Vite output to committed modules/boardwalk/dist/ using the existing boardwalk workflow (scripts/check-admin-dist.sh). Extend MLFields tests so mlmarkdown create-empty and GET-string merge match mltext. No npm package install or pin change (T-14.2.1-SC). go test ./cmd/summer -count=1 -run 'TestDocsTree' && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- --run admin/tests/form/MLFields.test.ts admin/tests/form/formState.test.ts && npm --prefix admin run build && go -C ../sm-translate-plugin vet ./... && go -C ../sm-translate-plugin test ./classes -count=1 -v -run '^(TestTranslatableGetSet)$' Non-zero exit; docs checker reports stale identifiers, broken links, or a consuming-application name; OpenAPI --check reports stale committed output; Vitest reports no tests or failures; build omits index.html/assets; plugin output contains "--- FAIL", "--- SKIP", or "no tests to run", or lacks "--- PASS: TestTranslatableGetSet". - MLMarkdownField selector options come from FORM_ENABLED_LOCALES; it still composes MarkdownField. - RelationChildModal create seeds ML maps and adopt merges GET maps/strings the same way FormView does. - cabana.FormMeta in schema.d.ts includes enabledLocales; boardwalk dist matches the fresh admin build. - README/docs name EnabledLocales and TranslatedExact, describe D-06/D-17 hydration, and name no consuming application. - AdminWriter.TranslatedExact compiles and existing TranslatedExact plugin tests still pass. Enabled locales are a first-class form-schema contract, GET/save round-trip the nested map, and generated admin artifacts stay in sync. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Admin browser → form schema / record GET | Authenticated admin receives every enabled locale's stored value for declared ML fields | | Cabana → TranslationWriter.TranslatedExact | Framework reads exact translations only through the published adapter | | SPA adopt → in-memory form values | A scalar GET must not wipe sibling locale keys the administrator can still edit | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-14.2.1-19 | Information Disclosure | hydrateMLRecord | high | mitigate | Hydrate only declared mltext/mlmarkdown on Show/save record payloads after protect/loadRecord; never list rows; TranslatedExact skips D-11 so unpublished empty values stay empty | | T-14.2.1-20 | Information Disclosure | FormMeta.EnabledLocales | medium | mitigate | Fill only on protect()'d formSchema/relationSchema; omitempty on list Meta | | T-14.2.1-21 | Tampering | FormView.adopt | medium | mitigate | Merge helper applies only to ML fields; server/seed maps win; password fields still clear | | T-14.2.1-SC | Tampering | npm packages | high | mitigate | No package installation or version change; existing exact pins only | ASVS L1: high threats are mitigated in this plan; Plan 06 adds named relation-child evidence and gate IDs. Run the Task 3 combined gate. Confirm `git status --short` in summercms.go and sm-translate-plugin contains only intended tracked source and generated admin artifacts. Do not commit. - D-06: create/GET/update admin forms list every enabled locale on each ML control and switch them together. - D-17: hydrated GET/save carries en and pl through ML fields; empty non-default values stay empty. - OpenAPI, schema.d.ts, and boardwalk dist include FormMeta.enabledLocales. - CR-01 is closed; WR-02 remains Plan 06. Create `.planning/phases/14.2.1-translate-plugin/14.2.1-05-SUMMARY.md` when done.