25 KiB
phase, reviewed, reviewer, threats_open, gate, removal_harness
| phase | reviewed | reviewer | threats_open | gate | removal_harness |
|---|---|---|---|---|---|
| 11 | 2026-09-30 | gsd-executor, plan 11-07 (code-and-test review of plans 11-01 to 11-07); T-11-31, T-11-32, RC-14 and RC-15 added in the 11-08 close-out/review | 0 | scripts/check-phase11.sh --all | scripts/check-phase11.sh --removal |
Phase 11 Security Review
This is a fresh code-and-test review of every threat in the registers of Plans 11-01 to 11-08 (T-11-01 to T-11-32 and T-11-SC; T-11-31 and T-11-32 come from gap plan 11-08). Severity and disposition are copied from the originating plan. A high threat counts as mitigated only when its named test or gate stage fails with the protection removed. scripts/check-phase11.sh --removal does this for every high mitigated threat: it applies an anchor-exact mutation that removes the protection, runs the named test, requires it to fail on an assertion (a build failure does not count), and restores the file byte for byte, checked with cmp. The results are recorded under "Removal checks". The accepted threat keeps its rationale verbatim from its originating plan.
The review found three defects in Phase 11 code, all fixed in plan 11-07 with a failing-when-broken test:
- lighthouse broadcast callbacks ran after GORM's own commit for a single-statement write, so the broadcast job was enqueued outside the write transaction (T-11-05; deferred from 11-05);
- lagoon handed after-commit callbacks a handle that carried the written model's statement (deferred from 11-05);
- beachcomber and lighthouse released their savepoint whenever the inner function reported no error, so a read failure that a Gate or channel function swallowed left the caller's Postgres transaction aborted (T-11-20, T-11-26).
Gap plan 11-08 closed verifier gap CR-01: lagoon.AfterCommit ran search sync immediately inside a plain GORM transaction, and the Cabana admin and SaveAlbum write paths used plain transactions, so Typesense received uncommitted, pre-pivot or rolled-back Album state (T-11-31, T-11-32).
Commands run from summercms.go. ../fonoteka.go tests run inside that repository. Gate stages are modes of scripts/check-phase11.sh.
| Threat | Category | Component | Severity | Disposition | Production mitigation | Test or gate stage | Observed result | Residual risk |
|---|---|---|---|---|---|---|---|---|
| T-11-01 | Spoofing | subscribe proxy | high | mitigate | lighthouse/centrifugo/handlers.go ProxyHandler compares X-Centrifugo-Secret with subtle.ConstantTimeCompare before reading the body; an empty configured proxy_secret denies everything |
TestProxy (missing, wrong and prefix secrets, empty_configured_secret_denies_everything, no secret in logs), fonoteka TestRealtimeSubscribeProxy, TestRealtimeSubscribeProxyWithoutSecretDenies; stage --named |
pass; removal check RC-01 fails TestProxy | Anyone who can read the Centrifugo config holds the secret; rotate it with the Centrifugo deployment |
| T-11-02 | Elevation of Privilege | channel parsing and authorizers | high | mitigate | lighthouse/channel.go ParseChannel (presence:presence: and over three segments give the empty namespace), ChannelID/PHPInt (PHP 8.5 (int)), byte-exact Registry.Get; fonoteka classes/ws CollectionAuthorizer (user exists, owner or editor, kind = collection) and WishlistAuthorizer (subscriber or household peer, kind = wishlist), both reading the database on every subscribe |
TestParseChannel, TestChannelIDMatchesPHP (44 php -r inputs), TestProxy (WS-005 double presence, four segments, leading colon, case), fonoteka TestWsAuthorizer (20 cases plus editor removal); stage --named |
pass; removal checks RC-02 and RC-03 fail TestParseChannel, RC-04 fails TestWsAuthorizer | Channel ids are the PHP ones (collection:<id>); authorization, not obscurity, protects them |
| T-11-03 | Information Disclosure | deny responses | medium | mitigate | Every deny answers the same HTTP 200 {"error":{"code":403,"message":"Access denied"}}; the reason goes only to the Warn log |
TestProxy (every deny case compares the exact body and status), fonoteka TestRealtimeSubscribeProxy (deny logs carry the internal reason) |
pass | None known |
| T-11-04 | Information Disclosure | connection token info claim | medium | mitigate | TokenIssuer.ForUser marshals exactly sub, exp, info{name} |
TestTokenClaims (byte-exact claim segments for all five generators), fonoteka TestRealtimeTokenRoute |
pass | The display name is visible to anyone holding the token, as in PHP |
| T-11-05 | Information Disclosure | broadcast audience and rolled-back writes | high | mitigate | lighthouse/broadcast.go installCallbacks registers the after-write callbacks After(gorm:after_*) and Before(gorm:commit_or_rollback_transaction) (fixed in 11-07: an After-only anchor ran past GORM's commit) and enqueues on the write's *sql.Tx inside a savepoint; fonoteka realtime.go albumChannels publishes only for a kind = collection collection; the payload is SerializeAlbum |
TestBroadcastTx (commit publishes once, rollback nothing, single-statement write enqueues on its own transaction), TestSuppression, TestBulkEmitsOnce, fonoteka TestAlbumBroadcastBinding, TestAlbumBroadcastSmoke, parity TestBroadcastGoldens |
pass; removal checks RC-05 fails TestBroadcastTx and RC-06 fails TestAlbumBroadcastBinding | Delivery order across jobs is not guaranteed, as with PHP's queued broadcast job |
| T-11-06 | Information Disclosure | logs (api key, tokens, secrets, payloads) | medium | mitigate | The Centrifugo client sets the key only in Authorization; errors carry the method and status; the proxy never logs secrets; broadcast failures log channels and event only; the log driver omits payloads |
TestClientRequests (errors without the key, DebugInfo without it), TestProxy (no secret in logs), TestBroadcastPublishFailure (no payload in the failure log), TestDrivers (log driver), TestHealthCommand |
pass | None known |
| T-11-07 | Information Disclosure | cross-collection search leak through a stale or mis-scoped index | high | mitigate | fonoteka models/album_search.go ToSearchableArray refuses an album without a positive collection_id; typesense.Engine.SearchIDs returns candidates only and the beachcomber README requires an SQL re-gate |
fonoteka TestAlbumSearchable (collection_id_zero_is_refused, document keys and types), TestAlbumSearchDeleteAndFailures |
pass; removal check RC-07 fails TestAlbumSearchable | Phase 12 owns the endpoint re-gate of SearchIDs results; until then no endpoint exposes them |
| T-11-08 | Elevation of Privilege | summer_jobs ids (cancel/progress IDOR) | medium | accept | Phase 11 exposes no HTTP route over summer_jobs; the Phase 13 CSV endpoints must scope ids through the owning import (findVisible). Recorded for Phase 13. | none (accepted) | accepted | Phase 13 must scope job ids through the owning import |
| T-11-09 | Tampering | scheduled-command worker | high | mitigate | conga/scheduler.go runScheduled runs a job only when its entry id is in the compiled table and its command and args match exactly |
TestScheduledEntryMismatchSkipped (mismatched command, args, unknown entry, forged river_job row), fonoteka TestFonotekaScheduleSkipsUnregisteredPrune |
pass; removal check RC-08 fails TestScheduledEntryMismatchSkipped | Whoever can write the compiled binary controls the schedule, as intended |
| T-11-10 | Denial of Service | token and subscribe flooding | medium | mitigate | fonoteka ws-api bucket (120/min per user or IP) on both routes, jwt.auth before the throttle on the token route; ProxyHandler caps the body at 64 KiB |
TestProxy/oversized_body, fonoteka TestRealtimeTokenRoute (route order), TestRealtimeSubscribeProxy (raw route with throttle:ws-api), TestAllRouteGroupsBoot |
pass | A distributed flood stays within per-IP limits |
| T-11-11 | Information Disclosure | VAPID private key | medium | mitigate | flare prints a private key only when newly generated; --show-current truncates; Config and VAPIDKeys redact it in String, GoString and LogValue |
TestGenerateVAPIDKeysCommand, TestTestPushCommand, TestVAPIDKeys (formatting), TestVAPIDSendRoundTrip |
pass | The generated key is shown once on the operator's terminal |
| T-11-12 | Tampering | Dispatch/Enqueue (orphan jobs for rolled-back writes) | high | mitigate | conga/conga.go Dispatch writes the row and runs InsertTx on the caller's *sql.Tx (its own transaction when there is none); Enqueue joins a caller's transaction |
TestDispatchTransactional (commit, own transaction, rollback leaves neither row nor job) |
pass; removal check RC-09 fails TestDispatchTransactional | None known |
| T-11-13 | Denial of Service | River listener pool | medium | mitigate | conga/worker.go listenerPool: a dedicated pgx pool with MaxConns 1, MinConns 0 per worker, closed by Worker.Stop; README documents session pooling for PgBouncer |
TestListenPickupLatency (LISTEN pickup under 1 s, poll-only control), TestWorkerStopFallsBackToHardStop; stage --go runs the LISTEN test three times |
pass (pool size checked by code review) | A PgBouncer in transaction mode breaks LISTEN; documented |
| T-11-14 | Tampering | queue:clear | medium | mitigate | conga/commands.go clearQueue deletes only available, scheduled and retryable jobs of one queue, in 10000-job batches |
TestQueueClear (running job untouched), TestClearQueueStatesAndBatches (10006 jobs over two batches, finished and other-queue jobs kept) |
pass | None known |
| T-11-15 | Information Disclosure | job failure metadata and logs | medium | mitigate | runAttempt writes only the error text under metadata error; job args are never logged; River logs through the app logger |
TestOutcomeFailFinalAttemptOnly (metadata is the dispatch metadata plus the error text), TestOutcomePanicBecomesError |
pass | A job that puts secrets in its own error text would store them |
| T-11-16 | Denial of Service | panicking plugin jobs | medium | mitigate | Manager.call recovers panics into errors; the final-attempt ERROR rule applies |
TestOutcomePanicBecomesError |
pass | None known |
| T-11-17 | Denial of Service | periodic enqueue on leader failover | low | mitigate | Scheduled runs are unique by args within the cadence period; Daily/Every are wall-clock schedules |
TestScheduleUniqueByPeriod, TestScheduleNext (DST in Europe/Warsaw), TestScheduleOrdering (insert options) |
pass | Multi-process leader election is River's guarantee (backstop, not tested) |
| T-11-18 | Repudiation | scheduled runs | low | mitigate | Each run, skip and failure is logged with the command name (and duration); unregistered commands are Warn | TestScheduleRunsCommand, TestScheduleMissingCatalog, TestScheduleLogWriter, fonoteka TestFonotekaScheduleSkipsUnregisteredPrune |
pass | None known |
| T-11-19 | Elevation of Privilege | Mount of a user route without a guard | high | mitigate | lighthouse/route.go validateRoute refuses a UserAuth route when Surfaces.UserAuth is empty, before any route is mounted |
TestMountSurfaces (user_route_without_guard, nothing mounted on refusal) |
pass; removal check RC-10 fails TestMountSurfaces | None known |
| T-11-20 | Denial of Service | broadcast failure aborting the write transaction | medium | mitigate | lighthouse/broadcast.go inSavepoint rolls back to its savepoint on an error and, since 11-07, also when the release fails because a swallowed read aborted the transaction |
TestBroadcastEdges (channel, payload and query failures keep the write), TestBroadcastSwallowedReadFailure (failed RED before the 11-07 fix) |
pass | None known |
| T-11-21 | Repudiation | phase gate fail-open | medium | mitigate | scripts/check-phase11.sh: each detector returns on its first violation; skipped, missing or zero tests and "no tests to run" fail the gate; only the two Phase 12 goldens may skip, with their pending text |
check-phase11.sh --self-test (18 detector cases, 10 hygiene plants each refused for its own rule, a clean look-alike, the removal harness on a scratch module) |
pass | None known |
| T-11-22 | Spoofing / SSRF | push endpoint requests | high | mitigate | flare/flare.go checkEndpoint allows only https URLs without user info whose host passes HostAllowed(push.allowed_hosts), before dialing; redirects are never followed |
TestSendAllowlist (host table and refused endpoints), TestSendRefusesDisallowedEndpoint (redirects, tricks, no request made) |
pass; removal check RC-11 fails TestSendAllowlist and TestSendRefusesDisallowedEndpoint | A compromised allowed push service is out of scope |
| T-11-23 | Information Disclosure | websockets:health output | low | mitigate | websockets:health prints only "API Key Set: Yes/No" |
TestHealthCommand |
pass | None known |
| T-11-24 | Information Disclosure | persisted overrides file | medium | mitigate | --update goes through compass Persist (atomic write, mode 0600, merge over earlier overrides) |
TestGenerateVAPIDKeysCommand (mode 0600, earlier override kept), compass TestPersistKeepsEarlierOverrides |
pass | None known |
| T-11-25 | Information Disclosure | Typesense API key in logs or errors | medium | mitigate | typesense.Engine sets the key only in X-TYPESENSE-API-KEY; StatusError carries method, path and status, never the body; transport errors drop the URL; sync warnings name index, key and operation |
TestEngineWire (errors without body, key or URL), TestSyncFailuresNonFatal (no key in the warning), TestSyncEngineRegistration |
pass | None known |
| T-11-26 | Denial of Service | search failure blocking or failing writes | medium | mitigate | beachcomber/sync.go: sync runs after commit, bounded by the engine timeout; every error and panic is one Warn; reads run in a savepoint that, since 11-07, is also rolled back when a swallowed read failed |
TestSyncFailuresNonFatal (engine error and panic, document error and panic, failed gate read in a caller's transaction: failed RED before the 11-07 fix), fonoteka TestAlbumSearchDeleteAndFailures |
pass | A slow Typesense adds up to the connection timeout to a write's latency |
| T-11-27 | Information Disclosure | data sent while the kill-switch is off | medium | mitigate | Gates before any request: engine configured (API key), database published, application Gate (fonoteka settingsGate, errors mean off) |
TestSyncGates (null engine, empty key, no database, gate off), fonoteka TestAlbumSearchable/settings_gate, TestAlbumSearchSmoke (zero requests) |
pass | None known |
| T-11-28 | Information Disclosure | goldens and route fixtures | high | mitigate | tide/centrifugo.go records only whether Authorization matched; fonoteka parity/check_corpus.go fails on the test-only Centrifugo values and on an X-Centrifugo-Secret that is not a {{var}} or the documented deny literal |
TestCentrifugoRecorder (value never stored), TestCentrifugoRecorderRecordsPublishAndBroadcast, parity TestUniqueAndSecretScan |
pass; removal check RC-12 fails TestUniqueAndSecretScan | The corpus scan knows only the test-only values; live secrets never reach the isolated PHP |
| T-11-29 | Spoofing | recorder listener and parity:broadcasts target | medium | mitigate | CentrifugoRecorder.ListenAndServe and RecordBroadcasts require loopback addresses (tide's T-02-01 rule) |
TestCentrifugoRecorderRefusesNonLoopback, TestCentrifugoRecorder (loopback serve and shutdown), TestRecordBroadcastsStep |
pass | None known |
| T-11-30 | Repudiation | golden normalisation hiding regressions | medium | mitigate | NormalizePublications masks only +00:00 timestamps, an exact {user_id, name} actor and captured ids under id keys; DiffPublications compares count, path, authorization and body; pending goldens skip, never pass |
TestNormalizePublications, TestDiffPublications, parity TestBroadcastGoldens (created/updated must skip with "pending: Phase 12", enforced by --named and --postgres) |
pass | Phase 12 must turn created/updated into assertions |
| T-11-31 | Information Disclosure | lagoon.AfterCommit / beachcomber sync | high | mitigate | modules/lagoon/transaction.go AfterCommit buffers inside lagoon.Transaction and GORM's implicit single-statement transaction, and inside a foreign plain GORM *sql.Tx logs a warning and skips the callback; nested calls must use the exact parent transaction connection, so root and unrelated transaction handles are rejected; Cabana create/update/delete/bulk-delete and relation Link/Unlink and fonoteka SaveAlbum run in lagoon.Transaction |
TestTransactionAfterCommit (plain_gorm_transaction_is_refused, nested_rejects_unrelated_transaction_handle), TestTransactionEdges (nested root handle), beachcomber TestSyncAfterCommit (plain_gorm_transaction_rollback_sends_nothing), fonoteka TestAlbumSearchDeleteAndFailures (foreign transaction rollback never reaches Typesense); stages --go, --postgres, --named |
pass; removal checks RC-14 and RC-15 fail TestTransactionAfterCommit | Code that writes inside its own plain GORM transaction gets no search sync (warned, not silent) until it adopts lagoon.Transaction or calls Sync after commit |
| T-11-32 | Tampering | Cabana and SaveAlbum ordered artist pivots | medium | mitigate | The Album row and its ordered artist pivots commit in one lagoon.Transaction (Cabana CRUDService.save, RelationService.Link/Unlink, fonoteka SaveAlbum), and the buffered sync reloads the committed row and pivots |
fonoteka TestAlbumsAdminSearchUsesCommittedArtists (assembled admin router; indexed artist_ids equal the committed pivot order), TestSaveAlbumDefersAfterCommitUntilArtistsSync (callback sees the committed order; none on rollback) |
pass; with Cabana save reverted to a plain transaction the admin test fails (no import), and with SaveAlbum reverted the SaveAlbum test fails (no callback), both checked by hand in the 11-08 close-out |
Concurrent commits for the same Album are not serialized; the Phase 12 SQL re-gate of SearchIDs results remains the boundary |
| T-11-SC | Tampering | Go module installs (River v0.47.0 and sub-modules) | high | mitigate | River is pinned at v0.47.0 with go.sum checksums; no Centrifugo, Typesense or Web Push client and no cron library was added in any plan | check-phase11.sh --hygiene (client libraries in go list -m all of both repositories, direct cron requirements, River version), --self-test (plants) |
pass; removal check RC-13 (client rule disabled) fails --self-test |
robfig/cron/v3 is in the module graph only as River's test dependency, not a requirement |
Removal checks
Each row is one anchor-exact mutation from scripts/check-phase11.sh --removal. The anchor occurs exactly once in the file; the test must fail on an assertion (not a build failure); the file is restored and compared with cmp against the copy saved before the mutation. All fifteen were run on 2026-09-30 and all failed as required; git status was clean in both repositories afterwards. RC-14 and RC-15 were added and run in the 11-08 close-out/review and failed as required.
| Check | Threat | File | Anchor removed or changed | Replacement | Test run | Observed |
|---|---|---|---|---|---|---|
| RC-01 | T-11-01 | modules/lighthouse/centrifugo/handlers.go |
if cfg.ProxySecret == "" || subtle.ConstantTimeCompare(...) != 1 { |
if subtle.ConstantTimeCompare(...) == 2 { |
go test ./modules/lighthouse/centrifugo -run '^TestProxy$' |
fails: TestProxy/missing_secret, wrong_secret, secret_prefix, empty_configured_secret_denies_everything; restored, cmp ok |
| RC-02 | T-11-02 | modules/lighthouse/channel.go |
if strings.HasPrefix(channel, presencePrefix+presencePrefix) { |
if false { |
go test ./modules/lighthouse -run '^TestParseChannel$' |
fails: TestParseChannel (presence:presence: cases); restored, cmp ok |
| RC-03 | T-11-02 | modules/lighthouse/channel.go |
if len(parts) > 3 { |
if false { |
go test ./modules/lighthouse -run '^TestParseChannel$' |
fails: TestParseChannel (four-segment cases); restored, cmp ok |
| RC-04 | T-11-02 | ../fonoteka.go/plugins/golem15/fonoteka/classes/ws/collection_authorizer.go |
Where("golem15_fonoteka_collections.kind = ?", "collection"). |
removed | go test ./plugins/golem15/fonoteka -run '^TestWsAuthorizer$' |
fails: TestWsAuthorizer/wishlist_id_under_collection_namespace; restored, cmp ok |
| RC-05 | T-11-05 | modules/lighthouse/broadcast.go |
cb.Create().After("gorm:after_create").Before(commitCallback).Register( |
cb.Create().After("gorm:after_create").Register( |
go test ./modules/lighthouse -run '^TestBroadcastTx$' |
fails: TestBroadcastTx/single_statement_write_enqueues_in_its_own_transaction; restored, cmp ok |
| RC-06 | T-11-05 | ../fonoteka.go/plugins/golem15/fonoteka/realtime.go |
if c.Kind != "collection" { |
if false { |
go test ./plugins/golem15/fonoteka -run '^TestAlbumBroadcastBinding$' |
fails: TestAlbumBroadcastBinding/channels/wishlist_album; restored, cmp ok |
| RC-07 | T-11-07 | ../fonoteka.go/plugins/golem15/fonoteka/models/album_search.go |
if a == nil || a.CollectionID == 0 { |
if a == nil { |
go test ./plugins/golem15/fonoteka -run '^TestAlbumSearchable$' |
fails: TestAlbumSearchable/collection_id_zero_is_refused; restored, cmp ok |
| RC-08 | T-11-09 | modules/conga/scheduler.go |
if !ok || entry.Command != a.Command || !slices.Equal(entry.Args, a.Args) { |
if !ok { |
go test ./modules/conga -run '^TestScheduledEntryMismatchSkipped$' |
fails: TestScheduledEntryMismatchSkipped; restored, cmp ok |
| RC-09 | T-11-12 | modules/conga/conga.go |
res, err := client.InsertTx(ctx, sqlTx, args, opts) |
_ = sqlTx then res, err := client.Insert(ctx, args, opts) |
go test ./modules/conga -run '^TestDispatchTransactional$' |
fails: TestDispatchTransactional/rollback (River job survives the rollback); restored, cmp ok |
| RC-10 | T-11-19 | modules/lighthouse/route.go |
if len(s.UserAuth) == 0 { |
if false { |
go test ./modules/lighthouse -run '^TestMountSurfaces$' |
fails: TestMountSurfaces/user_route_without_guard; restored, cmp ok |
| RC-11 | T-11-22 | modules/flare/flare.go |
if !HostAllowed(host, p.cfg.AllowedHosts) { |
if false { |
go test ./modules/flare -run '^(TestSendAllowlist|TestSendRefusesDisallowedEndpoint)$' |
fails: TestSendAllowlist, TestSendRefusesDisallowedEndpoint; restored, cmp ok |
| RC-12 | T-11-28 | ../fonoteka.go/parity/check_corpus.go |
if strings.Contains(text, v) { (centrifugo test value scan) |
if false && strings.Contains(text, v) { |
go test ./parity -run '^TestUniqueAndSecretScan$' |
fails: TestUniqueAndSecretScan; restored, cmp ok |
| RC-13 | T-11-SC | scripts/check-phase11.sh (mutated copy) |
hits="$(grep -iE "$CLIENT_RE" "$modlist" | head -n1 || true)" |
hits="" |
bash <copy> --self-test |
fails: "refuse: self-test hygiene_11 accepted a planted client-gocent"; original untouched, cmp ok |
| RC-14 | T-11-31 | modules/lagoon/transaction.go |
if transactionalHandle(db) { (foreign-transaction refusal in AfterCommit) |
if false { |
go test ./modules/lagoon -run '^TestTransactionAfterCommit$' |
fails: TestTransactionAfterCommit/plain_gorm_transaction_is_refused, callback_handle_has_a_clean_statement; restored, cmp ok |
| RC-15 | T-11-31 | modules/lagoon/transaction.go |
if !parent.owns(gdb) { (exact parent transaction identity) |
if false { |
go test ./modules/lagoon -run '^TestTransactionAfterCommit$' |
fails: TestTransactionAfterCommit/nested_rejects_unrelated_transaction_handle; restored, cmp ok |
Fixes made during the review
| Defect | Threat | Fix | Failing-when-broken test | Commit |
|---|---|---|---|---|
| A single-statement write enqueued its broadcast job after GORM's own commit, outside the write transaction | T-11-05 | lighthouse after-write callbacks also declare Before("gorm:commit_or_rollback_transaction") |
TestBroadcastTx/single_statement_write_enqueues_in_its_own_transaction (RED: channels ran on the pool, not the write's *sql.Tx) |
summercms.go 6f50b6c |
After-commit callbacks received a handle carrying the written model's statement; a WithContext query read the written model's table |
T-11-26 | lagoon passes a clean handle (Session{NewDB, Context}, Clauses(), Session{NewDB}) on all three after-commit paths |
TestTransactionAfterCommit/callback_handle_has_a_clean_statement (RED: SELECT ... "lagoon_ac_items"."label" and an aborted plain transaction) |
summercms.go c544319 |
| A read failure swallowed inside the sync or broadcast savepoint left the caller's transaction aborted (25P02) | T-11-20, T-11-26 | beachcomber and lighthouse roll back to the savepoint when its release fails |
TestSyncFailuresNonFatal/failed_gate_read_keeps_the_callers_transaction, TestBroadcastSwallowedReadFailure (both RED with 25P02) |
summercms.go 6df43d4 |
| Search sync ran inside plain GORM transactions, before the Album's artist pivots were written, and survived a rollback (verifier gap CR-01) | T-11-31, T-11-32 | Cabana writes and SaveAlbum use lagoon.Transaction; lagoon.AfterCommit refuses a foreign *sql.Tx; a nested lagoon.Transaction over a root handle errors |
TestAlbumsAdminSearchUsesCommittedArtists, TestSaveAlbumDefersAfterCommitUntilArtistsSync, TestTransactionAfterCommit/plain_gorm_transaction_is_refused, TestSyncAfterCommit/plain_gorm_transaction_rollback_sends_nothing |
summercms.go f7b6b0c, a33b1ad, 2766f34; fonoteka.go 1c88199 |