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 |
|
|
false |
|
|
|
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>
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}; theFilterOptionsdoc 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}andFormView.Preview(jsonpreview, omitempty);FormMessages.Preview,FormMessages.Edit;FieldPreset{Field, Type}andFormField.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(jsonlocked, omitempty);ListColumn.Invisible(jsoninvisible, omitempty). - YAML keys:
config_form.yamlpreview:(mapping; optionalheaderPartial) andmessages.preview,messages.edit;fields.yamltypespasswordandpermissioneditor, keypreset,mode: radio|checkboxonpermissioneditor;columns.yamlkeyinvisible. - SPA: route
preview(/:vendor/:plugin/:controller/:id/preview),PreviewView.vue,PreviewField.vue,PasswordField.vue,PermissionEditorField.vue;FormModegainspreview;mapWinterUrllearnspreview/:id;RelationField.vuelocked options; partial style kit classes.summer-callout,.summer-callout--warning,.summer-callout--danger,.summer-callout__title,.summer-callout__text; TS aliasesFormPreview,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 extendedmodules/cabana/testdata/rostertree,admin/tests/smoke/preview.smoke.test.ts,admin/tests/smoke/seams.smoke.test.ts. - Release: annotated git tag
v0.1.3on summercms.go.
Planner decisions recorded for this plan
- Preview view. A separate
PreviewView.vue(UI-SPEC leaves the choice open);FormView.vuestays the create and update screen. preview:shape. A mapping; writepreview: {}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.FilterOptionshas no database handle and was resolved on the model only, so database-backed choices (the Usersgroupsfilter, D-23) were not possible. Resolving it on the controller first mirrorsDropdownOptionsProviderand 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.
(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 && 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.
(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.
(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.
(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.
(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> |
<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>