May 20, 2026

Every Filter, Both Engines, Byte-Identical

Full Client/Server Filter Parity (#186)

Yesterday's #171 fix aligned the saved-search checker with the filters it already knew about. Today closed the rest: every one of the ~30 homepage filters now has an exact server-side mirror, shipped in five batches:

  1. Eight trivial property filters (debark day, roundtrip, region include/exclude, cruise line include/exclude, exclude-dates, warmth).
  2. Itinerary and availability filters (ports include/exclude, special days with the holiday windows replicated, cabin-available).
  3. Ship metadata (class, year, capacity, Playmakers).
  4. All 12 price filters (PP/Daily/Total × 4 cabins) plus lopsided and lopsided-suites.
  5. All 10 per-user filters (lists, booked, notes, casino, B2B casino, instant offer, no-overlap, B2B booking -- including friend scopes).

The per-user filters were then refactored onto an overlay-merge pattern: one loader returns the exact same data structure the browser's /api/user/sailing-overlays endpoint serves, so each PHP predicate is a near line-for-line translation of its JS counterpart. Future drift becomes a code-review item instead of a hidden JOIN-semantics surprise.

Proving It: Set Hashes, Not Counts

Identical counts can hide different sets, so the parity tests now compare the full sailing-ID set via SHA-256 hash -- 41 anonymous + 7 authenticated Playwright scenarios. That immediately caught a real bug: the checker was matching hidden sailings the homepage never shows.

There's also a new admin-only parity button on the homepage: click it with any filter combination active and it runs the same filters through the server-side checker, diffs the ID sets live, and shows ✓ match or the exact delta. It's how several of today's bugs were found on production data.

Three Rounds of Adversarial Audit

An independent audit (Codex) turned up 15 parity gaps, all closed across three rounds -- highlights: lopsided compared only adjacent cabin tiers while the JS compares every pair; the guest-count default was 2 in PHP but 1 in JS; expired casino offers weren't excluded; substring filters were case-insensitive where JS String.includes is case-sensitive; and port-key normalization differed subtly between the booked-overlay side and the candidate side. Round-5 verification: all clean.

After today, the two engines produce byte-identical match sets for every combination in the suite. The synonymity requirement is now documented as a hard rule for any future filter: JS engine + PHP checker + unit test + parity scenario, or it doesn't merge.

Dependency Patch

symfony/mime bumped to close two email header-injection CVEs published this morning.