Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-02-SUMMARY.md

20 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status, plan_head_before, plan_head_after, user_plugin_head_before, user_plugin_head_after
phase plan subsystem tags requires provides affects actuals tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status plan_head_before plan_head_after user_plugin_head_before user_plugin_head_after
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 02 api
notifications
credentials
lagoon-encrypted
onboarding
invitations
sm-user-plugin
register-event
parity
tide
phase provides
13-01 lagoon prohibited rule, check_corpus ported case-status check, php_parity.sh rows/QUEUE_CONNECTION, D-15 manifest fix
phase provides
12 fonoteka seed hook and PARITY_CASE recipe, Winter error pages, invitation service and acceptance guard, ProvisionOrgFor, ProvisionCollection
Notifications: GET notifications (50 newest, stored payload bytes), unread-count, read-all, {id}/read (Winter 404 for foreign/missing/out-of-range ids)
Credentials: ai-credential GET/POST, org-ai-credential GET/POST/DELETE, discogs-credential GET/POST with PHP's check order, bodies and Winter 500 pages; secrets only as lagoon.Encrypted
classes.ResolveAIConfig with PHP AiConfigResolver tiers, AIConfig, ErrNoAICredential, VisionModelFor and the AdminVisionModel seam (none until Phase 14); AIAllowed's site-admin branch reads it
sm-user-plugin: RegisterEvent.Payload (input minus password keys), RegisterUser/RegisterOptions/FireRegisterEvent/IssueToken/APIArray, README
Onboarding status/bootstrap (409 before validation, advisory-locked recount), public GET invitations/{token} inspection, fonoteka RegisterEvent listener (pending registration or collection provisioning)
Parity: notifications, credentials, empty, invite-for-register seed states on both sides; 13 routes re-recorded and ported (113); fixtures/nuxt/onboarding.yaml and TestFonotekaNuxtFlows/onboarding
13-03
13-04
13-05
13-06
14
tokens tasks commits
67300 3 5
added patterns
Raw JSON passthrough: a jsonable column whose key order is the contract is read as json.RawMessage and emitted unchanged
Credential upserts lock the owner row by id only (SELECT id ... FOR UPDATE) and update selected columns, so a secret stored under another key never has to decrypt
Fixtures carry test secrets and passwords only as {{secret:...}} vars written by the reset and set by the Go seed (parityCredentialSecrets)
Parity routes whose cases need disruptive state (no live user) replay on their own database (isolatedParityRoutes)
Cross-plugin registration: other plugins create users through the user plugin's exported RegisterUser/FireRegisterEvent/IssueToken/APIArray, never by duplicating /register
created modified
fonoteka.go/plugins/golem15/fonoteka/classes/notifications.go
fonoteka.go/plugins/golem15/fonoteka/classes/ai_config_resolver.go
fonoteka.go/plugins/golem15/fonoteka/classes/onboarding.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/notifications_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/credentials_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/onboarding_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitation_inspect_controller.go
fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go
fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go
fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go
fonoteka.go/plugins/golem15/user/controllers/registration.go
fonoteka.go/parity/fixtures/nuxt/onboarding.yaml
fonoteka.go/plugins/golem15/fonoteka/classes/credential_write_service.go
fonoteka.go/plugins/golem15/fonoteka/classes/gates.go
fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go
fonoteka.go/plugins/golem15/fonoteka/classes/php_values.go
fonoteka.go/plugins/golem15/fonoteka/plugin.go
fonoteka.go/plugins/golem15/fonoteka/routes.go
fonoteka.go/plugins/golem15/fonoteka/routes_table_phase12_test.go
fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
fonoteka.go/plugins/golem15/fonoteka/handler_failures_test.go
fonoteka.go/plugins/golem15/user/classes/events.go
fonoteka.go/plugins/golem15/user/controllers/api_controller.go
fonoteka.go/plugins/golem15/user/register_test.go
fonoteka.go/plugins/golem15/user/README.md
fonoteka.go/parity/manifest.yaml
fonoteka.go/parity/fixtures/routes/ (13 routes, 51 fixtures)
fonoteka.go/parity/fonoteka_seed_test.go
fonoteka.go/parity/fonoteka_reset.php
fonoteka.go/parity/fonoteka_flows_test.go
fonoteka.go/parity/parity_test.go
fonoteka.go/parity/parity_contract_test.go
fonoteka.go/parity/README.md
The Discogs store ports PHP's actual rules (token nullable|string|regex, shared nullable|boolean), not the plan's paraphrase (token required, shared sometimes): an absent token reuses the stored one, a null or blank token is the 500 page, and a manager's not-shared store deletes the organisation's Discogs credential as PHP does.
Credential secrets, onboarding passwords and a well-formed unknown invitation token are fixed test-only vars (secret:ai-key, secret:discogs-token, secret:owner-password, secret:short-password, secret:unknown-invite) written by the reset and the Go seed, so no key, token or password literal reaches a fixture and check_corpus --check-secrets stays green.
RegisterEvent.Payload is the register input as read (plus /register's password_confirmation default), minus password and password_confirmation; the IP columns PHP adds to $data are not in it (they are on the user row). The bootstrap fires it with the validated data, as PHP passes $reg.
The 'empty' onboarding state soft-deletes every user on both sides; the two onboarding routes replay on databases of their own and every empty case is recorded from a fresh php_parity.sh reset, so the shared replay database and the PHP instance never carry stray users.
ListNotifications returns NotificationEntry (raw payload) instead of the plan's []models.Notification, because the model's Jsonable[map] re-marshals keys alphabetically and the contract is PHP's stored key order.
Commits went to master in fonoteka.go and the sm-user-plugin submodule (not pushed), as the orchestrator directed for this sequential run and as 13-01 did; the GSD protected-branch check reports master as protected.
Phase 13 ported routes are listed in parity_contract_test.go phase13SeedRoutes and mapped to their seed states through fonotekaRouteExtras (route default) and fonotekaCaseExtras (exceptions).
Public (unauthenticated) /_fonoteka/api/v1 routes are listed in routes_isolation_test.go publicFonotekaRoutes and must carry throttle:10,1 and neither jwt.auth nor inv.must-change-password.
API-04
API-06
API-07
id description requirement verification human_judgment
D1 Bell list: the caller's 50 newest notifications, newest id first, payload bytes as stored (PHP key order), Carbon times, [] when none API-04
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go#TestNotificationsRoutes pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus/GET___fonoteka_api_v1_notifications_jwt pass
false
id description requirement verification human_judgment
D2 unread-count, read-all and {id}/read scoped to the caller; foreign, missing and out-of-range ids are the Winter 404 page; reading a read row again is 200 API-04
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/notifications_smoke_test.go#TestNotificationsRoutes pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (notifications unread-count, read-all, {id}/read) pass
false
id description requirement verification human_judgment
D3 AI, organisation AI and Discogs credential CRUD with PHP's order of checks, bodies, 403/404 guards, provisioning before the guard and the Winter 500 page for every validation failure and missing secret; secrets stored only as ciphertext and never echoed or logged API-06
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go#TestCredentialsCRUD pass
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go#TestCredentialSecretsNeverSerialized pass
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go#TestDiscogsSharedMirror pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (7 credential routes, 33 recorded cases) pass
kind ref status
other go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes .../routes.php --require-recorded --check-secrets pass
false
id description requirement verification human_judgment
D4 ResolveAIConfig walks PHP's tiers (admin global model via AdminVisionModel, org lock, personal, organisation, ErrNoAICredential) with provider defaults; AIAllowed admits a site admin only with a global model API-06
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/credentials_smoke_test.go#TestResolveAIConfigPrecedence pass
false
id description requirement verification human_judgment
D5 sm-user-plugin RegisterEvent.Payload without password keys and the RegisterUser/FireRegisterEvent/IssueToken/APIArray exports; /register's statuses and bodies unchanged API-07
kind ref status
unit fonoteka.go/plugins/golem15/user/register_test.go#TestRegisterEventPayload pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus/POST___user_api_v1_register_user-api and TestUserAPINuxtFlows pass
false
id description requirement verification human_judgment
D6 Onboarding status and bootstrap (one owner and one organisation under concurrency, 409 before validation, Winter 500 on invalid input), public invitation inspection, and the register listener holding an invited registrant at acceptance API-07
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go#TestBootstrapConcurrent pass
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go#TestRegisterInvitationListener pass
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/onboarding_smoke_test.go#TestInspectInvitation pass
kind ref status
e2e fonoteka.go/parity/fonoteka_flows_test.go#TestFonotekaNuxtFlows/onboarding pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus/coverage (113 ported, 113 passing) pass
false
42min 2026-10-03 complete 75b89dd24255fa5aa227fc30552ebba4a40992f5 ec054a2c8ee2d887dd4899bb07fb01cda720ce3d c258e9fade235bf7414ed02478ca99925e97d360 d80d7990f6f8b743c4df2f0fa9538d9e2acf780e

Phase 13 Plan 02: Notifications, credentials, onboarding and the register hook Summary

The bell (list, count, clear), personal and organisation AI and Discogs keys, the first-run owner bootstrap, public invitation inspection and the invitation-aware register hook now run in Go with PHP's bytes, error pages and resolution order: 13 routes re-recorded from the fonoteka reset and ported (113 total), plus a recorded and replayed onboarding journey, through an additive sm-user-plugin change.

Performance

  • Duration: 42 min
  • Started: 2026-10-03T04:46:57Z
  • Completed: 2026-10-03T05:29:00Z
  • Tasks: 3 of 3
  • Files modified: 30 source/test files plus 51 route fixtures and one flow fixture (fonoteka.go and the user submodule)

Accomplishments

  • Notifications. GET notifications reads the payload column as raw JSON, so PHP's stored key order and escapes reach the client unchanged; unread-count, read-all and {id}/read are scoped by user_id. A foreign, missing or out-of-int4-range id is the Winter 404 page.
  • Credentials. The seven handlers follow each PHP controller's order:
    • organisation store provisions an org-less caller, then checks canManage (403), then validates;
    • Discogs store trims and validates the token, then reuses the stored token, then provisions and checks (403 with the localized shared_forbidden text), then writes the user row and the organisation mirror (or the mirror delete) in one transaction. Every $request->validate() or missing-secret failure is the Winter 500 page. Writes fill only CredentialFillFields plus the secret as lagoon.NewEncrypted; upserts lock the row by id, so an existing secret never has to decrypt.
  • AI resolver. ResolveAIConfig ports the four tiers. AdminVisionModel is the package-level seam for the global vision model: none until Phase 14 (INTG-02), which matches PHP with no global model. AIAllowed now admits a site admin only when that seam returns a model.
  • User plugin (D-09, D-11). RegisterEvent.Payload and the exported registration steps (RegisterUser, RegisterOptions, FireRegisterEvent, IssueToken, APIArray) are additive; Register calls them in its old order. The /register corpus case and TestUserAPINuxtFlows replay unchanged.
  • Onboarding.
    • status counts live users.
    • bootstrap answers 409 before validation. Otherwise it validates (500 page), then takes pg_advisory_xact_lock(hashtext('fonoteka:onboarding:bootstrap')), recounts, creates the organisation (Str::slug), registers the activated owner through RegisterUser and fires the event after commit. It answers {"token","user"} exactly as /register does.
  • Register listener (D-10). A valid invitation_token (sha256 match, pending, unexpired, trimmed lowercased email match) upserts the pending registration on user_id; anything else provisions the user's collection.
  • Inspection. Public GET invitations/{token} answers pending with the collection name, else unavailable.
  • Parity. New seed states notifications, credentials, empty and invite-for-register exist on both sides. The 13 routes were re-recorded and ported (51 cases), and the onboarding routes replay on their own databases. fixtures/nuxt/onboarding.yaml was recorded with the two-run invite recipe and replays on an empty database.

Task Commits

fonoteka.go (master, not pushed):

  1. Task 1: bell list - 93dd666 (feat)
  2. Task 2: notification clearing and credentials - 9cf3a66 (feat)
  3. Task 3: sm-user-plugin pointer bump - 473d37f (chore)
  4. Task 3: onboarding, inspection, register listener - ec054a2 (feat)

sm-user-plugin submodule (master, not pushed):

  1. Task 3: RegisterEvent.Payload and the registration exports - d80d799 (feat)

Decisions Made

See key-decisions above.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Notification ids above the int4 range answered an opaque 500

  • Found during: Task 2
  • Issue: pathID accepts uint32, and Postgres refused to bind 4000000000 for the integer id, so POST notifications/4000000000/read answered the opaque 500 instead of PHP's 404.
  • Fix: MarkNotificationRead treats 0 and ids above math.MaxInt32 as not found. The same gap on other pathID routes is logged in deferred-items.md.
  • Files modified: classes/notifications.go
  • Commit: 9cf3a66

2. [Rule 3 - Blocking] Route-inventory tests broke on the newly mounted routes

  • Found during: Task 2 (Task 1's commit ran only TestParityCorpus, so TestParityContract was already failing after 93dd666)
  • Issue: TestParityContract allow-lists ported routes. TestPhase08Coverage asserted the whole discogs family absent. TestRequirePasswordChangeExemptSet required inv.must-change-password on every /_fonoteka/api/v1 route.
  • Fix:
    • phase13SeedRoutes lists the Phase 13 ported routes.
    • The discogs subtests now assert that the credential routes are JWT-only, that /discogs-credential/test is absent and that the import and cover-price routes are absent.
    • A publicFonotekaRoutes set asserts that the three public routes carry throttle:10,1 and neither jwt.auth nor the password-change lock.
  • Commits: 9cf3a66, ec054a2

3. [Rule 1 - Test isolation] My unreadable-payload row broke T-12-23 on the shared test database

  • Found during: Task 2
  • Issue: T-12-23 casts every album_added payload to jsonb, and my seeded not json row was of that type.
  • Fix: my seeded rows now use another type, and the unreadable row is deleted on cleanup.
  • Commit: 9cf3a66

4. [Rule 2 - Security] No secret or password literal in fixtures

  • Found during: Tasks 2 and 3
  • Issue: tide refuses unclassified password values, and check_corpus refuses 64-hex values.
  • Fix: the request bodies use {{secret:...}} vars, set by both seeds.
  • Commits: 9cf3a66, ec054a2

5. [Rule 1 - Contract] The Discogs store rules follow the PHP source, not the plan's wording

  • Found during: Task 2
  • Issue: the PHP source has token nullable|string|regex and shared nullable|boolean; the plan paraphrased them as required/sometimes.
  • Fix: the recordings confirm PHP's behaviour, and the port follows the source.
  • Commit: 9cf3a66

6. [Rule 3 - Blocking] assertPortedMismatch used GET ai-credential as its unported example

  • Found during: Task 2
  • Fix: it now uses POST ai-credential/test, which stays pending until Phase 14.
  • Commit: 9cf3a66

Total deviations: 6 auto-fixed: 2 bugs, 2 blocking test inventories, 1 security hardening, 1 contract correction. Impact: the response contracts match PHP. The interface changes from the plan are ListNotifications returning NotificationEntry, and the lang files needing no change because discogs.shared_forbidden was already ported in pl and en.

Issues Encountered

  • TestPhase09SecurityRoutes (pre-existing, 12.2 cabana routes) still fails; it is logged in deferred-items.md from 13-01.
  • go test ./... at the fonoteka.go root covers only the root module; the plugin modules of the workspace were run explicitly (./plugins/golem15/fonoteka/... ./plugins/golem15/user/...).
  • The FORCE_COLOR=3 shell variable was unset for test runs, as in 13-01.

Known Stubs

None. classes.AdminVisionModel reports no global vision model until Phase 14 ports the Golem15.Golem setting (INTG-02). That is a named dependency boundary that matches PHP with no global model, not a stub. The two credential /test routes stay unmounted and pending (D-02).

Threat Flags

None beyond the plan's register:

  • T-13-08 is mitigated: TestCredentialSecretsNeverSerialized scans the bodies, the serialized models, the resolver output, the stored columns and the captured slog output.
  • T-13-09, T-13-10, T-13-18, T-13-19, T-13-20, T-13-21 and T-13-27 are mitigated and tested as planned.
  • T-13-11 is accepted: base_url is stored only, and ResolveAIConfig documents that the Phase 14 client owns the SSRF guard.

User Setup Required

None.

Next Phase Readiness

  • 13-03 (wishlist) can reuse the notifications seed state and WriteNotification. Add the wishlist routes to phase13SeedRoutes and fonotekaRouteExtras.
  • 13-06 (unit tests and fuzz) should add the credential, notification and onboarding write routes to write_endpoints_fuzz_test.go. Per C-04, this plan did not touch it.
  • The sm-user-plugin commit d80d799 is local to the submodule; push it with ssu when the user asks.

Self-Check: PASSED

  • Created files exist: all twelve key-files.created paths checked with test -f.
  • Commits exist: 93dd666, 9cf3a66, 473d37f, ec054a2 (fonoteka.go); d80d799 (sm-user-plugin).
  • Plan verification: go vet ./... is clean for the root and both plugin modules. go test is green for the root, parity and sm-user-plugin. The fonoteka plugin is green except the pre-existing TestPhase09SecurityRoutes. The parity corpus is at 113 ported and passing. TestFonotekaNuxtFlows/onboarding and TestUserAPINuxtFlows pass. check_corpus --require-recorded --check-secrets is green.