Files
summercms/.planning/research/PITFALLS.md
2026-09-16 03:00:12 +02:00

54 KiB
Raw Blame History

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="<PRM URL>", 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