--- phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def plan: 01 type: execute wave: 1 depends_on: [] files_modified: - modules/lagoon/attach/store.go - modules/lagoon/attach/guard.go - modules/lagoon/attach/relation.go - modules/lagoon/attach/file.go - modules/lagoon/attach/thumb.go - modules/lagoon/attach/store_test.go - modules/lagoon/deferred.go - modules/lagoon/deferred_migrations.go - modules/lagoon/purge.go - modules/lagoon/deferred_test.go - modules/lagoon/migrations.go - modules/lagoon/date.go - modules/lagoon/date_test.go - modules/lagoon/fill.go - modules/lagoon/validate.go - modules/lagoon/commands.go - modules/lagoon/schedule.go - modules/lagoon/migrations_test.go - modules/lagoon/README.md - modules/conga/scheduler.go - modules/conga/schedule_test.go - modules/conga/README.md - modules/pact/capabilities.go - modules/pact/README.md - docs/database/models.md - docs/database/casts-and-validation.md - docs/database/attachments.md - docs/plugins/scheduling.md - docs/console/setup-and-maintenance.md - ../fonoteka.go/parity/schema_diff_test.go - ../fonoteka.go/parity/migrate_test.go autonomous: true requirements: [SC-1, SC-2, SC-4] estimate: tokens: 190000 raw_tokens: 190000 tasks: 3 confidence: low must_haves: truths: - "Per D-01, `lagoon.Migrate` creates Winter's `deferred_bindings` table (id, master_type, master_field, slave_type, slave_id, pivot_data, session_key, is_bind, created_at, updated_at) plus exactly one added column, `backend_user_id`, under its own framework history id `summercms.deferred`; rolling the set back drops the table." - "Per D-02 and D-01, every deferred-binding store operation takes a `lagoon.DeferredKey{SessionKey, AdminID, MasterType}`, writes `backend_user_id` on every row and never reads or changes a row of another admin or another master type." - "Per D-04 (Winter `beforeCreate` semantics), binding the same slave twice in one session writes one row, and an unbind of a slave with a pending bind cancels the pair: the bind row is deleted and returned to the caller, no unbind row is written." - "Per D-22, a binding for a child created under deferral carries the framework envelope `{\"created\":true,\"pivot\":{...}}` in `pivot_data`; `lagoon.DeferredEnvelope` round-trips it and a binding without the envelope is a plain link." - "Per D-05, `lagoon.PurgeDeferred` removes bindings older than the cut-off, deletes an unattached `system_files` row a bind points at and, only after the transaction commits, its blob and thumbnails through `attach.DeleteKeys`; it deletes a slave model only when the binding carries `created: true`, and it keeps rows that were only linked." - "Per D-05, `summer deferred:purge [--days=N]` exists (default 5 days, config `database.deferred_bindings.purge_days`), and conga schedules it daily at `database.deferred_bindings.purge_at` (default `03:00`; an empty value disables the framework entry) under the entry id `summercms.lagoon[0]:deferred:purge`." - "Per D-06, `attach.Relation{Name, Many, Public}` and the `attach.HasRelations` interface (`AttachRelations() []attach.Relation`) exist for models to declare attachOne/attachMany relations." - "Per D-07 and D-08, `attach.Store` writes the blob under the Winter partition key with a server-generated disk name, inserts an unattached `system_files` row with `sort_order` equal to its id, enforces `Limits.MaxBytes`, extensions and MIME types, applies the image-content guard (jpeg, png, gif and webp; sniffed bytes plus `image.DecodeConfig` plus a 4096 by 4096 pixel ceiling) when `Limits.Image` is set, and deletes the blob again when the row insert fails." - "Per D-19, `lagoon.Date` (DATE, JSON `\"2026-10-02\"`) and `lagoon.TimeOfDay` (TIME, JSON `\"14:30:00\"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and a zero value stores NULL; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants." - "Per D-19 and RESEARCH Pitfall 1, `lagoon.Fill` writes a JSON string into `time.Time`, `*time.Time`, `lagoon.Date`, `*lagoon.Date`, `lagoon.TimeOfDay` and `*lagoon.TimeOfDay` fields through `encoding.TextUnmarshaler`, and every conversion that worked before this plan behaves the same." - "Per RESEARCH Pitfall 3, `lagoon.Validate` treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (and a pointer to one) as empty for `required`; no other type's emptiness changes." - "Per D-16, pact declares the optional controller hooks `RelationBeforeCreate`, `RelationAfterCreate`, `RelationBeforeUpdate`, `RelationAfterUpdate`, `RelationBeforeDelete` and `RelationAfterDelete` in the same one-method style as `RelationBeforeLink`." - "Edge (D-19 timezone): `lagoon.Date.Scan` of a `time.Time` takes the calendar date the driver returned (pgx gives DATE as UTC midnight), so a value written as 2026-10-02 reads back as 2026-10-02 whatever the process time zone." - "Edge (D-05 concurrency): the purge locks the bindings it processes with `FOR UPDATE SKIP LOCKED`, so a parent save that is committing the same session's bindings is never disturbed." - "Edge (D-08 boundary): a body of exactly `Limits.MaxBytes` bytes is stored and `Limits.MaxBytes + 1` bytes answers `attach.ErrTooLarge` with no blob and no row left behind." artifacts: - path: "modules/lagoon/deferred_migrations.go" provides: "DeferredBindingMigrations (deferred_bindings table, D-01)" contains: "backend_user_id" - path: "modules/lagoon/deferred.go" provides: "DeferredBinding, DeferredKey, DeferredEnvelope, DeferredBind, DeferredUnbind, DeferredBindings, DeferredForget, DeferredSlaves, MorphType" contains: "func DeferredBind(" - path: "modules/lagoon/purge.go" provides: "PurgeDeferred, PurgeOptions, PurgeResult" contains: "SKIP LOCKED" - path: "modules/lagoon/date.go" provides: "Date, TimeOfDay and their constructors and parsers" contains: "type TimeOfDay struct" - path: "modules/lagoon/schedule.go" provides: "FrameworkSchedule, FrameworkScheduleID" contains: "deferred:purge" - path: "modules/lagoon/attach/store.go" provides: "Store, Upload, Limits, ErrTooLarge, ErrFileType, ErrMIMEType, ErrNotImage, DefaultImageExtensions, DefaultFileExtensions" contains: "func Store(" - path: "modules/lagoon/attach/guard.go" provides: "AllowedImageMIMEs, IsAllowedImage, MaxImagePixels" - path: "modules/lagoon/attach/relation.go" provides: "Relation, HasRelations" contains: "AttachRelations() []Relation" key_links: - from: "modules/lagoon/migrations.go" to: "modules/lagoon/deferred_migrations.go" via: "Migrate runs DeferredBindingMigrations under history id summercms.deferred" pattern: "summercms.deferred" - from: "modules/lagoon/purge.go" to: "modules/lagoon/attach/file.go" via: "blob keys from attach.BlobKeys are deleted through attach.DeleteKeys inside lagoon.AfterCommit" pattern: "AfterCommit" - from: "modules/conga/scheduler.go" to: "modules/lagoon/schedule.go" via: "scheduleEntries prepends lagoon.FrameworkSchedule entries with plugin id summercms.lagoon" pattern: "FrameworkSchedule" - from: "modules/lagoon/fill.go" to: "modules/lagoon/date.go" via: "convertValue falls back to encoding.TextUnmarshaler for string sources" pattern: "TextUnmarshaler" prohibitions: - statement: "Blob objects MUST NOT be deleted inside a database transaction; every blob delete runs in lagoon.AfterCommit through attach.DeleteKeys" status: resolved verification: test - statement: "The purge MUST NOT delete a slave row whose binding lacks the created envelope (a linked-only record) or a system_files row that is attached to an owner" status: resolved verification: test - statement: "attach MUST NOT import lagoon (import cycle); anything needing both lives in lagoon" status: resolved verification: test - statement: "fonoteka.go application code (album photos, collection media, user avatar upload code) MUST NOT change in this plan; only the two parity test expectation files are edited" status: resolved verification: test - statement: "No new Go module dependency is added; go.mod and go.sum stay unchanged" status: resolved verification: test --- ## Phase Goal ROADMAP Phase 12.2 goal (verbatim, not in user-story form): A plugin's admin forms cover the three gaps a downstream project on SummerCMS v0.1 hit: a date/datetime field, a file upload field, and creating, editing and deleting related records inside the parent form (WinterCMS RelationController parity). Uploads and related-record changes on a record that is not saved yet use Winter-like deferred binding: they are held against a session key and committed with the parent's first save, or discarded with it. This plan's slice: the storage primitives every later plan builds on. After it, a host application can store an upload with the framework's guard, hold it against an admin's session key, purge abandoned bindings from the CLI or the daily schedule, and fill date and time columns from JSON without a plugin-defined date type. Nothing is visible in the admin SPA yet; plans 02 to 04 wire these pieces into cabana and the SPA. Build the lagoon, lagoon/attach, conga and pact foundations of Phase 12.2 (summercms.go), plus the two fonoteka.go parity test expectations that a new framework table changes. Purpose: plans 02 and 03 commit and list bindings, store uploads and run relation hooks through these APIs; plan 05 brings them to full coverage. Smoke tests only here (project rule: unit tests are the last plan). Output: `deferred_bindings` migration set and store ops, `attach.Store` with the ported image guard, `attach.Relation`, `lagoon.Date`/`lagoon.TimeOfDay` with Fill and `required` support, `deferred:purge` with the framework schedule entry, pact relation hooks; lagoon, pact and conga READMEs and the affected docs pages. Repos: summercms.go (framework) and fonoteka.go (two parity test expectation files only, committed separately). Framework code, READMEs and docs use neutral names (acme, blog) and never name the application. Planning docs and code go in separate commits; never add co-author tags. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.md @modules/lagoon/migrations.go @modules/lagoon/fill.go @modules/lagoon/attach/file.go @modules/conga/scheduler.go - lagoon (today): `Migrate(gdb, plugins)` runs `migrator(gdb, "summercms.attach", attach.Migrations)`, then `migrator(gdb, "summercms.cabana", BackendAdminMigrations)`, then `migrator(gdb, QueueHistoryID, QueueMigrations(sqlDB))`, then plugin sets; `HistoryTableName(pluginID)` maps `summercms.deferred` to `summer_migrations_summercms_deferred`. `Transaction(ctx, gdb, fn func(ctx, tx) error) error`; `AfterCommit(ctx, db, fn func(ctx, db))` runs fn only after the outermost lagoon.Transaction commits. `Fill(model, allowed []string, requested map[string]any, production bool) error`; `setField` converts pointer fields to the element type first (`convertValue(src, elemType)`), non-pointer fields fall back to `sql.Scanner`; `convertValue` order today: Encrypted special case, AssignableTo, json.Number, ConvertibleTo. `isEmptyValue(val any) bool` (validate.go:264) treats nil, nil pointers, empty strings/slices/maps as empty. `RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command` with `withDB(ctx, app, fn)`; flags are `[]bonfire.Flag{{Name, Description}}`, read with `in.Flag(name)`. - attach (today): `Owner{MorphName() string}`; `File{ID, DiskName, FileName, FileSize, ContentType, Title, Description *string, Field, AttachmentID, AttachmentType string, IsPublic *bool, SortOrder int, Metadata *string, CreatedAt, UpdatedAt}` (TableName `system_files`); unexported `blobKeysFor(f File) []string` returns `[partition+disk, partition+"thumb__"]`; `DeleteKeys(ctx, bucket, keys)` treats keys ending in `_` as prefixes; `BlobKey(disk)`, `PartitionDirectory(disk)`, `PublicURL(key)`, `(*File).URL()`, `(*File).Thumb(ctx, bucket, w, h, mode) (publicURL string, err error)`; `maxThumbSourcePixels = 4096 * 4096`; thumb.go blank-imports the gif/jpeg/png/webp decoders. - conga: `scheduleEntries(app, plugins) ([]scheduleEntry, error)` compiles `pact.HasSchedule` entries with ids `fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command)` via `scheduleFor(sc.Cadence, loc)`; `periodicJobs` builds the River periodic jobs and the entry table that `runScheduled` checks (forged rows are skipped). conga already imports lagoon. - pact: `ScheduledCommand{Command string; Args []string; Cadence Cadence}`, `DailyAt(hour, minute int) Cadence`, `HasModels{Models() []any}`, `RelationBeforeLink{RelationBeforeLink(ctx, relation string, parent, related any, pivot map[string]any) error}`. - compass: `(*Config).Lookup(path) (any, bool)`, `String(path)`, `Int(path)`, `Has(path)`. - Application reference only (do not change): `../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go` (`IsAllowedImage`, `AllowedImageMIMEs`) and `classes/album_files.go` (`StorePublicFile`: 11 random bytes hex disk name plus lower-cased extension, `DetectContentType`, sort_order = id). - Winter sources (meta repo `../examples/golem15-wintercms-starter`): `vendor/winter/storm/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php`, `2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php`, `vendor/winter/storm/src/Database/Models/DeferredBinding.php` (beforeCreate, cleanUp, deleteSlaveRecord), `vendor/winter/storm/src/Filesystem/Definitions.php` (default extension lists). ## Artifacts this phase produces (This plan's share.) - Table `deferred_bindings` (columns per D-01 plus `backend_user_id INTEGER NOT NULL`), indexes `deferred_bindings_master_type_index`, `_master_field_index`, `_slave_type_index`, `_slave_id_index`, `_session_lookup_index (session_key, backend_user_id, master_type)`, `_created_at_index`; migration id `202610020001_create_deferred_bindings`; history id `summercms.deferred` (table `summer_migrations_summercms_deferred`). - lagoon: `DeferredBindingMigrations []*gormigrate.Migration`, `DeferredHistoryID = "summercms.deferred"`, `DeferredBinding` (model, TableName `deferred_bindings`), `DeferredKey{SessionKey string; AdminID uint; MasterType string}`, `DeferredEnvelope{Created bool; Pivot map[string]any}`, `(DeferredBinding).Envelope() (DeferredEnvelope, error)`, `DeferredFileType = "system_files"`, `MorphType(db *gorm.DB, model any) (string, error)`, `DeferredBind(ctx, tx, key, field, slaveType, slaveID string, env *DeferredEnvelope) error`, `DeferredUnbind(ctx, tx, key, field, slaveType, slaveID string) (*DeferredBinding, error)`, `DeferredBindings(ctx, tx, key, fields []string) ([]DeferredBinding, error)`, `DeferredForget(ctx, tx, ids []uint) error`, `DeferredSlaves(tx, key, field, slaveType string, bind bool) *gorm.DB`, `PurgeDeferred(ctx, db, bucket, PurgeOptions) (PurgeResult, error)`, `PurgeOptions{Before time.Time; Models func(slaveType string) (any, bool)}`, `PurgeResult{Bindings, Files, Children, Skipped int}`, `Date`, `NewDate`, `DateOf`, `ParseDate`, `TimeOfDay`, `NewTimeOfDay`, `ParseTimeOfDay`, `FrameworkSchedule(app *backpack.App) ([]pact.ScheduledCommand, error)`, `FrameworkScheduleID = "summercms.lagoon"`. - attach: `Upload{FileName string; Body io.Reader; Public bool}`, `Limits{MaxBytes int64; Extensions []string; MIMETypes []string; Image bool}`, `Store(ctx, db, bucket, Upload, Limits) (*File, error)`, `ErrTooLarge`, `ErrFileType`, `ErrMIMEType`, `ErrNotImage`, `DefaultImageExtensions`, `DefaultFileExtensions`, `AllowedImageMIMEs`, `IsAllowedImage(data []byte) bool`, `MaxImagePixels`, `Relation{Name string; Many bool; Public bool}`, `HasRelations{AttachRelations() []Relation}`, `BlobKeys(f File) []string`, `(*File).ThumbKey(ctx, bucket, w, h, mode) (string, error)`. - pact: `RelationBeforeCreate`, `RelationAfterCreate`, `RelationBeforeUpdate`, `RelationAfterUpdate`, `RelationBeforeDelete`, `RelationAfterDelete` (each `Relation(ctx, relation string, parent, child any) error`). - CLI command `deferred:purge [--days=N]`; config keys `database.deferred_bindings.purge_days` (default 5) and `database.deferred_bindings.purge_at` (default `"03:00"`, empty disables the framework schedule entry); schedule entry id `summercms.lagoon[0]:deferred:purge`. ## Assumption-delta decision Detector not fired for this plan's surface. Considered `DeferredKey` by hand: the binding owner is the pair (session key, backend admin), never a second identity model; the backend admin stays the only principal. Noun primary: deferred binding. Decision: no-change. ## Planner assumptions recorded for this plan - A4: `master_type`/`slave_type` are `MorphName()` when the model implements `attach.Owner`, else the GORM table name (`lagoon.MorphType`); the file slave type is the table name `system_files` (`lagoon.DeferredFileType`), never a PHP class string. - A5: the purge lives in `lagoon.RuntimeCommands(app, plugins)` and resolves created-child models from every plugin's `pact.HasModels`. - A8: `Limits.MIMETypes` entries containing `/` are MIME patterns (`image/*` allowed) matched against the sniffed type; entries without `/` are extensions. - A9: purge config keys as listed in Artifacts. - RESEARCH Open Question 1: no second bucket for protected files; protected rows live in the same bucket under unguessable disk names. Task 1: A file stored for an unsaved record is held by an admin's deferred binding, and an expired binding is purged with its row and, after commit, its blob D-01 table shape is user-locked in CONTEXT.md (already decided, no checkpoint): every host application migrates it, and a later shape change needs a migration plus a data rewrite. modules/lagoon/attach/store.go, modules/lagoon/attach/guard.go, modules/lagoon/attach/relation.go, modules/lagoon/attach/file.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/store_test.go, modules/lagoon/deferred.go, modules/lagoon/deferred_migrations.go, modules/lagoon/purge.go, modules/lagoon/deferred_test.go, modules/lagoon/migrations.go, modules/lagoon/README.md, docs/database/attachments.md, ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/parity/migrate_test.go modules/lagoon/migrations.go, modules/lagoon/attach/migrations.go, modules/lagoon/attach/file.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/lifecycle_test.go (Postgres harness), modules/lagoon/transaction.go (Transaction, AfterCommit), modules/lagoon/postgres_test.go (TestMain, dedicatedDB), modules/lagoon/README.md, docs/database/attachments.md, ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go, ../fonoteka.go/parity/schema_diff_test.go (allowedDiffs), ../fonoteka.go/parity/migrate_test.go (wantTables), ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Migrations/2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Models/DeferredBinding.php, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Filesystem/Definitions.php, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Code Examples, Pitfalls 5, 6, 8, 9) Per D-01, D-02, D-05, D-06, D-07, D-08 and D-22, wire one path end to end: attach.Store, then a deferred bind owned by an admin, then PurgeDeferred removing binding, row and (after commit) blob. (1) Migration (D-01): modules/lagoon/deferred_migrations.go exports `DeferredHistoryID = "summercms.deferred"` and `DeferredBindingMigrations` with one gormigrate migration, ID `202610020001_create_deferred_bindings`, in the `attach.Migrations` style: 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()) and the six indexes named in Artifacts; Rollback drops the table. In migrations.go `Migrate` runs this set with `migrator(gdb, DeferredHistoryID, DeferredBindingMigrations)` right after the `summercms.cabana` set and before the queue set, with error prefix `lagoon: migrate deferred bindings`; update the Migrate doc comment. Do not append to attach.Migrations or BackendAdminMigrations. (2) Store ops in modules/lagoon/deferred.go: model `DeferredBinding` (columns per D-01; `IsBind bool` with no gorm default tag so an explicit false is written; `PivotData *string`; `BackendUserID uint`), `DeferredKey`, `DeferredEnvelope` (JSON `{"created":true,"pivot":{...}}`, both keys omitempty, D-22), `(DeferredBinding).Envelope()`, `DeferredFileType = "system_files"`, and `MorphType(db, model)` (attach.Owner's MorphName when implemented, else the GORM table name parsed with db's naming strategy; error on an empty result). `DeferredBind` and `DeferredUnbind` port Winter's beforeCreate: look up a row with the same master_type, master_field, slave_type, slave_id, session_key and backend_user_id; same direction means no new row; opposite direction deletes the existing row (and DeferredUnbind returns that cancelled bind so the caller removes the slave); otherwise insert. Both refuse an empty session key, a zero AdminID or an empty MasterType with an error. `DeferredBindings` selects the key's rows whose master_field is in fields, ordered by id, `FOR UPDATE`. `DeferredForget` deletes exactly the rows with the given ids (the caller passes only ids it read for its own key). `DeferredSlaves(tx, key, field, slaveType, bind)` returns a subquery selecting `slave_id` for the key, field, slave type and direction, for use as `CAST(pk AS TEXT) IN (?)` (RESEARCH Pattern 4). (3) attach (D-06, D-07, D-08): relation.go defines `Relation{Name string; Many bool; Public bool}` and `HasRelations{AttachRelations() []Relation}` beside `Owner`. guard.go ports the application guard: `AllowedImageMIMEs` (jpeg, png, gif, webp), `IsAllowedImage(data []byte) bool` (fail closed on empty data; sniff with http.DetectContentType must be allowed; `image.DecodeConfig` must succeed with format jpeg, png, gif or webp and positive width and height) and `MaxImagePixels = maxThumbSourcePixels`; an image over the ceiling is refused. store.go defines `Upload`, `Limits`, the four sentinel errors, `DefaultImageExtensions` (jpg, jpeg, png, gif, webp) and `DefaultFileExtensions` (Winter's Definitions.php default list minus svg, js, map, css, less, scss, swf and xml, the script-capable types; record the final list in the godoc). `Store` lower-cases the client extension and accepts it only when it matches `^[a-z0-9]{1,10}$` and is in `Limits.Extensions` (or the default list for the mode when empty); peeks up to 1 MiB of the body through a bufio.Reader for the sniff and, with `Limits.Image`, `IsAllowedImage` on the peeked bytes; checks `Limits.MIMETypes` per A8; streams the body through a counting `io.LimitReader(MaxBytes+1)` into `bucket.NewWriter` at `BlobKey(diskName)` (disk name: 11 crypto/rand bytes as 22 lowercase hex characters plus "." and the extension; never any client path component), aborting and deleting the key with ErrTooLarge when the count exceeds MaxBytes (MaxBytes 0 means no field limit); inserts the `File` row with attachment columns empty, `IsPublic` from Upload, `FileSize`, the sniffed base media type (or `mime.TypeByExtension` when the sniff is application/octet-stream), then sets `sort_order` to the new id; on a row error deletes the blob with `context.WithoutCancel`. Export `BlobKeys(f File) []string` (the blobKeysFor body; keep the unexported name as a call-through or replace its callers) and `(*File).ThumbKey(ctx, bucket, w, h, mode) (string, error)` returning the blob key `Thumb` already computes, with `Thumb` becoming `PublicURL(ThumbKey(...))` so its behaviour is unchanged. attach never imports lagoon. (4) Purge (D-05, D-22) in modules/lagoon/purge.go: `PurgeDeferred(ctx, db, bucket, opts)` processes bindings with `created_at < opts.Before` in id order, batches of 500, each batch in `lagoon.Transaction` selecting with `FOR UPDATE SKIP LOCKED`. Per binding: is_bind with slave_type `DeferredFileType` deletes the `system_files` row only when its attachment_id is empty or NULL, collecting `attach.BlobKeys`; is_bind with an envelope whose Created is true resolves `opts.Models(slaveType)`, loads the row by primary key into a fresh model and deletes it through GORM (model hooks and soft delete apply); an unresolvable slave type leaves the binding in place, counts `Skipped` and logs once per type at Warn; every other binding (unbinds, linked-only binds) is just deleted. Processed bindings are deleted in the same transaction, and the collected keys go to `lagoon.AfterCommit(ctx, tx, …)` calling `attach.DeleteKeys`. A nil bucket with file deletions is an error before any delete. (5) Smoke tests (full coverage is plan 05): modules/lagoon/deferred_test.go `TestDeferredUploadPurgeTracer` on the lagoon Postgres harness with a `mem://` bucket: Migrate, attach.Store a small PNG with `Limits{Image: true}`, DeferredBind it for an acme master type and admin 7, set created_at back 6 days, PurgeDeferred with Before = now minus 5 days, then assert the binding and the row are gone and the blob key no longer exists; also one bind/unbind cancel pair. modules/lagoon/attach/store_test.go `TestStoreSmoke`: a PNG stored with sort_order equal to id, an SVG refused with ErrNotImage in image mode, a body of MaxBytes+1 refused with ErrTooLarge and no blob left. (6) fonoteka.go test expectations (a separate fonoteka.go commit; no application code changes): add `"deferred_bindings"` to `allowedDiffs` in parity/schema_diff_test.go with a reason naming 12.2 D-01 (Winter core table, snapshot does not dump Winter core tables, Go adds backend_user_id), and add `"summer_migrations_summercms_deferred"` to `wantTables` in parity/migrate_test.go in sorted position. (7) Docs in the same change (CLAUDE.md): modules/lagoon/README.md (Features, API reference rows for every new lagoon and attach identifier above, the framework migration list now naming deferred_bindings) and docs/database/attachments.md (a "Storing an upload" section: attach.Store, Limits, the image guard, the default extension lists, attach.Relation and HasRelations; a "Protected files" paragraph: one bucket, unguessable disk names, the framework never builds a public URL for an is_public=false row, mount attach.StaticHandlerPublic or keep directory listing off). Prose only, no hand-written Go fences; every identifier named must exist. go vet ./... && go test ./modules/lagoon ./modules/lagoon/attach -count=1 -v -run '^(TestDeferredUploadPurgeTracer|TestStoreSmoke.*)$' && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestMigrateSeedsCanonicalGenres)$' -count=1 Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestDeferredUploadPurgeTracer" and a "--- PASS: TestStoreSmoke" line; TestDocsTree reports an unknown identifier; TestSchemaMatchesPHPSnapshot reports "Go extra table deferred_bindings"; TestMigrateSeedsCanonicalGenres reports a history table mismatch. - `grep -c 'backend_user_id INTEGER NOT NULL' modules/lagoon/deferred_migrations.go` prints 1 and `grep -c '202610020001_create_deferred_bindings' modules/lagoon/deferred_migrations.go` prints 1. - `grep -c 'DeferredHistoryID' modules/lagoon/migrations.go` prints at least 1. - `go doc ./modules/lagoon DeferredBind`, `go doc ./modules/lagoon DeferredUnbind`, `go doc ./modules/lagoon PurgeDeferred`, `go doc ./modules/lagoon MorphType`, `go doc ./modules/lagoon/attach Store`, `go doc ./modules/lagoon/attach IsAllowedImage`, `go doc ./modules/lagoon/attach HasRelations`, `go doc ./modules/lagoon/attach BlobKeys` and `go doc ./modules/lagoon/attach File.ThumbKey` exit 0. - `grep -c 'SKIP LOCKED' modules/lagoon/purge.go` prints at least 1 and `grep -c 'AfterCommit' modules/lagoon/purge.go` prints at least 1. - `go list -deps ./modules/lagoon/attach | grep -cx 'git.golem15.com/golem15/summercms/modules/lagoon'` prints 0 (attach does not import lagoon). - `grep -c '"deferred_bindings"' ../fonoteka.go/parity/schema_diff_test.go` prints 1 and `grep -c 'summer_migrations_summercms_deferred' ../fonoteka.go/parity/migrate_test.go` prints 1. - `git diff --stat HEAD -- go.mod go.sum` prints nothing (no new Go dependency). - `grep -c 'attach.Store' docs/database/attachments.md` and `grep -c 'deferred_bindings' modules/lagoon/README.md` each print at least 1. A host application can store a guarded upload, hold it for one admin's session key, and purge it later; the table is migrated in every application and fonoteka.go's parity suite knows the new framework table. Task 2: A model fills and stores date, datetime and time columns from JSON strings with framework types, and required rejects an empty date New value types and an additive Fill fallback ordered after every existing conversion. modules/lagoon/date.go, modules/lagoon/date_test.go, modules/lagoon/fill.go, modules/lagoon/validate.go, modules/lagoon/README.md, docs/database/models.md, docs/database/casts-and-validation.md modules/lagoon/fill.go (setField, convertValue, convertNumber, fillScanSource), modules/lagoon/validate.go (isEmptyValue and its callers at lines 63, 114, 154, 179, 349), modules/lagoon/encrypted.go (Scan/Value style), modules/lagoon/fill_test.go, modules/lagoon/validate_test.go, modules/lagoon/README.md, docs/database/models.md, docs/database/casts-and-validation.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Pitfalls 1 and 3, "lagoon.Date / TimeOfDay") Per D-19 and RESEARCH Pitfalls 1 and 3 (success criterion 1: no plugin-defined date types). (1) modules/lagoon/date.go: `Date` (unexported year, month, day and a valid flag) with `NewDate(y int, m time.Month, d int) Date` (normalises through time.Date and stays valid), `DateOf(t time.Time) Date` (calendar date in t's own location), `ParseDate(s string) (Date, error)` (exactly `2006-01-02`), methods `Time(loc *time.Location) time.Time`, `String() string`, `IsZero() bool`, `Scan(src any) error` (nil, time.Time taking t's Year/Month/Day without conversion, string and []byte in `2006-01-02` or a leading `2006-01-02T…` timestamp), `Value() (driver.Value, error)` (NULL when zero, else the `2006-01-02` string), `MarshalJSON` (null when zero), `UnmarshalJSON` (null gives zero), `MarshalText`, `UnmarshalText`. `TimeOfDay` (hour, minute, second, valid) with `NewTimeOfDay(h, m, s int)`, `ParseTimeOfDay(s string)` accepting `15:04` and `15:04:05` and truncating fractional seconds, methods `Hour()`, `Minute()`, `Second()`, `String()` (`15:04:05`), `IsZero()`, `Scan` (nil, string, []byte, time.Time clock), `Value` (NULL when zero, else `15:04:05`), JSON and text marshalling with the same null rule. Errors use the prefix `lagoon: date` / `lagoon: time of day`. Pointer variants need no extra code. (2) fill.go: in `convertValue`, after the ConvertibleTo branch fails and only when the source is a string or []byte, try `encoding.TextUnmarshaler` on `reflect.New(destType)`; success returns the element, failure returns the original "cannot assign" error wrapped so cabana still reports a FillTypeError. `time.Time` parses RFC 3339 through its own UnmarshalText (an offset such as `+02:00` is kept as the instant; GORM writes timestamptz in UTC). Every conversion that worked before stays first, so nothing that filled before changes. (3) validate.go: `isEmptyValue` also returns true for a zero `time.Time`, `Date` or `TimeOfDay` value and for a non-nil pointer to one; it checks these three types explicitly (no generic IsZero interface), so a numeric or other type's emptiness never changes. (4) Smoke tests in modules/lagoon/date_test.go: `TestDateSmoke` (ParseDate, JSON round trip, Scan of a UTC-midnight time.Time and of a string, Value of zero is nil), `TestTimeOfDaySmoke`, `TestFillTextSmoke` (Fill a struct with time.Time, *time.Time, Date, *Date, TimeOfDay, *TimeOfDay from JSON strings; a garbage date is a FillTypeError), `TestValidateRequiredZeroDateSmoke` (required fails on a zero Date and passes on a set one). (5) Docs in the same change: modules/lagoon/README.md (Date, TimeOfDay and their constructors in Features and API reference; a note that `required` now treats a zero date or time as empty, a lagoon behaviour change, and that optional dates should use pointer fields), docs/database/models.md (mass assignment: Fill accepts date and time strings), docs/database/casts-and-validation.md (a "Date and time columns" section mapping DATE, TIME and timestamptz to the Go types and their JSON shapes). Prose and tables only. go vet ./... && go test ./modules/lagoon -count=1 -v -run '^(TestDateSmoke|TestTimeOfDaySmoke|TestFillTextSmoke|TestValidateRequiredZeroDateSmoke)$' && go test ./modules/lagoon -count=1 -run '^(TestFill.*|TestValidate.*)$' && go test ./cmd/summer -run TestDocsTree -count=1 Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks a "--- PASS" line for each of the four smoke tests; an existing TestFill or TestValidate test fails (behaviour of an earlier conversion changed). - `go doc ./modules/lagoon Date`, `go doc ./modules/lagoon TimeOfDay`, `go doc ./modules/lagoon ParseDate` and `go doc ./modules/lagoon ParseTimeOfDay` exit 0. - `grep -c 'TextUnmarshaler' modules/lagoon/fill.go` prints at least 1. - `grep -c 'TimeOfDay' modules/lagoon/validate.go` prints at least 1. - `grep -c 'lagoon.Date' docs/database/casts-and-validation.md` prints at least 1 and `grep -c 'TimeOfDay' modules/lagoon/README.md` prints at least 1. A plugin model declares `lagoon.Date`, `lagoon.TimeOfDay` or `time.Time` columns, fills them from the admin's JSON strings and gets a 422 from `required` on an empty date. Task 3: An operator purges abandoned bindings with `deferred:purge`, the scheduler runs it daily, and controllers can hook child create, update and delete New command, two optional config keys with defaults, a schedule entry that config can disable, and optional interfaces nothing has to implement. modules/lagoon/commands.go, modules/lagoon/schedule.go, modules/lagoon/migrations_test.go, modules/lagoon/README.md, modules/conga/scheduler.go, modules/conga/schedule_test.go, modules/conga/README.md, modules/pact/capabilities.go, modules/pact/README.md, docs/plugins/scheduling.md, docs/console/setup-and-maintenance.md modules/lagoon/commands.go, modules/lagoon/migrations_test.go (TestRuntimeCommandsRegisterBareAndColonNames), modules/conga/scheduler.go, modules/conga/schedule_test.go (every scheduleEntries and periodicJobs expectation), modules/conga/commands.go, modules/conga/README.md, modules/pact/capabilities.go (RelationBeforeLink and neighbours), modules/pact/README.md, modules/lagoon/attach/bucket.go (OpenBucket, Publish), docs/plugins/scheduling.md, docs/console/setup-and-maintenance.md, cmd/summer/docs.go (docsCommands picks up lagoon.RuntimeCommands names) Per D-05 and D-16. (1) `deferred:purge` in `lagoon.RuntimeCommands` (update the doc comment that lists the commands): flag `days` ("Purge bindings older than this many days"); without the flag the days come from `database.deferred_bindings.purge_days` when set, else 5 (Winter cleanUp(5)); a negative or non-integer value is an error naming the flag. The command opens the database through `withDB`, takes the bucket from `app.Lookup[*blob.Bucket]()` or opens one with `attach.OpenBucket(ctx, app.Config)` (closing what it opened), builds the model resolver from every plugin's `pact.HasModels().Models()` keyed by `MorphType` (a duplicate key is an error naming both types), calls `PurgeDeferred` with Before = now minus the days, and prints one success line with the bindings, files, children and skipped counts. (2) modules/lagoon/schedule.go: `FrameworkScheduleID = "summercms.lagoon"` and `FrameworkSchedule(app *backpack.App) ([]pact.ScheduledCommand, error)` returning `{Command: "deferred:purge", Cadence: pact.DailyAt(h, m)}` from `database.deferred_bindings.purge_at` (`HH:MM`, default `03:00`); an explicitly empty value returns no entries; a malformed value (not two-digit hour 00-23, colon, two-digit minute 00-59) returns an error naming the key, which conga reports as a boot error. A nil app or nil Config returns no entries and no error, so config-less test apps see no change. (3) conga/scheduler.go: `scheduleEntries` prepends the framework entries with plugin id `lagoon.FrameworkScheduleID` and the same id format and compile path (`scheduleFor`), before the plugin loop, so they appear in `schedule:list`, run through `runScheduled` and are protected by the compiled entry table like plugin entries. Update every conga test whose expected entries change when an app config is present, and add `TestFrameworkScheduleSmoke` (an app with config gets `summercms.lagoon[0]:deferred:purge` first; `purge_at: ""` removes it; a nil-config app keeps its old entry list). (4) pact/capabilities.go: the six optional controller interfaces named in Artifacts, each `Relation(ctx context.Context, relation string, parent, child any) error`, documented beside RelationBeforeLink: Before hooks run inside the child write's transaction before the row write, After hooks after it and before commit; `parent` is the loaded parent, or a fresh zero-key record when the parent is not saved yet (deferral); an error rolls the write back and cabana answers its opaque lifecycle error. (5) Docs in the same change: modules/lagoon/README.md (CLI commands table row for `deferred:purge`, Configuration rows for both keys, API rows for FrameworkSchedule and FrameworkScheduleID), modules/conga/README.md (framework schedule entries), modules/pact/README.md (API reference rows for the six hooks), docs/plugins/scheduling.md (the framework's daily purge entry and how to disable or move it), docs/console/setup-and-maintenance.md (the `deferred:purge` command and its flag). Config keys are not checked automatically: review them by hand against the code. go vet ./... && go test ./modules/conga -count=1 -v -run '^(TestFrameworkScheduleSmoke)$' && go test -short ./modules/conga ./modules/lagoon ./modules/pact -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestFrameworkScheduleSmoke"; docs:build --check prints a problem line (for example an unknown command `deferred:purge`); the fonoteka.go build or vet fails. - `grep -c '"deferred:purge"' modules/lagoon/commands.go` prints at least 1 and `grep -c 'purge_days' modules/lagoon/commands.go` prints at least 1. - `grep -c 'FrameworkSchedule' modules/conga/scheduler.go` prints at least 1. - `go doc ./modules/pact RelationBeforeCreate`, `go doc ./modules/pact RelationAfterDelete` and `go doc ./modules/lagoon FrameworkSchedule` exit 0. - `grep -c 'deferred:purge' docs/console/setup-and-maintenance.md`, `grep -c 'deferred:purge' docs/plugins/scheduling.md` and `grep -c 'purge_at' modules/lagoon/README.md` each print at least 1. - `go test -short ./modules/conga -count=1` passes (existing schedule tests updated, none deleted). Abandoned uploads and pending children are cleaned up daily or on demand, and controllers can observe child writes; plan 03 calls the hooks. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Uploaded bytes and client file name → attach.Store | Untrusted content and names reach the blob store, the sniffer and the image decoders | | Session key + admin id → deferred_bindings | Ownership of pending work; later plans pass request-derived keys here | | deferred_bindings rows → purge | Stored rows decide which records and blobs are deleted | | River job rows → scheduled command | A job row names a command to run | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-12.2-01 | Denial of Service | attach.Store body | high | mitigate | Counting LimitReader(MaxBytes+1) while streaming, blob deleted on overflow, 1 MiB bounded peek; callers add MaxBytesReader (plan 02) (Task 1). | | T-12.2-02 | Elevation of Privilege | polyglot or script-capable upload | high | mitigate | Image mode: sniff plus DecodeConfig plus pixel ceiling, fail closed; file-mode default list drops svg, js, map, css, less, scss, swf, xml (Task 1). | | T-12.2-03 | Tampering | client file name | high | mitigate | Disk name is 22 random hex characters plus a validated `[a-z0-9]{1,10}` extension; no client path component reaches a blob key (Task 1). | | T-12.2-04 | Tampering | PurgeDeferred | high | mitigate | Deletes a slave model only with the D-22 `created` envelope and a file only when unattached; linked-only rows are kept; rows locked with SKIP LOCKED (Task 1). | | T-12.2-05 | Tampering | blob deletes | medium | mitigate | Blob keys collected in the transaction and deleted in lagoon.AfterCommit via attach.DeleteKeys; a rollback keeps the bytes (Task 1). | | T-12.2-06 | Spoofing | deferred-binding ownership | high | mitigate | backend_user_id NOT NULL; every store op takes a DeferredKey with AdminID and refuses zero or empty parts (Task 1). | | T-12.2-07 | Tampering | forged scheduled job row | medium | mitigate | The framework entry joins conga's compiled entry table, so runScheduled still runs only exact compiled entries (T-11-09) (Task 3). | | T-12.2-08 | Denial of Service | Fill text parsing | low | accept | TextUnmarshaler runs only for string sources already bounded by the request body cap; parsers are linear stdlib parsers. | | T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package is added in this plan (go.mod/go.sum unchanged, acceptance criterion). | - summercms.go: `go vet ./... && go test ./... -count=1` green with Docker up; `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green. - fonoteka.go: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestMigrateSeedsCanonicalGenres)$' -count=1` green. - The fonoteka.go commit touches only parity/schema_diff_test.go and parity/migrate_test.go. - deferred_bindings migrates under `summercms.deferred`; store ops, envelope and purge behave per D-01, D-02, D-05 and D-22. - attach.Store stores guarded uploads per D-07/D-08; attach.Relation exists per D-06. - lagoon.Date/TimeOfDay fill from JSON and `required` rejects a zero date per D-19. - `deferred:purge` and the framework schedule entry exist; pact has the D-16 hooks; READMEs and docs updated; docs checker green. Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md` when done.