GrailAtlasAn independent reference for mechanical watches

Changelog

DRAFT:this policy is in working-draft form pending review by qualified counsel. The content is the Grail Atlas team's best-effort framework; treat it as informational until the watermark is removed.

Reverse-chronological summary of what shipped on Grail Atlas. The full commit history is in the repo; this page is the human-readable lens.

2026-05-30 — Phase 2 start: NL search, photo quality, TypeScript clean, editorial expansion

Natural language search on /browse

  • NLSearchBox now calls POST /api/search/natural-language — full Claude intent parsing combined with Voyage semantic embeddings.
  • Results show "Looking for: [brass-colored intent summary]" above matches: brand, era, style, size, dial color, budget parsed from the query.
  • Budget filtering wired into the query executor (USD cents converted for structural matching).
  • Fires on submit only; no per-keystroke Claude API calls.

Photo quality cleanup

  • 218-issue audit completed (docs/photo-audit-2026-05-30.md). Fixed the majority:
  • 7 quartz/digital policy-violation refs removed from the catalog (536 refs remain).
  • 30 wrong-watch photo entries removed (wrong brand, wrong variant, multi-watch retail displays).
  • 2 watermarked Rolex Submariner images replaced with CC BY-SA 3.0 clean shot.
  • 9 orphaned pocket-watch files deleted from disk (Lange cluster).
  • Case-back shots, side-profile shots, white-balance scans: entries removed where no replacement available.
  • 81 photos resized to 1200px max — approximately 100MB page weight reduction.
  • Low-res entries (under 600px) removed in favor of WatchArt fallback.
  • Explore candidate pool filtered to the 416 refs with real photos; WatchArt-only picks no longer appear.

TypeScript cleanup

  • 54 pre-existing errors resolved: noUncheckedIndexedAccess, string indexing on typed objects, optional spread, missing module stubs.
  • Project remains at 0 TypeScript errors.

Anatomy guide expansion

  • 9 bracelet/strap entries added: Oyster bracelet, Jubilee bracelet, integrated bracelet, leather strap, rubber strap, NATO strap, mesh bracelet, end links, bracelet finishing.
  • All 47 anatomy entries now have featuredWatches pointing to real catalog refs; detail pages show RefThumb photo cards.
  • Anatomy hub section heading updated to "Exterior" to reflect new strap/bracelet scope.

Caliber descriptions

  • 438 caliber fields enriched with: movement type, beat rate, power reserve, jewel count, and one distinctive technical fact.
  • Format standardized: "Brand Cal. N -- type, XbpH, Xh PR, Xj; distinctive fact."

Atlas improvements

  • 22 single-brand regions (Wienerwald, Frankfurt, Tuscany, Helsinki, Tokyo, etc.) no longer show a dark map with one dot. Now shows: region name, "One watchmaking address in [Region]: Brand in City" with direct link, then brand card below.

SEO

  • Reference pages: hero photo URL added to JSON-LD Product schema for Google rich results.
  • Brand pages: OpenGraph tags added (summary_large_image Twitter card).

Discovery fixes

  • Recently-added and trending lists had stale ref IDs (returned empty lists). Updated to real catalog IDs.
  • Hero gallery pool expanded from 17 to 23 refs: added Grand Seiko, Vacheron, Longines Spirit, Blancpain Fifty Fathoms, Lange 1815, Rolex GMT Pepsi, IWC Portugieser.

Blog brainstorm

  • Category-angle candidate re-enabled: was disabled due to a misread of the plural categories field; coverage analyzer already handled it correctly.

Review links

  • Significant expansion of relatedReviews across Omega Speedmaster variants, TAG Heuer Monaco/Carrera/Autavia/Aquaracer, Longines Heritage/Spirit/Conquest, Tissot Heritage variants, Nomos Metro/Orion/Lambda, Grand Seiko SBGE285/SBGY007, A. Lange Datograph Up/Down and Little Lange 1, and others.
  • All URLs verified live before adding; sources: Fratello, Monochrome Watches, SJX Watches, Worn & Wound.

2026-05-29 — content sweep, map labels, blog cron, admin hardening

Em dash sweep

  • Exhaustive sweep across app/, src/, lib/, components/ removed all em dashes from editorial content, comments, and copy.

Atlas map

  • Region-level maps now show permanent brand + city labels next to each pin instead of hover-only tooltips.
  • Country-level maps keep CSS hover with SVG <title> native tooltip fallback.
  • Tooltip Y position clamped to never clip at panel top.

Blog infrastructure

  • Daily blog cron live (8 AM UTC via Vercel cron). Generates 5 draft candidates; Brett reviews before any publish.

Admin hardening

  • Auth enforced via ADMIN_EMAIL env var across all /admin/* routes.
  • Admin links surfaced on /account page for easy access.

2026-05-23 — review workflow, find-similar, photo sweep, site hygiene

Reference review workflow

  • New /admin/pending queue, auth-gated to a single email allowlist

(ADMIN_EMAIL). Every new reference an agent adds lands in the queue by default; Brett approves each one before its pending review chip goes away. Migration 0022_reference_approvals.sql backs the state; the runtime helper in src/engines/review/review.ts is the source of truth.

  • Home-page count now reads N hand-reviewed of 309. When N === total

it flips to Hand-curated. — earned, not claimed.

Grail List expansion (collection tracker)

  • /account/grail-list becomes a real collection tracker: **Wanted

(Realistic / Dream) → Owned → Sold**. Inline editor per entry for note, target budget, mark-as-owned, mark-as-sold.

  • New /account/profile settings page: username, public toggle,

show-prices toggle, display name, bio.

  • New public profile at /u/[username] — server-renders the list with

no-enumeration semantics (private/missing/invalid all 404).

  • Migration 0023_grail_list_expansion.sql adds status / tier /

target-budget / acquired / sold columns. New account_profile table with RLS for own + public read.

Style-first explore

  • /explore now has a category tile grid above the pivot picker.

Dive, Pilot, Chrono, Dress, Field, Sport-Lux, GMT/Worldtime — one click and you're in round 2 with that category as the constraint.

  • findSimilar already handled null-pivot + categories-only, so the

engine didn't need changing; the UI just exposes that path.

Find similar → deep-links

  • Each watch on the home hero gallery and every individual reference

page now has a Find similar → chip that server-signs a fresh /explore state with that ref pre-pivoted and deep-links straight into round 2. Falls back to the bare reference page if EXPLORE_STATE_SECRET / CSRF_SECRET is unset (local dev only).

Photo sweep

  • New shared RefThumb component renders the 56×56 press-or-wikimedia

thumbnail with WatchArt fallback. Wired through 13 row-list surfaces: home Featured References, brand index, family + sub-line, category / era / tier / under, picker, search (refs + editorial), reference-page Related, explore-result, compare-card, 404 suggestions. Press photos use the brand press kit's editorial-use-with-credit policy.

Sitemap + robots hygiene

  • Sitemap is now catalog-driven for families (178), sub-lines (72),

and atlas country drill-downs (17) — previously only 3 family slugs were hand-listed. Net add: ~265 sitemap entries from routes that already build.

  • Robots adds disallow rules for /admin, /auth/confirm,

/privacy/confirm, /privacy/cancel, /explore/round, /explore/result, /sign-in, /sign-up, /u/. The token-URL blocks matter for privacy: leaking those via SERP would let crawlers scan-attack the confirm/cancel flows.

Auth (scanner-resistant)

  • Email verification now uses Supabase's token_hash + verifyOtp

flow via a new /auth/confirm interstitial: the GET renders a form, the POST consumes the token. Mail-scanner prefetches no longer burn the link. Added a "Need a new verification link?" flow on /sign-in for expired tokens.

  • New /api/auth/resend-verification route with anti-enumeration

and a signup-bucket rate limit.

2026-05-22 — GDPR-as-floor, audit chain, Atlas, catalog wave 1-3

GDPR / DSAR infrastructure

  • New automated DSAR pipeline (POST /api/privacy/request) with

byte-identical 202 across all four enumeration scenarios + ±50ms latency budget. Confirm + cancel flows use single-use signed token URLs with 48h cooling-off on erasure (immediate on access).

  • Migrations 0016-0021: privacy_requests, privacy_audit_log

(immutable hash chain with TRUNCATE / event-trigger guards), ropa, salt_archive, protected_emails, privacy_email_outbox.

  • Hash-chained audit log with three layers of immutability (SQL

triggers + advisory locks + event triggers blocking DROP/TRUNCATE). Hourly external attestation via GitHub Actions to a separate repo for tamper detection that survives service-role compromise.

  • /legal/ropa renders live from ropa table; ROPA-coverage CI gate

ensures any migration touching personal data also touches the ROPA.

Brand rename + visual identity

  • Project renamed GrailWatch → Grail Atlas site-wide (locked

2026-05-20). Parchment-on-ink palette flipped across the whole site (no dark mode). grailatlas.com is the canonical domain.

Atlas (manufacture map)

  • New /atlas page with Natural Earth country boundaries, per-brand

pin locations, and 17 country drill-downs at /atlas/[code]. Built from src/data/seed/brand-locations.ts — Switzerland, Germany, Japan are the headline drill-downs; smaller carve-outs (Sweden, Denmark, Italy, etc.) get their own pages once they cross the ≥2-brand or ≥2-studio threshold.

Catalog expansion (waves 1–3): 63 → 309 refs across 49 brands

  • Wave 1 (CAT-DEEP + CAT-NEW): +97 refs across 22 new families and

11 new brands.

  • Wave 2 (W2-A under-$5K hub + W2-B independents + W2-C deeper

sweep): +99 refs.

  • Wave 3 + follow-up: +58 refs, +8 boutique brands, +2 countries.
  • Press photos from 22 brand press kits (editorial-use-with-credit)

alongside Wikimedia Commons (CC0 / CC-BY / CC-BY-SA only). Brand press-kit policy documented at src/data/seed/photos.ts.

  • Reference page renders the full photo gallery (press primary,

then editorial, then Wikimedia); related-reads pulls in curated reviews + click-to-consent YouTube posters.

2026-05-21 — Round Y: 8th-panel cleanup

Cross-panel consensus items shipped

  • /explore no longer silently teleports to /result when

the constraint set is impossible. New explicit "your filter is too tight" UI with three honest paths (loosen, see partial result, start over).

  • Speedmaster 3861 worked example wired from / (new "WHAT

AN HONEST ASSESSMENT READS LIKE" section), /about (lede callout), and /press (top-of-page worked-example card).

  • Sitemap updated to include /notebook, the Notebook #0

essay, the Speedmaster worked example, /explore, and /legal/sub-processors. The two editorial deep-reads bumped to priority 0.9.

  • next build step added to CI after the test + tsc steps.
  • SupabaseFeedRepository wired through all five SSR routes

via assessActiveFeedCached. Third-panel recurring P1 — finally shipped. Default env stays FEED_REPO=seed; flipping to supabase routes every render through the Supabase repo with seed-fallback.

Watch-domain factual fixes

  • BB58 caseHeightMm 11.9 → 12.7 (crystal-included trade

convention; reads close to the 14060M's 12.5).

  • Notebook #0 supercase chronology: the supercase chassis

arrived on the date Sub in 2010 (116610LN) and the no-date Sub in 2012 (114060), not "2010" as the essay said.

AppSec hardening

  • verifyState shape check tightened — picked ≤ 12, seen ≤

400, string elements ≤ 80 chars, round ∈ [0, 100].

  • Cache-Control: no-store on POST /api/saved-searches

(revealOnce body carries cleartext).

  • validateQueryShape cross-field invariants (min ≤ max on

the three paired ranges).

  • Matched-variant chip carries a "canonical-entry pricing

shown; per-variant comps not yet in catalog" footnote.

Legal

  • Privacy §2 enumerates the /explore exploration-profile

shape the saved-search row persists.

  • ROPA RP-02 enumerates the 16 allowed JSONB keys, describes

both origin paths (form + /explore save), and adds the validateQueryShape-enforced gates to Security.

  • Notebook #0 opinion disclaimer added pointing to

corrections@ role address.

  • Speedmaster 3861 worked example "hypothetical" banner at top

of page.

Product/Growth

  • /explore/result "Save as search" promoted to a primary CTA

(gold-bordered card) at the top of the "If you want to narrow" section.

Tests: 523 passing. tsc --noEmit clean.

2026-05-21 — Round W: heartbeat split + family-result + editorial

User-direct fixes

  • heartbeat-aperture split from skeleton in the Complication

union. A heartbeat-aperture is a partial dial aperture showing the balance wheel (Lange Datograph Up/Down at 6, Ulysse Nardin Freak, Roger Dubuis et al.); a fully-openworked skeleton dial is a different watch. They share collector vocabulary but not movement-architecture meaning. Both appear separately in the /explore complications form.

  • /explore/result reframed as a family, not a finalist. The

prior shape framed one reference as "the answer." The new shape surfaces a taste cluster — the user's picks plus the engine's nearby unseen references, rounded to 7 — and replaces the "save this exploration as a search" CTA with three honest narrowing paths (save-as-search, walk-from-here from any family member, open the editorial bodies). No single reference is highlighted as "the pick." The user instruction was specifically that taste discovery should not collapse to one watch.

Editorial pre-launch

  • Tudor BB58 editorial body shipped. The reference was in the

catalog without one; full body now lives in src/data/editorial/index.ts. Five sections matching the rest of the corpus: heroLede, history, pitfalls, marketNote, serviceNote.

  • /postmortems/speedmaster-3861-launch-example — the worked

example the Product/Growth panel asked for. End-to-end walk through a hypothetical Speedmaster Professional 3861 listing: landed cost → band → tier → trust → risk → verdict. Every step cross-referenced to engine code. This is the URL we point press and Show-HN at.

  • /notebook + /notebook/0001-why-the-14060m — Grail

Notebook archive index + Issue #0. The first essay is "Why the 14060M" — the four-questions framework for picking a first reference. Header nav now includes Notebook.

Tests: 523 passing (no engine changes; new files are presentation). tsc --noEmit clean.

2026-05-21 — Round V: /explore v1.1 — user hardness + new criteria

What's new vs v1

Per-section hardness toggle. Each constraint group in the seed form now ships with a must match / prefer radio at its heading. The user decides per-section whether a violation drops a candidate entirely (hard) or only reranks it (medium). Sensible defaults: budget / case-size / case-height / case-material / bracelet / dial- color are hard by default; era / categories / excluded-brands / complications / numerals are medium. prefer-without on tri-state attributes (complications, numerals) is always hard regardless of the section toggle — the user typed an explicit negative.

Four new constraint dimensions.

  • dialColors (multi-select) — black / white / silver / champagne /

cream / blue / green / grey / brown / salmon / rose / gold / mother-of-pearl

  • bracelet (multi-select) — leather-strap / rubber-strap /

fabric-strap / metal-bracelet-removable (Oyster, Jubilee, President) / metal-bracelet-integrated (Royal Oak, Nautilus)

  • caseMaterial (multi-select) — steel / four golds / platinum /

titanium / ceramic / bronze / two-tone

  • caseHeightMinMm + caseHeightMaxMm — case thickness range

Expanded Complication vocabulary. Added skeleton, tourbillon, jumping-hour, minute-repeater, retrograde, equation-of-time, regulator, dead-beat-seconds, dual-time. None of the 30 current catalog refs carry any of these; the catalog-thin fallback returns the result page early if the filter set is so tight nothing matches.

Catalog annotations (v0). Every reference now carries the four new fields. dialColors lists the most-commonly-traded factory colors per reference (not exhaustive on prolific refs like the Datejust). caseMaterial lists factory production variants (15202 ships in 5; the canonical entry pictures steel). bracelet captures factory strap/bracelet options. caseHeightMm is a single number per reference, accurate to a tenth of a mm.

Matched-variant chip on round cards. When a user's filter matches a non-canonical variant of a multi-valued reference (e.g., the user filters for rose-gold; the 15202 catalog row is canonically steel but ships in rose-gold), the round card surfaces a chip "rose-gold variant matches" rather than silently treating the canonical-steel entry as the match. v1 doesn't have per-variant comps so the price/picture stays canonical; the chip is the honest UI for the gap.

Engine extended. src/engines/explore/explore.tsfindSimilar() honors per- section hardness via constraints.hardness. New scoring axes for dial color, bracelet, and case material. SimilarityHit gains an optional matchedVariants field carrying the specific variants that satisfied the filter. 12 new engine tests cover the new dimensions, the hardness override behavior, the always-hard prefer-without, and two realistic filter compositions (Brett's GMT-or-worldtimer steel-only under-$15k filter and the friend's Arabic-only no-complications dress filter).

Saved-search JSONB extended. validateQueryShape accepts the new fields (caseHeightMinMm/Max, dialColors, bracelet, caseMaterial). The result-page "Save as search" CTA pre-fills the entire constraint set so ingest-day alerts honor what the user actually walked into.

Tests: 523 passing (up from 511). tsc --noEmit clean. Backtest gate green.

2026-05-21 — Round U: /explore discovery engine v1

New product: /explore

A discovery engine built around paired comparison rather than multiple-choice quiz. The seventh panel cut /picker because it asked users to declare what they wanted upfront; /explore asks them to show taste by reacting to specific watches. Up to 6 rounds, hard-capped. Two entry modes: random-seed (start with 4 random catalog references) or user-seed (start with a watch you already know you like). Constraints (budget, case size, era, category, excluded brands, complications tri-state, numeral style tri-state, color preference) are all optional and applied as either hard filters or medium-strength penalties.

Engine. src/engines/explore/explore.tsfindSimilar(pivot, candidates, constraints, options). Pure feature-overlap scoring: brand-tier proximity (20), era proximity (15), category Jaccard (18), case-size proximity (12), movement-tier (10), price-band proximity from market median (15), complications Jaccard (10), dial-numeral match (5). Medium-penalty pass subtracts 12 per soft-violated constraint. Diversity reserve surfaces at least one unseen-brand candidate in every round. Per-candidate explanation chips ("shares: integrated-bracelet · neo-vintage · 38mm") make the loop legible — no swipe-loop. 12 unit tests cover the engine.

State. src/engines/explore/explore-state.ts — HMAC-signed URL state mirrors the CSRF token pattern. Session blob carries the constraint set, pick history, and round counter; 6-hour TTL; canonical-JSON envelope means key-order independence; falls back to the CSRF secret when EXPLORE_STATE_SECRET isn't set. 9 unit tests cover sign/verify, tamper detection, expiry, malformed input, and pick advancement.

Catalog annotations. All 30 references now carry complications?: Complication[] and dialNumerals?: DialNumerals on WatchReference. v0 annotations — hand-curated against the editorial bodies. Future refinement passes will be tracked in their own commits.

Pages.

  • /explore — seed form, every field optional.
  • /explore/round?s=... — round panel of 4 with per-card

explanation chips, "new to you" badge for diversity-reserve picks, "Pick this" / "Read more" buttons per card, "Stop and see result" exit.

  • /explore/result?s=... — one-paragraph taste read in the

patient-compendium voice, final pick card, "Save as search" CTA pre-filled with the full constraint set + pivot reference, plus 3 "you might also like" references that survived the constraints and were never shown.

Header nav. New "Explore" link between "Deals" and "Calculator."

Saved-search JSONB schema extended to carry the full exploration constraint set: minPriceUsd, caseSizeMinMm/Max, era, categories, excludedBrands, preferredComplications, excludedComplications, preferredNumerals, excludedNumerals, pivotReferenceId. validateQueryShape in app/api/saved-searches/route.ts enforces the new keys + types (string-array length caps, numeric ranges, no unknown keys).

Cuts (deferred to v2)

  • Voyage embedding tie-breaker. Feature-overlap is the v1

baseline; the hybrid layer comes later. Will wire the same way Upstash did — env-flag-detected.

  • Color matching. Catalog lacks the structured field; v1

stores the user's color preference and surfaces it on the result page only.

  • Account-bound state. v1 ships URL-signed state only; the

signed-in-user server-side mirror lands in a follow-up.

Tests: 511 passing (up from 490). tsc --noEmit clean. Backtest gate green.

Design doc: docs/superpowers/specs/2026-05-21-explore-design.md.

2026-05-21 — seventh-panel review + Round T

Seventh expert-panel debate (Engineering / AppSec / Legal / Watch Domain / Product)

  • Five panel reports filed under docs/advisory/2026-05-21-seventh-panel-*.md.
  • Synthesis at docs/advisory/2026-05-21-seventh-panel.md — sets the

Round T priority list.

  • 21 commits pushed to origin/main; the GitHub repo is now current.

Round T — finish Round Q's half-shipped work

Cookie consent end-to-end. CookieBanner.tsx previously wrote localStorage['gw-consent-v1']; the cookie policy + ROPA both claimed the source of truth was a __Host-consent cookie. The seventh panel flagged the three-way drift. The banner now writes a real first-party gw-consent cookie (Path=/, SameSite=Lax, Secure over HTTPS); the policy §4 inventory + ROPA RP-04 are rewritten to match. The __Host- prefix was considered but silently fails on http://localhost, which was the latent source of the drift.

Cookie banner 404. CookieBanner.tsx linked to /policies/cookie-policy; the real route is /legal/cookies. Fixed.

Three editorial fact-fixes. Speedmaster Reduced caliber base ("Frédéric Piguet 1285" → "ETA 2892-A2 base + Dubois Dépraz 2020 chronograph module"); IWC Mark XVIII IW327011 pitfall (the standard Mark XVIII has a date at 3 — the no-date variant is the IW327007 Tribute-to-Mark-XI); Daytona 16520 caliber-4030 mods (replaced "replaced regulator" with the actual changes: Glucydur balance, Microstella regulating system, redesigned escape wheel + pallets).

Movement classifier. Zenith El Primero family (the Rolex-modified caliber 4030 in the Daytona 16520, the bare El Primero in the Zenith A386) was misclassified as unknown. IWC 30110 was also unknown despite sitting on a Sellita SW300 base that's already top-supplier. Both classified explicitly as top-supplier now.

Hash-at-rest end-to-end (finishes migration 0010). The RSS feed route (/feeds/saved-search/[id]) now SHA-256-hashes the inbound token and queries against feed_token_hash — cleartext column is no longer read by any code. GET /api/saved-searches drops the cleartext from the response shape; lists return only a 6-char display suffix of the hash. POST /api/saved-searches returns the cleartext once in a revealOnce field; the UI shows a copy-this-URL modal and the cleartext is never re-fetched. Migration 0011 adds proof-of-consent columns to newsletter_subscribers (opt_in_ip, confirm_ip, confirm_token_expires_at); the subscribe + confirm routes populate and enforce them. Migration 0012 drops the cleartext saved_searches.feed_token column entirely.

Newsletter-confirm uses the shared async rate-limit store. Round Q wired a per-IP bucket but the route owned its own InMemoryRateLimitStore — bypassing Upstash and reintroducing the cold-lambda leak. Now routes through getAsyncRateLimitStore().

GET /api/saved-searches rate-limit + shape check. Authenticated ≠ unbounded. publicRead bucket attached; the query JSONB column is validated against a narrow { brand?: string; maxPriceUsd?: number } shape so a logged-in adversary can't write arbitrary JSON into the column.

WebhookSecurityEventSink env-driven wiring. getSecurityRecorder() now picks WebhookSecurityEventSink when SECURITY_WEBHOOK_URL is set; falls back to stderr otherwise. Setting the env var in Vercel finally does what the docs already claimed.

ROPA [/legal/sub-processors] unbracketed. The page exists.

/picker cut from the public surface. Removed from header + footer nav; removed from the homepage "browse references" row; removed from the /guides/first-watch outbound link. Page itself ships robots: { index: false, follow: false }. Engine + JSON API remain in case /compare or future internal tooling depends on the scoring function. Watch Domain + Product/Growth + Eng/Ops three-panel consensus.

Calculator verdict line. Engine output now translates into a Wait / Buy / Pass recommendation + one reasoning sentence above the raw numbers. Pure function over the existing scoreable / dealTier / risk outputs — no new policy, just translation. Product/Growth P1.

/references editor's-note: "How we chose these 30." Replaces the count-only framing with a stance: one canonical reference per archetype, hype-only references off the starter list, 24 bodies we'd defend over 60 stubs we'd explain away. Product/Growth P1.

SupabaseFeedRepository feature-flag swap-over scaffold. New src/data/repo/active-feed.ts exposes getFeedRepository(), assessActiveFeed(), assessActiveFeedCached(). Default is seed; FEED_REPO=supabase switches to the Supabase repository with seed-fallback when the Supabase listing set is empty; FEED_REPO=supabase-strict honors the empty set honestly. The five route files that import assessSeedFeedCached can migrate one at a time without coordination.

Tests: 490 passing (up from 485). tsc --noEmit clean. Backtest CI gate still passes.

2026-05-21 — sixth-panel review + Round Q

Sixth expert-panel debate (Engineering / AppSec / Legal / Watch Domain / Product)

  • Five panel reports filed under docs/advisory/2026-05-21-sixth-panel-*.md.
  • Synthesis at docs/advisory/2026-05-21-sixth-panel.md — sets the Round Q

priority list.

Round Q — credibility, hygiene, and the Upstash swap

Watch-domain factual corrections. COSC-1910 (Rolex predates COSC by 63 years — corrected to the Bienne and Kew Observatory precision certificates); "Hans Hess" attribution on the Submariner family page (corrected to René-Paul Jeanneret); 145.022 Apollo-program scope softened (Apollo 11 was on the earlier 105.012 / 145.012); Daytona 16520 El Primero date module ("simplified" → "removed entirely"); Datejust 16234 first-sapphire framing qualified; Royal Oak 15202 "salmon dial" scrubbed (salmon is the platinum 16202 50th-anniversary variant); Lange 1 first-big-date claim softened (IWC's Da Vinci Perpetual in 1985 predates it); Nautilus 3700 "Calatrava case maker" corrected to Favre & Perret + 1976 retail corrected to 3,100 CHF.

Movement classifier. Valjoux 72 (king of vintage chrono calibers — used on manual-wind Daytonas 6263/6265 and the Heuer Carrera 2447) was misclassified as unknown prior to this round. Added Valjoux 23 / 72 / 88 / 92 / 720, Lemania 1872 / 1340 / 1341 / 5012 / 5100, and Frédéric Piguet / Peseux 7001 / A. Schild / FHF mid-supplier regexes.

Brand tier. Rolex moved from top-luxury to luxury. The trade does not place Rolex in haute-horlogerie alongside Patek / AP / VC / Lange — Rolex is the king of luxury sport, but a two-tone Datejust is not a top-luxury reference. Backtest gate still passes.

Catalog framing. Home page hero and /references lede now state "30 hand-curated references" rather than implicitly claiming comprehensive coverage. Audience-managed-expectation framing.

Editorial voice. Removed duplicate "Honest" on the home-page hero ("Honest watch deals. Honest watch data." → "Watch deals, read carefully.").

Trust engine. Softened the published-string on onStolenRegistry from a factual assertion to a hedged reference ("appears on" → "is listed on a third-party stolen-watch registry; we do not verify registry submissions"). Defamation-defense framing.

Banned-phrase regex. Expanded language-bans.test.ts with a new ACCUSATORY_PATTERNS test that catches accusatory framings about specific sellers/listings while allowing legitimate educational mention (the postmortems documenting the policy, the FAQ explaining the graded-risk approach, the counterfeit-awareness guide). The exact patterns are in the test file. Allowlist extended for the ToS warranty-disclaimer clause that explicitly disavows any such accusation.

Newsletter privacy stack. Added RP-10 to the ROPA (double-opt-in semantics, 72h pending-token TTL, 24-month proof-of-consent retention). Three rows in the retention schedule. New paragraph in privacy policy §2.

Cookie inventory. §4 placeholder filled in with the four cookies the site actually sets: __Host-csrf, sb-<project>-auth-token, __Host-consent. Notes per-saved-search RSS authenticating via opaque token rather than cookie.

Sub-processors page. New /legal/sub-processors page lists Supabase, Vercel, Railway, Upstash, Mapbox, Voyage, DeepL with location, role, data, transfer mechanism.

SecurityEventCategory honesty. Expanded the union from a single admin.action overload to 11 specific categories. 12 routes updated to tag honestly.

Sign-out rate-limit. Moved /api/auth/sign-out from the ugcWrite bucket (5/min) to publicRead (60/min). Refusing to let a user sign out is worse than the DDoS surface.

GitHub Actions SHA-pinning. actions/checkout@v4 and actions/setup-node@v4 are now pinned to commit SHAs.

/api/newsletter/confirm rate-limit. Per-IP publicRead token bucket on the confirm-link GET.

feed_token hash-at-rest (migration 0010). Added feed_token_hash column to saved_searches, backfilled from SHA-256(feed_token), indexed. Route-handler swap-over is a follow-up.

Upstash-backed rate-limit store. New src/engines/rate-limit/upstash-rate-limit.ts implements AsyncRateLimitStore against the Upstash REST V2 endpoint using a single Lua EVAL per request (atomic refill+decide+write). Wired into route-security.ts via getAsyncRateLimitStore() — when UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN env vars are present, lambdas share buckets; when absent, falls back to the in-process store. 11 unit tests cover the adapter via a mock-fetch test seam. Runbook at docs/runbooks/upstash-rate-limit.md.

Round R — Supabase end-to-end pipeline

The Engineering panel headline P1: "Make the engine pipeline run against a Supabase-backed feed end-to-end." SupabaseFeedRepository was previously a skeleton — every method threw "not yet implemented." Now:

  • src/data/repo/supabase-feed-repository.ts implements all five

FeedRepository methods with real select queries against the live schema. snake_case → camelCase mapping is broken out into testable pure functions (rowToReference, rowToPlatform, rowToSeller, rowToListing, rowToComp).

  • currentlyListed / knownSold are derived from the lifecycle

columns (sold_at, delisted_at). auction is populated from is_auction + the auction columns.

  • 14 unit tests cover the row mappers and the chain-of-calls the

repository makes (table name, eq/is filters, order, limit).

  • New supabase-feed-pipeline.test.ts runs buildFeed() against a

fake-Supabase-backed SupabaseFeedRepository and asserts one listing flows end-to-end through the engine pipeline to a real ListingAssessment. Also covers the orphan-listing skip (missing reference / platform / seller) and the empty-feed posture.

The home page + /api/deals still read the seed by default; the swap-over is a follow-up gated on the first ingest. The point of Round R is that the Supabase path is no longer a skeleton — the integration test proves the pipeline runs against Supabase, which was the panel's actual ask.

Tests: 485 passing (up from 456 at start of session). tsc --noEmit clean. Backtest CI gate still passes after the Rolex tier-reclassification.

2026-05-20 (afternoon, 16 rounds in one day)

Catalog → 30 references, 24 with editorial bodies

  • New references with full editorial bodies: Submariner 14060M,

Speedmaster Pro 3861, Speedmaster Pre-Moon 145.022, Daytona 16520, F.P. Journe Chronomètre Souverain, Datejust 16234, Tank Louis Cartier, Royal Oak 15202, Speedmaster Reduced, JLC Reverso Classic, Lange 1 191.039, IWC Mark XVIII, Cartier Santos medium, GMT-Master II 16710, Explorer II 16570, Heuer Carrera 2447, Zenith El Primero A386, Royal Oak 14790, Vacheron Patrimony 81180, Speedmaster Pro 145.012 transitional, Rolex Explorer 1016, Patek Nautilus 3700, Lange Datograph 403.035, Omega Constellation "Pie-Pan" 168.005.

  • 7 additional references without editorial yet (BB58, Speedy Pro,

Snowflake, Shunbun, Submariner 126610LN, JLC Master Control, Tudor Pelagos 39).

  • Brand histories for 15 brands hand-authored.
  • Movement bestiary at /movements grouping every caliber by tier.

Tools

  • Deal Calculator at /calculator — paste a listing, get the full readout
  • /compare — two-listing side-by-side scoring, shareable URL
  • /vs/{refA}-vs-{refB} — head-to-head deep links for reference pairs
  • /picker — preference-based recommendation wizard
  • /search + /api/search — token-scored catalog + editorial search
  • Per-category, per-tier, per-era, per-brand, per-price-band landing

pages — /category/[c], /tier/[t], /era/[e], /brand/[slug], /under/[budget]

Content + storytelling

  • /about (who's behind it), /principles (the five governing

principles), /how-it-works (full methodology deep-dive)

  • /faq with FAQPage JSON-LD, /glossary with 17 labels
  • /guides hub with 7 guides:

- 10-minute inspection checklist (with HowTo JSON-LD) - Watch condition grading - Box & papers — what counts as 'full set' - Understanding watch movements - How to read a seller's feedback profile - Shipping & duties for international watch buyers - Counterfeit awareness - Buying your first serious watch - Watches are not investments

  • /postmortems — five entries documenting what GrailWatch got wrong
  • /stats — live, honest catalog snapshot
  • /changelog (this page)
  • /press — press kit with boilerplate + screenshots/OG URL refs
  • /legal/disclosure — affiliate + monetization disclosure
  • All policy pages rendered from docs/policies/*.md via a

custom-Markdown renderer (no client-bundle markdown library)

Account + auth

  • Sign-in / sign-up / sign-out post through /api/auth/* route handlers

with CSRF + rate-limit + anti-enumeration timing floor + sec-event recording. The canonical anti-enumeration message replaces Supabase's specific error text.

  • /account dashboard with persisted saved searches and Grail List —

RLS-isolated to the owner.

  • Sign-in page honors ?next= (same-origin-only).
  • Header shows signed-in display name + Sign out button when

authenticated.

Database

  • Migrations 0001 (catalog) → 0007 (newsletter subscribers) applied to

the live Supabase project. Each user-touching table has per-owner RLS.

  • pgTAP-style RLS test SQL pack at supabase/tests/rls.test.sql.

Engine

  • 20 modules; 430 tests passing; tsc --noEmit clean.
  • Backtest accuracy gate with curated comp panels + holdout cases on

Speedy 3861, Sub 14060M, and BB58 (regression-bound CI gate).

  • Movement-tier classifier (manufacture / top-supplier / mid-supplier /

entry / unknown) feeding the Reliability rating.

  • Service-state factor feeding valuation (recent-service +3%,

service-needed -8%, unserviced-vintage -3%, undisclosed neutral).

  • Three-bucket era (vintage / neo-vintage / modern) with full

factor rows.

  • Brand-classifier overhaul: Vacheron + JLC to top-luxury, TAG Heuer +

Hublot demoted to enthusiast, modern independents added.

  • Hardened market-momentum (≥8 comps per half, ±5% stable band,

trimmed median, compressing / steady / dispersing regime detection).

  • Score audit trail on every published score (canonical-JSON SHA-256).
  • Code-simplifier refactor pass on trust / sybil engines.

Security (full stack wired into route handlers)

  • guardMutation composes CSRF + rate-limit + security-event +

anti-enumeration timing floor in one canonical helper.

  • Per-route CSRF double-submit, per-IP rate limits, structured

sec-event recorder with secret-redacting metadata pass.

  • SSRF egress guard (safeFetch) for all outbound HTTP.
  • Service-role key isolation (static-scan + runtime browser guard).
  • Async scrypt on the HTTP path.
  • __Host- prefix + Secure + HttpOnly + SameSite=Strict cookie default.
  • Webhook-shipping sink for sec-events.
  • HTTP headers (CSP, HSTS, X-Frame-Options DENY, COOP / CORP,

Permissions-Policy).

  • /.well-known/security.txt + /security disclosure policy.

Legal + policy

  • Privacy, Terms, AUP (with DSA Art. 16), Cookie, Security disclosure,

Affiliate disclosure.

  • DPIA, ROPA, retention schedule, standalone LIA for the audit-trail

override.

  • Banned-phrase test prevents a specific trademark-exposure tagline

plus accusatory language ("fraud / scammer / crook / thief") from appearing in published copy.

Discovery + SEO

  • Sitemap with every URL category (references, brands, eras, tiers,

categories, price bands, guides, every static page)

  • WebSite + Organization JSON-LD on every page
  • HowTo JSON-LD on the inspection-checklist guide
  • BreadcrumbList JSON-LD on reference + brand pages
  • FAQPage JSON-LD on /faq
  • Product + AggregateOffer JSON-LD on reference pages
  • Dynamic OG image at /og and /og?ref= (Edge ImageResponse)
  • PWA manifest + /icon + /apple-icon icon routes
  • Twitter card + OpenGraph metadata on every page
  • RSS at /feed.xml with auto-discovery in <head>

Developer surface

  • /api/deals?country= — merit-ranked feed JSON
  • /api/reference/[id] — per-reference JSON
  • /api/brands — brand index JSON
  • /api/categories — category index JSON
  • /api/movements — movement bestiary JSON
  • /api/search?q= — JSON search
  • /api/health — uptime + commit + engine version
  • All rate-limited; edge-cached.

Affiliate plumbing (latent, env-gated)

  • src/lib/affiliate.ts decorates outbound platform links with EPN

tags when NEXT_PUBLIC_AFFILIATE_TAGS_ENABLED=1 + campaign id set. No-op when not enabled. Architecturally separated from scoring.

Newsletter scaffolding

  • Migration 0007 — newsletter_subscribers with double-opt-in + RLS

lockdown.

  • /api/newsletter/subscribe — CSRF + rate-limited; canonical anti-

enumeration message.

  • /api/newsletter/confirm?token= — flips pending → confirmed.
  • NewsletterSignup component on the home page.

Ops

  • CI workflow with npm audit --omit=dev --audit-level=high.
  • Backup-drill runbook + secret-rotation runbook.
  • Assessment cache layer (migration 0005 + Supabase + in-memory

repos + cache-first SSR adapter).

  • Railway worker skeleton for periodic reassessment.
  • eBay-ingest worker spec with three pending user-side decisions

documented.

  • CI supply-chain hygiene doc.

Tests

  • 430 passing across engine, lib, web, eval layers
  • tsc --noEmit clean

What's still deferred (user input or external dependency)

  • Vercel SSO toggle, domain purchase, DMCA agent registration, counsel

engagement, eBay Partner Network + Marketplace Insights application

  • External account creation (Sentry, Logflare, Renovate)
  • GitHub Actions SHA pinning (needs gh api network access)
  • Live migrations 0004 / 0005 / 0006 / 0007 against Supabase
  • RLS test pack run against a Supabase preview branch
  • The 16 stacked local commits pushed to origin/main
Changelog | Grail Atlas