diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 0447bd3..b0eeaae 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -999,10 +999,22 @@ Plans:
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
**Requirements**: TBD
**Depends on:** Phase 16.1
-**Plans:** 0 plans
+**Plans:** 6 plans
Plans:
-- [ ] TBD (run /msd-plan-phase 16.2 to break down)
+**Wave 1**
+- [ ] 16.2-01-PLAN.md — Framework (summercms.go): pact field-type API (human API checkpoint), cabana registry, boot option checks, Decode/Load/list in the save transaction, untouched skip, FieldWritesFromContext, field-types and field upload routes, OpenAPI and TS types, SPA PluginField/MLPluginField and global loader, dist, READMEs and docs
+- [ ] 16.2-02-PLAN.md — Journal Blog Editor element (sm-journal-plugin assets/src to assets/dist): TipTap package check (human), untouched value contract, lossless schema and serializer, uploads and YouTube embeds, JS build, vitest, check-dist, embed
+- [ ] 16.2-04-PLAN.md — vue-journal-layer: expandEmbeds after gateJournalHtml, SSR proof, tag v0.2.0 (human push gate), pin in vue-grzybyfunkcjonalne-app
+
+**Wave 2** *(blocked on Wave 1 completion)*
+- [ ] 16.2-03-PLAN.md — Journal Go integration: blogeditor and mlblogeditor types, CheckHTML per locale, D-07 Load, post and category fields, content_html companion writes without goldmark, editor uploads through the extracted media storage
+
+**Wave 3** *(blocked on Wave 2 completion)*
+- [ ] 16.2-05-PLAN.md — Release gate (human), summercms tag v0.1.17, journal and host bumps, UAT redeploy and on-server smoke, UAT acceptance: untouched re-saves byte-identical, editor edit with upload and embed
+
+**Wave 4** *(blocked on Wave 3 completion)*
+- [ ] 16.2-06-PLAN.md — Unit tests: resave contract at every layer, full Go and JS coverage, Postgres tests for a Docker host, phase gate scripts/check-phase16.2.sh, validation map
### Phase 17: Blog companions
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 927b26f..8431f0a 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,18 +1,18 @@
---
msd_state_version: "1.0"
milestone: v1.0
-current_phase: "16.1"
-current_phase_name: "Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)"
-status: verifying
+current_phase: "16.2"
+current_phase_name: plugin-provided-field-types-and-the-blog-editor-formwidget
+status: executing
stopped_at: Completed 16.1-03-PLAN.md
-last_updated: "2026-10-09T20:52:42.223Z"
+last_updated: "2026-10-09T21:54:52.956Z"
last_activity: 2026-10-09
last_activity_desc: Phase 16.1 execution started
-state_head: 62025d22b3bffed3d8646627d699e489dafc7d3b
+state_head: cd25af08782e69da40515350c21a30aaa229e8c2
progress:
total_phases: 34
completed_phases: 13
- total_plans: 151
+ total_plans: 157
completed_plans: 147
gsd_state_version: "1.0"
milestone_name: milestone
@@ -29,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position
-Phase: 16.1 (Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)) — EXECUTING
+Phase: 16.2 (plugin-provided-field-types-and-the-blog-editor-formwidget) — READY TO EXECUTE
Plan: 3 of 3
-Status: Phase complete — ready for verification
+Status: Ready to execute
Last activity: 2026-10-09 — Phase 16.1 execution started
Progress: [██████████] 100%
diff --git a/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-PLAN.md b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-PLAN.md
new file mode 100644
index 0000000..de18138
--- /dev/null
+++ b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-PLAN.md
@@ -0,0 +1,490 @@
+---
+phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - modules/pact/capabilities.go
+ - modules/pact/README.md
+ - modules/cabana/field_type.go
+ - modules/cabana/field_type_upload.go
+ - modules/cabana/field_type_smoke_test.go
+ - modules/cabana/form_schema.go
+ - modules/cabana/schema_types.go
+ - modules/cabana/registry.go
+ - modules/cabana/http.go
+ - modules/cabana/crud.go
+ - modules/cabana/field_ml.go
+ - modules/cabana/tx_context.go
+ - modules/cabana/list_schema.go
+ - modules/cabana/settings.go
+ - modules/cabana/relation_form.go
+ - modules/cabana/extension.go
+ - modules/cabana/admin_openapi.go
+ - modules/cabana/security_coverage_test.go
+ - modules/cabana/README.md
+ - admin/openapi/admin.json
+ - admin/src/api/schema.d.ts
+ - admin/src/api/types.ts
+ - admin/src/app/fieldTypes.ts
+ - admin/src/app/pluginAssets.ts
+ - admin/src/main.ts
+ - admin/src/views/LoginView.vue
+ - admin/src/views/FormView.vue
+ - admin/src/views/ListView.vue
+ - admin/src/components/form/registry.ts
+ - admin/src/components/form/FieldRenderer.vue
+ - admin/src/components/form/PreviewField.vue
+ - admin/src/components/form/FormField.vue
+ - admin/src/components/form/formState.ts
+ - admin/src/components/form/mlLocale.ts
+ - admin/src/components/form/formContext.ts
+ - admin/src/components/form/fields/PluginField.vue
+ - admin/src/components/form/fields/MLPluginField.vue
+ - admin/src/components/relation/RelationChildModal.vue
+ - admin/tests/form/PluginField.test.ts
+ - modules/boardwalk/dist
+ - docs/backend/field-types.md
+ - docs/backend/forms.md
+autonomous: false
+requirements: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
+estimate:
+ tokens: 130000
+ raw_tokens: 130000
+ tasks: 4
+ confidence: low
+must_haves:
+ truths:
+ - "D-11: a plugin registers a value-bearing admin field type under a short alias (pact.HasFormFieldTypes); boot fails with a message naming both owners when two plugins claim one alias, and fails when an alias equals a cabana core type (text, markdown, mlmarkdown, widget, partial and the rest of formFieldTypes)."
+ - "D-12.1: a plugin-typed field accepts the general field keys plus the type's own Options keys; any other key, and any CheckOptions error, fails boot naming the field and the type."
+ - "D-12.2 and D-10: a save runs the type's Decode inside the save transaction after the record is loaded, for the host value and for every non-default locale value of a translatable type; a pact.FormFieldError is a 422 validation_failed on that field and nothing is written; any other plugin error is an opaque 500 without the plugin's text."
+ - "D-12.3: Show and the save response serve each plugin field through the type's Load (per locale for a translatable type)."
+ - "D-12.4: a list column typed with a plugin alias shows the type's ListValue string."
+ - "D-03 (server half): on update, a submitted value equal to the value the form was served for that field and locale is not decoded and not written, neither to the host column nor as a translation."
+ - "D-14 and D-06: a translatable plugin type exchanges a locale to string map and is stored through cabana's existing ML lift, translation writer and hydrate path; a plugin implements no locale storage."
+ - "Companion writes: Form hooks read the values a save actually wrote, per field and locale, through cabana.FieldWritesFromContext."
+ - "D-13: after login the admin SPA loads every registered field type's scripts once and its stylesheets globally (never disabled on navigation); a field with an element renders through PluginField (MLPluginField when translatable) in any controller's form, binds the element's value property and takes summer-change events as the new form value."
+ - "D-18 (framework half): POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload is owned by cabana (backend guard, X-Requested-With, controller permissions, field context, FormExtendQuery record scope, http.body_limits.upload_bytes) and answers 201 {data:{url}} from the type's Upload; the SPA performs the upload for the element on summer-upload and answers with summer-upload-result."
+ - "D-15: the form schema carries plugin fields generically (element, translatable, typeOptions); admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated, modules/boardwalk/dist is rebuilt, and the pact and cabana READMEs plus docs/backend/field-types.md describe the API with neutral names."
+ artifacts:
+ - path: "modules/pact/capabilities.go"
+ provides: "FormFieldType, FormFieldValue, FormFieldUpload, FormFieldUploadResult, FormFieldError, HasFormFieldTypes"
+ contains: "HasFormFieldTypes"
+ - path: "modules/cabana/field_type.go"
+ provides: "per-boot field type registry, compile checks, decode/skip/load/list runner"
+ contains: "compileFieldTypes"
+ - path: "modules/cabana/field_type_upload.go"
+ provides: "the cabana-owned field upload route"
+ contains: "fieldUpload"
+ - path: "modules/cabana/tx_context.go"
+ provides: "FieldWrite, FieldWritesFromContext, WithFieldWrites"
+ contains: "FieldWritesFromContext"
+ - path: "admin/src/components/form/fields/PluginField.vue"
+ provides: "generic plugin element host"
+ contains: "summer-change"
+ - path: "admin/src/app/fieldTypes.ts"
+ provides: "global field-type asset loader"
+ contains: "loadFieldTypes"
+ - path: "docs/backend/field-types.md"
+ provides: "plugin field type guide and element contract"
+ contains: "HasFormFieldTypes"
+ key_links:
+ - from: "modules/cabana/http.go Activate"
+ to: "modules/cabana/registry.go compileRegistry"
+ via: "the registry built by compileFieldTypes(plugins) passed as a parameter (no package-level registry)"
+ pattern: "compileFieldTypes"
+ - from: "modules/cabana/crud.go save"
+ to: "pact.FormFieldType Decode and Load"
+ via: "decodePluginFields between loadRecord and lagoon.Fill inside the save transaction"
+ pattern: "decodePluginFields"
+ - from: "admin/src/main.ts and admin/src/views/LoginView.vue"
+ to: "GET {api}/field-types"
+ via: "loadFieldTypes next to loadNavigation"
+ pattern: "loadFieldTypes"
+ - from: "admin/src/components/form/fields/PluginField.vue"
+ to: "POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"
+ via: "summer-upload event handled by the SPA with the admin session"
+ pattern: "summer-upload"
+ prohibitions:
+ - statement: "No package-level mutable field type registry in cabana: the registry is built per Activate and passed down."
+ status: unresolved
+ verification: judgment
+ - statement: "The element never receives a token, a cookie, a function or a Vue instance; uploads go through the SPA and the cabana route only."
+ status: unresolved
+ verification: judgment
+ - statement: "Existing core field types, the type: widget contract and the public behaviour of CompileForm do not change."
+ status: unresolved
+ verification: judgment
+ - statement: "Framework READMEs and docs never name a consuming application or plugin; examples use blog or acme."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
+
+This plan's slice (user decision 1, plan 01): the framework half in summercms.go. A plugin registers a value-bearing field type through `pact`; cabana compiles, decodes, loads, lists, uploads and stores it (ML included); the admin SPA hosts its element globally. Framework first: plans 02 and 03 build the Journal editor on this API.
+
+## Spec-less probe fallback
+
+Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". No SPEC and no edge-probe predicates were generated this run. CONTEXT D-01 to D-19, the three folded todos and RESEARCH Patterns 1-9 are the source of truth. Prohibitions above were recalled in-prompt and are descriptor-less (flagged-unverified).
+
+## Assumptions (explicit)
+
+- A5 (RESEARCH): settings forms and relation child or pivot forms refuse plugin field types at boot ("supported in controller forms only"), the same restriction cabana already documents for virtual fields and FormRules. D-13 targets controller forms.
+- Untouched detection (Claude's discretion, D-03): both layers. cabana skips a submitted value whose canonical JSON equals the served value; the element (plan 02) never re-serializes an untouched document.
+- Field-type asset discovery (Claude's discretion, research Open Question 2): a dedicated `GET {api}/field-types` route, so the navigation contract stays unchanged.
+- A translatable plugin type exchanges strings per locale (the TranslationWriter API is string-based and MLField sends only strings); a non-translatable type exchanges any JSON value.
+
+## Plan sizing note
+
+The locked plan count (6) puts the whole framework half in this plan. It has four tasks: one decision checkpoint (no implementation context) and three implementation tasks. The estimate exceeds the usual budget; it is advisory.
+
+## Source coverage audit (phase-wide)
+
+| Source | ID | Item | Plan | Status |
+|---|---|---|---|---|
+| GOAL | — | Plugins register field types; Journal Blog Editor edits imported HTML losslessly | 01-06 | COVERED |
+| REQ | — | none mapped (ROADMAP "Requirements: TBD") | — | n/a |
+| CONTEXT | D-01 | WYSIWYG + source toggle on TipTap | 02 | COVERED |
+| CONTEXT | D-02 | HTML in content and content_html on admin save | 03 | COVERED |
+| CONTEXT | D-03 | Byte-preserving unless edited | 01 (server skip), 02 (element), 03, 05 (UAT), 06 | COVERED |
+| CONTEXT | D-04 | Text, images, tables, embeds, raw HTML block | 02 | COVERED |
+| CONTEXT | D-05 | Embed placeholder, CheckHTML not relaxed | 02, 03, 04 | COVERED |
+| CONTEXT | D-06 | Translatable like mlmarkdown | 01, 03 | COVERED |
+| CONTEXT | D-07 | Markdown post opens with content_html | 03 | COVERED |
+| CONTEXT | D-08 | mlblogeditor replaces mlmarkdown for post content | 03 | COVERED |
+| CONTEXT | D-09 | Public write API unchanged | 03 | COVERED |
+| CONTEXT | D-10 | CheckHTML refuses on every locale, no goldmark on admin path | 01, 03 | COVERED |
+| CONTEXT | D-11 | Short global alias, collision fails boot | 01 | COVERED |
+| CONTEXT | D-12 | pact interface: options, decode, load, list | 01 | COVERED |
+| CONTEXT | D-13 | Element loads globally, value contract, --c-* only | 01, 02 | COVERED |
+| CONTEXT | D-14 | Plugin registers ML variant through cabana lift/hydrate | 01, 03 | COVERED |
+| CONTEXT | D-15 | Generic schema shape, OpenAPI, TS, dist, README, docs | 01 | COVERED |
+| CONTEXT | D-16 | TipTap in a custom element, plugin JS build, committed dist | 02 | COVERED |
+| CONTEXT | D-17 | Layer expands embeds, new tag pinned in the app | 04, 05 | COVERED |
+| CONTEXT | D-18 | Image upload with alt and caption | 01 (route), 02 (UI), 03 (storage) | COVERED |
+| CONTEXT | D-19 | Category content uses blogeditor | 03 | COVERED |
+| TODO | — | cabana-plugin-registered-field-types | 01 | COVERED |
+| TODO | — | cabana-richeditor-field-type | 03 | COVERED |
+| TODO | — | journal-imported-html-admin-resave (and its test) | 03, 05, 06 | COVERED |
+| RESEARCH | P1 | Registry threaded through compile, no package global | 01 | COVERED |
+| RESEARCH | P2 | Decode/Load inside the tx, FieldError mapping, written-values accessor | 01 | COVERED |
+| RESEARCH | P3 | Two-layer untouched detection | 01, 02 | COVERED |
+| RESEARCH | P4 | Preservation-first schema and serializer | 02 | COVERED |
+| RESEARCH | P5 | Element contract, global CSS owner (Pitfall 8) | 01, 02 | COVERED |
+| RESEARCH | P6 | Journal integration, category not translatable | 03 | COVERED |
+| RESEARCH | P7 | Embed expansion after the gate | 04 | COVERED |
+| RESEARCH | P8 | Plugin JS build, no eval, injectCSS false, check-dist | 02 | COVERED |
+| RESEARCH | P9 | Collision-free upload route, OpenAPI + phase09Routes | 01 | COVERED |
+| RESEARCH | Pitfall 7 | Non-default content_html companion write | 01 (accessor), 03 | COVERED |
+| RESEARCH | Pitfall 11 | No Docker: DryRun/unit smoke + human Docker-host gate | 05, 06 | COVERED |
+| RESEARCH | Security | Magic-byte image check on the editor upload | 03 | COVERED |
+| RESEARCH | OQ3 | Release scope (tags, bumps, UAT) | 04, 05 | COVERED |
+
+## Artifacts this phase produces (this plan)
+
+- `pact.FormFieldType`, `pact.FormFieldValue`, `pact.FormFieldUpload`, `pact.FormFieldUploadResult`, `pact.FormFieldError` (with `Error() string`), `pact.HasFormFieldTypes`.
+- `cabana.FieldWrite`, `cabana.FieldWritesFromContext`, `cabana.WithFieldWrites`, `cabana.CompileFormWithTypes`, `cabana.FieldTypeAssets`, `cabana.FieldTypesResponse`, `cabana.FieldUploadResponse`.
+- `cabana.FormField` JSON keys `element`, `translatable`, `typeOptions` (Go fields `Element`, `Translatable`, `TypeOptions`).
+- Unexported: `fieldTypeRegistry`, `compileFieldTypes`, `readClientAssets`, `liftPluginValues`, `decodePluginFields`, `hydratePluginFields`, `servedFieldValue`, `isMLField`, `fieldUpload`, `fieldTypesList`, `withFieldWrites`.
+- Routes: `GET {api}/field-types`, `POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`.
+- New files: `modules/cabana/field_type.go`, `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_smoke_test.go`, `admin/src/app/fieldTypes.ts`, `admin/src/components/form/fields/PluginField.vue`, `admin/src/components/form/fields/MLPluginField.vue`, `admin/tests/form/PluginField.test.ts`, `docs/backend/field-types.md`.
+- SPA exports: `loadFieldTypes`, `loadGlobalStyles`, `GLOBAL_STYLE_OWNER`, `rendererForField`, `isRegisteredField`, `isMLField`, `FIELD_CHANGE_EVENT`, `FIELD_UPLOAD_EVENT`, `FIELD_UPLOAD_RESULT_EVENT`.
+- Smoke tests: `TestFieldTypeTracerSmoke`, `TestFieldTypeExpansionSmoke`.
+
+
+Add the plugin field type API to pact and cabana and host plugin field elements in the admin SPA (D-11 to D-15, D-03 server half, D-06/D-14 storage path, D-10 refusal mapping, D-18 upload route).
+
+Purpose: Journal (plans 02 and 03) and later plugins ship their own value-bearing field types without a framework release per type.
+Output: the pact types, the cabana registry, compile, save, show, list and upload paths, the two routes, regenerated OpenAPI and TS types, PluginField/MLPluginField and the global loader, rebuilt dist, READMEs and docs.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
+@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
+@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
+@.planning/todos/pending/cabana-plugin-registered-field-types.md
+
+Concurrency (16.1 D-02): another session may edit `modules/`. Before each commit run `git status --porcelain -- modules admin docs scripts` and `git diff` on the files you touch; stage only files this task changed, by explicit path. Commits: no co-author tags; code and planning docs in separate commits.
+
+No Docker here (RESEARCH Pitfall 11): `go test ./modules/cabana` without `-run` fails on the testcontainers suites. Use the named `-run` sets below; full Postgres-backed coverage is plan 06 on a Docker host.
+
+
+Proposed pact surface (Task 1 confirms it; names are Claude's discretion within D-12):
+
+```go
+// in modules/pact/capabilities.go (pact already imports context and gorm; add io)
+type FormFieldType struct {
+ Name string // global alias used as `type:` in fields.yaml, ^[a-z][a-z0-9]*$
+ Element string // custom-element tag defined by Scripts, plugin prefix rule
+ Translatable bool // value is a locale -> string map stored through cabana's translation writer
+ Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
+ Styles []string // AdminFS paths under assets/ (.css), enabled on every admin page
+ Options []string // the type's own fields.yaml keys
+ CheckOptions func(options map[string]any) error `json:"-"` // optional
+ Decode func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` // required
+ Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` // optional
+ ListValue func(value any) string `json:"-"` // optional
+ Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
+}
+type FormFieldValue struct {
+ DB *gorm.DB // the save transaction (Decode) or the read transaction (Load)
+ Record any // the loaded record on update, the new model on create
+ Field string
+ Options map[string]any // the field's typeOptions
+ Locale string // "" for a non-translatable type
+ Default bool // true when Locale is the host-column locale (or the type is not translatable)
+ Value any // submitted (Decode) or stored (Load); a string for a translatable type
+ Translated func(attribute string) (string, bool, error) // exact read of another attribute of Record in Locale; nil when not translatable or no writer
+}
+type FormFieldUpload struct {
+ DB *gorm.DB
+ Record any // nil on the create form ({id} 0)
+ Field string
+ Options map[string]any
+ Filename string
+ Body io.Reader // already capped by http.body_limits.upload_bytes
+}
+type FormFieldUploadResult struct{ URL string }
+type FormFieldError struct{ Messages []string } // phrase keys or text; Error() joins them
+type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType } // implemented by the plugin
+```
+
+Proposed cabana surface:
+
+```go
+type FieldWrite struct {
+ Locale string // "" for a non-translatable type
+ Default bool // the value went to the host column
+ Value any
+}
+func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool) // copy; false outside create/update
+func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context // for Form hook tests
+func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*FormSchema, error)
+// FormField gains: Element string `json:"element,omitempty"`, Translatable bool `json:"translatable,omitempty"`,
+// TypeOptions map[string]any `json:"typeOptions,omitempty"`, fieldType *compiledFieldType `json:"-"`
+```
+
+Routes (relative to `/api/v1`):
+- `GET /field-types` → `{"data":[{"name","element","translatable","scripts":["/assets/vendor/plugin/...js?v=<12 hex>"],"styles":[...]}]}`, sorted by name.
+- `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` (multipart, one `file` part; `{id}` 0 = create form) → 201 `{"data":{"url":"..."}}`; 422 `validation_failed` on the field for a FormFieldError; 404 for an unknown field, a field without a plugin type or without Upload, or a record outside the FormExtendQuery scope; 413 over the cap.
+
+Element contract (D-13; extends the Phase 10.1 widget contract, documented in docs/backend/field-types.md). Fixed for this phase: D-13 leaves the contract to the planner and this block finalizes it. Plan 02 implements these exact names in parallel (wave 1), so Task 1 does not reopen them; Task 1 may rename only pact and cabana Go identifiers and the GET /field-types answer, which plan 02 never references.
+- The SPA creates the element imperatively and sets the `value` property (string for a translatable type, any JSON otherwise) and the attributes `field-name`, `record-id` ('' on create), `locale` (content locale for a translatable type, '' otherwise), `ui-locale` (admin UI language), `options` (JSON of typeOptions), `disabled` (present when read-only), `invalid` (present when the field has an error).
+- The element dispatches bubbling CustomEvents: `summer-change` with `detail.value` only on user edits; `summer-upload` with `detail {id, file}`. The SPA answers an upload with `summer-upload-result` `detail {id, url}` or `{id, error}` dispatched on the element.
+- Setting `value` to the value the element last emitted must not reset the element; the SPA sets `value` only when the form value differs from the element's current `value`.
+- The element never receives a token, a cookie, a function or a Vue instance, and styles only through the public `--c-*` CSS variables, with selectors scoped under its tag.
+
+Existing code the executor builds on (read, do not re-derive):
+- `modules/cabana/form_schema.go`: `formFieldTypes` (lines 24-30), `formFieldKeys` (37-47), `decodeFields(raw []byte)` (399), `fieldMap.UnmarshalYAML` (411), `compileFieldNode(name, node)` (455; key check at 466 runs before type resolution at 474).
+- `modules/cabana/registry.go`: `compileRegistry(items []controllerRef)` (57), callers of `compileList` and `compileFormIfPresent`.
+- `modules/cabana/http.go`: `Activate` (74-184), `mount` (220-361; fileupload routes 335-347, `requireAjax`, `constrainFile`), `protect` (1030), `projectRow` (1067).
+- `modules/cabana/crud.go`: `save` (567-715: ML lift 600, loadRecord 616, projectOperation/Fill 622, applyMLTranslations 688, hydrateMLRecord 709), `ShowRecord` (418, hydrate at 444), `lifecycleFailure` (815), `scalarFormField` (1224) used at 204 and 1073.
+- `modules/cabana/field_ml.go`: `mlFieldType`, `liftMLValues`, `applyMLTranslations`, `hydrateMLRecord`, `TranslationWriter`.
+- `modules/cabana/extension.go`: `widgetTagPrefix`, `checkWidgetTag`, `compileClientAssets` (253-300, the `read` closure to extract), `checkAssetPath`, `bootErr`, `pluginAsset`.
+- `modules/cabana/tx_context.go`: `withVirtualFields`/`VirtualFieldsFromContext` (36-60), the pattern for the writes accessor.
+- `admin/src/components/form/fields/WidgetField.vue`: element creation, `defined()` with `WIDGET_TIMEOUT`, `api.POST` with `params.path` from the control props, ExtensionFailure.
+- `admin/src/app/pluginAssets.ts`: `assetAllowed`, `loadScript`, `loadStyles`, `activateStyles` (disables links whose owner is not active: Pitfall 8).
+
+
+
+
+
+
+ Task 1: Confirm the published pact field-type API before it is written (D-12)
+ modules/pact/capabilities.go (AdminAction, AdminActionInput, HasAdminActions, AdminClientAssets: the style to copy), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md (D-11 to D-15, Claude's Discretion), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 1, 2, 5, 9; Code Example 1), this plan's interfaces block
+ Do the automatable part, then stop. Run the verify command (baseline: the contract and schema tests this plan must keep green pass before any change) and confirm `git status --porcelain -- modules admin docs scripts` prints nothing; if it prints anything, report the in-flight files (concurrent session, 16.1 D-02) and stop without editing.
+
+Then print the proposal sheet: the pact types and func signatures exactly as in the interfaces block; which funcs are optional (CheckOptions, Load, ListValue, Upload) and which is required (Decode); the cabana additions (FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, the FormField keys element, translatable and typeOptions); the two routes and their answers; the element contract (value property, attributes, summer-change, summer-upload, summer-upload-result), labelled "fixed: shared with plan 02, not open at this checkpoint"; the alias rule (^[a-z][a-z0-9]*$, no core type, no duplicate) and the option-key rule (^[A-Za-z][A-Za-z0-9]*$, no core field key); and the assumptions section of this plan (A5 refusal in settings and relation forms, field-types route, string values for translatable types). Stop for the human.
+ Adopt this pact and cabana field-type API as proposed, or adjust named Go identifiers before they are written?
+ D-12 rates the pact interface one-way: once summercms is tagged (plan 05, v0.1.17) other repositories compile against these names, and the todo cabana-plugin-registered-field-types expects payment and other plugins to build on it. Before the tag, a rename costs one search-and-replace in this phase; after it, every consuming plugin changes. Journal's Go integration (plan 03) is the first consumer and is written against these exact names. Scope of this checkpoint: pact and cabana Go identifiers and the GET /field-types answer only. The element contract (tag prefix rule, value property, the attributes, summer-change, summer-upload, summer-upload-result) is fixed by this plan per D-13 and is being implemented by plan 02 in parallel, so it is not adjustable here; a human who wants it changed replies "stop" here and at plan 02's package checkpoint, and plans 01 and 02 are re-planned together.
+
+
+
+
+ Reply "approve", "adjust:" followed by the Go identifier changes, or "stop" to change the element contract (re-plans 01 and 02 together).
+
+ go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4
+ Non-zero exit: vet reports a problem, or fewer than four "--- PASS: Test" lines (a baseline contract or schema test already fails before this plan changes anything).
+
+
+ - The baseline verify passed and `git status --porcelain -- modules admin docs scripts` printed nothing.
+ - The SUMMARY records the human's reply verbatim and, for "adjust", the final identifier list that Tasks 2 to 4 use; the element contract names in that list are exactly the ones in the interfaces block.
+
+ A human approved the API or named Go identifier changes, with the element contract unchanged; nothing has been edited yet.
+
+
+
+ Task 2: Tracer: one non-translatable plugin field type end to end (pact, boot, schema, save Decode, Show Load, field-types route, SPA PluginField)
+ Published pact API: after the plan 05 tag, other plugins' go.mod require these names; renaming means editing every consumer (D-12).
+ Task 1 was answered "approve" or "adjust" and the SUMMARY lists the identifiers to use.
+ modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/field_type.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/extension.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/fieldTypes.ts, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/PreviewField.vue, admin/src/components/form/FormField.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/PluginField.vue, admin/tests/form/PluginField.test.ts, modules/boardwalk/dist, docs/backend/field-types.md, docs/backend/forms.md
+ modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_schema.go, modules/cabana/schema_types.go (FormField, ListColumn), modules/cabana/registry.go, modules/cabana/http.go (Activate, mount, protect, navigation handler), modules/cabana/crud.go (save, ShowRecord, lifecycleFailure, scalarFormField and its callers), modules/cabana/extension.go, modules/cabana/settings.go (decodeFields caller at 74), modules/cabana/relation_form.go (decodeFields callers at 145 and 246), modules/cabana/admin_openapi.go (the /navigation and FormField annotations), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/query_relation_test.go (openDryRunDB, newDryRunListService: test style), modules/cabana/README.md (Admin API routes table, API reference), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/state/useNavigation.ts, admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/tests/form/WidgetField.test.ts, docs/backend/partials-and-widgets.md (Form widgets, Widget element contract), docs/backend/forms.md, docs/site.yaml
+ Build the thinnest complete path for a plain plugin type (per D-11, D-12, D-13, D-15), using the identifiers recorded by Task 1.
+
+pact (D-12): add FormFieldType, FormFieldValue, FormFieldUpload, FormFieldUploadResult, FormFieldError (Error joins Messages with "; ") and HasFormFieldTypes to modules/pact/capabilities.go with doc comments in the AdminAction style: cabana owns routes, CSRF, authentication, permissions, record scope, locale storage and the untouched-value skip; Decode returns the value to store, Load returns what the element receives, ListValue returns list text, Upload stores a file and returns its URL. The HasFormFieldTypes doc comment says it is implemented by the plugin (not a controller), like HasAdminControllers.
+
+Registry (D-11, RESEARCH Pattern 1): new modules/cabana/field_type.go with compileFieldTypes(plugins []party.Plugin) (*fieldTypeRegistry, error), called in Activate before compileRegistry and passed down as a parameter; no package-level registry. Rules, each a boot error of the form "cabana: plugin field type : ": the alias matches ^[a-z][a-z0-9]*$; it is not a key of formFieldTypes; it is not claimed by another plugin (the message names both plugin IDs); Element passes checkWidgetTag with widgetTagPrefix(pluginID); Decode is non-nil; each Options key matches ^[A-Za-z][A-Za-z0-9]*$, is not a key of formFieldKeys and is not repeated; a plugin declaring Scripts or Styles implements pact.AdminAssets. Extract the read-and-hash closure of compileClientAssets into readClientAssets(pluginID, owner string, fsys fs.FS, files []string, exts ...string) ([]*pluginAsset, error) and use it for both controller assets and field-type Scripts (.js, .mjs) and Styles (.css); field-type assets join Registry.assets so the existing GET {prefix}/assets/{vendor}/{plugin}/{file...} route serves them with ETag and ?v=.
+
+Compile (D-12.1): compileRegistry and compileList/compileFormIfPresent take the registry; decodeFields(raw, types) passes it through fieldMap to compileFieldNode, which now reads the type first: for a plugin alias the accepted keys are label, comment, span, type, required, tab, context, attributes, size, default plus the type's Options; any other key is "unknown field "; option values decode from the YAML nodes into JSON-compatible values (string, bool, int or float64, []any, map[string]any) in FormField.TypeOptions; CheckOptions (when non-nil) errors fail boot naming field and type. Core types keep today's path byte for byte. FormField gains Element, Translatable, TypeOptions (JSON element, translatable, typeOptions, omitempty) and an unexported fieldType pointer (json "-"). CompileForm keeps its signature and core-only behaviour (a plugin alias stays "unsupported type"); add CompileFormWithTypes(pluginID, ctl, fsys, types) that runs the alias and option checks (element prefix and assets are checked by Activate). settings.go and relation_form.go use a refusing registry: a plugin alias there fails boot with "plugin field type is supported in controller forms only" (assumption A5).
+
+Save and show (D-12.2, D-12.3, D-10 mapping): the scalarFormField checks at crud.go BindWritableFields and mergedRules also accept a field with a plugin type, so it binds to its column and `required` works. New liftPluginValues(cc, body, op), called next to liftMLValues before the transaction, removes the values of non-translatable plugin fields present in the body whose context allows op and keeps them as raw JSON values (projection would drop nested ones). New decodePluginFields, called inside the transaction after projectOperation and before lagoon.Fill, runs Decode with FormFieldValue{DB: tx, Record: target, Field, Options, Default: true, Value} and sets projected[field] to the result; a *pact.FormFieldError becomes &ValidationError{Details: {field: Messages}} (422 validation_failed); any other error goes through lifecycleFailure (opaque 500, no plugin text). New hydratePluginFields runs after hydrateMLRecord in ShowRecord and at the end of save: data[field] = Load(...) with Value = the projected stored value; nil Load keeps the value; a Load error is an opaque 500.
+
+Route (D-13): GET {api}/field-types behind the backend guard (handler fieldTypesList) answers the FieldTypesResponse shape in the interfaces block, [] when no plugin registers a type. Annotate it in admin_openapi.go (FieldTypeAssets, FieldTypesResponse) and document the FormField keys; add "GET /field-types" to phase09Routes; run bash scripts/check-admin-openapi.sh (writes admin/openapi/admin.json and admin/src/api/schema.d.ts) and add the FieldTypeAssets alias to admin/src/api/types.ts.
+
+SPA (D-13, D-15): pluginAssets.ts gains GLOBAL_STYLE_OWNER = '*' and loadGlobalStyles(urls), whose links activateStyles never disables (Pitfall 8). New admin/src/app/fieldTypes.ts: loadFieldTypes() GETs /field-types, loads each script once through loadScript, adds styles through loadGlobalStyles and resolves to the list; it never rejects (a failed script surfaces later as that field's failure state). main.ts calls it with loadNavigation when signed in; LoginView calls it after login. formContext.ts adds FIELD_CHANGE_EVENT = 'summer-change'. New PluginField.vue modelled on WidgetField.vue (same header rule: never a token, cookie, function or Vue instance; imperative element in a mount point; status loading, ready or failed with WIDGET_TIMEOUT; ExtensionFailure): it sets the element's value property from modelValue only when it differs from element.value, sets the attributes of the element contract (field-name, record-id, locale empty for a plain type, ui-locale from FORM_LOCALE, options as JSON of typeOptions, disabled, invalid), and emits update:modelValue with detail.value on summer-change. registry.ts adds rendererForField(field) and isRegisteredField(field): a field with an element renders PluginField and counts as a value field; FieldRenderer, PreviewField and formState's editable check switch to them; FormField's group label treats a plugin field as a group. Rebuild dist with npm --prefix admin run build.
+
+Smoke tests: modules/cabana/field_type_smoke_test.go TestFieldTypeTracerSmoke (no database): a fake plugin "acme.demo" whose AdminFS (fstest.MapFS) holds assets/js/notes.js registers type "notes" with element "acme-demo-notes", option key "rows", a Decode that refuses the string "bad" with a FormFieldError and otherwise returns the value, and a Load that prefixes "served:"; subtests: alias collision with a second plugin and with "markdown" fail boot; an unknown key and a CheckOptions error fail boot; GET /field-types through httptest lists notes with a ?v= script URL; GET schema/form carries element and typeOptions; decodePluginFields on a stub record sets projected["notes"] and maps "bad" to a ValidationError on "notes"; hydratePluginFields serves the "served:" value. admin/tests/form/PluginField.test.ts: a stub custom element; mounting PluginField sets the value property and the attributes; a summer-change event emits update:modelValue; an echo of the same value does not set the property again; an undefined tag ends in the failure state.
+
+Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features and API reference rows for each new pact identifier); cabana README (the GET /field-types row in Admin API routes, CompileFormWithTypes and the FormField keys in API reference); new docs/backend/field-types.md (front matter title "Plugin field types", section backend, order 25) covering registration, aliases, options, Decode and Load and the element contract; a link from docs/backend/forms.md where the supported field types are listed. Commit as feat(pact), feat(cabana) and feat(admin) commits, each with its README, docs, OpenAPI or dist changes, staged by explicit path.
+
+ go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5
+ Non-zero exit: vet fails, or fewer than five top-level "--- PASS: Test" lines (the tracer smoke is missing or failing, or the route inventory, permission matrix or schema tests regressed).
+ npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/PluginField.test.ts tests/form/WidgetField.test.ts tests/form/registry.test.ts tests/form/formState.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree
+ Non-zero exit: vue-tsc reports a type error, a vitest file fails or is not found, check-admin-openapi prints "committed admin OpenAPI output is stale", check-admin-dist prints "modules/boardwalk/dist is stale", or TestDocsTree reports an identifier, link or anchor the docs name that does not exist.
+
+
+ - `grep -n "type HasFormFieldTypes interface" modules/pact/capabilities.go` prints one line, and `grep -n "func compileFieldTypes" modules/cabana/field_type.go` prints one line.
+ - `grep -n '"GET /field-types"' modules/cabana/security_coverage_test.go` prints one line and admin/openapi/admin.json contains the path "/field-types".
+ - TestFieldTypeTracerSmoke passes with subtests for: collision between two plugins, collision with markdown, unknown key, CheckOptions error, the field-types route, schema keys element and typeOptions, Decode success and FormFieldError mapping, Load on show.
+ - PluginField.test.ts passes, including the echo case (no second property write for an equal value).
+ - check-admin-openapi --check, check-admin-dist and TestDocsTree pass; docs/backend/field-types.md has front matter section backend.
+ - `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing (neutral names).
+
+ A fake plugin's non-translatable type compiles at boot, appears in the form schema and the field-types list, is decoded on save and loaded on show, and the SPA renders its element with a two-way value binding.
+
+
+
+ Task 3: Expansion (Go): translatable types, untouched skip, written-values accessor, list values and the cabana upload route
+ modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_ml.go, modules/cabana/crud.go, modules/cabana/tx_context.go, modules/cabana/list_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, docs/backend/field-types.md
+ modules/cabana/field_type.go (Task 2 state), modules/cabana/field_ml.go, modules/cabana/crud.go (save, ShowRecord, BindWritableFields column binding helpers modelFields and gormColumn), modules/cabana/tx_context.go, modules/cabana/list_schema.go (listColumnTypes at 22, compileColumns at 200), modules/cabana/http.go (projectRow at 1067, the fileupload routes at 335-347, protect at 1030), modules/cabana/field_file.go (fileUpload and fileUploadOn at 684-760: MaxBytesReader, multipart, transaction, scope), modules/cabana/csrf.go (requireAjax), modules/cabana/locale_order_test.go (recordingWriter: fake TranslationWriter style), modules/cabana/admin_openapi.go (file upload annotations near line 1156), modules/cabana/README.md, docs/backend/field-types.md, docs/backend/admin-controllers.md (VirtualFieldsFromContext section: style for the writes accessor)
+
+ - Translatable type: a body map {en, pl} with pl default runs Decode once per locale; pl lands on the host column, en goes through TranslationWriter.WriteTranslated; a FormFieldError on en is a 422 on the field and nothing is written.
+ - Untouched (D-03): on update, a submitted per-locale value equal to the served value (Load of the stored value) is neither decoded nor written; on create nothing is skipped.
+ - FieldWritesFromContext inside FormBeforeUpdate and FormAfterUpdate lists exactly the written (field, locale) values with Default set for the host value; it returns false outside a save.
+ - A list column typed "notes" shows ListValue(value); without ListValue the raw value is kept.
+ - Upload route: 201 {data:{url}} from Upload; 404 for a core-typed field or a type without Upload; 403 without the controller permission; missing X-Requested-With refused by requireAjax; over the cap 413; FormFieldError 422 on the field; id 0 passes Record nil.
+
+ Extend Task 2's runner (per D-03, D-06, D-10, D-12.4, D-14, D-18).
+
+Translatable types (D-14, D-06): add isMLField(field FormField) bool = mlFieldType(field.Type) or a plugin type with Translatable; switch the field loops in liftMLValues and hydrateMLRecord to it, so a translatable plugin field is lifted (locale to string map, default required, enabled locales only) and hydrated exactly like mlmarkdown. decodePluginFields then handles translatable fields per locale: the default-locale value is decoded into projected[field]; non-default values are decoded and replace the matching entries of the translations list that applyMLTranslations writes. A translatable Decode must return a string; any other result type is an opaque 500. FormFieldValue.Translated is a closure over TranslationWriter.TranslatedExact(ctx, tx, record, attribute, locale) for the same record and locale (nil without a writer). hydratePluginFields applies Load per locale to the hydrated map.
+
+Untouched skip (D-03 server half, RESEARCH Pattern 3): new servedFieldValue computes what Show serves for a field and locale on the loaded target (host value read through the same column binding BindWritableFields uses, pointers dereferenced; non-default from TranslatedExact, missing = ""), then Load. On update, decodePluginFields compares the submitted value with the served value by canonical encoding/json bytes and skips equal ones entirely (not in projected, not in translations). On create nothing is skipped.
+
+Written values (Pitfall 7): tx_context.go gains FieldWrite, withFieldWrites (unexported), WithFieldWrites (exported, documented for Form hook tests) and FieldWritesFromContext (returns a copy; false outside a create or update), mirroring withVirtualFields/VirtualFieldsFromContext. save puts the writes on the context right after decodePluginFields, before formBeforeUpdate/formBeforeCreate, so Before and After hooks both see them.
+
+List (D-12.4): compileColumns accepts a plugin alias as a column type (listColumnTypes stays the core set); ListColumn gets an unexported fieldType; projectRow returns ListValue(value) for such a column when ListValue is non-nil.
+
+Upload route (D-18, RESEARCH Pattern 9): new modules/cabana/field_type_upload.go with fieldUpload mounted as g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload)) plus constrainFile-style Where constraints, next to the file routes (7 segments; distinct from .../files/{field}/reorder and .../relations/{name}/link by the literal segment). Inside s.protect: the field must exist in cc.Form with a plugin type whose Upload is non-nil, else 404 not_found; the field's context must allow create when id is 0 and update otherwise, else 404; the body is capped with http.MaxBytesReader(w, r.Body, s.uploadBytes); exactly one multipart part named file, else 422 invalid on the field; for id != 0 the record loads through loadRecord (FormExtendQuery scope) in a transaction, a miss is 404; Upload gets FormFieldUpload{DB, Record (nil for id 0), Field, Options, Filename, Body}; a FormFieldError is 422 validation_failed on the field; any other error an opaque 500; success 201 {"data":{"url":...}} (FieldUploadResponse). Annotate the route in admin_openapi.go, add "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload" to phase09Routes, run bash scripts/check-admin-openapi.sh and add the FieldUploadResponse alias to admin/src/api/types.ts.
+
+Tests: TestFieldTypeExpansionSmoke in field_type_smoke_test.go (no database; a recording TranslationWriter in the locale_order_test.go style; a translatable fake type "notesml"; the upload route through httptest with id 0 and a principal context as the existing route tests build it) covering every behavior bullet above.
+
+Docs: cabana README rows for FieldWrite, FieldWritesFromContext, WithFieldWrites and the upload route, and a note that settings and relation forms refuse plugin field types; docs/backend/field-types.md sections "Translatable types", "Untouched values", "Companion columns in Form hooks" (with FieldWritesFromContext) and "Uploads"; neutral names only. Commit as feat(cabana) with the README, docs and OpenAPI files, staged by explicit path.
+
+ go vet ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6
+ Non-zero exit: vet fails, or fewer than six top-level "--- PASS: Test" lines (the expansion smoke fails or is missing, or an existing route, permission, relation-search or locale-order smoke regressed).
+ bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 ./cmd/summer -run TestDocsTree
+ Non-zero exit: the committed OpenAPI or schema.d.ts is stale, the SPA no longer type-checks against the regenerated types, or TestDocsTree reports a missing identifier, link or anchor.
+
+
+ - `grep -n "func FieldWritesFromContext" modules/cabana/tx_context.go` and `grep -n "func WithFieldWrites" modules/cabana/tx_context.go` each print one line.
+ - `grep -n 'fields/{field}/upload' modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/admin_openapi.go` prints at least one line per file.
+ - TestFieldTypeExpansionSmoke has passing subtests for: per-locale Decode with default on the host and non-default through the writer, a non-default FormFieldError 422 with no write, untouched skip on update for host and translation, no skip on create, the writes accessor in Before and After hooks, ListValue in projectRow, and the upload route answers 201, 404 (core field), 403 (no permission), 413 (over the cap) and 422 (FormFieldError).
+ - The OpenAPI check, typecheck and TestDocsTree pass.
+
+ Translatable plugin types store through the existing ML path, untouched values are never written, hooks can see what was written, list columns format through the type, and the upload route is mounted, documented and permissioned.
+
+
+
+ Task 4: Expansion (SPA): MLPluginField, element uploads through the SPA, plugin list columns, docs and dist
+ admin/src/components/form/fields/MLPluginField.vue, admin/src/components/form/fields/PluginField.vue, admin/src/components/form/registry.ts, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/views/FormView.vue, admin/src/views/ListView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/PluginField.test.ts, modules/boardwalk/dist, docs/backend/field-types.md
+ admin/src/components/form/fields/PluginField.vue (Task 2 state), admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts (isMLFieldType callers at 68, 139, 187, 228-233), admin/src/views/FormView.vue (isMLFieldType at 180 and 252), admin/src/components/relation/RelationChildModal.vue (isMLFieldType at 221), admin/src/views/ListView.vue (column cell rendering), admin/src/api/files.ts (multipart upload with X-Requested-With and failure kinds), admin/src/api/client.ts, admin/tests/form/MLFields.test.ts, admin/tests/form/PluginField.test.ts, docs/backend/field-types.md
+
+ - A field with element and translatable renders MLPluginField: the locale switcher of MLField around PluginField; the element gets the active content locale in its locale attribute and that locale's string as value; summer-change updates only that locale in the map.
+ - isMLField(field) is true for mltext, mltextarea, mlmarkdown and for a field with element and translatable; formState initial values, editable payload, adopt and record display name treat such a field as a locale map.
+ - summer-upload {id, file} posts multipart to the field upload route with the form's vendor, plugin, controller, record id (0 on create) and field name, then dispatches summer-upload-result {id, url} on success and {id, error} with the server message on 422, 413 or a network failure.
+ - A list column whose type is not built in renders its value as plain text.
+
+ Finish the SPA half (per D-06, D-13, D-14, D-18).
+
+ML (D-14): new MLPluginField.vue in the MLMarkdownField.vue shape: MLField with :inner="PluginField", inner-type = field.type and ml-type = field.type; PluginField accepts an optional content-locale prop that MLField's inner rendering supplies and writes it to the locale attribute. mlLocale.ts gains isMLField(field) (keep isMLFieldType exported unchanged); switch the callers in formState.ts, FormView.vue and RelationChildModal.vue to the field-based check. rendererForField returns MLPluginField for a translatable plugin field; groupLabelled is true for it.
+
+Uploads (D-18): formContext.ts adds FIELD_UPLOAD_EVENT = 'summer-upload' and FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'. PluginField listens for summer-upload, posts the File as the single multipart part "file" to /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload with the same session and X-Requested-With handling api/files.ts uses (path values from the control props, record id 0 on create), and dispatches summer-upload-result on the element with {id, url} or {id, error} (the 422 message, or a localized generic failure for 413 and network errors). The SPA never hands the element a token or a function.
+
+Lists (D-12.4): make ListView render a column whose type has no built-in renderer as plain text of the row value (the server already sent the ListValue string), if it does not already.
+
+Tests: extend admin/tests/form/PluginField.test.ts with the ML wrapper (two locales, locale attribute, per-locale change), the upload round trip (mocked fetch: success, 422 message, network failure) and isMLField on the formState helpers.
+
+Docs and dist: docs/backend/field-types.md gains the full element contract (value, attributes, summer-change, summer-upload, summer-upload-result, the echo rule, --c-* only, no credentials) and the translatable variant; rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the SPA change as feat(admin), staged by explicit path.
+
+ npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree
+ Non-zero exit: a type error, any vitest file failing (including MLFields, FormView, ListView and the extended PluginField tests), check-admin-dist printing "modules/boardwalk/dist is stale", or TestDocsTree reporting a missing identifier or anchor.
+
+
+ - `grep -n "export function isMLField" admin/src/components/form/mlLocale.ts` prints one line, and FormView.vue, formState.ts and RelationChildModal.vue call isMLField.
+ - PluginField.test.ts passes its ML, upload (success, 422, network) and isMLField cases; the full vitest suite passes.
+ - docs/backend/field-types.md documents summer-change, summer-upload and summer-upload-result.
+ - check-admin-dist passes with the rebuilt dist committed.
+
+ Translatable plugin fields switch locales like mlmarkdown, elements upload through the SPA without credentials, plugin list columns render, and the docs describe the full contract.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| admin browser → cabana admin API | save bodies, upload bodies and field-type requests cross here (cookie or Bearer session) |
+| plugin Go code → cabana | Decode, Load, ListValue and Upload run inside cabana's handlers and transaction |
+| plugin element JS → admin SPA DOM | the element runs in the admin origin under CSP script-src 'self' |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-16.2-01 | Spoofing | compileFieldTypes alias registry | high | mitigate | Boot fails when an alias equals a core type or another plugin's alias, naming both owners; element tags must carry the owning plugin's prefix (checkWidgetTag) |
+| T-16.2-02 | Tampering (stored XSS) | decodePluginFields | high | mitigate | Decode runs for the host value and for every non-default locale inside the save transaction; there is no write path for a plugin field that skips Decode except the equal-to-served skip, which by definition writes nothing |
+| T-16.2-03 | Elevation of Privilege | fieldUpload route | high | mitigate | Backend guard, requireAjax (X-Requested-With) with the SameSite=Strict cookie, controller RequiredPermissions via protect, field context check, FormExtendQuery record scope for id != 0, 404 for fields without a plugin Upload |
+| T-16.2-04 | Denial of Service | fieldUpload body | medium | mitigate | http.MaxBytesReader with http.body_limits.upload_bytes (413), exactly one multipart part |
+| T-16.2-05 | Information Disclosure | PluginField / element | high | mitigate | The element receives only the value property and plain attributes; uploads are performed by the SPA; no token, cookie, function or Vue instance is ever passed (test asserts attributes only) |
+| T-16.2-06 | Tampering | typeOptions to element | low | mitigate | Options reach the element only as a JSON attribute string; the SPA never renders them as HTML |
+| T-16.2-07 | Information Disclosure | plugin errors | medium | mitigate | Only pact.FormFieldError messages reach the client (422); any other Decode, Load or Upload error is an opaque 500 through lifecycleFailure |
+| T-16.2-08 | Tampering | untouched skip | low | accept | An attacker who submits the served value only causes no write; the skip cannot store anything that Decode did not already accept |
+| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan: Go has no new dependency and the admin SPA builds from its committed lockfile |
+
+
+
+- Task 1: baseline contract and schema tests pass and a human approved the API.
+- Task 2: TestFieldTypeTracerSmoke and the regression set pass; PluginField vitest, OpenAPI check, dist check and TestDocsTree pass.
+- Task 3: TestFieldTypeExpansionSmoke and the regression set pass; OpenAPI check, typecheck, TestDocsTree pass.
+- Task 4: full admin vitest suite, dist check and TestDocsTree pass.
+- Postgres-backed save, show and upload tests with a real record are written in plan 06 and run on a Docker host.
+
+
+
+- D-11 to D-15 implemented in pact, cabana and the SPA with fail-loud boot checks.
+- D-03 server skip, D-10 refusal mapping, D-06/D-14 storage through the existing ML path, D-18 upload route in place.
+- OpenAPI, TS types, dist, READMEs and docs updated in the same changes; no framework doc names a consuming application.
+
+
+
diff --git a/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-PLAN.md b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-PLAN.md
new file mode 100644
index 0000000..feb205f
--- /dev/null
+++ b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-PLAN.md
@@ -0,0 +1,284 @@
+---
+phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+coupling_justified: ["16.2-01: shares only the element contract (tag prefix rule, value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid, events summer-change, summer-upload, summer-upload-result), which plan 01 fixes per D-13 and its Task 1 checkpoint cannot rename (only pact and cabana Go identifiers, none of which this plan references), so both execution orders produce the same names"]
+files_modified:
+ - ../sm-journal-plugin/.gitignore
+ - ../sm-journal-plugin/package.json
+ - ../sm-journal-plugin/package-lock.json
+ - ../sm-journal-plugin/tsconfig.json
+ - ../sm-journal-plugin/vite.config.ts
+ - ../sm-journal-plugin/scripts/check-dist.sh
+ - ../sm-journal-plugin/assets/src/blogeditor/index.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/element.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/embed.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/labels.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
+ - ../sm-journal-plugin/assets/dist/blogeditor.js
+ - ../sm-journal-plugin/assets/dist/blogeditor.css
+ - ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
+ - ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
+ - ../sm-journal-plugin/admin.go
+ - ../sm-journal-plugin/README.md
+autonomous: false
+requirements: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
+estimate:
+ tokens: 110000
+ raw_tokens: 110000
+ tasks: 3
+ confidence: low
+must_haves:
+ truths:
+ - "D-16: the Blog Editor is the custom element golem15-journal-blogeditor built from sm-journal-plugin assets/src with a pinned Vite library build into assets/dist/blogeditor.js and assets/dist/blogeditor.css; the bundle is committed, embedded through admin.go's //go:embed list, and scripts/check-dist.sh fails when assets/dist differs from a fresh build; the host app needs no npm."
+ - "D-01: the element is a WYSIWYG editor on TipTap with a source tab; the source tab shows and edits the exact HTML string."
+ - "D-03: setting value and reading it back without a user edit returns the identical string for the imported-post fixture (footer, style, spans, figures, tables, entities); no summer-change is emitted until a user edit; editing one paragraph leaves every other top-level block of the fixture byte-identical in the output."
+ - "D-04: the toolbar offers headings, bold, italic, bullet and ordered lists, links, blockquote, tables, images, embeds and a raw HTML block; unknown block markup (footer, section, aside, header, nav, details, style, div) becomes a non-editable raw HTML card that renders its source as text."
+ - "D-05: an embed is inserted from a YouTube URL as exactly with an 11-character id; the admin editor never renders an iframe."
+ - "D-18 (editor half): an image chosen with the file picker or dropped on the editor is sent as summer-upload {id, file}; on summer-upload-result {id, url} the editor inserts an img with alt text, inside figure with figcaption when a caption is given; on {id, error} it shows the error and inserts nothing."
+ - "D-13: the element follows the documented contract (value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload) and styles only through --c-* variables under its tag."
+ artifacts:
+ - path: "../sm-journal-plugin/assets/dist/blogeditor.js"
+ provides: "committed editor bundle"
+ contains: "golem15-journal-blogeditor"
+ - path: "../sm-journal-plugin/scripts/check-dist.sh"
+ provides: "dist drift gate"
+ contains: "diff -r"
+ - path: "../sm-journal-plugin/assets/src/blogeditor/serializer.ts"
+ provides: "lossless serializer with top-level block reuse"
+ contains: "serializeDocument"
+ - path: "../sm-journal-plugin/admin.go"
+ provides: "embed of the editor bundle"
+ contains: "assets/dist/blogeditor.js"
+ key_links:
+ - from: "../sm-journal-plugin/admin.go adminFS"
+ to: "../sm-journal-plugin/assets/dist/blogeditor.js"
+ via: "//go:embed line, served by cabana as a field-type script (plan 03 declares it)"
+ pattern: "assets/dist/blogeditor"
+ - from: "golem15-journal-blogeditor summer-upload"
+ to: "admin SPA PluginField (plan 01)"
+ via: "bubbling CustomEvent answered with summer-upload-result"
+ pattern: "summer-upload"
+ prohibitions:
+ - statement: "The editor never re-serializes an untouched document and never uses editor.getHTML() as the stored value."
+ status: unresolved
+ verification: judgment
+ - statement: "The bundle uses no eval or new Function and injects no style tag (admin CSP script-src 'self'; injectCSS false)."
+ status: unresolved
+ verification: judgment
+ - statement: "The element renders no iframe and no live raw HTML in the admin; raw blocks show escaped text."
+ status: unresolved
+ verification: judgment
+ - statement: "The element never asks for, stores or sends credentials and never calls the public Journal media API itself."
+ status: unresolved
+ verification: judgment
+---
+
+## Phase Goal
+
+ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
+
+This plan's slice (user decision 1, plan 02): the Blog Editor custom element in `../sm-journal-plugin` (`assets/src` to `assets/dist`), with its own JS build, tests and drift gate, embedded in the plugin. Plan 03 registers it as the `blogeditor` and `mlblogeditor` field types.
+
+## Spec-less probe fallback
+
+Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
+
+## Decisions recorded here (Claude's discretion)
+
+- **TipTap version (user decision 6):** keep 3.31.4, the version RESEARCH probed (lossiness, emitUpdate, trailingNode), behind a blocking human package check before the first install. An older 3.x was never probed, so pinning one would trade a known release-age flag for unverified behaviour.
+- **Embed markup and allowlist (D-05):** exactly ``, attributes in that order, no caption, id matching `^[A-Za-z0-9_-]{11}$`. YouTube is the only provider; the provider table in `embed.ts` and in the layer (plan 04) has one entry so another provider is one entry in each.
+- **Untouched detection (D-03):** the element keeps the exact served string and returns it until a user edit (client layer); cabana also skips values equal to what it served (plan 01).
+- **Top-level block reuse (RESEARCH A3):** included. Unedited top-level blocks keep their original bytes after an edit elsewhere.
+- **Image without caption:** a plain `img` block; with a caption, `figure` with `img` and `figcaption`.
+
+## Artifacts this phase produces (this plan)
+
+- Custom element `golem15-journal-blogeditor` (class `BlogEditorElement`), registered once by `assets/src/blogeditor/index.ts`.
+- `assets/src/blogeditor/extensions.ts`: `preservationExtensions()`, nodes `rawHtml`, `figure`, `figcaption`, `embed`, mark `span`, extension `htmlAttributes` (global attribute map), table render override.
+- `assets/src/blogeditor/serializer.ts`: `serializeDocument(editor, original)`, `captureBlocks(html, schema)`.
+- `assets/src/blogeditor/embed.ts`: `EMBED_PROVIDERS`, `parseEmbedUrl(url)`.
+- `assets/src/blogeditor/labels.ts`: `labelsFor(uiLocale)` with `en` and `pl` tables.
+- `assets/src/blogeditor/toolbar.ts`: `buildToolbar(...)`.
+- Build files: `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts` (build and vitest config), `scripts/check-dist.sh`, `.gitignore`.
+- Committed bundle: `assets/dist/blogeditor.js`, `assets/dist/blogeditor.css`.
+- Test files: `tests/blogeditor/fixtures/imported-post.html`, `tests/blogeditor/smoke.test.ts`.
+
+
+Build the Blog Editor custom element (D-01, D-03, D-04, D-05, D-13, D-16, D-18 editor half) as sm-journal-plugin's own JS build with a committed, embedded bundle.
+
+Purpose: plan 03 registers this element as the Journal post and category content field type.
+Output: package setup, element source, preservation schema and serializer, upload and embed UI, vitest smoke tests, committed dist, drift gate, embed line, README section.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
+@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
+@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
+
+Repository: `../sm-journal-plugin` (branch main). Commits there: no co-author tags, stage by explicit path, one logical change per commit. It has no `.gitignore` today; add one with `node_modules/` before the first install. The Go module builds against `../summercms.go` through `go.work`.
+
+
+Element contract (identical to plan 01's interfaces block; plan 01 documents it in docs/backend/field-types.md). Fixed for this phase: plan 01's Task 1 checkpoint may rename only pact and cabana Go identifiers, never these names, so this plan runs in parallel with plan 01 against them. If plan 01's SUMMARY or checkpoint reply ever lists a change to any name below, stop and report instead of guessing.
+- Property `value` (string). Attributes `field-name`, `record-id`, `locale`, `ui-locale`, `options` (JSON), `disabled`, `invalid`.
+- Events (bubbling CustomEvent): `summer-change` detail `{value}` on user edits only; `summer-upload` detail `{id, file}`; the SPA answers on the element with `summer-upload-result` detail `{id, url}` or `{id, error}`.
+- Setting `value` to the string the element currently returns is a no-op (no reset, no event).
+
+Embed placeholder (stored markup, consumed by the layer in plan 04):
+
+```html
+
+```
+
+Element skeleton (RESEARCH Code Example 3, the value logic to keep):
+
+```ts
+get value(): string { return this.#dirty ? serializeDocument(this.#editor!, this.#blocks) : this.#value }
+set value(v: string) {
+ if (v === this.value) return // echo of our own summer-change
+ this.#value = v; this.#dirty = false
+ this.#editor?.commands.setContent(v, { emitUpdate: false })
+}
+// onUpdate({ transaction }): if (!transaction.docChanged) return; dirty = true; dispatch summer-change
+```
+
+Pinned toolchain (same versions as summercms.go/admin/package.json): vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. Editor packages (exact): @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image, all 3.31.4.
+
+Gate the server applies on save (plan 03, sm-journal-plugin classes/format_html.go CheckHTML): refuses `