Files
summercms/.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md

21 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, plan_head_before, plan_head_after, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects actuals plan_head_before plan_head_after tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
12-p-ytarium-api-collections-and-albums 02 api
fonoteka
collections
tenancy
personal-token
uploads
attach
share-token
me-context
realtime
parity
tide
phase provides
12-p-ytarium-api-collections-and-albums 12-01: lagoon.ValidateRequest + pl/en catalogs, attach.File.URL/PublicURL, tide multipart parts and upload URL masking, user groups (HasGroupCode)
phase provides
11-jobs-realtime-and-search-infrastructure lighthouse album binding and memory driver, beachcomber after-commit sync, conga worker
classes.Resolve/SwitchTo/DisplayActiveID/ErrCollectionNotFound (token-aware ActiveCollectionResolver port)
classes.AccessibleBy/AlbumsAccessibleBy (membership plus personal-token pin), ProvisionCollection
classes.CollectionDTO/CollectionIndexDTO/PhotoDTO, SerializeCollection/SerializePhoto/LoadCollectionDTO
classes.CollectionKey, gates (IsSiteAdmin, CanManageOrg, AIAllowed, AIInherited, ResolveDiscogsConfig, DiscogsAllowed, MarketCurrency)
classes share service (GenerateShareToken[From], ShareStateFor, Enable/Disable/Rename/RegenerateShare)
23 collections-area routes ported on both auth groups (collections CRUD, photos, image, switch, me/context, realtime/channels, collection/share)
controllers/api helpers decodeInput, phpInt, laravelBoolean, requestScope, writeWinterHTTPError (winter_<status>.html), writeValidationFailed
parity seed hook fonoteka (reseeded per case) and parity/fonoteka_reset.php for the PHP side
12-03
12-04
12-05
13
tokens tasks commits
61230 3 3
02e1aa94612fbfe0940d8424ae7efedb00ea0190 6c729a708f96fe941c4791049b89a9cad321461f
added patterns
One handler value per route, mounted on the JWT group and its token twin with exactly one inv.scope per token route
PHP HttpException answers are embedded Winter production pages (winter_<status>.html) with only the app.url stylesheet origin templated
Parity cases that mutate state are recorded from a fresh PHP reset each and replayed from a fresh Go seed each (per-case reseed)
PHP-side recording state is built by a tinker script that mirrors the Go seed hook and bumps id sequences into 8-digit ranges
created modified
../fonoteka.go/plugins/golem15/fonoteka/classes/access.go
../fonoteka.go/plugins/golem15/fonoteka/classes/collection_provisioner.go
../fonoteka.go/plugins/golem15/fonoteka/classes/fingerprint.go
../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go
../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_share_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_context_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/realtime_channels_controller.go
../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/fonoteka_reset.php
../fonoteka.go/parity/fixtures/routes/files/collection-photo.png
../fonoteka.go/parity/fixtures/routes/files/not-an-image.txt
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
../fonoteka.go/plugins/golem15/fonoteka/classes/backend_album_collection.go
../fonoteka.go/plugins/golem15/fonoteka/models/collection.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go
../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
../fonoteka.go/config/storage.yaml
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/parity_contract_test.go
../fonoteka.go/parity/genres_seed_test.go
../fonoteka.go/parity/user_api_seed_test.go
../fonoteka.go/parity/README.md
../fonoteka.go/README.md
Collection strings are trimmed on save (Collection.BeforeSave) because Winter's Model::setAttribute trims every string attribute; the recordings show ' New Shelf ' stored as 'New Shelf', so the plan's byte-for-byte encoding edge does not hold for this stack
ProvisionCollection and Resolve's fallback carry no kind filter, as PHP; a lower-id wishlist can become the active context (pinned by TestResolveFallbackHasNoKindFilter)
GET collections sorts names with PHP 8 SORT_REGULAR (two numeric names compare as numbers, else bytes), stable over id order; recorded by the ordering case
The backend admin album binding keeps a non-provisioning resolver (backendActiveCollection); only API callers provision
Uploads use the Winter layout (bucket storage/app/uploads/public, prefix /storage/app/uploads/public); the parity test config uses the same prefix
Recordings come from a tinker reset of a canonical state (parity/fonoteka_reset.php) rather than the bootstrap flow, one reset per case; id sequences are lifted to 8 digits so captured ids are scrubbed and never collide
A body over the router cap answers 413 {"error":"Payload too large"} from the handler (the router only wraps the body); not recordable against PHP
requestScope(w, r, app) returns the gorm handle, the user row and the optional personal token for handlers shared by both auth groups
New Winter error statuses are added as winter_<status>.html files next to http_errors.go; no code change
API-01
id description requirement verification human_judgment
D1 Token-aware tenant core: pinned tokens resolve to their single accessible collection, JWT users get the stored or lowest accessible collection under users-then-context row locks, else exactly one provisioned Moja kolekcja API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestResolveProvisionsOnce pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestResolvePinnedToken pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestResolveFallbackHasNoKindFilter pass
false
id description requirement verification human_judgment
D2 GET collections on both groups (pin-only for tokens, owner_name local-part rule, PHP name order) and the token group's one-scope-per-route rule API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionsIndexBothGroups pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go#TestTokenGroupOneScopePerRoute pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (GET collections jwt and personal_token cases) pass
false
id description requirement verification human_judgment
D3 Collections create/show/update/delete, photo and image uploads and deletes, and switch with the Winter 404 page, on both groups where PHP mirrors them; delete removes albums one by one and repairs contexts API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionDeleteRemovesAlbumsOneByOne pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionPhotoUpload pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionSwitchRefusals pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (16 collections routes, multipart replays) pass
false
id description requirement verification human_judgment
D4 me/context flags, realtime/channels channel name and the owner-only collection/share surface with crypto/rand share tokens API-01
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestMeContextFlags pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestRealtimeChannelsName pass
kind ref status
unit ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestShareTokenAlphabet pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestShareOwnerOnly pass
kind ref status
e2e go -C ../fonoteka.go test ./parity -run TestParityCorpus (56 ported and passing); check_corpus.go --require-recorded --check-secrets pass
false
id description requirement verification human_judgment
D5 Photo URLs and avatar URLs use the Winter /storage/app/uploads/public layout (D-22) API-01
kind ref status
integration ../fonoteka.go/parity/avatar_assembled_test.go#TestAvatarAssembled pass
kind ref status
integration ../fonoteka.go/plugins/golem15/fonoteka/collections_smoke_test.go#TestCollectionPhotoUpload pass
false
46min 2026-10-02 complete

Phase 12 Plan 02: Collections, me/context, realtime channel and share link Summary

Token-aware tenant core (pinned tokens, row-locked resolve and provisioning, one access scope) and all 23 collections-area routes ported byte-compatibly on both auth groups, with Winter 404 pages, multipart photo/image uploads under the Winter URL layout, per-album collection deletes, me/context gates and crypto/rand share tokens, replayed from 82 fresh PHP recordings.

Performance

  • Duration: 46 min
  • Started: 2026-10-02T09:51:46Z
  • Completed: 2026-10-02T10:37:30Z
  • Tasks: 3 (tracer verified end to end before expansion)
  • Files modified: 119 in fonoteka.go (code, tests, 75 fixture files, docs)

Accomplishments

  • classes.Resolve ports ActiveCollectionResolver::resolve: a personal token needs exactly one accessible pinned id (else ErrCollectionNotFound, the Winter 404 page); a JWT user gets the stored context while accessible, else the lowest-id accessible collection, else a provisioned Moja kolekcja, under SELECT ... FOR UPDATE on the users row then the context row. Four concurrent first resolves create one collection and one context row.
  • classes.AccessibleBy / AlbumsAccessibleBy are the request-facing access rule: grouped owner-or-editor membership on live rows, plus the token pin (id IN pin OR own wishlist). Genres on the token group now resolve the pin, as PHP does.
  • Collections CRUD, photos, image, switch, me/context, realtime/channels and collection/share are mounted once each; the token group carries exactly one inv.scope per route, so a write-only token updates a collection and is refused on reads with PHP's 403 body (which now carries Cache-Control: no-cache, private).
  • Uploads write Winter-style disk names under storage/app/uploads/public, set sort_order to the id, and answer url and a 200x200 crop thumb_url; photo removal detaches the row as AttachMany::remove does; image upload replaces the previous image.
  • Deleting a collection soft-deletes its albums one by one (each gets its search removal and deleted.fonoteka.album broadcast after commit, none on rollback) and repairs the context of the owner and every editor.
  • me/context returns exactly the ten recorded flags (site admin from the user's admin group, AI org lock and inheritance, Discogs tiers, market currency); realtime/channels returns collection:<id>; the share surface is owner-only with tokens drawn from crypto/rand with rejection at 248.
  • 82 parity cases recorded from the isolated PHP (APP_DEBUG=false); TestParityCorpus reports 56 ported routes passing; check_corpus.go --require-recorded --check-secrets is green.

Task Commits

All commits are in fonoteka.go (summercms.go code is untouched):

  1. Task 1: tenant core and GET collections on both groups (tracer) - 574b2f1 (feat)
  2. Task 2: collection CRUD, photos, image and switch - b41d5ca (feat)
  3. Task 3: me/context, realtime/channels and the share link - 6c729a7 (feat)

Ledger: fonoteka.go 02e1aa9..6c729a7, 3 commits (git rev-list --count).

Files Created/Modified

  • classes/active_collection.go, access.go, collection_provisioner.go: resolver, switch, display id, access scopes, provisioner
  • classes/serialize.go: CollectionDTO/CollectionIndexDTO/PhotoDTO, album counts and media loaders
  • classes/gates.go, share_service.go, fingerprint.go: me/context gates, share tokens and state, collection_key HMAC
  • controllers/api/*: request decoding helpers, Winter error pages, collections, media, share, me/context and channel handlers
  • models/collection.go: per-album BeforeDelete and Winter string trimming in BeforeSave
  • routes.go: JWT routes and token twins with per-route scopes, inline throttles, {id}/{fileId} constraints
  • config/storage.yaml, plugin config/config.yaml, README.md: Winter upload layout and the new golem15.fonoteka.* keys
  • parity/*: fonoteka seed hook (reseeded per case), manifest flips (56 ported), PHP reset script, recording recipe, 75 fixtures

Decisions Made

See key-decisions in the frontmatter.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Winter trims string attributes, so names are not stored byte-for-byte

  • Found during: Task 1 (first PHP recordings)
  • Issue: The plan's encoding truth says names keep leading and trailing spaces. Winter's Model::setAttribute trims every string ($trimStringAttributes = true); the recorded PHP stores " New Shelf " as New Shelf.
  • Fix: Collection.BeforeSave trims name and description with PHP's trim set; share renames trim too. Recorded cases pin it (POST, PUT, share rename).
  • Commit: b41d5ca, 6c729a7

2. [Rule 1 - Bug] PHP's provisioner and resolve fallback have no kind filter

  • Found during: Task 1
  • Issue: The plan said the provisioner reuses the lowest kind=collection collection; CollectionProvisioner::provision uses accessibleByMembership()->orderBy('id')->first() with no kind filter.
  • Fix: Followed PHP; TestResolveFallbackHasNoKindFilter pins the wishlist case, and the recorded owner-delete case shows the repaired context.
  • Commit: 574b2f1

3. [Rule 1 - Bug] Display id for a token with other than one pinned id is null

  • Issue: The plan said such a token falls back to the stored context; activeCollectionIdForDisplay returns null for any token without exactly one id.
  • Fix: Followed PHP (TestCollectionsIndexBothGroups unpinned-token rows have is_active null).
  • Commit: 574b2f1

4. [Rule 1 - Bug] Name order is PHP SORT_REGULAR, not a plain byte compare

  • Issue: sortBy('name') uses asort(SORT_REGULAR): two numeric names compare as numbers ("9" before "10").
  • Fix: phpCompareStrings in a stable sort; the recorded ordering case covers numeric, duplicate, accented and case-differing names.
  • Commit: 574b2f1

5. [Rule 1 - Bug] InvScope refusals lacked Cache-Control: no-cache, private

  • Issue: The recorded PHP 403 carries the header every Laravel response has; the Go middleware omitted it.
  • Fix: middleware/token_scope.go sets it before writing 401/403.
  • Commit: 574b2f1

6. [Rule 3 - Blocking] Shared parity database across routes

  • Issue: Every ported route replays against one Postgres; the fonoteka hook's tokens and alice's organisation broke the tokens, connected-apps and user-api fixtures, and an unpinned token:mcp-read now 404s on the token genres route as PHP would.
  • Fix: The genres hook deletes the fonoteka tokens and serves token:mcp-read from a dedicated pinned reader (alice's "Parity MCP" token stays unpinned for the tokens fixtures; all genre counts are zero either way); the user-api hook resets the organisation fields; fonoteka routes reseed before every case.
  • Commit: 574b2f1

7. [Rule 3 - Blocking] Backend album binding must not provision

  • Issue: ResolveActiveCollection now has PHP resolve semantics (provisioning, no kind filter), but the admin album binding (a Go-only Phase 10 path) relied on the old non-provisioning read.
  • Fix: backendActiveCollection keeps the old behaviour for the backend binding; API callers (token store, OAuth consent) use the full resolver as PHP does.
  • Commit: 574b2f1

8. [Rule 3 - Blocking] Upload part files live under parity/fixtures/routes/files/

  • Issue: The plan named parity/fixtures/files/; tide refuses part paths that leave the fixture's directory (../).
  • Fix: Files sit in parity/fixtures/routes/files/, pinned by sha256.
  • Commit: b41d5ca

9. [Rule 3 - Blocking] Deferred-scope placeholder tests and the unported-route check

  • Issue: TestOAuthTokenSurfaceIsolationCoverage subtests fail by design once token collections, write scopes, switch and share routes exist; assertPortedMismatch used me/context as its unported route.
  • Fix: Replaced each placeholder with the real assertion (401 then read 200 then write-only PUT 200; read-only token 403 on every write route; switch and share JWT-only); the mismatch check now uses GET ai-credential (Phase 14).
  • Commits: 574b2f1, b41d5ca, 6c729a7

10. [Rule 2 - Missing critical] Replaced and deleted image blobs are removed after commit

  • Issue: PHP's image()->delete() leaves the old bytes on disk; the plan asked for after-commit deletion.
  • Fix: Rows are deleted in the request, blobs after the write succeeds; not observable in responses.
  • Commit: b41d5ca

11. [Rule 3 - Blocking] Over-cap uploads answer 413 from the handler

  • Issue: surf only wraps the body in http.MaxBytesReader; there is no router 413 response, and PHP's post_max_size path cannot be recorded.
  • Fix: decodeInput reports the cap and the handler answers 413 {"error":"Payload too large"}; TestCollectionPhotoUpload asserts it and that no row is stored.
  • Commit: b41d5ca

12. [Recording approach] PHP state from a tinker reset per case

  • The plan suggested the bootstrap seed plus tinker additions. A single reset script (parity/fonoteka_reset.php) that mirrors the Go hook was used instead, run before each case, with collection and file id sequences lifted to 8-digit ranges so captured ids are scrubbed and never collide. The README documents the recipe.

Total deviations: 11 auto-fixed (5 bugs, 1 missing critical, 5 blocking) plus 1 recording-approach change. Impact on plan: All 23 routes ported and replaying; every deviation follows the recorded PHP contract or keeps the shared corpus honest. No scope creep.

Issues Encountered

  • The org-lock-on me/context case was not recorded (it needs a PHP restart with FONOTEKA_AI_ORG_LOCK); TestMeContextFlags covers lock on and off.
  • A personal-token caller cannot reach collection/share or switch over HTTP (the routes are absent from the token group), so the in-handler second lock is covered by Go tests rather than recordings.
  • Recording uploads against the isolated PHP wrote 16 files into the PHP checkout's storage/app/uploads/public/6ab/f82/; they were removed afterwards.
  • The pending-invitation 409 guard is not part of Resolve yet; it lands with household and invitations in 12-03 (invitation_acceptance_required is always false, as PHP returns it).

Known Stubs

None.

User Setup Required

None. Production keeps storage.uploads on the Winter layout; the new golem15.fonoteka.* keys default to PHP's values.

Next Phase Readiness

  • 12-03 adds the pending-invitation guard to Resolve, more Winter pages (409/410 as new winter_<status>.html files) and reuses ProvisionCollection for member removal.
  • 12-04 builds album handlers on AlbumsAccessibleBy, requestScope, decodeInput and the media helpers; models.marketCurrency() still returns EUR and should read classes.MarketCurrency.
  • 12-05 can reuse collectionsHarness, the fonoteka seed hook and GenerateShareTokenFrom for coverage and fuzzing.

Phase: 12-p-ytarium-api-collections-and-albums Completed: 2026-10-02

Self-Check: PASSED

All 18 created files listed in key-files exist on disk. Commits 574b2f1, b41d5ca and 6c729a7 exist in fonoteka.go. Plan-level verification passed: go vet ./... and go test ./... -count=1 in fonoteka.go (root, parity, the fonoteka plugin and sm-user-plugin), TestParityCorpus with 56 ported and passing, and check_corpus.go --require-recorded --check-secrets exiting 0.