--- phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def plan: 03 type: execute wave: 3 depends_on: ["12.2-02"] files_modified: - modules/cabana/relation.go - modules/cabana/relation_child.go - modules/cabana/relation_form.go - modules/cabana/deferred.go - modules/cabana/field_file.go - modules/cabana/schema.go - modules/cabana/form_schema.go - modules/cabana/schema_types.go - modules/cabana/registry.go - modules/cabana/contracts.go - modules/cabana/messages.go - modules/cabana/crud.go - modules/cabana/http.go - modules/cabana/admin_openapi.go - modules/cabana/security_coverage_test.go - modules/cabana/openapi_conformance_test.go - modules/cabana/relation_child_smoke_test.go - modules/phrasebook/backend/lang/en/lang.yaml - modules/phrasebook/backend/lang/pl/lang.yaml - admin/openapi/admin.json - admin/src/api/schema.d.ts - modules/cabana/README.md - docs/backend/relation-manager.md autonomous: true requirements: [SC-3, SC-4] estimate: tokens: 300000 raw_tokens: 300000 tasks: 3 confidence: low must_haves: truths: - "Per D-13, `cabana.RelationContract` gains `Kind` (empty or `belongsToMany` keeps today's pivot behaviour exactly; `hasMany` is the new kind) and `ForeignKey` (the related model's column pointing at the parent, hasMany only); a hasMany contract with NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns set, a ForeignKey that is not a related-model column, or an unknown Kind stops boot; the framework still never guesses a table or column (P9 D-16)." - "Per D-13 (non-breaking), every existing `AdminRelationContracts()` implementation compiles and behaves unchanged: a contract without Kind is a belongsToMany with the same link, unlink, linked and candidate queries, and the fonoteka.go admin tests stay green without any fonoteka.go change." - "Per D-11 and RESEARCH Open Question 5, the child modal form comes from `config_relation.yaml` `manage.form` (falling back to a top-level `form`), with an optional `view.form` (same fallback), compiled at boot by the typed form pipeline with fail-loud errors naming plugin, controller and file; `$///...` paths resolve inside the same plugin and a path into another plugin stops boot." - "Per D-23, a relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` in a relation form stop boot; a relation form field named like the hasMany ForeignKey stops boot." - "Per D-12 and RESEARCH Open Question 3, view-panel `toolbarButtons` accept `create|update|delete|link|unlink`, unknown buttons stop boot, `create`/`update` without a relation form stop boot, `unlink` on a hasMany with a non-nullable ForeignKey stops boot, and each button is the capability of its routes (403 when undeclared); `update` must be listed explicitly to edit a row (a documented difference from Winter)." - "Per D-12, on hasMany `delete` deletes the child through its model (hooks and soft delete run) and `unlink` sets its foreign key to NULL through the model; on belongsToMany `delete` removes this parent's pivot row and then deletes the related record through its model, and `unlink` removes the pivot row (as today)." - "Per D-14, `pivot.form` (belongsToMany only) is compiled with `pivot[x]` names normalised to `x`; its fields must be pivot columns and must not be the pivot foreign keys, id, timestamps or HookPivotColumns; pivot input is filled through that whitelist on link (`{ids:[one id], pivot:{...}}`) and on `GET`/`PUT .../relations/{name}/pivot/{child}`; `RelationBeforeLink` still stamps the server-owned columns." - "Per D-15, every child, pivot and child-file endpoint loads the parent through FormExtendQuery and finds the child with one query that includes the parent predicate (hasMany: related.ForeignKey = parent key; belongsToMany: a pivot row for the parent; unsaved parent: the admin's session-key bind rows); a child of another parent, a parent hidden by FormExtendQuery and a pending child of another admin all answer 404 `not_found`, never 403." - "Per D-16, child create, update and delete go through the related model's Fill and the merged rules (422 envelope, P9 D-10) and its GORM hooks, and call the controller's optional `pact.RelationBefore/After{Create,Update,Delete}` hooks; a hook error rolls back and answers the opaque lifecycle error." - "Per D-03, child changes are immediate on a saved parent; on an unsaved parent (record id 0 with `X-Session-Key`) create inserts the child with a NULL foreign key (hasMany) or the related row (belongsToMany) and binds it with the D-22 `created` envelope, link binds with the whitelisted pivot values, unlink and delete of a pending child cancel its bind (a created child is deleted), and the linked list shows the bound rows." - "Per D-04, the parent's create save applies relation bindings in the same transaction as file bindings: a hasMany bind sets the foreign key through the child model, a belongsToMany bind re-runs the link eligibility (RelationExtendManageQuery, ExcludedRelatedIDs, not already linked) with the saved parent and writes the pivot row with the stored pivot values and RelationBeforeLink stamps; an ineligible bind answers 422 on the relation-manager field name and the bindings remain." - "Per D-17, `.../relations/{name}/records/{child}/files/{field}` (child 0 for a child not created yet) serves the same seven file operations for a fileupload field of the relation form, keyed by the `X-Child-Session-Key` header and the child's morph type; the child's create or update save commits those file bindings; if the parent is unsaved the child itself is deferred against the parent's key." - "RelationSchema JSON carries `kind`, `deferrable` (belongsToMany always; hasMany only with a nullable ForeignKey), the localized `manageForm`, `viewForm` and `pivotForm` fields and the new message keys; the relation-manager FormField carries `deferrable`, so the SPA knows which managers render on the create screen." - "Per RESEARCH Pitfall 9, boot fails when a deferrable relation that declares `create` points at a related model that no activated plugin lists in `Models()`, because `deferred:purge` could not remove its abandoned children." - "Edge (D-15 adoption, RESEARCH Pitfall 8): hasMany link candidates are related rows with a NULL foreign key, and rows with a live `created` bind of any session are excluded from every candidate list, so another parent cannot adopt a pending child." - "Edge (D-14 bulk): a link with a `pivot` object and more than one id answers 422 on `ids`; an unknown pivot key answers 422 on that key." artifacts: - path: "modules/cabana/relation.go" provides: "RelationContract.Kind/ForeignKey, RelationHasMany, RelationBelongsToMany, kind-aware validation, list and link/unlink queries" contains: "ForeignKey" - path: "modules/cabana/relation_child.go" provides: "child create/show/update/delete handlers and service, loadChild parent scoping, pivot show/update" contains: "loadChild" - path: "modules/cabana/relation_form.go" provides: "manage/view/pivot form compile with D-23 type rules and pivot[x] normalisation" contains: "pivot[" - path: "modules/cabana/deferred.go" provides: "ChildSessionKeyHeader, relation binding commit" contains: "X-Child-Session-Key" key_links: - from: "modules/cabana/relation_child.go" to: "modules/cabana/crud.go" via: "loadRecord applies FormExtendQuery to the parent before any child query" pattern: "loadRecord" - from: "modules/cabana/deferred.go" to: "modules/cabana/relation.go" via: "commitDeferred applies relation binds through the shared link helper so eligibility and RelationBeforeLink run with the saved parent" pattern: "RelationBeforeLink" - from: "modules/cabana/field_file.go" to: "modules/cabana/relation_child.go" via: "childFileScope resolves the child with loadChild before any file operation" pattern: "childFileScope" prohibitions: - statement: "A child, pivot or child-file endpoint MUST NOT answer 403 for a child of another parent or a parent hidden by FormExtendQuery; it answers 404" status: resolved verification: test - statement: "Pivot input MUST NOT set the pivot foreign keys, id, timestamps, deleted_at or HookPivotColumns; only pivot.form fields are filled from the request" status: resolved verification: test - statement: "A request body MUST NOT set a hasMany child's foreign key; the server sets it from the scoped parent" status: resolved verification: test - statement: "An existing AdminRelationContracts implementation MUST NOT need a code change; fonoteka.go is not edited" status: resolved verification: test - statement: "Framework READMEs and docs MUST NOT name a consuming application" status: resolved verification: test --- ## Phase Goal ROADMAP Phase 12.2 goal (verbatim): A plugin's admin forms cover the three gaps a downstream project on SummerCMS v0.1 hit: a date/datetime field, a file upload field, and creating, editing and deleting related records inside the parent form (WinterCMS RelationController parity). Uploads and related-record changes on a record that is not saved yet use Winter-like deferred binding: they are held against a session key and committed with the parent's first save, or discarded with it. This plan's slice: the admin API for relation child CRUD on hasMany and belongsToMany (with pivot), every child endpoint scoped to its parent (success criterion 3), and deferral of child changes on an unsaved parent committed in the parent's create transaction (the relation half of criterion 4). Extend cabana's relation manager (summercms.go) with the hasMany contract kind, relation forms (`manage.form`, `view.form`, `pivot.form`), the Winter toolbar buttons, child create/show/update/delete and pivot routes, child file routes, unsaved-parent deferral and the relation commit; regenerate OpenAPI and TS types; update the cabana README and `docs/backend/relation-manager.md`. Purpose: plan 04 builds the child, pivot and create-screen UI on these routes; plan 05 adds the full D-15 security suite. Output: relation contract kind, forms, routes, scoping, deferral, commit, OpenAPI, smoke tests, docs, message keys. Repo: summercms.go only (fonoteka.go verified, never edited). Neutral names in code, tests and docs. Code and planning docs in separate commits; no co-author tags. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md @modules/cabana/relation.go - cabana relation (today): `RelationContract{Name; NewRelated func() any; NewPivot func() any; ParentForeignKey; RelatedForeignKey; Columns map[string]string; HookPivotColumns []string; ExcludedRelatedIDs func(parent any) ([]uint, error)}`, `AdminRelationContractProvider`, `RelationSchema{Name, Label, View, Manage RelationPanel, Messages}` (custom MarshalJSON, `Localize`), `RelationPanel{List, ToolbarButtons, ShowSearch}`, `CompiledRelation{Schema, Contract, RequiredPermissions}`, `relationDocument`/`relationPanelDocument` (decodeStrict, so new YAML keys need struct fields), `compileRelations(pluginID, ctl, fsys, form)`, `compileRelationButtons(raw, view)` (link|unlink only; manage panel cannot declare unlink), `validateRelationContract(ctl, contract)`, `protectedPivotColumn(col, contract)`, `RelationService{Linked, Candidates, Link, Unlink}`, `relationBaseQuery(ctx, tx, cc, cr, parent, candidates)`, `pendingRelationIDs`, `RelationMutationInput{IDs []any}` (decoded with DisallowUnknownFields), `RelationMutationResult{Linked, Removed}`, `relationInvalid(field, msg)`, `relationOf(cc, name)`, `setModelColumn`, `relationMutation` (toolbar capability check, 403). - cabana other: `FieldRelationContract.Kind` uses "belongsTo"/"belongsToMany" (constants `relationKindBelongsTo`, `relationKindBelongsToMany`); `uintLike(t)`, `structFieldByColumn(model, column)`, `modelColumns(model)`, `BindWritableFields`, `ProjectWritableFields`, `mergedRules`, `projectFullRecord`, `loadRecord`, `newWritableModel`, `lifecycleFailure`, `RecordEnvelope`, `BulkResult`, `assetPath(pluginID, ref)` (handles `~/plugins///` and plugin-relative only), `identifier(s)`, `decodeFields(raw)`, `relationMessageKeys`/`RelationMessages`/`relationMessageDefaults` (messages.go), `validateMessageKeys`. - From plan 02: `SessionKeyHeader`, `sessionKeyFrom(r)`, `fileScope`, `parentFileScope`, the file handlers taking a resolved scope, `compiledFile`, `compiledDate`, `commitDeferred(ctx, tx, cc, target, op, in)`, `FormField.Multiple/Protected` and the fileupload/datepicker keys. - From plan 01: `lagoon.DeferredKey`, `DeferredBind`, `DeferredUnbind`, `DeferredBindings`, `DeferredForget`, `DeferredSlaves`, `DeferredEnvelope{Created, Pivot}`, `DeferredBinding` (model, table deferred_bindings, PivotData *string), `MorphType`; `pact.RelationBeforeCreate` .. `RelationAfterDelete` (`(ctx, relation string, parent, child any) error`), `pact.HasModels`. - Winter sources: `../examples/golem15-wintercms-starter/modules/backend/behaviors/RelationController.php` (onRelationManageCreate/Update/Delete, onRelationManagePivotCreate/Update, makeConfigForMode, deferred handling), `vendor/winter/storm/src/Database/Relations/Concerns/DeferOneOrMany.php`. ## Artifacts this phase produces (This plan's share.) - `RelationContract.Kind`, `RelationContract.ForeignKey`, constants `RelationHasMany = "hasMany"`, `RelationBelongsToMany = "belongsToMany"`. - `RelationSchema` JSON keys `kind`, `deferrable`, `manageForm`, `viewForm`, `pivotForm` (localized field lists); `RelationPanel` keeps its shape; `FormField.Deferrable` (`deferrable`, relation-manager fields). - `RelationMutationInput.Pivot map[string]any` (`pivot`), `AdminRelationLinkRequest` (OpenAPI body), `ChildSessionKeyHeader = "X-Child-Session-Key"`. - `RelationMessages` / config_relation.yaml `messages` keys: create, createTitle, updateTitle, previewTitle, created, updated, deleteSelected, deleteConfirm, deleteOneConfirm, deleted, pivotTitle, pivotSaved, editPivot, createSubmit, updateSubmit, pivotSubmit, linkSubmit, with defaults `backend::lang.messages.relation.` in en and pl. - config_relation.yaml keys: top-level `form`, `view.form`, `manage.form`, `pivot.form`; toolbarButtons `create|update|delete|link|unlink`. - Routes (prefix-relative, backend guard, writes under requireAjax): `POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records`, `GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}`, `PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}`, `POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete`, `GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}`, `PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}`, and the seven child file routes under `/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}` (list, upload, `/{file}` update and delete, `/reorder`, `/{file}/download`, `/{file}/thumb`); swag doc funcs `AdminRelationChildCreate`, `AdminRelationChildShow`, `AdminRelationChildUpdate`, `AdminRelationChildDelete`, `AdminRelationPivotShow`, `AdminRelationPivotUpdate`, `AdminRelationChildFileList`, `AdminRelationChildFileUpload`, `AdminRelationChildFileUpdate`, `AdminRelationChildFileRemove`, `AdminRelationChildFileReorder`, `AdminRelationChildFileDownload`, `AdminRelationChildFileThumb`. - Service methods: `RelationService.CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`, `ShowPivot`, `UpdatePivot`; internal `loadChild`, `childFileScope`, `compileRelationForm`. ## Assumption-delta decision Detector fired (pluralization): `RelationContract` moves from one shape (belongsToMany with a pivot) to two kinds (belongsToMany with a pivot, hasMany with a foreign key). Primary noun: the relation kind. Decision: promote. A `Kind` discriminator becomes the primary attribute of the contract and the pivot fields (NewPivot, ParentForeignKey, RelatedForeignKey, HookPivotColumns) become the detail of the belongsToMany variant, with `ForeignKey` the detail of the hasMany variant. Constraint carried with the decision: the zero value of Kind means belongsToMany, so every existing `AdminRelationContracts()` implementation, including the controllers of the shared core plugins in host applications, compiles and behaves exactly as before (core plugin contracts must not break). Rationale: Winter's RelationController is kind-driven, and a discriminator keeps validation, queries and docs branching on one field instead of inferring the kind from which fields happen to be set. ## Planner assumptions recorded for this plan - RESEARCH Open Question 3: `update` must be listed explicitly to edit a child row (Winter opens the update form on row click regardless); documented as a difference. - RESEARCH Open Question 5: top-level `form` is accepted as Winter's fallback for both `manage.form` and `view.form`. - RESEARCH Open Question 4: no cancel endpoint; abandoned bindings are purged. - Delete of several children is one route, `POST .../relations/{name}/delete` with `{ids}` (all ids must be children of the parent or the whole request is 404 and nothing is deleted); the child modal's delete uses it with one id. - belongsToMany child create writes the related row and its pivot row (RelationBeforeLink stamps, no pivot form values); pivot values are edited through the pivot route afterwards. - The child form's file uploads use their own key in `X-Child-Session-Key` (discretion: transport of the session key), so a child modal on an unsaved parent carries both keys. Task 1: An admin creates a hasMany child of a saved record from the relation manager through the related model's own form RelationContract is a public, cross-module contract implemented by host applications; the change is additive and its zero value keeps today's behaviour, so it is flagged, not gated. modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/relation_form.go, modules/cabana/schema.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/contracts.go, modules/cabana/registry.go, modules/cabana/messages.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/relation_child_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/relation-manager.md modules/cabana/relation.go (whole file), modules/cabana/relation_field.go (Kind constants, uintLike, structFieldByColumn), modules/cabana/crud.go (save, BindWritableFields, ProjectWritableFields, mergedRules, loadRecord, projectFullRecord), modules/cabana/schema.go (assetPath), modules/cabana/form_schema.go (decodeFields, compileFieldNode), modules/cabana/messages.go, modules/cabana/http.go (mount, relationMutation), modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go (conformFS config_relation.yaml, conformMember, conformGadgetMember), modules/pact/capabilities.go (Relation hooks from plan 01), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (messages.relation), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Copywriting Contract: messages.relation keys with EN/PL text), ../examples/golem15-wintercms-starter/modules/backend/behaviors/RelationController.php, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go (an existing contract that must keep working), docs/backend/relation-manager.md, modules/cabana/README.md Per D-11, D-12, D-13, D-15, D-16 and D-23, wire one hasMany child create end to end on a saved parent. (1) Contract (D-13): add `Kind string` and `ForeignKey string` to RelationContract (document both; zero Kind means belongsToMany) and exported constants `RelationHasMany` and `RelationBelongsToMany`. `validateRelationContract` branches on the kind: belongsToMany keeps every current check and requires ForeignKey empty; hasMany requires NewPivot nil and ParentForeignKey, RelatedForeignKey and HookPivotColumns empty, ForeignKey an identifier that is a column of the related model with a uint-like or pointer-to-uint-like Go type (`uintLike`), plus the existing Columns and owner-field checks; an unknown kind is an error. Record `deferrable` on CompiledRelation (belongsToMany: true; hasMany: ForeignKey Go type is a pointer). (2) Relation forms (D-11, D-23) in modules/cabana/relation_form.go: extend `relationDocument` with top-level `Form string` and `Pivot *struct{ Form string }`, and `relationPanelDocument` with `Form string`; `compileRelationForm(pluginID, ctl, fsys, path, model any, purpose string)` reads the file through `assetPath` and `decodeFields`, refuses `relation`, `relation-manager`, `widget` and `partial` ("type is not supported in a relation form ()"), binds writable fields to the related model's columns with the same rules as BindWritableFields, runs the plan-02 datepicker Go-type and fileupload attach checks against the related model, and refuses a field named like the hasMany ForeignKey. The manage form is `manage.form` or the top-level form; the view form is `view.form` or the top-level form. Extend `assetPath` so `$///rest` resolves to `rest` when vendor/plugin is the calling plugin's id and is a boot error ("$/ path names another plugin") otherwise. CompiledRelation keeps the compiled manage form, view form, child writable fields and child compiledFile/compiledDate maps (unexported). (3) Toolbar (D-12): `compileRelationButtons` accepts `create|update|delete|link|unlink` on the view panel; the manage panel keeps accepting only `link`; `create` or `update` without a manage form, and `update` or a `view.form` without any form, are boot errors; unknown or duplicate buttons stay errors. (4) Linked list for hasMany: `relationBaseQuery` branches on kind: hasMany linked is `related. = parentPK` (clause.Eq with quotedIdent), candidates come in Task 2. belongsToMany is unchanged. (5) Create on a saved parent: `RelationService.CreateChild(ctx, cc, relation, ownerID, in RecordInput)` and the handler `relationChildCreate` for `POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records` (requireAjax): protect, `create` in view toolbarButtons else 403, body capped at default_bytes and decoded with decodeObject; inside lagoon.Transaction load the parent with loadRecord (FormExtendQuery, 404), build the related model (it must implement lagoon.HasFillable and the Rules interface, checked at boot when create or update is declared), project the body through the child writable fields for op create, lagoon.Fill, BeforeValidate, lagoon.Validate with the child form's merged rules (422 via ValidationError), then `pact.RelationBeforeCreate` (lifecycleFailure on error), set the ForeignKey to the parent key server-side, `tx.Create`, `pact.RelationAfterCreate`; answer 201 RecordEnvelope projected with the child writable fields. Id 0 (unsaved parent) is Task 3; until then id 0 is 404 as today. (6) Schema and messages: RelationSchema gains `Kind`, `Deferrable`, and the localized `ManageForm`, `ViewForm`, `PivotForm` field lists (`[]FormField`, omitempty, localized in `Localize` with the related model as DropdownOptionsProvider); the relation-manager FormField gets `Deferrable` from its compiled relation. Add the 17 message keys named in Artifacts to `relationMessageKeys` (YAML camelCase), `RelationMessages` (JSON camelCase) and `relationMessageDefaults` (`backend::lang.messages.relation.`), with en and pl texts copied verbatim from the UI-SPEC Copywriting Contract into lang.yaml (CLDR plural maps for `deleted`: en one/other, pl one/few/many/other); validateMessageKeys must pass. (7) OpenAPI and inventories: doc func `AdminRelationChildCreate` (body `AdminRecord`, 201 RecordEnvelope, 401/403/404/422); add the route to phase09Routes and the handler to phase09ProtectedCalls; extend the conformance fixture with a hasMany `parts` relation (`conformPart` with a nullable `gadget_id`, its fields.yaml referenced as `$/acme/conform/models/part/fields.yaml`, view toolbarButtons `create`) and one conformance case; regenerate admin.json and schema.d.ts. (8) Smoke test modules/cabana/relation_child_smoke_test.go `TestRelationChildSmokeCreate`: a part created under gadget A carries A's id and appears in A's linked list, not in B's; a body that names `gadget_id` cannot move it (the server sets the key). (9) Docs in the same change: docs/backend/relation-manager.md (the hasMany contract, Kind and ForeignKey, relation forms and their fallbacks, `$/` paths, allowed field types, the five toolbar buttons and `update` being explicit, the child create route, the relation hooks), modules/cabana/README.md (routes, RelationContract fields, constants, message keys). go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildSmoke.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && go test ./modules/phrasebook -count=1 && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1 Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestRelationChildSmokeCreate"; check-admin-openapi.sh reports stale output; a fonoteka.go admin test fails (the existing belongsToMany contract changed behaviour); a message-key validation error names a missing backend::lang.messages.relation key. - `go doc ./modules/cabana RelationContract.Kind`, `go doc ./modules/cabana RelationContract.ForeignKey` and `go doc ./modules/cabana RelationHasMany` exit 0. - `grep -c 'relations/{name}/records' modules/cabana/security_coverage_test.go` prints at least 1 and `grep -c 'relations/{name}/records' admin/openapi/admin.json` prints at least 1. - `grep -c 'create_title' modules/phrasebook/backend/lang/en/lang.yaml` and `grep -c 'create_title' modules/phrasebook/backend/lang/pl/lang.yaml` each print 1. - `git -C ../fonoteka.go status --porcelain` prints nothing (no application change). - `grep -c 'hasMany' docs/backend/relation-manager.md` prints at least 1 and `grep -c 'manage.form\|manage:' docs/backend/relation-manager.md` prints at least 1. A relation manager creates a hasMany child through the related model's own form, the server sets the foreign key, and existing belongsToMany contracts behave as before. Task 2: An admin views, edits, deletes, links and unlinks children of hasMany and belongsToMany relations and edits pivot fields, and no endpoint reaches a child of another parent Additive routes, an optional link body key and kind-specific query branches behind the existing service. modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/relation_form.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/relation_child_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/relation-manager.md modules/cabana/relation.go and modules/cabana/relation_child.go (as left by Task 1), modules/cabana/crud.go (deleteRecord, normalizeIDs, lockScoped), modules/cabana/http.go (relationMutation, decodeRelationMutation), ../examples/golem15-wintercms-starter/modules/backend/behaviors/RelationController.php (onRelationManageUpdate, onRelationManageDelete, onRelationManagePivotCreate, onRelationManagePivotUpdate, onRelationButtonUnlink), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Pattern 3, Pitfalls 10 and 15) Per D-12, D-14, D-15 and D-16. (1) Parent scoping (D-15) in relation_child.go: `loadChild(ctx, tx, cc, cr, parent any, ownerID uint, key *lagoon.DeferredKey, childID uint, lock bool) (any, error)` finds the child with ONE query that carries the parent predicate: hasMany `related.pk = child AND related. = parentPK`; belongsToMany a JOIN on the pivot with `p. = parentPK AND related.pk = child` (reuse relationBaseQuery with candidates false); `FOR UPDATE` when lock; a miss is `recordNotFound{}` (404). The unsaved-parent branch (key set, ownerID 0) is added in Task 3. Every handler below loads the parent with loadRecord first. (2) Show and update: `GET .../relations/{name}/records/{child}` (allowed when `update` is declared or a view form exists; projects with the manage form when update is declared, else the view form) and `PUT .../relations/{name}/records/{child}` (requireAjax, `update` declared, body capped at default_bytes): Fill through the child writable fields for op update, Validate with merged rules, `RelationBeforeUpdate`, `tx.Save`, `RelationAfterUpdate`; both answer RecordEnvelope. (3) Delete (D-12): `POST .../relations/{name}/delete` with `{ids}` (requireAjax, `delete` declared, normalizeIDs): every id must pass loadChild with lock, else 404 and nothing is deleted; per child `RelationBeforeDelete`, then hasMany deletes the child through `tx.Delete(child)` (hooks, soft delete), belongsToMany deletes this parent's pivot row through the pivot model and then the related record through its model, then `RelationAfterDelete`; answer `Envelope[BulkResult]`. (4) Link and unlink by kind: hasMany candidates are related rows whose ForeignKey IS NULL; for both kinds add `NOT EXISTS` over deferred_bindings for a live bind of the related morph type whose pivot_data JSON has created true (Pitfall 8), plus RelationExtendManageQuery and ExcludedRelatedIDs as today. hasMany link loads the eligible candidates FOR UPDATE and sets the ForeignKey to the parent key through the child model's Save; hasMany unlink loads the parent's children among the ids FOR UPDATE and sets the ForeignKey to NULL through Save (boot already refused unlink on a non-nullable key). belongsToMany link and unlink stay as they are, except: `RelationMutationInput` gains `Pivot map[string]any` (json `pivot,omitempty`); a pivot object requires a compiled pivot form and exactly one id (422 on ids otherwise); its keys must be pivot form fields (422 per unknown key); values fill the pivot model through the pivot form whitelist with lagoon.Fill before RelationBeforeLink runs, and the merged pivot rules are validated. Refactor Link into a shared helper that takes the transaction, the loaded parent, the ids and optional pivot values, so Task 3's commit reuses it. (5) Pivot form (D-14): compile `pivot.form` for belongsToMany only (a pivot form on hasMany stops boot); normalise `pivot[x]` keys to `x` before the identifier check (accept both spellings, Pitfall 10); fields must be pivot model columns, scalar or datepicker types, and never ParentForeignKey, RelatedForeignKey, id, created_at, updated_at, deleted_at or a HookPivotColumns entry (boot error). Routes `GET` and `PUT .../relations/{name}/pivot/{child}` (PUT under requireAjax, body capped): allowed when a pivot form exists and `link` or `update` is declared (403 otherwise); load the parent, then the pivot row `WHERE ParentForeignKey = parentPK AND RelatedForeignKey = child` FOR UPDATE (miss 404); GET answers `Envelope[AdminRecord]` with the pivot form fields; PUT fills through the whitelist, validates and saves the pivot model. (6) OpenAPI and inventories: doc funcs `AdminRelationChildShow`, `AdminRelationChildUpdate`, `AdminRelationChildDelete`, `AdminRelationPivotShow`, `AdminRelationPivotUpdate`, and `AdminRelationLink` documents the body as `AdminRelationLinkRequest` (`ids`, optional `pivot`); add the five routes to phase09Routes and their handlers to phase09ProtectedCalls; extend the fixture (parts toolbar `create|update|delete|link|unlink`; members gets a pivot form with `pivot[note]` and a `note` column on conformGadgetMember) and add one conformance case per route; regenerate admin.json and schema.d.ts. (7) Smoke tests: `TestRelationChildSmokeScope` (gadget B's part through gadget A's routes is 404 on GET, PUT, delete and pivot; a FormExtendQuery-hidden parent is 404), `TestRelationChildSmokePivot` (link with a pivot note stores it; a `pivot` key naming the pivot foreign key is refused; RelationBeforeLink still stamps its column). (8) Docs: relation-manager.md (update, delete, link and unlink per kind, pivot forms, `pivot[x]` names, the 404 rule for children of other parents), cabana README route rows. go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildSmoke.*|TestRelation.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1 Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestRelationChildSmokeScope" and "--- PASS: TestRelationChildSmokePivot"; check-admin-openapi.sh reports stale output; a fonoteka.go admin relation test fails. - `grep -c 'func loadChild' modules/cabana/relation_child.go` prints 1. - `grep -c 'relations/{name}/pivot/{child}' modules/cabana/security_coverage_test.go` prints at least 2 and `grep -c 'relations/{name}/delete' modules/cabana/security_coverage_test.go` prints at least 1. - `go doc ./modules/cabana RelationMutationInput.Pivot` and `go doc ./modules/cabana AdminRelationLinkRequest` exit 0. - `grep -c 'pivot\[' docs/backend/relation-manager.md` prints at least 1. Children of both relation kinds can be read, edited, deleted, linked and unlinked, pivot fields are edited through a whitelist, and every child route is parent-scoped. Task 3: On a record that is not saved yet the relation manager creates, links, unlinks and deletes children against the session key, child forms take file uploads, and the parent's create save commits it all Behaviour on record id 0 is new (it answered 404 before); saved-parent behaviour from Tasks 1 and 2 is unchanged. modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/deferred.go, modules/cabana/field_file.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/registry.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/relation_child_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/relation-manager.md modules/cabana/deferred.go and modules/cabana/field_file.go (plan 02), modules/cabana/relation_child.go and modules/cabana/relation.go (Tasks 1-2), modules/cabana/crud.go (save), modules/cabana/http.go (Activate; plugins are available there), modules/lagoon/deferred.go, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Traits/DeferredBinding.php (commitDeferred), ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Relations/Concerns/DeferOneOrMany.php, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Patterns 4 and 5, Pitfalls 7, 8, 9, 14) Per D-03, D-04, D-15, D-17 and D-22. (1) Unsaved parent (D-03): every relation route accepts record id 0 only for a deferrable relation, with a valid `X-Session-Key` and the controller's create operation declared; otherwise 404. The parent key is `lagoon.DeferredKey{key, admin id, controller morph type}` and master_field is the relation name. loadChild's unsaved branch is `CAST(related.pk AS TEXT) IN DeferredSlaves(key, relation, relatedMorph, bind=true)`. Linked list on id 0 lists exactly those rows. Create inserts the child (hasMany with a NULL ForeignKey; belongsToMany the related row) with the same Fill, Validate and hooks as Task 1 (`parent` is a fresh zero-key record) and binds it with `DeferredEnvelope{Created: true}`. Link binds each eligible candidate (eligibility checked now with the zero-key parent and again at commit) with `DeferredEnvelope{Pivot: whitelisted values}` when a pivot object is sent. Unlink and delete call DeferredUnbind for each id; a cancelled bind whose envelope has Created true deletes the child through its model (and, for delete of a linked-only pending record, the record is deleted as on a saved parent). Pivot GET/PUT on id 0 read and write the envelope's pivot values of the pending bind row (update `pivot_data` on the row selected by key, admin, master type, relation and slave id; whitelist and validate exactly as on a saved parent). (2) Relation commit (D-04): commitDeferred also reads bindings whose master_field is a deferrable relation-manager relation allowed in the operation's context, and applies them in id order with the files: a hasMany bind loads the child FOR UPDATE, skips it unless its ForeignKey is NULL, and sets the key through the child model's Save; a belongsToMany bind runs the shared link helper with the saved parent and the envelope's pivot values (eligibility and RelationBeforeLink run now); an ineligible bind returns `relationInvalid(, "contains an ineligible record")` so the save is a 422 on that field and the transaction keeps the bindings; an unbind sets the ForeignKey NULL (hasMany) or deletes the pivot row (belongsToMany) when present. Applied rows are deleted with DeferredForget. (3) Child file routes (D-17): `ChildSessionKeyHeader = "X-Child-Session-Key"`; `childFileScope(ctx, tx, r, cc)` resolves the relation, the fileupload field of the manage form, and the child: child 0 needs a valid child key and `create` declared; child > 0 must pass loadChild under the parent scope (saved parent, or the parent key's binds when id is 0) and `update` declared for writes; the file key is `{child key, admin, related morph}`. Mount the seven routes under `/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}` on the plan-02 handlers with this scope. Child create and update read `X-Child-Session-Key` and commit the child's own file bindings (the plan-02 file commit run against the child model, its morph type and its compiledFile map) inside the child's transaction. (4) Boot (Pitfall 9) in Activate after compileRegistry: for every deferrable relation that declares create, the related model's MorphType must match a model listed by some activated plugin's `pact.HasModels().Models()`; otherwise stop boot naming the plugin, controller, relation and type. (5) OpenAPI and inventories: doc funcs for the seven child file routes (headers X-Session-Key and X-Child-Session-Key documented as optional parameters); add the routes to phase09Routes and handlers to phase09ProtectedCalls; give `conformPart` an attach relation `image` (attachOne, Public false) with a fileupload field in its form, list conformPart in the conform plugin's Models(), and add one conformance case per route; regenerate admin.json and schema.d.ts. (6) Smoke tests: `TestRelationChildSmokeDeferredCreate` (on id 0 create a part and link a member with a pivot note; create the gadget with the same key; the part carries the gadget id, the member pivot row exists with the note and the RelationBeforeLink stamp, no binding row is left), `TestRelationChildSmokeDeferredRollback` (an ineligible deferred link answers 422 on the relation-manager field and the bindings remain), `TestRelationChildSmokeChildFile` (upload an image to child 0, create the child, the file is attached to the child). (7) Docs: relation-manager.md (managers on the create screen: deferrable relations, the session key, commit at the first save, discard and purge, the behaviour change for existing belongsToMany managers without `context: update`, child file uploads and the two headers), cabana README (ChildSessionKeyHeader, routes). go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildSmoke.*|TestFileuploadSmoke.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && go test ./modules/cabana ./modules/lagoon/... -count=1 && 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 ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1 Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestRelationChildSmokeDeferredCreate", "--- PASS: TestRelationChildSmokeDeferredRollback" and "--- PASS: TestRelationChildSmokeChildFile"; check-admin-dist.sh reports "modules/boardwalk/dist is stale"; docs:build --check prints a problem line; a fonoteka.go admin test fails. - `grep -c 'X-Child-Session-Key' modules/cabana/deferred.go` prints at least 1 and `go doc ./modules/cabana ChildSessionKeyHeader` exits 0. - `grep -c 'records/{child}/files/{field}' modules/cabana/security_coverage_test.go` prints at least 7. - `grep -c 'childFileScope' modules/cabana/field_file.go modules/cabana/relation_child.go | awk -F: '{s+=$2} END {print s}'` prints at least 2. - `grep -c 'Models()' modules/cabana/http.go modules/cabana/registry.go | awk -F: '{s+=$2} END {print s}'` prints at least 1 (purge resolvability boot check). - `grep -c 'create screen\|unsaved' docs/backend/relation-manager.md` prints at least 1. - `git -C ../fonoteka.go status --porcelain` prints nothing. A record being created can gain children, links, pivot values and child files before its first save, and that save commits them in one transaction or rejects them with a 422 that keeps the pending work. ## Trust Boundaries | Boundary | Description | |----------|-------------| | SPA → relation child, pivot and child-file routes | Untrusted child ids, related ids, pivot keys and values, child bodies | | Session keys (parent and child) → deferred_bindings | Client-chosen keys select pending children and files | | config_relation.yaml and relation fields.yaml → boot compile | Trusted plugin config, validated fail-loud, including paths | | Deferred bindings → commit in the parent's save | Stored intent applied with the saved parent's authority | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-12.2-19 | Information Disclosure / Tampering | child of another parent (IDOR) | high | mitigate | loadChild carries the parent predicate in the same query (FK, pivot or admin's binds); miss is 404 (Task 2, Task 3); plan 05 security suite. | | T-12.2-20 | Tampering | mass assignment of server-owned pivot columns | high | mitigate | pivot.form whitelist; boot refuses pivot keys, timestamps and HookPivotColumns in the form; unknown pivot keys 422; RelationBeforeLink keeps stamping (Task 2). | | T-12.2-21 | Elevation of Privilege | undeclared child operations | high | mitigate | view toolbarButtons are the capability (403), checked before any SQL; create on id 0 also needs the controller's create operation (Tasks 1-3). | | T-12.2-22 | Tampering | adoption of a pending child by another parent | medium | mitigate | Candidates exclude rows with a live created bind; hasMany candidates need a NULL foreign key (Task 2). | | T-12.2-23 | Information Disclosure | parent hidden by FormExtendQuery | high | mitigate | Every child route loads the parent through loadRecord (FormExtendQuery) before the child query (Tasks 1-3). | | T-12.2-24 | Spoofing | another admin's pending children | high | mitigate | Unsaved-parent scope reads binds by key and admin id; id 0 without a valid key is 404 (Task 3). | | T-12.2-25 | Tampering | deferred link eligibility bypass | medium | mitigate | Commit re-runs RelationExtendManageQuery, ExcludedRelatedIDs and the already-linked check with the saved parent; 422 keeps the bindings (Task 3). | | T-12.2-26 | Information Disclosure | cross-plugin YAML read through `$/` | medium | mitigate | `$/` resolves only inside the calling plugin; anything else stops boot (Task 1). | | T-12.2-27 | Information Disclosure | relation pickers and partials inside child forms | medium | mitigate | D-23: relation, relation-manager, widget and partial stop boot in relation forms (Task 1). | | T-12.2-28 | Tampering | child body setting the hasMany foreign key | high | mitigate | ForeignKey is set server-side from the scoped parent; a form field with that name stops boot; ProjectWritableFields drops unknown keys (Task 1). | | T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package added in this plan. | - summercms.go: `go vet ./... && go test ./... -count=1` green with Docker up; `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` clean; `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green. - fonoteka.go: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1` green and `git -C ../fonoteka.go status --porcelain` empty. - hasMany and belongsToMany children are created, read, updated and deleted from relation forms, with pivot editing, through D-11 to D-14 and D-16. - Every child, pivot and child-file endpoint is parent-scoped with 404 on a miss (D-15). - Deferred children, links and child files on an unsaved parent commit in the parent's create transaction (D-03, D-04, D-17, D-22). - Existing relation contracts are unaffected; OpenAPI, TS types, inventory, conformance, messages, README and docs updated in the same change. Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md` when done.