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.
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 |
|
true |
|
|
|
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>
Artifacts this phase produces
(This plan's share.)
- Table
deferred_bindings(columns per D-01 plusbackend_user_id INTEGER NOT NULL), indexesdeferred_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 id202610020001_create_deferred_bindings; history idsummercms.deferred(tablesummer_migrations_summercms_deferred). - lagoon:
DeferredBindingMigrations []*gormigrate.Migration,DeferredHistoryID = "summercms.deferred",DeferredBinding(model, TableNamedeferred_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(eachRelation<X>(ctx, relation string, parent, child any) error). - CLI command
deferred:purge [--days=N]; config keysdatabase.deferred_bindings.purge_days(default 5) anddatabase.deferred_bindings.purge_at(default"03:00", empty disables the framework schedule entry); schedule entry idsummercms.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_typeareMorphName()when the model implementsattach.Owner, else the GORM table name (lagoon.MorphType); the file slave type is the table namesystem_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'spact.HasModels. - A8:
Limits.MIMETypesentries 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.
(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.
(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.
(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> |
<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
requiredrejects a zero date per D-19. deferred:purgeand the framework schedule entry exist; pact has the D-16 hooks; READMEs and docs updated; docs checker green. </success_criteria>