Per D-10 and user decision 5, tide owns a fake Centrifugo HTTP recorder bound to loopback only: PHP's CENTRIFUGO_API_URL points at it during recorded flows, and it stores each publish/broadcast request as {method path, whether Authorization was present, JSON body} with the api key never written anywhere.
Per D-10, broadcast goldens are stored as YAML under ../fonoteka.go/parity/fixtures/broadcasts with timestamps (data.timestamp and payload.timestamp) and the actor normalised, and captured ids replaced by `{{id:...}}` placeholders; the same recorder serves as the fake Centrifugo on the Go side and the structural diff ignores key order.
Per D-10 and user decision 5, TestBroadcastGoldens proves the Go `deleted.fonoteka.album` publication and the Go `collection.bulk_updated` {reason: bulk_create, count} publication equal the PHP goldens after normalisation; the created and updated goldens are recorded and committed now, reported as pending Phase 12 (their `album` subtree), and never counted as passing.
Per D-12 and RT-01/RT-02, the corpus gains two tracked extra routes, `GET /api/realtime/token realtime` and `POST /api/realtime/subscribe realtime`, recorded against isolated PHP and replayed green against the Go app: token 200 (the token captured into the private vars store as jwt:centrifugo, never committed) and 401 without a bearer; subscribe allow for a collection member (`{"result":{"info":[]}}`), allow on a presence channel (allow and override keys), and denies for a wrong secret, empty user, unknown namespace, `presence:presence:`, four segments and a non-member, all HTTP 200.
The PHP-side test-only Centrifugo values (api key, token secret, proxy secret) are fixed constants shared by php_parity.sh and the Go replay config, the proxy secret reaches fixtures only as a `{{var}}` reference, and the fixture secret scan stays clean.
check_corpus and parity_test counts move from 169 to 171 tracked routes and from 31 to 33 ported routes, with the realtime IDs listed next to the user-api extras so the 154-route routes.php digest lock is unchanged.
Committed goldens and fixtures MUST NOT contain a live secret, a JWT, the Centrifugo API key or the proxy secret value
resolved
test
requirement_id
category
statement
status
verification
RT-03
transparency
Normalisation MUST NOT mask any field beyond timestamps, the actor and captured ids, and the created/updated goldens MUST NOT count as passing until Phase 12 asserts their album subtree
resolved
test
Phase Goal
ROADMAP Phase 11 goal (verbatim, not in user-story form): River jobs run on the correct dual-driver split, Centrifugo publishing and channel authorization match the existing server, and Typesense sync stays a re-gated pre-filter — all brought up before the API phases that depend on them.
This plan's slice: the realtime contract is proven against real PHP output, both for what Centrifugo receives when albums change and for the two HTTP routes the Nuxt app and Centrifugo call (RT-01, RT-02, RT-03 parity evidence; "API parity is the acceptance test").
Extend the framework parity toolkit `tide` with a fake Centrifugo recorder and broadcast golden files, record Płytarium's album broadcasts and realtime routes from isolated PHP, and replay them against the Go app.
Purpose: D-10 requires payload parity proven against real PHP output, and the project's acceptance rule is that the PHP contract is replayed, not reasoned about. Decisions implemented: D-10; user decision 5 (fake-Centrifugo recorder, created/updated recorded now and asserted in Phase 12); D-12/D-13 route contracts through recorded fixtures.
Output: tide recorder and golden helpers with README, summer parity:broadcasts, fonoteka.go broadcast goldens and TestBroadcastGoldens, two realtime manifest routes with recorded fixtures replayed green.
Repos: summercms.go (tide, summer CLI) and fonoteka.go (parity corpus). tide stays application-agnostic (neutral names in its code, tests and README). Planning docs and code in separate commits. Never add co-author tags.
fonoteka parity: goldens fixtures/broadcasts/{created,updated,deleted,bulk}.yaml, flows fixtures/broadcasts/flows/{album-lifecycle,album-bulk}.yaml, TestBroadcastGoldens, seed hook realtime, auth group realtime, manifest IDs GET /api/realtime/token realtime and POST /api/realtime/subscribe realtime, fixtures fixtures/routes/GET__api_realtime_token_realtime.yaml and POST__api_realtime_subscribe_realtime.yaml, php_parity.sh env CENTRIFUGO_API_URL, CENTRIFUGO_API_KEY, CENTRIFUGO_SECRET, CENTRIFUGO_PROXY_SECRET.
Task 1: PHP's album delete publication is recorded through the tide recorder and the Go delete broadcast matches it
The isolated PHP stack can run: `command -v php` succeeds and `test -f /media/nvme/dev/golem15/fonoteka/artisan` succeeds; plan 11-03 is executed (`go doc ./modules/lighthouse WithoutBroadcasting` exits 0); `docker info` exits 0.
modules/tide/centrifugo.go, modules/tide/centrifugo_golden.go, modules/tide/README.md, cmd/summer/parity.go, cmd/summer/main.go, cmd/summer/main_test.go, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/fixtures/broadcasts/flows/album-lifecycle.yaml, ../fonoteka.go/parity/fixtures/broadcasts/deleted.yaml
modules/tide/flow.go, modules/tide/record.go, modules/tide/proxy.go (requireLoopbackAddr, isLoopbackHost), modules/tide/diff.go, modules/tide/variables.go, modules/tide/fixture.go, modules/tide/README.md, cmd/summer/parity.go, cmd/summer/main.go, cmd/summer/main_test.go, ../fonoteka.go/parity/php_parity.sh, ../fonoteka.go/parity/README.md, ../fonoteka.go/parity/fixtures/seed/bootstrap.yaml, ../fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_albums_{id}_jwt.yaml, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go and realtime_smoke_test.go (from 11-03), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (store, update, destroy, bulk), /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/classes/CentrifugoClient.php, .planning/phases/11-jobs-realtime-and-search-infrastructure/11-RESEARCH.md (Open Question 4, Pitfall 15)
(1) tide recorder (D-10, user decision 5), new modules/tide/centrifugo.go: `type Publication struct { Method, Path string; Authorization bool; Body json.RawMessage }` (YAML field names method, path, authorization, body with the body as a literal block like other fixtures); `type CentrifugoRecorderOptions struct { APIKey string }`; `NewCentrifugoRecorder(opts) *CentrifugoRecorder` implementing http.Handler: POST paths ending in `/publish` or `/broadcast` are recorded (Authorization true only when the header equals `apikey `; the header value itself is never stored) and answered `200 {"result":{}}`; `/presence` answers `{"result":{"presence":{}}}`, `/unsubscribe` and `/info` answer `{"result":{}}`; other paths 404; bodies capped at 1 MiB; `Publications()` returns a copy in arrival order, `Reset()` clears; `ListenAndServe(ctx, addr)` refuses non-loopback addresses with the proxy's loopback rule (T-02-01) and stops on ctx cancel. New centrifugo_golden.go: `BroadcastGolden{Version int; Name, Flow, Pending string; Publications []Publication}` with goccy/go-yaml load (DisallowUnknownField) and write helpers; `NormalizePublications(pubs []Publication, store *Store) ([]Publication, error)` replaces `$.data.timestamp`, `$.data.payload.timestamp` with the literal `{{timestamp}}`, `$.data.payload.actor` with `{{actor}}`, and any string or number equal to a captured `id:*` value of the store (including inside channel names such as `collection:12`) with that `{{id:name}}` placeholder, and nothing else; `DiffPublications(expected, actual)` compares count, method, path, authorization and the body with tide's structural JSON diff, returning Diffs with `$[i].body...` paths.
(2) CLI: in cmd/summer/parity.go add parityBroadcastsCommand() named parity:broadcasts (flags flow, target, vars, listen default 127.0.0.1:8424, out, name, api-key default from env PARITY_CENTRIFUGO_API_KEY): start the recorder, run the flow against the PHP target with tide.RecordFlow using the vars store (so captured ids and JWTs stay in the 0600 file), wait briefly for trailing publishes, normalise with the store and write the golden; refuse a non-loopback target like the proxy does. Register it in toolCommands and the main_test.go expected list. Document it in modules/tide/README.md (recorder, golden format, normalisation rules; neutral names only).
(3) PHP env: php_parity.sh export_env adds CENTRIFUGO_API_URL=${CENTRIFUGO_API_URL:-http://127.0.0.1:8424/api}, CENTRIFUGO_API_KEY=${CENTRIFUGO_API_KEY:-parity-centrifugo-api-key}, CENTRIFUGO_SECRET=${CENTRIFUGO_SECRET:-parity-centrifugo-token-secret} and CENTRIFUGO_PROXY_SECRET=${CENTRIFUGO_PROXY_SECRET:-parity-centrifugo-proxy-secret} — fixed test-only values for the isolated instance, never production secrets (Phase 3 test-secret precedent); leave BROADCAST_ENABLED as is (it only affects the Laravel broadcaster, not model broadcasts).
(4) Record: write flows/album-lifecycle.yaml (seed via the bootstrap identities; steps: create an album in alice's collection with POST /_fonoteka/api/v1/albums capturing its id as id:album, update it with PUT, delete it with DELETE, each step carrying its own golden target) — or split the lifecycle into per-step recordings, whichever keeps one golden per event; run the isolated PHP (php_parity.sh reset, serve), then summer parity:broadcasts to produce fixtures/broadcasts/deleted.yaml (and the created/updated goldens in Task 2). The deleted golden holds every publication PHP emitted during the delete step; if PHP emits more than the deleted event on a soft delete, the Go side must reproduce it and the SUMMARY records the finding.
(5) Go side, new ../fonoteka.go/parity/broadcast_goldens_test.go TestBroadcastGoldens with subtest deleted: boot the app on the TestMain pool with realtime config pointing api_url at an httptest server wrapping tide.NewCentrifugoRecorder (api key = the same test-only constant), start conga.StartWorker for the app, seed a kind=collection collection and an album for a frontend user, delete the album inside lagoon.Transaction with bouncer.WithUser ctx, wait up to 5s for the publication, normalise it with a store holding the Go ids under the golden's placeholder names, and require DiffPublications(golden, got) to be empty.
go vet ./... && go test ./modules/tide ./cmd/summer -count=1 && (cd ../fonoteka.go && go test ./parity -run '^TestBroadcastGoldens$/^deleted$' -count=1 -v)
<fails_when>Any command exits non-zero; the verbose run lacks "--- PASS: TestBroadcastGoldens/deleted", prints "no tests to run" or "--- SKIP"; the diff output lists any $[i] path.</fails_when>
<acceptance_criteria>
- go doc ./modules/tide CentrifugoRecorder, go doc ./modules/tide NormalizePublications and go doc ./modules/tide DiffPublications exit 0.
- grep -c 'CENTRIFUGO_API_URL' ../fonoteka.go/parity/php_parity.sh prints 1.
- grep -c 'deleted.fonoteka.album' ../fonoteka.go/parity/fixtures/broadcasts/deleted.yaml prints at least 1 and grep -c '{{timestamp}}' ../fonoteka.go/parity/fixtures/broadcasts/deleted.yaml prints at least 1.
- grep -c 'parity-centrifugo-api-key' ../fonoteka.go/parity/fixtures/broadcasts/deleted.yaml prints 0 (the key never reaches a golden).
- grep -c 'parity:broadcasts' cmd/summer/main_test.go prints at least 1.
</acceptance_criteria>
A real PHP album delete is captured as a normalised golden and the Go app's delete broadcast produces the same Centrifugo request.
Task 2: Bulk-add parity is proven and the created/updated goldens are recorded for Phase 12
../fonoteka.go/parity/fixtures/broadcasts/flows/album-bulk.yaml, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml, ../fonoteka.go/parity/fixtures/broadcasts/updated.yaml, ../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/README.md
../fonoteka.go/parity/broadcast_goldens_test.go and fixtures/broadcasts/* (Task 1), ../fonoteka.go/parity/README.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (bulk), ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_albums_bulk_jwt.yaml (request shape), modules/tide/centrifugo_golden.go
(1) Record from isolated PHP with `summer parity:broadcasts`: created.yaml and updated.yaml from the lifecycle flow's create and update steps, and bulk.yaml from flows/album-bulk.yaml (POST /_fonoteka/api/v1/albums/bulk with two albums into alice's collection, expecting exactly one `collection.bulk_updated` publication with payload {reason: bulk_create, count: 2}). Set `pending: "Phase 12 asserts the album subtree"` in created.yaml and updated.yaml.
(2) TestBroadcastGoldens subtests: bulk creates two albums inside lighthouse.WithoutBroadcasting[models.Album] and lagoon.Transaction, then calls svc.Emit with channels collection:<id>, event collection.bulk_updated and payload {reason: bulk_create, count: 2} in the same transaction, and requires an empty diff against bulk.yaml (exactly one publication); created and updated load their goldens, check they parse and name created.fonoteka.album / updated.fonoteka.album, and call t.Skip with the golden's pending text so the run reports them pending, never as passes (D-16 of Phase 2: pending never equals passing).
(3) ../fonoteka.go/parity/README.md gains a "Broadcast goldens" section: the recorder port, the php_parity.sh CENTRIFUGO_* defaults, the parity:broadcasts command lines used, the normalisation rules and the Phase 12 pending note.
(cd ../fonoteka.go && go test ./parity -run '^TestBroadcastGoldens$' -count=1 -v)
<fails_when>Non-zero exit; the output lacks "--- PASS: TestBroadcastGoldens/deleted" or "--- PASS: TestBroadcastGoldens/bulk"; created or updated appears as "--- PASS" instead of "--- SKIP"; the output prints "no tests to run".</fails_when>
<acceptance_criteria>
- grep -c 'collection.bulk_updated' ../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml prints at least 1 and grep -c 'bulk_create' ../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml prints at least 1.
- grep -c 'Phase 12' ../fonoteka.go/parity/fixtures/broadcasts/created.yaml ../fonoteka.go/parity/fixtures/broadcasts/updated.yaml prints at least 1 for each file.
- grep -c 'Broadcast goldens' ../fonoteka.go/parity/README.md prints at least 1.
</acceptance_criteria>
Deleted and bulk broadcasts are byte-equivalent to PHP after normalisation, and created/updated goldens are committed and visibly pending Phase 12.
Task 3: The Nuxt token route and Centrifugo's subscribe proxy replay green against recorded PHP fixtures
../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/check_corpus.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/realtime_seed_test.go, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/fixtures/routes/GET__api_realtime_token_realtime.yaml, ../fonoteka.go/parity/fixtures/routes/POST__api_realtime_subscribe_realtime.yaml, ../fonoteka.go/parity/README.md
../fonoteka.go/parity/manifest.yaml (auth_groups, a user-api entry), ../fonoteka.go/parity/check_corpus.go (expectedRouteCount, userAPIRouteIDs, comparePHPSnapshot), ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/parity_test.go (expectedPHPRoutes, expectedPortedRoutes, seedHooks, newConfiguredTarget, testConfig), ../fonoteka.go/parity/user_api_seed_test.go (seed hook precedent), ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/README.md (Record/Replay), /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/routes.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/http/controllers/ProxyController.php, .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-11 extra manifest IDs)
(1) Manifest (D-12, D-13; Phase 7 D-11 precedent for extra IDs): add auth group `realtime` and two routes with `status: ported` and `seed_hook: realtime`: `GET /api/realtime/token realtime` with cases `case` (alice bearer → 200, token captured by the existing jwt:centrifugo rule) and `no-bearer` (401 from jwt.auth); `POST /api/realtime/subscribe realtime` with JSON bodies shaped like Centrifugo's subscribe proxy request (`client`, `transport`, `protocol`, `encoding`, `user`, `channel`) and header `X-Centrifugo-Secret: "{{secret:centrifugo_proxy}}"` (add the header to the route's keep list in capture-rules.yaml so it is replayed, and classify the value so the fixture holds only the reference), cases: member allow on `collection:{{id:collection}}`, presence allow on `presence:` channel for a namespace whose authorizer allows presence if the PHP stack has one (otherwise record the PHP deny on `presence:collection:{{id:collection}}`, which is the ported behaviour), wrong secret, empty user, unknown namespace, `presence:presence:collection:1`, four segments, non-member collection — each response recorded as PHP returns it (all HTTP 200).
(2) Record against isolated PHP (php_parity.sh env from Task 1 carries the proxy secret) with summer parity:record --manifest ... --fixtures ... --target http://127.0.0.1:8423 --vars /tmp/summercms-parity/vars.yaml --resume true --next-batch 2, storing the proxy secret as secret:centrifugo_proxy in the private vars file only.
(3) Go replay: new realtime_seed_test.go seedRealtime hook (registered as realtime in seedHooks) that seeds alice, her kind=collection collection and a second user without membership through direct Postgres like the user-api hook, and stores secret:centrifugo_proxy = the php_parity.sh test-only constant plus the ids the fixtures reference; testConfig/newConfiguredTarget add realtime config (driver centrifugo, token_secret and proxy_secret = the same test-only constants, api_url pointing at an unreachable loopback port so no publish leaves the test). Update counts: check_corpus.go expectedRouteCount 171 with a realtimeRouteIDs list folded into comparePHPSnapshot next to userAPIRouteIDs; parity_test.go expectedPHPRoutes 171 and expectedPortedRoutes 33; routes.snapshot gains the two IDs without changing its PHP digest line. Record the steps in parity/README.md.
(4) Run TestParityCorpus and the corpus checker (including its secret scan) and fix any byte difference on the Go side (Cache-Control, Content-Type, no trailing newline, [] info) — never by editing recorded PHP bytes.
(cd ../fonoteka.go && go test ./parity -run '^(TestParityCorpus|TestParsePHPRoutesCountAndGroups|TestUniqueAndSecretScan|TestCompareIDSets|TestParityContract)$' -count=1 -v)
<fails_when>Non-zero exit; the output lacks "--- PASS" for TestParityCorpus, TestUniqueAndSecretScan or TestParsePHPRoutesCountAndGroups; any subtest for "GET /api/realtime/token realtime" or "POST /api/realtime/subscribe realtime" reports FAIL; the coverage summary line reports "failing" above 0 or a recorded total other than 171.</fails_when>
<acceptance_criteria>
- grep -c 'api/realtime/token realtime' ../fonoteka.go/parity/manifest.yaml ../fonoteka.go/parity/routes.snapshot ../fonoteka.go/parity/check_corpus.go prints at least 1 for each file.
- grep -cE 'expectedRouteCount\s*=\s*171' ../fonoteka.go/parity/check_corpus.go prints 1 and grep -cE 'expectedPortedRoutes\s*=\s*33' ../fonoteka.go/parity/parity_test.go prints 1.
- grep -c 'parity-centrifugo-proxy-secret' ../fonoteka.go/parity/fixtures/routes/POST__api_realtime_subscribe_realtime.yaml prints 0 and grep -c '{{secret:centrifugo_proxy}}' ../fonoteka.go/parity/fixtures/routes/POST__api_realtime_subscribe_realtime.yaml prints at least 1.
- grep -c '"info":\[\]' ../fonoteka.go/parity/fixtures/routes/POST__api_realtime_subscribe_realtime.yaml prints at least 1.
</acceptance_criteria>
The token route and subscribe proxy are part of the recorded corpus and the Go app replays every recorded case byte-compatibly, with no secret committed.
<threat_model>
Trust Boundaries
Boundary
Description
Isolated PHP stack → tide recorder (loopback)
PHP sends Centrifugo API calls with an api key to a local recorder
Recorded fixtures/goldens → git
Anything captured may be committed
Developer shell → summer parity:broadcasts
Targets and listen addresses come from flags
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-11-28
Information Disclosure
goldens and route fixtures
high
mitigate
The recorder stores only whether Authorization matched; JWTs and the proxy secret go to the 0600 vars store and appear as {{var}} references; test-only constants are used for the isolated PHP; the corpus secret scan runs in Task 3 and a grep asserts the constants are absent from fixtures.
T-11-29
Spoofing
recorder listener and parity:broadcasts target
medium
mitigate
Recorder listen address and PHP target must be loopback, reusing tide's proxy rule (T-02-01) (Task 1).
T-11-30
Repudiation
golden normalisation hiding regressions
medium
mitigate
Only timestamps, actor and captured ids are normalised; created/updated are reported pending, never passing; DiffPublications checks count, path and authorization too (Tasks 1-2).
T-11-SC
Tampering
Go module installs
high
mitigate
No module added; tide keeps goccy/go-yaml v1.19.2 and stdlib net/http.
</threat_model>
After Task 3: `go vet ./... && go test ./...` in summercms.go; `(cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... && go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/...)`; TestBroadcastGoldens shows deleted and bulk passing with created/updated skipped as pending; TestParityCorpus reports 171 tracked routes with no failures.
<success_criteria>
tide has a loopback-only fake Centrifugo recorder, golden I/O, normalisation and diff, documented without application names.
PHP deleted and bulk broadcasts are replayed byte-equivalent by Go; created/updated goldens are stored and pending Phase 12.
The realtime token and subscribe routes are recorded from PHP and replay green; counts updated to 171/33.
No live or test secret value is committed in fixtures or goldens.
</success_criteria>
Create `.planning/phases/11-jobs-realtime-and-search-infrastructure/11-06-SUMMARY.md` when done.