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

496 lines
41 KiB
Markdown

# Architecture Research
**Phase 1 correction (2026-09-16):** The canonical Go module is `git.golem15.com/golem15/summercms`; the framework tool lives in this repository, while generated app `main.go` and `plugins.gen.go` live in each app. Phase 1 CONTEXT.md D-01, D-02 and D-04 supersede older `github.com/golem15/summercms` and app-owned `cmd/summer` sketches below.
**Domain:** Go CMF (WinterCMS/Laravel-shaped), headless-first, compiled-plugin model, v1 = Płytarium port
**Researched:** 2026-09-16
**Confidence:** HIGH for plugin-registration mechanics (verified against Caddy/xcaddy and PocketBase current docs) and for request-lifecycle mapping (grounded directly in the real `fonoteka` `Plugin.php`/`routes.php`); MEDIUM for the cross-plugin schema-extension pattern and the framework/app repo split (design recommendation, not yet validated by a build)
## Standard Architecture
### System Overview
```
┌───────────────────────────────────────────────────────────────────────────┐
│ APPLICATION REPO (e.g. fonoteka.go) │
│ ┌─────────────────────────────┐ ┌────────────────────────────────┐ │
│ │ plugins/ (go.work members) │ │ cmd/summer (app binary entry) │ │
│ │ fonoteka/ (domain plugin) │ │ plugins.gen.go (blank imports) │ │
│ │ user/ (stack plugin) │ │ main.go -> backpack.Boot() │ │
│ │ translate/, websockets/, │ └────────────────────────────────┘ │
│ │ feedback/, sitemap/, ... │ │
│ └──────────────┬───────────────┘ │
├─────────────────┼───────────────────────────────────────────────────────┤
│ ▼ FRAMEWORK CORE (this repo, one Go module) │
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐ │
│ │ party │ │ backpack│ │ festival │ │ compass │ │ bonfire │ kernel │
│ │ (plugin │ │ (app │ │ (event │ │ (config)│ │ (CLI) │ layer │
│ │ registry│ │ container)│ bus) │ │ │ │ │ │
│ └────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │ │
├───────┴───────────┴───────────┴────────────┴───────────┴─────────────────┤
│ INFRASTRUCTURE ADAPTERS (framework core) │
│ ┌────────┐ ┌─────────┐ ┌───────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │
│ │ surf │ │ lagoon │ │ bouncer │ │ cooler │ │ conga │ │ postcard │ │
│ │ (HTTP/ │ │ (GORM/ │ │ (JWT/ │ │ (cache)│ │ (River │ │ (mail) │ │
│ │ router)│ │ Postgres│ │ session/ │ │ │ │ jobs) │ │ │ │
│ │ │ │ /migrate│ │ OAuth2) │ │ │ │ │ │ │ │
│ └────────┘ └─────────┘ └───────────┘ └────────┘ └────────┘ └──────────┘ │
│ ┌───────────┐ ┌───────────┐ ┌────────┐ ┌────────┐ │
│ │ lifeguard │ │ sandcastle│ │phrasebk│ │ sunset │ pact = shared │
│ │ (validate)│ │ (blob) │ │ (i18n) │ │ (view, │ interfaces, imported │
│ │ │ │ │ │ │ │ stub) │ by every layer, zero │
│ └───────────┘ └───────────┘ └────────┘ └────────┘ deps on the others │
├───────────────────────────────────────────────────────────────────────────┤
│ EXTERNAL SERVICES │
│ Postgres · Centrifugo (WS) · Typesense · Discogs/Anthropic/OpenAI APIs │
└───────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ REST (JWT / token / OAuth2 bearer) │ WS (client-side)
vue-fonoteka-app (Nuxt 4) fonoteka-mcp (MCP server)
```
Key structural decision: **the framework core is one Go module; plugins are `go.work` workspace members, each its own module.** This is a deliberate split of the "single module vs multi-module workspace" question, not an either/or:
- `backpack`, `pact`, `towel`, `compass`, `festival`, `surf`, `lagoon`, `bouncer`, `lifeguard`, `cooler`, `conga`, `postcard`, `bonfire`, `sandcastle`, `phrasebook`, `sunset`, `party` ship together, version together, and are released as one thing — internal packages in one module is correct here (no plugin ever needs a different version of `lagoon` than another plugin in the same binary; a shared module guarantees that).
- Plugins (`fonoteka`, `user`, `translate`, `websockets`, ...) are independently ownable, independently testable, and — per the already-recorded decision in `why-go-not-scala.md` — a `go.work` workspace of separate modules, mirroring Caddy/xcaddy where each plugin module registers itself via a blank import and `init()`. `go.work`'s `use` directive resolves local plugin modules without `replace` hacks or tagged releases during development; a plugin gets a real semver tag only once it needs to be shared across apps (see Integration Points).
### Component Responsibilities
| Component | Responsibility | Typical Implementation |
|-----------|-----------------|-------------------------|
| `pact` | Interfaces shared across packages to avoid import cycles (`Plugin`, `Authenticator`, `Searchable`, locale/user contracts) | Pure interfaces, zero deps, imported by everything |
| `towel` | String/slice/map helpers, small stdlib gap-fillers (Illuminate\Support equivalent) | Generic functions, stdlib only |
| `compass` | Config discovery, env layering, plugin config namespaces | Ported design from `summer-compass` (HOCON→koanf), unchanged layering semantics |
| `festival` | Typed in-process event bus; "filter" events that plugins fill in additively | ~200 LOC hand-rolled generic bus (no library dominates this niche per ecosystem research) |
| `backpack` | App container: boot lifecycle (Register→Boot, mirroring `PluginBase`), type-keyed service binding/resolution | `App` struct + `Bind[T any]`/`Resolve[T any]` generics helpers |
| `party` | Plugin descriptor contract, registry, dependency-ordered boot/migrate, generated import list, `summer build`/`make:plugin` | Caddy/xcaddy-style: `init()` self-registration + generated blank-import file |
| `bonfire` | Console command framework, scaffolding, rich output | Ported design from `summer-bonfire`; cobra or hand-rolled per that doc's "no external TUI framework" stance |
| `surf` | net/http ServeMux wrapper, middleware chain, route groups | stdlib `ServeMux` (1.22+ patterns) + typed middleware stack |
| `lagoon` | GORM models, Postgres, per-plugin migrations (goose), pagination | GORM + goose, `AutoMigrate`-free (explicit migrations only, for the "drop last migration and fix it" workflow) |
| `bouncer` | JWT (SPA), personal API tokens, OAuth2/OIDC (MCP/ChatGPT), backend admin sessions | `golang-jwt/jwt`, `zitadel/oidc`, `alexedwards/scs` |
| `lifeguard` | YAML `rules:` → runtime validation with translated messages | `go-playground/validator`'s `Var()` (per-field runtime rules, not struct tags — see Patterns) |
| `cooler` | In-process cache, rate-limit buckets | `otter` |
| `conga` | Queue/jobs, transactional enqueue | `riverqueue/river` (Postgres) |
| `postcard` | Mail sending, template layouts | `wneessen/go-mail` + `html/template` |
| `sandcastle` | File storage, cover images | `gocloud.dev/blob` |
| `phrasebook` | i18n, CLDR plurals, namespaced keys | Ported design from `summer-phrasebook` |
| `sunset` | View/templating — minimal in v1 (headless) | `html/template` stub only; no theme engine work in v1 |
## Recommended Project Structure
```
summercms.go/ # THIS repo — framework core, one Go module
├── go.mod # module github.com/golem15/summercms
├── pact/ # interfaces only
├── towel/
├── compass/
├── festival/
├── backpack/
├── party/ # plugin descriptor + registry + build tooling
│ ├── plugin.go # Plugin, HasModels, HasRoutes, ... capability interfaces
│ ├── registry.go # Register(), topological Requires() sort
│ └── build/ # `summer build`/`make:plugin` codegen (invoked by bonfire commands)
├── bonfire/
├── surf/
├── lagoon/
├── bouncer/
├── lifeguard/
├── cooler/
├── conga/
├── postcard/
├── sandcastle/
├── phrasebook/
├── sunset/
└── cmd/summer/ # the `summer` CLI binary itself (make:plugin, build, migrate, serve)
fonoteka.go/ # SEPARATE repo — the Płytarium application
├── go.work # use ./plugins/fonoteka, ./plugins/user, ./plugins/translate, ...
├── go.mod # requires github.com/golem15/summercms
├── config/ # compass base + env overlays, per-plugin namespaces
├── plugins/
│ ├── fonoteka/ # domain plugin — its own go.mod (workspace member)
│ │ ├── go.mod
│ │ ├── plugin.go # descriptor: ID/Requires/Register/Boot + capability methods
│ │ ├── models/ # Album, Artist, Collection, ... (25 GORM structs)
│ │ ├── migrations/ # 27 goose migrations, embed.FS
│ │ ├── routes.go # surf.Router registration (mirrors routes.php groups exactly)
│ │ ├── admin/ # 5 AdminController impls + fields.yaml/columns.yaml
│ │ ├── console/ # ReindexAlbums, IssueOAuthClient, PruneNotifications
│ │ ├── jobs/ # CsvImportWorker, WishlistDigestWorker (conga.JobWorker)
│ │ └── lang/ # pl/en phrasebook namespaces
│ ├── user/ # ported stack plugin — own go.mod
│ ├── translate/, websockets/, feedback/, sitemap/, apparatus/, golem/ (AI)
│ └── ...
├── cmd/summer/
│ ├── plugins.gen.go # generated blank-import list (regenerated by `summer build`)
│ └── main.go # calls backpack.Boot(app) then bonfire dispatch or surf.Serve()
├── admin-spa/ # Vue 3 + TS, consumes /_admin/api/schema/* + OpenAPI types
└── test/parity/ # API parity harness (see Patterns)
```
### Structure Rationale
- **Framework repo has no knowledge of Płytarium.** No Discogs client, no Centrifugo channel names, no Typesense schema live here — those are app concerns. This is the enforceable version of "core plugins... cannot have breaking changes... unless directly asked" from the user's global rules: the framework's `pact` contracts are the only surface an app plugin may depend on for cross-cutting concerns, so the framework can evolve without touching app code as long as `pact` interfaces hold.
- **`plugins/<name>/go.mod` per plugin, `go.work` at the app root.** Matches the already-recorded decision (`why-go-not-scala.md`): "plugins/ is a workspace of Go modules." `go.work`'s `use` directive means a plugin never needs a `replace` line or a tagged release to be part of the local build — `summer build` just runs `go build` inside the workspace.
- **Stack plugins (user, translate, websockets, ...) start inside the app repo's `plugins/` workspace, not the framework repo.** They are genuinely shared across projects today (per `CLAUDE.md`'s core-plugin constraint) but v1 has exactly one consumer. Promote a plugin to its own repo (mirroring today's PHP golem15 stack-plugin-per-repo + submodule model) only when a second app (keios.eu) needs it — this is the Go-workspace equivalent of `go mod init` + `git subtree split`, a mechanical move because the plugin's import path is already package-qualified.
- **`cmd/summer` lives in the app repo, not the framework repo**, because the generated `plugins.gen.go` import list is inherently app-specific (it lists *this app's* plugin set). The framework repo's own `cmd/summer` is a template/skeleton the app repo's `summer make:plugin` scaffolding is generated from, not a binary anyone runs directly.
## Architectural Patterns
### Pattern 1: Plugin descriptor via one required interface + optional capability interfaces
**What:** WinterCMS's `PluginBase` uses optional methods (`registerNavigation()`, `registerPermissions()`, `registerSettings()`, `registerMailTemplates()`, `registerSchedule()`) that a plugin overrides only if relevant, PHP silently no-ops the rest. Go has no optional-override inheritance, so the port is small capability interfaces checked with a type assertion — exactly PocketBase's and Caddy's own idiom (a module "is" whatever interfaces it satisfies).
**When to use:** Every plugin. This is the core extension mechanism requested by the milestone.
**Trade-offs:** More boilerplate per plugin (must declare which interfaces it satisfies) but fully static — `go vet`/`go build` catch a typo in a route handler signature that PHP would only surface at runtime.
**Example** (directly modeled on the real `golem15.fonoteka` `Plugin.php` read for this research — `$require`, `register()`, `boot()`, `registerConsoleCommand`, `registerNavigation`, `registerPermissions`, `registerSchedule`, `Event::listen`):
```go
package pact
type Plugin interface {
ID() string // "golem15.fonoteka"
Requires() []string // ["golem15.apparatus", "golem15.user"] — party topo-sorts on this
Register(app *backpack.App) error // bind own services; must not call other plugins yet
Boot(app *backpack.App) error // safe to reference other plugins' bound services
}
// Optional capabilities — a plugin implements whichever it needs.
type HasModels interface{ Models() []any } // GORM structs, for migrate/reflect
type HasMigrations interface{ Migrations() fs.FS } // goose files, embed.FS
type HasRoutes interface{ Routes(r *surf.Router) }
type HasCommands interface{ Commands() []bonfire.Command }
type HasJobs interface{ Jobs() []conga.JobWorker }
type HasListeners interface{ Listeners(bus *festival.Bus) }
type HasAdminControls interface{ AdminControllers() []party.AdminController }
type HasNavigation interface{ Navigation() []party.NavItem }
type HasPermissions interface{ Permissions() []party.Permission }
type HasSchedule interface{ Schedule() []party.ScheduledCommand }
type HasMailTemplates interface{ MailTemplates() fs.FS }
type HasConfig interface{ ConfigDir() fs.FS } // compass.AddNamespace
type HasLang interface{ LangDir() fs.FS } // phrasebook namespace
```
```go
// plugins/fonoteka/plugin.go
package fonoteka
type Plugin struct{}
func New() *Plugin { return &Plugin{} }
func (p *Plugin) ID() string { return "golem15.fonoteka" }
func (p *Plugin) Requires() []string { return []string{"golem15.apparatus", "golem15.user"} }
func (p *Plugin) Register(app *backpack.App) error { return nil }
func (p *Plugin) Boot(app *backpack.App) error {
p.registerNotificationListeners(app) // Event::listen('eloquent.created: Album', ...)
p.registerCollectionProvisioning(app) // Event::listen('golem15.user.register', ...)
return nil
}
func (p *Plugin) Models() []any { return []any{&Album{}, &Artist{}, &Collection{}, /* ...25 total */} }
func (p *Plugin) Migrations() fs.FS { return migrationsFS } // //go:embed migrations/*.sql
func (p *Plugin) Routes(r *surf.Router) { registerRoutes(r) }
func (p *Plugin) Commands() []bonfire.Command {
return []bonfire.Command{ReindexAlbums{}, IssueOAuthClient{}, PruneNotifications{}}
}
func (p *Plugin) Jobs() []conga.JobWorker { return []conga.JobWorker{CsvImportWorker{}, WishlistDigestWorker{}} }
func (p *Plugin) Navigation() []party.NavItem { /* mirrors registerNavigation() */ return nav }
func (p *Plugin) Permissions() []party.Permission { /* mirrors registerPermissions() */ return perms }
func (p *Plugin) Schedule() []party.ScheduledCommand {
return []party.ScheduledCommand{{Command: "fonoteka:prune-notifications", Cron: "@daily"}}
}
func init() { party.Register(New()) } // Caddy-style: blank-imported package self-registers
```
### Pattern 2: Generated import list + `summer build` (Caddy/xcaddy model, verified)
**What:** Confirmed against Caddy's own docs and xcaddy's generated `main.go`: each module registers itself in `init()` via `caddy.RegisterModule`, and xcaddy generates a `main.go` with blank imports for `caddycmd`, the standard modules, and any requested plugins, then calls `caddycmd.Main()`. SummerCMS's `party` mirrors this exactly, one file, regenerated on demand:
```go
// cmd/summer/plugins.gen.go — REGENERATED by `summer build`, do not hand-edit
package main
import (
_ "github.com/golem15/fonoteka.go/plugins/fonoteka"
_ "github.com/golem15/fonoteka.go/plugins/user"
_ "github.com/golem15/fonoteka.go/plugins/translate"
_ "github.com/golem15/fonoteka.go/plugins/websockets"
// ...
)
```
`summer make:plugin <name>` scaffolds `plugins/<name>/{go.mod,plugin.go,models/,migrations/,routes.go}`, adds a `use ./plugins/<name>` line to `go.work`, and appends the blank import to `plugins.gen.go`. `summer build` regenerates `plugins.gen.go` from a manifest (or by scanning `plugins/*/plugin.go` for the `party.Register` call) and runs `go build ./cmd/summer`. A dev watch loop (reflex/air-style, restart-on-save) makes iterating on a plugin feel like WinterCMS's drop-in autoload, at the cost of a sub-second rebuild.
**When to use:** Always, for every plugin, first-party or (later) third-party. This is the only supported extension path in v1 — no runtime plugin loading, per the recorded decision.
**Trade-offs:** Requires the Go toolchain at build/deploy time (acceptable — CI already needs it); adding a plugin is a build-time operation, not a runtime one (this is the entire point of the compiled-plugin decision, not a regression).
### Pattern 3: Plugin-extends-plugin without dynamic traits — three concrete mechanisms, not one
WinterCMS's `Model::extend()` and `\Event::listen()` closures let a plugin bolt fillable attributes, relations, and behavior onto another plugin's model at runtime. Go has no runtime monkey-patching. Three separate, concrete techniques replace it, matched to what the extension actually needs to do — and this is a load-bearing distinction, not a single hand-wave:
**3a. Serialization/response extension → typed "filter" events on `festival`.**
This is the port of a pattern already live in the real `fonoteka` `Plugin.php` (`Event::listen('golem15.user.getApiArray', ...)`, additive, `halt=false`, flat-merge). It requires no schema change — only extra keys in an outgoing JSON payload.
```go
package user // owned by the user plugin
type APIArrayEvent struct {
User *User
Extra map[string]any // listeners append; core serializer merges this into the response
}
```
```go
// plugins/fonoteka/plugin.go — Boot()
bus.Listen(func(e *user.APIArrayEvent) {
e.Extra["organisation_id"] = e.User.OrganisationID
e.Extra["must_change_password"] = e.User.MustChangePassword
})
```
The `user` plugin's serializer never imports `fonoteka`; `fonoteka` imports `user`'s exported event type. One-directional dependency, no cycle.
**3b. Lifecycle extension → GORM's own callback registry, not a custom event.**
For "when this other plugin's model is created/updated, do something" (the port of `Event::listen('eloquent.created: '.Album::class, ...)`), GORM already has the right primitive: a plugin registers a named callback directly on the shared `*gorm.DB`, keyed by model type via `db.Statement.Schema`. No modification to the owning model's file.
```go
db.Callback().Create().After("gorm:create").Register("fonoteka:notify_album_created", func(tx *gorm.DB) {
if tx.Statement.Schema.ModelType != reflect.TypeOf(Album{}) { return }
// ... build actor, call NotificationService — same logic as the PHP listener
})
```
This is registered once at boot (typically inside the owning plugin's `Boot()`, alongside `festival` listeners), and is a real GORM feature (its own `Plugin` interface: `Initialize(*gorm.DB) error` plus `db.Callback()`), not an invented abstraction.
**3c. Schema extension (one plugin adding columns to another plugin's table) → migration + companion struct, not struct mutation.**
WinterCMS plugins routinely `Schema::table('users', ...)` from a different plugin's migration to add columns to a shared table. The Go port keeps this real capability but makes the *querying* side explicit and compile-time safe: the extending plugin ships its own migration (ALTER TABLE) and defines its own struct that maps onto the same table, embedding the owning struct for the fields it doesn't add:
```go
// plugins/fonoteka/models/user_org.go — fonoteka's own file, never touches plugins/user/models/user.go
type UserWithOrg struct {
user.User // embeds the owning plugin's columns
OrganisationID string `gorm:"column:organisation_id"`
OrganisationRole string `gorm:"column:organisation_role"`
}
func (UserWithOrg) TableName() string { return "users" }
```
`fonoteka`'s migration adds the columns; `fonoteka`'s own code queries via `UserWithOrg`; `user`'s own code is unaware the columns exist and keeps using its own `User` struct. This is real Go/GORM composition, not hand-waving, and it is exactly what "plugins extend other plugins' models through ordinary Go interfaces and events" reduces to once you need actual new columns rather than just new response keys.
**Explicit non-goal:** arbitrary runtime field injection into another plugin's *existing* struct is out of scope — Go structs are fixed at compile time. Anything needing that dynamism belongs behind the WASM extension seam later (`.planning/seeds/wasm-extension-api.md`), not the compiled plugin API.
### Pattern 4: Rule-string validation without struct tags
**What:** WinterCMS's `fields.yaml`/`columns.yaml` `rules:` block is a YAML-authored, per-field pipe string (`required|max:255`) resolved at runtime, not at compile time — so it cannot become a Go struct tag (struct tags are fixed at compile time; these rules are data). `go-playground/validator`'s `validator.Var(value, tag)` — not `validator.Struct` — is the correct primitive: `lifeguard` parses each YAML rule string once into a validator tag string, and calls `Var()` per field at request time, translating validator's field/tag errors through `phrasebook` for the message.
**When to use:** Every admin-form save and every plugin-defined YAML validation block.
**Trade-offs:** Slightly slower than compile-time struct validation (reflection-light, not reflection-free) but is the only approach that keeps `fields.yaml` authorable without a rebuild — which is the entire reason WinterCMS put rules in YAML in the first place.
## Data Flow
### Request Flow — SPA-authenticated API call (the 154 ported routes)
This is a direct structural port of the real `routes.php` middleware groups read for this research (`jwt.auth`+`bindings`, `inv.scope:*`, the public/onboarding groups, the OAuth groups):
```
Nuxt app request
│
▼
surf: recover/panic guard → request-id/logging → CORS
│
▼
surf: locale middleware (Accept-Language / user.preferred_locale → phrasebook.SetLocale on ctx)
│
▼
bouncer: route-group-specific auth
├─ JWT group (/_fonoteka/api/v1/*) → decode+verify JWT, set User in ctx
├─ token group (/api/v1/fonoteka/*) → resolve inv-token, then inv.scope:<read|write|ai>
├─ OAuth2 group (/oauth/mcp/*) → zitadel/oidc RS validation, or unauthenticated token/register/metadata endpoints
└─ public/onboarding groups → no auth, IP-throttled (cooler-backed limiter)
│
▼
surf: must-change-password gate (if JWT group) → org/collection context resolver
(sets ActiveCollection into ctx via an unexported context-key type — never a string key)
│
▼
surf: per-route rate limiter (cooler token buckets — mirrors the named RateLimiter::for() buckets)
│
▼
plugin route handler (registered by fonoteka.Routes(r))
│
├─→ lagoon (GORM query/save) ──→ Postgres
├─→ festival (fire event; other plugins' Listeners react — Pattern 3a/3b)
├─→ conga (enqueue job, same DB transaction as the write — River's transactional enqueue)
└─→ sandcastle / cooler / postcard as needed
│
▼
JSON response (encoding/json, byte-shape-compatible with the PHP response — parity is the acceptance test)
```
### Job Flow
```
HTTP handler (e.g. CSV import POST)
→ conga.Enqueue(tx, CsvImportJob{...}) // same DB transaction as the parent write
→ tx.Commit() // job only becomes visible to workers on commit
River worker pool (in-process goroutines or a separate `summer work` process)
→ picks up job → runs plugin-registered JobWorker.Work(ctx, job)
→ festival event fired on completion/failure
→ postcard (digest email) / websocket publisher (Centrifugo) as side effects
```
### Migration Flow
```
`summer migrate` (bonfire command)
→ party.Registry resolves plugin boot/migrate order via Requires() (topological sort)
→ for each plugin, in order:
lagoon.Migrator runs goose against a PER-PLUGIN version table
(goose -table golem15_fonoteka_goose_version, not the shared default)
→ cross-plugin ALTER-table migrations (Pattern 3c) execute after the
altered table's owning plugin has already migrated — guaranteed by
the same Requires() order used for Boot()
`summer migrate:rollback --plugin=fonoteka` targets one plugin's own table
(ports the "drop the last migration and fix it" workflow named in PROJECT.md)
```
### Admin Form Save Flow
```
Admin SPA: GET /_admin/api/schema/albums/fields
→ party parses fields.yaml (embedded via AdminController.FieldsYAML()) into typed FieldSchema
→ served as JSON (field name, type ∈ {text,textarea,number,switch,dropdown,relation,repeater,fileupload,datepicker}, rules, label key)
Admin SPA renders form generically (PocketBase-style: the SPA does not need a
compile-time TS type for the schema itself — only for the underlying Album
API shape, which IS OpenAPI-generated, per PROJECT.md)
User submits → PUT /_admin/api/albums/{id}
→ lifeguard.Validate(fields, payload) — per-field validator.Var() runtime calls (Pattern 4)
→ on success: lagoon Save() → GORM callbacks fire (Pattern 3b) → festival event (Pattern 3a)
→ response
```
## Scaling Considerations
| Scale | Architecture Adjustments |
|-------|---------------------------|
| 0-1k users (household-scale reality of Płytarium) | Single `summer` binary, single Postgres, River workers as goroutines inside the same process. This is the permanent steady state for v1's actual target. |
| 1k-100k users (second app, e.g. keios.eu, or a future SaaS framing) | JWT/token auth is fully stateless — horizontal scale-out of the API binary behind a load balancer needs no sticky sessions. Only the **backend admin cookie session** (bouncer/scs) needs a shared store (Postgres-backed scs, not in-memory) once more than one instance runs. River workers can be split into a dedicated `summer work` process pool separate from the HTTP-serving process. |
| 100k+ users | Out of scope for the stated domain (a personal/household record collection tool) — noted only so the design doesn't accidentally block it: Typesense already offloads search, `gocloud.dev/blob` already offloads file storage to object storage, and Postgres read replicas are the natural next lever for `lagoon` if ever needed. |
### Scaling Priorities
1. **First real bottleneck, if any:** the admin cookie session store, the moment a second app instance runs — must move from in-memory `scs` to its Postgres-backed store before scaling out horizontally.
2. **Second:** River worker throughput for CSV import/wishlist digest under real household-scale data is unlikely to matter; not a design concern for v1.
## Anti-Patterns
### Anti-Pattern 1: Treating `backpack`'s container as a service locator everywhere
**What people do:** Resolve everything via `backpack.Resolve[T](app)` inside handlers and jobs, Laravel-facade style, because it's convenient and mirrors WinterCMS.
**Why it's wrong:** Defeats Go's compile-time dependency graph — `go vet`/the compiler can no longer tell you a handler's real dependencies, and tests need a fully-booted container instead of a struct literal.
**Instead:** Use the container only at `Register()`/`Boot()` time to wire concrete dependencies into each plugin's own structs (handlers, workers, admin controllers) via constructor injection; application code holds typed fields, not container references.
### Anti-Pattern 2: Marshaling the full plugin surface over RPC/WASM now
**What people do:** Reach for `hashicorp/go-plugin` or `extism`/`wazero` to get "real" plugin isolation, because it sounds safer.
**Why it's wrong:** Already decided against in `why-go-not-scala.md` and `wasm-extension-api.md` — the plugin surface here (register models, extend other plugins' models via GORM callbacks and events, add form field types, add HTTP routes) is too wide to marshal across a process/sandbox boundary without losing the entire point of Patterns 1-3.
**Instead:** Compiled plugins only, until the compiled API has been stable for a full milestone; then a narrow, explicitly-scoped WASM slot per the seed doc.
### Anti-Pattern 3: String-keyed request context values for org/locale/user
**What people do:** `ctx.Value("orgID")` — easy, and how a lot of quick Go HTTP code gets written.
**Why it's wrong:** Collides across packages, no compile-time safety, easy to typo.
**Instead:** Unexported struct key types per concern (`type orgCtxKey struct{}`), one per `surf`/`bouncer`/`phrasebook` concern, accessor functions (`surf.OrgFromContext(ctx)`) exported instead of the key.
### Anti-Pattern 4: Letting a single shared `goose_db_version` table serialize unrelated plugins' migrations
**What people do:** One migrations table for the whole app, because it's goose's default.
**Why it's wrong:** Forces a strict global migration order across plugins that don't actually depend on each other, and makes "roll back just this plugin's last migration" (the workflow PROJECT.md explicitly asks for) impossible without hand-editing the tracking table.
**Instead:** Per-plugin goose version table (`-table golem15_<plugin>_goose_version`), ordered only by the `Requires()` DAG, not by a single global sequence.
## Integration Points
### External Services
| Service | Integration Pattern | Notes |
|---------|----------------------|-------|
| Postgres | `lagoon` (GORM) + `conga` (River, same DB) | One migration target for v1, per the recorded constraint. |
| Centrifugo | `bouncer`/`festival` publish only; Nuxt client connects directly, unchanged | Framework never becomes a WS server itself — only a publisher + token issuer, per the recorded constraint. |
| Typesense | A thin `lagoon`-adjacent search client, driven by `festival` events on model create/update | Mirrors Scout's observer-driven sync; `Album.disableSearchSyncing()` equivalent gate ported as a settings check. |
| Discogs / Anthropic / OpenAI | Plain `net/http` clients inside the `fonoteka` plugin, never inside the framework core | App-specific; framework core has zero knowledge these exist. |
| ChatGPT connector / MCP | `bouncer`'s `zitadel/oidc` OAuth2/OIDC provider, mirrored 1:1 from the `/oauth/mcp/*` routes read in `routes.php` | RFC 8414 metadata, RFC 7591 DCR, auth code + PKCE, refresh tokens — same shapes, same acceptance test. |
### Internal Boundaries
| Boundary | Communication | Notes |
|----------|----------------|-------|
| Framework core ↔ plugins | Go interfaces (`pact`) only, one direction (plugins import framework, never the reverse) | Enforces the "core plugins can't have breaking changes" rule structurally: the framework doesn't know a specific plugin exists. |
| Plugin ↔ plugin (e.g. `fonoteka` ↔ `user`) | Exported events (`festival`, Pattern 3a), GORM callbacks (Pattern 3b), companion structs over shared tables (Pattern 3c), and `Requires()`-ordered boot | Never a concrete import of another plugin's internal package beyond its exported model/event types — mirrors the real `Plugin.php`'s `class_exists()` guards for optional cross-plugin coupling (e.g. `Golem15\WebSockets\Classes\AuthorizerRegistry`), which Go should port as an interface-typed, nil-checked optional dependency rather than a hard import. |
| Framework repo ↔ application repo | `go.mod` require (versioned once stable) or `go.work` `use ../summercms.go` (local dev) | The framework never imports application code; the application always imports the framework by module path. |
| API parity harness ↔ both backends | Plain HTTP client replaying a recorded fixture corpus | The harness has almost no dependency on SummerCMS internals — it can be built and its fixture corpus assembled starting at t=0, in parallel with all framework work, since it only needs *a* running PHP backend to record against. |
## Framework vs Application Repo Boundary
Płytarium is **not** a plugin inside `summercms.go`. It is a plugin — actually a small workspace of plugins — inside a **separate application repo** (proposed name: `fonoteka.go`, sibling to the existing PHP `fonoteka` repo), which `go.mod`-requires this framework. Three tiers, mirroring WinterCMS's own reality more faithfully than a two-tier split would:
1. **Framework core** (`summercms.go`, this repo) — `backpack` through `party`, `surf` through `sunset`. No app knowledge. Versioned and released on its own.
2. **Shared stack plugins** (`user`, and eventually `blog`/`pages`/`payment` equivalents) — start life inside the app repo's `plugins/` workspace for v1 velocity (only one consumer exists), get extracted to their own repos the moment a second app (keios.eu) needs one, exactly as `../IDEA_LIB_NAMES.md`'s "Porting Illuminate module by module... the target app pulls what is needed" already implies. Extraction is mechanical: the plugin's own `go.mod` already exists as a workspace member; promoting it is a repo split, not a rewrite.
3. **App-specific domain plugin** (`fonoteka`) — always lives in the app repo. Never shared, never promoted.
This gives the roadmap a clean phase boundary: framework-core phases produce `summercms.go` releases; app phases produce `fonoteka.go` commits against a pinned (or `go.work`-local) framework version. The two repos can be developed by the same people without merging their concerns.
## First Vertical Slice
The smallest binary that proves the whole stack end-to-end against a real Płytarium endpoint: **`GET /_fonoteka/api/v1/genres`** (or `styles` — either is equally minimal: no relations beyond a simple has-many from Album, no jobs, no search, no storage, no OAuth). It still forces every layer to exist in its simplest form:
1. `compass` boots config (DB DSN, `SUMMER_ENV`).
2. `backpack` boots the app container.
3. `lagoon` connects to Postgres, runs one goose migration (`create_genres`).
4. `party` registers a minimal `fonoteka` plugin descriptor with one model (`Genre`) and one route.
5. `surf` serves the route through the real middleware chain, including...
6. `bouncer`'s JWT middleware (decode + verify a real token, minted by a throwaway `summer` command against a seeded test user) — even the simplest authed route in `routes.php` sits behind `jwt.auth`, so skipping this would not be a faithful slice.
7. Response JSON is diffed, byte-for-byte on shape, against the same request recorded from the live PHP backend — the first fixture in the API parity harness.
Everything past this (the other 24 models, 153 routes, jobs, realtime, search, OAuth2, admin SPA) is the same pattern repeated and parallelized; this slice is the one phase that cannot be skipped or parallelized, because every later phase depends on all seven of its pieces existing and working together.
## Suggested Build Order
```
Layer 0 (parallel, no deps): pact, towel
Layer 1 (parallel, needs L0 only): compass, phrasebook, bonfire ← bonfire has zero inter-module deps per its own doc
Layer 2: festival (needs pact)
Layer 3: backpack (needs compass, festival, pact)
Layer 4: party (needs backpack, pact, festival, bonfire)
Layer 5 (parallel, needs L0-4): lagoon, surf ← neither depends on the other
Layer 6: bouncer (needs lagoon for user storage, surf for middleware shape)
── FIRST VERTICAL SLICE checkpoint here ──
Layer 7 (parallel, independent adapters, needs L0-4 only):
lifeguard, cooler, conga (needs lagoon for Postgres), postcard, sandcastle
Layer 8 (parallel, independent of each other, needs L6-7):
admin schema pipeline (fields.yaml→JSON, party+lagoon+lifeguard+surf)
API parity harness (near-zero summer-* deps — can actually start at t=0,
fixture recording against the live PHP backend needs no Go work at all)
bulk model/route porting (24 remaining models, 153 remaining routes,
parallelizable across plugins, gated only by each model's own FK order —
the same DAG the 27 PHP migrations already encode)
Layer 9 (last, lowest risk): sunset (stub only — no theme work needed in v1)
```
`sunset` is deliberately last and thin: v1 is headless, so the only place templating shows up at all is `postcard`'s mail layouts, which `html/template` covers without a separate investment.
## Sources
- Caddy build docs and xcaddy generated-`main.go`/`caddy.RegisterModule` mechanics — verified 2026-09-16: [Extending Caddy](https://caddyserver.com/docs/extending-caddy), [Build from source](https://caddyserver.com/docs/build), [xcaddy DeepWiki](https://deepwiki.com/caddyserver/xcaddy)
- PocketBase hook system (event-driven extension, schema-driven admin) — verified 2026-09-16: [Extend with Go — Event hooks](https://pocketbase.io/docs/go-event-hooks/), [Hook System — DeepWiki](https://deepwiki.com/pocketbase/pocketbase/2.3-hook-system), [Collection schema management — DeepWiki](https://deepwiki.com/pocketbase/pocketbase/9.2-collection-schema-management)
- GORM's own `Callback()`/`Plugin` extension mechanism (training-knowledge, MEDIUM confidence — not independently re-verified this session; validate against current GORM docs in the phase that first uses cross-plugin callbacks)
- `.planning/PROJECT.md`, `.planning/research/go-ecosystem.md`, `.planning/notes/why-go-not-scala.md`, `.planning/notes/v1-target-plytarium.md`, `.planning/seeds/wasm-extension-api.md` (this repo)
- `modules/summer-compass/README.md`, `modules/summer-bonfire/PLAN.md`, `modules/summer-phrasebook/README.md` (design carry-overs, this meta-repo)
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php` and `routes.php` — read in full for this research; the concrete source of the request-lifecycle mapping, the `Event::listen` extension examples, and the plugin descriptor's optional-method shape
---
*Architecture research for: SummerCMS (Go) — framework + Płytarium v1 port*
*Researched: 2026-09-16*