Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-02-PLAN.md
2026-10-04 19:41:08 +02:00

83 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
12.1-user-plugin-admin-screens 02 execute 2
12.1-01
modules/pact/capabilities.go
modules/pact/capabilities_test.go
modules/pact/README.md
modules/cabana/contracts.go
modules/cabana/form_schema.go
modules/cabana/schema_types.go
modules/cabana/messages.go
modules/cabana/crud.go
modules/cabana/tx_context.go
modules/cabana/extension.go
modules/cabana/registry.go
modules/cabana/field_file.go
modules/cabana/field_permission.go
modules/cabana/relation_field.go
modules/cabana/list_schema.go
modules/cabana/filter_schema.go
modules/cabana/query.go
modules/cabana/http.go
modules/cabana/partial_render.go
modules/cabana/admin_openapi.go
modules/cabana/phase121_fixture_test.go
modules/cabana/phase121_form_test.go
modules/cabana/example_form_seams_test.go
modules/cabana/testdata/roster
modules/cabana/README.md
modules/phrasebook/backend/lang/en/lang.yaml
modules/phrasebook/backend/lang/pl/lang.yaml
admin/openapi/admin.json
admin/src/api/schema.d.ts
admin/src/api/types.ts
admin/src/app/router.ts
admin/src/app/winterUrl.ts
admin/src/styles/main.css
admin/src/views/PreviewView.vue
admin/src/views/FormView.vue
admin/src/components/form/PreviewField.vue
admin/src/components/form/formState.ts
admin/src/components/form/registry.ts
admin/src/components/form/fields/PasswordField.vue
admin/src/components/form/fields/PermissionEditorField.vue
admin/src/components/form/fields/RelationField.vue
admin/src/components/partial/PartialHost.vue
admin/src/components/list/DataTable.vue
admin/tests/smoke/preview.smoke.test.ts
admin/tests/smoke/seams.smoke.test.ts
admin/tests/fixtures/roster.form-schema.json
admin/tests/fixtures/roster.record.json
admin/tests/fixtures/roster.list-schema.json
modules/boardwalk/dist
docs/backend/forms.md
docs/backend/lists-and-filters.md
docs/backend/relation-manager.md
docs/backend/admin-controllers.md
docs/backend/partials-and-widgets.md
docs/backend/admin-spa.md
false
SC-1
SC-2
SC-3
SC-4
tokens raw_tokens tasks confidence
320000 320000 6 low
truths artifacts key_links
Per D-26, this is plan 02 of five: the preview context, the permissioneditor field and the form seams in summercms.go, ending with the tag v0.1.3; plans 03 to 05 build on that tag.
Per D-11, config_form.yaml accepts a `preview:` block (optional `headerPartial`), the form schema reports it, fields with `context: preview` are shown only on the preview screen and are never writable, and the SPA has a read-only record screen at the preview route with its own footer (record actions, then one primary edit button), a status hint slot above the card, and `recordUrl`, `create.redirect` and `update.redirectClose` may point at it through `.../preview/:id`.
Per D-11 and D-10, a config_form.yaml that declares `recordActions` without a `preview:` block stops boot, because record actions are offered only on the preview screen.
Per D-16, `type: permissioneditor` is a built-in field with `mode: radio` or `mode: checkbox`: the controller supplies the options (code, label, tab, comment, locked) per request, a submitted code outside the options or a value outside the mode's set (radio 1 or -1, checkbox 1) is 422 on the field, a changed locked code is 403, stored codes that are not offered are kept, and the 10.1 widget contract (scalar fill keys only) is unchanged.
Per D-27 and D-19 (G1, G2), `type: password` is a masked field whose value is never projected into a response and never a fill key, and form virtual fields listed by the controller through pact.FormVirtualFields are exempt from column binding, never filled, never projected, and reach hooks through cabana.VirtualFieldsFromContext only when the field's context allows the operation.
Per D-28 (G5), a controller implementing pact.FormRules supplies the rule set per operation (create or update) and that set replaces the model's Rules() for admin saves; rules may name virtual fields, whose submitted values (never the model column of the same name) are what the rules see.
Per D-27 (G3) and D-22, a belongsTo relation field whose foreign key is a protected fill key stays read-only unless its contract sets WritableForeignKey; the protected fill key list itself is unchanged.
Per D-27 (G4) and D-07, a controller implementing cabana.RelationLockProvider names related ids the current admin may not add or remove: options and labels carry `locked: true`, and a save (create or update) that changes the locked subset is refused with 403 before any row is written and changes nothing.
Per D-27 (G6, G7) and D-22, columns.yaml accepts `invisible: true` (searchable on the server, not rendered and not sent in rows) and fields.yaml accepts `preset` on a text field (a source field name, or field plus type slug or exact) which the SPA applies on create only while the target is untouched.
A scope filter's choices may come from the controller: a controller implementing pact.FilterOptions is asked before the model, so choices read from the database need no global handle (found at planning; needed for D-23).
Per D-25, every change ships with the module READMEs, the docs/backend pages, the admin OpenAPI document, the generated TS types and a rebuilt modules/boardwalk/dist in the same commit, fixtures use the neutral id acme.roster, and the framework is tagged v0.1.3 only after both full suites, the docs checks and the generated-output checks pass and the user has answered the tag checkpoint.
No Go module and no npm package is added or changed in version by this plan.
UI S2 empty: With no applicable record action the preview footer shows only the edit button.
UI S2 populated: After a record action succeeds a success toast shows and the record and status hint reload in place, with the previous content visible during the reload.
UI S2 overflow: The preview footer and its right cluster wrap; record action buttons move to a second row instead of shrinking.
UI S2 zero-one-many: Zero applicable record actions leave only the edit button; one or many render in declared order before it.
UI S2 long-text: Record action buttons are `whitespace-nowrap` and wrap as whole buttons; a label is never truncated.
UI S3 empty: On preview an empty value shows the muted empty-value dash, and an empty multiple relation shows the same dash.
UI S3 loading: While the preview loads the title is blank, the card is not rendered and the footer buttons are disabled; the status hint's first fetch shows one 68px skeleton block and a refetch keeps the previous hint visible.
UI S3 error: A preview load failure or 404 shows the existing alert with `form.load_failed` and the back button still works; a failed status hint shows the extension failure box and the rest of the screen works.
UI S3 populated: The preview renders fields as a `dl` grid in the form card with read-only boxes per the PreviewField table.
UI S3 partial: Preview fields group into the same tabs as the form, and a tab with no preview-visible field is not rendered; a status hint with zero nodes renders nothing and takes no gap.
UI S3 overflow: Preview values wrap with `overflow-wrap: anywhere`, textarea boxes grow with their text, and the page scrolls under the fixed footer.
UI S3 zero-one-many: A multiple relation on preview shows the dash for none and wrapping chips for one or many.
UI S3 long-text: Long preview values and callout text wrap and are never truncated; the title truncates as on the form.
UI S5 empty: A permission editor with no options renders the read-only box with `permissioneditor.empty`.
UI S5 error: A 422 on a permission editor renders on the `FormField` error line and the container border becomes `danger`.
UI S5 populated: Permissions render as sections grouped by tab in one list (no inner tablist; confirmed by the user as the meaning of D-16 'tabbed'), each row with label, comment and a three-segment radio group or a checkbox.
UI S5 partial: A locked permission row shows its stored value, a disabled control, the lock icon and the `permissioneditor.locked` text while other rows stay editable.
UI S5 overflow: A permission editor with many permissions has no inner scroll; the page scrolls and section headers are not sticky.
UI S5 zero-one-many: One tab gives one section with its header; permissions without a tab form a last section labelled `permissioneditor.other`.
UI S5 long-text: Permission labels and comments wrap in the left column, the control never shrinks, and below 640px the control moves under the label.
UI S6a partial: With some options locked, locked chips have no remove button and are skipped by Backspace, unlocked chips work as before, and the locked note shows under the field.
UI S7a empty: A password field is always empty on load, and an empty password on update is not sent.
UI S7a error: A password 422, including a confirmation mismatch, renders on the field's error line; the SPA does not compare the two fields.
UI S7a partial: With only one of password and confirmation filled the save is sent as entered and the server's 422 marks the field.
UI S7a long-text: A long password scrolls inside the input and `pr-12` keeps it clear of the show/hide toggle; every password field is cleared and hidden again after a successful save.
UI S7b empty: With an empty preset source the target stays empty.
UI S7b partial: A preset target follows its source until the first manual edit, then stops for the session; on update nothing is filled.
UI S7b long-text: A long preset source is slugged in full with no length cut in the SPA; the server's validation decides.
statement verification
`mapWinterUrl` maps `preview/:id` to the preview route, and opening the preview route of a form without a preview replaces it with the record route backstop
statement verification
Radio mode emits `1` / `-1` and omits inherit, checkbox mode emits `1` and omits unchecked, a locked row cannot change, and codes outside the options are never sent backstop
statement verification
A locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace backstop
path provides contains
modules/cabana/field_permission.go permissioneditor compile, lift, validate, locked guard, store and project PermissionEditorProvider
path provides contains
modules/cabana/tx_context.go VirtualFieldsFromContext next to TxFromContext VirtualFieldsFromContext
path provides contains
modules/cabana/relation_field.go WritableForeignKey, RelationLockProvider, RelationOption.Locked and the locked-id guard RelationLockProvider
path provides
admin/src/views/PreviewView.vue read-only record screen per UI-SPEC S3 with RecordActions in its footer
path provides
admin/src/components/form/fields/PermissionEditorField.vue permissioneditor control per UI-SPEC S5
path provides
admin/src/components/form/fields/PasswordField.vue password control per UI-SPEC S7
path provides
modules/cabana/phase121_form_test.go TestPreview*, TestPasswordField*, TestVirtualFields*, TestFormRules*, TestPreset*, TestPermissionEditor*, TestRelationLock*, TestWritableForeignKey*, TestInvisibleColumn*, TestFilterOptionsController* smoke tests
path provides
modules/boardwalk/dist rebuilt embedded SPA at the tagged commit
from to via pattern
admin/src/app/router.ts admin/src/views/PreviewView.vue route named preview, listed in CONTROLLER_ROUTES name: 'preview'
from to via pattern
admin/src/app/winterUrl.ts admin/src/app/router.ts preview/:id maps to the preview route of the current controller only preview
from to via pattern
modules/cabana/crud.go modules/cabana/relation_field.go save calls the locked-id guard after checkRelationScope and before any row write checkRelationLocks
from to via pattern
modules/cabana/crud.go modules/cabana/field_permission.go save lifts, validates and stores permissioneditor values; projectFullRecord projects them liftPermissionValues
from to via pattern
admin/src/views/PreviewView.vue admin/src/components/form/RecordActions.vue the footer renders the actions offered in the record response meta.actions RecordActions

Phase Goal

ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): Backend admins manage frontend users, user groups and organisations in the admin SPA without SQL, so the PHP backend is not needed for user administration after cutover. The Users, User Groups and Organisations screens of the PHP user plugin are ported to golem15.user, driven by its fields.yaml/columns.yaml.

This plan's slice: after it, any plugin form can have a preview screen with status hints and record actions, a password field with confirmation, form-only fields that reach hooks, its own admin validation rules, a permission editor, a writable foreign-key picker, locked relation options that the server enforces, preset fields and hidden search columns. The framework is then tagged v0.1.3.

Add the preview context (D-11), the `permissioneditor` field (D-16) and the form seams G1 to G7 (D-27, D-28) to `modules/pact`, `modules/cabana` and the admin SPA, with the same documentation and generated-output duties as plan 01, then tag the framework v0.1.3 behind a user decision checkpoint.

Purpose: locked decisions D-07, D-19, D-22 and D-23 cannot be met by the plugin screens without these seams (RESEARCH "Framework Gaps Beyond CONTEXT.md"). Output: the contracts and keys listed under "Artifacts this phase produces", an extended acme.roster fixture, smoke tests, updated READMEs and docs, and the annotated tag v0.1.3.

Repo: summercms.go only. Every commit keeps go vet ./... and go test ./... green and carries its own README, docs, OpenAPI, TS types and modules/boardwalk/dist changes. Code and planning docs go in separate commits. Never add co-author tags. Fixtures, READMEs and docs use neutral names (acme, blog) and never name a consuming application.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md @.planning/phases/10-admin-vue-spa/design/README.md @modules/cabana/crud.go @modules/cabana/form_schema.go - From plan 01 (see 12.1-01-SUMMARY.md for the final names): `pact.AdminRecordAction`, `pact.HasAdminRecordActions`, `cabana.RecordAction`, `RecordMeta.Actions`, `cabana.ForbiddenError{Message, Details}` (403, localized by the service, rolls the write back), the `acme.roster` fixture (`rosterPlugin`, `rosterController`, `rosterPerson`, `newRosterEnv`), `admin/src/components/form/RecordActions.vue` (props `source`, `recordId`, `actions`, `disabled`; events `busy`, `done`, `stale`, `gone`), `FormErrorBanner` prop `forbidden`. - `modules/cabana/form_schema.go`: `formFieldTypes`, `formFieldKeys`, `formConfigDocument{Name, Form, ModelClass, DefaultRedirect, Create, Update, Messages}` (strict decode), `compileFieldNode`, `compileContext`, `FormSchema.Localize(ctx, tr, provider)`, `FormView`. - `modules/cabana/crud.go`: `save` pipeline (liftRelationValues, transaction, loadRecord, lagoon.Fill, BeforeValidate, `mergedRules`, `valuesForRules`, lagoon.Validate, formBefore*, checkRelationScope, assignBelongsTo, Save or Create, syncBelongsToMany, commitDeferred, formAfter*, projectFullRecord), `BindWritableFields`, `scalarFormField`, `protectedFillKey` (the list stays as it is), `projectRecord`, `contextAllows(cc, name, op)`. - `modules/cabana/tx_context.go`: `withTx`, `TxFromContext` and their unexported key type. - `modules/cabana/field_file.go`: the rule "mode is only valid on type: fileupload or datepicker" inside compileFileuploadKeys. `modules/cabana/field_date.go`: the one-file-per-field-type pattern (`compileDatepickerKeys`, `compileDateFields`). - `modules/cabana/relation_field.go`: `FieldRelationContract{Field, Kind, NewRelated, ForeignKey, NewPivot, ParentForeignKey, RelatedForeignKey, OrderColumn, LabelColumn}`, `RelationOption{Value, Label}`, `compileFieldRelation` (the line setting ReadOnly from protectedFillKey), `RelationOptions`, `liftRelationValues`, `checkRelationScope`, `assignBelongsTo` (its own protectedFillKey guard), `syncBelongsToMany`, `projectRelationFields`, `relationLabels`. - `modules/cabana/list_schema.go`: `columnDocument{Label, Searchable, Sortable, Type, Relation, Select}`, `compileColumns`, `ListColumn`. `modules/cabana/http.go`: `projectRow`, `formSchema` (per-request field filtering into a new slice). `modules/cabana/filter_schema.go`: `validateFilter`, `filterOptions`, `filterProvider` (model only today). `modules/pact/capabilities.go`: `FilterOptions{FilterOptions(scope string) []Option}`. - `modules/cabana/extension.go`: `compilePartials` (headerPartial and `type: partial` names, `cc.formPartials`). `modules/cabana/partial_render.go`: the partial route accepts `?id=` only for names in `cc.formPartials` and loads the record with `readScopedRecord`. - `modules/cabana/messages.go`: `formMessageKeys`, `FormMessages`, `formMessageDefaults`. - SPA: `admin/src/app/router.ts` (`CONTROLLER_ROUTES`, the `record` route), `admin/src/app/winterUrl.ts` (`mapWinterUrl`, the `update/:id` branch, `DIGITS`), `admin/src/views/FormView.vue` (`mode`, `fields`, `tabs`, `recordName`, `redirectTarget`, `onLeave`, `save`, `adopt`), `admin/src/components/form/formState.ts` (`FormMode`, `contextAllows`, `editablePayload`, `initialValues`), `admin/src/components/form/registry.ts` (`renderers`, `valueless`, `groupLabelledTypes`), `admin/src/components/form/control.ts` (`FieldControlProps`, `controlClass`, `controlAttributes`), `admin/src/components/form/fields/TextField.vue`, `DropdownField.vue`, `RelationField.vue`, `admin/src/components/form/FormTabs.vue`, `FormGrid.vue`, `admin/src/components/partial/PartialHost.vue` (props `source`, `name`, `recordId`, `variant`, `reloadKey`), `admin/src/components/list/CellValue.vue` (the Yes and No pills), `admin/src/styles/main.css` (`@theme` tokens, `@layer components` partial style kit). - Reka UI 2.9.10 exports used: RadioGroupRoot, RadioGroupItem, CheckboxRoot, CheckboxIndicator; lucide icons Pencil, ArrowLeft, Lock, Eye, EyeOff, Check, CircleAlert.

Artifacts this phase produces

(This plan's share. The names below are fixed for plans 03 to 05.)

  • pact interfaces: FormVirtualFields{FormVirtualFields() []string}, FormRules{FormRules(ctx context.Context, op string) map[string]string}; the FilterOptions doc comment now says the admin controller may implement it and is asked before the model.
  • cabana types and functions: VirtualFieldsFromContext(ctx) (map[string]any, bool); FormPreview{HeaderPartial} and FormView.Preview (json preview, omitempty); FormMessages.Preview, FormMessages.Edit; FieldPreset{Field, Type} and FormField.Preset; PermissionOption{Code, Label, Tab, Comment, Locked}, FormField.PermissionOptions, PermissionEditorProvider{AdminPermissionOptions, AdminPermissionValues, AdminSetPermissionValues}; FieldRelationContract.WritableForeignKey; RelationLock{IDs []uint; Message string}, RelationLockProvider{AdminRelationLocks(ctx context.Context, field string) (RelationLock, error)}, RelationOption.Locked (json locked, omitempty); ListColumn.Invisible (json invisible, omitempty).
  • YAML keys: config_form.yaml preview: (mapping; optional headerPartial) and messages.preview, messages.edit; fields.yaml types password and permissioneditor, key preset, mode: radio|checkbox on permissioneditor; columns.yaml key invisible.
  • SPA: route preview (/:vendor/:plugin/:controller/:id/preview), PreviewView.vue, PreviewField.vue, PasswordField.vue, PermissionEditorField.vue; FormMode gains preview; mapWinterUrl learns preview/:id; RelationField.vue locked options; partial style kit classes .summer-callout, .summer-callout--warning, .summer-callout--danger, .summer-callout__title, .summer-callout__text; TS aliases FormPreview, FieldPreset, PermissionOption.
  • Phrase keys (en, pl): backend::lang.form.{return_to_preview, locked_item, locked_note, show_password, hide_password}, backend::lang.permissioneditor.{allow, inherit, deny, locked, empty, other}, backend::lang.messages.form.{preview, edit}.
  • Tests and fixture: modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, the extended modules/cabana/testdata/roster tree, admin/tests/smoke/preview.smoke.test.ts, admin/tests/smoke/seams.smoke.test.ts.
  • Release: annotated git tag v0.1.3 on summercms.go.

Planner decisions recorded for this plan

  • Preview view. A separate PreviewView.vue (UI-SPEC leaves the choice open); FormView.vue stays the create and update screen.
  • preview: shape. A mapping; write preview: {} to enable the screen without a hint. An empty (null) preview: is a boot error, so the key can never be half-set by accident.
  • Permission editor storage. The framework lifts, validates and guards the value; reading and writing the model's column goes through the controller (AdminPermissionValues, AdminSetPermissionValues), so the stored JSON shape stays the plugin's decision (D-15 needs PHP's shapes). Stored codes that are not in the option list are kept unchanged.
  • Virtual field values in rules. For a field listed in FormVirtualFields, validation sees the submitted value or nothing; the model column of the same name (for example a password hash) is never read.
  • Filter options on the controller (new, found at planning). pact.FilterOptions has no database handle and was resolved on the model only, so database-backed choices (the Users groups filter, D-23) were not possible. Resolving it on the controller first mirrors DropdownOptionsProvider and adds no exported identifier. It is listed in the tag checkpoint so the user sees it before the contract is published.
  • Spec-less probe fallback: skipped (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" and the UI-SPEC "UI Considerations" rows lifted into must_haves.
Task 1: A list row opens a read-only preview screen with a status hint, preview-only fields, record action buttons and an edit button D-11 (user-confirmed): a new SPA route and screen, a new config_form.yaml key and a new context value that plugin YAML and redirects will depend on. modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/messages.go, modules/cabana/extension.go, modules/cabana/partial_render.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/router.ts, admin/src/app/winterUrl.ts, admin/src/styles/main.css, admin/src/views/PreviewView.vue, admin/src/views/FormView.vue, admin/src/components/form/PreviewField.vue, admin/src/components/form/formState.ts, admin/src/components/partial/PartialHost.vue, admin/tests/smoke/preview.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, admin/tests/fixtures/roster.record.json, modules/boardwalk/dist, docs/backend/forms.md, docs/backend/partials-and-widgets.md, docs/backend/admin-spa.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S2, S3, "Partial style kit addition", Copywriting Contract), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (FormView, router and winterUrl section), .planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/messages.go, modules/cabana/extension.go (compilePartials, compileExtension), modules/cabana/partial_render.go, modules/cabana/crud.go (contextAllows, projectRecord), modules/cabana/phase121_fixture_test.go, modules/cabana/testdata/extension/controllers/gadgets/_stats.htm, admin/src/app/router.ts, admin/src/app/winterUrl.ts, admin/src/views/FormView.vue, admin/src/views/ListView.vue (rowLink), admin/src/components/form/formState.ts, admin/src/components/form/FormGrid.vue, admin/src/components/form/FormTabs.vue, admin/src/components/form/FormField.vue, admin/src/components/form/RecordActions.vue, admin/src/components/partial/PartialHost.vue, admin/src/components/partial/partialNodes.ts, admin/src/components/list/CellValue.vue, admin/src/components/form/fields/DatepickerField.vue and FileuploadField.vue (their read-only modes), admin/src/styles/main.css, admin/tests/app/winterUrl.test.ts, admin/tests/app/router.test.ts, admin/tests/smoke/edit.smoke.test.ts, docs/backend/forms.md, docs/backend/partials-and-widgets.md Per D-11 and D-10; UI-SPEC S2 and S3. Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) Server. form_schema.go: formConfigDocument gains yaml key preview (a mapping with the single optional key headerPartial; a null value is the boot error "preview must be a mapping; write preview: {} to enable the preview screen without a header partial"; any other key is refused by strict decoding; headerPartial must be a bare partial name under the same rule and hint as list headerPartial). FormSchema keeps the preview config; schema_types.go: FormPreview with field HeaderPartial (json headerPartial, omitempty) and FormView.Preview as a pointer (json preview, omitempty), copied by Localize. messages.go: formMessageKeys, FormMessages and formMessageDefaults gain Preview and Edit (yaml and JSON keys preview and edit; defaults backend::lang.messages.form.preview and backend::lang.messages.form.edit). extension.go compilePartials: the preview header partial joins the controller's partial names and formPartials (so the partial route accepts it with a record id and loads the record through the form scope); the same boot errors apply (missing template, no pact.AdminPartialData). compileExtension: a form that declares recordActions without a preview block is the boot error "recordActions needs a preview block (record actions are offered on the preview screen)". A field with context: preview already compiles; no write path may accept it: contextAllows is only ever asked for create or update. Swag: the AdminFormSchema description gains one sentence on preview. Regenerate the OpenAPI outputs; add alias FormPreview to admin/src/api/types.ts.

(2) Phrase keys in en and pl with the UI-SPEC copy: messages.form.preview ("Record preview" / "Podgląd rekordu"), messages.form.edit ("Edit record" / "Edytuj rekord"), form.return_to_preview ("Back to preview" / "Wróć do podglądu").

(3) Fixture: roster config_form.yaml gains a preview block with headerPartial status, messages preview and edit, and create.redirect plus update.redirectClose pointing at acme/roster/people/preview/:id; config_list.yaml recordUrl points at the same preview URL; fields.yaml gains joined_ip (type text, context preview, a new nullable column on rosterPerson); new template controllers/people/_status.htm using the summer-callout classes with role status; the roster controller implements PartialData for status with a curated view model (title and text phrase keys chosen from banned, deleted or inactive; an empty view model when none applies). Smoke test TestPreviewSmoke in modules/cabana/phase121_form_test.go: the form schema carries preview.headerPartial status and the two messages; an update body carrying joined_ip leaves the column unchanged; the show response carries joined_ip; the partial route with the record id renders the callout for a banned person and answers 404 for a person of another tenant; a form YAML with recordActions and no preview fails boot; a null preview fails boot.

(4) SPA routing. router.ts: route path /:vendor/:plugin/:controller/:id/preview with the same digits constraint as the record route, name preview, component PreviewView, meta shell true; CONTROLLER_ROUTES gains preview. winterUrl.ts: a preview/:id branch copying the update branch (digits only, same controller only, anything else falls back to the list); update the header comment table. formState.ts: FormMode gains preview.

(5) SPA screen per UI-SPEC S3. New admin/src/components/form/PreviewField.vue: one field as a labelled pair (dt with weight 600 and no required mark, dd) rendered by type exactly as the S3 table: text, number and dropdown in the read-only box (dropdown shows the option label); textarea in the growing box with preserved line breaks; checkbox and switch as the Yes or No pills from CellValue; datepicker and fileupload in their existing read-only modes; single relation as the box with its label or the muted empty option or dash; multiple relation as wrapping chips without remove buttons or the dash; partial rendered as on a form; permissioneditor rendered through its field component with every control disabled (available after Task 3; until then the type falls through to the read-only box); password, widget and relation-manager fields are not rendered. An empty value shows the muted dash from backend::lang.list.empty_value. New admin/src/views/PreviewView.vue: loads the form schema and the record in parallel; when the schema has no preview it replaces the route with the record route; frame, header (40px outline back button to the list with aria-label backend::lang.form.return_to_list, the record name as title, subtitle from messages.preview, FormTabs when the visible fields declare tabs), status hint slot (PartialHost variant header with the record id and a reload key, between header and card, outside every tab; nothing and no gap when the partial has zero nodes; one 68px skeleton block on the first fetch; the previous hint stays visible on a refetch; the extension failure box on failure), card with the field grid as a dl using the FormGrid column and span rules, and the fixed footer with nothing on the left and, on the right, RecordActions fed from the record response's meta.actions followed by the single primary button (Pencil icon, label from messages.edit, data-action="edit", linking to the record route). Fields shown are those whose context allows preview; a tab with no such field is not rendered. On RecordActions done: success toast, reload the record (values, labels, offered actions) keeping the previous content visible, and bump the hint reload key; on stale: reload the record and the hint; on gone or a failed load: the existing load-failure alert with backend::lang.form.load_failed, the back button still works. While an action runs every footer button is disabled. The footer wraps; buttons never shrink. PartialHost.vue changes only as far as the skeleton and keep-previous behaviour of the header variant on this screen need. FormView.vue: when the schema has a preview and the mode is update, the back arrow and Cancel go to the preview route (aria-label backend::lang.form.return_to_preview) with the unsaved-changes confirm as today; after a delete the form still goes to the list.

(6) Partial style kit: admin/src/styles/main.css @layer components gains the five summer-callout classes with exactly the rules of the UI-SPEC table (they read only the public colour variables; no new token).

(7) SPA smoke test admin/tests/smoke/preview.smoke.test.ts with fixtures roster.form-schema.json and roster.record.json: the preview route renders the title, the dl grid, the preview-only field and no input for it; the footer shows the offered record action before the primary edit button, and only the edit button when none is offered; an empty value shows the dash; mapWinterUrl maps the fixture's preview/:id; a schema without preview replaces the route with the record route; on the update form with a preview the back button leads to the preview route.

(8) Docs and READMEs in the same commit: docs/backend/forms.md new section "Preview screen" (the preview block, context preview, messages preview and edit, redirects, where record actions appear, a YAML fence that is a src= reference to the roster config_form.yaml); docs/backend/partials-and-widgets.md and the "Partial style kit" section of modules/cabana/README.md (the summer-callout classes and the recommended markup, written as a src= fence of the fixture template); docs/backend/admin-spa.md (the preview route). Rebuild and commit modules/boardwalk/dist. go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/cabana -run '^(TestPreview|TestRecordAction|TestPhase10OpenAPIConformance|TestPhase101)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestPreviewSmoke" or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> Run the development application against this framework tree, open a list whose recordUrl points at preview, click a row, read the screen in light and dark mode, run a record action, then press the edit button and Cancel. The preview matches UI-SPEC S3 (title, hint callout as the only tinted block, dl grid, one primary button at the bottom right), the record action reloads the record in place without a skeleton flash, and Cancel on the update form returns to the preview. <why_human>Visual fit with Direction C and the in-place reload feel cannot be asserted by unit tests.</why_human> <acceptance_criteria> - go test ./modules/cabana -run '^TestPreviewSmoke$' -count=1 -v prints "--- PASS: TestPreviewSmoke"; the test asserts that a context: preview field is not written by an update and that recordActions without preview fails boot. - grep -c "name: 'preview'" admin/src/app/router.ts prints 1 and grep -c "'preview'" admin/src/app/winterUrl.ts prints at least 1. - grep -c 'summer-callout--warning' admin/src/styles/main.css prints 1. - grep -c 'return_to_preview' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. - test -f admin/src/views/PreviewView.vue &amp;&amp; test -f admin/src/components/form/PreviewField.vue succeeds and npm --prefix admin test -- tests/smoke/preview reports the smoke file passed. - scripts/check-admin-dist.sh prints "modules/boardwalk/dist matches a fresh build". </acceptance_criteria> A plugin can give a form a preview screen: rows open it, it shows the record read-only with a status hint and the record actions of plan 01, and its edit button leads to the update form.

Task 2: A form takes a password with confirmation and form-only fields that reach the plugin's hooks, validated by the controller's own rules, and a text field presets from another D-27 G1, G2, G7 and D-28 G5 (user-confirmed): two new field types and keys in the typed schema and two new pact interfaces. modules/pact/capabilities.go, modules/pact/capabilities_test.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/tx_context.go, modules/cabana/extension.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/components/form/fields/PasswordField.vue, admin/src/views/FormView.vue, admin/tests/smoke/seams.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, modules/boardwalk/dist, docs/backend/forms.md, docs/backend/admin-controllers.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S7), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("Framework Gaps Beyond CONTEXT.md" G1, G2, G5, G7; Pitfalls 1 and 2), modules/pact/capabilities.go (the Form hooks), modules/cabana/crud.go (BindWritableFields, save, mergedRules, valuesForRules, projectRecord, projectOperation, fillAllowed, scalarFormField, protectedFillKey), modules/cabana/tx_context.go, modules/cabana/form_schema.go (formFieldTypes, formFieldKeys, compileFieldNode), modules/cabana/schema_types.go (FormField), modules/cabana/extension.go, modules/lagoon/validate.go (the supported rule tokens and how `confirmed` and `unique` read values), modules/cabana/crud_lifecycle_test.go, admin/src/components/form/fields/TextField.vue, admin/src/components/form/control.ts, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/views/FormView.vue, admin/tests/form/fields.test.ts, docs/backend/forms.md, docs/backend/admin-controllers.md Per D-27 (G1, G2, G7), D-28 (G5) and D-19; UI-SPEC S7. Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) pact: interface FormVirtualFields with FormVirtualFields() []string (field names of the controller's form that are not model columns for the purpose of the form: they are never bound, filled or projected, and their submitted values reach the Form hooks through cabana.VirtualFieldsFromContext); interface FormRules with FormRules(ctx context.Context, op string) map[string]string where op is create or update (when implemented, the returned set replaces the model's Rules() for admin saves; rule strings use the tokens lagoon.Validate supports; rules may name virtual fields).

(2) Field type password (G1). form_schema.go: password joins formFieldTypes; it is not a scalar form field for binding. extension.go: a password field the controller does not list in FormVirtualFields is the boot error "field NAME: type password needs the controller to list it in FormVirtualFields"; a field listed in FormVirtualFields that is not in fields.yaml, or whose type is not one of password, text, textarea, number, checkbox, switch or dropdown, is a boot error naming the field. A password value is never part of a record response on any route and never a fill key.

(3) Virtual fields (G2). contracts.go: CompiledController gains the set of virtual field names. crud.go BindWritableFields: a virtual field is skipped (no column check, no writable binding), so Fill never receives it and projectRecord never returns it. save: before the transaction, collect the submitted values of virtual fields whose context allows the operation and that are present in the body; a nested (object or list) value is validation_failed on that field with the text "The NAME field has an invalid value."; place the resulting map on the transaction context. tx_context.go: VirtualFieldsFromContext(ctx) (map[string]any, bool) next to TxFromContext, returning a copy; it is false outside a create or update save. The Form hooks (before and after create and update) read it from the context they already receive.

(4) Rules per operation (G5). crud.go mergedRules: when the controller implements pact.FormRules its result for the operation is the base set instead of the model's Rules(); the form's required flags still merge for scalar fields and now also for virtual fields whose context allows the operation. valuesForRules: for a field in the virtual set the value is the submitted virtual value, or absent when it was not submitted; the model column of the same name is never read for it. Everything else in the pipeline is unchanged; newWritableModel still requires the model to implement Rules().

(5) Preset (G7). form_schema.go: preset joins formFieldKeys; accepted as a string (the source field name; type slug) or a mapping with keys field and type; type is slug or exact, anything else is the boot error "preset type NAME is not supported (want slug or exact)"; preset on a type other than text is "preset is only valid on type: text"; extension.go checks that the source is a text field of the same form and not the field itself. schema_types.go: FieldPreset (Field json field, Type json type) and FormField.Preset as a pointer with omitempty.

(6) Regenerate the OpenAPI outputs; add alias FieldPreset to admin/src/api/types.ts.

(7) Fixture and smoke tests. rosterPerson gains a password column (a stored hash, json "-") and a slug column; fields.yaml gains password (type password, context create and update), password_confirmation (type password, context create and update), notify (type checkbox, default true, context create) and slug (type text, preset name); the roster controller lists password, password_confirmation and notify in FormVirtualFields, implements FormRules (create: password required, between 8 and 255, confirmed; update: password nullable, between 8 and 255, confirmed; name required) and in FormBeforeCreate and FormBeforeUpdate stores a derived hash when a password was submitted and records notify in the spy. TestPasswordFieldSmoke (create with matching passwords answers 201, the stored hash differs from the plain text, no response on create, show, update or list contains the key password or the plain text; a mismatch answers 422 on password), TestVirtualFieldsSmoke (the hook saw notify true on create; notify sent on update never reaches the hook because its context is create; a nested value answers 422; none of the three names appears in any response), TestFormRulesSmoke (an update changing only name answers 200 although the model's own Rules would demand a confirmed password; a virtual field not listed by the controller fails boot), TestPresetSchema (the form schema carries preset field name and type slug; an unsupported type and a non-text target fail boot). modules/cabana/example_form_seams_test.go: a compiled example controller with FormVirtualFields and FormRules, for the docs.

(8) SPA per UI-SPEC S7. New admin/src/components/form/fields/PasswordField.vue: a relative wrapper, an input of type password with controlClass plus the input height and right padding 12, autocomplete new-password and spellcheck false, and the 32 by 32 show/hide toggle inside the right edge with the Eye and EyeOff icons, aria-pressed and the labels backend::lang.form.show_password and hide_password, reachable by Tab after the input. registry.ts: password is a plain value field (one registry line; extend the header comment with a Phase 12.1 sentence). formState.ts editablePayload: an empty password value is not sent on update and is sent as entered on create. FormView.vue: after a successful save every password field value is cleared and its control returns to hidden; a non-empty password marks the form dirty; the SPA never compares password and confirmation. Preset: on create only, while the target text field has not been edited by hand, each input on the source field sets the target (type slug: lower-case ASCII, every run of other characters becomes one hyphen, hyphens trimmed at both ends; type exact: the same text); the first manual edit of the target stops it for the session; an empty source leaves the target empty; on update nothing is filled; no visual indicator. Phrase keys form.show_password and form.hide_password in en and pl. Smoke test admin/tests/smoke/seams.smoke.test.ts: the password field renders empty although the record fixture is loaded, toggling shows the text, an empty password is absent from the update body, both password fields are empty after a successful save; typing a name on create fills slug with the slugged text until slug is edited by hand; on update slug does not follow.

(9) Docs and READMEs in the same commit: modules/pact/README.md (FormVirtualFields, FormRules); modules/cabana/README.md (Features, API reference: VirtualFieldsFromContext, FieldPreset); docs/backend/forms.md (field types table rows for password, a section "Form-only fields" on virtual fields, the preset key, and "What a save may write" extended: virtual fields are never written by the framework); docs/backend/admin-controllers.md Hooks section (reading virtual values, rules per operation, with src= fences from example_form_seams_test.go). Rebuild and commit modules/boardwalk/dist. go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestPasswordField|TestVirtualFields|TestFormRules|TestPreset|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the named cabana run lacks any of "--- PASS: TestPasswordFieldSmoke", "--- PASS: TestVirtualFieldsSmoke", "--- PASS: TestFormRulesSmoke", "--- PASS: TestPresetSchema" or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/pact FormVirtualFields, go doc ./modules/pact FormRules and go doc ./modules/cabana VirtualFieldsFromContext each print a declaration. - go test ./modules/cabana -run '^TestPasswordFieldSmoke$' -count=1 -v prints "--- PASS: TestPasswordFieldSmoke"; the test asserts that no create, show, update or list response body contains the submitted plain-text password or a password key. - go test ./modules/cabana -run '^TestFormRulesSmoke$' -count=1 -v prints "--- PASS: TestFormRulesSmoke"; the test asserts that an update of name alone answers 200. - grep -c "\['password', PasswordField\]" admin/src/components/form/registry.ts prints 1. - grep -c 'hide_password' modules/phrasebook/backend/lang/en/lang.yaml prints 1. - npm --prefix admin test -- tests/smoke/seams reports the smoke file passed. - The protected fill key list in modules/cabana/crud.go still contains password, permissions, is_activated and organisation_id (go test ./modules/cabana -run '^TestCRUD' -count=1 passes unchanged). </acceptance_criteria> A plugin form can collect a password with confirmation and other form-only values, validate them with rules of its own per operation, and receive them in its hooks; none of them is ever written or returned by the framework itself.

Task 3: A form shows a permission editor in radio or checkbox mode, and the server stores only codes and values the controller offers D-16 (user-confirmed): a new field type in the typed schema and the generated TS types, built to be reused for backend role permissions. modules/cabana/field_permission.go, modules/cabana/field_file.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/contracts.go, modules/cabana/crud.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/registry.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/components/form/PreviewField.vue, admin/tests/smoke/seams.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, admin/tests/fixtures/roster.record.json, modules/boardwalk/dist, docs/backend/forms.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S5, Color, Typography, Spacing Scale), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (field_permission.go and form_schema.go section; PermissionEditorField analog), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Pitfalls 5 and 10), modules/cabana/field_date.go, modules/cabana/field_file.go (compileFileuploadKeys and the mode rule), modules/cabana/relation_field.go (liftRelationValues, checkRelationScope), modules/cabana/crud.go (save, projectFullRecord), modules/cabana/http.go (formSchema), modules/cabana/registry.go (compileRegistry order), admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/WidgetField.vue (a group-labelled control), admin/src/components/form/control.ts, admin/src/components/form/registry.ts, admin/src/components/form/FormField.vue, admin/src/components/list/DataTable.vue (the checkbox box classes), docs/backend/forms.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/formwidgets/FrontendPermissionEditor.php and its partials (read-only reference for grouping and modes) Per D-16; UI-SPEC S5; RESEARCH Pitfall 10. Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) Schema. form_schema.go: permissioneditor joins formFieldTypes. New modules/cabana/field_permission.go holds the type's code, in the one-file-per-type pattern of field_date.go: compilePermissionKeys(typ, values, field) called from compileFieldNode (on a permissioneditor field mode is required and must be radio or checkbox, message "mode must be radio or checkbox on type: permissioneditor"; the keys options, default, nameFrom, emptyOption, relation and preset are refused on it); the rule in field_file.go that refuses mode becomes "mode is only valid on type: fileupload, datepicker or permissioneditor". Exported types: PermissionOption with Code, Label, Tab, Comment (strings) and Locked (bool; json keys code, label, tab, comment, locked with omitempty on tab, comment and locked); interface PermissionEditorProvider with three methods: AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error), AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error) and AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error. compilePermissionFields(pluginID, cc) (called from compileRegistry next to compileDateFields) stops boot when a form has a permissioneditor field and the controller does not implement PermissionEditorProvider. schema_types.go: FormField gains PermissionOptions []PermissionOption (json permissionOptions, omitempty).

(2) Per-request options. http.go formSchema: for each permissioneditor field call AdminPermissionOptions with the request context, localize Label, Tab and Comment, and set the options on the localized copy in a new slice (the cached schema is never mutated); a provider error answers the generic 500. Options keep the order the controller returns.

(3) Save path in crud.go and field_permission.go. liftPermissionValues(cc, body, op): for each permissioneditor field present in the body and allowed by its context, the value must be a JSON object of code to integer (integers may arrive as numbers; strings, booleans, fractions, nested values or a non-object are validation_failed on the field with the text "The NAME field must be an object of permission codes."); the lifted value never goes through scalar projection. Inside the transaction, after the Form before-hooks and before the row write: load the options, then check every submitted code is offered (else 422 "The NAME field contains an unknown permission.") and every value is in the mode's set (radio: 1 or -1, a 0 is treated as absent; checkbox: 1, a 0 is treated as absent; anything else 422 "The NAME field contains an invalid value."); read the stored values through AdminPermissionValues (an empty map on create); for every option marked Locked the stored and the submitted value must be equal, else return a ForbiddenError with Details on the field; build the next set as the stored codes that are not offered plus the submitted codes, and hand it to AdminSetPermissionValues before tx.Save or tx.Create. Projection: projectFullRecord sets data[field] from AdminPermissionValues for every permissioneditor field, on show, create and update, as an object (never null). A field absent from the body is left untouched.

(4) Regenerate the OpenAPI outputs; add alias PermissionOption to admin/src/api/types.ts.

(5) Fixture and smoke test. rosterPerson gains a nullable text column permissions; fields.yaml gains permissions (type permissioneditor, mode radio, tab, context update); the roster controller implements PermissionEditorProvider with four options in two tabs plus one without a tab, one of them locked for the limited admin, storing the map as a JSON object text in the column. TestPermissionEditorSmoke: the form schema carries the options with localized labels and the locked flag only for the limited admin; an update with allowed codes stores the JSON object and the show response returns it; an unknown code answers 422; a value of 2 answers 422; a non-object answers 422; changing the locked code as the limited admin answers 403 and the column is unchanged; a stored code that is not offered survives an update; a permissioneditor field without mode fails boot; a controller without the provider fails boot.

(6) SPA per UI-SPEC S5. New admin/src/components/form/fields/PermissionEditorField.vue: a container with the S5 border classes (danger border when invalid) and role group named by the field label; options grouped by tab into sections in the order the server sends, options without a tab in a last section labelled backend::lang.permissioneditor.other; each section header as in S5 (an h3; in checkbox mode the right side shows the column heading backend::lang.permissioneditor.allow); each permission row with label, optional comment and the control; radio mode: one Reka RadioGroupRoot per row styled as the three-segment control Allow (1), Inherit (0), Deny (-1) with the selected styles of the S5 table, a code missing from the value shown as Inherit; checkbox mode: one Reka CheckboxRoot per row with the list checkbox classes and the row label as its label; a locked option has a disabled control, a Lock icon after the label and the text backend::lang.permissioneditor.locked in place of the comment, and still shows its stored value; read-only (the preview, or attributes readonly true) disables every control and shows no lock icons; no options: the read-only box with backend::lang.permissioneditor.empty; below 640px the control wraps under the label; no inner scroll and no sticky headers. Emitted value: an object of code to integer; radio emits 1 or -1 and omits an inherited code; checkbox emits 1 and omits an unchecked code; codes that are not in the option list are never emitted; a locked row never changes. Any change marks the form dirty and clears the field's error. registry.ts: permissioneditor joins renderers and groupLabelledTypes and is not valueless. PreviewField.vue renders the type through this component with every control disabled. Phrase keys backend::lang.permissioneditor.allow, inherit, deny, locked, empty and other in en and pl with the UI-SPEC copy. Extend admin/tests/smoke/seams.smoke.test.ts: sections render by tab; choosing Deny on one row and saving sends that code with -1 and omits inherited codes; a locked row is disabled; an empty option list shows the empty text.

(7) Docs and READMEs in the same commit: modules/cabana/README.md (Features, API reference: PermissionOption, PermissionEditorProvider); docs/backend/forms.md new section "Permission editor" (the type, the two modes and their value sets, the provider's three methods with a src= fence from example_form_seams_test.go, locked options, kept unknown codes, the wire shape). Rebuild and commit modules/boardwalk/dist. go vet ./modules/cabana/ && go test ./modules/cabana -run '^(TestPermissionEditor|TestPhase101|TestPhase10OpenAPIConformance|TestDatepicker|TestFileupload)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check <fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestPermissionEditorSmoke" or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/cabana PermissionEditorProvider prints an interface with AdminPermissionOptions, AdminPermissionValues and AdminSetPermissionValues. - go test ./modules/cabana -run '^TestPermissionEditorSmoke$' -count=1 -v prints "--- PASS: TestPermissionEditorSmoke"; the test asserts 422 for an unknown code and for a value outside the mode's set, and 403 with an unchanged column for a changed locked code. - grep -c "\['permissioneditor', PermissionEditorField\]" admin/src/components/form/registry.ts prints 1. - grep -c 'permissionOptions' admin/src/api/schema.d.ts prints at least 1. - grep -c 'permissioneditor:' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. - The Phase 10.1 widget tests pass unchanged (go test ./modules/cabana -run '^TestPhase101' -count=1), proving the widget fill contract was not widened. </acceptance_criteria> A plugin can put a tabbed permission editor on a form; the admin edits allow, deny and inherit (or allow) per permission, and the server stores only offered codes with allowed values.

Task 4: A foreign-key picker can be made writable, relation options can be locked for an admin and the server enforces the lock, list columns can be hidden but searchable, and filter choices can come from the controller D-27 G3, G4, G6 (user-confirmed): the relation field contract and RelationOption grow; `locked` reaches the generated TS types. The filter-options resolution adds no identifier. modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/filter_schema.go, modules/cabana/query.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/fields/RelationField.vue, admin/src/components/form/PreviewField.vue, admin/src/components/list/DataTable.vue, admin/tests/smoke/seams.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, admin/tests/fixtures/roster.list-schema.json, modules/boardwalk/dist, docs/backend/relation-manager.md, docs/backend/lists-and-filters.md .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S6 "Locked options in RelationField"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (relation_field.go section), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("T-12-18: Every users_groups Write Path" path 1; gaps G3, G4, G6), modules/cabana/relation_field.go, modules/cabana/crud.go (save), modules/cabana/list_schema.go (columnDocument, compileColumns), modules/cabana/http.go (projectRow), modules/cabana/query.go (applyListSearch, normalizeSort), modules/cabana/filter_schema.go (validateFilter, filterOptions, filterProvider), modules/cabana/filter_options_test.go, modules/cabana/relation_field_test.go, admin/src/components/form/fields/RelationField.vue, admin/src/components/list/DataTable.vue, admin/tests/form/RelationField.test.ts, docs/backend/relation-manager.md, docs/backend/lists-and-filters.md Per D-27 (G3, G4, G6), D-07 and D-22; UI-SPEC S6. Repo: summercms.go; one code commit with generated outputs, README and docs.

(1) Writable foreign key (G3). relation_field.go: FieldRelationContract gains WritableForeignKey bool with a one-line doc comment ("declares a belongsTo foreign key that is a protected fill key writable through this relation field"); in compileFieldRelation the read-only decision becomes: read-only when the foreign key is a protected fill key and WritableForeignKey is false; setting it on a belongsToMany contract is the boot error "WritableForeignKey is only valid on belongsTo"; assignBelongsTo's own guard changes the same way, so the two never disagree. The protected fill key list is not edited, and a scalar field with a protected name stays unwritable. Submitted ids are still revalidated through the scoped options query.

(2) Locked options (G4). relation_field.go: RelationLock with IDs []uint (related ids the current admin may not add or remove) and Message string (a phrase key or text for the 403; may be empty); interface RelationLockProvider with AdminRelationLocks(ctx context.Context, field string) (RelationLock, error), documented as: asked per request with the request context (the principal is read with bouncer.User, the write transaction with TxFromContext when there is one); the lock is enforced on save, the flag on options is a display aid. RelationOption gains Locked bool with json key locked and omitempty, so responses without locks keep their bytes. RelationOptions marks locked rows; projectRelationFields marks locked labels on the show, create and update responses. New checkRelationLocks(ctx, tx, cc, model, values) called in save after checkRelationScope and before assignBelongsTo, on create and on update: for a belongsToMany value read the parent's current related ids from the pivot (none on create) and compare the locked subset before and after as sets; for a belongsTo value compare the current and the submitted id and refuse when they differ and either is locked; on a difference return a ForbiddenError whose Message is the lock's Message and whose Details name the field with that message (or with the phrase key backend::lang.form.forbidden when the lock gave none). Nothing is written before the check, and the surrounding transaction rolls back. A provider error is a lifecycle failure. A relation field absent from the body is not checked (nothing changes). A controller without the provider behaves exactly as before.

(3) Invisible columns (G6). list_schema.go: columnDocument gains yaml key invisible; schema_types.go ListColumn gains Invisible bool (json invisible, omitempty); http.go projectRow leaves invisible columns out of the row; search and sort treat the column as before (a searchable invisible column is searched). The SPA DataTable renders only columns that are not invisible; the first rendered column is the first visible one.

(4) Filter choices from the controller. filter_schema.go: the options handler and validateFilter ask the controller first when it implements pact.FilterOptions and fall back to the model; the scope itself is still the model's pact.FilterScope. pact/capabilities.go: the FilterOptions doc comment says so. No new identifier.

(5) Regenerate the OpenAPI outputs.

(6) Fixture and smoke tests. The roster model gains a nullable organisation_id (a protected fill key) pointing at a rosterTeam model, and a belongsToMany tags relation through a pivot model; fields.yaml gains team (type relation, belongsTo) and tags (type relation, belongsToMany); the controller sets WritableForeignKey on the team contract and implements RelationLockProvider locking the tag named "staff" for the limited admin with a phrase-key message; columns.yaml gains email with invisible true and searchable true; a filter tagged with a scope whose choices the controller serves from the database. TestWritableForeignKeySmoke (the team field is not read-only, a save sets organisation_id, an id outside the options scope answers 422, and the same contract without the flag compiles read-only), TestRelationLockSmoke (options and labels carry locked for the limited admin only; as the limited admin adding the locked tag on update answers 403 with details on tags and the pivot is unchanged; removing it answers 403; creating a record with it answers 403 and no row is created; changing only unlocked tags while keeping the locked one answers 200; the full admin may change it), TestInvisibleColumnSmoke (the schema flags the column, rows carry no email key, a search by email finds the row), TestFilterOptionsController (the options route returns the controller's choices; a model-only provider still works).

(7) SPA per UI-SPEC S6. RelationField.vue: in multiple mode a selected and locked chip has no remove button, shows a Lock icon in the 24px slot and carries visually hidden text from backend::lang.form.locked_item; Backspace in the empty search input removes the last unlocked chip or does nothing; in the listbox a locked, unselected option is aria-disabled, muted, shows a Lock icon at the right edge, has no hover highlight, is not chosen by click or Enter and is skipped by arrow keys; in single mode a locked current value renders the read-only box with a trailing Lock; when at least one locked option was seen, one note line with a Lock icon and backend::lang.form.locked_note shows under the control and its id joins aria-describedby. PreviewField keeps showing chips without remove buttons. Phrase keys form.locked_item and form.locked_note in en and pl with the UI-SPEC copy. Extend admin/tests/smoke/seams.smoke.test.ts: a locked chip has no remove button and survives Backspace; a locked option is not selectable; the note shows; an invisible column renders no header and no cell.

(8) Docs and READMEs in the same commit: modules/pact/README.md (the FilterOptions note); modules/cabana/README.md (API reference: WritableForeignKey, RelationLock, RelationLockProvider); docs/backend/relation-manager.md "Relation fields" (writable foreign keys, locked options and the 403, with src= fences from example_form_seams_test.go); docs/backend/lists-and-filters.md (the invisible column key; filter choices from the controller). Rebuild and commit modules/boardwalk/dist. go vet ./... && go test ./modules/cabana -run '^(TestWritableForeignKey|TestRelationLock|TestInvisibleColumn|TestFilterOptionsController|TestPhase10FilterOptions|TestPhase10Relation|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... <fails_when>Any command exits non-zero; the named cabana run lacks any of "--- PASS: TestWritableForeignKeySmoke", "--- PASS: TestRelationLockSmoke", "--- PASS: TestInvisibleColumnSmoke", "--- PASS: TestFilterOptionsController", "--- PASS: TestPhase10RelationOptions", "--- PASS: TestPhase10RelationSave", "--- PASS: TestPhase10RelationForgedID", "--- PASS: TestPhase10RelationBoot" or prints "no tests to run"; the full go test run prints a line starting with "FAIL"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when> <acceptance_criteria> - go doc ./modules/cabana RelationLockProvider and go doc ./modules/cabana RelationLock each print a declaration, and go doc ./modules/cabana FieldRelationContract lists WritableForeignKey. - go test ./modules/cabana -run '^TestRelationLockSmoke$' -count=1 -v prints "--- PASS: TestRelationLockSmoke"; the test asserts 403 and an unchanged pivot for adding and for removing a locked id on update, 403 and no new row on create, and 200 for a change that leaves the locked subset alone. - go test ./modules/cabana -run '^TestPhase10Relation' -count=1 -v prints "--- PASS: TestPhase10RelationOptions", "--- PASS: TestPhase10RelationSave", "--- PASS: TestPhase10RelationForgedID" and "--- PASS: TestPhase10RelationBoot" and does not print "no tests to run": the existing relation field tests in modules/cabana/relation_field_test.go pass with their assertions unchanged, so relation responses keep their bytes when nothing is locked. - grep -c 'locked?' admin/src/api/schema.d.ts prints at least 1 and grep -c 'invisible?' admin/src/api/schema.d.ts prints at least 1. - grep -c 'locked_note' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. - go -C ../fonoteka.go build ./... exits 0 (the application still compiles against the framework working tree). </acceptance_criteria> A plugin can let admins pick a record for a protected foreign key, lock chosen relation options per admin with the server refusing any change to them, hide search-only columns, and serve filter choices from its database.

Task 5: Decide how the framework contract of this phase is published as tag v0.1.3 Publish the framework work of plans 01 and 02 as the annotated tag v0.1.3 (D-25). The tag fixes the names and shapes listed below for every application that builds on it. One-way door: once v0.1.3 is pushed, other projects may build on it, and renaming any of these would break their YAML, their Go code or their role rows. What the tag publishes: pact `AdminBulkAction`, `HasAdminBulkActions`, `AdminRecordAction`, `HasAdminRecordActions`, `RowState`, `ListRowStates`, `FormVirtualFields`, `FormRules`; cabana `ForbiddenError`, `BulkActionResult`, `RecordAction`, `VirtualFieldsFromContext`, `PermissionOption`, `PermissionEditorProvider`, `RelationLock`, `RelationLockProvider`, `FieldRelationContract.WritableForeignKey`, `FieldPreset`, `FormPreview`; YAML keys `bulkActions`, `recordActions`, `preview`, `invisible`, `preset`, field types `password` and `permissioneditor`, list messages `rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`, form messages `preview`, `edit`; routes `POST .../{controller}/bulk/{action}` and `POST .../{controller}/{id}/actions/{action}`; response fields `meta.row_states`, `meta.actions`, `locked`; the SPA route `.../{id}/preview`. One item was not in the confirmed list G1 to G7 and was found at planning: a controller may now serve scope-filter choices (`pact.FilterOptions` is asked on the controller before the model); it adds no identifier and is needed for the Users `groups` filter (D-23). Bulk and record actions have separate name spaces per kind (D-14 needs `activate` and `unban` in both). Undo cost after a push: a new tag (v0.1.4) and a migration note for every consumer; a local tag can still be deleted and recreated. The executor creates the annotated tag v0.1.3 on the green head and pushes master and the tag to origin The plugin plans start from a published baseline; nothing is left pending. The contract is public at once; a later rename needs v0.1.4. The executor creates the annotated tag locally and does not push; the user pushes later Plans 03 to 05 can proceed (the application uses a local replace), and names can still be corrected by moving the local tag before it is pushed. The push stays a pending release step that someone must remember. While the tag is not on origin, the sm-user-plugin push at the end of plan 05 is skipped and recorded as pending too: a published plugin commit must not depend on an unpublished framework contract. Do not tag now Time to review or rename the contract first. Plan 03 has the tag as a precondition and stops until the tag exists. .planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md, .planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md (D-25, D-27), modules/cabana/README.md (API reference), modules/pact/README.md (API reference), docs/backend/forms.md, docs/backend/admin-controllers.md - The user answered with one of the option ids; the answer is recorded in the plan summary. - `git tag --list v0.1.3` prints nothing when the checkpoint is presented (nothing was tagged before the answer). Answer tag-and-push, tag-local or hold. With hold, say what should change before the tag. Task 6: The framework head is proven green and tagged v0.1.3 A pushed v0.1.3 is a published contract: consumers pin it, and any later rename of a pact or cabana name, YAML key or route needs a new tag and a migration for each consumer. The user answered tag-and-push or tag-local at Task 5; with hold this task is not run and the plan ends with the tag recorded as pending in the summary and in STATE.md. modules/boardwalk/dist .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Pitfall 13 "Submodule and tag ordering"), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-05-SUMMARY.md (how v0.1.1 was released) Per D-25 and the answer of Task 5. Repo: summercms.go. No source change is expected in this task; if a gate below fails, fix the cause in its own code commit (with its generated outputs) and run every gate again before tagging.

(1) Confirm the working tree has no uncommitted code change (git status --short shows only planning files, if any) and record the head sha.

(2) Run every gate on that head: the framework full suite (go vet and go test over the whole module), the SPA typecheck and tests, the OpenAPI drift check, the dist check, the docs tree test and summer docs:build --check, the three contract tests, and the application's build, vet and full test suite against this tree (the application resolves the framework through its local replace).

(3) Create the annotated tag on the recorded head: name v0.1.3, message "SummerCMS v0.1.3: bulk and record actions, preview screen, row state, permission editor, form seams". Planning-doc commits made after this point do not move the tag.

(4) With the answer tag-and-push: push master and the tag to origin and confirm the remote lists the tag. With tag-local: do not push; record "push master and v0.1.3" as a pending release step in the summary and in STATE.md, and record next to it that the sm-user-plugin push of plan 05 Task 3 waits for it (that push runs only when git ls-remote --tags origin v0.1.3 lists the tag).

(5) Record in the summary: the tagged sha, the option chosen, and the measured run time of each gate (for VALIDATION.md). go vet ./... && go test ./... -count=1 && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1 && test "$(git tag --list v0.1.3)" = "v0.1.3" && test "$(git cat-file -t v0.1.3)" = "tag" && git merge-base --is-ancestor v0.1.3 HEAD <fails_when>Any command exits non-zero; a go test run prints a line starting with "FAIL"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found"; git tag --list v0.1.3 prints nothing; git cat-file -t v0.1.3 prints "commit" (a lightweight tag).</fails_when> <acceptance_criteria> - git tag --list v0.1.3 prints v0.1.3, git cat-file -t v0.1.3 prints tag, and git rev-parse 'v0.1.3^{commit}' equals the head sha recorded in step 1. - scripts/check-admin-dist.sh and scripts/check-admin-openapi.sh --check both succeed on the tagged commit. - With tag-and-push: git ls-remote --tags origin v0.1.3 lists the tag. With tag-local: the summary and STATE.md name the pending push and state that the sm-user-plugin push of plan 05 waits for it. - git tag --list 'v0.1.*' still lists v0.1.1 and v0.1.2 unchanged. </acceptance_criteria> The framework half of the phase is released as v0.1.3 (pushed, or local with the push recorded as pending), and the plugin plans have a fixed contract to build on.

<threat_model>

Trust Boundaries

Boundary Description
Admin browser → admin API (save bodies) Untrusted field values, virtual values, permission maps and relation ids cross here
cabana → plugin hooks and providers The framework hands hooks only validated values; providers decide options and locks per principal
Plugin partial templates → admin browser The status hint is server-rendered through the partial allowlist
Repository → consumers of the tag v0.1.3 publishes a contract other applications build on

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-12.1-09 Tampering mass assignment through virtual or password fields high mitigate Virtual fields are never bound, filled or projected; their values reach hooks only through VirtualFieldsFromContext and only when the field's context allows the operation; the protected fill key list is unchanged (Task 2, TestVirtualFieldsSmoke).
T-12.1-10 Information Disclosure password value in a response, log or URL high mitigate password is never projected on any route; the SPA sends it only in the save body, clears it after a save and never writes it to a toast or the URL (Task 2, TestPasswordFieldSmoke).
T-12.1-11 Elevation of Privilege protected foreign key written through a relation field high mitigate Writable only with an explicit WritableForeignKey on the contract; ids are revalidated through the scoped options query; scalar fields with protected names stay unwritable (Task 4, TestWritableForeignKeySmoke).
T-12.1-12 Elevation of Privilege locked relation ids changed by a crafted request (mechanism behind T-12-18) high mitigate checkRelationLocks runs on create and update before any row write and returns ForbiddenError; the transaction rolls back; the SPA lock is display only (Task 4, TestRelationLockSmoke).
T-12.1-13 Tampering permission code injection or out-of-range value high mitigate Codes must be in the controller's option list, values in the mode's set, locked codes unchanged (403); stored codes that are not offered are kept, never invented (Task 3, TestPermissionEditorSmoke).
T-12.1-14 Tampering a preview-only field written by a crafted body medium mitigate Writes ask contextAllows only for create or update; TestPreviewSmoke posts the field and asserts the column unchanged (Task 1).
T-12.1-15 Elevation of Privilege script or markup through the status hint or callout classes medium mitigate The hint is rendered through the existing partial allowlist as nodes, never as an HTML string; the callout kit is plain CSS classes; server text is text (Task 1).
T-12.1-16 Tampering open redirect or foreign route through preview/:id in plugin YAML medium mitigate mapWinterUrl emits only the current controller's routes, digits only, and falls back to the list (Task 1, winterUrl cases in the smoke test).
T-12.1-17 Tampering an unreviewed contract published by the tag high mitigate Blocking decision checkpoint listing the published names (Task 5); the tag is created only on a head where every gate passed (Task 6).
T-12.1-SC Tampering npm/pip/cargo installs high mitigate No package is installed or bumped by this plan: no dependency line of go.mod or admin/package.json is added or changed by its commits; any need for one stops at a blocking human checkpoint.
</threat_model>
- `go vet ./... && go test ./... -count=1` green in summercms.go after every task commit; `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green before the tag. - `npm --prefix admin run typecheck && npm --prefix admin test` green; `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` clean after every task commit. - `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` pass. - `git tag --list v0.1.3` prints v0.1.3 and it is an annotated tag on a commit where all of the above passed.

<success_criteria>

  • The preview screen, the permission editor and the seams G1 to G7 exist with the names in "Artifacts this phase produces" and behave as the D-11, D-16, D-27 and D-28 truths state.
  • The SPA matches UI-SPEC S2, S3, S5, S6 and S7.
  • READMEs, docs, the OpenAPI document, the TS types and dist/ match the code at the tagged commit; no consuming application is named; no dependency changed.
  • v0.1.3 exists as an annotated tag (pushed or recorded as pending per the user's answer). </success_criteria>
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md` when done. Record the final contract names, the tag sha, the option chosen at Task 5 and the measured gate run times.