# Pitfalls Research **Domain:** Go rewrite of a WinterCMS/Laravel-shaped CMF, porting a live PHP headless API (Płytarium/fonoteka) to Go with byte-level response parity for an existing Nuxt app and MCP/OAuth client. **Researched:** 2026-09-16 **Confidence:** HIGH for anything cited to actual Płytarium source (routes.php, controllers/api/*.php, models/Album.php, traits/SerializesFonoteka.php, fonoteka-mcp/src/http.ts) or to official GORM/River docs; MEDIUM for Go-ecosystem-general claims verified against GitHub issues/docs; MEDIUM for the "Laravel-shaped framework in Go" class, informed by the project's own `why-go-not-scala.md` post-mortem and the Caddy/xcaddy precedent in `go-ecosystem.md`. This report groups pitfalls into two classes named in the brief: (A) building a Laravel-shaped framework in Go, and (B) porting the PHP API to Go with byte-for-byte parity. Class B pitfalls are cited to specific lines/patterns in the Płytarium source where possible, since that codebase already encodes the exact contract the Go port must reproduce. ## Critical Pitfalls ### Pitfall 1: Building plugin/kernel infrastructure bottom-up again **What goes wrong:** The team designs a general plugin SDK, event bus, config layering, and i18n system to elegant completion before any of the 25 Płytarium models or 154 routes exercise them, discovers the abstractions don't fit the app's actual needs, and stalls or rewrites. **Why it happens:** This is documented, not hypothetical: `why-go-not-scala.md` records that the Scala attempt "stalled before any application ran... building Illuminate-shaped infrastructure bottom-up with no real app pulling requirements." The temptation recurs in Go because the kernel phase (config, plugin registration, event bus, i18n, command framework) is listed first in `PROJECT.md`'s Active requirements and reads like a framework project rather than an application port. **How to avoid:** Treat every kernel abstraction as provisional until a real Płytarium model/route/job needs it. Sequence phases so the data layer and HTTP/auth phases start porting concrete Płytarium models and routes as early as possible, even against a minimal kernel, and let the kernel's final shape be discovered from what the port actually calls. Do not let the plugin descriptor format, event bus generics, or config schema get a "v1.0" design pass before at least one full vertical slice (one model, one route, one migration, one test) exists end to end. **Warning signs:** Kernel-phase plans grow in scope without a corresponding Płytarium model/route landing; PRs touch `compass`/`phrasebook`/`bonfire`-equivalent packages for multiple consecutive phases with no plugin actually registered; the plugin descriptor changes shape more than once before plugin #1 (fonoteka) exists. **Phase to address:** Kernel phase, but bounded — roadmap should interleave kernel work with the first data-layer/route slice rather than sequencing "finish the framework, then port the app." --- ### Pitfall 2: Package-level global state where WinterCMS used per-request context **What goes wrong:** Płytarium's `ActiveCollectionResolver`, `MeContextController`, and the org/tenant-scoping pattern all resolve a request-scoped "active collection" from the authenticated user. A Go port that caches this in a package-level variable, a singleton service, or a mutable struct field on a shared controller instance (instead of `context.Context` or a per-request value) will leak state across goroutines under concurrent requests — each request in Go runs on its own goroutine, unlike PHP-FPM's one-process-per-request isolation, which silently protected this kind of mistake for years. **Why it happens:** Porting `app(ActiveCollectionResolver::class)` (a per-request-resolved singleton in Laravel's container) literally suggests "a Go singleton with a resolve method," which is safe in PHP-FPM but not in Go's shared-process, concurrent-request model. **How to avoid:** Thread the active-collection/tenant resolution result through `context.Context` (or explicit function parameters) from the HTTP middleware layer down, never through package-level or struct-level mutable fields shared across requests. Any "resolver" type must be stateless (safe for concurrent use) and return a value per call, not cache it on itself. **Warning signs:** A struct field that is set in one method and read in another on the same receiver, where that receiver is registered once (e.g., at plugin init) rather than constructed per request; `go test -race` failures or flaky tests under `-race` involving user/collection context; data appearing to leak between concurrent requests in manual testing. **Phase to address:** Kernel phase (establish the context-passing convention before any plugin uses it) and HTTP/auth phase (org/collection context middleware) — both must agree on one mechanism before the API-port phase starts consuming it. --- ### Pitfall 3: Reusing GORM model structs as JSON request-binding structs (losing $fillable/$guarded) **What goes wrong:** `Album` in PHP declares `protected $fillable = [...]` (an explicit allow-list of mass-assignable columns) and `protected $guarded = ['*']` as a backstop. This is what stops a client from setting `market_price_source`, `collection_id`, or any other server-owned field via a crafted request body. Go structs have no equivalent concept: if the same struct is used both for `json.Unmarshal(requestBody)` and as the GORM model persisted to the DB, every exported field becomes writable by any caller who knows its JSON tag — a mass-assignment vulnerability the PHP code explicitly guards against (see the `market_price_source` comment: "Server-owned — see D-3, this is NOT in AlbumWriteService::FILL_FIELDS or the PUT /albums validator"). **Why it happens:** It looks like less code to decode straight into the persistence model, especially when porting line-by-line from a PHP model that conflates "the model" and "the fillable shape" via one class. **How to avoid:** Define separate request DTOs per write endpoint (create/update) distinct from the GORM model, validated with `go-playground/validator`, then explicitly copy allow-listed fields onto the model (mirroring `AlbumWriteService::FILL_FIELDS` from the PHP source, not the full model). Never `json.Unmarshal` directly into a GORM-tagged struct for write endpoints. **Warning signs:** A single struct type used for both `db:"..."`/GORM tags and incoming request binding; a new server-computed field (like `market_price_source`) added to the model without a corresponding audit of every write endpoint's DTO. **Phase to address:** Data layer (establish the DTO-vs-model convention with the first model) and API-port phase (apply it to all 25 models, cross-checked against each PHP `*Rules()`/`FILL_FIELDS` list). --- ### Pitfall 4: Nil slice encodes as `null`, PHP's guarded array always encodes as `[]` **What goes wrong:** `Album::$jsonable = ['tracklist', 'cover_import_failures']`, and `SerializesFonoteka::serializeAlbum()` explicitly guards both: `'tracklist' => is_array($album->tracklist) ? $album->tracklist : []` and the same pattern for `cover_import_failures`. The PHP code goes out of its way to guarantee these keys are *always* a JSON array, never `null`. Go's `encoding/json` marshals a nil slice as `null`, not `[]`. A direct field-for-field port (`Tracklist []Track `json:"tracklist"``) without an explicit non-nil default will silently emit `"tracklist": null` whenever the value hasn't been set, breaking any Nuxt/MCP client code that does `.map()` or `.length` on that field without a null check it never needed against the PHP backend. **Why it happens:** Go's `nil`-slice-marshals-to-`null` behavior is easy to forget because it is "correct JSON" and passes casual review; the byte-diff only shows up against a live parity harness or a client crash. **How to avoid:** For every field the PHP serializer explicitly defaults to `[]` (audit `SerializesFonoteka.php` field-by-field, not just Album), initialize the Go slice to `[]T{}` at construction/hydration time, or give the response type a custom `MarshalJSON` that normalizes nil to `[]`. Cover this with a parity-harness assertion, not developer memory, since there will be dozens of such fields across 25 models. **Warning signs:** A JSON diff between PHP and Go responses showing `null` where PHP has `[]`; client-side `.map is not a function` errors in the Nuxt app or MCP server after a Go cutover. **Phase to address:** API-port phase, verified by the parity harness (this is exactly the class of bug the parity suite exists to catch — make it an explicit assertion category, not just "responses match," because a naive deep-equal that treats `null` and `[]` as different will catch it, but a lenient comparator might not). --- ### Pitfall 5: Money field ported as float64 instead of the fixed-precision string PHP deliberately uses **What goes wrong:** `Album::$casts['market_price_stored'] = MarketPriceCast::class`, and the docblock is explicit: this is a hand-written cast that keeps the value "a stable 4-decimal string, never a float," specifically *because* Laravel's built-in `decimal:4` cast "crashes Eloquent's own internal dirty-check on a blank string." The wire format is `"market_price_stored": "129.5000"` — a JSON string, always 4 decimals, never a bare number. A Go port using `float64` for this column (the "obvious" numeric mapping) will (a) produce `129.5` instead of `"129.5000"` on the wire — a type AND format mismatch the Nuxt client's price parser doesn't expect — and (b) reintroduce classic float-precision bugs (`999999.9999` ceiling checks, arithmetic) that the PHP code deliberately avoided by never treating this as a float. **Why it happens:** "Price" reads as a number, and Go's ecosystem defaults (encoding/json, most ORMs) map SQL `decimal` to `float64` unless a decimal type is deliberately chosen. **How to avoid:** Model `market_price_stored` (and any other DECIMAL column with a similar contract) as a string type end-to-end in Go — store/format with fixed 4-decimal precision (e.g. `shopspring/decimal` internally, but marshal via a custom type that always renders 4 decimals as a JSON string) — never `float64`. Write a unit test that round-trips the exact `999999.9999` ceiling and a "clear the price" blank-string case, matching the PHP comment's stated reason for the custom cast. **Warning signs:** Any model field typed `float64`/`float32` that maps to a PHP `decimal` column with a custom cast comment in the source; JSON diffs showing a bare number where PHP emits a quoted string. **Phase to address:** Data layer (choose the Go type for every DECIMAL column against its PHP cast, not just its SQL type) and API-port phase (serialization). --- ### Pitfall 6: `time.Time` JSON marshaling doesn't match Carbon's `toIso8601String()`, and nullable dates must emit `null`, never be omitted **What goes wrong:** Every timestamp in `SerializesFonoteka` goes through `$x?->toIso8601String()`, which is Carbon's ISO-8601 format with a colon-separated UTC offset: `2026-09-16T12:34:56+00:00`. Go's default `time.Time.MarshalJSON` emits RFC 3339 with a `Z` suffix for UTC: `2026-09-16T12:34:56Z`. These are both valid ISO-8601 but are different byte sequences — a strict client-side date parser or a byte-diff parity check will flag this as a mismatch even though "the date is the same." Separately, `$x?->toIso8601String()` on a null Carbon instance evaluates to `null`, and that key is still present in the array (PHP's `null` serializes to a JSON `null`, not an absent key) — a Go `*time.Time` field tagged `json:"...,omitempty"` will *omit the key entirely* when nil, which is a different contract (`null` present vs. key absent) that the SPA/MCP client's TypeScript types were generated against. **Why it happens:** Both mismatches look like "the same information" to a reviewer and only fail in exact string/shape comparison, not in casual manual testing. **How to avoid:** Write a custom JSON marshaling wrapper for timestamps that renders `+00:00` (or the stored offset) instead of `Z`, matching Carbon's format exactly. For nullable date fields, do not use `omitempty` on a pointer type if the PHP contract always includes the key with `null` — decide field-by-field (per `SerializesFonoteka.php`) whether a null is emitted or the key is dropped (see Pitfall 8 for the general "key presence" issue), and encode that decision explicitly rather than relying on struct tag defaults. **Warning signs:** Parity-harness diffs showing `Z` vs `+00:00` on every single timestamp field (this will be a very loud, very total failure the first time the harness runs against real ported endpoints); a missing key in a Go response where the PHP response has the key with value `null`. **Phase to address:** API-port phase; should be solved once as a shared response-time-formatting helper before the 154-route port starts, not per-endpoint. --- ### Pitfall 7: Go's zero-value bool can't represent PHP's tri-state null/true/false fields **What goes wrong:** `serializeCollection()` computes `'is_owner' => $userId !== null ? ((int) $collection->owner_id === (int) $userId) : null` and `'is_active' => $activeCollectionId === null ? null : (...)`, with an explicit comment that "null means this response did not compute it." This is a genuine three-state field: `true`, `false`, or `null`-meaning-not-computed, and different endpoints (index vs. show/create/update) deliberately choose different states. A Go `bool` field has exactly two states; if the port uses a plain `bool`, "not computed" and "false" become indistinguishable, and any endpoint that intentionally passes `null` here will instead emit `false`, changing client behavior (e.g. an SPA badge that should be hidden when unknown will instead show "not owner"). **Why it happens:** Direct field-by-field translation of PHP into Go without noticing the ternary's third branch, because Go's type system doesn't force you to represent "unknown" unless you deliberately reach for `*bool` or a small enum. **How to avoid:** Audit every field in `SerializesFonoteka.php` (and its siblings for other models) for ternaries that can produce `null` alongside `true`/`false`, and model those as `*bool` (nil = not computed) with an explicit "always include the key, possibly as JSON `null`" marshaling contract, per Pitfall 6's key-presence rule. **Warning signs:** A PHP serializer method with a ternary whose false branch is `null` rather than `false`; a Go field typed plain `bool` backing such a method. **Phase to address:** API-port phase, same pass as Pitfall 6. --- ### Pitfall 8: Response envelope, conditional key inclusion, and pagination/error shapes drift from the PHP house style **What goes wrong:** Three shapes are load-bearing and inconsistent with each other by design, which is exactly the kind of nuance a generic Go serialization approach will flatten: - List endpoints wrap in `{"data": [...], "meta": {"current_page","last_page","per_page","total"}}` (`AlbumApiController::paginated()`) — no `links` key, unlike Laravel's own default paginator output. A Go port that uses a "standard" pager envelope (e.g. adding `links`, or using `page`/`limit`/`count` naming) breaks every list endpoint at once. - Validation failures are `{"error": "Validation failed", "errors": {field: [messages, ...]}}` at HTTP 422 (`validationFailure()`), where `errors` is a map of field name to an array of message strings, even for a single message — this is Laravel's `$validator->errors()` shape verbatim. go-playground/validator's native error type is structurally different (a slice of `FieldError` objects with methods, not a map of strings) and needs a translation layer to reproduce this exact map shape, including array-field paths (`tracklist.*.artist` → concrete indices like `tracklist.0.artist` in the actual errors map). - A field like `reservation` in `serializeAlbum()` is **omitted from the payload entirely** when `$reservationContext === null`, not present-as-null — "this keeps every existing caller's payload byte-identical without touching a single call site," per the source comment. This is a per-endpoint, conditional key-presence contract that a single shared Go response struct with `omitempty` cannot express correctly in general (`omitempty` on a struct/slice/map has its own zero-value quirks, and doesn't distinguish "caller opted out of this field" from "the field is empty"). **Why it happens:** Go encourages "define one response struct, marshal it" as the idiomatic pattern, but the PHP house style was built by hand, field by field, with different envelope and inclusion rules per endpoint family (paginated list vs. single resource vs. OAuth/RFC endpoints — see Pitfall 9) and no single shared struct could express all of it without per-endpoint escape hatches. **How to avoid:** Do not build one generic "API response" Go type used everywhere. Build small, explicit per-endpoint response constructors (functions returning `map[string]any` or endpoint-specific structs assembled conditionally) that mirror each PHP controller's own `paginated()`/`validationFailure()`/serializer method, and drive the parity harness off actual recorded Nuxt/MCP requests (per `CLAUDE.md`'s acceptance rule) rather than a hand-designed "clean" envelope. Build the validator-error-map translator once, early, as a dedicated shared piece, and unit-test it against Laravel's exact array-index-in-dotted-path convention for nested/array fields. **Warning signs:** A shared `APIResponse[T]` generic wrapper appearing in the plan for the API-port phase before any endpoint is ported — treat this as a smell, not a shortcut. A parity-harness diff showing an extra `links` key, a `count`/`limit` pagination key, or a `null` where a key should be absent. **Phase to address:** API-port phase, and this is precisely what the parity-harness phase should be built to catch mechanically — plan the harness to diff full response bodies key-by-key (not just status code / rough shape) from day one. --- ### Pitfall 9: A global response-envelope or auth middleware wraps RFC-compliant OAuth endpoints **What goes wrong:** `OAuthMetadataController::show()` (RFC 8414) emits its JSON **at the document root**, with a PHP comment stating outright: "NOT wrapped in the house `{"data": …}` envelope. The missing envelope is deliberate and required by RFC 8414; a reviewer must not 'fix' it back onto the house shape." `OAuthTokenController::token()` and its `rfcError()` helper emit bare `{"error": "invalid_grant"}` / `{"access_token": ..., "token_type": "Bearer", ...}` shapes per RFC 6749, again unenveloped, and add `Cache-Control: no-store` / `Pragma: no-cache` headers the house JSON responses don't carry. If the Go HTTP layer applies one blanket middleware to wrap all JSON bodies in a house envelope, or one blanket error-formatter for all 4xx responses (a natural thing to build for consistency), it will corrupt every OAuth/metadata endpoint the MCP server and ChatGPT connector depend on — and because these are machine-consumed RFC endpoints, the failure mode is a silent auth breakage for external clients, not something a developer notices browsing the SPA. **Why it happens:** "One consistent response envelope" and "one consistent error formatter" are natural Go-framework instincts (middleware composability is a strength of Go's `net/http`), but this domain has at least three incompatible envelope families (house REST, RFC 8414/6749 OAuth, and whatever the realtime/Centrifugo token endpoint uses) that must not be unified. **How to avoid:** Explicitly exempt `/oauth/*`, `/.well-known/*`, and any other RFC-mandated endpoint from generic envelope/error middleware at the routing layer — make this a structural routing decision (separate route group with no shared middleware for body-wrapping), not a per-handler opt-out that's easy to forget when a 26th model's routes are added later. **Warning signs:** A middleware named something like `wrapResponse`/`standardEnvelope` registered on the top-level router rather than scoped to the house-API route group; any OAuth/metadata endpoint whose test asserts against a wrapped body. **Phase to address:** OAuth phase, decided in the same phase that stands up `net/http` routing/middleware conventions (HTTP/auth phase) — the routing structure must separate these families from the start, since retrofitting it after 154 routes exist is expensive. --- ### Pitfall 10: WWW-Authenticate / Protected Resource Metadata header details are exactly what the MCP client and ChatGPT connector parse **What goes wrong:** `fonoteka-mcp/src/http.ts` shows the MCP proxy itself constructing a 401 with `WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="", scope="read write ai"`, explicitly commented "Claude copies this 401 hint onto [its own response]." Per RFC 9728 and the MCP authorization spec (verified via web search against modelcontextprotocol.io), MCP clients discover the authorization server either from this exact header parameter or from `/.well-known/oauth-protected-resource`. If the Go backend's own 401 responses (for the JWT-guarded and OAuth-protected routes) get this header's parameter names, quoting, or scope list wrong — or omit `resource_metadata` — client-side auto-discovery breaks in a way that surfaces as a generic, hard-to-diagnose "auth failed" in Claude/ChatGPT, not a clear Go-side error. **Why it happens:** This header is easy to treat as cosmetic ("just a 401") when in fact its parameters are a discovery protocol consumed by machine clients that don't tolerate deviation the way a human eyeballing an SPA login page would. **How to avoid:** Treat the exact header string (parameter set, order is not critical but presence and values are) as part of the API contract to be parity-tested, not just the 401 status code. Write it once as a shared helper used by every protected-route 401 path, derived from the same values `OAuthMetadataController`/PRM endpoint publishes, so they can't drift independently. If `zitadel/oidc` has its own default 401/WWW-Authenticate emission, verify it against this exact contract before trusting its defaults — override if needed. **Warning signs:** The MCP server or ChatGPT connector failing to "discover" auth against the Go backend in integration testing while direct `curl` calls to the same endpoint "look right" to a human; a 401 test asserting only `resp.StatusCode == 401` without asserting header contents. **Phase to address:** OAuth phase, verified in the same phase's test plan against `fonoteka-mcp`'s actual client behavior (not just against the Go server's own unit tests). --- ### Pitfall 11: GORM soft delete + unique constraints silently break "delete and re-add" flows **What goes wrong:** Płytarium models use `Winter\Storm\Database\Traits\SoftDelete` (nullable `deleted_at`, global scope excludes soft-deleted rows) alongside real uniqueness constraints — e.g. `AlbumReservation` is explicitly `hasOne` (not `hasMany`) "because `album_id` is UNIQUE on `golem15_fonoteka_album_reservations`." GORM's soft delete (`gorm.DeletedAt`) works the same way at the ORM level, but Postgres unique indexes/constraints created by a naive migration (`UNIQUE(album_id)` with no `deleted_at` in the key) do not know about the soft-delete flag: a soft-deleted-then-recreated row (or two soft-deleted rows for the same `album_id`) will violate the constraint at the database level even though GORM's application-level scope would have allowed it. Laravel apps often get this right by convention/experience; a straight schema port to Go migrations without re-deriving "should this be a partial unique index on `deleted_at IS NULL`" per column will regress this per-migration. **Why it happens:** The unique constraint is defined once in a migration and rarely revisited; the soft-delete interaction is a property of *how the column is used together with the ORM scope*, which isn't visible by reading the migration alone. **How to avoid:** Audit all 27 migrations for unique constraints/indexes on soft-deletable tables, and reproduce Postgres partial unique indexes (`CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL`) wherever the PHP behavior relied on "unique among non-deleted rows." Write a migration-level test that soft-deletes a row and recreates one with the same unique key, asserting it succeeds, for every such table. **Warning signs:** A migration with a plain `UNIQUE` constraint on a table that also has `deleted_at`; a GORM insert failing with a unique-violation error in a scenario the PHP app supported (delete-then-re-add). **Phase to address:** Data layer. --- ### Pitfall 12: Naive GORM many-to-many drops pivot/extra columns (ordering breaks silently) **What goes wrong:** `Album::$belongsToMany['artists']` carries `'pivot' => ['sort_order']`, and `SerializesFonoteka::serializeAlbum()` explicitly sorts by `$a->pivot->sort_order` before serializing — artist display order on an album is driven by this pivot column. GORM's default `many2many` association (a plain `[]Artist` field with a `gorm:"many2many:..."` tag) does not expose extra pivot columns without explicitly modeling the join table as its own struct and using `SetupJoinTable`. A straightforward "list of associated structs" port will compile, look correct in a demo with one artist per album, and then silently lose custom artist ordering for every multi-artist album (a common case — collaborations, compilations) — a regression that is easy to miss because nothing errors, the order is just wrong. **Why it happens:** GORM's simple many2many API is the first thing documentation and examples show; the extra-columns pattern is a secondary, easy-to-miss feature. **How to avoid:** For `Album.artists` (and the `styles` many-to-many, and any other WinterCMS `$belongsToMany` with pivot data across the 25 models — audit all of them, not just Album), model the join table as an explicit Go struct with `sort_order` (or other pivot columns) and use `SetupJoinTable`, or query the join rows directly and hydrate ordering in application code. Cover with a test asserting round-trip order for a 3+ artist album. **Warning signs:** A `$belongsToMany` PHP relation with a `'pivot' => [...]` key or an `'order' => ...` key that has no corresponding join-table modeling in the Go port. **Phase to address:** Data layer. --- ### Pitfall 13: GORM AutoMigrate used as the migration mechanism instead of real up/down migrations **What goes wrong:** The roadmap requirement is explicit: "per-plugin migrations runnable up and down, with a 'drop the last migration and fix it' workflow" across 27 PHP migrations that must be reproduced with matching column types, defaults, and indexes. `AutoMigrate` is additive-only — it creates tables/columns/indexes it doesn't find, but never drops or alters existing columns, and has no concept of "down." Reaching for it as "the" schema mechanism (attractive because it's zero-migration-file DX, very Eloquent-like) cannot satisfy the down-migration requirement and cannot exactly reproduce the 27 originals' constraints (e.g., partial unique indexes from Pitfall 11, decimal precision from Pitfall 5, pivot tables from Pitfall 12). **Why it happens:** AutoMigrate is the most Eloquent-like, least-code path in GORM's own docs, and "GORM chosen for Eloquent-like DX" (per `PROJECT.md`'s Key Decisions) can be read as license to lean on it everywhere. **How to avoid:** Use goose (per `go-ecosystem.md`'s pick) with explicit up/down SQL or Go migrations, one per PHP migration, and treat GORM strictly as the runtime query/model layer, not the schema-authoring tool. AutoMigrate may be acceptable for ephemeral test databases only, never as the source of truth for the 27-migration port. **Warning signs:** A migration directory containing GORM model structs with `AutoMigrate` calls instead of goose migration files; no down-migration path exercised in CI. **Phase to address:** Data layer. --- ### Pitfall 14: River and GORM sharing one Postgres connection pool the wrong way **What goes wrong:** River's own documentation (verified via web search, `riverqueue.com/docs/gorm`) states that sharing a pool with GORM requires the `database/sql`-compatible `riverdatabasesql` driver, which runs in **poll-only mode** (no Postgres LISTEN/NOTIFY) because `database/sql` doesn't support LISTEN — jobs are picked up on a polling interval, not instantly. Getting this backwards — assuming one shared pool gives both transactional enqueue *and* low-latency dispatch — either loses the "enqueue the job in the same DB transaction as the business write" guarantee (mirroring the PHP `DB::transaction()` wrapping bulk album creation) if a separate pgx-only client is used everywhere, or accepts polling-latency job pickup everywhere if only the shared/database-sql driver is used for both enqueue and work. **Why it happens:** "One pool for the whole app" is the simpler mental model and is what a Laravel-shaped mind expects (Laravel's queue and Eloquent share one DB connection transparently); River's split-driver design isn't obvious until read carefully. **How to avoid:** Use `riverdatabasesql` (sharing GORM's `*sql.DB`) specifically for transactional job inserts that must commit atomically with a business write (e.g. CSV import row creation, wishlist digest scheduling tied to a write). Use a separate `riverpgxv5`-backed River client (its own pgx pool) for the actual work-processing client that pulls and executes jobs, to get real LISTEN/NOTIFY dispatch instead of poll-only latency. Do not use a single client/driver for both roles. **Warning signs:** Background jobs (CSV import, wishlist digest) taking up to the poll interval to start even under light load; a single River client instance referenced from both the HTTP write path and the worker startup code. **Phase to address:** Background/integrations phase (jobs), decided alongside the data-layer phase's choice of GORM's underlying `*sql.DB`/pool setup so the two are compatible from the start. --- ### Pitfall 15: Typesense treated as a source of truth instead of a pre-filter, reopening an authorization bypass **What goes wrong:** `AlbumApiController`'s docblock states the architecture explicitly: "Typesense is only a pre-filter: SQL hydration repeats the same two gates" (the `accessibleBy` scope and the active-collection id). `AlbumSearchService` re-applies both gates in SQL after getting candidate IDs from Typesense, specifically so a stale, mis-scoped, or delayed-to-reindex search document can never leak a result the requesting user isn't authorized to see. A Go port that treats a Typesense hit as already-authorized (e.g., serializing fields straight from the Typesense document, or trusting its scoping without a subsequent SQL re-check) reopens a cross-collection data leak — this is a security regression, not just a parity mismatch, and it's the kind of thing that only shows up when the search index and the DB briefly disagree (exactly when it matters). **Why it happens:** Returning data straight from the search engine is faster and is what most Typesense/Elasticsearch tutorials show; the PHP code's extra SQL round-trip looks like unnecessary duplication unless the authorization comment is read. **How to avoid:** Preserve the two-step architecture verbatim: Typesense/search engine returns candidate IDs only, and every result is re-fetched and re-scoped through the same GORM query-scoping (`accessibleBy`-equivalent + active-collection filter) used by the non-search list endpoints, before serialization. **Warning signs:** A search endpoint whose response fields come directly from a search-engine document type rather than from the same model-serialization path as `index()`/`show()`; no SQL query visible in traces for a search request. **Phase to address:** API-port phase (search endpoints), reviewed against the security checklist, not treated as purely a search-relevance concern. --- ## Moderate Pitfalls ### Init-order plugin registration nondeterminism **What goes wrong:** Go's `init()` functions run in package-dependency order, which is deterministic *within a build*, but a compile-time plugin registry (the chosen Caddy/xcaddy-style model) that iterates a `map[string]Plugin` for registration/route-mounting order can still produce nondeterministic route registration order across runs, since Go map iteration order is randomized. WinterCMS ported plugin-load-order bugs (one plugin's migration/route depending on another's having registered first) are easy to reintroduce if the generated plugin import list or the registry's internal storage is map-based rather than an explicit ordered slice. **Prevention:** Generate an explicit ordered list (slice, not map) for plugin registration in the `summer build`-generated import file, and make any plugin-to-plugin dependency explicit (a declared "depends on" in the plugin descriptor) rather than implicit ordering. Add a test that registers plugins in two different orders and asserts identical resulting route/model state. ### Rate-limit bucket parity **What goes wrong:** PHP defines multiple named buckets with specific keys and limits — `fonoteka-api-token` (60/min, keyed by token id, falling back to IP), `fonoteka-oauth-token` and `fonoteka-oauth-register` (30/min per IP, deliberately generous because "ChatGPT/Claude DCR originates from vendor egress, not household IPs — 5/hour would cap the whole platform"). A generic single Go rate-limiter middleware redesigned during the port will likely under- or over-throttle these specific, deliberately-tuned cases. **Prevention:** Port bucket-by-bucket with identical keys and limits; treat the specific numbers and keying strategy as part of the contract, not an implementation detail to be redesigned. ### Centrifugo broadcast-suppression and channel-name contract **What goes wrong:** Bulk operations use `Album::withoutBroadcasting()` to suppress per-row events, then emit exactly one summary event (`collection.bulk_updated`) after — a deliberate choke on event volume the Nuxt client is tuned to expect. A literal per-row port without the suppress-then-emit-once pattern will flood the client with events during bulk imports (CSV import in particular), and channel name strings (`collection:{id}`) must match exactly since Centrifugo channel names are opaque strings to any client subscribed by name. **Prevention:** Port the suppress/batch/emit-once pattern as an explicit mechanism (not "just call publish for every write"), and treat channel name strings as part of the wire contract. ### snake_case JSON keys across ~25 models' worth of fields **What goes wrong:** Every response key (`artist_display`, `market_price_checked_at`, `cover_import_failures`, etc.) is snake_case. Go's idiomatic default and most OpenAPI-codegen tool defaults are PascalCase/camelCase; across hundreds of fields, relying on developers to hand-write `json:"snake_case"` tags correctly and consistently (including the OpenAPI-generated admin SPA types mentioned in the requirements) will drift. **Prevention:** Enforce via a lint rule or generated-code test (e.g., a test that walks all response struct fields via reflection and asserts every JSON tag is snake_case), not developer discipline. Configure the OpenAPI generator explicitly for snake_case output and verify against a sample. ### Constant-time secret comparison lost in translation **What goes wrong:** `OAuthTokenController::authenticateClient()` uses PHP's `hash_equals()` for comparing the client secret hash — a timing-safe comparison. A Go port using `==` on byte slices/strings for the equivalent check reintroduces a timing side channel that was deliberately closed in the PHP original. **Prevention:** Use `crypto/subtle.ConstantTimeCompare` (or an equivalent constant-time helper) for every secret/hash comparison ported from a PHP `hash_equals()` call; grep the PHP codebase for `hash_equals` and check each Go equivalent explicitly. ### go 1.27 json/v2 strictness meeting years of lenient PHP-written data **What goes wrong:** Go 1.27's `encoding/json` (json/v2-backed) rejects duplicate keys and invalid UTF-8 on decode, stricter than PHP's `json_decode`. Historic JSON-column data (`tracklist`, `cover_import_failures`, and similar `jsonable` columns across other models) written over years by the PHP app could contain data that decodes fine in PHP but fails in Go. **Prevention:** Run a one-time validation pass over existing JSON/JSONB column data during migration/import to catch decode failures before cutover, rather than discovering them per-request in production; decide a fallback behavior (log-and-empty vs. hard error) for any row that still fails. ### Dev rebuild loop friction undermining the "feels like WinterCMS" goal **What goes wrong:** The compiled-plugin model's promised dev-loop ("a watch-rebuild loop... makes it feel like WinterCMS") depends entirely on the scaffolding tooling being built well; if `summer build`'s rebuild is slow (import-list regeneration triggering a full rebuild, not an incremental one) or the watch loop is flaky, AI-agent-driven development (the stated primary user, per `CLAUDE.md`) will be materially slower per iteration than the PHP original's drop-in-and-reload model, and this friction compounds across a 154-route, 25-model port. **Prevention:** Budget explicit time in the kernel phase to measure and optimize rebuild time (Go's incremental build cache should make this sub-second to a few seconds for a single plugin change; verify it, don't assume it), and treat rebuild latency as a phase acceptance criterion, not an afterthought. ## Technical Debt Patterns | Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | |----------|--------------------|-----------------|------------------| | One generic `APIResponse[T]{Data, Meta}` struct for all endpoints | Less boilerplate per handler | Cannot express the OAuth/RFC-root, conditional-key, and per-endpoint envelope variations (Pitfalls 8, 9); becomes a rewrite once the first non-conforming endpoint is ported | Never for the Płytarium port; maybe for a genuinely new endpoint family post-cutover with no PHP parity requirement | | GORM `AutoMigrate` for a new admin-only table with no PHP precedent | Fast to stand up | Schema drift from goose-managed migrations, no down path | Only for a brand-new table that has no PHP original and no down-migration requirement (rare in v1) | | Direct field-for-field struct port from PHP model to Go GORM model, used for both read and write | Fastest initial port | Reopens mass-assignment (Pitfall 3), loses tri-state bools (Pitfall 7), loses nil-vs-empty-array control (Pitfall 4) | Never for models with any write endpoint; borderline acceptable for read-only reference tables (e.g. `Genre`, `Style`) with no user-writable fields | | Trusting `zitadel/oidc`'s default error/response shapes without diffing against RFC 8414/6749 examples in the PHP source | Less code to write for the OAuth phase | Silent divergence from the exact shapes `fonoteka-mcp` and ChatGPT's connector were built against | Never — always diff library defaults against the PHP behavioral reference (`wavepath.org/plugins/golem15/oauthserver` and Płytarium's own `OAuthTokenController`/`OAuthMetadataController`) before trusting them | ## Integration Gotchas | Integration | Common Mistake | Correct Approach | |-------------|------------------|-------------------| | River + GORM (Postgres pool) | Using one client/driver for both transactional enqueue and job execution | `riverdatabasesql` (shared `*sql.DB`) for transactional enqueue; separate `riverpgxv5` client for LISTEN/NOTIFY-based work processing (Pitfall 14) | | Centrifugo | Publishing per-row broadcast events during bulk writes | Suppress-then-emit-once pattern (`withoutBroadcasting` equivalent), exact channel name strings preserved | | Typesense | Serializing search results directly from the index document | Search returns candidate IDs only; SQL re-hydration re-applies authorization scoping (Pitfall 15) | | zitadel/oidc | Trusting default 401/WWW-Authenticate and metadata output verbatim | Diff every OAuth/OIDC response against the PHP `OAuthTokenController`/`OAuthMetadataController` shapes and the MCP client's actual expectations (RFC 9728 `resource_metadata`) before shipping | | MCP server / ChatGPT connector | Treating a 401 as "just a status code" | Header parameters (`resource_metadata`, `scope`, `error`) are a discovery protocol consumed by the client; test against the actual `fonoteka-mcp` proxy, not just curl | | gocloud.dev/blob (file uploads/covers) | Assuming public URL shape is automatically compatible with the PHP app's existing stored URLs | Match the PHP stack's current public URL shape exactly (Płytarium's `relativeMediaUrl`/`getThumb()` conventions) since the Nuxt client and any already-stored URLs in the DB depend on it | ## Performance Traps | Trap | Symptoms | Prevention | When It Breaks | |------|----------|------------|-----------------| | N+1 queries on list endpoints where PHP deliberately used `withCount`/eager-loading | Slow list endpoints under real collection sizes; DB connection pool exhaustion | Preserve the exact `embedsFor($user)`/`EMBEDS` eager-load contract per endpoint via GORM `Preload()`, and replicate the `isset($x->count)`-fallback pattern (eager count on list, live count on single-record) | Noticeable once a household collection reaches a few hundred albums with related artists/styles/photos | | River poll-only mode used for latency-sensitive job pickup | Background jobs (CSV import, digests) appear to "hang" for the poll interval | Use the pgx-based River client for the work-processing side (Pitfall 14) | Any use of poll-only mode for jobs a user is actively waiting on (e.g. CSV import feedback) | | GORM `Update(struct)` silently skipping zero-value fields | A field set to `0`/`""`/`false` never persists; "the update isn't sticking" bug reports | Use `Select("field1","field2")` or a `map[string]interface{}` for updates where a zero value is a legitimate target value (verified against GORM's own docs and GitHub issue #4297) | Any update path touching a nullable-to-zero or boolean-to-false transition — will misbehave from day one, not just at scale | ## Security Mistakes | Mistake | Risk | Prevention | |---------|------|------------| | Treating Typesense results as pre-authorized | Cross-collection/cross-household data leak via stale or mis-scoped search index | SQL re-gate after every search (Pitfall 15) | | `==` string/byte comparison for OAuth client secret or token hashes | Timing side channel reintroduced during the port | `crypto/subtle.ConstantTimeCompare` everywhere PHP used `hash_equals()` | | Reusing GORM models as request-binding structs | Mass assignment of server-owned fields (e.g. `market_price_source`, `collection_id`) | Per-endpoint DTOs + explicit allow-listed field copy (Pitfall 3) | | Blanket envelope/error middleware applied to OAuth/RFC routes | Auth flows silently broken for MCP/ChatGPT clients, not caught by SPA-focused manual testing | Route-group-level exemption for `/oauth/*` and `/.well-known/*` (Pitfall 9) | | Exposing a raw numeric tenant/collection id where the PHP original deliberately used an opaque channel-name string (see `realtime/channels`' own comment about the "opaque-tenancy invariant") | Reopens an IDOR-adjacent concern the PHP code closed on purpose | Read the "why" comments in the PHP source before "simplifying" a response shape — several fields are deliberately obfuscated/omitted for a stated security reason, not by oversight | ## UX Pitfalls | Pitfall | User Impact | Better Approach | |---------|--------------|-------------------| | Bulk-write realtime event flooding (per-row instead of suppress-then-emit-once) | UI flicker, duplicate toasts/notifications during CSV import or bulk album add | Port the exact suppression/batch pattern, not just the underlying write logic | | Date/boolean/array shape mismatches surfacing as client-side exceptions rather than visibly wrong data | Confusing crashes in the Nuxt app or MCP tool calls with no obvious link to "the backend changed" | Parity-harness assertions on exact JSON shape (Pitfalls 4, 6, 7) so these are caught before reaching a user session | | Locked-out users via a mis-ported `423`/must-change-password gate ordering | A user stuck unable to reach the one endpoint (`me/locale`) deliberately excluded from the lock, per the PHP routing comment | Reproduce the exact middleware ordering and route-group boundaries from `routes.php`, including which routes are deliberately outside a given gate | ## "Looks Done But Isn't" Checklist - [ ] **Pagination:** Often missing the exact `meta` key set (`current_page`, `last_page`, `per_page`, `total`, no `links`) — verify against `AlbumApiController::paginated()` byte-for-byte, not against a "reasonable" pager shape. - [ ] **Validation errors:** Often returns a shape close to but not identical to Laravel's `field -> []string` map, especially for nested/array field paths (`tracklist.0.artist`) — verify against real 422 responses recorded from the PHP app, including multi-error and array-field cases. - [ ] **OAuth token/metadata endpoints:** Often "work" for the happy path (Claude/ChatGPT completes login once) but drift on refresh-token rotation, `WWW-Authenticate` header exactness on 401, or the RFC-8414-root-not-enveloped rule — verify against a second, later session (refresh flow) and against the exact metadata document bytes, not just a successful first login. - [ ] **Soft-deleted + unique columns:** Often passes all tests written against fresh data but breaks the first time a user deletes-then-recreates a uniquely-keyed row (a reservation, a wishlist subscription) — verify with an explicit delete-then-recreate test per soft-deletable+unique table. - [ ] **Money/date/array/bool field shapes:** Often "look right" in a browser JSON viewer (which doesn't distinguish `"1.50"` from `1.5`, or `Z` from `+00:00`, at a glance) but fail a byte-level parity diff — verify with an automated diff, not visual inspection. - [ ] **Background jobs:** Often work in manual testing (where poll-interval latency of a second or two goes unnoticed) but reveal the transactional-enqueue-vs-fast-dispatch tradeoff only under real concurrent load or when a job must be visibly "instant" to a user (e.g. after CSV import completes) — verify the River driver split (Pitfall 14) under a timed test, not just "the job eventually ran." ## Recovery Strategies | Pitfall | Recovery Cost | Recovery Steps | |---------|-----------------|------------------| | JSON shape drift caught late (post-cutover) by a client bug report | MEDIUM | Add the specific field to the parity harness's assertion set, fix the serializer, re-run the harness against all 154 routes to catch siblings of the same bug (e.g. if one nullable date is wrong, audit all nullable dates at once) | | Missing pivot/extra columns discovered after data has been written without them | MEDIUM–HIGH | Backfill `sort_order` (or equivalent) from any still-available PHP-side source (export before cutover, or infer from creation order) before the join-table modeling fix ships, since the data itself — not just the code — is now wrong | | Unique-constraint-vs-soft-delete bug causing failed writes in production | LOW–MEDIUM | Add the partial unique index via a new migration; existing data is usually unaffected since the bug manifests as a write failure, not corrupted data | | Kernel/plugin infrastructure over-built before an app pulls requirements (recurrence of the Scala failure mode) | HIGH | Stop, freeze the abstraction, and force a real vertical slice (one model/route/job) through it before touching the kernel further — the same recovery `why-go-not-scala.md` implies was never actually attempted in the Scala effort | | OAuth/MCP client silently failing to auto-discover auth after a header/metadata regression | LOW–MEDIUM | Since the header/metadata contract is small and centralizable (Pitfall 10), a single shared helper fix resolves it everywhere at once — but verify against `fonoteka-mcp`'s actual client, since a spec-compliant-looking fix can still not match what that specific client parses | ## Pitfall-to-Phase Mapping | Pitfall | Prevention Phase | Verification | |---------|-------------------|----------------| | Bottom-up kernel over-building (Pitfall 1) | Kernel phase (interleaved with first data/route slice) | A named phase checkpoint: "has at least one Płytarium model/route/job exercised the kernel yet?" before kernel work continues | | Global state instead of context.Context (Pitfall 2) | Kernel phase + HTTP/auth phase | `go test -race` in CI from phase 1 onward; a concurrency test simulating two users' requests interleaved | | Mass-assignment via reused structs (Pitfall 3) | Data layer + API-port phase | Code review checklist item: every write endpoint has a distinct request DTO; a fuzz/property test posting extra unknown fields and asserting they're rejected/ignored, not persisted | | Nil slice vs `[]` (Pitfall 4) | API-port phase | Parity harness asserts exact JSON, including `null` vs `[]`, for every jsonable/array field across all 25 models | | Float vs fixed-decimal string (Pitfall 5) | Data layer + API-port phase | Unit test round-tripping the exact PHP ceiling/ blank-string cases; parity harness diffs the money field as a raw JSON token type (string vs number) | | ISO8601 offset / null-vs-omitted dates (Pitfall 6) | API-port phase | Shared time-marshaling helper with a unit test against Carbon's exact output format; parity harness includes timestamp fields in its diff | | Tri-state bool (Pitfall 7) | API-port phase | Field-by-field audit of `SerializesFonoteka.php`-equivalent methods for ternaries with a null branch; parity harness diffs `null`/`true`/`false` states | | Envelope/error/conditional-key drift (Pitfall 8) | API-port phase, caught by parity harness | Parity harness diffs full response bodies (not just status/rough shape) against recorded real Nuxt/MCP requests, per `CLAUDE.md`'s acceptance rule | | Blanket middleware corrupting OAuth/RFC endpoints (Pitfall 9) | HTTP/auth phase (routing structure) + OAuth phase | A route-group-isolation test: OAuth/metadata routes have no house-envelope middleware attached, checked structurally (route registration inspection), not just by example response | | WWW-Authenticate/PRM header exactness (Pitfall 10) | OAuth phase | Integration test against the actual `fonoteka-mcp` proxy's discovery flow, not just a unit test on the Go handler | | Soft-delete + unique constraint interaction (Pitfall 11) | Data layer | Per-table delete-then-recreate test for every soft-deletable table with a unique constraint | | Many-to-many pivot columns dropped (Pitfall 12) | Data layer | Round-trip test asserting stored ordering for multi-row pivot associations (3+ artists on one album) | | AutoMigrate as schema source of truth (Pitfall 13) | Data layer | CI check that no `AutoMigrate` call exists outside test-only code paths; all 27 migrations exist as goose files with tested down migrations | | River/GORM pool conflation (Pitfall 14) | Background/integrations phase (jobs) | A timed test asserting job pickup latency matches the LISTEN/NOTIFY path, not the poll interval, for the work-processing client | | Typesense trusted as pre-authorized (Pitfall 15) | API-port phase (search) | A security test: inject a stale/mis-scoped search-index document and assert the SQL re-gate still excludes it from an unauthorized user's results | | Init-order plugin registration nondeterminism | Kernel phase | Test registering plugins in reordered input and asserting identical resulting state | | Rate-limit bucket parity | HTTP/auth phase + API-port phase | Enumerate every PHP `RateLimiter::for()` bucket and assert a matching Go bucket with the same key strategy and limit | | Centrifugo suppress/batch/channel-name contract | Background/integrations phase + API-port phase | Integration test against a real (or recorded) Centrifugo session asserting exactly one event per bulk operation and exact channel name strings | | snake_case key enforcement | API-port phase | Reflection-based test walking all response types asserting snake_case JSON tags; OpenAPI generator config test | | Constant-time secret comparison | OAuth phase | Static check / lint rule flagging non-`subtle` comparisons of any value named `secret`/`hash`/`token` in the OAuth package | | json/v2 strictness vs legacy data | Data layer (migration/import) | A one-time data-validation pass over existing JSON/JSONB columns before cutover, with a documented decision on failure handling | | Dev rebuild loop friction | Kernel phase | Measured rebuild-time acceptance criterion (e.g., "under N seconds for a single-plugin change") as a phase exit condition | ## Sources - `.planning/PROJECT.md`, `.planning/notes/why-go-not-scala.md`, `.planning/notes/v1-target-plytarium.md`, `.planning/research/go-ecosystem.md`, `CLAUDE.md` (project-internal, HIGH confidence — decisions and constraints as recorded by the project itself) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` — route groups, middleware ordering, rate-limit bucket definitions (HIGH confidence, read directly) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php` — pagination envelope, validation error envelope, duplicate-detection and broadcast-suppression patterns (HIGH confidence, read directly) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthTokenController.php`, `OAuthMetadataController.php` — RFC 6749/8414 response shapes, client authentication, constant-time secret comparison (HIGH confidence, read directly) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php` — casts, fillable/guarded, jsonable defaults, soft delete, pivot columns (HIGH confidence, read directly) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php` — the full serialization contract: conditional key inclusion, tri-state booleans, ISO8601 dates, empty-array defaults (HIGH confidence, read directly) - `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts` — 401/WWW-Authenticate/PRM discovery construction as consumed by the MCP client (HIGH confidence, read directly) - River docs (`riverqueue.com/docs/gorm`) and GitHub discussion/issue threads on River+GORM pool sharing (MEDIUM-HIGH confidence, official docs + verified via web search 2026-09-16) - GORM official docs (`gorm.io/docs/update.html`) and GitHub issue #4297 on zero-value update skipping (HIGH confidence, official docs) - modelcontextprotocol.io authorization spec and RFC 9728 discussion threads on Protected Resource Metadata / WWW-Authenticate discovery (MEDIUM-HIGH confidence, verified via web search 2026-09-16) - Caddy/xcaddy compiled-plugin precedent as discussed in `go-ecosystem.md` (MEDIUM confidence, carried from prior research pass) --- *Pitfalls research for: WinterCMS/Laravel-shaped CMF rewrite in Go, PHP-to-Go API parity port* *Researched: 2026-09-16*