docs(12.2): research phase domain

This commit is contained in:
Jakub Zych
2026-10-02 15:24:00 +02:00
parent 1307060e15
commit 1c476d243f

View File

@@ -0,0 +1,664 @@
# 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):**
```bash
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]`:
```go
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]`:
```go
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)
```php
// 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):
```go
// 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)
```php
// 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`)
```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)
```go
// 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)
```go
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
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.
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.
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.
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.
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.
## 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|TestFillText'` | ❌ Wave 0 |
| 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|TestOpenAPI'` | ✅ (update lists) |
| 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)