diff --git a/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md new file mode 100644 index 0000000..46e7dd2 --- /dev/null +++ b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md @@ -0,0 +1,132 @@ +--- +phase: 03 +slug: first-vertical-slice-genres-end-to-end +status: verified +threats_open: 0 +asvs_level: 1 +created: 2026-09-17 +verified: 2026-09-17 +--- + +# Phase 3 — Security Review + +> Token verification, cross-plugin guard lookup, migration isolation, query tenant boundaries, and secret handling for `GET /_fonoteka/api/v1/genres`. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| Config and Go module resolution → process | DSN, JWT secret, and dependency metadata enter boot | `SUMMER_DATABASE__DSN`, `SUMMER_GOLEM15__USER__JWT__SECRET`, `go.mod` | +| Bearer token → user lookup → handler | Untrusted JWT claims become authenticated context | Authorization header, `users.id`, `MustChangePassword` | +| Plugin declarations → ServeMux | Named middleware and group routes compile to stdlib mux | `jwt.auth`, `inv.must-change-password`, path params | +| Authenticated user and context → SQL | Active collection and album counts must stay tenant-scoped | `owner_id`, editor rows, `collection_id`, `genre_id` | +| CLI plugin argument → migration history | Rollback must not select another plugin's gormigrate table | `--plugin`, history table name | +| Recorded fixture → test request | JWT and colliding IDs come from trusted seed state | `jwt:alice`, `id:genre` / `id:token` / `id:wishlist-album` | + +--- + +## Threat Register + +| Threat ID | Category | Severity | Component | Disposition | Mitigation | Status | +|-----------|----------|----------|-----------|-------------|------------|--------| +| T-03-01 | Tampering | high | migrations / locale / rollback | mitigate | Explicit DDL, per-plugin history tables, ICU `pl-PL` check before migrate, isolated `RollbackLast` | closed | +| T-03-02 | Elevation of privilege | high | named middleware | mitigate | Missing names fail boot with plugin+name; unauthenticated requests never reach the handler; seven-stage order | closed | +| T-03-03 | Spoofing | high | JWT guard | mitigate | HS256 pinned, `exp`+`sub` required, persisted user lookup, empty secret fails boot, 401 bodies omit secrets | closed | +| T-03-04 | Information disclosure | high | aggregate query | mitigate | Grouped owner OR editor, AND active collection; foreign albums stay at count 0 | closed | +| T-03-05 | Tampering | medium | `lagoon.OrderBy` / `non_empty` | mitigate | Allow-listed identifiers/direction, no COLLATE, `non_empty` in `0\|1` else 422 | closed | +| T-03-06 | Tampering | high | parity acceptance | mitigate | Unmodified fixture, passing increments only after replay, mutated body fails, 153 pending never count as pass | closed | +| T-03-07 | Information disclosure | medium | typed route IDs | mitigate | Malformed and unknown IDs both 404; constraints compiled at registration | closed | +| T-03-SC | Tampering | medium | Go module resolution | mitigate | Official module paths already in `go.mod`; testcontainers added only as the named STACK test dependency | closed | + +*Status: open · closed* +*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)* + +--- + +## Findings by Threat + +### T-03-01 — schema drift / locale / rollback isolation + +- **Source:** `lagoon/connection.go` (`Open`/`Use`/`CheckLocale`), `lagoon/migrations.go` (`HistoryTableName`, `Migrate`, `RollbackLast`), Fonoteka plugin `updates` (explicit `CREATE TABLE`, no `AutoMigrate`). +- **Test evidence:** `TestWrongICULocaleFailsOpen`, `TestTwoPluginMigrationSetsIsolated`, `TestNoAutoMigrate`, app `TestMigrateSeedsCanonicalGenres`, `TestRollbackLastIsolatesFonoteka`, `TestPluginMigrationsDoNotUseAutoMigrate`, `TestGenreQueryFailsOnWrongLocale`. +- **Finding:** ICU `pl-PL` is required before GORM is used. Two plugins keep separate `summer_migrations_*` tables; rolling back `demo.beta` (framework) or `golem15.fonoteka` (app) leaves the other plugin's history and rows intact. Production code and plugin sources contain no `AutoMigrate`. +- **Disposition:** closed / mitigate. + +### T-03-02 — missing or bypassed named guard + +- **Source:** `surf/router.go` (`wrap` fails on unknown names; `compile` wraps recover → CORS around the mux; named auth runs after locale and before org/rate/handler), `plugins/golem15/fonoteka/plugin.go` (`Use("jwt.auth", "inv.must-change-password")`). +- **Test evidence:** `TestAssembleMissingMiddlewareFailsBoot`, `TestUnauthenticatedNamedGuardDoesNotReachHandler`, `TestPipelineOrderRecoverCORSLocaleAuthPasswordOrgRateHandler`, `TestCORSPreflightBypassesNamedAuth`, app `TestGenreSecurityBoundaries/unauthenticated` and `/cors-preflight`. +- **Finding:** A missing `jwt.auth` name fails Assemble with plugin ID and name. OPTIONS preflight returns 204 without running named auth or the genre handler (`Cache-Control` stays unset). GET without a token returns 401 `Token not provided` and does not set the handler's `Cache-Control: no-cache, private`. +- **Disposition:** closed / mitigate. + +### T-03-03 — JWT forgery and secret handling + +- **Source:** `bouncer/jwt.go` (`WithValidMethods([]string{"HS256"})`, `WithExpirationRequired()`, nonempty `sub`, `UserProvider.FindByID`), `plugins/golem15/user/user.go` (`jwtSecret` fails closed). +- **Test evidence:** `TestVerifyRejectsBadTokens`, `TestVerifyRejectsAlgNoneEmptySecretAndAbsentExp`, `TestVerifyAndMiddlewareOmitTokenAndSecret`, `TestMiddlewareStatusBodies`, app `TestGenreSecurityBoundaries` (malformed / expired / wrong HMAC / alg:none / bad signature / absent exp / absent sub / unknown user), `TestEmptyJWTSecretFailsBoot`, `TestGenreListBehindJWT`. +- **Finding:** `alg:none`, HS384, bad signatures, missing/expired `exp`, missing `sub`, and unknown subjects never reach the handler. Empty `golem15.user.jwt.secret` fails `Boot`. 401 JSON bodies are PHP-shaped and do not echo the token or secret. +- **Disposition:** closed / mitigate. + +### T-03-04 — cross-tenant album counts + +- **Source:** `plugins/golem15/fonoteka/active_collection.go` (`AccessibleByMembership` grouped OR, then AND `collection_id`), `genre_handler.go` left-join count. +- **Test evidence:** `TestGenreCountsScopedToActiveCollection`, `TestGenreSecurityBoundaries/alice-counts-ignore-foreign`, `/bob-counts-ignore-alice`. +- **Finding:** Alice's rock albums do not appear in Bob's jazz counts and vice versa. Invalid stored context falls back to the lowest-ID accessible real collection. Wishlist and foreign collections are not counted. +- **Disposition:** closed / mitigate. + +### T-03-05 — ORDER BY / `non_empty` injection + +- **Source:** `lagoon/order.go` allow-list, `genre_handler.go` `parseNonEmpty`. +- **Test evidence:** `TestOrderClauseAllowList`, integration `non_empty=0|1|invalid` and duplicate-key last-wins, 422 envelope. +- **Finding:** Unknown columns and directions are rejected. No COLLATE is emitted. Invalid `non_empty` returns the PHP 422 envelope. +- **Disposition:** closed / mitigate. + +### T-03-06 — false-green parity + +- **Source:** `parity/parity_test.go` (`runCorpusRoute` increments passing only after ported replay), `parity/manifest.yaml` (one `ported` route with `seed_hook: genres`), unmodified `fixtures/routes/get_genres_jwt.yaml`. +- **Test evidence:** `TestParityCorpus` coverage subtest (154 recorded, 1 passing, 153 pending, 0 failing, 0 unrecorded), `TestParityContract/honest-counts`, `ported-mismatch`, `ported-mutated-response`. +- **Finding:** Pending routes are not sent to the Go handler. A mutated `album_count` fails `ReplayFlow`. The recorded PHP fixture still contains `Bearer {{jwt:alice}}` and `"album_count":0`. +- **Disposition:** closed / mitigate. + +### T-03-07 — typed ID oracle + +- **Source:** `surf/params.go` (`IntParam`, `constrain`), `examples/hello/plugins/greeter/plugin.go`. +- **Test evidence:** `TestTypedIDRouteReturns404`, `TestWhereInRejectsOutsideEnum`, `TestTypedItemRoute`. +- **Finding:** `/items/nope` and `/items/99` both 404; `/kinds/other` 404. Request text never builds a regex or SQL fragment. +- **Disposition:** closed / mitigate. + +### T-03-SC — module path legitimacy + +- **Source:** framework `go.mod` (GORM, pgx, gormigrate, golang-jwt, testcontainers at STACK versions). +- **Test evidence:** `go vet ./...` / `go test ./...` in both modules; slopcheck false positives on recent versions documented in 03-RESEARCH.md. +- **Finding:** No unofficial module path was added. testcontainers is the STACK-named test dependency used by `lagoon` isolation tests and the existing app `TestMain`. +- **Disposition:** closed / mitigate. + +--- + +## Accepted Risks Log + +No accepted risks. + +High-severity JWT, missing-guard, cross-tenant, migration-isolation, and false-green parity issues are mitigated with failing-when-broken tests. Token *issuing* remains test-only (Phase 7). Full CORS/locale/rate-limit depth remains Phase 6. Accented/punctuation Polish collation vs MariaDB is documented as a later fixture risk (T-03-05 residual, medium, not open). + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-09-17 | 8 | 8 | 0 | gsd-executor (03-04) | + +--- + +## Sign-Off + +- [x] All threats have a disposition (mitigate / accept / transfer) +- [x] Accepted risks documented in Accepted Risks Log +- [x] `threats_open: 0` confirmed +- [x] `status: verified` set in frontmatter +- [x] No open high-severity JWT or cross-tenant issue remains + +**Approval:** verified 2026-09-17 diff --git a/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md index 4c80cb4..37a70f9 100644 --- a/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md +++ b/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md @@ -1,10 +1,11 @@ --- phase: 03 slug: first-vertical-slice-genres-end-to-end -status: draft -nyquist_compliant: false -wave_0_complete: false +status: complete +nyquist_compliant: true +wave_0_complete: true created: 2026-09-17 +verified: 2026-09-17 --- # Phase 3 — Validation Strategy @@ -13,61 +14,98 @@ created: 2026-09-17 | Property | Value | |---|---| -| Framework | Go 1.27 `testing`, `httptest`; existing `../fonoteka.go/parity` testcontainers Postgres suite | +| Framework | Go 1.27 `testing`, `httptest`; existing `../fonoteka.go/parity` testcontainers Postgres suite; framework `lagoon` tests now share the same STACK-named testcontainers modules | | Config | Root `go.mod` plus app `../fonoteka.go/go.mod`; existing `go.work` only covers the framework and hello example | | Quick run | `go vet ./... && go test ./...` in each of `summercms.go` and `../fonoteka.go` | -| Full suite | Root and app vet/test/race, focused real genres parity subtest, Phase 2 corpus contract and regression script | -| Feedback latency | Fast loop should stay within the existing warmed Go test times; Postgres and PHP replay run at wave/final gates | +| Full suite | `bash scripts/check-phase3.sh` — root and app vet/test/race, focused real genres parity, genre security/tenant tests, corpus audit, Phase 2 Go/CLI harness regression (TestParitySynthetic + CLI record/replay smoke) | +| Repeatable gate | `scripts/check-phase3.sh`; Phase 2 PHP self-replay remains `scripts/check-phase2.sh --fresh-php` | +| Measured runtime | Successful `check-phase3.sh` run: **~21s** after cache warmup (2026-09-17). Focused `TestParityCorpus$` 5.115s; focused genre security 5.003s; `TestParitySynthetic` 4.951s; CLI smoke recorded + `replay matched`. | ## Sampling Rate - After every implementation task commit: root `go vet ./... && go test ./...`; after app files exist, run the same commands in `../fonoteka.go`. - After each plan wave: run the focused Postgres route/genre test and the ported parity subtest in the app, plus the quick checks. -- Before Phase 3 verification: run root and app `go test -race ./...`, all ported corpus routes, migration up/down, and Phase 2 regression checks. -- Docker unavailable is a failed app integration gate, following Phase 2 `TestMain` behavior; `testing.Short` remains a fast local option only. +- Before Phase 3 verification: `bash scripts/check-phase3.sh`. Docker unavailable is a failed gate; `testing.Short` remains a fast local option only. ## Per-Task Verification Map -The user confirmed four plans on 2026-09-17. Every task has an automated `` command and an observable acceptance criterion. These rows are planned checks; results are filled during execution. +The user confirmed four plans on 2026-09-17. Every task has an automated `` command and an observable acceptance criterion. Rows below are filled from plan summaries plus the 03-04 gate. | Task ID | Wave | Requirement | Threat | Automated evidence | Status | |---|---:|---|---|---|---| -| 03-01-01 | 1 | DATA-01, DATA-02 | T-03-01, T-03-SC | Root/app vet/test; Postgres 16 ICU migration smoke, 15 seeds, separate history | Pending | -| 03-01-02 | 1 | HTTP-01, HTTP-02, QA-04 | T-03-02, T-03-03 | Root/app vet/test; persisted-user JWT request reaches real genre row | Pending | -| 03-02-01 | 2 | QA-04 | T-03-04 | Root/app vet/test; focused TestGenre nonzero/foreign/editor counts | Pending | -| 03-02-02 | 2 | QA-04 | T-03-05 | Root/app vet/test; focused TestGenre `non_empty`, 422, 15-name order | Pending | -| 03-03-01 | 3 | DATA-02, HTTP-01 | T-03-01, T-03-07 | Root/app vet/test; rollback isolation and hello typed route | Pending | -| 03-03-02 | 3 | QA-04 | T-03-06 | Root/app vet/test; TestParityCorpus one real pass and 153 pending | Pending | -| 03-04-01 | 4 | DATA-01, DATA-02, HTTP-01, HTTP-02 | T-03-01 to T-03-03 | Root vet/test/race and app vet/test; adversarial auth/routing tests | Pending | -| 03-04-02 | 4 | QA-04, HTTP-02 | T-03-04 to T-03-07 | `bash scripts/check-phase3.sh`; app test/race, PHP harness regression, security review | Pending | +| 03-01-01 | 1 | DATA-01, DATA-02 | T-03-01, T-03-SC | Root/app vet/test; Postgres 16 ICU migration smoke, 15 seeds, separate history | Pass — 03-01 SUMMARY (`d0d8450` / `55f110e`) | +| 03-01-02 | 1 | HTTP-01, HTTP-02, QA-04 | T-03-02, T-03-03 | Root/app vet/test; persisted-user JWT request reaches real genre row | Pass — 03-01 SUMMARY (`4ee4c4a` / `d7915a8`); 401/423 never reach handler | +| 03-02-01 | 2 | QA-04 | T-03-04 | Root/app vet/test; focused TestGenre nonzero/foreign/editor counts | Pass — 03-02 SUMMARY (`fc7d581`) | +| 03-02-02 | 2 | QA-04 | T-03-05 | Root/app vet/test; focused TestGenre `non_empty`, 422, 15-name order | Pass — 03-02 SUMMARY (`56be82f` / `f5adabc`) | +| 03-03-01 | 3 | DATA-02, HTTP-01 | T-03-01, T-03-07 | Root/app vet/test; rollback isolation and hello typed route | Pass — 03-03 SUMMARY (`8e3bf26` / `91e3df4`) | +| 03-03-02 | 3 | QA-04 | T-03-06 | Root/app vet/test; TestParityCorpus one real pass and 153 pending | Pass — 03-03 SUMMARY (`0cc1a4d`); fixture unmodified | +| 03-04-01 | 4 | DATA-01, DATA-02, HTTP-01, HTTP-02 | T-03-01 to T-03-03 | Root vet/test/race and app vet/test; adversarial auth/routing tests | Pass — `92255a46ed039f605231ad71eed243436c4a1fbc`; re-run green in 03-04 gate | +| 03-04-02 | 4 | QA-04, HTTP-02 | T-03-04 to T-03-07 | `bash scripts/check-phase3.sh`; app test/race, Go/CLI harness regression, security review | Pass — 2026-09-17, `phase3 check passed`; app `c6615683cbd5bb10cc141bee69f1938c46450ffe`; script `c2dfa7b62f3ba993dbfc50313f437c367770b3cb`; gate follow-up `fda61f0c1519e0756e966a64b868a8f92085fcb7` | | Capability | Requirement | Threat | Test and evidence | |---|---|---|---| -| Shared pgx/GORM connection and plugin migrations | DATA-01, DATA-02 | T-03-01 schema drift | Testcontainers creates all minimal tables and 15 ordered genres; app and framework observe one `*sql.DB`; two plugin histories survive independent up/down/rollback; no `AutoMigrate` call | -| Named route and middleware pipeline | HTTP-01, HTTP-02 | T-03-02 auth bypass | `httptest` records seven stage order, preflight behavior, missing-name boot failure, typed malformed/unknown ID 404, JWT/must-change-password placement | -| JWT guard | HTTP-02, QA-04 | T-03-03 token forgery | Missing/malformed/expired/wrong algorithm/bad signature/missing `sub`/unknown user yield PHP shaped 401; empty secret fails boot; valid user reaches handler | -| Active collection and genre aggregate | QA-04 | T-03-04 cross-tenant data leak | Postgres inserts owned/editor/foreign albums; exact positive counts and `non_empty=1` set; foreign count remains zero; invalid query yields 422 envelope; empty list is `[]` | -| Polish order | QA-04 | T-03-05 sort drift | Testcontainers initializes Postgres 16 with ICU `pl-PL`; startup rejects a libc or wrong-locale database; ordinary ORDER BY returns all 15 names in fixture order; accented/punctuation edge case documented as a later regression fixture risk | -| Recorded parity | QA-04 | T-03-06 false green | Real `newTarget` and temporary seed hook replay unmodified fixture; corpus reports one ported/pass and 153 pending; deliberate response mutation fails diff | +| Shared pgx/GORM connection and plugin migrations | DATA-01, DATA-02 | T-03-01 schema drift | `TestWrongICULocaleFailsOpen`, `TestTwoPluginMigrationSetsIsolated`, `TestNoAutoMigrate`, app `TestMigrateSeedsCanonicalGenres`, `TestRollbackLastIsolatesFonoteka`. ICU `pl-PL` required before GORM. Two plugin histories survive independent up/down/rollback. No `AutoMigrate`. | +| Named route and middleware pipeline | HTTP-01, HTTP-02 | T-03-02 auth bypass | `TestAssembleMissingMiddlewareFailsBoot`, `TestUnauthenticatedNamedGuardDoesNotReachHandler`, `TestPipelineOrderRecoverCORSLocaleAuthPasswordOrgRateHandler`, `TestCORSPreflightBypassesNamedAuth`, `TestTypedIDRouteReturns404`. Missing names fail boot with plugin+name; unauthenticated GET never sets handler `Cache-Control`. | +| JWT guard | HTTP-02, QA-04 | T-03-03 token forgery | `TestVerifyRejectsBadTokens`, `TestVerifyRejectsAlgNoneEmptySecretAndAbsentExp`, `TestVerifyAndMiddlewareOmitTokenAndSecret`, app `TestGenreSecurityBoundaries`, `TestEmptyJWTSecretFailsBoot`. HS256 pinned; empty secret fails Boot; 401 bodies omit token/secret. | +| Active collection and genre aggregate | QA-04 | T-03-04 cross-tenant data leak | `TestGenreCountsScopedToActiveCollection`, `TestGenreSecurityBoundaries/alice-counts-ignore-foreign`, `/bob-counts-ignore-alice`. Foreign albums stay at count 0. Invalid stored context falls back to lowest-ID accessible collection. | +| Polish order | QA-04 | T-03-05 sort drift | `TestOrderClauseAllowList`; integration 15-name order under ICU `pl-PL`; `TestGenreQueryFailsOnWrongLocale`. No COLLATE. Invalid `non_empty` returns PHP 422 envelope. | +| Recorded parity | QA-04 | T-03-06 false green | Unmodified `fixtures/routes/get_genres_jwt.yaml`; `TestParityCorpus` 154 recorded, 1 passing, 153 pending, 0 failing, 0 unrecorded; `TestParityContract/honest-counts` and `ported-mutated-response`. | ## Wave 0 Requirements -- Existing `../fonoteka.go/parity/TestMain` already starts a Postgres container and the recorded fixture exists. The first implementation slice adds the real app boot and a focused route smoke test before moving `newTarget`. -- Test helper for applying both plugin migration sets and seeding Alice's active collection belongs in the app test package, and must not be copied into `tide`. -- No additional test framework or watch mode is required. +- [x] Existing `../fonoteka.go/parity/TestMain` starts a Postgres container and the recorded fixture exists. Plan 01 added real app boot and a focused route smoke test before Plan 03 moved `newTarget`. +- [x] Test helper for applying both plugin migration sets and seeding Alice's active collection lives in the app test package (`genres_seed_test.go`), not `tide`. +- [x] No additional test framework or watch mode was required. testcontainers-go v0.44.0 is the STACK-named dependency used by framework `lagoon` isolation tests. ## Manual-Only Verifications -| Behavior | Requirement | Why manual | Test instructions | -|---|---|---|---| -| Database provisioning | QA-04 | Production Postgres lies outside the test suite | Use a fresh database created with `TEMPLATE template0`, `LOCALE_PROVIDER icu`, `ICU_LOCALE 'pl-PL'`, and UTF8 encoding; run the startup locale check before migrations. The user confirmed database-level locale on 2026-09-17. | +| Behavior | Requirement | Why manual | Test instructions | Result | +|---|---|---|---|---| +| Database provisioning | QA-04 | Production Postgres lies outside the test suite | Use a fresh database created with `TEMPLATE template0`, `LOCALE_PROVIDER icu`, `ICU_LOCALE 'pl-PL'`, and UTF8 encoding; run the startup locale check before migrations. | **Documented in `../fonoteka.go/README.md`.** Testcontainers databases are created the same way in `lagoon/postgres_test.go` and app `TestMain`. Wrong-locale Open fails. | + +## Phase 3 gate evidence (03-04-02) + +Commands (no credentials): + +```bash +go vet ./... && go test ./... && go test -race ./... # summercms.go +(cd ../fonoteka.go && go vet ./... && go test ./... && go test -race ./...) +(cd ../fonoteka.go && go test ./parity -count=1 -run 'TestParityCorpus$') +(cd ../fonoteka.go && go test ./parity -count=1 -run 'TestGenreSecurityBoundaries|TestGenreCountsScopedToActiveCollection|TestGenreQueryFailsOnWrongLocale') +(cd ../fonoteka.go && go run ./parity/check_corpus.go \ + --manifest ../fonoteka.go/parity/manifest.yaml \ + --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php \ + --require-recorded --require-clients --check-secrets) +(cd ../fonoteka.go && go test ./parity -run TestParitySynthetic -count=1) +bash scripts/check-phase3.sh +``` + +Observed (2026-09-17, `bash scripts/check-phase3.sh` exit 0): + +- Docker present; missing Docker is an explicit refuse, not a skip. +- Root vet/test/race: all packages `ok` (cached after first run). +- App vet/test/race: `ok git.golem15.com/golem15/fonoteka/parity`. +- Focused genres parity (`TestParityCorpus$`): `ok` in 5.115s — one real ported pass. +- Focused genre security/tenant: `ok` in 5.003s. +- Corpus audit: `recorded 154/154`. +- `TestParitySynthetic`: `ok` in 4.951s. +- CLI smoke: `parity:record` wrote a loopback fixture; `parity:replay` printed `replay matched`. +- Script printed `phase3 check passed`. + +Corpus honesty (app `TestParityContract` / `TestParityCorpus`): **154 recorded, 1 passing, 153 pending, 0 failing, 0 unrecorded**. A deliberate `album_count` mutation fails `ReplayFlow`. The recorded PHP fixture is unchanged (`Bearer {{jwt:alice}}`, `"album_count":0`). + +PHP `--fresh-php` (Phase 2 sign-off, not this gate): as of 2026-09-17 `scripts/check-phase2.sh --fresh-php` reports **150/154** on four wishlist `album_count` routes (expected 1, live 2). Phase 3 did not edit PHP, tide, or those fixtures. The Phase 3 script therefore re-runs the Phase 2 Go/CLI harness pieces and leaves PHP self-replay at `check-phase2.sh`. + +Prior implementation commits (03-01 through 03-03, plus 03-04 Task 1) ran `go vet ./...` and `go test ./...` green in their summaries. + +Security review: `03-SECURITY-REVIEW.md` maps T-03-01 through T-03-07 and T-03-SC to passing tests; `threats_open: 0`; no open high-severity JWT or cross-tenant issue. ## Validation Sign-Off -- [ ] Plan IDs and per-task commands filled after plan-count checkpoint. -- [ ] Every task has automated feedback; no three consecutive tasks without it. -- [ ] Security threat model appears in each plan and the final plan tests its high severity mitigations. -- [ ] Root and app quick checks, race checks, Postgres integration, and recorded parity are green. -- [ ] Mark `nyquist_compliant: true` only after implementation evidence exists. +- [x] Plan IDs and per-task commands filled after plan-count checkpoint. +- [x] Every task has automated feedback; no three consecutive tasks without it. +- [x] Security threat model appears in each plan and the final plan tests its high severity mitigations. +- [x] Root and app quick checks, race checks, Postgres integration, and recorded parity are green. +- [x] Mark `nyquist_compliant: true` only after implementation evidence exists. -**Approval:** pending execution. +**Approval:** Phase 3 gate evidence recorded 2026-09-17. `nyquist_compliant: true`.