Five sequential plans: foundations (deferred_bindings, attach.Store, lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation child CRUD with deferral, admin SPA, and unit and security tests. Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
47 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.2-admin-form-fields-date-file-upload-relation-editing-with-def | 03 | execute | 3 |
|
|
true |
|
|
|
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.
<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.)
RelationContract.Kind,RelationContract.ForeignKey, constantsRelationHasMany = "hasMany",RelationBelongsToMany = "belongsToMany".RelationSchemaJSON keyskind,deferrable,manageForm,viewForm,pivotForm(localized field lists);RelationPanelkeeps 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.yamlmessageskeys: create, createTitle, updateTitle, previewTitle, created, updated, deleteSelected, deleteConfirm, deleteOneConfirm, deleted, pivotTitle, pivotSaved, editPivot, createSubmit, updateSubmit, pivotSubmit, linkSubmit, with defaultsbackend::lang.messages.relation.<snake_case>in en and pl.- config_relation.yaml keys: top-level
form,view.form,manage.form,pivot.form; toolbarButtonscreate|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 funcsAdminRelationChildCreate,AdminRelationChildShow,AdminRelationChildUpdate,AdminRelationChildDelete,AdminRelationPivotShow,AdminRelationPivotUpdate,AdminRelationChildFileList,AdminRelationChildFileUpload,AdminRelationChildFileUpdate,AdminRelationChildFileRemove,AdminRelationChildFileReorder,AdminRelationChildFileDownload,AdminRelationChildFileThumb. - Service methods:
RelationService.CreateChild,ShowChild,UpdateChild,DeleteChildren,ShowPivot,UpdatePivot; internalloadChild,childFileScope,compileRelationForm.
Assumption-delta decision
<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.
</assumption_delta_decision>
Planner assumptions recorded for this plan
- RESEARCH Open Question 3:
updatemust 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
formis accepted as Winter's fallback for bothmanage.formandview.form. - RESEARCH Open Question 4: no cancel endpoint; abandoned bindings are purged.
- Delete of several children is one route,
POST .../relations/{name}/deletewith{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.
(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 $/<vendor>/<plugin>/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.<ForeignKey> = 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.<snake_case>), 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
<fails_when>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.</fails_when>
<acceptance_criteria>
- 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.
</acceptance_criteria>
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.
(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.<ForeignKey> = parentPK; belongsToMany a JOIN on the pivot with p.<ParentForeignKey> = 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
<fails_when>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.</fails_when>
<acceptance_criteria>
- 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.
</acceptance_criteria>
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.
(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(<relation-manager field name>, "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
<fails_when>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.</fails_when>
<acceptance_criteria>
- 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.
</acceptance_criteria>
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.
<threat_model>
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. |
| </threat_model> |
<success_criteria>
- 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. </success_criteria>