---
phase: 05-data-layer-full-fidelity
plan: 03
type: execute
wave: 3
depends_on: ["05-01", "05-02"]
files_modified:
- summercms.go/lagoon/encrypted.go
- summercms.go/lagoon/encrypted_test.go
- summercms.go/lagoon/keygen.go
- summercms.go/lagoon/keygen_test.go
- summercms.go/lagoon/commands.go
- summercms.go/lagoon/laravel_decrypt.go
- summercms.go/lagoon/laravel_decrypt_test.go
- fonoteka.go/config/app.yaml
- fonoteka.go/plugins/golem15/user/updates/10_organisations.go
- fonoteka.go/plugins/golem15/user/models/organisation.go
- fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go
- fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
- fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go
- fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go
- fonoteka.go/plugins/golem15/fonoteka/models/oauth_refresh_token.go
- fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go
- fonoteka.go/plugins/golem15/fonoteka/models/org_ai_credential.go
- fonoteka.go/plugins/golem15/fonoteka/models/user_discogs_credential.go
- fonoteka.go/plugins/golem15/fonoteka/models/org_discogs_credential.go
autonomous: true
requirements: [DATA-07, DATA-09]
must_haves:
truths:
- "lagoon.Encrypted round-trips AES-256-GCM ciphertext with a column key HKDF-derived from app.key under a fixed label using the Go 1.27 standard library's crypto/hkdf (no new dependency); a missing, short, or undecodable app.key fails boot with a named actionable error and no default (D-10, D-11)"
- "Ciphertext is versioned (format/key-id prefix + nonce + ciphertext+tag), and app.previous_keys is a decrypt-only fallback list, mirroring Laravel's APP_PREVIOUS_KEYS (D-12)"
- "lagoon.Encrypted.MarshalJSON/.String()/.GoString() always emit a redaction; plaintext is reachable only through an explicit .Reveal() call, greppable across the codebase (D-13)"
- "summer key:generate prints a fresh 32-byte base64 key and performs no other side effect (D-11)"
- "A standalone helper decrypts a Laravel AES-256-CBC+HMAC 'encrypted' payload for the future cutover import, and is never called from lagoon.Encrypted's live Scan/Value path (D-10)"
- "golem15_user_organisations (structure only) is created by the user plugin's own migration, not fonoteka's; Phase 5 ships no widen_users migration at all — a deliberate deviation from D-03's literal wording, confirmed by the user at plan time"
- "api_tokens, the 3 OAuth tables, and the 4 credential tables exist with token_hash/code_hash/client_secret_hash columns Hidden and api_key/token columns typed lagoon.Encrypted and Hidden (DATA-07, DATA-09)"
artifacts:
- path: summercms.go/lagoon/encrypted.go
provides: "Encrypted Scanner/Valuer + Reveal() + versioned AES-256-GCM format"
- path: summercms.go/lagoon/keygen.go
provides: "key:generate bonfire.Command"
- path: fonoteka.go/plugins/golem15/user/updates/10_organisations.go
provides: "create_organisations (structure only)"
- path: fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go
provides: "create_api_tokens, create_oauth_tables, create_credentials_tables"
key_links:
- from: fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go
to: summercms.go/lagoon/encrypted.go
via: "APIKey lagoon.Encrypted `gorm:\"column:api_key\"`"
pattern: "lagoon\\.Encrypted"
- from: summercms.go/lagoon/encrypted.go
to: fonoteka.go/config/app.yaml
via: "app.key read at column-key derivation time, fails boot if empty/short"
pattern: "app\\.key|SUMMER_APP__KEY"
---
Ship the encrypted-at-rest cast and the tables/models that need it: the AES-256-GCM `lagoon.Encrypted` type with HKDF key derivation (Go 1.27 standard library `crypto/hkdf`, no new dependency) and versioned ciphertext (D-10..D-13), the `summer key:generate` command, a standalone (unwired) Laravel-payload decrypt helper for the future cutover import, `golem15_user_organisations` (structure only, in the user plugin per the user's confirmed deviation from D-03's wording), and the fonoteka `api_tokens`/OAuth/credential tables and models.
Purpose: this is the phase's other named security-load-bearing surface (mass assignment was Plan 02) — encryption key handling, redaction discipline, and the fact that Organisations land in the *user* plugin's migration set even though only Fonoteka's credential tables reference them this phase.
Output: `lagoon.Encrypted`, `lagoon.KeyGenerateCommand`, a Laravel-decrypt helper; `golem15_user_organisations`; `ApiToken`, `OAuthClient`, `OAuthAuthCode`, `OAuthRefreshToken`, `UserAiCredential`, `OrgAiCredential`, `UserDiscogsCredential`, `OrgDiscogsCredential` models and their 3 migrations.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md
@.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md
@.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md
From summercms.go/lagoon/fill.go (Plan 05-01):
```go
type HasHidden interface { Hidden() []string }
```
From fonoteka.go/plugins/golem15/fonoteka/models/registry.go and updates/registry.go, and the equivalent in plugins/golem15/user (Plan 05-01):
```go
func Register(models ...any) // package models
func All() []any // package models
func Register(ms ...*gormigrate.Migration) // package updates
func All() []*gormigrate.Migration // package updates
```
From summercms.go/bouncer/jwt.go Verify (the fail-loud-on-empty-secret shape to copy for app.key):
```go
func Verify(tokenString, secret string) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", fmt.Errorf("bouncer: jwt secret is empty")
}
...
}
```
From summercms.go/lagoon/commands.go RuntimeCommands (the command-trio shape key:generate joins):
```go
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
return []bonfire.Command{ {Name: "migrate", ...}, {Name: "migrate:rollback", ...}, {Name: "migrate:status", ...} }
}
```
From summercms.go/compass/config.go (dot-path config access `app.key`/`app.previous_keys` will use):
```go
func (c *Config) String(path string) string
func (c *Config) Lookup(path string) (any, bool)
```
Task 1 (summercms.go): lagoon.Encrypted — AES-256-GCM, stdlib HKDF key derivation, versioned ciphertext, key:generate, Laravel decrypt-only helper
summercms.go/lagoon/encrypted.go, summercms.go/lagoon/encrypted_test.go, summercms.go/lagoon/keygen.go, summercms.go/lagoon/keygen_test.go, summercms.go/lagoon/commands.go, summercms.go/lagoon/laravel_decrypt.go, summercms.go/lagoon/laravel_decrypt_test.go
summercms.go/bouncer/jwt.go (Verify's fail-loud-on-empty-secret shape, lines 67-70)
summercms.go/lagoon/connection.go (checkLocale's maximally-actionable-error-message shape, lines 129-136)
summercms.go/lagoon/commands.go (RuntimeCommands trio shape to extend)
summercms.go/compass/config.go (String/Lookup dot-path access)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Pattern: Encrypted-at-rest cast (D-10..D-13)" section; Sources list's `laravel.com/docs/11.x/encryption` citation for the CBC+HMAC payload shape)
.planning/research/PITFALLS.md (Security Mistakes section)
- `Encrypted.Scan` then `.Reveal()` round-trips arbitrary plaintext bytes through `Value()`/`Scan()` using a key derived from a fixed test `app.key`.
- Two `Encrypted` values holding the same plaintext produce different ciphertext bytes (fresh nonce per encrypt call).
- `json.Marshal(encryptedValue)`, `encryptedValue.String()`, and `fmt.Sprintf("%#v", encryptedValue)` never contain the plaintext substring.
- A ciphertext produced under key-id 1, then decrypted after `app.previous_keys` gains key-id 1 as a decrypt-only fallback and the primary key rotates to key-id 2, still decrypts correctly.
- Deriving the column key from an empty, too-short (fewer than 32 decoded bytes), or non-base64 `app.key` returns a named, actionable error mentioning `SUMMER_APP__KEY` — never a zero-value/default key.
- `key:generate`'s command output is a valid base64 string that decodes to exactly 32 bytes.
- `DecryptLaravelPayload` correctly decrypts a real Laravel `encrypted` cast payload (base64 JSON `{iv,value,mac}`, AES-256-CBC + HMAC-SHA256 under the raw `APP_KEY` bytes) fixture, and `grep -rn "DecryptLaravelPayload" summercms.go/lagoon/*.go` shows it is called only from its own file and test — never from `encrypted.go`'s `Scan`/`Value`.
In `lagoon/encrypted.go`: implement `type Encrypted struct { plaintext []byte; set bool }` (unexported fields so nothing outside `.Reveal()`/`.Value()`/internal marshal code can read `plaintext` directly) with `func (e *Encrypted) Scan(src any) error` (decode the versioned format below and store plaintext internally, `set=true`), `func (e Encrypted) Value() (driver.Value, error)` (re-encrypt current plaintext under the current primary key, return the versioned ciphertext string), `func (e Encrypted) Reveal() string` (the one explicit, greppable plaintext accessor — return `""` if `!e.set`), `func (e Encrypted) MarshalJSON() ([]byte, error)` returning `json.Marshal("[redacted]")` unconditionally, `func (e Encrypted) String() string` and `func (e Encrypted) GoString() string` both returning a fixed redaction literal (never plaintext, never even a length hint). Column key derivation: `func deriveColumnKey(appKey []byte) ([]byte, error)` using the Go 1.27 **standard library** `crypto/hkdf` package (`hkdf.Key(sha256.New, appKey, salt, info, 32)`, or the `Extract`/`Expand` pair — Go 1.24+ ships HKDF in stdlib, so this introduces **no new dependency**; do not add `golang.org/x/crypto` or run `go get` for this) with SHA-256, a fixed info label string like `"summercms.lagoon.encrypted.v1"`, producing a 32-byte AES-256 key; `appKey` itself is loaded once at first use from `app.key` (base64-decoded, must decode to exactly 32 raw bytes) via a package-level function `LoadAppKey(cfg *compass.Config) ([]byte, []byte, error)` returning `(primaryKey, previousKeysConcat, error)` — reads `app.key` and `app.previous_keys` (a YAML list of base64 strings) from `cfg`, fails with `fmt.Errorf("lagoon: app.key is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)")`-shaped errors on empty/short/undecodable, following `bouncer.Verify`'s and `lagoon.checkLocale`'s fail-loud verbosity exactly. Ciphertext format: a single byte format/key-id version prefix, a 12-byte GCM nonce, then GCM-sealed ciphertext+tag, the whole thing base64-encoded for storage in the `text` column (matches RESEARCH.md's confirmed `text` column type for `api_key`/`token`). `Scan` tries the primary derived key first, then each of `previousKeys` in order, returning a decrypt error only if none match (D-12's decrypt-only fallback). Publish the loaded keys once per app boot via `backpack.App.Publish`, looked up by `Scan`/`Value` — do not re-read config on every row; document the exact wiring call site (`lagoon.PublishEncryptionKeys(app, primaryKey, previousKeys)`, called from wherever `lagoon.OpenFromApp`/`Publish` already runs) in a doc comment.
In `lagoon/keygen.go`: implement `func KeyGenerateCommand() bonfire.Command` returning `bonfire.Command{Name: "key:generate", Description: "Print a fresh 32-byte base64 app.key", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... }}` using `crypto/rand.Read` on a 32-byte buffer, `out.Success(base64.StdEncoding.EncodeToString(key))` — never `math/rand`. Append `KeyGenerateCommand()` to the slice `RuntimeCommands` returns in `lagoon/commands.go` (one new line in the existing slice literal — a framework-owned file no other plan in this phase touches).
In `lagoon/laravel_decrypt.go`: implement `func DecryptLaravelPayload(payloadJSON string, appKey []byte) ([]byte, error)` — base64-JSON-decode `{iv, value, mac}` per Laravel's `encrypted` cast format, verify the HMAC-SHA256 `mac` over `iv+value` using `appKey` (raw bytes — Laravel uses the raw `APP_KEY` directly for both cipher and MAC key, verify against the RESEARCH.md-cited Laravel docs), then AES-256-CBC-decrypt `value` with `iv`, unpadding PKCS#7. Doc-comment it "cutover-import-only; never called from `Encrypted`'s live Scan/Value path" (D-10).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted
- All seven `Encrypted`/`key:generate`/`DecryptLaravelPayload` behaviors above pass as automated tests.
- `grep -rn "DecryptLaravelPayload("` shows zero call sites outside its own file and test.
- `grep -rn "golang.org/x/crypto" summercms.go/lagoon/encrypted.go summercms.go/go.mod` returns no matches (stdlib `crypto/hkdf` only, no new dependency).
- `go vet ./lagoon/...` is clean; `grep -rn "\"math/rand\"" summercms.go/lagoon/keygen.go` returns no matches.
`lagoon.Encrypted`, key derivation/rotation, `key:generate`, and the standalone Laravel decrypt helper exist, are fully tested, redaction is provably unbypassable via marshal/String/GoString, and HKDF derivation uses only the Go 1.27 standard library.
Task 2 (fonoteka.go): create_organisations (structure only) in the user plugin — no widen_users migration this phase
fonoteka.go/plugins/golem15/user/updates/10_organisations.go, fonoteka.go/plugins/golem15/user/models/organisation.go, fonoteka.go/config/app.yaml
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v3.2.0/create_organisations_table.php (confirm the exact filename via a directory listing if the version directory differs)
fonoteka.go/plugins/golem15/user/updates/00_base.go (moved by Plan 05-01 — the exact ID-naming and Migrate/Rollback shape to follow)
.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (user_confirmed_decisions #2)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("widen_users finding", "Squashed Migration List" row 2, the anti-pattern note on organisations table ownership)
Create `updates/10_organisations.go` (package `updates`, in `plugins/golem15/user`) with a doc comment stating it folds `updates/v3.2.0/create_organisations_table.php` and explicitly noting: "Phase 5 ships no widen_users migration — deferred to Phase 7's AUTH-02 per 05-CONTEXT.md user-confirmed decision; this migration only creates the FK target `golem15_user_organisations` table that Fonoteka's org credential tables reference." Read the PHP migration file directly for its exact column list; at minimum expect `id SERIAL PRIMARY KEY`, `name TEXT NOT NULL`, `created_at`/`updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. Do not add `organisation_id`/`organisation_role` columns to `users`, and do not add membership/role tables even if the PHP version directory bundles more into the same migration — split them out, porting only the bare table this phase. `var organisationsMigrations = []*gormigrate.Migration{{ID: "_create_organisations", Migrate: ..., Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS golem15_user_organisations").Error }}}`, with `func init() { updates.Register(organisationsMigrations...) }`.
Create `models/organisation.go` (package `models`, in `plugins/golem15/user`) with `type Organisation struct { ID uint; Name string; CreatedAt time.Time; UpdatedAt time.Time }`, `func (Organisation) TableName() string { return "golem15_user_organisations" }`, `func init() { models.Register(Organisation{}) }` — structure only, no `Fillable`/`Rules`/relations beyond the FK target Fonoteka's credential models need (Phase 7 owns roles/membership behavior).
Add an `app.key: ""` entry to `fonoteka.go/config/app.yaml` as a documented placeholder — following `config/database.yaml`'s "Set SUMMER_..." comment-header convention: "# Set SUMMER_APP__KEY to a 32-byte base64 value (summer key:generate). Empty fails boot once any encrypted-cast model runs." Do not set a real value.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./...
- `golem15_user_organisations` is created by a testcontainers-backed migration test in the `user` plugin's own module, not fonoteka's.
- `grep -rn "organisation_id\|organisation_role" fonoteka.go/plugins/golem15/user/updates/10_organisations.go` returns no matches on the `users` table (only the new table's own columns exist).
- `grep -rln "widen_users" fonoteka.go/plugins/golem15/user/updates` returns no matches (no such migration exists).
`golem15_user_organisations` exists as a structure-only table owned by the user plugin's migration set; no widen_users migration ships this phase.
Task 3 (fonoteka.go): secrets-slice migrations and models — api_tokens, OAuth tables, credential tables, all with Hidden hash/secret columns and lagoon.Encrypted credential columns
fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go,
fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, models/oauth_client.go, models/oauth_auth_code.go, models/oauth_refresh_token.go,
fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, models/org_ai_credential.go, models/user_discogs_credential.go, models/org_discogs_credential.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.3/create_fonoteka_api_tokens_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.6/add_area_ids_to_api_tokens.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/create_oauth_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/add_oauth_client_id_to_api_tokens.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.9/add_scope_ceiling_to_oauth_clients.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.4/create_user_ai_credentials_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.5/create_user_discogs_credentials_table.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.3/create_org_credentials_tables.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/UserAiCredential.php, OrgAiCredential.php, UserDiscogsCredential.php, OrgDiscogsCredential.php (exact $casts/$hidden confirmation)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 4, 13, 14, 15, 16, 17, 21, 23; "Pattern: Encrypted-at-rest cast" code example)
Create `updates/11_secrets_slice.go` (package `updates`) with three migrations, IDs continuing Plan 05-02's `updates/10_album_slice.go` date/sequence (Wave 2 already shipped that file; this plan is Wave 3, no shared line edited):
1. `create_api_tokens` — `golem15_fonoteka_api_tokens (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT, token_hash TEXT NOT NULL UNIQUE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, last_used_ip TEXT, oauth_client_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify exact types against the two folded files; `scopes`/`collection_ids` are `text` jsonable columns per RESEARCH.md row 4, not native `jsonb`).
2. `create_oauth_tables` — `golem15_fonoteka_oauth_clients (id SERIAL PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret_hash TEXT NOT NULL, client_name TEXT, redirect_uris TEXT, grant_types TEXT, token_endpoint_auth_method TEXT, registration_ip TEXT, consented_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, scope_ceiling TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_auth_codes (id SERIAL PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, code_hash TEXT NOT NULL UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, redirect_uri TEXT, scopes TEXT, collection_ids TEXT, code_challenge TEXT, code_challenge_method TEXT, resource TEXT, state TEXT, expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_refresh_tokens (id SERIAL PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, api_token_id INTEGER REFERENCES golem15_fonoteka_api_tokens(id) ON DELETE CASCADE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, rotated_to_id INTEGER, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify against both folded files including `scope_ceiling`).
3. `create_credentials_tables` — `golem15_fonoteka_user_ai_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, base_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_ai_credentials` (same shape, `organisation_id INTEGER NOT NULL UNIQUE REFERENCES golem15_user_organisations(id) ON DELETE CASCADE` in place of `user_id`), `golem15_fonoteka_user_discogs_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_discogs_credentials` (same shape with `organisation_id`) — `api_key`/`token` are `text` columns (never `varchar`, confirmed live per RESEARCH.md). This migration depends on `create_organisations` (Task 2) for the FK target; since gormigrate runs each plugin's set independently in `party.Activate` dependency order and `golem15.fonoteka` `Requires()` `golem15.user`, this ordering is already guaranteed — do not add an explicit cross-plugin migration dependency mechanism.
End the file with `func init() { updates.Register(apiTokenMigration, oauthTablesMigration, credentialsTablesMigration) }` (or a single slice var, matching `00_base.go`'s style).
Create the 8 model files (package `models`), each following the exact struct/tag shape from RESEARCH.md's per-model inventory rows and the D-10..D-13 code example: `ApiToken` (row 4: `UserID`, `Name *string`, `TokenHash string` `json:"-"`, `Scopes lagoon.Jsonable[[]string]`, `CollectionIDs lagoon.Jsonable[[]uint]`, `ExpiresAt`/`RevokedAt`/`LastUsedAt *time.Time`, `LastUsedIP *string`, `OAuthClientID *string`; `Hidden()` returns `{"token_hash"}`); `OAuthClient` (row 14, `ClientSecretHash string` `json:"-"`, `Hidden()` `{"client_secret_hash"}`); `OAuthAuthCode` (row 13, `CodeHash string` `json:"-"`, `Hidden()` `{"code_hash"}`); `OAuthRefreshToken` (row 15, `TokenHash string` `json:"-"`, `Hidden()` `{"token_hash"}`); `UserAiCredential`/`OrgAiCredential` (rows 21/16, `APIKey lagoon.Encrypted` `gorm:"column:api_key"` `json:"-"`, `Hidden()` `{"api_key"}`, exactly matching the code example in RESEARCH.md's "Pattern: Encrypted-at-rest cast" section); `UserDiscogsCredential`/`OrgDiscogsCredential` (rows 23/17, `Token lagoon.Encrypted` `gorm:"column:token"` `json:"-"`, `Hidden()` `{"token"}`). Every file's `init()` calls `models.Register(TheType{})`.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run TestMigrateSeedsCanonicalGenres
- A testcontainers-backed migration test migrates `updates.All()` for `golem15.fonoteka` and asserts all 6 new tables exist (`golem15_fonoteka_api_tokens`, the 3 OAuth tables, the 4 credential tables — 8 total across the 3 migrations) and roll each back individually.
- `grep -rn "json:\"-\"" fonoteka.go/plugins/golem15/fonoteka/models/{api_token,oauth_client,oauth_auth_code,oauth_refresh_token,user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows every hash/secret/credential column tagged.
- `grep -rn "lagoon.Encrypted" fonoteka.go/plugins/golem15/fonoteka/models/{user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows exactly one match per file, on the `api_key`/`token` field.
3 migrations create the api_tokens/OAuth/credentials tables; 8 models exist with correct Hidden()/json:"-" and lagoon.Encrypted-typed secret columns, self-registered.
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| app.key config -> Encrypted column key | a misconfigured or absent key must never silently fall back to a weak/default key |
| stored ciphertext -> old key holder | key rotation must not lock out legitimately-encrypted-under-the-old-key rows, nor allow decrypting with a key that was never a real primary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-08 | Information Disclosure | `lagoon.Encrypted` accidental serialization | mitigate | `json:"-"` backstop plus `MarshalJSON`/`String`/`GoString` all hardcoded to redact; `Hidden()` on every credential model names the same column (D-13) |
| T-05-09 | Information Disclosure / Denial of Service | key rotation | mitigate | Versioned ciphertext (key-id prefix) plus `app.previous_keys` decrypt-only fallback prevents both silent data loss and indefinite reuse of a retired key (D-12) |
| T-05-10 | Tampering | `app.key` missing/weak | mitigate | `LoadAppKey` fails boot loudly on empty/short/undecodable input, no default value path exists (D-11) |
| T-05-11 | Tampering (supply chain) | HKDF key derivation for `lagoon.Encrypted` | accept | Uses the Go 1.27 **standard library** `crypto/hkdf` package — no new dependency is introduced (this corrects an earlier draft of this plan that proposed `golang.org/x/crypto/hkdf`; Go 1.24+ ships HKDF in stdlib, so CLAUDE.md's stdlib-first rule applies directly and there is nothing for a package-legitimacy checkpoint to review) |
| T-05-12 | Repudiation | `api_tokens`/`oauth_refresh_tokens` store only `*_hash` columns, never raw secrets | accept | Matches PHP's own design; raw tokens are never persisted anywhere, hashed only, consistent with the `Hidden()` backstop |
`cd summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted` then full `go test ./lagoon/...`; `cd ../fonoteka.go && go vet ./... && go test ./...` (testcontainers Postgres) covering the new user-plugin and fonoteka-plugin migrations and models.
- `lagoon.Encrypted` round-trips, redacts on every marshal path, supports key rotation via `app.previous_keys`, fails boot loudly on a bad `app.key`, and derives its column key using only the Go 1.27 standard library (no new dependency).
- `key:generate` and the standalone Laravel decrypt helper exist and are tested.
- `golem15_user_organisations` exists, owned by the user plugin; no `widen_users` migration ships this phase.
- All 3 secrets-slice migrations and 8 models exist, self-register, and have correct `Hidden()`/`json:"-"`/`lagoon.Encrypted` typing.