Files
summercms/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-06-SUMMARY.md

20 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
11-jobs-realtime-and-search-infrastructure 06 testing
parity
tide
centrifugo
realtime
goldens
broadcasts
jwt
phase provides
11-jobs-realtime-and-search-infrastructure 11-01 conga.StartWorker and lagoon.Transaction; 11-03 lighthouse (Bind, WithoutBroadcasting, Emit), the Centrifugo driver, the realtime token and subscribe routes
phase provides
02-api-parity-harness-bootstrap tide flows, RecordFlow, Store, structural JSON diff, loopback rules; the fonoteka parity corpus and php_parity.sh
tide: CentrifugoRecorder (loopback fake Centrifugo), Publication, BroadcastGolden with LoadBroadcastGolden/WriteBroadcastGolden, NormalizePublications, DiffPublications, RecordBroadcasts
summer parity:broadcasts (flow, step, ids, pending, api-key)
fonoteka.go: PHP goldens deleted/bulk (asserted) and created/updated (pending Phase 12), TestBroadcastGoldens
fonoteka.go: realtime auth group, GET /api/realtime/token and POST /api/realtime/subscribe recorded from PHP and replayed green (171 tracked, 33 ported)
bouncer: jwt.auth 401 sends Cache-Control: no-cache, private
11-07 unit tests
12 albums API (created/updated goldens)
15 cutover
tokens tasks commits
27641 3 2
fb4aed176a 5382947ef8
added patterns
Broadcast parity: record PHP's Centrifugo requests through a loopback recorder, normalise only timestamps, actor and captured ids, diff structurally
One golden per event: parity:broadcasts --step runs earlier flow steps as setup and keeps only that step's publications
A masked number is a bare {{id:name}}, a masked string a quoted one, so a type change still diffs
Pending goldens are loaded, checked and reported as t.Skip, never as a pass
created modified
modules/tide/centrifugo.go
modules/tide/centrifugo_golden.go
modules/tide/centrifugo_test.go
../fonoteka.go/parity/broadcast_goldens_test.go
../fonoteka.go/parity/realtime_seed_test.go
../fonoteka.go/parity/fixtures/broadcasts/flows/album-lifecycle.yaml
../fonoteka.go/parity/fixtures/broadcasts/flows/album-bulk.yaml
../fonoteka.go/parity/fixtures/broadcasts/deleted.yaml
../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml
../fonoteka.go/parity/fixtures/broadcasts/created.yaml
../fonoteka.go/parity/fixtures/broadcasts/updated.yaml
../fonoteka.go/parity/fixtures/routes/GET__api_realtime_token_realtime.yaml
../fonoteka.go/parity/fixtures/routes/GET__api_realtime_token_realtime__no-bearer.yaml
../fonoteka.go/parity/fixtures/routes/POST__api_realtime_subscribe_realtime.yaml
../fonoteka.go/parity/fixtures/routes/POST__api_realtime_subscribe_realtime__{presence,wrong-secret,empty-user,unknown-namespace,double-presence,four-segments,non-member}.yaml
modules/tide/README.md
cmd/summer/parity.go
cmd/summer/main.go
cmd/summer/main_test.go
modules/bouncer/jwt.go
modules/bouncer/jwt_test.go
modules/bouncer/README.md
../fonoteka.go/parity/php_parity.sh
../fonoteka.go/parity/README.md
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/routes.snapshot
../fonoteka.go/parity/check_corpus.go
../fonoteka.go/parity/check_corpus_test.go
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/parity_contract_test.go
../fonoteka.go/parity/migrate_test.go
../fonoteka.go/parity/genre_security_test.go
../fonoteka.go/parity/capture-rules.yaml
Broadcast normalisation masks ids only under id/*_id/*_ids keys and as the numeric tail of a channel name, not every equal number, so a count such as bulk_create's count is never masked; a value matching two id variables is an error
Timestamps are masked only in the Carbon +00:00 ISO shape and the actor only when it is exactly {user_id, name}, so a format or shape change still diffs
parity:broadcasts records one golden per flow step (--step); the lifecycle flow first switches alice to the seeded id:collection so the channel uses a captured id
The recorder answers /presence, /unsubscribe and /info with empty results and 404s anything else; it never stores the Authorization value
PHP cannot allow any presence channel of this app (its collection authorizer reads the id from the second channel segment), so the corpus records the PHP deny on presence:collection:<id> and the Go port keeps it
CENTRIFUGO_SECRET test value is 40 bytes (parity-centrifugo-token-secret-test-only) because php-jwt refuses HS256 keys shorter than 32 bytes
The Go corpus target runs the Centrifugo driver with the php_parity.sh token/proxy secrets but no API key and an unreachable API URL, so replays never enqueue or send a broadcast
Framework modules that answer on behalf of a PHP guard carry Laravel's Cache-Control: no-cache, private (bouncer write401, like wristband and the centrifugo handlers)
Case-level capture in the manifest when a route rule would also fire on a case whose response lacks the captured value
RT-01
RT-02
RT-03
id description requirement verification human_judgment
D1 tide fake Centrifugo recorder: records publish/broadcast (method, path, apikey match, body), answers presence/unsubscribe/info, 404 otherwise, loopback-only ListenAndServe RT-03
kind ref status
unit modules/tide/centrifugo_test.go#TestCentrifugoRecorderRecordsPublishAndBroadcast pass
kind ref status
unit modules/tide/centrifugo_test.go#TestCentrifugoRecorderRefusesNonLoopback pass
false
id description requirement verification human_judgment
D2 Golden I/O, normalisation (timestamps, actor, captured ids, ambiguity error, no over-masking) and structural diff with $[i] paths; RecordBroadcasts --step against a loopback backend; parity:broadcasts registered RT-03
kind ref status
unit modules/tide/centrifugo_test.go#TestNormalizePublications pass
kind ref status
unit modules/tide/centrifugo_test.go#TestDiffPublications pass
kind ref status
unit modules/tide/centrifugo_test.go#TestBroadcastGoldenRoundTrip pass
kind ref status
unit modules/tide/centrifugo_test.go#TestRecordBroadcastsStep pass
kind ref status
unit cmd/summer/main_test.go#TestToolCommandNames pass
false
id description requirement verification human_judgment
D3 Go deleted.fonoteka.album publication equals the PHP golden after normalisation RT-03
kind ref status
integration ../fonoteka.go/parity/broadcast_goldens_test.go#TestBroadcastGoldens/deleted pass
false
id description requirement verification human_judgment
D4 Go collection.bulk_updated {reason: bulk_create, count: 2} equals the PHP golden (exactly one publication) RT-03
kind ref status
integration ../fonoteka.go/parity/broadcast_goldens_test.go#TestBroadcastGoldens/bulk pass
false
id description requirement verification human_judgment rationale
D5 created/updated PHP goldens recorded, parsed, event-checked and reported as SKIP pending Phase 12 RT-03
kind ref status
integration ../fonoteka.go/parity/broadcast_goldens_test.go#TestBroadcastGoldens/created (SKIP by design) pass
true Pending by design: Phase 12 must turn these goldens into assertions; the skip is tracked in .planning/WINDOWS.md
id description requirement verification human_judgment
D6 GET /api/realtime/token (200 token captured, 401 no bearer) and POST /api/realtime/subscribe (member allow, presence deny, wrong secret, empty user, unknown namespace, presence:presence:, four segments, non-member) recorded from PHP and replayed green; 171 recorded, 33 passing, 0 failing RT-02
kind ref status
integration ../fonoteka.go/parity/parity_test.go#TestParityCorpus/GET__api_realtime_token_realtime pass
kind ref status
integration ../fonoteka.go/parity/parity_test.go#TestParityCorpus/POST__api_realtime_subscribe_realtime pass
kind ref status
integration ../fonoteka.go/parity/parity_contract_test.go#TestParityContract pass
false
id description requirement verification human_judgment
D7 No Centrifugo test value, live secret or JWT in fixtures or goldens; the corpus secret scan checks Centrifugo values and X-Centrifugo-Secret headers RT-01
kind ref status
unit ../fonoteka.go/parity/check_corpus_test.go#TestUniqueAndSecretScan pass
kind ref status
other go run parity/check_corpus.go --manifest parity/manifest.yaml --routes <routes.php> --require-recorded --require-clients --check-secrets pass
false
23min 2026-09-30 complete

Phase 11 Plan 06: Centrifugo broadcast goldens and realtime route parity Summary

tide now has a loopback fake Centrifugo recorder, broadcast golden files, a normaliser and a structural diff, driven by summer parity:broadcasts. PHP's album delete and bulk-add publications were recorded from isolated PHP, and the Go app reproduces them. The created and updated goldens are committed and pending Phase 12. The Nuxt token route and Centrifugo's subscribe proxy are recorded as 10 PHP cases and replay green against Go.

Performance

  • Duration: 23 min
  • Started: 2026-09-30T11:08:51Z
  • Completed: 2026-09-30T11:32:21Z
  • Tasks: 3
  • Files modified: 39 (10 in summercms.go, 29 in fonoteka.go)

Accomplishments

  • tide recorder and goldens (D-10, user decision 5).
    • NewCentrifugoRecorder records POSTs to …/publish and …/broadcast as {method, path, authorization, body}. authorization is only true when the header was apikey <key>, and the key is never stored. Bodies are capped at 1 MiB. ListenAndServe uses the proxy's loopback rule (T-02-01, T-11-29).
    • NormalizePublications masks only these values:
      • data.timestamp and data.payload.timestamp in the +00:00 ISO shape;
      • data.payload.actor when it is exactly {user_id, name};
      • captured id:* values (T-11-30).
    • DiffPublications checks the count, method, path, authorization flag and body. The body diff is structural and ignores key order.
    • RecordBroadcasts and parity:broadcasts refuse non-loopback targets. They refuse to write a golden that is empty or contains the API key, and they refuse a token-shaped body (T-11-28).
  • PHP evidence. Each PHP event produces exactly one publication. A soft delete publishes only deleted.fonoteka.album {id, collection_id, action, actor, timestamp} to /api/publish. Bulk publishes only collection.bulk_updated {"reason":"bulk_create","count":2}.
  • Go matches. TestBroadcastGoldens/deleted and /bulk pass: they boot the app with a River worker and the recorder standing in for Centrifugo. created and updated are reported as SKIP (pending).
  • Realtime routes (D-12, D-13, RT-01, RT-02).
    • The token route has two cases: alice's bearer gets 200 with the token captured as jwt:centrifugo, and a request without a bearer gets 401 Token not provided.
    • The subscribe proxy has eight cases. Only the member case is allowed, with {"result":{"info":[]}}. The other seven are HTTP 200 denies.
    • The corpus now reports 171 recorded, 33 ported, 33 passing, 0 failing and 138 pending, and the routes.php digest lock is unchanged.

Task Commits

summercms.go:

  1. Task 1: tide recorder, goldens and parity:broadcasts: 9ecbf74 (feat)
  2. Task 3 (Rule 1 fix): jwt.auth 401 Cache-Control: 5382947 (fix)

fonoteka.go:

  1. Task 1: deleted golden, php_parity.sh env, TestBroadcastGoldens/deleted: 1223fd7 (feat)
  2. Task 2: bulk golden, created/updated pending goldens, README section: bd17fdb (feat)
  3. Task 3: genre security test expects the PHP 401 header: 2164495 (test)
  4. Task 3: realtime routes recorded and replayed: 504f6d6 (feat)

Files Created/Modified

  • modules/tide/centrifugo.go: CentrifugoRecorder, Publication, RecordBroadcasts, BroadcastConfig and the defaults.
  • modules/tide/centrifugo_golden.go: BroadcastGolden load and write, an ordered JSON codec, NormalizePublications and DiffPublications.
  • modules/tide/centrifugo_test.go: smoke tests for the recorder, normaliser, diff, round trip and step recording.
  • modules/tide/README.md: Features, Usage, API rows, and a new CLI commands section with the golden format.
  • cmd/summer/parity.go, main.go, main_test.go: the parity:broadcasts command.
  • modules/bouncer/jwt.go, jwt_test.go, README.md: the 401 now carries Cache-Control: no-cache, private.
  • fonoteka.go parity/:
    • php_parity.sh: the CENTRIFUGO_* values;
    • broadcast flows and goldens;
    • broadcast_goldens_test.go and realtime_seed_test.go;
    • manifest, snapshot, check_corpus.go and count updates;
    • capture rules;
    • the README sections "Broadcast goldens" and "Realtime routes".

Decisions Made

See key-decisions in the frontmatter.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] The jwt.auth 401 lacked PHP's Cache-Control header

  • Found during: Task 3 (Go replay of the no-bearer case)
  • Issue: PHP's 401 carries Cache-Control: no-cache, private, but bouncer's write401 did not send it, so the replay failed with header.Cache-Control: expected no-cache, private actual <missing>.
  • Fix: write401 now sets the header. The bouncer test asserts it, and the README mentions it. genre_security_test.go had used a missing Cache-Control header to prove the handler was not reached. It now asserts the header is present and relies on the jwt.auth body for that proof.
  • Files modified: modules/bouncer/jwt.go, jwt_test.go, README.md; ../fonoteka.go/parity/genre_security_test.go
  • Verification: go test ./... passes in both repos, and so does TestParityCorpus/GET__api_realtime_token_realtime.
  • Committed in: 5382947 (summercms.go), 2164495 (fonoteka.go)

2. [Rule 3 - Blocking] The token secret was too short for php-jwt

  • Found during: Task 3 (recording)
  • Issue: PHP returned 500 with DomainException: Provided key is too short, because firebase/php-jwt requires at least 32 bytes for HS256 and the planned parity-centrifugo-token-secret is 30 bytes.
  • Fix: The test-only default is now parity-centrifugo-token-secret-test-only (40 bytes) in php_parity.sh, the Go constant, the corpus scan list and the README.
  • Committed in: 504f6d6

3. [Rule 3 - Blocking] The route capture rule fired on the no-bearer case

  • Found during: Task 3 (recording)
  • Issue: The capture-rules.yaml jwt:centrifugo rule is merged into every case without its own captures. It therefore failed on the 401 no-bearer case, whose response has no $.token.
  • Fix: The capture is declared on the manifest case itself (the same rule), and recording runs without --rules. capture-rules.yaml gets only a keep_request_headers entry for the subscribe route. It deliberately has no capture: a capture from the request header would overwrite the vars value with the placeholder text or with the wrong-secret literal.
  • Committed in: 504f6d6

4. [Rule 2 - Missing critical] Corpus secret scan for Centrifugo values (T-11-28)

  • The scan in check_corpus.go now fails on any of the three php_parity.sh Centrifugo values. It also fails on an X-Centrifugo-Secret header that is neither a {{var}} reference nor the documented not-the-proxy-secret deny literal. TestUniqueAndSecretScan covers both checks.
  • Committed in: 504f6d6

5. [Scope notes]

  • Presence case. The must-have says "allow on a presence channel (allow and override keys)", but PHP cannot allow any presence channel in this app. For presence:collection:<id>, CollectionChannelAuthorizer parses segment 1 (collection) as the id, gets 0 and denies. As the plan's fallback allows, the corpus records the PHP deny, and Go matches it. The allow-and-override shape stays covered by 11-03's smoke test with a test authorizer.
  • Normaliser scope. NormalizePublications masks ids only under id, *_id and *_ids keys, and as the numeric tail of a channel name. The plan said "any string or number equal to a captured id". Masking every equal number would have hidden count: 2 whenever a collection id is 2. This narrower rule masks less than the plan allowed, which is consistent with the transparency prohibition.
  • Extra API and flags. RecordBroadcasts, BroadcastConfig, DefaultCentrifugoListen, DefaultBroadcastSettle and MaxPublicationBody are new exported names, and parity:broadcasts gains --step, --ids, --rules, --settle and --pending. All of these are documented and checked with go doc.
  • Extra fixture files. The two planned fixture paths hold the case and member cases. The other cases use the corpus's __<case> suffix convention.
  • Extra modified files. parity_contract_test.go (the allow-list and count), migrate_test.go (testConfig realtime keys) and check_corpus_test.go were changed although the plan did not list them.
  • Artist id. The lifecycle flow also captures id:artist, so the Phase 12 album subtree has no coincidental id matches. The first recording masked an artist id as {{id:album}} because the two values were equal. The goldens were then re-recorded.

Total deviations: 4 auto-fixed (1 bug, 2 blocking, 1 missing critical), plus the scope notes above. Impact on plan: Fix 1 is a real parity fix in the framework guard, a non-breaking header addition. Fixes 2 and 3 were needed before PHP could be recorded at all. The scope notes narrow the masking and document one PHP behaviour. No scope creep.

Issues Encountered

  • pkill -f "artisan serve …" matched the invoking shell and exited 144, which left the php -S child running with the old environment. The server was then stopped by its PID and restarted.
  • The deferred item from 11-05 about lighthouse callbacks running after commit for plain gdb.Create did not affect these goldens. All Go writes in TestBroadcastGoldens go through lagoon.Transaction, so it stays deferred to 11-07.

Known Stubs

None. The pending created/updated goldens are intentional (the plan's user decision 5). They are tracked in .planning/WINDOWS.md as a skipped-test entry for Phase 12.

User Setup Required

None. Recording needs the isolated PHP stack and PARITY_CENTRIFUGO_API_KEY; both are documented in the fonoteka.go parity README.

Next Phase Readiness

  • 11-07 (unit tests) should cover these recorder edges:
    • 405 and 413;
    • waitListening failures;
    • flowIDNames;
    • decodePlaceholderJSON error paths;
    • the varsOutsideDir refusal;
    • the parity:broadcasts flag errors.
  • Phase 12 must turn created.yaml and updated.yaml into assertions (delete the t.Skip and resolve the WINDOWS.md entry). Their album subtree carries literal created_at and updated_at values, which Phase 12 must handle, for example with a broadcast normaliser extension or by re-recording with a fixed clock. It uses {{id:album}} and {{id:artist}}.

Phase: 11-jobs-realtime-and-search-infrastructure Completed: 2026-09-30

Self-Check: PASSED

  • All 13 key created files checked exist on disk.
  • summercms.go commits 9ecbf74 and 5382947 exist, and so do fonoteka.go commits 1223fd7, bd17fdb, 2164495 and 504f6d6. Both working trees are clean apart from the pre-existing .planning/milestone.lock and state.json changes.
  • In summercms.go, go vet ./... && go test ./... passes. In fonoteka.go, vet and test of ./..., ./plugins/golem15/fonoteka/... and ./plugins/golem15/user/... pass.
  • TestBroadcastGoldens: deleted and bulk PASS, created and updated SKIP.
  • TestParityCorpus: recorded 171/171 passing 33 failing 0 unrecorded 0 pending 138.
  • check_corpus.go --require-recorded --require-clients --check-secrets passes.
  • Every acceptance-criteria grep and go doc check of the three tasks passes.