Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
Jakub Zych f4ec94531f docs(12.2): create phase plan
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.
2026-10-02 16:53:42 +02:00

75 KiB
Raw Blame History

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_bindings table, 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_type and slave_type hold the same morph type string that system_files.attachment_type already uses. No PHP class names. The migration ships as a framework migration set in lagoon, beside attach.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.
  • 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, after FormBeforeCreate / FormBeforeUpdate and 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.
  • D-05: Purging is a daily River periodic job plus summer deferred:purge [--days=N]. The default age is 5 days (Winter's DeferredBinding::cleanUp(5)), configurable. Both remove expired bindings and the orphaned slave records they point at. A deferred-created child row or an unattached system_files row is deleted. For files, the blob and its thumbnails are deleted after commit through the existing two-phase attach.DeleteKeys path.

File upload

  • D-06: Models declare attachments through an interface, AttachRelations() []attach.Relation, where each entry carries at least Name, Many (attachOne vs attachMany) and Public. This follows the AdminRelationContracts() style. A type: fileupload field 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/attach stores an upload. It writes the blob under the Winter partition key and the system_files row, enforces MIME and size limits, applies the image-content guard (including webp, P12 D-24), and handles sort_order and thumbnails. cabana uses it, and app plugins can adopt it later. Existing app code that builds attach.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 fileupload keys: mode (image|file), fileTypes, mimeTypes, maxFilesize, maxFiles, imageWidth, imageHeight, thumbOptions (the thumb mode), useCaption (edit title and description) and prompt. 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 a MaxBytesReader cap (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 when useCaption is set. They work on both saved and unsaved records.
  • D-10: Both public and protected attachments are supported. A relation with Public: true stores is_public=true and uses the existing Winter-shaped public URLs (P12 D-22). A relation with Public: false stores is_public=false, and the SPA gets its download and thumbnail through an authenticated admin API route under the backend guard. 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.yaml manage.form (Winter style, e.g. $/vendor/plugin/models/child/fields.yaml), with an optional view.form. It is compiled at boot by the same typed form-schema pipeline and with the same fail-loud rules.
  • D-12: toolbarButtons follow Winter: create, update, delete, link, unlink. On hasMany, delete deletes the child row through the model so lifecycle hooks and soft delete fire, and unlink sets its foreign key to null. On belongsToMany, delete deletes the related record and unlink removes the pivot row (as today). Unknown buttons fail at boot.
  • D-13: RelationContract (modules/cabana/relation.go) is extended to describe hasMany (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.form is supported for belongsToMany. 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. RelationBeforeLink still 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 FormExtendQuery with the controller's permissions, and the child must belong to that parent: by FK for hasMany, by pivot row for belongsToMany, or by the admin's session-key bindings while the parent is unsaved. Otherwise the endpoint returns not_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. datepicker and fileupload work 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. A relation-manager field inside a child form is a boot error.

Datepicker

  • D-18: For mode: datetime, the value is stored as timestamptz in UTC, and the SPA shows and edits it in the admin's browser timezone. ignoreTimezone: true keeps the wall-clock value unchanged, with no conversion. date and time modes never convert.

  • D-19: Go types:

    • datetime maps to time.Time / *time.Time.
    • date maps to a framework lagoon.Date (a DATE column; JSON "2026-10-02").
    • time maps to lagoon.TimeOfDay (a TIME column; JSON "14:30:00").

    Nullable variants are included. All of them implement Scanner / Valuer and 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 datepicker keys: mode, format (display only; Winter/moment tokens are mapped to the SPA formatter), minDate, maxDate, yearRange, firstDay, twelveHour and ignoreTimezone. Any other key is a boot error. minDate / maxDate are 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 cabana routes (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, use requireAjax on 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 the lagoon.Date / lagoon.TimeOfDay method sets.
  • Thumbnail size for previews (derived from imageWidth / imageHeight, with a sensible default).
  • Whether list columns gain type: date / type: time renderers. Add them only if they are trivial next to the existing type: 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 attach store 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/date in admin/package.json (see Standard Stack). No new Go module.
  • Compiled plugins only. No runtime plugin loading.
  • go vet and go test ./... green at every commit. Early plans that add routes must update the existing route inventory tests (phase09Routes in modules/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's README.md and the affected docs/ pages in the same change. go test ./cmd/summer -run TestDocsTree and summer docs:build --check must 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
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.Eq with quotedIdent, never string-built column names).
  • belongsToMany: JOIN pivot p ON p.<RelatedFK> = related.pk WHERE p.<ParentFK> = :parentPK AND related.pk = :child (reuse relationBaseQuery(..., 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 (Winter withDeferred, below).
  • A miss returns recordNotFound{}, which writeCRUDError maps to 404 not_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:

  1. 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.
  2. Ignore (do not apply) bindings whose master_field is not a fileupload field or relation-manager relation of this controller in this operation's context. Leave them for purge.
  3. Apply in id order. File bind: set attachment_type, attachment_id, field; for attachOne, first delete the currently attached siblings (rows now, blob keys via lagoon.AfterCommit → attach.DeleteKeys). File unbind: delete the file row, with blobs after commit (attach relations delete by default, Winter getRelationDefaults). Relation bind: hasMany sets the FK through a model Save so hooks run; belongsToMany runs the existing Link eligibility logic (re-runs relationBaseQuery(candidates=true) and RelationBeforeLink) with pivot_data. Unbind: the unlink logic.
  4. Re-check maxFiles and fileupload required after applying. On failure, return a 422 on that field; the transaction rolls back and the bindings survive.
  5. DELETE FROM deferred_bindings WHERE id IN (applied) in the same transaction. Extend RecordInput (crud.go:26-28) with SessionKey string (additive). The admin id comes from bouncer.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)). Never reflect.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 RelationBeforeLink at commit time, when the parent has a PK. A deferred link stores only pivot.form-whitelisted values in pivot_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)
// 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.

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)

  1. 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 StaticHandlerPublic or disable listing. Add an optional storage.uploads.protected_bucket_url later if needed.
    • RESOLVED: no second bucket in v0.1.1 (planner assumption, 12.2-01-PLAN.md).
  2. Field types inside child forms (A6). Recommend datepicker, fileupload and the scalar types only. Supporting relation pickers 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; relation pickers deferred.
  3. Does create imply update for hasMany row editing (Winter parity), or must update be listed? Recommendation: require update explicitly (the capability rule stays literal) and document the difference from Winter.
    • RESOLVED: update must be listed explicitly (12.2-03-PLAN.md).
  4. Cancel endpoint. Winter leaves abandoned bindings to cleanUp. Recommendation: no cancel endpoint in v0.1.1; purge handles it. Optionally add a keepalive fetch on route leave later.
    • RESOLVED: no cancel endpoint; purge handles abandoned bindings (12.2-03-PLAN.md).
  5. Top-level form: in config_relation.yaml. Winter falls back from manage.form to a top-level form (makeConfigForMode). Recommendation: accept the top-level form as Winter's fallback for both panels.
    • RESOLVED: top-level form accepted as the fallback for manage.form and view.form (12.2-03-PLAN.md).

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/... plus npm --prefix admin test when the SPA changed.
  • Per plan: full suite command above (Docker up).
  • Phase gate: full suite green, scripts/check-admin-openapi.sh --check and scripts/check-admin-dist.sh clean, then /gsd-verify-work.

Wave 0 Gaps

  • Test fixtures: a testdata plugin under modules/cabana/testdata/ with a parent model (implements attach.Owner + attach.HasRelations), a hasMany child with nullable FK, a belongsToMany with pivot form, and config_relation.yaml using manage.form/pivot.form.
  • phase09Routes (modules/cabana/security_coverage_test.go:34) extended in the same commit as each new route (otherwise TestPhase09PermissionMatrix fails 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.

  1. Foundations (lagoon, attach, conga, pact): lagoon.Date/TimeOfDay; Fill TextUnmarshaler; IsZero required; DeferredBinding model, 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.
  2. cabana datepicker + fileupload + commit: compile keys and Go-type checks, isListRelation fix, date/time list columns, file routes (upload/list/remove/reorder/caption/protected download+thumb), session-key parsing, commit in save, OpenAPI annotations + regenerated admin.json/schema.d.ts, route inventory, cabana README + docs/backend/forms.md.
  3. cabana relation child CRUD + deferral: RelationContract.Kind/ForeignKey, manage.form/view.form/pivot.form compile ($/ paths, pivot[x] names), toolbarButtons set, child CRUD + pivot routes + child file routes, id-0 deferral, relation commit, D-15 scoping, deferrable in the schema, OpenAPI, README + docs/backend/relation-manager.md.
  4. Admin SPA: @internationalized/date dependency; 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 (RelationChildModal reusing FormGrid) and pivot modal; create-screen rendering for deferrable relations; date/time cells; npm run build → modules/boardwalk/dist. A UI-SPEC pass may precede it (workflow.ui_phase: true).
  5. 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.json deps.
  • pgx/v5@v5.10.0/stdlib/sql.go:715-720 (scan types).
  • npm registry + gsd-tools package-legitimacy check for @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 mimeTypes key 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)