diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-REVIEW-DISPOSITION.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-REVIEW-DISPOSITION.md new file mode 100644 index 0000000..35399ec --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-REVIEW-DISPOSITION.md @@ -0,0 +1,43 @@ +--- +phase: 14.1 +review: 14.1-REVIEW.md +titles: json +findings: + - id: WR-01 + severity: warning + disposition: fixed + title: "Hidden profile_data is not json:\"-\"; DATA-07 registry test is red" + - id: WR-02 + severity: warning + disposition: open + title: "Last-method 409 is a non-transactional count-then-delete" + - id: IN-01 + severity: info + disposition: open + title: "Winter 404 unit tests inject a stub, not host WriteWinterHTTPError" + - id: IN-02 + severity: info + disposition: open + title: "MeToken D-09 empty-but-Valid CollectionIDs is untested" + - id: IN-03 + severity: info + disposition: open + title: "Index Find decrypts Encrypted token columns it never returns" +open: 4 +total: 5 +recorded: 2026-10-05T20:00:00Z +--- + +# Phase 14.1: Code Review Disposition + +| Finding | Severity | Disposition | Source | +|---------|----------|-------------|--------| +| WR-01 | warning | fixed | sm-user-plugin cf5c6b9; fonoteka.go 7fc790d | +| WR-02 | warning | open | - | +| IN-01 | info | open | - | +| IN-02 | info | open | - | +| IN-03 | info | open | - | + +Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`. +Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run. +Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose. diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-REVIEW.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-REVIEW.md new file mode 100644 index 0000000..19efa33 --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-REVIEW.md @@ -0,0 +1,93 @@ +--- +phase: 14.1-oauth-identities-and-fonoteka-me-routes +reviewed: 2026-10-05T19:45:00Z +depth: standard +files_reviewed: 14 +files_reviewed_list: + - ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go + - ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go + - ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go + - ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go + - ../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go + - ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml + - ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml + - ../fonoteka.go/plugins/golem15/fonoteka/routes.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go + - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go + - ../fonoteka.go/parity/parity_test.go + - ../fonoteka.go/parity/fonoteka_seed_test.go + - ../fonoteka.go/parity/fonoteka_reset.php +findings: + critical: 0 + warning: 2 + info: 3 + total: 5 +status: issues_found +--- + +# Phase 14.1: Code Review Report + +**Reviewed:** 2026-10-05T19:45:00Z +**Depth:** standard +**Files Reviewed:** 14 +**Status:** issues_found + +## Summary + +Phase 14.1's production path is largely aligned with D-01–D-12: last-method 409 is identity-count-only (D-06), missing/foreign/unknown DELETE share one host `WriteWinterHTTPError` (HTTP-01), GET list is an explicit `{provider, linked_at}` map, identity routes sit on the JWT group only with unconstrained `{provider}` and DELETE `throttle:10,1`, and `/me` emits `collection_ids` JSON null while `scopes` stay `[]` (D-09). Two warnings remain: `Hidden()` for `profile_data` is not backed by `json:"-"` (and `TestHiddenNeverMarshals` is currently red), and last-method 409 is a non-transactional count-then-delete. + +No BLOCKER (critical) findings on the listed HTTP contracts. + +## Warnings + +### WR-01: Hidden `profile_data` is not `json:"-"`; DATA-07 registry test is red + +**File:** `../fonoteka.go/plugins/golem15/user/models/oauth_identity.go:15-29` +**Issue:** `AccessToken` and `RefreshToken` correctly carry `json:"-"`. `ProfileData` is listed in `Hidden()` but has only a GORM tag. `lagoon.Jsonable` has no `MarshalJSON`, so `encoding/json` of an `OAuthIdentity` emits exported `Data` / `Valid` / `NullOnEmpty` under the key `ProfileData` (emails and other profile PII). Index itself is safe (explicit two-key map, D-05), and `TestOAuthIdentitiesIndexDoesNotLeakEncryptedTokensOrProfileData` passes on that path. + +The project's HasHidden backstop does not. `plugins/golem15/fonoteka/classes/hidden_marshal_test.go` still has `expectedUserModels = 7`. Confirmed this review: `go test ./plugins/golem15/fonoteka/classes -run TestHiddenNeverMarshals` fails with `user models.All() = 8, want 7`. After bumping the count, `assertHiddenTagged` would fail on `profile_data`, and `populateHidden` cannot set a `Jsonable` field. + +Phase 02's `go test ./plugins/golem15/fonoteka` does not include the `classes` package, so this stayed hidden from the plan verify command. + +**Fix:** +```go +ProfileData lagoon.Jsonable[map[string]any] `gorm:"column:profile_data" json:"-"` +``` +Bump `expectedUserModels` to 8 and extend `setHiddenSentinel` (or skip Jsonable populate once `json:"-"` is present). Re-run `TestHiddenNeverMarshals`. + +### WR-02: Last-method 409 is a non-transactional count-then-delete + +**File:** `../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go:81-98` +**Issue:** Destroy loads the row, then `Count`s identities for `user_id`, then `Delete`s if `n != 1`. Two concurrent DELETEs of two remaining providers can both observe `n == 2` and both delete, leaving zero identities. D-06 is fail-closed lockout; D-13 already records that a social-only account cannot sign in on Go. `throttle:10,1` does not serialize in-flight requests. Sequential unit tests (204 sibling, EN/PL 409, 409-with-password) cannot catch this. + +**Fix:** Run Find + Count + Delete in one transaction with `SELECT … FOR UPDATE` on the caller's identity rows (or a single `DELETE … WHERE user_id = ? AND provider = ? AND (SELECT count(*) …) > 1` that returns 0 rows → 409). Keep the password flag unused. + +## Info + +### IN-01: Winter 404 unit tests inject a stub, not host `WriteWinterHTTPError` + +**File:** `../fonoteka.go/plugins/golem15/user/oauth_identities_test.go:155-180` +**Issue:** Missing, foreign, and unknown DELETE all call `writeOAuthIdentityNotFound`. The host (`routes.go:241-245`) injects `api.WriteWinterHTTPError`, whose HTML does not include the path — production bodies are indistinguishable. Plugin tests prove that property only against a constant stub (`writeWinter404` / `winter404Body`), not the embedded `winter_404.html` + `app.url` Origin. A future `WriteNotFound` that branched on `r.URL` would still pass these tests. + +**Fix:** Optional: point Destroy tests at `api.WriteWinterHTTPError` with a config `app.url` of `http://127.0.0.1:8423` so they share bytes with the recorded DELETE fixture. + +### IN-02: MeToken D-09 empty-but-Valid `CollectionIDs` is untested + +**File:** `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go:36-38` +**Issue:** The handler correctly keeps `collection_ids` as a nil `any` (JSON null) unless `Valid && len(Get()) > 0`, and uses `wire.Slice` only for `scopes`. `TestMeTokenHandlerNilScopesSerializeAsEmptyArrayAndCollectionIDsAsJSONNull` uses a zero `ApiToken` (`Valid == false`). `mintUnrestrictedParityToken` also seeds invalid Jsonable. The `Valid && empty slice` branch is implemented and unused by tests. + +**Fix:** Add a case with `CollectionIDs: lagoon.Jsonable[[]uint]{Data: []uint{}, Valid: true}` and require `"collection_ids":null` still, not `[]`. + +### IN-03: Index `Find` decrypts Encrypted token columns it never returns + +**File:** `../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go:31-48` +**Issue:** `Find(&rows)` scans `access_token` / `refresh_token`, so `lagoon.Encrypted.Scan` decrypts plaintext into process memory for a list that only emits `provider` and `linked_at`. Not a JSON leak (`json:"-"` on those fields; explicit map). GORM debug logging of the struct is redacted (`String` / `GoString` / `MarshalJSON` → `[redacted]`). + +**Fix:** `Select("provider", "linked_at")` (or omit the encrypted columns) on the Index query. + +--- + +_Reviewed: 2026-10-05T19:45:00Z_ +_Reviewer: the agent (gsd-code-reviewer)_ +_Depth: standard_ diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VERIFICATION.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VERIFICATION.md new file mode 100644 index 0000000..d620d7e --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VERIFICATION.md @@ -0,0 +1,186 @@ +--- +phase: 14.1-oauth-identities-and-fonoteka-me-routes +verified: 2026-10-05T20:00:00Z +status: passed +score: 8/8 must-haves verified +covered_files: + - ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-PLAN.md" + - ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-SUMMARY.md" + - ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-PLAN.md" + - ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-SUMMARY.md" +covered_digest: "v2:sha256:a8355b54246ac18cf06c363cbfe9fe2c15ab07074939d3b90042cc2534099f73" +covered_files_note: "verification.fingerprint covers only paths under the summercms.go root. Sibling implementation at re-verification: fonoteka.go 7fc790d (master ahead 10 of origin, clean tree), sm-user-plugin cf5c6b9 (master ahead 27 of origin, clean tree). Framework tree has no 14.1 module edits." +behavior_unverified: 0 +overrides_applied: 0 +mvp_mode_note: "ROADMAP marks Phase 14.1 mode: mvp, but the ROADMAP goal is not a User Story. Plan 01/02 objectives carry the story. As in Phases 1, 3, 5 and 8 to 14, ROADMAP success criteria are the contract; User Flow Coverage is derived from them plus the plan story." +decision_coverage: + honored: 13 + total: 13 + not_honored: [] +gaps: [] +deferred: + - truth: "Live Nuxt Settings → Connected accounts and fonoteka-mcp me() against the Go backend" + addressed_in: "Phase 15" + evidence: "Phase 15 success criteria 3 and 4: vue-fonoteka-app runs unchanged for a full manual session; fonoteka-mcp completes install/auth and representative tool calls. Plan 02 human-check is that cutover UAT, not a 14.1 code gap." +--- + +# Phase 14.1: OAuth identities and fonoteka me routes Verification Report + +**Phase Goal:** The three manifest routes no phase owned (14-CONTEXT D-09) are ported and pass the parity diff, so that no route is left `pending` before cutover. They are `GET /_fonoteka/api/v1/oauth-identities`, `DELETE /_fonoteka/api/v1/oauth-identities/{provider}` and `GET /api/v1/fonoteka/me` (the full contract, not only the minimal Phase 8 D-20 version). +**Verified:** 2026-10-05T20:00:00Z +**Status:** passed +**Re-verification:** Yes — closed the DATA-07 Hidden marshal gap after WR-01 (`json:"-"` on `ProfileData`, `expectedUserModels = 8`, Jsonable sentinel skip). `TestHiddenNeverMarshals` and `TestSchemaMatchesPHPSnapshot` PASS on fonoteka.go 7fc790d / sm-user-plugin cf5c6b9. + +**MVP note:** ROADMAP marks this phase `mode: mvp`, but the goal is not a User Story. Plan objectives carry `As a signed-in Nuxt user…`. ROADMAP success criteria remain the contract. + +## User Flow Coverage + +User story (from plan objectives): As a signed-in Nuxt user (and as a fonoteka-mcp token caller), I want to list and unlink connected OAuth identities and receive the full personal-token `/me` body, so that Settings → Connected accounts and MCP bootstrap work against Go with zero pending routes. + +| Step | Expected | Evidence | Status | +|------|----------|----------|--------| +| List connected accounts | Empty list is `{"data":[]}`; linked rows are facebook then google with `linked_at` `+00:00`, no secrets | `OAuthIdentitiesIndex`; fixtures `GET___fonoteka_api_v1_oauth-identities_jwt.yaml` and `__linked.yaml`; `TestOAuthIdentitiesIndexEmptyList` PASS; corpus `GET___fonoteka_api_v1_oauth-identities_jwt` PASS | ✓ | +| Unlink one provider | 204 empty when a sibling remains; Winter HTML 404 for missing/foreign/unknown; 409 localized last-method | `OAuthIdentitiesDestroy`; DELETE fixture; D-11 tests PASS; corpus `DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt` PASS | ✓ | +| MCP `/me` bootstrap | Restricted token lists collection ids; unrestricted emits `"collection_ids":null`; `scopes` stay `[]` | `MeToken`; both `/me` fixtures; `TestMeTokenHandlerNilScopesSerializeAsEmptyArrayAndCollectionIDsAsJSONNull` PASS; corpus both `/me` routes PASS | ✓ | +| Zero pending routes | Manifest 175 ported, corpus pending 0 | `manifest.yaml` 175×`ported`; `expectedPortedRoutes = 175`; `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0` | ✓ | + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +| --- | --- | --- | --- | +| 1 | GET oauth-identities lists the signed-in user's linked social identities exactly as PHP returns them | ✓ VERIFIED | Empty body `{"data":[]}` (`TestOAuthIdentitiesIndexEmptyList`). Linked extra seeds facebook+google Encrypted tokens; recorded PHP body is `[{"provider":"facebook","linked_at":"2026-01-15T12:00:00+00:00"},{"provider":"google","linked_at":"2026-02-01T08:30:00+00:00"}]`. Corpus subtest PASS. Index builds an explicit two-key map, ordered by provider, `linked_at` via `wire.Time` UTC. | +| 2 | DELETE oauth-identities/{provider} unlinks one identity with PHP's status codes and error shapes | ✓ VERIFIED | 204 empty + sibling remains (`TestOAuthIdentitiesDestroyNoContentKeepsSibling`). Last-method 409 EN/PL texts match lang YAML, including when `has_self_set_password` is true. Missing/foreign/unknown (`linkedin`) share byte-identical Winter HTML 404. Unauthenticated DELETE `/google` is 401 JSON. Recorded DELETE case is PHP Winter 404. Handler has no `HasSelfSetPassword`. Mux `{provider}` is unconstrained; allow-list lives in Destroy. | +| 3 | GET /api/v1/fonoteka/me matches the PHP response for fonoteka-mcp token callers | ✓ VERIFIED | Restricted fixture `collection_ids:[{{id:collection}}]`, `name` `parity-mcp`. Unrestricted extra `me-unrestricted` records `"collection_ids":null`, `name` `parity-unrestricted`. Handler emits JSON null unless `CollectionIDs.Valid && len>0`; `scopes` stay `wire.Slice`. Unit test requires `"collection_ids":null` and fails on `"scopes":null`. Corpus both cases PASS. | +| 4 | All three manifest entries flip from pending to ported with recorded PHP cases, leaving zero pending routes | ✓ VERIFIED | Manifest: 175 routes, all `status: ported`, pending list empty. Three ids ported with cases empty GET, linked GET, missing DELETE, restricted `/me`, unrestricted `/me`. `expectedPHPRoutes = expectedPortedRoutes = 175`. `assertPortedMismatch` wraps a ported fixture with `mutateStatus` (no `unported PHP route must not pass`). Verifier run: `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`. | +| 5 | `golem15_user_oauth_identities` exists via gormigrate with both unique indexes, cascade FK, jsonb `profile_data`, and `lagoon.Encrypted` token columns | ✓ VERIFIED | Migration ID `202610050001_create_oauth_identities`, `tx.Exec` DDL, no `AutoMigrate`. `TestOAuthIdentitiesMigration` PASS: table, columns, jsonb udt, both unique indexes, `user_id` → `users` ON DELETE CASCADE, table gone after rollback. Model `TableName`, empty `Fillable`, Encrypted tokens `json:"-"`. | +| 6 | Identity routes exist on the JWT group only; DELETE carries `throttle:10,1`; `/_user` and `/api/v1/fonoteka` never gain identity paths | ✓ VERIFIED | `routes.go` JWT group mounts Index/Destroy; `WriteNotFound` → `WriteWinterHTTPError`; DELETE `"throttle:10,1"`; no `Where("provider"`. User plugin `routes.go` has no identity strings (still `/_user/api/v1` only). Personal-token group serves `GET /me` only. `test_oauth_identity_routes_exist_on_jwt_surface_only` PASS (`assertRouteSurfaces` jwt-only + throttle contains-check). | +| 7 | GET list with seeded Encrypted tokens never contains plaintext, Encrypted JSON keys, or `[redacted]` | ✓ VERIFIED | `TestOAuthIdentitiesIndexDoesNotLeakEncryptedTokensOrProfileData` PASS. Linked PHP fixture body has only `provider`/`linked_at` despite seeded `parity-oauth-*-SECRET` and profile emails on both PHP reset and Go `seedParityOAuthIdentities`. | +| 8 | Registering OAuthIdentity keeps the DATA-07 Hidden marshal backstop green | ✓ VERIFIED | `ProfileData` is `json:"-"` (cf5c6b9). `expectedUserModels = 8` and Jsonable sentinel skip (7fc790d). `go -C fonoteka.go test ./plugins/golem15/fonoteka/classes -count=1 -run TestHiddenNeverMarshals` PASS. | + +**Score:** 8/8 truths verified (0 present, behavior-unverified) + +### Deferred Items + +| # | Item | Addressed In | Evidence | +|---|------|-------------|----------| +| 1 | Live Nuxt Connected accounts list/unlink and fonoteka-mcp `me()` against Go | Phase 15 | Phase 15 SC3/SC4. 14.1's QA-05 slice is the corpus at 175/175/0. | + +D-08 (Winter-import mapper / Laravel decrypt) and D-13 (social-login-only lockout preflight) stay out of this phase per CONTEXT; they are Phase 15, not 14.1 gaps. + +## Required Artifacts + +gsd-tools `verify.artifacts`: plan 01 6/6 passed, plan 02 4/4 passed. Manual three-level check: + +| Artifact | Expected | Status | Details | +| -------- | -------- | ------ | ------- | +| `plugins/golem15/user/models/oauth_identity.go` | OAuthIdentity, TableName, Hidden, Register | ✓ VERIFIED | Exists, substantive, registered. `ProfileData` is `json:"-"` with Encrypted tokens. HTTP list is FLOWING. | +| `plugins/golem15/user/updates/202610050001_create_oauth_identities.go` | gormigrate ID + unique indexes | ✓ VERIFIED | DDL + rollback; wired via `init` Register. | +| `plugins/golem15/user/controllers/oauth_identities.go` | Index/Destroy/Options | ✓ VERIFIED | Wired from fonoteka JWT group; queries `OAuthIdentity` by `user_id`. | +| `plugins/golem15/user/lang/{en,pl}/lang.yaml` | `last_method_blocked` | ✓ VERIFIED | Exact PHP EN/PL strings; handler key `golem15.user::lang.oauth.last_method_blocked`. | +| `plugins/golem15/fonoteka/routes.go` | JWT GET/DELETE mounts | ✓ VERIFIED | Lines 240–246. | +| `plugins/golem15/fonoteka/controllers/api/me_token_controller.go` | D-09 null `collection_ids` | ✓ VERIFIED | No `wire.Slice(collectionIDs)`. | +| `plugins/golem15/user/oauth_identities_test.go` | D-11 + secret-leak | ✓ VERIFIED | Eight `TestOAuthIdentities*` funcs, all PASS this run. | +| `plugins/golem15/user/updates/oauth_identities_test.go` | migration up/down | ✓ VERIFIED | PASS this run. | +| `plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go` | `"collection_ids":null` | ✓ VERIFIED | PASS this run. | +| `plugins/golem15/fonoteka/phase08_coverage_test.go` | jwt-only surfaces | ✓ VERIFIED | Subtest PASS. | +| `plugins/golem15/user/README.md` | host-mounted constructors | ✓ VERIFIED | `OAuthIdentitiesIndex/Destroy/Options`; says "the host application"; no consuming-application name. | + +## Key Link Verification + +gsd-tools `verify.key-links` rejected sibling `../fonoteka.go/...` paths (`Source path rejected — resolves outside the project directory`). Manual wiring: + +| From | To | Via | Status | Details | +| ---- | --- | --- | ------ | ------- | +| fonoteka `routes.go` | `controllers/oauth_identities.go` | JWT `OAuthIdentitiesIndex` / `OAuthIdentitiesDestroy`; `WriteNotFound` → `WriteWinterHTTPError` | WIRED | Import `sm-user-plugin/controllers as userctrl`. | +| `oauth_identities.go` | `models/oauth_identity.go` | Index/Destroy `Where("user_id = ?", user.ID)` | WIRED | Count-then-delete is sequential (see WR-02). | +| `me_token_controller.go` | `models.ApiToken` | `bouncer.Credential` as `*models.ApiToken`, `CollectionIDs` | WIRED | FLOWING from matched credential; no re-query. | +| `oauth_identities_test.go` | constructors | `httptest` + `bouncer.WithUser` | WIRED | All D-11 tests call Index/Destroy. | +| `parity_test.go` | `manifest.yaml` | `TestParityCorpus` 175/175 | WIRED | Constants and coverage subtest. | + +## Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +| -------- | ------------- | ------ | ------------------ | ------ | +| OAuthIdentitiesIndex | `data[].provider`, `linked_at` | GORM `Find` on `golem15_user_oauth_identities` for caller `user_id` | Yes (empty slice or DB rows) | ✓ FLOWING | +| OAuthIdentitiesDestroy | 204 / 404 / 409 | Count + Delete of caller rows; host Winter 404 writer | Yes | ✓ FLOWING | +| MeToken | `collection_ids`, `scopes` | `bouncer.Credential` `*models.ApiToken` | Yes (null vs int list; Slice for scopes) | ✓ FLOWING | +| Linked GET fixture | `data` | PHP-recorded extras; Go `seedParityOAuthIdentities` | Yes; secrets not in body | ✓ FLOWING | + +## Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +| -------- | ------- | ------ | ------ | +| D-11 + secret-leak + empty list + 204 | `go -C $FONOTEKA test ./plugins/golem15/user ./plugins/golem15/user/updates -count=1 -run 'TestOAuthIdentities'` | All PASS including `TestOAuthIdentitiesMigration` (10.3s / 10.8s) | ✓ PASS | +| jwt-only surfaces + MeToken null | `go -C $FONOTEKA test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/controllers/api -count=1 -run 'TestOAuthTokenSurfaceIsolationCoverage\|TestMeToken'` | `test_oauth_identity_routes_exist_on_jwt_surface_only` PASS; MeToken nil/restricted/no-requery PASS | ✓ PASS | +| Corpus 175/175/0 | `go -C $FONOTEKA test ./parity -count=1 -v -run 'TestParityCorpus' -timeout 30m` | `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`; oauth-identities GET/DELETE and both `/me` subtests PASS (47s replay) | ✓ PASS | +| DATA-07 Hidden marshal backstop | `go -C $FONOTEKA test ./plugins/golem15/fonoteka/classes -count=1 -run 'TestHiddenNeverMarshals'` | PASS on 7fc790d (count 8, ProfileData json:"-") | ✓ PASS | + +## Probe Execution + +| Probe | Command | Result | Status | +| ----- | ------- | ------ | ------ | +| — | — | No `scripts/*/tests/probe-*.sh` and no phase-declared probes | SKIPPED | + +## Requirements Coverage + +REQUIREMENTS.md maps none of these IDs to Phase 14.1 (`Requirements: TBD` on the ROADMAP row). Plans claimed them as continuation of completed v1 rows. No orphaned IDs (nothing mapped to 14.1 that a plan omitted). Every claimed ID is accounted for: + +| Requirement | Source Plan | Description | Status | Evidence | +| ----------- | ---------- | ----------- | ------ | -------- | +| API-09 | 01, 02 | All routes on correct groups with identical paths/methods/status/bodies | ✓ SATISFIED for this slice | Corpus 175/175/0; three former pending ids ported | +| HTTP-01 | 01, 02 | Unknown/malformed ids 404 on ownership-scoped resources | ✓ SATISFIED | Unconstrained `{provider}`; missing/foreign/unknown share Winter 404 | +| HTTP-03 | 01, 02 | JWT vs personal-token vs public groups | ✓ SATISFIED | jwt-only identity surfaces; token group has `/me` not identities | +| HTTP-04 | 01 | Inline throttles 1:1 | ✓ SATISFIED | DELETE `"throttle:10,1"` | +| HTTP-06 | 01 | `[]` vs null, `+00:00` timestamps | ✓ SATISFIED | Empty list `[]`; `linked_at` `+00:00`; `/me` `collection_ids` null exception per D-09 | +| DATA-02 | 01, 02 | gormigrate up/down; AutoMigrate never schema source | ✓ SATISFIED | Migration test PASS; no AutoMigrate | +| DATA-07 | 01, 02 | Jsonable + Encrypted hidden from serialization | ✓ SATISFIED | HTTP list leak tests PASS; `TestHiddenNeverMarshals` PASS after `json:"-"` on `ProfileData` | +| I18N-01 | 01, 02 | `vendor.plugin::group.key` YAML en/pl | ✓ SATISFIED | `golem15.user::lang.oauth.last_method_blocked`; EN/PL 409 tests PASS | +| QA-05 | 02 | Parity green; consumers unchanged | ✓ SATISFIED for this slice | Corpus 175/175/0. Live Nuxt/MCP deferred to Phase 15 | + +**Prohibitions (test-tier, wired):** DELETE allow-list in handler not mux (Winter 404 tests + no `Where("provider"`); two-key list map (secret-leak test); constructors not on `/_user` or token group (phase08); last-method count-only (409-with-password); gormigrate not AutoMigrate; 401 probe uses `/google`; pending never counts as passing (`expectedPortedRoutes = 175`). + +## Decision Coverage + +All 13 trackable CONTEXT.md decisions (D-01..D-13) are honored by shipped artifacts (`check.decision-coverage-verify`: honored 13/13, blocking false). D-08 and D-13 are honored as explicit deferrals to Phase 15. + +## Test Quality Audit + +| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict | +|-----------|-----------|--------|---------|----------|-----------------|---------| +| `user/oauth_identities_test.go` | HTTP-01, I18N-01, DATA-07 | 7 tests | 0 | No | Behavioral (204/409/401/404 bytes, leak needles) | OK | +| `user/updates/oauth_identities_test.go` | DATA-02 | 1 | 0 (`-short` skip via dedicatedDB only) | No | Value (jsonb, index names, CASCADE) | OK | +| `me_token_controller_test.go` | HTTP-06, D-09 | 3 | 0 | No | Value (`"collection_ids":null`) | OK | +| `phase08_coverage_test.go` | HTTP-03, HTTP-04 | oauth-identity subtest | 0 | No | Behavioral (jwt-only + throttle) | OK | +| `parity/parity_test.go` | API-09, QA-05 | TestParityCorpus | `-short` skips replay | No (PHP-recorded fixtures) | Behavioral replay | OK | +| `fonoteka/classes/hidden_marshal_test.go` | DATA-07 | 1 | 0 | No | Value (model count + json tags) | OK | + +**Disabled tests on requirements:** 0 +**Circular patterns detected:** 0 (linked `/me` fixtures recorded from isolated PHP, not from Go) +**Insufficient assertions:** 0 on the named 14.1 tests +**Provenance:** VALID — PHP `php_parity.sh` recordings for empty GET, linked GET, DELETE 404, restricted `/me`, unrestricted `/me` + +## Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +| ---- | ---- | ------- | -------- | ------ | +| `controllers/oauth_identities.go` | 81–98 | Count then Delete without transaction | ⚠️ Warning | 14.1-REVIEW WR-02: two concurrent unlinks of the last two providers can both observe n==2. Sequential PHP/Go tests cannot see it. `throttle:10,1` does not serialize in-flight requests. | +| `oauth_identities_test.go` | 155–180 | Winter 404 asserted against a stub, not `WriteWinterHTTPError` | ℹ️ Info | IN-01. Host wiring + recorded DELETE fixture still prove production bytes. | +| `me_token_controller_test.go` | nil token | `Valid && empty slice` branch untested | ℹ️ Info | IN-02. Handler condition is `Valid && len>0`; unused branch. | +| Index `Find` | 31–48 | Decrypts Encrypted columns unused by the response | ℹ️ Info | IN-03. Not a JSON leak. | + +No `TBD`/`FIXME`/`XXX` in phase implementation files. Plan 01 SUMMARY overclaimed `json:"-"` on `profile_data`; the PLAN task text only required Encrypted tokens tagged `json:"-"` and Hidden() for all three. + +## Human Verification Required + +N/A — API-parity / foundation phase. ROADMAP success criteria are programmatically checkable. Plan 02's live Nuxt/MCP check is deferred to Phase 15 (see Deferred Items). + +## Gaps Summary + +None. The three orphan routes are ported and the parity corpus is `175/175/0`. The DATA-07 marshal backstop is green after the WR-01 fix. WR-02 remains an open review warning, not a failed success criterion. + +--- + +_Verified: 2026-10-05T20:00:00Z_ +_Verifier: the agent (gsd-verifier)_