Changelog
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-09-05: the blank page, and monitoring that does not run on Vercel
Production outage, fixed
- For four days grailatlas.com returned a complete, correct page that rendered nothing. Every inline script was blocked by the Content-Security-Policy, React never started, and visitors got white.
- Cause: the static-caching work in August removed the per-request nonce from the three places that supplied it, but the policy header kept demanding one. A nonce that changes on every request and a page served from cache cannot both be true, so the nonce is what went.
object-src,base-uriandframe-ancestorsare untouched and carry most of the real protection. - Nobody caught it because every check being run was
curl, which does not execute scripts, apply a CSP, or hydrate a page. Verification now loads the page in a browser and reads the console.
Monitoring that survives the thing it monitors
- An uptime check and a Grail Finder board-staleness alarm now run outside Vercel. A check hosted on the platform cannot tell you the platform is the problem.
- The quota sentinel stopped swallowing its own insert errors and now reports the attestation HTTP status instead of reporting success.
Catalog and browse
- 753 references across 123 brands. The August and September additions filled structural gaps rather than piling onto brands already deep: a travel axis, four alarm watches (the complication was one brand deep), Glashütte Original including a tourbillon, Grand Seiko 9RA2 Spring Drive, Trilobe, Ferdinand Berthoud, a plain Lange 1815 and a real Oyster Perpetual.
- 22 references had no category at all, which made them invisible to every category filter and landing page. All 22 categorized.
- Browse pairs size siblings in results, so a 36mm and a 41mm version of the same watch share a row instead of competing as two entries. If you filter by size, they split again.
- Outbound marketplace links carry the affiliate parameter through the render path, disclosed where the links appear.
Fixes
- A chip on the home page read "N/A".
- The contact page published twelve email addresses where six exist.
- A reference-page footer that collided with itself, a Canada map pin in the wrong place, and an email field 1,700px wide.
2026-08-29: natural language search that answers the question you asked
Query parsing
- "Under $5,000" and "over $5,000" returned each other's results. Negated comparators were being read as their own opposite.
- "$5000" parsed as $500, and "200m" of water resistance parsed as $200 million.
- "Without a date window" excluded nothing.
- Brand short forms and accented names ("AP", "VC", Glashütte Original) matched nothing, because the parser wrote one spelling and the matcher expected another.
- Matching was discarding most of what the parser extracted, so a query carrying four constraints was effectively filtering on one.
- A query with no usable constraint now falls back to a real recall floor instead of an empty page.
Browse and URLs
- A filter the site did not understand used to return results anyway, quietly ignoring the part it could not parse. It says so now.
- Unknown reference URLs return a real 404. They had been returning a soft 404, which let search engines index pages that do not exist.
2026-08-25: the site reads in five languages
Localization
- Reference, brand, complication and style pages are published in Arabic, Chinese, French, German and Spanish at
/[locale]/URLs, with hreflang tags so search engines serve the right one. - A language switcher in the site chrome that only offers a language where a translation genuinely exists, rather than linking readers into English under a foreign label.
- Following a link inside a translated page keeps you in that language.
- Translation moved off DeepL to OpenRouter: better handling of watch vocabulary, at lower cost. Translations are pre-generated rather than fetched per view, and the translation keys are segregated from the rest of the site.
Copy and documentation
- Contractions removed from every user-facing string, in line with the house voice.
- New buyer guide: payment and account security, covering which payment methods leave you with recourse and which do not.
/api-docsis a real page again (it had been nesting a second<html>inside the site), and the published spec now describes the API actually running.
2026-08-17: the site is cached now
Why every page was slow and expensive
- One line in the header navigation read the signed-in user on the server. The header renders inside the root layout, so that single call opted every page on the site out of static rendering. 4,320 reference URLs were rebuilt from scratch on every visit, each with an authentication round trip attached. Nothing looked broken: the cache settings were all present and inert.
- Per-user state (account nav, admin link, report-photo button, saved listings) now resolves in the browser after the cached page arrives. Signed-out visitors and crawlers, which is most of the traffic, make no extra request for it at all.
- The build prerenders 1,067 pages, 720 of them references. Static routes in the build table went from 41 to 103.
- Every prerendered page is checked for leaked per-user data, admin links, email addresses and tokens, across all of them rather than a sample: shared HTML carrying one reader's state would serve it to the internet.
- A test walks the server import graph from each cached entry point and fails on the import that would break caching again, naming the exact chain.
Spend controls
- Natural language search has a per-user quota. Ordinary filters stay free and unmetered.
- The catalog search API is capped and metered before it can spend.
- The storage sentinel was watching disk when the actual overage was egress. It watches egress now, and actually runs on schedule.
- A privacy email worker that returned a 500 on every scheduled run was unscheduled rather than left to fail on a timer.
- Social cards get CDN cache headers, not just browser ones.
2026-08-08: search rebuilt on the whole catalog, and more ways to filter
Search
- Search filters the full catalog and then ranks by embedding, rather than ranking a pre-truncated slice. "World timers" resolves correctly now.
- Full attribute coverage, audited against the data model: every field the catalog stores is something you can ask for, in the units the catalog uses.
- Negation works ("not a chronograph", "no date window").
- When nothing matches exactly, secondary results appear grouped by the one axis that had to be relaxed, so you can see what your constraint actually cost you.
- Price was never a filter at all, in either sense of the word. It is one now, including a visible group for references whose price is unknown instead of dropping them.
- Results render as full watch cards with photographs, not text rows.
New filter axes
- Caliber is a first-class filter. Movement identity was split out of the prose caliber field, which took filter coverage from 10 percent of the catalog to 41 percent.
- Caliber pages show what else uses the same movement, and what those watches cost.
- Winding type, see-through caseback (a real three-state field rather than an assumption), limited-edition size, individual numbering, and collector nicknames.
- Designer attribution, with pages at
/designerfor the people who drew these watches. /referencesmoved to a grid built on one shared watch card, the same card the rest of the site uses.
Catalog accuracy
- 14 movement records corrected, plus a consistency gate so a caliber page and a reference page can no longer disagree about the same movement.
- Caliber facts are checked against the movement registry in CI, and power reserve ranges are no longer misread as single values.
2026-08-05: brand histories, share heroes, and a wider catalog
Reference and brand pages
- Reference pages fold the editorial content into the hero: photograph, a pulled quote, and a share button that uses the native share sheet where one exists and copies the link where it does not.
- Brand pages replaced the photo mosaic with the same hero treatment.
- Brand history for 119 of 119 brands.
- A
/styleshub, with the editorial moved onto the complication pages so the two stop competing for the same search result. - Self-referencing canonical URLs across the indexable site.
Catalog
- New brands and depth across several tranches: Parmigiani Fleurier, three one-man ateliers (Pagès, Brette, Halter), three independent Japanese makers, Lange depth-fill, Grand Seiko Evolution 9, plus Zenith, Panerai and Breitling depth.
- A microbrand section: 14 makers, a world map, honest resale pricing, and a cheaper-alternative engine that names what each microbrand is actually competing with.
- The Glashütte region filled in: Union Glashütte, Wempe, Bruno Söhnle.
- The Atlas map gained a North America inset and raised its European ceiling to 62N, so Nordic brands are no longer cut off.
Blog
- Every post must lead with an image showing the thing the post is about, enforced at publish time. The Lange 1 piece leads with the outsize date, not a generic three-quarter shot.
- No publication date is shown anywhere. These are not news; a visible date makes a considered post look stale rather than settled. The machine-readable date stays for search engines.
- Editor rewrite: the Read view matches what publishes, entities auto-link to their catalog pages, and posts can be reprocessed.
2026-07-29: five new search axes, and a blog engine that stopped repeating itself
Browse
- Thickness, lug-to-lug, beat rate, power reserve and movement certification (COSC, METAS, Geneva Seal) are search axes now. Fit and accuracy were things the catalog already knew and the filters could not reach.
Blog
- The brainstorm engine could not see its own queue, so it kept proposing posts that were already written. It reads the queue now, rotates angles, and stops writing titles nobody would click.
- Brand weighting retuned so one marque cannot take over a week of drafts.
Catalog corrections
- Every F.P. Journe entry described a remontoire those movements do not have. Removed across the board.
- The Chronomètre Bleu dial is layered lacquer, not chrome over silver.
- Corrections to the Lange 1 and El Primero records; tantalum added as a case material.
Operations
- A games-health watchdog that runs off-machine, so a failed Grail Finder refresh raises an alarm instead of leaving a silent gap.
2026-07-22: nine years of GPHG, browsable
The competition catalog at /gphg
- Every entry in the Grand Prix d'Horlogerie de Genève from 2018 through 2026: 2,202 watches across nine competitions, with the winners marked.
- Per-watch pages, year hubs, and a trends view at
/gphg/trendsputting nine years of the competition in numbers. - Faceted filtering with live counts, quick-filter presets, a brand typeahead in place of a 705-option dropdown, and a similar alternatives panel on every detail page.
- Prices converted from Swiss francs at a stated rate, with the top three price tiers visually separated.
- Dial colours read from the photographs rather than trusted from the entry text, and descriptions available in six languages.
- Photographs are self-hosted, not hotlinked off the competition's own servers.
- Sitemap, canonical tags, social cards and structured data, so these pages are findable rather than a walled section.
2026-07-21: the catalog fact-check
A provenance gate on catalog data
- Every reference carries a
sourcesfield, checked against a domain allowlist. A claim with no source it can point at does not ship. - A fact-check generator runs across the catalog, so errors surface as a report instead of waiting for a reader to find one.
What the check found
- Four references were fabricated, and were deleted or retargeted to the real watch: a Vacheron Constantin "Forty-Eight Retrograde", a Blancpain "Villeret Reverse Chronograph", a Ming 27.03, and a Chopard "L.U.C Perpetual Calendar".
- Duplicate rows merged where two entries described one watch: Patek Nautilus 5711/1A, Blancpain Bathyscaphe, Blancpain Air Command, Blancpain Fifty Fathoms Automatique.
- 65 caliber-spec facts corrected across 79 references, each verified against a source independently. Omega's Cal. 3861 is cam-actuated with 26 jewels, not column-wheel with 33.
- The AP Royal Oak Day-Date reference number (26320OR is not a Day-Date), the Fifty Fathoms Automatique case size, the Doxa Sub 300 Sharkhunter dial colour, and the Bulgari Octo Roma reference number.
- 21 dead citations repaired, and a reporting tool added so dead review and video links surface on their own.
2026-07-09: Grail Finder is live on Reddit
The game
- Grail Finder is a daily watch identification game running as a Reddit app: two watches a day, four rounds each, five options per round.
- It is now self-contained. It had been calling grailatlas.com from inside Reddit's webview, which Reddit blocks, and that was the cause of most of what looked like game bugs.
- A menu screen with the leaderboard reachable before you play, a past-days archive, and an evergreen pinned post so the game has a permanent home rather than only a daily one.
- Streaks, badges, player profiles, leaderboards, and a "Most Wanted" board showing the community's grail picks.
The honesty redesign
- The game no longer tells you whether you were right while the day is still running. It shows the share of players who agreed with you, and reveals the answer once the day closes.
- Which also means the answer is not sitting in a payload the browser can read. Watch identity is redacted from every client-reachable response, and the daily endpoint no longer works as an answer oracle.
- Replaying a locked-in answer no longer inflates a score. One lock-in per round.
- Rounds three and four only score if the earlier rounds were right, so a lucky late guess does not read as knowledge.
Provenance
- Every screenshot the game produces carries a visible provenance stamp and an invisible, crop-resistant watermark, so a shared result can be traced back to the play that produced it.
2026-07-04: photo verification that uses more than one signal
The pipeline
- Photographs are checked by two vision models, then corroborated against independent signals: eBay listing photos for the same reference, and a visual comparison against sibling references in the same family. A photo passes when the signals agree, not when one model sounds confident.
- 22 photo entries pointing at Wikimedia files that do not exist were removed, and the manifest is now checked against the files actually on disk.
- An admin dashboard tracking verification performance over time, so a change to the prompt can be measured rather than assumed.
Catalog structure
- References store a resolved caliber slug instead of matching movement names at render time. The runtime matcher was matching on prefixes and getting them wrong.
- 22 missing family entries added for 8 brands that had references but no family page.
- A live catalog-health view for finding gaps.
2026-06-27: recommendations, real prices, and a community page
Pricing
- eBay sold comps drive a price history and a price context bar on reference pages: 25th percentile, median and 75th percentile by condition tier. These are sold prices, not asking prices, and the copy says so.
- A condition explainer showing how five condition grades move the price on that specific watch.
- Save a listing and a daily job emails you when it drops more than 5 percent.
Personalization
/for-youbuilds a profile from your grail list, saved listings and game picks, and recommends against it.- An account dashboard with collection stats: owned count, hunting count, market value.
Community
/communitylists the subreddits, forums and marketplaces worth your time, with a reason attached to each.- A community feed on the home page, pulled from public Reddit.
Analytics
- Analytics moved to PostHog, gated behind consent. Nothing is recorded before you agree to it.
2026-06-16: an authentication guide for every watch in the catalog
Authentication guides
- All 616 references have a guide covering the tells for that specific reference: how the dial printing should read, what the caseback should say, and where the fakes get it wrong.
- Inspection fingerprints for the references counterfeiters target hardest.
Desirability
- Within-family desirability rankings across 327 of 328 families: which reference in a family collectors actually chase, and why.
Photos
- A verification queue with vision-model scoring, an admin review interface, and calibration tracking so the scoring can be checked against human verdicts rather than trusted.
- Auto-sourcing from manufacturer image servers for references with no photograph.
- A photo report button on reference pages. A confirmed bad photo rescores the Grail Finder sessions that used it.
Mobile
- A responsive pass across reference, family, grail-list and browse pages.
2026-06-01: the editorial buildout
Reference and buyer content
- The catalog reached 600 references, up from 536.
- New buyer guides: water resistance, pre-owned markets, watch care, reference numbers, serial numbers, condition grades, finding a watchmaker, and lug-to-lug fit.
- Caliber deep-dive pages at
/caliber/[slug], cross-linked from every reference that uses the movement. - Family narratives for all 279 remaining families, so no family page is a bare list of references.
- Reference pages gained service cost and interval, a wrist-size fit guide, an editorial market price, a dial variant registry, and collector desirability notes. They also print properly now.
- A watchmaking history timeline at
/history, a 2026 market overview, a much larger glossary, and curated landing pages at/best/[category].
Discovery
- Chronograph architecture filters: column-wheel, integrated, modular.
- Same-caliber cross-references, a related-watches section, and a compare-from-notebook flow.
Search engines
- OpenGraph and Twitter cards across every page type: brand, family, caliber, atlas, anatomy, guides, collections, comparisons, and the axis landing pages.
- Schema.org structured data: ItemList on the best-of pages, Article on blog posts, WebSite on the home page.
Privacy
- Data-subject requests are jurisdiction-aware across 11 privacy regimes, each with its own deadline and extension rules, instead of one global assumption.
- A communication thread per request, an email outbox worker, legal holds, and admin alerts for new requests and approaching deadlines.
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
featuredWatchespointing 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: the caliber name, then type, beats per hour, power reserve and jewel count, then one 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
Productschema for Google rich results. - Brand pages: OpenGraph tags added (
summary_large_imageTwitter 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
categoriesfield; coverage analyzer already handled it correctly.
Review links
- Significant expansion of
relatedReviewsacross 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_EMAILenv var across all/admin/*routes. - Admin links surfaced on
/accountpage for easy access.
2026-05-23: review workflow, find-similar, photo sweep, site hygiene
Reference review workflow
- New
/admin/pendingqueue, 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 itspending reviewchip goes away. Migration0022_reference_approvals.sqlbacks the state; the runtime helper insrc/engines/review/review.tsis the source of truth. - Home-page count now reads
N hand-reviewed of 309. When N === total it flips to Hand-curated, earned rather than claimed.
Grail List expansion (collection tracker)
/account/grail-listbecomes 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/profilesettings 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.sqladds status / tier / target-budget / acquired / sold columns. Newaccount_profiletable with RLS for own + public read.
Style-first explore
/explorenow 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.findSimilaralready 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
/explorestate with that ref pre-pivoted and deep-links straight into round 2. Falls back to the bare reference page ifEXPLORE_STATE_SECRET/CSRF_SECRETis unset (local dev only).
Photo sweep
- New shared
RefThumbcomponent 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+verifyOtpflow via a new/auth/confirminterstitial: 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-infor expired tokens. - New
/api/auth/resend-verificationroute 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/roparenders live fromropatable; 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.comis the canonical domain.
Atlas (manufacture map)
- New
/atlaspage with Natural Earth country boundaries, per-brand pin locations, and 17 country drill-downs at/atlas/[code]. Built fromsrc/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
/exploreno longer silently teleports to/resultwhen 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 buildstep added to CI after the test + tsc steps.SupabaseFeedRepositorywired through all five SSR routes viaassessActiveFeedCached. Third-panel recurring P1: finally shipped. Default env staysFEED_REPO=seed; flipping tosupabaseroutes every render through the Supabase repo with seed-fallback.
Watch-domain factual fixes
- BB58
caseHeightMm11.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
verifyStateshape check tightened: picked ≤ 12, seen ≤ 400, string elements ≤ 80 chars, round ∈ [0, 100].Cache-Control: no-storeon POST/api/saved-searches(revealOnce body carries cleartext).validateQueryShapecross-field invariants (min ≤ maxon 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
/exploreexploration-profile shape the saved-search row persists. - ROPA RP-02 enumerates the 16 allowed JSONB keys, describes both origin paths (form +
/exploresave), and adds thevalidateQueryShape-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-aperturesplit fromskeletonin 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/explorecomplications form./explore/resultreframed 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 with user hardness and 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-pearlbracelet(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-tonecaseHeightMinMm+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.ts: findSimilar() 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.ts: findSimilar(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.tsimplements all fiveFeedRepositorymethods with realselectqueries against the live schema. snake_case → camelCase mapping is broken out into testable pure functions (rowToReference,rowToPlatform,rowToSeller,rowToListing,rowToComp).currentlyListed/knownSoldare derived from the lifecycle columns (sold_at,delisted_at).auctionis populated fromis_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.tsrunsbuildFeed()against a fake-Supabase-backedSupabaseFeedRepositoryand asserts one listing flows end-to-end through the engine pipeline to a realListingAssessment. 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
/movementsgrouping 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)/faqwith FAQPage JSON-LD,/glossarywith 17 labels/guideshub 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/*.mdvia 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. /accountdashboard 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 --noEmitclean. - 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)
guardMutationcomposes 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+/securitydisclosure 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
/ogand/og?ref=(Edge ImageResponse) - PWA manifest +
/icon+/apple-iconicon routes - Twitter card + OpenGraph metadata on every page
- RSS at
/feed.xmlwith 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.tsdecorates outbound platform links with EPN tags whenNEXT_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.NewsletterSignupcomponent 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 --noEmitclean
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 apinetwork 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