docs(11-07): security review with removal checks and the validated test map

- 11-SECURITY-REVIEW.md: T-11-01..T-11-30 and T-11-SC with each plan's
  severity and disposition, mitigation, test and result; RC-01..RC-13
  removal checks for every high mitigated threat; the three defects fixed
  in 11-07
- 11-VALIDATION.md: task ids, plans and waves per row, commands run,
  status validated, nyquist_compliant and wave_0_complete true
This commit is contained in:
Jakub Zych
2026-09-30 14:49:36 +02:00
parent 7743487b76
commit a34c6ece18
2 changed files with 116 additions and 31 deletions

View File

@@ -0,0 +1,82 @@
---
phase: "11"
reviewed: "2026-09-30"
reviewer: "gsd-executor, plan 11-07 (code-and-test review of plans 11-01 to 11-07)"
threats_open: 0
gate: "scripts/check-phase11.sh --all"
removal_harness: "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-07 (T-11-01 to T-11-30 and T-11-SC). 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).
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-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 thirteen were run on 2026-09-30 and all failed as required; `git status` was clean in both repositories afterwards.
| 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 |
## 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` |

View File

@@ -3,10 +3,12 @@ phase: "11"
slug: "jobs-realtime-and-search-infrastructure"
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
status: draft
nyquist_compliant: false
wave_0_complete: false
status: validated
nyquist_compliant: true
wave_0_complete: true
created: "2026-09-29"
validated: "2026-09-30"
gate: "scripts/check-phase11.sh --all"
---
# Phase 11 — Validation Strategy
@@ -38,36 +40,37 @@ created: "2026-09-29"
## Per-Task Verification Map
Task IDs are filled in once the plans exist; rows are keyed by behaviour until then.
Task IDs are `<plan>-T<task>`. Every row's command was run on 2026-09-30 and passed; `scripts/check-phase11.sh --named` runs all of them by exact name and refuses a skip, a missing pass or "no tests to run". Framework commands run from `summercms.go`, fonoteka.go commands from `../fonoteka.go`.
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| TBD | TBD | TBD | JOBS-01 | — | N/A | integration | `go test ./modules/conga -run TestListenPickupLatency -count=1` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | JOBS-01 | — | N/A | integration | `go test ./modules/conga -run TestDispatchTransactional` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | JOBS-01 | — | N/A | integration | `go test ./modules/conga -run 'TestOutcome|TestCancel'` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | JOBS-01 | — | N/A | integration | `go test ./modules/conga -run TestQueueClear` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | CLI-06 | — | N/A | integration | `go test ./modules/conga -run TestQueueWork` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | CLI-04 | — | N/A | unit + integration | `go test ./modules/conga -run TestSchedule` ; `go test ./modules/bonfire -run TestCall` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-01 | T-11-xx | 503 when the secret is empty; 401 without a valid JWT | unit | `go test ./modules/lighthouse/centrifugo -run TestToken` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-01 | — | API key never logged; no request when the key is empty | unit | `go test ./modules/lighthouse/centrifugo -run TestClient` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-02 | T-11-01 | Missing/wrong proxy secret denies; deny reason logged, not returned | unit | `go test ./modules/lighthouse/centrifugo -run TestProxy` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-02 | T-11-xx | Non-member denied on every subscribe | integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run TestWsAuthorizer` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-03 | — | N/A | integration | `go test ./modules/lighthouse -run 'TestBroadcastTx|TestSuppression|TestBulkEmitsOnce'` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-03 | — | N/A | integration | `go test ./modules/lagoon -run TestOnDatabaseAfterActivate` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | RT-03 | — | N/A | parity | `cd ../fonoteka.go && go test ./parity -run TestBroadcastGoldens` | ❌ W0 | ⬜ pending |
| TBD | TBD | TBD | SRCH-01 | — | Documents always carry a positive `collection_id` | integration | `go test ./modules/beachcomber/... -run TestSync` ; `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run TestAlbumSearchable` | ❌ W0 | ⬜ pending |
| 11-01-T1, 11-07-T1 | 11-01, 11-07 | 1, 5 | JOBS-01 | T-11-13 | LISTEN pickup under 1 s with a 30 s poll; poll-only control misses | integration | `go test ./modules/conga -run '^TestListenPickupLatency$' -count=3` | ✅ `modules/conga/listen_test.go` | ✅ green |
| 11-01-T1, 11-07-T1 | 11-01, 11-07 | 1, 5 | JOBS-01 | T-11-12 | Row and River job share the caller's transaction; rollback leaves neither | integration | `go test ./modules/conga -run '^TestDispatchTransactional$' -count=1` | ✅ `modules/conga/listen_test.go` | ✅ green |
| 11-01-T2, 11-07-T1 | 11-01, 11-07 | 1, 5 | JOBS-01 | T-11-15, T-11-16 | Only the final attempt records ERROR; panics recovered; skip is COMPLETE with metadata; cancel leaves STOPPED | integration | `go test ./modules/conga -run '^(TestOutcome.*\|TestCancel.*\|TestStopJobFromWorker\|TestManagerPHPSemantics)$' -count=1` | ✅ `modules/conga/worker_test.go`, `manager_test.go` | ✅ green |
| 11-01-T2, 11-07-T1 | 11-01, 11-07 | 1, 5 | JOBS-01 | T-11-14 | Only available, scheduled and retryable jobs of one queue are cleared | integration | `go test ./modules/conga -run '^(TestQueueClear\|TestClearQueueStatesAndBatches)$' -count=1` | ✅ `modules/conga/commands_test.go` | ✅ green |
| 11-01-T2, 11-07-T1 | 11-01, 11-07 | 1, 5 | CLI-06 | — | queue:work filters queues and names the known ones; serve honours queue.work_in_serve | integration | `go test ./modules/conga -run '^TestQueueWork$' -count=1` | ✅ `modules/conga/commands_test.go` | ✅ green |
| 11-02-T1, 11-02-T2, 11-07-T1 | 11-02, 11-07 | 2, 5 | CLI-04 | T-11-09, T-11-17, T-11-18 | Forged or mismatched scheduled jobs are skipped; runs unique per period; invalid cadences refused | unit + integration | `go test ./modules/conga -run '^TestSchedule.*$' -count=1` ; `go test ./modules/bonfire -run '^(TestCall\|TestCallEdges)$' -count=1` ; `go test ./plugins/golem15/fonoteka -run '^TestFonotekaScheduleSkipsUnregisteredPrune$' -count=1` | ✅ `modules/conga/schedule_test.go`, `modules/bonfire/call_test.go`, fonoteka `schedule_test.go` | ✅ green |
| 11-03-T1, 11-07-T2 | 11-03, 11-07 | 2, 5 | RT-01 | T-11-04 | 503 when the secret is empty (after the user check); 401 without a principal or user; exact claim set | unit | `go test ./modules/lighthouse/centrifugo -run '^TestToken.*$' -count=1 -race` | ✅ `modules/lighthouse/centrifugo/token_test.go` | ✅ green |
| 11-03-T1, 11-04-T2, 11-07-T2 | 11-03, 11-04, 11-07 | 2, 4, 5 | RT-01 | T-11-06, T-11-22 | API key never logged or in errors; no request without a key; push only to allow-listed https hosts | unit | `go test ./modules/lighthouse/centrifugo -run '^(TestClient.*\|TestHealthCommand)$' -count=1` ; `go test ./modules/flare -count=1 -race` | ✅ `client_test.go`, `commands_test.go`, `modules/flare/*_test.go` | ✅ green |
| 11-03-T2, 11-07-T2 | 11-03, 11-07 | 2, 5 | RT-02 | T-11-01, T-11-03 | Missing, wrong or unconfigured proxy secret denies; deny reason logged, not returned | unit | `go test ./modules/lighthouse/centrifugo -run '^TestProxy.*$' -count=1 -race` | ✅ `modules/lighthouse/centrifugo/proxy_test.go` | ✅ green |
| 11-03-T2, 11-07-T2 | 11-03, 11-07 | 2, 5 | RT-02 | T-11-02 | Non-member denied on every subscribe; wishlist id under the collection namespace denied | integration | `go test ./plugins/golem15/fonoteka -run '^(TestWsAuthorizer\|TestRealtimeSubscribeProxy)$' -count=1 -race` | ✅ fonoteka `ws_authorizer_test.go` | ✅ green |
| 11-03-T3, 11-07-T2 | 11-03, 11-07 | 2, 5 | RT-03 | T-11-05, T-11-20 | Broadcast enqueued in the write transaction (single statements included); suppression per type; one bulk event | integration | `go test ./modules/lighthouse -run '^(TestBroadcastTx\|TestSuppression\|TestBulkEmitsOnce\|TestBroadcastEdges\|TestBroadcastSwallowedReadFailure)$' -count=1 -race` ; `go test ./plugins/golem15/fonoteka -run '^TestAlbumBroadcastBinding$' -count=1` | ✅ `modules/lighthouse/broadcast_test.go`, fonoteka `album_realtime_test.go` | ✅ green |
| 11-01-T3, 11-07-T1 | 11-01, 11-07 | 1, 5 | RT-03 | — | GORM hooks registered through OnDatabase in the production boot order; after-commit handle has a clean statement | integration | `go test ./modules/lagoon -run '^(TestOnDatabaseAfterActivate\|TestTransactionAfterCommit\|TestQueueMigrationsUpDown)$' -count=1` | ✅ `modules/lagoon/*_test.go` | ✅ green |
| 11-06-T1, 11-06-T2 | 11-06 | 3 | RT-03 | T-11-28, T-11-30 | deleted and bulk publications equal the PHP goldens; created and updated skip until Phase 12 asserts them | parity | `go test ./parity -run '^TestBroadcastGoldens$' -count=1 -v` | ✅ fonoteka `parity/broadcast_goldens_test.go` | ✅ green |
| 11-05-T1, 11-05-T2, 11-07-T2 | 11-05, 11-07 | 3, 5 | SRCH-01 | T-11-07, T-11-25, T-11-26, T-11-27 | Documents always carry a positive `collection_id`; nothing sent while the kill-switch is off or the key is empty; failures never touch the write | integration | `go test ./modules/beachcomber/... -run '^TestSync.*$' -count=1 -v` ; `go test ./modules/beachcomber/typesense -run '^TestEngineWire$' -count=1` ; `go test ./plugins/golem15/fonoteka -run '^(TestAlbumSearchable\|TestAlbumSearchSmoke)$' -count=1` | ✅ `modules/beachcomber/sync_test.go`, `typesense/engine_test.go`, fonoteka `album_search_test.go` | ✅ green |
| 11-07-T3 | 11-07 | 5 | all seven | T-11-21, T-11-SC | The phase gate fails closed and refuses excluded client libraries and an unpinned River | gate | `scripts/check-phase11.sh --self-test` ; `scripts/check-phase11.sh --all` | ✅ `scripts/check-phase11.sh` | ✅ green |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Status: ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `modules/conga/postgres_test.go` — TestMain with testcontainers, exposing both `*sql.DB` and the DSN (the listener pool needs the DSN)
- [ ] A fake Centrifugo `httptest` helper (records method, path, headers, body), shared by lighthouse tests and tide
- [ ] A fake Typesense `httptest` helper
- [ ] fonoteka.go `parity/schema_diff_test.go` + `parity/migrate_test.go` allow-list updates for the River and `summer_jobs` tables
- [ ] `go get github.com/riverqueue/river@v0.47.0 github.com/riverqueue/river/riverdriver/riverdatabasesql@v0.47.0 github.com/riverqueue/river/rivertype@v0.47.0`
- [x] `modules/conga/postgres_test.go` — TestMain with testcontainers, exposing both `*sql.DB` and the DSN (11-01); lighthouse and beachcomber got the same harness in 11-07
- [x] A fake Centrifugo `httptest` helper (records method, path, headers, body): `tide.CentrifugoRecorder` (11-06) and the test-local fakes in `modules/lighthouse/centrifugo/client_test.go` and fonoteka `realtime_smoke_test.go`
- [x] A fake Typesense `httptest` helper: `modules/beachcomber/typesense/engine_test.go` and fonoteka `search_smoke_test.go`
- [x] fonoteka.go `parity/schema_diff_test.go` + `parity/migrate_test.go` allow-list updates for the River and `summer_jobs` tables (11-01)
- [x] `go get github.com/riverqueue/river@v0.47.0 github.com/riverqueue/river/riverdriver/riverdatabasesql@v0.47.0 github.com/riverqueue/river/rivertype@v0.47.0` (11-01; the hygiene stage pins v0.47.0)
---
@@ -82,11 +85,11 @@ Task IDs are filled in once the plans exist; rows are keyed by behaviour until t
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 60s (the `-short` package runs; the testcontainers suites take minutes and run per wave and in the gate)
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
**Approval:** validated 2026-09-30 by plan 11-07 (`scripts/check-phase11.sh --all` prints "phase11 all passed"). The two manual-only rows above are collected at /gsd-verify-work.