## Summary
Stack 3 of 3, built on https://github.com/AI-Builder-Team/Aerie/pull/1408 (navigation), which is built on https://github.com/AI-Builder-Team/Aerie/pull/1407 (classification and payload contract). Base is the navigation branch, so this diff shows only this PR's work.
This is the piece that makes the page answer "where are the differences?" and lets the answer be shared.
- Where the differences are panel at the top: for each of the 19 compared fields, how many joined sites differ on it (real vs formatting only, as a stacked bar), sorted by count descending, each with 3 example sites showing the raw production and Surtr values as literals. Fields that agree everywhere are listed on one line. A field that differs on every site is the tell for a representation artifact rather than a data disagreement, and here the reader can see the actual values that differ instead of guessing (for example null vs []).
- Click a field to filter: the site list narrows to the sites that differ on it. Because that spans mismatched and formatting-only sites, it switches to the All tab, and a chip clears it. The tab counts follow the filter.
- Export CSV: site_id, field, kind, production, surtr for every differing field of every site in the current view (the whole filtered list, not just the 50 rendered). kind is real, formatting or unparseable. Values are the same JSON-style literals the page shows, so null, "" and [] stay distinguishable in a spreadsheet. Written through the shared CSV writer, which neutralizes formula-shaped cells (covered by a test).
- Copy summary: plain text with the headline counts (Matched / Formatting only / Mismatched, production only, Surtr only), any data-quality warnings, and the per-field panel with examples. It always describes the whole payload, independent of the filters. A missing or refusing clipboard raises a toast (through the shared toUserMessage pathway) instead of failing silently.
All client-side; the route and the payload are unchanged in this PR.
One change outside the comparison page: the shared downloadCsv (chat/components/dashboards/shared/csv-export.ts) already caught and logged a failed download but told the caller nothing. It now returns true once the download is triggered and false when it failed, still never throwing, and the Export CSV button raises a toast on false. Existing callers (school ops, diligence, diligence work units, P&L breakdown) ignore the return value and are unaffected; their suites pass. Those four exports still do not tell the user when a download fails; that is pre-existing and left for their own PRs. Mercy noted it as a deferred, non-blocking finding.
## Class audit across the stack
The user asked for whole error families rather than single instances. Across the three PRs:
- Representation differences treated as data disagreement: null vs [], null vs ''/whitespace, timestamp format and precision, and (found while auditing valuesMatch) a blank string silently equal to a real zero and an array equal to a scalar. All fixed or classified in the first PR. Anything not documented stays a real difference and shows up in this panel with its raw values, rather than being guessed away.
- The inverse family, where a UI hides a representation difference: null, [] and '' were all rendered as a dash or blank. Values now render as literals everywhere: table, panel, CSV and copied text.
- Small-row-count and flat-list assumptions: pagination, tabs and search in the second PR; per-field summary here. The route still returns every row in one response (about 1 MB at today's size, a few MB at the cohort's 1000-row soft cap); noted, deliberately not changed.
- Mercy's one finding on the first PR (offset minutes not range-checked) was fixed for the whole parser (every clock component, and years below 100), not just the cited line.
## Business Value
The page exists to decide whether Surtr's REBL3 mirror can be trusted against production. The reviewer's first question is "which fields disagree, and are they real?", and this panel answers it in one screen: on a payload shaped like the live one, a single field disagreeing on every site is visible immediately, with the two raw values side by side, so the team can accept or reject a normalization rule in minutes instead of opening hundreds of rows. Export and copy make the finding portable: a spreadsheet for the Surtr owner and a paste-ready summary for the thread, which is how these questions actually get resolved.
## Manual Effort Estimate (proposal, for Keval to confirm/adjust)
About 5 focused hours for this PR by hand with no AI: per-field summary with samples and ordering about 1.5h; panel, field filter and chip about 1.5h; CSV rows, summary text and clipboard/toast error handling about 1h; tests, including the formula-injection and clipboard-failure cases, about 1h. Whole three-PR stack: about 20 focused hours.
Linear: no ticket filed yet (no Linear tool in this session); to be linked.
## Test plan
- [x] pnpm --dir chat exec vitest run on the report, lib, route, view and page test files: 179 passed, plus the new downloadCsv browser test: 3 passed; and the school ops, diligence, financials and shared dashboard suites that use the CSV helper: 924 passed
- [x] pnpm --dir chat exec tsc --noEmit
- [x] pnpm lint (only 2 pre-existing warnings in an unrelated file)
- [ ] Not verified in a browser: the page is behind Clerk auth and the Next dev server is not run in this workflow. The CSV download and clipboard write are exercised at their boundaries (the shared downloadCsv and navigator.clipboard are mocked), so the real file save and the real clipboard permission prompt are unverified.
🤖 Generated with [Claude Code](https://claude.com/claude-code)