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.
75 KiB
Phase 12.2: Admin form fields: date, file upload, relation editing with deferred binding - Research
Researched: 2026-10-02 Domain: Go admin schema pipeline (cabana), attachments (lagoon/attach), Winter deferred binding, Vue admin SPA (Reka UI) Confidence: HIGH for codebase integration points and Winter semantics (all read this session). MEDIUM for the purge/scheduling design and the deferred-created child marker, which need small decisions the CONTEXT does not settle.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Deferred binding
- D-01: Pending bindings live in Winter's
deferred_bindingstable, with the same columns as Winter's two migrations:id,master_type,master_field,slave_type,slave_id,session_key,pivot_data,is_bind,created_at,updated_at. One column is added: the owning backend admin id.master_typeandslave_typehold the same morph type string thatsystem_files.attachment_typealready uses. No PHP class names. The migration ships as a framework migration set inlagoon, besideattach.Migrations. — Reversibility: costly — the table is migrated in every host application, and changing its shape later needs a migration plus data rewrite. - D-02: Session keys come from the SPA. It generates a cryptographically random key of at least 128 bits each time a form opens, and sends it with every upload, file removal, child write and the final save. The server stores the authenticated admin's id on each binding. A key used by a different admin is treated as unknown: its bindings are neither read nor committed. The server validates the key's format and length.
- D-03: The Winter split decides when deferral applies:
- File uploads and file removals are always deferred until the parent's Save, on new and saved records alike, as Winter's FileUpload widget does (
add($file, $sessionKey)). Cancel or navigating away discards them. - Relation-manager child changes (create, update, delete, link, unlink, pivot edits) are immediate on a saved parent and deferred only while the parent is unsaved, as Winter's RelationController does.
- File uploads and file removals are always deferred until the parent's Save, on new and saved records alike, as Winter's FileUpload widget does (
- D-04: Commit and discard work like this:
- On the parent's create or update save, every binding for
(session_key, admin)is applied inside the save transaction, afterFormBeforeCreate/FormBeforeUpdateand before commit. Binds attach or link, unbinds detach or unlink or delete. - If the transaction rolls back, the bindings stay in place, so a 422 doesn't lose the uploads.
- A successful save deletes the applied binding rows.
- On the parent's create or update save, every binding for
- D-05: Purging is a daily River periodic job plus
summer deferred:purge [--days=N]. The default age is 5 days (Winter'sDeferredBinding::cleanUp(5)), configurable. Both remove expired bindings and the orphaned slave records they point at. A deferred-created child row or an unattachedsystem_filesrow is deleted. For files, the blob and its thumbnails are deleted after commit through the existing two-phaseattach.DeleteKeyspath.
File upload
- D-06: Models declare attachments through an interface,
AttachRelations() []attach.Relation, where each entry carries at leastName,Many(attachOne vs attachMany) andPublic. This follows theAdminRelationContracts()style. Atype: fileuploadfield must name a declared relation, otherwise boot fails with an error naming the plugin, controller and file. - D-07: A new exported API in
lagoon/attachstores an upload. It writes the blob under the Winter partition key and thesystem_filesrow, enforces MIME and size limits, applies the image-content guard (including webp, P12 D-24), and handlessort_orderand thumbnails.cabanauses it, and app plugins can adopt it later. Existing app code that buildsattach.File{}by hand (fonoteka.go album photos, collection media, user avatar) is not changed in this phase, so the change is non-breaking. - D-08: Accepted
fileuploadkeys:mode(image|file),fileTypes,mimeTypes,maxFilesize,maxFiles,imageWidth,imageHeight,thumbOptions(the thumb mode),useCaption(edit title and description) andprompt. Any other key is a boot error (P9 D-06). No cropping. Limits are enforced on the server, never only in the SPA, and the upload route has aMaxBytesReadercap (P7 D-04 pattern). - D-09: Supported operations: upload (deferred, D-03), image preview and thumbnail, remove (deferred), reorder for
attachMany(sort_order), and title/description editing whenuseCaptionis set. They work on both saved and unsaved records. - D-10: Both public and protected attachments are supported. A relation with
Public: truestoresis_public=trueand uses the existing Winter-shaped public URLs (P12 D-22). A relation withPublic: falsestoresis_public=false, and the SPA gets its download and thumbnail through an authenticated admin API route under thebackendguard. That route is scoped to a parent record the admin may access (or to the admin's own pending bindings).
Relation child CRUD
- D-11: The child modal's form comes from
config_relation.yamlmanage.form(Winter style, e.g.$/vendor/plugin/models/child/fields.yaml), with an optionalview.form. It is compiled at boot by the same typed form-schema pipeline and with the same fail-loud rules. - D-12:
toolbarButtonsfollow Winter:create,update,delete,link,unlink. OnhasMany,deletedeletes the child row through the model so lifecycle hooks and soft delete fire, andunlinksets its foreign key to null. OnbelongsToMany,deletedeletes the related record andunlinkremoves the pivot row (as today). Unknown buttons fail at boot. - D-13:
RelationContract(modules/cabana/relation.go) is extended to describehasMany(relation kind plus the child's foreign key) next to the existing pivot shape. The framework still never guesses table or column names (P9 D-16). - D-14: Winter's
pivot.formis supported forbelongsToMany. Pivot fields are edited in a modal when linking and later through "edit pivot". Pivot input is filled through a whitelist of the pivot form's fields.RelationBeforeLinkstill stamps the server-owned pivot columns, and those columns can't be set from the request. - D-15: Every child endpoint is scoped to its parent. The parent is loaded through
FormExtendQuerywith the controller's permissions, and the child must belong to that parent: by FK forhasMany, by pivot row forbelongsToMany, or by the admin's session-key bindings while the parent is unsaved. Otherwise the endpoint returnsnot_found, so a child of another parent can be neither read nor changed. This is success criterion 3, and it gets security tests. - D-16: Child saves go through the related model's
Fill/Validate(422 envelope, P9 D-10) and its lifecycle hooks. The admin-controller hook set from P9 D-13 gains optional relation hooks for child create, update and delete in the same type-asserted style. Exact names are the planner's choice. - D-17: The child modal is a full form.
datepickerandfileuploadwork inside it with the child form's own session key, which is committed with the child's save. If the parent is unsaved too, the child itself is deferred against the parent's key. Arelation-managerfield inside a child form is a boot error.
Datepicker
-
D-18: For
mode: datetime, the value is stored astimestamptzin UTC, and the SPA shows and edits it in the admin's browser timezone.ignoreTimezone: truekeeps the wall-clock value unchanged, with no conversion.dateandtimemodes never convert. -
D-19: Go types:
datetimemaps totime.Time/*time.Time.datemaps to a frameworklagoon.Date(aDATEcolumn; JSON"2026-10-02").timemaps tolagoon.TimeOfDay(aTIMEcolumn; JSON"14:30:00").
Nullable variants are included. All of them implement
Scanner/Valuerand JSON marshalling. A plugin never defines its own date types (success criterion 1). Boot fails if a datepicker field's mode doesn't match its model field's Go type. -
D-20: Accepted
datepickerkeys:mode,format(display only; Winter/moment tokens are mapped to the SPA formatter),minDate,maxDate,yearRange,firstDay,twelveHourandignoreTimezone. Any other key is a boot error.minDate/maxDateare also validated on the server. -
D-21: The SPA picker is built on Reka UI DatePicker / Calendar primitives (already in the stack, P10 D-07), styled with Direction C tokens, locale-aware, with keyboard and a11y support. Native
<input type=date>is not used.
Claude's Discretion
- The names of the new
cabanaroutes (upload, file update/reorder/remove, child CRUD, pivot edit, protected file download), provided they sit under the existing{prefix}/api/v1/{vendor}/{plugin}/{controller}/...scheme, userequireAjaxon writes, carry swag annotations, and update the admin OpenAPI document and generated TS types (P10 D-15). - How the session key travels: a header or a body field.
- The exact shape of
attach.Relation, the attach store API, and thelagoon.Date/lagoon.TimeOfDaymethod sets. - Thumbnail size for previews (derived from
imageWidth/imageHeight, with a sensible default). - Whether list columns gain
type: date/type: timerenderers. Add them only if they are trivial next to the existingtype: datetime. - Configuration key names for the purge age and job schedule.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Run the security-review agent, because the phase touches authorization scoping and file uploads.
Deferred Ideas (OUT OF SCOPE)
- Image cropping in fileupload (Winter's crop/resize popup): a future admin-fields phase.
- A relation manager nested inside a child modal.
- Migrating fonoteka.go's hand-rolled upload code onto the new
attachstore helper: optional follow-up in the app repo.
Reviewed Todos (not folded): benchmarking, nest-framework-packages, backend-admin-api-tokens, bonfire-duplicate-command-names. </user_constraints>
<phase_requirements>
Phase Requirements
No requirement IDs are mapped (TBD). The five ROADMAP success criteria are the contract:
| ID | Description | Research Support |
|---|---|---|
| SC-1 | type: datepicker with Winter mode (date, datetime, time) stores date, timestamp and time columns without plugin-defined date types |
lagoon.Date / lagoon.TimeOfDay design; lagoon.Fill gap (no string to time.Time); isListRelation and isEmptyValue pitfalls; Reka DateField/TimeField props verified in the installed reka-ui 2.9.10 |
| SC-2 | type: fileupload for attachOne/attachMany: limits, preview/thumbnail, remove, reorder, saved and unsaved records |
Winter FileUpload semantics; attach store API design; image guard currently lives only in the app; body-limit gap on raw admin routes; protected download route |
| SC-3 | Relation manager create/update/delete for hasMany and belongsToMany (with pivot) in a modal from the related model's fields.yaml, every child endpoint parent-scoped |
RelationContract extension; Winter RelationController handlers; route pattern conflict analysis; D-15 scoping queries; security test list |
| SC-4 | Deferred binding commit in the parent's create transaction, discard with the session, orphan purge | Winter DeferredBinding model and trait semantics; commit placement in CRUDService.save; lagoon.AfterCommit; purge and scheduler design |
| SC-5 | Unit tests in the last plan, docs checker passes | Validation Architecture; docs checker rules (identifier spans, commands, README sections) |
| </phase_requirements> |
Project Constraints (from CLAUDE.md)
- Go 1.27, standard library first. A dependency is added only when the research doc or a phase decision names it. This document names exactly one new direct dependency,
@internationalized/dateinadmin/package.json(see Standard Stack). No new Go module. - Compiled plugins only. No runtime plugin loading.
go vetandgo test ./...green at every commit. Early plans that add routes must update the existing route inventory tests (phase09Routesinmodules/cabana/security_coverage_test.go:34,TestPhase09ContractInventory), or the suite goes red.- Unit tests are always the last plan of the phase. Earlier plans may carry smoke tests.
- Plan-count checkpoint: present the suggested plan count with one-line scopes and wait for confirmation before writing PLAN.md files.
- Commits: no co-author tags (user global instruction overrides the attribution reminder); one logical change per commit; planning docs and code in separate commits.
- Any change to a
modules/package's exported API, config keys, CLI commands or dependencies updates that module'sREADME.mdand the affecteddocs/pages in the same change.go test ./cmd/summer -run TestDocsTreeandsummer docs:build --checkmust pass. Config keys in docs are not checked automatically: review by hand. - Framework READMEs and docs never name a consuming application. Use
acme,blog, "the host application". - Every identifier named in a README or docs page must exist in the package.
- Core plugin contracts (user, blog, pages, payment) must not break. This phase changes only
summercms.go; every existing public signature stays (additive changes only). - Lean mode: few, large plans. Skip optional agents unless a phase touches security or the plugin API. This phase touches both, so run the security review.
Summary
The phase adds three admin capabilities on top of a mature, fail-loud schema pipeline. The integration points are well defined. compileFieldNode (modules/cabana/form_schema.go:328-451) whitelists types and keys. CRUDService.save (modules/cabana/crud.go:290-391) is the one transaction where deferred bindings get committed. RelationService.Link/Unlink (modules/cabana/relation.go:625-733) already show the parent-scoped, row-locked mutation pattern that child CRUD must follow. lagoon.Migrate (modules/lagoon/migrations.go:63-110) is where the new deferred_bindings set is registered. Everything in cabana is compiled at boot with errors that name the plugin, controller and file, and new YAML keys must follow that rule.
Five findings change the plan's shape. (1) The image-content guard is not in the framework: it exists only in an application plugin (classes/image_guard.go), so D-07 has to port it into lagoon/attach. (2) lagoon.Fill cannot write a JSON string into time.Time or any struct date type, and cabana's list reflection treats every non-time.Time struct field as a relation. Both have to change before a datepicker can work. A downstream app currently works around exactly this with a string type. (3) attach cannot import lagoon, because lagoon already imports attach. Anything that needs both, such as the purge or after-commit blob deletes, lives in lagoon or cabana. (4) Admin routes are mounted with GroupRaw, so no surf body limit applies. The upload route must wrap the body in http.MaxBytesReader itself, sized from the required http.body_limits.upload_bytes. (5) The scheduler only runs pact.HasSchedule entries from plugins, and no command constructor that cabana owns receives the plugin list. A framework-owned daily purge therefore needs a small, additive hook in conga and a purge command that can resolve model types.
Winter's semantics are fully readable in the meta repo. Deferred children are real rows written with a NULL foreign key, then bound by session key. Add/remove pairs on the same slave cancel each other. attachOne add deletes the sibling file. Attachment remove deletes the file ('delete' => true is the attach default). Reorder and caption edits apply immediately, not deferred. cleanUp(5) deletes bindings older than five days. Port these behaviours one for one, adding what Winter lacks: admin ownership, parent scoping on child endpoints (Winter's onRelationManageDelete deletes any id without parent scoping), and server-side size limits (Winter enforces only PHP's upload_max_filesize, not the field's maxFilesize).
Primary recommendation: Build the phase as five plans. (1) lagoon/attach/conga foundations: Date types, the Fill fix, the deferred_bindings migration and store, the attach upload store and image guard, the purge command and the framework schedule entry. (2) cabana datepicker and fileupload with commit-on-save. (3) cabana relation child CRUD with hasMany, manage.form and pivot.form, and unsaved-parent deferral. (4) The SPA. (5) Unit and security tests, plus docs-checker hardening. Pass the session key in an X-Session-Key header, and use the owner id 0 on existing routes to mean "the unsaved record of this session".
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Field schema compile (datepicker/fileupload keys, relation forms) | API / Backend (cabana boot) | — | P9 D-06 fail-loud typed compile; the SPA only renders what the server compiled |
| Date/time value parsing, min/max enforcement | API / Backend (lagoon Fill + cabana save) | Browser (picker constraints, UX only) | Limits are enforced on the server; the SPA mirrors them for UX |
Timezone conversion for datetime |
Browser | Database (timestamptz UTC) | D-18: UTC stored, browser tz shown; server receives RFC 3339 with offset |
| Upload validation (size, type, image guard) | API / Backend (attach store) | Browser (accept attribute, pre-check) | D-08: never SPA-only |
| Blob storage and thumbnails | Database / Storage (gocloud bucket) | API (protected stream route) | Winter partition keys; public via host static serving, protected via admin route |
| Deferred binding state | Database / Storage (deferred_bindings) |
API (commit in save tx) | D-01, D-04 |
| Session key generation | Browser | API (format and ownership validation) | D-02 |
| Child CRUD scoping (IDOR) | API / Backend | Database (FK/pivot predicates, row locks) | D-15 |
| Orphan purge | API / Backend (CLI command, River periodic) | Database, Storage | D-05 |
| Child modal, pivot modal, file list UI | Browser (admin SPA) | — | P10 Direction C components, Reka UI primitives |
Standard Stack
Core (already in the repo, verified versions)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| gorm.io/gorm | v1.31.2 [VERIFIED: go.mod:38] |
Child saves, bindings, file rows | Project ORM |
| gocloud.dev | v0.46.0 [VERIFIED: go.mod:31] |
Blob writes/reads/deletes | Existing attach bucket abstraction |
| github.com/disintegration/imaging | v1.6.2 [VERIFIED: go.mod:10] |
Thumbnails (File.Thumb) |
Existing thumbnailer |
| golang.org/x/image | v0.46.0 [VERIFIED: go.mod:33] |
webp decode (P12 D-24) | Already registered in attach/thumb.go:21 |
| github.com/riverqueue/river | v0.47.0 [VERIFIED: go.mod:23] |
Daily purge via conga's periodic jobs | P11 scheduler |
| github.com/go-gormigrate/gormigrate/v2 | (in go.mod) | deferred_bindings migration set |
Every framework set uses it |
| reka-ui | 2.9.10 [VERIFIED: admin/package.json, node_modules/reka-ui/package.json] |
DatePicker, DateField, TimeField, Calendar, Dialog primitives | P10 D-07, D-21 |
Go stdlib mime/multipart, net/http, crypto/rand |
Go 1.27 | Streaming multipart, MaxBytesReader, DetectContentType |
stdlib-first |
Supporting (one new direct dependency, named here)
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| @internationalized/date | 3.12.4 [VERIFIED: npm registry; node_modules/@internationalized/date/package.json] |
CalendarDate, CalendarDateTime, ZonedDateTime, Time, parseDate, parseAbsolute, getLocalTimeZone to bind Reka v-models |
Already installed transitively by reka-ui ("@internationalized/date":"^3.5.0" in reka-ui's dependencies). Declare it as a direct dependency pinned to 3.12.4, so SPA source can import it without relying on hoisting. |
This document names @internationalized/date as the one dependency addition this phase authorizes (CLAUDE.md rule 4). No new Go dependency is needed.
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Reka DatePicker | native <input type=date> |
Ruled out by D-21 |
Streaming r.MultipartReader() |
r.ParseMultipartForm |
ParseMultipartForm spools large parts to temp files and parses every part; the streaming reader reads one file_data part under a byte cap |
Header X-Session-Key |
body field _session_key (Winter) |
A header works the same for JSON bodies, multipart bodies and GETs (pending-file lists, protected thumbnails); a body field would need three transports |
Installation (SPA only):
npm --prefix admin install --save-exact @internationalized/date@3.12.4
Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---|---|---|---|---|---|---|
| @internationalized/date | npm | latest 3.12.4 published 2026-09-01 (package line years old) | ~19.0M/wk | github.com/adobe/react-spectrum (packages/@internationalized/date) | [OK] (gsd-tools query package-legitimacy check) |
Approved. Already in admin/node_modules as a reka-ui dependency; postinstall: null |
Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none
Architecture Patterns
System Architecture Diagram
Admin SPA (FormView) cabana (backend guard, requireAjax on writes)
───────────────────── ───────────────────────────────────────────────
open form ─► sessionKey = random 256 bit ───────────┐
│ X-Session-Key header on every call below
DatepickerField ─ value (RFC3339 UTC | YYYY-MM-DD | HH:MM:SS) ─┐
FileuploadField ─ POST multipart file_data ─► upload route ─► MaxBytesReader(upload_bytes)
│ ─► attach.Store (ext/MIME/size/image guard)
│ ─► blob write ─► system_files row (unattached)
│ ─► deferred_bindings(is_bind=1, admin, key)
DELETE file ─► unbind (or cancel a pending bind + delete pending file)
reorder / caption ─► immediate sort_order / title on in-scope file rows
RelationManager ─ child modal (manage.form) ─► child CRUD routes
parent saved? ── yes ─► immediate write in tx, scoped by FK / pivot (D-15)
── no (id 0) ─► create child row (FK NULL) + bind; link/unlink → bind/unbind
Save ─ POST/PUT record + X-Session-Key ─► CRUDService.save tx:
Fill ─► Validate ─► FormBefore* ─► row write ─► syncBelongsToMany
─► commitDeferred(key, admin, master_type, declared fields) ─► FormAfter* ─► project
─► delete applied bindings (same tx) ; blob deletes via lagoon.AfterCommit ─► attach.DeleteKeys
│
River periodic (conga, daily) / `deferred:purge` ───┴─► expired bindings ─► delete created slaves
(unattached files + blobs, created child rows) ─► delete bindings
Recommended Project Structure (new and changed files)
modules/lagoon/
├── date.go # lagoon.Date, lagoon.TimeOfDay (Scanner/Valuer/JSON/Text)
├── deferred.go # DeferredBinding model + store ops (Bind, Unbind, Pending, Cancel, Purge)
├── deferred_migrations.go # DeferredBindingMigrations (gormigrate set)
├── migrations.go # Migrate: run the new set after attach.Migrations
├── fill.go # + encoding.TextUnmarshaler fallback
├── validate.go # + IsZero() emptiness for required
├── commands.go # + deferred:purge
├── schedule.go # FrameworkSchedule(app) consumed by conga
└── attach/
├── store.go # Store(ctx, db, bucket, Upload, Limits) (*File, error)
├── guard.go # AllowedImage / image dimension guard (ported from the app)
└── relation.go # attach.Relation, attach.HasRelations (AttachRelations())
modules/cabana/
├── form_schema.go # datepicker + fileupload keys, per-type key gating
├── field_date.go # compile + Go-type match + min/max server check
├── field_file.go # compile + routes (upload/list/remove/reorder/caption/download/thumb)
├── deferred.go # session key parse, commit inside save, list-with-deferred queries
├── relation.go / relation_child.go # Kind hasMany, manage/view/pivot forms, child CRUD, pivot edit
├── http.go # mount + nestedGet dispatch for new 6-segment GETs
└── admin_openapi.go # swag annotations for every new route
modules/pact/capabilities.go # optional Relation{Before,After}{Create,Update,Delete} hooks
modules/conga/scheduler.go # prepend lagoon.FrameworkSchedule entries
admin/src/components/form/fields/DatepickerField.vue, FileuploadField.vue
admin/src/components/relation/RelationChildModal.vue, RelationPivotModal.vue
admin/src/app/sessionKey.ts, admin/src/app/dateFormat.ts
Pattern 1: Per-type YAML key gating (extend, don't fork)
What: compileFieldNode first checks every key against the global formFieldKeys set, then type-specific helpers refuse keys on the wrong type (compileWidgetKeys, compilePartialPath).
Current values [VERIFIED: modules/cabana/form_schema.go:22-42]:
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {},
}
formFieldKeys = map[string]struct{}{
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
"widget": {}, "action": {}, "fill": {}, "path": {},
}
widgetKeys = []string{"widget", "action", "fill"}
Do: add "datepicker" and "fileupload" to formFieldTypes. Add the D-20 and D-08 keys to formFieldKeys: mode, format, minDate, maxDate, yearRange, firstDay, twelveHour, ignoreTimezone, fileTypes, mimeTypes, maxFilesize, maxFiles, imageWidth, imageHeight, thumbOptions, useCaption, prompt. Add compileDatepickerKeys and compileFileuploadKeys modelled on compileWidgetKeys (form_schema.go:458-499): a key outside its type answers "<key> is only valid on type: <type>". mode is shared, with different value sets per type. Also add datepicker to scalarFormField (crud.go:800-807), so it binds as a writable column and its required merges. fileupload stays non-scalar.
FormField JSON: keep Winter spellings as flat omitempty fields on FormField (schema_types.go:197-229), like the existing widget/action/fill/path. swag picks them up for OpenAPI automatically.
Pattern 2: Model-declared contracts, boot-checked (D-06, D-13)
AdminRelationContractProvider (relation.go:22-24) and FieldRelationProvider (relation_field.go:46-48) are type-asserted capabilities checked against model columns at boot. The current contract [VERIFIED: modules/cabana/relation.go:27-36]:
type RelationContract struct {
Name string
NewRelated func() any
NewPivot func() any
ParentForeignKey string
RelatedForeignKey string
Columns map[string]string
HookPivotColumns []string
ExcludedRelatedIDs func(parent any) ([]uint, error)
}
Do (additive, non-breaking): add Kind string (empty means belongsToMany, the current behaviour, so existing contracts keep compiling) and ForeignKey string (the child's column pointing at the parent, hasMany only). validateRelationContract (relation.go:338-394) branches by kind. For hasMany: NewPivot, ParentForeignKey, RelatedForeignKey and HookPivotColumns must be empty; ForeignKey must be an identifier that is a column of the related model; uintLike. The owner struct field check (relationFieldName) stays. unlink, and deferral on an unsaved parent, need a nullable FK (*uint). Do not fail boot for a non-nullable FK. Instead mark the relation deferrable: false: the SPA hides it on create as it does today, and the unlink route refuses with 403. This keeps existing apps booting. For files, define attach.Relation{Name string; Many bool; Public bool} and an interface attach.HasRelations { AttachRelations() []attach.Relation } on the model returned by AdminRecordSource.NewRecord(). The model must also implement attach.Owner (MorphName()), because system_files.attachment_type comes from it [VERIFIED: modules/lagoon/attach/file.go:17-22]. Boot error otherwise.
Pattern 3: Parent-scoped, row-locked mutation transaction (D-15)
Link (relation.go:625-692) is the model to copy: lagoon.Transaction → withTx → newWritableModel → loadRecord(ctx, tx, cc, parent, ownerID). loadRecord applies pact.FormExtendQuery and FOR UPDATE (crud.go:441-461). After that comes a child query constrained by the parent predicate. Every child endpoint does this:
- hasMany:
WHERE related.pk = :child AND related.<ForeignKey> = :parentPK(clause.EqwithquotedIdent, never string-built column names). - belongsToMany:
JOIN pivot p ON p.<RelatedFK> = related.pk WHERE p.<ParentFK> = :parentPK AND related.pk = :child(reuserelationBaseQuery(..., candidates=false),relation.go:465-494). - Unsaved parent (owner id
0):related.pk IN (SELECT slave_id::bigint FROM deferred_bindings WHERE session_key=? AND backend_user_id=? AND master_type=? AND master_field=? AND is_bind), minus later unbinds (WinterwithDeferred, below). - A miss returns
recordNotFound{}, whichwriteCRUDErrormaps to 404not_found(crud.go:393-410). Never 403 for a foreign child: that would leak existence.
Pattern 4: Winter withDeferred list query (port verbatim in spirit)
Winter [CITED: vendor/winter/storm/src/Database/Relations/Concerns/DeferOneOrMany.php]: rows = (existing relation rows, when the parent exists) OR (pk IN bound slave_ids for this master_field, master_type and session_key), AND pk NOT IN (unbound slave_ids whose binding id is greater than the latest bind binding id for that slave). Compare with CAST(pk AS TEXT), because slave_id is a string column. Use this for (a) file lists on saved and unsaved records (files are always deferred) and (b) relation lists on unsaved parents. Add backend_user_id = :admin to every subquery (D-02).
Pattern 5: Commit inside the save transaction (D-04)
Place the commit in CRUDService.save (crud.go:310-386) after the row write and syncBelongsToMany (line 373), and before formAfterCreate/formAfterUpdate (lines 376-383). Create needs the new PK, and Winter's commitDeferredAfter runs in the model's afterSave, before the controller's formAfterSave. This satisfies D-04 ("after FormBefore*, before commit"). Steps:
SELECT ... FROM deferred_bindings WHERE session_key=? AND backend_user_id=? AND master_type=? ORDER BY id FOR UPDATE. This serializes double-submits with the same key.- Ignore (do not apply) bindings whose
master_fieldis not a fileupload field or relation-manager relation of this controller in this operation'scontext. Leave them for purge. - Apply in id order. File bind: set
attachment_type,attachment_id,field; forattachOne, first delete the currently attached siblings (rows now, blob keys vialagoon.AfterCommit→attach.DeleteKeys). File unbind: delete the file row, with blobs after commit (attach relations delete by default, WintergetRelationDefaults). Relation bind: hasMany sets the FK through a modelSaveso hooks run; belongsToMany runs the existingLinkeligibility logic (re-runsrelationBaseQuery(candidates=true)andRelationBeforeLink) withpivot_data. Unbind: the unlink logic. - Re-check
maxFilesand fileuploadrequiredafter applying. On failure, return a 422 on that field; the transaction rolls back and the bindings survive. DELETE FROM deferred_bindings WHERE id IN (applied)in the same transaction. ExtendRecordInput(crud.go:26-28) withSessionKey string(additive). The admin id comes frombouncer.User(ctx).
Pattern 6: Six-segment GETs go through nestedGet
GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name} is one pattern, because ServeMux refused the overlapping relation-list and field-options routes (http.go:249-253, dispatch http.go:299-313). Add case segment == "files": to nestedGet for the file list. New routes whose literal segments sit in the same position as an existing route's literal (files vs relations at segment 5, records vs pivot vs link/unlink/candidates at segment 7) are disjoint and do not conflict. ServeMux panics at registration on a conflict, and TestPhase09PermissionMatrix mounts every route, so a conflict shows up at once.
Recommended route table (all under {prefix}/api/v1, the backend guard, protect(); writes wrapped in requireAjax):
| Method | Path | Purpose |
|---|---|---|
| GET (nestedGet) | /{v}/{p}/{c}/{id}/files/{field} |
List attached + pending files (withDeferred), with URLs/thumb URLs |
| POST | /{v}/{p}/{c}/{id}/files/{field} |
Multipart upload (file_data part), deferred bind |
| PUT | /{v}/{p}/{c}/{id}/files/{field}/{file} |
Caption (title, description) when useCaption |
| DELETE | /{v}/{p}/{c}/{id}/files/{field}/{file} |
Deferred remove (or cancel a pending upload) |
| POST | /{v}/{p}/{c}/{id}/files/{field}/reorder |
{ids:[...]} → sort_order (attachMany only) |
| GET | /{v}/{p}/{c}/{id}/files/{field}/{file}/download |
Protected original (is_public=false only) |
| GET | /{v}/{p}/{c}/{id}/files/{field}/{file}/thumb |
Protected thumbnail |
| POST | /{v}/{p}/{c}/{id}/relations/{name}/records |
Create child (deferred when id=0) |
| GET/PUT/DELETE | /{v}/{p}/{c}/{id}/relations/{name}/records/{child} |
Show/update/delete child |
| GET/PUT | /{v}/{p}/{c}/{id}/relations/{name}/pivot/{child} |
Read/edit pivot fields (belongsToMany + pivot.form) |
| POST | .../relations/{name}/link (existing) |
Body gains optional pivot object (whitelisted by pivot.form) |
| (child files) | /{v}/{p}/{c}/{id}/relations/{name}/records/{child}/files/{field}[...] |
Same file ops for a child form (child 0 = new child, D-17) |
{id} = 0 means "the record being created in this session". pathID already parses 0 (crud.go:642-649), and today loadRecord 404s it, so the meaning is backward compatible. Without a valid X-Session-Key, id 0 stays a 404.
Pattern 7: Framework-owned schedule entry
conga.scheduleEntries reads only pact.HasSchedule from plugins [VERIFIED: modules/conga/scheduler.go:63-99], with entry ids fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command). Add lagoon.FrameworkSchedule(app *backpack.App) []pact.ScheduledCommand, returning {Command: "deferred:purge", Cadence: pact.DailyAt(h, m)} unless disabled in config. Have scheduleEntries prepend it with the id prefix summercms.lagoon (conga already imports lagoon, conga/conga.go:18). Put the deferred:purge command in lagoon.RuntimeCommands(app, plugins) (lagoon/commands.go:16). Every generated main already includes it in the bonfire catalog (internal/build/build.go:114), so the scheduled run resolves the command, and the docs checker learns the name through cmd/summer/docs.go:151. No change to generated main or the app repos.
Anti-Patterns to Avoid
- Guessing the morph type or table from a Go type name. Use
attach.Owner.MorphName()when the model implements it, otherwise the GORM table name cabana already derives (tableName(model)). Neverreflect.TypeOf(x).String(). - Committing bindings outside the save transaction or deleting them before commit. Both break D-04's "422 doesn't lose uploads".
- Deleting blobs inside the transaction. A rollback cannot restore bytes. Use
lagoon.AfterCommit(lagoon/transaction.go:103) +attach.DeleteKeys(attach/file.go:157-183). - Answering 403 for another parent's child. Use 404 (D-15).
- Trusting the client filename or Content-Type header for type checks. Sniff the bytes and apply the image guard. Generate the disk name on the server.
- Running relation hooks with an unsaved parent. Run
RelationBeforeLinkat commit time, when the parent has a PK. A deferred link stores onlypivot.form-whitelisted values inpivot_data.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Calendar grid, keyboard nav, a11y, locale segments | custom calendar | Reka DatePickerRoot/DatePickerCalendar/DateFieldRoot/TimeFieldRoot |
Verified in installed 2.9.10 types (see Code Examples) |
| Date math, tz conversion in the SPA | new Date() arithmetic |
@internationalized/date (parseAbsolute, toZoned, getLocalTimeZone, CalendarDate, Time) |
DST and locale edge cases; new Date("2026-10-02") parses as UTC midnight |
| Upload size cap | counting bytes by hand | http.MaxBytesReader + io.LimitReader |
Stdlib; P7 D-04 pattern |
| Content sniffing | magic-byte tables | http.DetectContentType + image.DecodeConfig |
Exactly what the app guard does today |
| After-commit work | ad-hoc goroutines | lagoon.AfterCommit |
Runs only on commit, ordered, already used |
| Blob prefix delete (thumbs) | listing manually | attach.DeleteKeys with thumb_<id>_ prefix keys via blobKeysFor |
Handles NotFound and prefixes |
| Thumbnail generation | new resizer | (*attach.File).Thumb (lazy, cached, pixel-guarded) |
attach/thumb.go:125-209 |
| Random session key | Math.random |
crypto.getRandomValues(new Uint8Array(32)) → base64url |
D-02 at least 128 bits |
| Periodic job wiring | new River client code | conga scheduler entry + bonfire command | Leader election, uniqueness, forged-row protection already in conga |
Key insight: almost every primitive exists. The phase is mostly wiring with strict scoping; the risk is in the joins between modules, not in new algorithms.
Runtime State Inventory
Not a rename or refactor phase. One new table is migrated in every host application (deferred_bindings); that is covered by D-01's reversibility note. Nothing else is stored, registered or cached under a changed name. Stored data: None. Live service config: None. OS-registered state: None. Secrets/env vars: None (new config keys are optional with defaults). Build artifacts: the committed SPA build modules/boardwalk/dist must be rebuilt (scripts/check-admin-dist.sh), and admin/openapi/admin.json + admin/src/api/schema.d.ts regenerated (scripts/check-admin-openapi.sh).
Common Pitfalls
Pitfall 1: lagoon.Fill cannot fill time.Time (or any date struct) from JSON text
What goes wrong: A datepicker posts "2026-10-02T12:30:00Z". convertValue tries AssignableTo, then json.Number, then ConvertibleTo, and fails. The non-pointer path falls back to sql.Scanner only. The pointer path (*time.Time) returns the conversion error with no Scanner fallback at all [VERIFIED: modules/lagoon/fill.go:112-155, 172-193]. time.Time is not a Scanner, so cabana answers 422 "invalid value". A downstream app documents this exact workaround (a string-typed DateTime with a "Fill support for time.Time" TODO).
How to avoid: in convertValue, after the ConvertibleTo check fails and only for a string or []byte source, use encoding.TextUnmarshaler on reflect.New(destType). time.Time.UnmarshalText parses RFC 3339. lagoon.Date / lagoon.TimeOfDay implement UnmarshalText. This also covers the pointer path, because setField converts to the element type first. The ordering keeps every case that works today unchanged.
Warning signs: a 422 on a valid datetime; FillTypeError in logs.
Pitfall 2: cabana's list reflection treats lagoon.Date as a relation
What goes wrong: isListRelation returns true for any struct except time.Time and gorm.DeletedAt [VERIFIED: modules/cabana/list_schema.go:418-438]. A lagoon.Date column vanishes from list columns and is classed as a relation.
How to avoid: exclude struct types whose pointer implements sql.Scanner or whose value implements driver.Valuer, as embeddedStructType already does (model_fields.go:70-82). Check every other struct-kind switch for the same assumption (partial_render.go:351,415,454, query.go:499,516).
Pitfall 3: required passes on a zero time.Time
What goes wrong: isEmptyValue treats only nil, pointers, strings, slices and maps as empty [VERIFIED: modules/lagoon/validate.go:264-277]. A non-pointer time.Time{} (or lagoon.Date{}) is "present", so a required datepicker with no value saves 0001-01-01.
How to avoid: treat interface{ IsZero() bool } values as empty in isEmptyValue. Recommend nullable pointer fields for optional dates in the docs. Flag the lagoon behaviour change in its README.
Pitfall 4: Admin routes have no body limit
What goes wrong: cabana mounts every route with GroupRaw, and surf skips its body limit for raw routes (surf/bodylimit.go:29-33: if rt.raw { return 0, nil }). decodeObject (crud.go:629-640) reads unbounded JSON today. An upload route without its own cap accepts unlimited bytes.
How to avoid: wrap the upload body in http.MaxBytesReader(w, r.Body, min(http.body_limits.upload_bytes, maxFilesize + multipart overhead)). Enforce maxFilesize on the part with io.LimitReader(part, max+1). Give the new JSON child/pivot/caption/reorder routes a cap too (for example http.body_limits.default_bytes). At cabana.Activate, refuse a field whose maxFilesize exceeds upload_bytes (Winter throws the same way when maxFilesize > upload_max_filesize).
Pitfall 5: The image guard is application code, not framework code
What goes wrong: D-07 says "applies the image-content guard (including webp)", but IsAllowedImage lives in an application plugin (classes/image_guard.go), not in summercms.go. The framework has only the thumbnailer's pixel check.
How to avoid: port it into lagoon/attach: sniff with http.DetectContentType ∈ {image/jpeg, image/png, image/gif, image/webp}, then image.DecodeConfig format ∈ {jpeg, png, gif, webp} with width and height > 0, failing closed. Add a pixel ceiling consistent with maxThumbSourcePixels = 4096 * 4096 (attach/thumb.go:24-28), so every accepted image can be thumbnailed. With mode: image, the guard narrows Winter's image extension list (avif, bmp, svg are refused: no decoder or active content).
Pitfall 6: Protected files share the public bucket
What goes wrong: attach has a single bucket. Its doc says "Protected files must not be stored in this bucket (Winter uses a second disk)" [VERIFIED: modules/lagoon/attach/static.go:226-237]. If the host serves the bucket directory directly (web server or the ungated StaticHandler), an is_public=false blob is reachable by anyone who knows its disk name.
How to avoid: the disk name is 88 random bits (22 hex characters), so a URL is not guessable. The framework must never emit a public URL for a protected file (the SPA gets the admin route only), and the docs must say to mount StaticHandlerPublic (which 404s is_public=false) or keep directory listing off. Whether to add a second bucket key (Winter's protected disk) is an open question (see Open Questions).
Pitfall 7: The deferred child FK must be nullable (Winter has the same constraint)
What goes wrong: Winter creates a hasMany child on an unsaved parent as a real row with FK = parentKey, which is NULL [CITED: modules/backend/behaviors/RelationController.php onRelationManageCreate]. A NOT NULL child FK makes that insert fail.
How to avoid: compute deferrable per relation at boot. hasMany needs a pointer FK. belongsToMany is always deferrable (the pivot is written at commit). Both need a resolvable master type. Expose deferrable in the relation schema. On a non-deferrable relation the SPA keeps today's behaviour (hidden on create, registry.ts:51 recordBound).
Pitfall 8: Telling deferred-created children from deferred-linked ones at purge
What goes wrong: D-05 deletes "a deferred-created child row". The deferred_bindings columns (D-01) cannot tell "created in this session" from "existing orphan linked in this session". Purging the second kind destroys a real record.
How to avoid (recommendation, needs confirmation, A3): store a framework envelope in pivot_data: {"created": true, "pivot": {...}}. hasMany has no pivot, so the field is free there; belongsToMany keeps its pivot values under pivot. Purge deletes the slave only when created is true and the binding is is_bind. Also exclude rows with a live created binding from hasMany link candidates, so another parent cannot adopt a pending child before purge.
Pitfall 9: Purge needs model types for created children
What goes wrong: deleting a created child row through its model (hooks, Unscoped) needs the Go type behind slave_type. cabana's compiled registry is built only in cabana.Activate (serve). cabana.RuntimeCommands(app) gets no plugin list (cabana/commands.go:18), and party does not publish activated plugins (party/registry.go:48-110; only app.SetPlugins(orderedIDs)).
How to avoid (recommended): put deferred:purge in lagoon.RuntimeCommands(app, plugins), which does receive plugins. Resolve slave_type against the models of every plugin's pact.HasModels().Models() (keyed by MorphName() and by table name). At cabana boot, fail when a deferrable relation that offers create points at a related model no activated plugin lists in Models(). Alternative: add an additive party publish of the activated plugin slice and keep purge in cabana. This touches party and its README.
Pitfall 10: Winter pivot.form field names
What goes wrong: Winter pivot forms name fields pivot[role]. cabana's identifier() refuses [, so a copied Winter pivot fields.yaml fails boot.
How to avoid: decide on bare pivot column names in the Go port and document it, or accept pivot[x] and strip the wrapper during compile. Recommendation: accept both, normalise to the bare name, and say so in relation-manager.md.
Pitfall 11: $/vendor/plugin/... paths
What goes wrong: assetPath handles only ~/plugins/<vendor>/<plugin>/... and plugin-relative paths (cabana/schema.go:37-47). D-11's example $/vendor/plugin/models/child/fields.yaml (Winter's $/ = plugins dir) is not resolved, and a $/ path to another plugin cannot be read from this plugin's AdminFS.
How to avoid: extend assetPath to strip $/<vendor>/<plugin>/ when it names the same plugin. Fail boot with a clear message on a cross-plugin path.
Pitfall 12: The datetime list cell shifts date values
What goes wrong: CellValue.vue builds new Date(value) (line 40). "2026-10-02" parses as UTC midnight and shows the previous day west of UTC.
How to avoid: add trivial date and time list column types that render the string as-is. listColumnTypes today [VERIFIED: modules/cabana/list_schema.go:22-24]: "text": {}, "datetime": {}, "switch": {},.
Pitfall 13: Reka segment order is locale-driven; format is display-only
What goes wrong: Winter's format is a PHP date() format, converted with DateTimeHelper::momentFormat [CITED: modules/system/helpers/DateTime.php:88-135]. Reka's DateField renders segments in locale order, so format cannot reorder the editable segments.
How to avoid: compile format at boot into a token list (Winter's mapping table: d→DD, j→D, m→MM, n→M, Y→YYYY, y→YY, H→HH, G→H, h→hh, g→h, i→mm, s→ss, A/a, month and day names), with a boot error on tokens that have no equivalent (t, L, B, I, O, P, T, Z, c, r). Apply it to the read-only/preview text and the trigger label only.
Pitfall 14: The relation manager on the create screen is a visible behaviour change
What goes wrong: today FormView.vue:71-77 drops every relation-manager on create. With deferral, deferrable relation managers render on create, so existing apps with belongsToMany managers gain them on their create screens. Their ExcludedRelatedIDs(parent) receives a parent with zero values (no owner stamped yet), so candidates may include rows the saved parent would exclude.
How to avoid: re-run eligibility at commit (Link's logic already does: holder.Elem().Len() != len(pending) → relationInvalid, relation.go:658-660). Map that 422 onto the relation-manager field name. Note the behaviour change in the cabana README and docs/backend/relation-manager.md.
Pitfall 15: Winter toolbarButtons vs this port's capability rule
What goes wrong: in Winter, clicking a hasMany row opens the update form whatever toolbarButtons lists. In this port the view panel's buttons are the capability (http.go:471-484), and compileRelationButtons accepts only link/unlink today [VERIFIED: modules/cabana/relation.go:314-336]: if part != "link" && part != "unlink" { ... unsupported relation action ... } and if !view && part == "unlink" { ... manage panel cannot declare unlink ... }.
How to avoid: extend the accepted set to create|update|delete|link|unlink (D-12). Treat update as the gate for row-click edit and the PUT route. See Open Question 3 for whether create implies update.
Code Examples
Winter deferred_bindings shape (D-01 source of truth)
// Source: vendor/winter/storm/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php
$table->increments('id');
$table->string('master_type')->index();
$table->string('master_field')->index();
$table->string('slave_type')->index();
$table->string('slave_id')->index();
$table->string('session_key');
$table->boolean('is_bind')->default(true);
$table->timestamps();
// 2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php
$table->mediumText('pivot_data')->nullable()->after('slave_id');
Go port migration (Postgres DDL, following attach.Migrations style; the column name for the admin id is the planner's choice, backend_user_id recommended):
// Recommended; ID follows the existing "YYYYMMDDNNNN_<name>" convention (attach uses "202609180001_create_system_files").
`CREATE TABLE deferred_bindings (
id SERIAL PRIMARY KEY,
master_type TEXT NOT NULL,
master_field TEXT NOT NULL,
slave_type TEXT NOT NULL,
slave_id TEXT NOT NULL,
pivot_data TEXT,
session_key TEXT NOT NULL,
is_bind BOOLEAN NOT NULL DEFAULT TRUE,
backend_user_id INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`
// Winter indexes plus the hot lookup:
`CREATE INDEX deferred_bindings_master_type_index ON deferred_bindings (master_type)`
`CREATE INDEX deferred_bindings_master_field_index ON deferred_bindings (master_field)`
`CREATE INDEX deferred_bindings_slave_type_index ON deferred_bindings (slave_type)`
`CREATE INDEX deferred_bindings_slave_id_index ON deferred_bindings (slave_id)`
`CREATE INDEX deferred_bindings_session_lookup_index ON deferred_bindings (session_key, backend_user_id, master_type)`
`CREATE INDEX deferred_bindings_created_at_index ON deferred_bindings (created_at)`
Register it in lagoon.Migrate right after the summercms.attach set (migrations.go:67-73) under its own history id (for example summercms.deferred). Do not append to attach.Migrations: backend_admin_migrations_test.go:64,216 reads summer_migrations_summercms_attach ids.
Winter semantics to port (verbatim behaviour)
// Source: vendor/winter/storm/src/Database/Models/DeferredBinding.php
public function beforeCreate() { // dedupe + cancel add/remove pairs
if ($existingRecord = $this->findBindingRecord()) { // same master_type, master_field, slave_type, slave_id, session_key
if ($this->is_bind != $existingRecord->is_bind) { $existingRecord->deleteCancel(); return false; }
return false; // skip repeating bindings
}
}
public static function cleanUp(int $days = 5): void { /* created_at < now - days → deleteCancel() */ }
protected function deleteSlaveRecord(): void { /* only if is_bind, relation 'delete' option, and FK null */ }
// Source: Relations/Concerns/AttachOneOrMany.php add(): AttachOne deletes siblings; remove(): delete when 'delete' option
// Source: Concerns/HasRelationships.php getRelationDefaults(): attachOne/attachMany => ['order' => 'sort_order', 'delete' => true]
Reka UI props (installed 2.9.10, read from node_modules/reka-ui/dist/index4.d.ts)
// DatePickerRootProps = Omit<DateFieldRootProps,'as'|'asChild'> & PopoverRootProps
// & Pick<CalendarRootProps,'isDateDisabled'|'pagedNavigation'|'weekStartsOn'|'weekdayFormat'|'fixedWeeks'|'numberOfMonths'|'preventDeselect'>
// & { closeOnSelect?: boolean }
// DateFieldRootProps: modelValue?: DateValue | null; hourCycle?: HourCycle; granularity?: Granularity;
// hideTimeZone?: boolean; minValue?: DateValue; maxValue?: DateValue; locale?: string; disabled?; readonly?; id?
// TimeFieldRootProps: modelValue?: TimeValue | null; granularity?: 'hour'|'minute'|'second'; hourCycle?; minValue?; maxValue?; locale?
// Parts: DatePickerRoot, DatePickerField, DatePickerInput, DatePickerTrigger, DatePickerContent,
// DatePickerCalendar, DatePickerHeader, DatePickerPrev, DatePickerHeading, DatePickerNext,
// DatePickerGrid, DatePickerGridHead, DatePickerGridBody, DatePickerGridRow, DatePickerHeadCell,
// DatePickerCell, DatePickerCellTrigger; TimeFieldRoot, TimeFieldInput
Mapping (recommended):
| Winter key | Reka prop |
|---|---|
mode: date |
DatePickerRoot with CalendarDate (parseDate("2026-10-02")), granularity day |
mode: datetime |
DatePickerRoot with ZonedDateTime (parseAbsolute(iso, getLocalTimeZone())), granularity minute; emit toAbsoluteString() (UTC Z) |
mode: datetime + ignoreTimezone |
CalendarDateTime from the UTC wall clock; emit YYYY-MM-DDTHH:MM:SSZ with no shift |
mode: time |
TimeFieldRoot with Time (parseTime("14:30:00")) |
twelveHour |
hourCycle: 12 (else 24) |
firstDay (0-6) |
weekStartsOn |
minDate/maxDate |
minValue/maxValue (and server re-check) |
yearRange |
clamp minValue/maxValue when no explicit min/max (UX only) |
attach store API shape (recommended, discretion)
// modules/lagoon/attach/store.go
type Upload struct {
FileName string // client name: extension + file_name column only
Body io.Reader // already wrapped in a byte cap by the caller
Public bool
}
type Limits struct {
MaxBytes int64 // 0 = no field limit (the caller's MaxBytesReader still applies)
Extensions []string // lower-case, no dot; empty = Winter default list for the mode
MIMETypes []string // "image/png" or "image/*"; empty = no MIME filter
Image bool // apply AllowedImage + pixel ceiling
}
var (ErrTooLarge, ErrFileType, ErrMIMEType, ErrNotImage error) // mapped by cabana to 422 on the field
// Store writes the blob at BlobKey(diskName), inserts the system_files row (attachment
// columns NULL, sort_order = id as Winter's Sortable), deletes the blob again if the row
// insert fails, and returns the row. It never imports lagoon (import cycle).
func Store(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, in Upload, lim Limits) (*File, error)
Disk name: 22 lowercase hex characters from crypto/rand plus the lowercased client extension (same as the app's StorePublicFile, a Winter-compatible shape). Never use any client path component.
lagoon.Date / TimeOfDay (recommended, discretion)
type Date struct{ y int; m time.Month; d int; valid bool } // DATE; JSON "2006-01-02"
type TimeOfDay struct{ h, m, s int; valid bool } // TIME; JSON "15:04:05"
// Methods: Scan(any) error (time.Time, string, []byte), Value() (driver.Value, error) → "2006-01-02"/"15:04:05" string,
// MarshalJSON/UnmarshalJSON, MarshalText/UnmarshalText, IsZero(), String(); Date.Time(loc) / DateOf(t).
// Nullable variants: *lagoon.Date, *lagoon.TimeOfDay (nil ↔ NULL), consistent with *time.Time.
pgx's database/sql driver reports time.Time as the scan type for DATE and string for TIME [VERIFIED: pgx/v5@v5.10.0 stdlib/sql.go:715-720]: case pgtype.DateOID, pgtype.TimestampOID, pgtype.TimestamptzOID: return reflect.TypeFor[time.Time]() and default: return reflect.TypeFor[string](). So Date.Scan must accept time.Time (take Y/M/D in UTC) and TimeOfDay.Scan must accept string/[]byte.
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
Winter deferredBinding session key in _session_key POST field, unbound to a user |
SPA key + admin id on every row (D-02) | this phase | Stops cross-admin key reuse |
| Winter relation delete unscoped by parent | Parent-scoped child endpoints (D-15) | this phase | Closes an IDOR Winter has |
Winter maxFilesize enforced only client-side (server uses upload_max_filesize) |
Server enforces field maxFilesize (D-08) |
this phase | Stricter than Winter |
| Admin form with no date type (string workaround in apps) | lagoon.Date/TimeOfDay/time.Time + Fill TextUnmarshaler |
this phase | Apps can drop string date types |
Deprecated/outdated: the forms doc sentence that file upload "is not provided ... stops the start-up" (docs/backend/forms.md Field types section) must be rewritten.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Owner id 0 in existing route patterns is an acceptable stand-in for "unsaved record" (rather than a literal segment such as new) |
Pattern 6 | Low: only route shape and docs change |
| A2 | X-Session-Key header (base64url, accepted pattern ^[A-Za-z0-9_-]{32,128}$) is the session-key transport |
Summary, routes | Low |
| A3 | pivot_data may carry a framework envelope {"created":true,"pivot":{...}} to mark deferred-created slaves, given D-01 allows only one added column |
Pitfall 8 | Medium: the alternative is a second added column, which contradicts D-01's "one column is added" |
| A4 | master_type/slave_type = MorphName() when the model implements attach.Owner, else the GORM table name |
Anti-patterns | Medium: D-01 says "the same morph type string ... No PHP class names"; the table-name fallback applies only to models that have no morph name |
| A5 | Purge lives in lagoon.RuntimeCommands(app, plugins) and resolves created-child models from pact.HasModels |
Pitfall 9 | Medium: the alternative needs a party publish |
| A6 | Child forms refuse relation, relation-manager, widget and partial field types at boot in this phase (only relation-manager is mandated by D-17) |
Open Q 2 | Medium: a downstream child form might need a belongsTo picker |
| A7 | Reorder and caption edits apply immediately (Winter parity), not deferred | Pattern 5 | Low: Cancel does not revert a reorder |
| A8 | mimeTypes accepts both MIME names (image/png, image/*) and extensions |
attach store | Low. Winter's exact semantics for this key were not verified this session |
| A9 | Purge config keys: database.deferred_bindings.purge_days (default 5) and database.deferred_bindings.purge_at ("03:00", empty disables the framework schedule entry) |
Pattern 7 | Low: discretion item |
| A10 | Fileupload required is checked at commit as "at least one file after applying bindings" |
Pattern 5 | Low |
| A11 | Default preview thumbnail when imageWidth/imageHeight are absent: 240×240, mode from thumbOptions.mode (default crop, Winter's default) |
Discretion | Low |
| A12 | thumbOptions is a mapping accepting only mode ∈ {auto, exact, crop, fit}; Winter's extension and other keys are boot errors |
D-08 | Low |
| A13 | Winter's showWeekNumber and iconClass/attachOnUpload/emptyIcon are refused (not in D-20/D-08), so Winter YAML using them fails boot with a clear error |
Pitfalls | Low; consistent with P9 D-06 |
Open Questions (RESOLVED)
- A second bucket for protected files?
- What we know: one bucket today, documented as public-only (
static.go:226-237). Winter keeps protected files on a separate disk. - Unclear: whether hosts serve the bucket directory directly.
- Recommendation: no second bucket in v0.1.1. Unguessable disk names, no public URL ever emitted for protected files, and a docs warning to use
StaticHandlerPublicor disable listing. Add an optionalstorage.uploads.protected_bucket_urllater if needed. - RESOLVED: no second bucket in v0.1.1 (planner assumption, 12.2-01-PLAN.md).
- What we know: one bucket today, documented as public-only (
- Field types inside child forms (A6). Recommend datepicker, fileupload and the scalar types only. Supporting
relationpickers in child forms needs a child-scoped options route and a contract slot (RelationContract.FieldRelations). Ask whether the downstream needs it now.- RESOLVED: D-23 (user decision 2026-10-02): scalars, datepicker and fileupload only;
relationpickers deferred.
- RESOLVED: D-23 (user decision 2026-10-02): scalars, datepicker and fileupload only;
- Does
createimplyupdatefor hasMany row editing (Winter parity), or mustupdatebe listed? Recommendation: requireupdateexplicitly (the capability rule stays literal) and document the difference from Winter.- RESOLVED:
updatemust be listed explicitly (12.2-03-PLAN.md).
- RESOLVED:
- Cancel endpoint. Winter leaves abandoned bindings to
cleanUp. Recommendation: no cancel endpoint in v0.1.1; purge handles it. Optionally add akeepalivefetch on route leave later.- RESOLVED: no cancel endpoint; purge handles abandoned bindings (12.2-03-PLAN.md).
- Top-level
form:in config_relation.yaml. Winter falls back frommanage.formto a top-levelform(makeConfigForMode). Recommendation: accept the top-levelformas Winter's fallback for both panels.- RESOLVED: top-level
formaccepted as the fallback formanage.formandview.form(12.2-03-PLAN.md).
- RESOLVED: top-level
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Go | everything | ✓ | go1.27.0 | — |
| Docker | testcontainers Postgres tests | ✓ | 29.7.2 | -short skips DB tests |
| Node | SPA build/tests | ✓ | v22.23.2 (engines >=22.6) |
— |
| npm | SPA | ✓ | 12.0.2 | — |
| swag v1.16.6 | scripts/check-admin-openapi.sh |
✓ (module cache) | v1.16.6 | — |
| @internationalized/date | DatepickerField | ✓ (transitive) | 3.12.4 | — |
Missing dependencies with no fallback: none.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework (Go) | testing + testify; testcontainers-go Postgres (TestMain in modules/lagoon/postgres_test.go, modules/cabana/auth_test.go/query_test.go); -short skips DB tests |
| Framework (SPA) | vitest 3.2.7 + @vue/test-utils 2.4.11 + happy-dom (admin/vitest.config.ts, tests in admin/tests/**) |
| Quick run command | go test -short ./modules/cabana/... ./modules/lagoon/... ./modules/conga/... and npm --prefix admin test -- tests/form tests/relation |
| Full suite command | go vet ./... && go test ./... && 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 && go run ./cmd/summer docs:build --check |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| SC-1 | datepicker keys compile; unknown key and mode/type mismatch fail boot | unit | go test -short ./modules/cabana -run TestDatepicker |
❌ Wave 0 |
| SC-1 | lagoon.Date/TimeOfDay Scan/Value/JSON round-trip; Fill from string into time.Time, *time.Time, Date, *Date; IsZero required |
unit + PG integration | `go test ./modules/lagoon -run 'TestDate | TestTimeOfDay |
| SC-1 | min/max enforced server-side (422) | integration | go test ./modules/cabana -run TestDatepickerBounds |
❌ Wave 0 |
| SC-2 | attach.Store: size cap, extension, MIME, image guard (polyglot, webp ok, svg refused), sort_order=id, blob removed on row failure | unit (mem bucket) + PG | go test ./modules/lagoon/attach -run TestStore |
❌ Wave 0 |
| SC-2 | upload route: MaxBytesReader 413/422, maxFiles, deferred bind, list withDeferred, remove cancels pending, reorder, caption, attachOne sibling delete at commit | integration | go test ./modules/cabana -run TestFileupload |
❌ Wave 0 |
| SC-2 | protected download/thumb: 404 for other parent, other admin's pending file, public file; Content-Disposition + nosniff | security | go test ./modules/cabana -run TestProtectedFile |
❌ Wave 0 |
| SC-3 | hasMany/belongsToMany child create/update/delete; toolbarButtons gate (403); pivot whitelist; RelationBeforeLink stamps protected columns | integration | go test ./modules/cabana -run TestRelationChild |
❌ Wave 0 |
| SC-3 | D-15: child of another parent → 404 on GET/PUT/DELETE/pivot/files; FormExtendQuery-hidden parent → 404 | security | go test ./modules/cabana -run TestRelationChildScope |
❌ Wave 0 |
| SC-4 | commit in create tx; rollback (422) keeps bindings; success deletes them; foreign admin key ignored; master_field not declared ignored; double-submit serialized | integration | go test ./modules/cabana -run TestDeferredCommit |
❌ Wave 0 |
| SC-4 | purge: expired bindings removed, created children and unattached files deleted, blobs deleted after commit, linked orphans kept; --days |
integration | go test ./modules/lagoon -run TestPurgeDeferred |
❌ Wave 0 |
| SC-4 | framework schedule entry present, id format, disabled by config | unit | go test -short ./modules/conga -run TestFrameworkSchedule |
❌ Wave 0 |
| SC-3/4 | route inventory + OpenAPI parity | contract | `go test -short ./modules/cabana -run 'TestPhase09PermissionMatrix | TestPhase09ContractInventory |
| SC-1/2/3 | DatepickerField (tz conversion, ignoreTimezone, time mode), FileuploadField (upload, remove, reorder, protected thumb src), RelationManager create/edit/delete/pivot modals, create-screen deferral, session key header | SPA unit | npm --prefix admin test -- tests/form tests/relation |
❌ Wave 0 |
| SC-5 | docs checker | docs | go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check |
✅ |
Sampling Rate
- Per task commit:
go vet ./... && go test -short ./modules/...plusnpm --prefix admin testwhen the SPA changed. - Per plan: full suite command above (Docker up).
- Phase gate: full suite green,
scripts/check-admin-openapi.sh --checkandscripts/check-admin-dist.shclean, then/gsd-verify-work.
Wave 0 Gaps
- Test fixtures: a testdata plugin under
modules/cabana/testdata/with a parent model (implementsattach.Owner+attach.HasRelations), a hasMany child with nullable FK, a belongsToMany with pivot form, andconfig_relation.yamlusingmanage.form/pivot.form. phase09Routes(modules/cabana/security_coverage_test.go:34) extended in the same commit as each new route (otherwiseTestPhase09PermissionMatrixfails the length check).- SPA fixtures in
admin/tests/fixtures/for the new schema fields and the file list. - No framework install needed.
Security Domain
security_enforcement is absent from config, so it is treated as enabled.
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no (reuses backend guard) | bouncer backend JWT guard, unchanged |
| V3 Session Management | yes | Session key bound to admin id (D-02); format/length validated; unknown or foreign key = empty set |
| V4 Access Control | yes | protect() controller permissions; operationDeclared; toolbarButtons as capability; parent-scoped child/file queries (D-15) → 404 |
| V5 Input Validation | yes | Strict YAML compile; lagoon.Fill + Validate; pivot whitelist; server min/max dates; JSON DisallowUnknownFields on new bodies |
| V6 Cryptography | yes (randomness only) | crypto/rand disk names; crypto.getRandomValues session keys |
| V12 Files and Resources | yes | MaxBytesReader; per-field size; extension + sniffed MIME; image decode guard + pixel cap; server-generated disk names (no path traversal; parsePublicBlobPath unchanged); protected download with Content-Disposition: attachment for non-images, X-Content-Type-Options: nosniff, never inline SVG/HTML |
| V13 API | yes | requireAjax on every write (CSRF, csrf.go); swag-documented routes |
Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| IDOR on child/file/pivot ids | Info disclosure / Tampering | Parent predicate in the same query as the child id; 404 on miss |
| Session-key replay by another admin | Spoofing | backend_user_id predicate on every binding read and commit |
| Cross-controller binding replay (key used against a different model) | Tampering | Filter commit by master_type + declared master_field |
| Upload DoS (huge body, decompression bomb image) | DoS | Body cap; DecodeConfig before decode; 4096×4096 pixel ceiling |
| Polyglot / active content upload (SVG, HTML, JS) | Elevation (XSS) | Image guard in image mode; attachment disposition + nosniff on the admin route; exclude active types from the mode: file default list (recommend; Winter's default list includes svg/js/css) |
| Mass assignment of server-owned pivot columns | Tampering | pivot.form whitelist; protectedPivotColumn; HookPivotColumns |
| Race: double save commits twice | Tampering | FOR UPDATE on the session's bindings inside the save tx |
| Orphan accumulation | DoS (storage) | Daily purge + CLI |
| Deferred child adopted by another parent before purge | Tampering | Exclude rows with a live created binding from hasMany link candidates |
Suggested Plan Split (lean mode, for the plan-count checkpoint)
Five plans, sequential: plans 2 and 3 both edit http.go/crud.go/admin_openapi.go, and plan 4 consumes the regenerated TS types.
- Foundations (lagoon, attach, conga, pact):
lagoon.Date/TimeOfDay; Fill TextUnmarshaler; IsZero required;DeferredBindingmodel, migration set and store ops;attach.Relation/HasRelations;attach.Store+ image guard + thumb-key helper;deferred:purge+lagoon.FrameworkSchedule+ conga prepend; pact relation hooks; READMEs (lagoon, pact, conga) +docs/database/models.md,attachments.md. Smoke tests only. - cabana datepicker + fileupload + commit: compile keys and Go-type checks,
isListRelationfix,date/timelist columns, file routes (upload/list/remove/reorder/caption/protected download+thumb), session-key parsing, commit insave, OpenAPI annotations + regeneratedadmin.json/schema.d.ts, route inventory, cabana README +docs/backend/forms.md. - cabana relation child CRUD + deferral:
RelationContract.Kind/ForeignKey,manage.form/view.form/pivot.formcompile ($/paths,pivot[x]names), toolbarButtons set, child CRUD + pivot routes + child file routes, id-0 deferral, relation commit, D-15 scoping,deferrablein the schema, OpenAPI, README +docs/backend/relation-manager.md. - Admin SPA:
@internationalized/datedependency;sessionKey.ts; DatepickerField (Reka); FileuploadField (list, upload progress, remove, drag reorder, caption, protected thumbs via API); FormView session key and dirty tracking for pending files; RelationManager create/update/delete modal (RelationChildModalreusing FormGrid) and pivot modal; create-screen rendering for deferrable relations;date/timecells;npm run build→modules/boardwalk/dist. A UI-SPEC pass may precede it (workflow.ui_phase: true). - Unit and security tests (last): the full test map above, the D-15 security suite, upload limits, purge, SPA unit tests, the docs checker; then the security-review agent and the v0.1.1 tag checklist.
A four-plan variant merges plans 2 and 3 into one large cabana plan. Not recommended: it would be the biggest plan in the project, and both halves change the route inventory and the OpenAPI document.
Sources
Primary (HIGH confidence)
- Codebase read this session:
modules/cabana/{schema_types,form_schema,relation,relation_field,http,crud,registry,contracts,model_fields,tx_context,settings,csrf,schema,messages,admin_openapi,list_schema,query}.go;modules/lagoon/{migrations,fill,validate,validate_rules,transaction}.go;modules/lagoon/attach/{file,bucket,migrations,thumb,static}.go;modules/conga/scheduler.go;modules/pact/capabilities.go;modules/surf/{serve,bodylimit,router}.go;modules/party/registry.go;internal/build/build.go;cmd/summer/docs.go;internal/docsite/check_{identifiers,commands}.go;scripts/check-admin-{openapi,dist}.sh;admin/src/{components/form/registry.ts,components/form/control.ts,views/FormView.vue,api/client.ts,components/relation/RelationManager.vue,components/list/CellValue.vue};admin/package.json. - Winter reference (meta repo):
vendor/winter/storm/src/Database/{Migrations/2013_10_01_000001_Db_Deferred_Bindings.php, Migrations/2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php, Models/DeferredBinding.php, Traits/DeferredBinding.php, Relations/Concerns/{AttachOneOrMany,DeferOneOrMany}.php, Concerns/HasRelationships.php},vendor/winter/storm/src/Filesystem/Definitions.php;modules/backend/formwidgets/{FileUpload,DatePicker}.php,modules/backend/formwidgets/datepicker/partials/_datepicker.php,modules/backend/behaviors/RelationController.php,modules/system/helpers/DateTime.php. - Installed type declarations:
admin/node_modules/reka-ui/dist/index4.d.ts(DatePickerRootProps, DateFieldRootProps, TimeFieldRootProps),reka-ui/package.jsondeps. pgx/v5@v5.10.0/stdlib/sql.go:715-720(scan types).- npm registry +
gsd-tools package-legitimacy checkfor@internationalized/date.
Secondary (MEDIUM confidence)
- reka-ui.com docs pages for date-picker and time-field (anatomy only; prop tables were not on the pages, so props were taken from the installed
.d.ts).
Tertiary (LOW confidence)
- Winter
mimeTypeskey semantics (A8): not verified this session.
Metadata
Confidence breakdown:
- Standard stack: HIGH. Every version was read from go.mod or package.json and registry-checked.
- Architecture and integration points: HIGH. Line-cited reads of every touched function.
- Deferred-binding purge, scheduling and marker design: MEDIUM. The constraints are verified; the chosen design needs user confirmation (A3, A4, A5).
- Pitfalls: HIGH. Each was reproduced from source (Fill, isListRelation, isEmptyValue, raw body limits, guard location).
Research date: 2026-10-02 Valid until: 2026-11-01 (codebase-bound; re-check if Phase 12.1 lands changes to cabana first)