# Phase 12.2: Admin form fields (date, file upload, relation editing with deferred binding) - Pattern Map **Mapped:** 2026-10-02 **Files analyzed:** 26 (new + modified) **Analogs found:** 24 / 26 All analog paths are git-tracked sources in `summercms.go` (no mirrors). ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |---|---|---|---|---| | `modules/lagoon/date.go` (new) | model value type | transform (Scanner/Valuer/JSON/Text) | `modules/lagoon/encrypted.go` | role-match | | `modules/lagoon/deferred.go` (new) | model + store | CRUD | `modules/lagoon/attach/file.go` (File model, DeleteForOwner) | role-match | | `modules/lagoon/deferred_migrations.go` (new) | migration | batch DDL | `modules/lagoon/attach/migrations.go` | exact | | `modules/lagoon/migrations.go` (mod) | migration runner | batch | itself, `Migrate` lines 63-108 | exact | | `modules/lagoon/fill.go` (mod) | utility | transform | itself, `convertValue` lines 112-155, 172-193 | exact | | `modules/lagoon/validate.go` (mod) | utility | transform | itself, `isEmptyValue` line 264 | exact | | `modules/lagoon/commands.go` (mod, `deferred:purge`) | CLI command | batch | itself, `RuntimeCommands` `migrate` entry lines 16-30 | exact | | `modules/lagoon/schedule.go` (new) | config/provider | batch | `modules/conga/scheduler.go` `scheduleEntries` 63-99 + `pact.HasSchedule` | role-match | | `modules/lagoon/attach/store.go` (new) | service | file-I/O | `modules/lagoon/attach/thumb.go` (bucket writes) + `file.go` DeleteKeys | role-match | | `modules/lagoon/attach/guard.go` (new) | utility | transform | none in framework (app `classes/image_guard.go`); thumb pixel guard `attach/thumb.go:24-28` | partial | | `modules/lagoon/attach/relation.go` (new) | contract/interface | n/a | `attach.Owner` in `attach/file.go:17-22` | role-match | | `modules/cabana/form_schema.go` (mod) | config compiler | transform | itself, `compileWidgetKeys` 458-499 | exact | | `modules/cabana/field_date.go` (new) | config compiler | transform | `compileWidgetKeys` (form_schema.go) | role-match | | `modules/cabana/field_file.go` (new) | controller | file-I/O / request-response | `modules/cabana/relation.go` handlers + `Link` 625-692 | role-match | | `modules/cabana/deferred.go` (new) | service | CRUD (tx) | `RelationService.Link` relation.go 625-692; `CRUDService.save` crud.go 310-386 | role-match | | `modules/cabana/relation.go` / `relation_child.go` | service + controller | CRUD | `relation.go` Link/contract/`compileRelationButtons` 314-336 | exact | | `modules/cabana/crud.go` (mod: RecordInput.SessionKey, save commit, scalarFormField) | service | CRUD | itself | exact | | `modules/cabana/list_schema.go` (mod: `isListRelation`, `date`/`time` column types) | config compiler | transform | `model_fields.go:70-82` `embeddedStructType` | exact | | `modules/cabana/schema_types.go` (mod: FormField flat keys) | model (DTO) | n/a | existing `Widget`/`Action`/`Fill`/`Path` fields 197-229 | exact | | `modules/cabana/schema.go` (mod: `assetPath` `$/`) | utility | transform | itself 37-47 | exact | | `modules/cabana/http.go` (mod) | route | request-response | itself, mount 248-259 + `nestedGet` 299-313 | exact | | `modules/cabana/admin_openapi.go` (mod) | docs/annotations | n/a | `AdminRelationLink` 575-591 | exact | | `modules/pact/capabilities.go` (mod) | interface | event-driven hooks | `RelationBeforeLink` 389-392 | exact | | `modules/conga/scheduler.go` (mod) | scheduler | batch | itself `scheduleEntries` 63-99 | exact | | `admin/src/components/form/fields/DatepickerField.vue`, `FileuploadField.vue` (new) + `form/registry.ts` (mod) | component | request-response | `fields/DropdownField.vue` + `registry.ts` 10-41 | role-match | | `admin/src/components/relation/RelationChildModal.vue`, `RelationPivotModal.vue` (new) | component | request-response | `relation/RelationPickerModal.vue` | exact | | `admin/src/app/sessionKey.ts`, `admin/src/app/dateFormat.ts` (new) | utility | transform | `admin/src/app/listQuery.ts`, `winterUrl.ts` (small pure helpers) | partial | | Tests (last plan) | test | — | `modules/cabana/relation_test.go`, `modules/lagoon/attach/migrations`+`file_test.go`, `phase09_contract_test.go` (`TestPhase09PermissionMatrix`) | exact | ## Pattern Assignments ### `modules/lagoon/deferred_migrations.go` (migration) **Analog:** `modules/lagoon/attach/migrations.go` (whole file, 1-48) ```go var Migrations = []*gormigrate.Migration{ { ID: "202609180001_create_system_files", Migrate: func(tx *gorm.DB) error { stmts := []string{ `CREATE TABLE system_files (...)`, `CREATE INDEX ...` } for _, stmt := range stmts { if err := tx.Exec(stmt).Error; err != nil { return err } } return nil }, Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS system_files").Error }, }, } ``` Name the var `DeferredBindingMigrations`; ID like `2026100200NN_create_deferred_bindings`; DDL per RESEARCH "Code Examples" (adds `backend_user_id INTEGER NOT NULL`, indexes on master_type/master_field/slave_type/slave_id/session_key). ### `modules/lagoon/migrations.go` (modify `Migrate`) **Analog:** lines 67-80. Insert after the `summercms.cabana` block, same shape: ```go admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) if err != nil { return err } if err := admin.Migrate(); err != nil { return fmt.Errorf("lagoon: migrate backend admin: %w", err) } ``` Use a new history id (e.g. `"summercms.deferred"`); also wire into `RollbackLast`/status lookups that switch on framework ids. ### `modules/lagoon/date.go` (Date, TimeOfDay) **Analog:** `modules/lagoon/encrypted.go` Scan 62-93 / Value 97-102: ```go func (e *Encrypted) Scan(src any) error { if e == nil { return fmt.Errorf("lagoon: encrypted scan on nil receiver") } if src == nil { e.plaintext = nil; e.set = false; return nil } var raw string switch v := src.(type) { case string: raw = v case []byte: raw = string(v) default: return fmt.Errorf("lagoon: encrypted scan unsupported type %T", src) } ... } func (e Encrypted) Value() (driver.Value, error) { if !e.set { return nil, nil } ... } ``` Add `time.Time` source case for Date (pgx returns time.Time for DATE), plus `MarshalJSON`/`UnmarshalText`/`MarshalText`/`IsZero`. Error prefix `lagoon: date ...`. ### `modules/lagoon/fill.go` / `validate.go` Edit in place: `convertValue` (fill.go 112-155, pointer path 172-193) gets an `encoding.TextUnmarshaler` fallback only after `ConvertibleTo` fails and only for string/[]byte sources (RESEARCH Pitfall 1). `isEmptyValue` (validate.go 264-277) gets `interface{ IsZero() bool }` (Pitfall 3). Update `modules/lagoon/README.md`. ### `modules/lagoon/deferred.go` (model + store) **Analog:** `modules/lagoon/attach/file.go` — `TableName()` (line 47), `DeleteForOwner(tx, owner, ownerID, afterCommit)` (130), `DeleteKeys(ctx, bucket, keys)` (157). Store ops take `*gorm.DB` tx as first arg and defer blob deletes via `lagoon.AfterCommit(ctx, db, fn)` (`transaction.go:103`). Never delete blobs inside the tx. ### `modules/lagoon/commands.go` (+ `deferred:purge`) **Analog:** `RuntimeCommands` lines 16-30: ```go { Name: "migrate", Description: "Run plugin migrations in dependency order", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return withDB(ctx, app, func(gdb *gorm.DB) error { if err := Migrate(gdb, plugins); err != nil { return err } out.Success("migrations applied") return nil }) }, }, ``` Flags follow `migrate:rollback`'s `[]bonfire.Flag{{Name, Description}}` (lines 33-37). Resolve slave types via `pact.HasModels` over `plugins` (Pitfall 9). Update the "RuntimeCommands returns ..." doc comment and README CLI section; docs checker picks up the name. ### `modules/lagoon/schedule.go` + `modules/conga/scheduler.go` **Analog:** `conga/scheduler.go` 63-99. Prepend framework entries before the plugin loop using the same compile path: ```go sched, period, err := scheduleFor(sc.Cadence, loc) ... out = append(out, scheduleEntry{ id: fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command), plugin: p.ID(), index: i, cmd: sc, schedule: sched, period: period, }) ``` with plugin id `"summercms.lagoon"`. `FrameworkSchedule(app) []pact.ScheduledCommand` returns `{Command: "deferred:purge", Cadence: pact.DailyAt(h, m)}`. ### `modules/lagoon/attach/store.go`, `guard.go`, `relation.go` - Body limits: copy `http.MaxBytesReader` usage from `modules/wristband/register.go:67` / `cabana/auth.go:159`; add `io.LimitReader(part, max+1)` per part. - Pixel ceiling: reuse `maxThumbSourcePixels` (`attach/thumb.go:24-28`). - Deletion of blobs/thumbs: `blobKeysFor(f)` (file.go 110) + `DeleteKeys`. - `relation.go`: interface beside `attach.Owner` (file.go 17-22) — `type HasRelations interface { AttachRelations() []Relation }`. - Guard: no framework analog — port from the application's `classes/image_guard.go` per RESEARCH Pitfall 5 (DetectContentType + image.DecodeConfig, fail closed, webp via `golang.org/x/image/webp` only if already a dependency; check go.mod). ### `modules/cabana/form_schema.go` + `field_date.go` + `field_file.go` (compile) **Analog:** `compileWidgetKeys` 458-499: ```go func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error { if typ != "widget" { for _, key := range widgetKeys { if _, ok := values[key]; ok { return fmt.Errorf("%s is only valid on type: widget", key) } } return nil } tag, err := nodeString(values["widget"]) if err != nil || strings.TrimSpace(tag) == "" { return fmt.Errorf("widget (the custom-element tag) is required on type: widget") } field.Widget = tag ... } ``` Write `compileDatepickerKeys` / `compileFileuploadKeys` identically (key lists `datepickerKeys`, `fileuploadKeys`; `mode` shared, validated per type). Register types in `formFieldTypes`, keys in `formFieldKeys` (22-42). Add `datepicker` to `scalarFormField` (`crud.go:800`). New flat `omitempty` fields on `FormField` (`schema_types.go:197-229`). ### `modules/cabana/deferred.go`, `relation_child.go`, `field_file.go` handlers (tx services) **Analog:** `RelationService.Link` (`relation.go` 625-692): ```go err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) parent, err := newWritableModel(cc) if err != nil { return err } if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { return err } ownerPK := pkUint(parent) ... q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)}) ... if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil { if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil { return lifecycleFailure(cc, err) } } ... }) ``` Rules: every child query is parent-scoped (`clause.Eq` + `quotedIdent`), miss returns `recordNotFound{}` → 404 via `writeCRUDError` (crud.go 393-410); validation errors via `relationInvalid(field, msg)`. Commit hook goes in `CRUDService.save` (crud.go 310-386) after `syncBelongsToMany` (~373), before `formAfterCreate/Update` (376-383). Admin id from `bouncer.User(ctx)`. **Button compile:** extend `compileRelationButtons` (relation.go 314-336) accepted set to `create|update|delete|link|unlink`. **Contract:** add `Kind`, `ForeignKey` to `RelationContract` (27-36), branch in `validateRelationContract` (338-394). ### `modules/cabana/http.go` (routes) **Analog:** lines 248-259 and `nestedGet` 299-313: ```go g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink)) constrainRelation(g) ... case segment == "relations": s.relationLinked(w, r) ``` Each new route: `g.(path, requireAjax(handler))` for writes, followed by its `constrain*` call; add `case segment == "files": s.fileList(w, r)` to `nestedGet`. ServeMux conflicts panic in `TestPhase09PermissionMatrix`. ### `modules/cabana/admin_openapi.go` **Analog:** `AdminRelationLink` (575-591): doc func with `@Summary`, `@Tags admin`, `@Security BackendBearer`, `@Param` per path segment, `@Success 200 {object} Envelope[T]`, `@Failure 401/403/422/404 {object} ErrorEnvelope`, `@Router ... [method]`, body `func AdminX() {}`. Uploads use `@Accept multipart/form-data` and `@Param file_data formData file true`. Regenerate via `scripts/check-admin-openapi.sh`. ### `modules/pact/capabilities.go` **Analog:** lines 389-392: ```go // RelationBeforeLink optionally stamps pivot columns before a link insert. type RelationBeforeLink interface { RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error } ``` New `Relation{Before,After}{Create,Update,Delete}` follow the same one-method optional-interface shape; call sites type-assert `cc.Controller.(pact.X)` and wrap errors with `lifecycleFailure(cc, err)`. ### SPA: `DatepickerField.vue`, `FileuploadField.vue` **Analog:** `admin/src/components/form/fields/DropdownField.vue` 1-60: ```ts import type { FormOption } from '../../../api/types' import { t } from '../../../app/i18n' import { controlClass, type FieldControlProps } from '../control' const props = defineProps() const emit = defineEmits<{ 'update:modelValue': [value: ...] }>() ``` Template binds `:id="controlId"`, `:aria-invalid="invalid ? 'true' : undefined"`, `:aria-describedby="describedBy || undefined"`, `:aria-required`. Register in `admin/src/components/form/registry.ts` (imports 10-20, map entries 32-41: `['datepicker', DatepickerField]`, `['fileupload', FileuploadField]`). Visuals per 12.2-UI-SPEC.md; Reka DatePicker/DateField + `@internationalized/date`. ### SPA: `RelationChildModal.vue`, `RelationPivotModal.vue` **Analog:** `admin/src/components/relation/RelationPickerModal.vue` 1-50: ```ts import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui' import { api } from '../../api/client' import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types' import { message, t } from '../../app/i18n' import { showToast } from '../../state/useToasts' const props = defineProps<{ open: boolean; source: ControllerParams; recordId: number; relation: string; schema: RelationSchema }>() const emit = defineEmits<{ 'update:open': [open: boolean]; linked: [count: number]; closed: [] }>() let generation = 0 // stale-response guard ``` Wire into `RelationManager.vue`; remove create-screen drop in `FormView.vue:71-77` only for `deferrable` relations. Rebuild `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`). ## Shared Patterns - **Transactions:** `lagoon.Transaction` → `withTx` → `loadRecord` (FOR UPDATE, applies `pact.FormExtendQuery`; crud.go 441-461). Apply to all child/file/commit mutations. - **After-commit side effects:** `lagoon.AfterCommit(ctx, db, fn)` (`transaction.go:103`) + `attach.DeleteKeys`. Apply to every blob delete. - **Error envelope:** `recordNotFound{}` → 404, `relationInvalid(field,msg)` → 422, `lifecycleFailure(cc, err)` for hook errors; mapped by `writeCRUDError` (crud.go 393-410). Foreign child = 404, never 403. - **Write routes:** `requireAjax(...)` + `constrain*` after each `g.Post/Put/Delete` (http.go 248-259). - **Body caps:** `http.MaxBytesReader` (cabana raw routes have no surf limit — `surf/bodylimit.go:29-33`). - **Boot-time contract checks:** type-assert capability, fail `cabana.Activate` with descriptive error (relation.go `validateRelationContract` 338-394). - **Docs rule:** every exported API/config/CLI change updates module `README.md` and `docs/` (`docs/backend/relation-manager.md`) in the same change; `go test ./cmd/summer -run TestDocsTree`. ## No Analog Found | File | Role | Data Flow | Reason | |---|---|---|---| | `modules/lagoon/attach/guard.go` | utility | transform | Image guard exists only in an application plugin; port per RESEARCH Pitfall 5 | | `admin/src/app/sessionKey.ts`, `dateFormat.ts` | utility | transform | No crypto/date helpers in SPA; use RESEARCH (crypto.getRandomValues base64url; PHP-format token table, Pitfall 13). Shape like `admin/src/app/listQuery.ts` (pure exported functions) | ## Metadata **Analog search scope:** modules/lagoon, modules/lagoon/attach, modules/cabana, modules/conga, modules/pact, modules/surf, modules/wristband, admin/src/components, admin/src/app **Files scanned:** ~30 **Pattern extraction date:** 2026-10-02