diff --git a/.planning/STATE.md b/.planning/STATE.md
index 0d787e9b..5f942b2a 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1060,6 +1060,8 @@ Recent decisions affecting current work:
| 261022-x7a | User plugin golem15.user.password.public_routes (default true): false leaves POST /login, /forgot-password and /reset-password unregistered so they 404 like unknown paths; /register and the rest unchanged (sm-user-plugin 3828a65, ships v0.5.0; verified) | 2026-10-22 | e5dd457 | [261022-x7a-user-plugin-golem15-user-password-public](./quick/261022-x7a-user-plugin-golem15-user-password-public/) |
| 55 | fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged after commit | 2026-10-23 | 7c43820 | — |
| 261028-pq8 | Fix the four internal/build scaffolder todos (make:plugin vendor path, make:admin-controller output, scaffold header and make:command deps, same-second migration order) | 2026-10-28 | 8bc18c9 | [261028-pq8-fix-the-four-internal-build-scaffolder-t](./quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/) |
+| 261028-r16 | Fix the two lagoon todos: stored-upload extension from sniffed MIME (tests + deploy note) and the lagoon README after-commit callback order | 2026-10-28 | 36092f5 | [261028-r16-fix-the-two-lagoon-todos-derive-stored-u](./quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/) |
+| fast | ensureToolchain keeps an existing toolchain line instead of adding a second one | 2026-10-28 | 6ea2cb7 | — |
## Deferred Items
diff --git a/.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-PLAN.md b/.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-PLAN.md
new file mode 100644
index 00000000..bd842e87
--- /dev/null
+++ b/.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-PLAN.md
@@ -0,0 +1,316 @@
+---
+phase: quick-261028-r16
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - modules/lagoon/attach/store_test.go
+ - docs/services/storage.md
+ - docs/database/attachments.md
+ - modules/lagoon/transaction.go
+ - modules/lagoon/transaction_test.go
+ - modules/lagoon/relations.go
+ - modules/lagoon/README.md
+ - docs/database/transactions.md
+autonomous: true
+requirements:
+ - todo:2026-10-11-uploads-keep-client-extension-stored-xss
+ - todo:lagoon-readme-after-commit-callback-order
+
+estimate:
+ tokens: 70000
+ raw_tokens: 70000
+ tasks: 2
+ confidence: low
+
+must_haves:
+ truths:
+ - "A real PNG with `,
+- a 4-byte big-endian CRC computed by hash/crc32.ChecksumIEEE over type plus data.
+Add encoding/binary, hash/crc32 and net/http to the imports.
+
+Add TestPNGScriptChunkNaming. It is Docker-free and must not skip under -short. It checks:
+- png.Decode of the fixture succeeds,
+- attach.IsAllowedImage(fixture) is true,
+- the fixture contains the script literal (bytes.Contains),
+- http.DetectContentType(fixture) is "image/png",
+- attach.DiskExtension of that type with client extension "html" is "png".
+
+Add TestStoreScriptChunkPNG, modelled on TestStoreDiskExtensionSmoke. It skips under testing.Short() with the same message, and sets up attachGorm, lagoon.Migrate(gdb, nil) and memblob.OpenBucket(nil) with t.Cleanup Close. It calls attach.Store with Upload{FileName: "x.html", Body: bytes.NewReader(fixture), Public: true} and Limits{Image: true, Extensions: []string{"png", "html"}}. html is allowed on purpose, so the extension check is not what stops the attack. Then it asserts:
+- the DiskName and URL() suffixes are ".png",
+- FileName is "x.html" and ContentType is "image/png",
+- bucket.ReadAll(ctx, attach.BlobKey(f.DiskName)) equals the fixture byte for byte. The payload is stored as uploaded; only its name makes it harmless.
+Doc-comment both tests, naming the todo scenario (a write-scoped client uploads a valid image under an .html name).
+
+Part B, the docs. Per the todo's defence-in-depth item and the 19.3 deferred-items note.
+
+In docs/services/storage.md, rewrite the "Deploy note" blockquote at the end of the built-in route section as a short subsection or blockquote with two cases. Keep the existing sentence that attach.Store names each file after its sniffed content through attach.DiskExtension, so a PNG uploaded as x.html is stored as .png.
+
+(1) A static server such as nginx serving the uploads directory. It picks the Content-Type from the extension and sends none of the route's headers. Give a neutral nginx example in a fenced nginx block with these parts:
+- a server-level `add_header X-Content-Type-Options nosniff always;`, with a comment that an add_header inside a location replaces every add_header inherited from the server level,
+- a case-insensitive regex location `~* ^/storage/app/uploads/public/.+\.(?:jpe?g|png|gif|webp)$` with `root /srv/acme`, its own `types` block that maps only those extensions (image/jpeg jpg jpeg, image/png png, image/gif gif, image/webp webp), and `default_type application/octet-stream`,
+- a plain prefix `location /storage/` that answers `return 404;`, with a comment that it must stay a plain prefix, not `^~`, so that nginx tries the regex first.
+Say in prose: extend the extension list only with types the application accepts (see attach.DefaultFileExtensions), and serve anything that is not a raster image as a download (Content-Disposition attachment) or from a separate origin, the way the built-in route does.
+
+(2) A public S3 bucket, or a CDN that pulls from it, serving the files under storage.uploads.public_url. It does not look at the extension. It sends the Content-Type stored with the object, which attach.Store sets to the sniffed type. So an HTML body uploaded as note.txt (txt is in attach.DefaultFileExtensions) is stored as `.txt` but served as text/html. Serve such a bucket from its own origin, never the application's, and set X-Content-Type-Options nosniff and, for everything but raster images, Content-Disposition attachment through the bucket's or CDN's response-header settings.
+
+Use only identifiers that exist: attach.Store, attach.DiskExtension, attach.DefaultFileExtensions, storage.uploads.public_url. The docs checker verifies the identifiers. It does not verify config keys, so check those by hand against the configuration rows already on the page.
+
+In docs/database/attachments.md, line ~98, make the first sentence's reason precise: a static web server in front of the uploads directory picks the Content-Type from the extension. Add a short pointer that a public bucket sends the stored type instead, linking to the storage page's deploy note anchor. Use the anchor that `go run ./cmd/summer docs:build --check` accepts.
+
+Commit only these three paths, staged by explicit path. Suggested message: "docs(storage): pin the script-chunk PNG upload and split the uploads deploy note (quick-261028-r16)". No co-author tag.
+
+
+ go vet ./modules/lagoon/... && go test -count=1 -short -run 'TestPNGScriptChunkNaming|TestDiskExtensionTable' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestPNGScriptChunkNaming' && go test -count=1 -run 'TestStoreScriptChunkPNG|TestStoreDiskExtension' ./modules/lagoon/attach && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check
+
+
+ - `grep -c 'func TestStoreScriptChunkPNG\|func TestPNGScriptChunkNaming\|func pngWithScriptText' modules/lagoon/attach/store_test.go` prints 3.
+ - The non-short run of TestStoreScriptChunkPNG reports PASS, not SKIP. If the Docker socket is denied, rerun through `sudo -u golem` as CLAUDE.md describes, and record in SUMMARY which way it ran.
+ - `grep -c 'nosniff always' docs/services/storage.md` is at least 1 and `grep -c 'Content-Type stored with the object' docs/services/storage.md` is at least 1 (or equivalent wording the executor records in SUMMARY).
+ - With SHA1 set to this task's own commit (from `git log -1 --format=%H` right after committing, before anything else lands), `git show --name-only --format= "$SHA1"` exits 0 and lists store_test.go as the only file under modules/lagoon/attach/: no production attach code changed.
+ - No consuming application name appears in the new docs text (grep the diff for fonoteka, grzyby, plytarium: 0 hits).
+
+ One commit. The exact todo exploit (a decodable PNG with a script tEXt chunk, uploaded in image mode as x.html) is pinned with and without Docker. The storage deploy note correctly separates static-server and public-bucket serving, with a working neutral nginx allow-list. attachments.md agrees with it. go vet, go test -short ./... and the docs checker are green.
+
+
+
+ Task 2: Warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and the RegisterJoinTable read guidance
+ modules/lagoon/transaction.go, modules/lagoon/transaction_test.go, modules/lagoon/relations.go, modules/lagoon/README.md, docs/database/transactions.md
+
+ - modules/lagoon/transaction.go (whole file, about 260 lines)
+ - modules/lagoon/transaction_test.go lines 1-60 and 195-260 (harness, implicit_single_statement, plain_gorm_transaction_is_refused log capture)
+ - modules/lagoon/relations.go lines 1-28
+ - modules/lagoon/README.md lines 25-40, 80-105, 125-135 and 190-196
+ - docs/database/transactions.md lines 66-92
+ - docs/database/relations.md lines 20-56 (already-correct pivot-order guidance to mirror)
+ - modules/lagoon/example_test.go lines 580-625 (on-database docs region and TestDocsOnDatabase)
+ - $(go list -m -f '{{.Dir}}' gorm.io/gorm)/callbacks/transaction.go (ConnPool reset after commit)
+
+
+ - late_callback_warns: on a fresh handle from Use(ctx, db), a Create callback registered After(AfterCommitCallback) calls AfterCommit while creating acItem "flush-late". The row is committed (committedCount 1), the recorded work never runs (recorder empty, the same as before this change), Create returns no error, and the captured Warn log contains "registered after lagoon:after_commit ran".
+ - callback_between_commit_and_flush_runs: a callback registered After("gorm:commit_or_rollback_transaction").Before(AfterCommitCallback), creating "flush-between", has its work run exactly once ([flush-between]) and logs no warning.
+ - reused_statement_no_false_warning: a correctly ordered callback (Before("gorm:create")) on q := g.WithContext(ctx).Table("lagoon_ac_items"), creating "flush-reuse-1" and then "flush-reuse-2" through the same q, records [flush-reuse-1 flush-reuse-2] and logs no warning. This pins the transactionalHandle guard against the marker left behind by the first write.
+ - All existing TestTransactionAfterCommit subtests and TestDocsOnDatabase stay green unchanged.
+
+
+Code. Per the after-commit todo's suggested fix. The change is log-only and adds no exported identifiers.
+
+In modules/lagoon/transaction.go:
+- Add an unexported const statementFlushedKey = "lagoon:after_commit_flushed" next to statementBufferKey.
+- In flushStatementAfterCommit, make the first statement db.InstanceSet(statementFlushedKey, true), before the buffer InstanceGet. Every write that passes through the flush then marks its statement. Extend the function's doc comment to say so.
+- In AfterCommit's branch for a GORM-started transaction (gorm:started_transaction is set), before the buffering code, check two things: db.InstanceGet(statementFlushedKey) is present, and transactionalHandle(db) is false. Both together mean GORM's commit step already reset the statement's ConnPool to the pool and lagoon:after_commit already ran for this write. In that case call moduleLog.Warn with exactly the message "lagoon: after-commit callback registered after lagoon:after_commit ran; it will not run" and the key/value pair "hint", "register the GORM callback before gorm:commit_or_rollback_transaction".
+- Then fall through to the existing buffering, unchanged. Do not return early and do not run the work. That keeps behaviour identical in every case, including the rare case of a callback sorted between commit and flush on a statement reused from an earlier write.
+- Add a sentence to AfterCommit's doc comment. Called from a GORM callback that runs after AfterCommitCallback on the same write, the work is registered too late and never runs, and AfterCommit logs a warning. Register such callbacks Before("gorm:commit_or_rollback_transaction"). The transactionalHandle check keeps a statement reused across writes from warning for a correctly ordered callback in a later write.
+
+In modules/lagoon/transaction_test.go, add the three subtests from inside TestTransactionAfterCommit, after implicit_single_statement:
+- Each uses its own handle from Use(ctx, db) so its callback stays off the shared gdb.
+- Callback names: "lagoon_test:late", "lagoon_test:between", "lagoon_test:reuse".
+- Row names: "flush-late", "flush-between", "flush-reuse-1", "flush-reuse-2".
+- Capture logs exactly like plain_gorm_transaction_is_refused: swap slog.SetDefault to a TextHandler on a bytes.Buffer at LevelWarn and restore it with defer.
+- Assert with strings.Contains on the substring "registered after lagoon:after_commit ran".
+- If GORM itself rejects the second Create through the shared chained q (an error from GORM, not from lagoon), keep the other two subtests. Replace the reuse case with the closest GORM-supported reuse of one Statement, or drop it, and record the deviation and the reason in SUMMARY.
+
+Texts.
+- modules/lagoon/README.md, OnDatabase example (about line 89): change the registration chain to the one in example_test.go's on-database region, After("gorm:create").Before("gorm:commit_or_rollback_transaction"). The example currently chains off GORM's after-create callback.
+- README, the lead-in sentence before that example (about line 84): add one sentence. Register the callback before GORM's gorm:commit_or_rollback_transaction step: lagoon runs a single-statement write's after-commit work from lagoon:after_commit right after that step, so work buffered by a callback GORM sorts later never runs, and lagoon.AfterCommit logs a warning for it. Link it to ../../docs/database/transactions.md#callbacks-registered-at-boot if the docs checker accepts README links; otherwise leave the link out.
+- README, features line 29 (after-commit work): add that a callback which runs after lagoon:after_commit on the same write registers its work too late, the work never runs, and lagoon.AfterCommit logs a warning.
+- README, API row for lagoon.RegisterJoinTable (about line 194): append that pivot-ordered reads join the pivot table explicitly, because a Preload of the field cannot be ordered by a pivot column.
+- modules/lagoon/relations.go, RegisterJoinTable doc comment: replace the final sentence, which currently advises a Preload of the field plus an Order on the pivot's columns. The new text says the following. Reads in pivot order join the pivot table explicitly (Joins on the join table plus Order on its columns). A Preload of the field still loads the targets through the pivot model once RegisterJoinTable has run, but GORM issues that query without the join table, so ordering it by a pivot column fails with a missing FROM-clause entry. Keep the pivot-write contract paragraph as it is.
+- docs/database/transactions.md, WARNING block (about line 86): keep its first sentence. Change the end of the second sentence so that it says the buffered work is never run, the write still succeeds, and lagoon logs a warning when that happens.
+
+Commit only these five paths, staged by explicit path. Suggested message: "fix(lagoon): warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and pivot read guidance (quick-261028-r16)". No co-author tag. Leave other sessions' STATE.md and state.json edits alone.
+
+
+ go vet ./... && go test -count=1 -short ./modules/lagoon/... && go test -count=1 -run 'TestTransactionAfterCommit|TestDocsOnDatabase' -v ./modules/lagoon/ 2>&1 | grep -q -- '--- PASS: TestTransactionAfterCommit/late_callback_warns' && go test -count=1 -run 'TestTransactionAfterCommit|TestDocsOnDatabase' ./modules/lagoon/ && go test -short ./... && go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check
+
+
+ - `grep -c 'gorm:after_create' modules/lagoon/README.md` prints 0.
+ - `grep -c 'After("gorm:create").Before("gorm:commit_or_rollback_transaction")' modules/lagoon/README.md` prints at least 1.
+ - `grep -c 'Preload(field)' modules/lagoon/relations.go` prints 0, and `grep -c -i 'join' modules/lagoon/relations.go` is at least 2.
+ - `grep -c 'statementFlushedKey' modules/lagoon/transaction.go` is at least 3 (declaration, set, read).
+ - `grep -c 'registered after lagoon:after_commit ran' modules/lagoon/transaction.go modules/lagoon/transaction_test.go` shows at least 1 in each file.
+ - The non-short run reports PASS for late_callback_warns, callback_between_commit_and_flush_runs and reused_statement_no_false_warning, unless the reuse case was replaced as described, and for every existing TestTransactionAfterCommit subtest and TestDocsOnDatabase. If the Docker socket is denied, rerun through `sudo -u golem`.
+ - No exported identifier is added or removed. Set SHA2 to this task's own commit (`git log -1 --format=%H` right after committing). Write `git show "$SHA2" -- modules/lagoon/` to a file in the session scratchpad and check that the command exited 0. Then `grep -cE '^[+-](func|type|var|const) [A-Z]'` on that file prints 0.
+
+ One commit. lagoon.AfterCommit warns, and only warns, when a callback that runs after lagoon:after_commit on the same write registers work; correct orderings and reused statements stay silent. The README example matches the tested docs region. The RegisterJoinTable comment, the README row and the transactions WARNING tell the truth. go vet, go test -short ./..., the Docker lagoon tests and the docs checker are green.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client upload → attach.Store | The client's file name and bytes are untrusted. The name's extension is checked against the allow-list, but it must not decide how the stored file is served |
+| bucket/static server → browser | Whoever serves the uploads picks the Content-Type: nginx picks it from the extension, S3 sends the stored object's metadata. A rendering type on a shared origin is stored XSS |
+| plugin GORM callback → lagoon after-commit buffer | Plugin code decides callback order. Misordered callbacks lose side effects (mail, jobs, broadcasts) without any trace |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-q261028r16-01 | Elevation of privilege | attach.Store disk name (valid PNG with script uploaded as x.html) | high | mitigate | Already mitigated by attach.DiskExtension in Store (19.3, 2e22e9f). Task 1 adds TestPNGScriptChunkNaming (no Docker) and TestStoreScriptChunkPNG (image mode, html allowed), which pin the exact todo exploit: the .png disk name, the .png URL and the bytes stored unchanged |
+| T-q261028r16-02 | Elevation of privilege | Public S3 bucket or CDN serving the stored text/html metadata of a .txt upload | medium | transfer | Task 1 corrects the deploy note: serve a public bucket from its own origin, with nosniff and attachment disposition for non-images set through the bucket or CDN header settings. Changing Store's blob metadata changes behaviour, so it is deferred to its own todo (recorded by the orchestrator) |
+| T-q261028r16-03 | Tampering | Static server serving the uploads directory by extension | medium | mitigate | Task 1 documents a neutral nginx pattern: server-level nosniff, a regex allow-list location with its own types map and default_type application/octet-stream, and a plain prefix 404 for everything else |
+| T-q261028r16-04 | Repudiation | lagoon.AfterCommit buffering work after lagoon:after_commit already ran | medium | mitigate | Task 2 logs a warning with a fix hint. The README example and the transactions docs register before gorm:commit_or_rollback_transaction |
+| T-q261028r16-05 | Denial of service | Warning noise or false positives on hot write paths | low | mitigate | The warning needs both the flushed marker and the statement's ConnPool no longer being a TxCommitter. reused_statement_no_false_warning and callback_between_commit_and_flush_runs pin the silent cases. Cost per write: one sync.Map store |
+| T-q261028r16-SC | Tampering | npm/pip/cargo installs | low | accept | No new dependencies. Only the stdlib packages encoding/binary, hash/crc32 and net/http are added to a test file |
+
+
+
+- After each of the two commits: go vet ./..., go test -short ./..., go test ./cmd/summer -run TestDocsTree and go run ./cmd/summer docs:build --check are green (CLAUDE.md rule).
+- Docker-backed: go test -count=1 -run 'TestStoreScriptChunkPNG|TestStoreDiskExtension' ./modules/lagoon/attach and go test -count=1 -run 'TestTransactionAfterCommit|TestDocsOnDatabase' ./modules/lagoon/ pass (fall back to sudo -u golem when the socket is denied).
+- No exported lagoon or attach identifier changes. No file outside summercms.go is edited; the sibling repos were only read at planning time.
+- git log shows two commits without co-author tags, each staging only its own paths. Other sessions' STATE.md, state.json, G15OFFICE.md and .gitkeep files are left alone.
+
+
+
+- The stored-XSS todo's acceptance holds in the framework with a test of the exact exploit. The deploy docs give correct defence-in-depth advice for both static servers and public buckets.
+- The after-commit todo's three items are done: README example, RegisterJoinTable comment and README line, and the AfterCommit warning, which is log-only and adds no API.
+- The SUMMARY lists the follow-ups for the orchestrator: the fonoteka.go storeUpload (collection_media_controller.go:251) and StorePublicFile (album_files.go:35) adoption; the optional dedup in sm-feedback-plugin and sm-user-plugin; the deferred Store blob-metadata hardening for public buckets.
+
+
+
diff --git a/.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-SUMMARY.md b/.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-SUMMARY.md
new file mode 100644
index 00000000..6969b5e9
--- /dev/null
+++ b/.planning/quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/261028-r16-SUMMARY.md
@@ -0,0 +1,122 @@
+---
+phase: quick-261028-r16
+plan: 01
+subsystem: lagoon
+tags: [attach, uploads, stored-xss, docs, after-commit, gorm-callbacks]
+status: complete
+requires: []
+provides:
+ - "TestPNGScriptChunkNaming / TestStoreScriptChunkPNG regression for the script-chunk PNG upload"
+ - "Storage deploy note split into static-server and public-bucket cases"
+ - "lagoon.AfterCommit warning for work registered after lagoon:after_commit"
+affects: [lagoon, lagoon/attach, docs/services/storage.md, docs/database]
+tech-stack:
+ added: []
+ patterns: ["statement-scoped marker via InstanceSet read back in AfterCommit, guarded by transactionalHandle"]
+key-files:
+ created: []
+ modified:
+ - modules/lagoon/attach/store_test.go
+ - docs/services/storage.md
+ - docs/database/attachments.md
+ - modules/lagoon/transaction.go
+ - modules/lagoon/transaction_test.go
+ - modules/lagoon/relations.go
+ - modules/lagoon/README.md
+ - docs/database/transactions.md
+decisions:
+ - "No production attach change: attach.DiskExtension (19.3, 2e22e9f) stays the single helper; the exploit is pinned by tests only"
+ - "AfterCommit late-registration check is log-only and falls through to the existing buffering; no exported identifier added"
+ - "Deploy note became an H3 subsection (#deploy-note) so attachments.md can link to it"
+metrics:
+ duration: ~20min
+ completed: 2026-10-28
+actuals:
+ tokens: 7000
+ tasks: 2
+ commits: 2
+plan_head_before: 6ea2cb70e04e731404ad00d78a4a82dc8b50d5a7
+plan_head_after: 36092f542c55ba7bf5c4fdb51a0151305c49d85a
+---
+
+# Quick 261028-r16 Plan 01: Fix the two lagoon todos Summary
+
+The todo's stored-XSS exploit now has a test: a decodable PNG with a ``, correct CRC) inserted after IHDR at offset 33.
+- `TestPNGScriptChunkNaming` (no Docker) checks that the fixture decodes, passes `IsAllowedImage`, contains the script, sniffs as `image/png`, and that `DiskExtension(…, "html")` gives `png`.
+- `TestStoreScriptChunkPNG` (Postgres) runs the upload through `attach.Store` with `Limits{Image: true, Extensions: [png, html]}`. It asserts a `.png` DiskName and URL, FileName `x.html`, ContentType `image/png`, and blob bytes identical to the upload.
+- In `docs/services/storage.md`, the old one-paragraph note is now a `### Deploy note` subsection covering both cases:
+ - Static server: a neutral nginx example with server-level `add_header X-Content-Type-Options nosniff always;`, a regex image allow-list location with its own `types` and `default_type application/octet-stream`, and a plain-prefix `location /storage/ { return 404; }`.
+ - Public S3 bucket or CDN: it sends "the Content-Type stored with the object", which is the sniffed type. Advice: use a separate origin, and set nosniff and attachment disposition through the bucket's or CDN's response-header settings.
+- In `attachments.md`, the reason is now precise (static servers pick the type by extension) and it links to `../services/storage.md#deploy-note`.
+
+**Task 2.**
+- `statementFlushedKey` (unexported) is set first thing in `flushStatementAfterCommit`.
+- In the GORM-started-transaction branch, `AfterCommit` now warns (`lagoon: after-commit callback registered after lagoon:after_commit ran; it will not run`, hint `register the GORM callback before gorm:commit_or_rollback_transaction`) when the marker is present and `!transactionalHandle(db)`. It then buffers exactly as before.
+- New subtests: `late_callback_warns`, `callback_between_commit_and_flush_runs` and `reused_statement_no_false_warning`.
+- A mutation check (dropping the `!transactionalHandle` guard) makes `reused_statement_no_false_warning` fail, so that subtest really pins the guard. The guard was restored before committing.
+- README: the OnDatabase example now uses `After("gorm:create").Before("gorm:commit_or_rollback_transaction")`, and its lead-in explains why and links to the transactions page. The after-commit feature line and the `RegisterJoinTable` API row were updated.
+- `relations.go`: pivot-ordered reads use an explicit join on the pivot table. A Preload cannot be ordered by a pivot column.
+- `transactions.md` WARNING: the write still succeeds, and lagoon logs a warning.
+
+## Verification
+
+- After each commit: `go vet ./...`, `go test -short ./...`, `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` all green.
+- Docker-backed tests ran directly; the socket was accessible, so `sudo -u golem` was not needed:
+ - `TestStoreScriptChunkPNG`, `TestStoreDiskExtensionSmoke` and `TestStoreDiskExtension`: PASS.
+ - `TestTransactionAfterCommit` (all 12 subtests, including the 3 new ones) and `TestDocsOnDatabase`: PASS.
+- Acceptance greps:
+ - store_test.go defines the 3 new functions.
+ - storage.md contains `nosniff always` and `Content-Type stored with the object`.
+ - lagoon README: `gorm:after_create` has 0 hits, the correct chain has 1.
+ - relations.go: `Preload(field)` has 0 hits, `join` has 9.
+ - `statementFlushedKey` appears 4 times in transaction.go.
+ - The warning text appears in both transaction.go and transaction_test.go.
+- Commit contents:
+ - Task 1 changed only `store_test.go` under `modules/lagoon/attach/`.
+ - In the Task 2 diff, `grep -cE '^[+-](func|type|var|const) [A-Z]'` gives 0, so no exported identifier changed.
+ - No consuming-app names appear in the diffs.
+ - Neither commit has a co-author line.
+
+## Deviations from Plan
+
+None. The plan was executed as written. The reuse subtest worked through a shared chained `q := g.WithContext(ctx).Table("lagoon_ac_items")`, so no replacement was needed.
+
+One transient environment note: the first full `go vet ./...` failed once in `internal/build` (`could not import go/version`) while another session had an uncommitted edit in flight in `internal/build/scaffold.go`. A rerun passed, and that file was not touched. `plan_head_before` (6ea2cb7) is a commit from that other session that landed after the 8c9c967 snapshot.
+
+## Follow-ups (for the orchestrator; sibling repos not edited)
+
+1. **fonoteka.go adoption of attach.DiskExtension.** Two call sites still append `filepath.Ext()` to the disk name:
+ - `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go:251` (`storeUpload`)
+ - `../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go:35` (`StorePublicFile`)
+
+ The fix is one line per site: sniff first, then use `attach.DiskExtension(contentType, filepath.Ext(name))`. The function already strips the dot, lower-cases the extension and drops MIME parameters. After the change, an upload without an extension gets `.bin`/`.txt` instead of no extension.
+2. **Optional dedup only (already safe):** `sm-feedback-plugin` `screenshotExtension` (classes/submission_store.go) and `sm-user-plugin` `avatarExt` (controllers/api_controller.go:1497) could use `attach.DiskExtension`.
+3. **Deferred behaviour change, needs its own todo:** `attach.Store` writes the sniffed type as blob metadata, so a public S3 bucket serves an HTML body stored as `.txt` as `text/html`. The fix would write a non-rendering Content-Type, or `Content-Disposition: attachment`, for non-inline types. That changes Store's output, so this plan only documents the risk in the deploy note.
+4. Move both todos (`2026-10-11-uploads-keep-client-extension-stored-xss.md`, `lagoon-readme-after-commit-callback-order.md`) to `.planning/todos/completed/` in the closing docs commit.
+
+## Known Stubs
+
+None.
+
+## Threat Flags
+
+None. No new endpoints or trust-boundary surface. The only new runtime behaviour is one log line.
+
+## Self-Check: PASSED
+
+- FOUND: modules/lagoon/attach/store_test.go (TestStoreScriptChunkPNG, TestPNGScriptChunkNaming, pngWithScriptText)
+- FOUND: modules/lagoon/transaction.go (statementFlushedKey)
+- FOUND: commit ebdfd78
+- FOUND: commit 36092f5
diff --git a/.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md b/.planning/todos/completed/2026-10-11-uploads-keep-client-extension-stored-xss.md
similarity index 88%
rename from .planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md
rename to .planning/todos/completed/2026-10-11-uploads-keep-client-extension-stored-xss.md
index 63ed795d..ee0ba071 100644
--- a/.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md
+++ b/.planning/todos/completed/2026-10-11-uploads-keep-client-extension-stored-xss.md
@@ -30,3 +30,7 @@ WinterCMS's `File::getDiskName` behaves the same way (inherited, not introduced)
- A PNG uploaded as `x.html` is stored with a `.png` disk name and its URL ends in `.png`.
- A regression test covers that upload.
+
+Closed 2026-10-28 by quick 261028-r16 (ebdfd78, 36092f5).
+
+Framework side already done in 2e22e9f (phase 19.3); the fonoteka.go call sites moved to 2026-10-28-fonoteka-uploads-use-disk-extension.md, the S3 Content-Type risk to 2026-10-28-attach-store-public-content-type.md.
diff --git a/.planning/todos/pending/lagoon-readme-after-commit-callback-order.md b/.planning/todos/completed/lagoon-readme-after-commit-callback-order.md
similarity index 96%
rename from .planning/todos/pending/lagoon-readme-after-commit-callback-order.md
rename to .planning/todos/completed/lagoon-readme-after-commit-callback-order.md
index eec84bd3..d0eaffe8 100644
--- a/.planning/todos/pending/lagoon-readme-after-commit-callback-order.md
+++ b/.planning/todos/completed/lagoon-readme-after-commit-callback-order.md
@@ -11,3 +11,5 @@ The `lagoon.OnDatabase` example in `modules/lagoon/README.md` registers its crea
Found while writing `docs/database/transactions.md` (Phase 11.1 plan 04). The page registers the callback before GORM's commit callback and says why.
Suggested fix: change the README example to register before `gorm:commit_or_rollback_transaction`, and consider making `lagoon.AfterCommit` warn when it buffers work on a statement whose `lagoon:after_commit` callback has already run. The README was read-only in plan 11.1-04 (Phase 11 gap plan 11-08 owned it). While there, correct the `lagoon.RegisterJoinTable` comment and README line that say pivot reads use `Preload(field)` plus `.Order(...)` on the pivot's columns: GORM preloads many-to-many targets in a query without the join table, so ordering by a pivot column there fails with "missing FROM-clause entry"; an explicit join does work.
+
+Closed 2026-10-28 by quick 261028-r16 (ebdfd78, 36092f5).
diff --git a/.planning/todos/pending/2026-10-28-attach-store-public-content-type.md b/.planning/todos/pending/2026-10-28-attach-store-public-content-type.md
new file mode 100644
index 00000000..ff39419a
--- /dev/null
+++ b/.planning/todos/pending/2026-10-28-attach-store-public-content-type.md
@@ -0,0 +1,21 @@
+---
+title: attach.Store saves the sniffed Content-Type, so a public S3 bucket renders HTML stored as .txt
+date: 2026-10-28
+created: 2026-10-28T18:47:20.000Z
+area: lagoon/attach
+severity: minor
+priority: normal
+g15office: q1xe3vzb
+files:
+ - modules/lagoon/attach
+ - docs/services/storage.md
+---
+
+## Problem
+
+Found by quick 261028-r16. `attach.Store` writes the sniffed content type as the blob's Content-Type. A public S3 bucket or CDN serves that stored type rather than one picked from the extension, so an HTML body uploaded as `note.txt` is served as `text/html`. The risk is documented in `docs/services/storage.md#deploy-note` (separate origin, nosniff, attachment disposition), not fixed.
+
+## Solution
+
+- For types that are not meant to render inline, store a non-rendering Content-Type or `Content-Disposition: attachment`.
+- This changes what Store writes (additive config or a default change), so decide the affected types first; no breaking change for existing consumers. Update the lagoon README and the storage page.
diff --git a/.planning/todos/pending/2026-10-28-fonoteka-uploads-use-disk-extension.md b/.planning/todos/pending/2026-10-28-fonoteka-uploads-use-disk-extension.md
new file mode 100644
index 00000000..43d2315b
--- /dev/null
+++ b/.planning/todos/pending/2026-10-28-fonoteka-uploads-use-disk-extension.md
@@ -0,0 +1,23 @@
+---
+title: fonoteka.go uploads still name files after the client extension (stored XSS)
+date: 2026-10-28
+created: 2026-10-28T18:47:20.000Z
+area: fonoteka.go uploads
+severity: major
+priority: high
+g15office: cf81chjk
+files:
+ - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go:251 (storeUpload)
+ - ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go:35 (StorePublicFile)
+---
+
+## Problem
+
+Split from `2026-10-11-uploads-keep-client-extension-stored-xss.md` (closed by quick 261028-r16, framework side). `attach.Store` and `attach.DiskExtension` in the framework name files from the sniffed type, but fonoteka.go's own upload paths still append `filepath.Ext()` to the disk name. A PNG with `