Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-PLAN.md
Jakub Zych f4ec94531f docs(12.2): create phase plan
Five sequential plans: foundations (deferred_bindings, attach.Store,
lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation
child CRUD with deferral, admin SPA, and unit and security tests.
Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
2026-10-02 16:53:42 +02:00

45 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
12.2-admin-form-fields-date-file-upload-relation-editing-with-def 01 execute 1
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
true
SC-1
SC-2
SC-4
tokens raw_tokens tasks confidence
190000 190000 3 low
truths artifacts key_links prohibitions
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.
path provides contains
modules/lagoon/deferred_migrations.go DeferredBindingMigrations (deferred_bindings table, D-01) backend_user_id
path provides contains
modules/lagoon/deferred.go DeferredBinding, DeferredKey, DeferredEnvelope, DeferredBind, DeferredUnbind, DeferredBindings, DeferredForget, DeferredSlaves, MorphType func DeferredBind(
path provides contains
modules/lagoon/purge.go PurgeDeferred, PurgeOptions, PurgeResult SKIP LOCKED
path provides contains
modules/lagoon/date.go Date, TimeOfDay and their constructors and parsers type TimeOfDay struct
path provides contains
modules/lagoon/schedule.go FrameworkSchedule, FrameworkScheduleID deferred:purge
path provides contains
modules/lagoon/attach/store.go Store, Upload, Limits, ErrTooLarge, ErrFileType, ErrMIMEType, ErrNotImage, DefaultImageExtensions, DefaultFileExtensions func Store(
path provides
modules/lagoon/attach/guard.go AllowedImageMIMEs, IsAllowedImage, MaxImagePixels
path provides contains
modules/lagoon/attach/relation.go Relation, HasRelations AttachRelations() []Relation
from to via pattern
modules/lagoon/migrations.go modules/lagoon/deferred_migrations.go Migrate runs DeferredBindingMigrations under history id summercms.deferred summercms.deferred
from to via pattern
modules/lagoon/purge.go modules/lagoon/attach/file.go blob keys from attach.BlobKeys are deleted through attach.DeleteKeys inside lagoon.AfterCommit AfterCommit
from to via pattern
modules/conga/scheduler.go modules/lagoon/schedule.go scheduleEntries prepends lagoon.FrameworkSchedule entries with plugin id summercms.lagoon FrameworkSchedule
from to via pattern
modules/lagoon/fill.go modules/lagoon/date.go convertValue falls back to encoding.TextUnmarshaler for string sources TextUnmarshaler
statement status verification
Blob objects MUST NOT be deleted inside a database transaction; every blob delete runs in lagoon.AfterCommit through attach.DeleteKeys resolved test
statement status verification
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 resolved test
statement status verification
attach MUST NOT import lagoon (import cycle); anything needing both lives in lagoon resolved test
statement status verification
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 resolved test
statement status verification
No new Go module dependency is added; go.mod and go.sum stay unchanged resolved 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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.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<X>(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

<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. </assumption_delta_decision>

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 <fails_when>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.</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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 <fails_when>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).</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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<Phase>(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 ./... <fails_when>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.</fails_when> <acceptance_criteria> - 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). </acceptance_criteria> Abandoned uploads and pending children are cleaned up daily or on demand, and controllers can observe child writes; plan 03 calls the hooks.

<threat_model>

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).
</threat_model>
- 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.

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md` when done.