Skip to content
Chapter 95Lesson 7

Verify and self-grade

The whole audit is wired and documented — Sentry catches the deliberate throw, the logger redacts and correlates, PostHog waits for consent, eight findings sit filled in your findings/ directory. None of it is confirmed. This lesson asks the question a launch review actually turns on: how do you know the audit is done, not just attempted? You answer it three ways. You walk the verify recipe one surface at a time — Sentry dashboard, dev console, Network panel, PostHog dashboard, findings/ — and stop on the first failure. You commit the work in one irreversible commit. Then you open the solution/ answer key beside your own and score yourself clause by clause.

That ordering is the point, and it is the same honor system the pre-launch audit ran on. A real launch review has no answer key — nobody hands you a marking scheme afterward telling you which gaps you caught, because nobody planted them. The bugs are just there, and either you found them or you shipped them. So the value here is not the score. It is the rehearsal: running the entire pass under no peeking until committed, then grading yourself honestly and writing down a measured backlog. When you are done, your findings/SUMMARY.md sits open beside solution/findings/SUMMARY.md — a 10/10 scorecard, both bonus findings written out in full, every finding scored on rule and location and fix, and a personal checklist of the per-category surfaces you re-run next pass. That SUMMARY.md is the portable artifact: the one file you attach to a launch-review summary to prove the audit happened.

Run the full verify recipe in order, surface by surface, and stop on the first failure. The recipe order is not arbitrary. The Sentry step comes first because if the deliberate throw lands with a minified Function.t [as h] (chunk-abc123.js:1:42) stack instead of a readable one, your source-map upload is broken — and everything downstream is built on the assumption that the wiring works, so running the PostHog verify on a half-broken build wastes your time. A minified stack on step one means you go back and fix the SENTRY_AUTH_TOKEN gate before you touch step two. Walk the surfaces the way the running app exposes them: hit /api/test/throw with the Sentry dashboard open, replay a webhook and read the dev console, open / in incognito with the Network panel filtered to ingest, click Accept and watch the PostHog dashboard, then read your findings/ files one last time.

The most common miss on this pass is the consent gate’s two belts, and it is worth naming before you verify it. Inexperienced engineers flip only the init flag — opt_out_capturing_by_default: true — see zero pre-consent events in the Network panel, decide the gate works, and ship. Then post-consent events never fire either, because the grant path never called opt_in_capturing(): default-out alone keeps PostHog silent forever, even after the user clicks Accept. The gate is a pair — capture off by default and an explicit opt-in on the consented branch — so verify both belts: zero requests before consent, and a $pageview landing in the dashboard within thirty seconds after Accept. One without the other is a broken gate that looks fine from one angle.

Once every surface is green, commit. The commit is the honor-system boundary — git add -A && git commit -m "Unit 19 observability wired + audit findings" — and you do not open solution/ until it lands. The temptation to peek and quietly fix a finding is exactly the reflex this rehearsal trains you out of: a finding you “found” by reading the answer is not a finding you can repeat on a codebase nobody has graded. After the commit, score yourself clause by clause against solution/findings/. The scoring rule is partial credit on rule + location: a finding that names the right rule and the right call site has cleared the floor for its category even if its fix is thinner than the answer key’s, because the rule is what makes the finding actionable. The common partial-credit pattern is a matching rule and location with a different-but-valid fix seam — per-icon imports instead of optimizePackageImports on the barrel, a hand-written innerJoin instead of the relations API on the N+1, naming the composite index without generating its migration. Each scores; the reach is the more thorough half the answer key names, and where you missed it you write it down as a gap.

Two outputs are the deliverable, and they are the only src/ writes this lesson permits — there is no new source code here, only the findings/ artifacts and the git commit. findings/SUMMARY.md is the coverage-and-evidence document, not a list of finding titles: it carries the coverage count, the clause-by-clause scoring rubric, the per-finding senior-reach detail across all eight findings, both bonus findings referenced, and a personal checklist of the surfaces to re-run. findings/out-of-scope.md records the deliberate cuts — at least one observation that falls outside the eight audit categories, parked so a future pass inherits it without inflating the count. The two bonus findings get written for the reach above the floor: 009 names the raw <link> font on the marketing layout, and 010 names the missing composite (org_id, created_at) index on invoices, proven with EXPLAIN ANALYZE and with its migration actually generated — declaring the index in the schema without running drizzle-kit changes nothing in the database, so that half is what separates full credit from half.

One line of scope to hold clear: this pass finds, confirms, and documents — it does not patch the performance findings. Shipping the waterfall, the LCP-image, and the N+1 fixes belongs to the backlog, not this lesson. The one exception is the barrel import you already fixed in place earlier in this chapter to produce the analyzer before/after. Everything else stays documented, with measured impact, so the team can prioritize it against feature work.

The Sentry dashboard shows the deliberate /api/test/throw tagged with the current release, with navigation breadcrumbs, a readable (source-mapped) stack, and a requestId that matches its log line.
untested
The dev console shows stripe-signature rendering [REDACTED], requestId as a top-level field on every log line, and the webhook error’s Sentry breadcrumbs hold no un-redacted signature.
untested
The Network panel shows zero pre-consent /ingest requests, a $pageview reaching the PostHog dashboard within 30 s after Accept, and capture stopping on Reject.
untested
findings/SUMMARY.md carries a coverage count, a clause-by-clause scoring rubric, the per-finding senior-reach detail across all eight findings, a personal checklist, and references both bonus findings (next/font + the composite index).
tested
findings/out-of-scope.md records at least one out-of-category observation, parked as a deliberate cut rather than scored.
tested
Bonus finding findings/009-missing-next-font.md names the raw-<link> font on the marketing layout with the rule/location/consequence/fix template filled.
tested
Bonus finding findings/010-composite-index.md names the missing composite (org_id, created_at) index on invoices, proven with EXPLAIN ANALYZE (Seq Scan + in-memory Sort flipping to Index Scan), with the migration actually generated.
tested
All eight in-scope finding files (001–008) are present and filled, and findings/006-barrel-import.md embeds both before/after analyzer screenshots.
untested
The work is committed before the solution/ answer key is opened.
untested
Coverage is scored clause by clause against solution/, applying partial credit on rule + location and noting the answer key’s senior-reach detail as a gap where missed.
untested
A backlog of out-of-scope follow-ups is written down: ship the waterfall / LCP-image / N+1 fixes, add the CI gate, wire the Vercel Log Drain once deployed, add the no-img-element lint rule, add the composite-index migration.
untested

Run the verify recipe surface by surface and commit your work first. Then open solution/findings/ beside your own and score each finding against the partial-credit rule from the brief — rule and location clear the floor, fix detail is the reach. Only after you have scored yourself, read the reference deliverable below.

Reference solution and walkthrough

There is no source code to write in this lesson — the deliverable is two Markdown files plus the two bonus findings, scored against the answer key. Here they are as they land in the repo, with the reasoning behind the non-obvious choices.

The summary opens with the headline coverage number and the scorecard. Every category got a finding, so the floor is met at 8/8, and the two bonus findings push the count to 10/10.

findings/SUMMARY.md
# Audit coverage scorecard
The eight categories are the pass. This scorecard records what the audit covered, scores each finding clause-by-clause against the answer key, names the bonus findings reached above the floor, and folds every discovery surface into a personal checklist for the next pass.
## Coverage
**10/10** — the 8/8 floor (one finding per category) plus both bonus findings.
| # | Finding | Category | Half | Severity |
|---|---|---|---|---|
| 001 | Sentry not wired | Error monitoring (092 L1) | wired | critical |
| 002 | `stripe-signature` logged in the clear | Structured logs / 3am rule (092 L3) | wired | high |
| 003 | No request correlation id | Structured logs / correlation (092 L2) | wired | medium |
| 004 | PostHog captures before consent | Consent-gated analytics (093 L3, 081 L5) | wired | high |
| 005 | Dashboard RSC waterfall | RSC waterfall (094 L6) | documented | medium |
| 006 | `lucide-react` barrel import | Bundle size / barrel + analyzer (094 L3/L4) | documented (one-line fix) | high |
| 007 | Missing `preload` on the LCP hero | LCP / Core Web Vitals (094 L2) | documented | high |
| 008 | N+1 in the invoice list | N+1 at the DB layer (094 L7) | documented | medium |
| 009 | Marketing font via raw `<link>` | LCP / Core Web Vitals (094 L1/L2) | bonus, documented | medium |
| 010 | Missing `(org_id, created_at)` index | Missing DB index (094 L7) | bonus, documented | medium |
No deliberate misses: every one of the eight categories landed a finding, so the floor is met at 8/8 and the two bonus findings (009 `next/font`, 010 the composite index) push coverage to 10/10. The split is the chapter's structural lesson — **the four observability findings (001–004) are wired** (the diff between `start/` and `solution/`), **the four performance findings (005–008) plus both bonuses are documented**, never patched, with the sole exception of finding 006's one `optimizePackageImports` line (the analyzer before/after is the required evidence).

The coverage count is the headline because it is what a launch review is actually asking: did every category get looked at? A deep dive on one category that leaves another silent is the failure mode this number guards against. The Half column is the chapter’s structural verdict made visible — observability findings are wired, performance findings are documented — and you carry it because the two halves get different dispositions, which the verdicts section below makes explicit.

Next, the scoring rubric — the part you applied as you graded yourself. It scores each finding on the four template sections rather than pass/fail, and names exactly where a match earns only partial credit.

findings/SUMMARY.md
## Scoring rubric (clause by clause)
Each finding is scored against the four template sections, not as pass/fail:
- **Floor (the finding lands):** **Rule** names the specific chapter-092/093/094 rule and its lesson section, and **Location** names the file + line range *and* the diagnostic surface that surfaced it (a grep, a DevTools trace, the Network panel, `pnpm next experimental-analyze`, `.toSQL()`, `EXPLAIN ANALYZE`). Rule + Location is the floor: it proves the auditor found the real defect at the real call site by the real method.
- **Reach (the finding is senior):** **Consequence** is operator- or user-visible (a timing, a leaked secret, lost data — never "code smell," never "could potentially") and **Fix** names the installed seam (wired findings) or the senior reach by its helper/config (documented findings). Consequence + Fix-detail is the reach.
- **Partial credit:** Rule + Location match but the Fix is less thorough. The named partial-credit lines per finding:
- **006** — per-icon imports (`lucide-react/dist/esm/icons/<icon>`) instead of `optimizePackageImports`: shrinks the bundle but is per-call-site churn, half-credit against the single-seam config entry.
- **008** — a hand-written `innerJoin`/`leftJoin` instead of `findMany({ with: { customer: true } })`: removes the N+1 but reintroduces manual row-shaping, half-credit against the relations API.
- **010** — naming the composite index without generating the migration: the index changes nothing until the migration runs, half-credit against declare-plus-generate.
- **009** — naming only the render-blocking request and stopping, without `next/font`'s fallback-metrics (the CLS half): half-credit.
A severity with a two-line justification is part of every finding's score — calling a wired observability gap "low" or a documented performance defect "critical" is itself a scoring miss (the verdict, below, is the discipline).

The rubric is deliberately ordered. Rule and location are the floor because they are what make a finding actionable — a teammate can act on “the barrel-export rule is violated in (protected)/layout.tsx, found by the analyzer treemap” even if your proposed fix is thinner than the ideal. Fix detail is the reach because it is exactly where inexperienced engineers stop short, and the four partial-credit lines are the named places that happens. Note that “location” is not just a file and a line — it is the diagnostic surface that surfaced the defect, the grep or the DevTools trace or the EXPLAIN ANALYZE plan, because that is what proves you found the defect by the real method and can repeat it on a codebase nobody has graded.

Then the per-finding reach detail. This is the checklist you run your own findings against in the side-by-side — for each of the eight in-scope findings, the fix clause inexperienced engineers most often stop short of.

findings/SUMMARY.md
## Senior-reach detail per finding (what students most often miss)
The Rule + Location floor is the common pass; the reach below is what separates a senior audit. Self-grade against this list.
- **001 (Sentry):** release computed from `VERCEL_GIT_COMMIT_SHA` (never hardcoded — a hardcoded `'v1.0.0'` ties a week of unrelated errors to one version), `widenClientFileUpload: true`, source-map upload gated on `SENTRY_AUTH_TOKEN` at **build** time (missing token ⇒ a minified "line 1 column 12345" stack), `onRequestError = Sentry.captureRequestError` (the most-omitted piece — framework-boundary throws never reach Sentry without it), and only the four `withSentryConfig` keys (`silent`, `org`, `project`, `widenClientFileUpload`).
- **002 (log leak):** **one `redact`, two callers** (Pino's `redact` config *and* Sentry's `beforeSend` from a single definition), with the wildcard `*_KEY`/`*_SECRET` patterns that catch the next secret a developer adds without touching the seam. Scrubbing at each call site instead of one seam is the named trap.
- **003 (correlation):** `AsyncLocalStorage` (never module-level state, which bleeds one request's id into the next under concurrency), the Pino `mixin` stamping `requestId` automatically, the `requestId` joined to the Sentry event as **context** (not a tag — a per-request value would explode the low-cardinality tag index), set request-scoped *inside* `beforeSend` (never at module scope, where there is no request at boot), and the response header echoed so downstream services join the same request.
- **004 (consent):** the load-bearing **pair**`opt_out_capturing_by_default: true` *and* `posthog.opt_in_capturing()` on the consented branch (default-out alone never captures even after consent; a banner acting only on "Accept" leaves "Reject" in the default state) — both routed through the one `consent.ts` seam, plus belt two (the consent-gated dynamic `import('posthog-js')`) and the session-continuity re-call of `opt_in_capturing()` on mount when the cookie is present.
- **005 (waterfall):** `Promise.all` the **independent pair only** (`invoices`/`members`), leaving `user → org` sequential — wrapping all four is the "wrap everything" anti-pattern that breaks the real dependency. React `cache()` (not `unstable_cache`) is the named companion for request-scope dedup.
- **006 (barrel):** `optimizePackageImports` as the single seam (not per-icon imports), with `sideEffects: false` named as the internal-package companion lever.
- **007 (LCP image):** `preload` exactly once per page (the Next.js 16 prop, renamed from `priority`), `width`/`height` as the CLS protection layer, and the `@next/next/no-img-element` ESLint rule at error as regression prevention.
- **008 (N+1):** `findMany({ with: { customer: true } })` (the relations API emits one lateral-join statement — the "the ORM secretly N+1s" fear is dead), verified with `.toSQL()` (one statement, not N).
- **009 (font):** `next/font` for self-hosting (kills the third-party render-blocking request on the LCP path) *and* its size-adjusted fallback metrics (kills the swap reflow / CLS) — both halves.
- **010 (index):** the leftmost-prefix composite `(organization_id, created_at, id)` *and* the `drizzle-kit`-generated migration, verified with `EXPLAIN ANALYZE` (the `Seq Scan` + in-memory `Sort` flips to an `Index Scan`).
The final analyzer treemap (finding 006's `after-barrel.png`) is secondary evidence for the whole bundle-size half of the pass — paste it here alongside the per-finding embed as the at-a-glance proof the heaviest avoidable weight is gone.

Read these as a pattern, not ten unrelated facts. Each one is the half that closes the actual hole, listed because it is the half engineers skip. The detail itself is owned by the lessons that wired or documented each finding — finding 1’s onRequestError and the four withSentryConfig keys are the Sentry wiring from earlier in this chapter, finding 4’s two belts are the consent gate, findings 5–8 are the performance work. The summary names the reach so the side-by-side has a checklist; it does not re-teach the rule, because your job here is to confirm coverage, not relearn it.

Then the two senior verdicts — the disposition each half of the audit earns, and the reason getting the verdict right is itself part of the discipline.

findings/SUMMARY.md
## The two senior verdicts
The categories split into two verdicts, and getting the verdict right is part of the discipline:
- **Observability gaps (001–004) close *before* launch.** They lose data — an invisible incident, a leaked secret, an unjoinable trace, behavior captured without consent. You cannot recover the error you never saw or the consent you never asked for. These are wired in `solution/`; they are not backlog items.
- **Performance gaps (005–008, 009, 010) go to the *backlog* with measured impact.** They are slow, not bleeding — the app renders correct data, just heavier or later. They ship documented (with the timing, the byte count, the query count, the plan node) so the team can prioritize them against feature work, and they are fixed deliberately, not in a documentation pass (mixing a fix into a documentation pass is the named trap of this chapter).
The structural pattern threaded through both halves: **wire the seam, prove it on the running surface, document what you won't fix yet, and self-grade honestly.** Each cross-cutting rule lives at exactly one seam the team configures once — Sentry's `beforeSend` redactor, the logger's `AsyncLocalStorage` mixin, the consent `grant/revoke` pair, `optimizePackageImports` — and **coverage over depth** is the audit ethic: one finding per category, the floor met before any bonus, the count honest.

This is the senior payoff of the whole chapter, so sit with it. The two halves are not graded the same because they fail differently. Observability gaps lose data — you cannot recover the error you never captured or the consent you never asked for — so they close before launch, which is why they are the four findings you wired into solution/ rather than documented. Performance gaps are slow, not bleeding — the app renders correct data, just heavier or later — so they go to the backlog with measured impact, documented precisely enough (a timing, a byte count, a query count, a plan node) that the team can rank them against feature work. Mixing a performance fix into this documentation pass is the named trap: you would be patching on a branch whose job was to find and measure, and the before/after evidence would be gone. The one exception is finding 006’s single optimizePackageImports line, fixed in place precisely because the analyzer before/after is the required proof.

Then the personal diagnostic checklist — the artifact you actually keep.

findings/SUMMARY.md
## Personal diagnostic checklist (read the running app first)
The discipline the reference finding (007) established: open the running app, hold it beside the source, read one finding's fingerprint, write it before moving on. Each category has a surface that names the defect faster than reading code — fold them into the next pass:
- [ ] **Errors** — hit `GET /api/test/throw` with the Sentry dashboard open: does a decoded event land? (finding 001)
- [ ] **Log hygiene** — replay a webhook (`stripe trigger …`) and watch the dev console: does any header/secret print where `[REDACTED]` belongs? (finding 002)
- [ ] **Correlation** — trigger the throw and compare the log lines to the Sentry event: do they share a `requestId` you can pivot on? (finding 003)
- [ ] **Consent** — open `/` in incognito with the Network panel filtered to `ingest`/`posthog`: does anything fire before the banner? (finding 004)
- [ ] **RSC waterfall** — record a DevTools Performance trace of `/dashboard`: is there a staircase of sequential spans with idle gaps? (finding 005)
- [ ] **Bundle size** — run `pnpm next experimental-analyze` and open the treemap under `.next/diagnostics/analyze`: any single dependency tile dominating a route? (finding 006, the analyzer)
- [ ] **LCP path** — DevTools Performance LCP marker + the Network panel on `/`: is the LCP element `preload`-ed, and is the font self-hosted or a render-blocking third-party `<link>`? (findings 007, 009)
- [ ] **N+1** — DevTools/Sentry trace of `/dashboard` + `.toSQL()`: is the query count flat, or 1 + N? (finding 008)
- [ ] **Indexes**`EXPLAIN ANALYZE` the org-scoped, ordered reads: a `Seq Scan` + in-memory `Sort` where an `Index Scan` belongs? (finding 010)

This is the portable half of the audit. Each line is a surface that names the defect faster than reading code — the Sentry dashboard for errors, the Network panel for consent, the analyzer treemap for bundle weight, EXPLAIN ANALYZE for a missing index. Folding every discovery surface into a reusable checklist is the senior reflex that sharpens each pass: a miss this time becomes a line in this file, and the next codebase starts from a running checklist instead of a blank page. The eight categories are this unit’s instance of the method, not its limit.

Finally the forward pointers — what this pass deliberately leaves for the chapters ahead.

findings/SUMMARY.md
## Forward pointers
What this pass deliberately leaves for later units:
- **CI gates** — wiring the source-map-upload verification, a bundle-size budget, and `@lhci/cli` into a GitHub Actions gate so these checks run on every PR is **chapter 097** (Unit 20). Findings name "wire this into CI later" as a *follow-up*, never as the fix.
- **The Vercel Drain** — shipping the structured logs to a queryable drain (Axiom/APL) is a deploy-time concern, **chapter 098**. The logger work names the drain as the follow-up; it does not wire one here.
- **The migration workflow** — bonus 010's index uses the Unit-5 migration mechanics; the expand-migrate-contract workflow for zero-downtime schema changes is **chapter 100**.
- **Reviewing a seeded PR** — applying this same read-the-surface, name-the-seam discipline to a teammate's pull request is **chapter 104**.

That is SUMMARY.md. The forward pointers double as your backlog: the CI gate that catches these regressions on every PR is wired in the deployment chapter on continuous integration; the Vercel Log Drain that reads this logger in production goes in once you deploy; the composite-index migration ships with the rest of the performance backlog. None of them is the fix here — they are the follow-ups this pass names so the next sprint has a list.

The second file records the observations that are real but off-category. The discipline it enforces is keeping the coverage count honest — an out-of-category observation never inflates or deflates the eight-category count; it goes here so the next pass inherits the context.

findings/out-of-scope.md
# Out of scope
Observations outside the eight audit categories — recorded, never scored as findings.
The discipline this file enforces: a launch-review audit scores against its declared categories (the eight observability + performance categories of this pass). Things you notice that fall outside those categories are still worth recording — a future audit or a backlog grooming pass may pick them up — but they are **not** scored here and do not count toward or against coverage. Writing them down separately is how a senior keeps the scorecard honest: the count in `SUMMARY.md` reflects only the in-scope categories, and out-of-scope noise never inflates or deflates it.
## Observations
- **Denormalized `customerName` on `invoices` duplicates the `customers.name` it now references.** `src/db/schema.ts` (the `invoices` table) keeps a `customerName` text column from the pre-095 lineage *and* a `customerId` FK to the new `customers` table (and the `customer` relation). The name now lives in two places, so a customer rename leaves the invoice's `customerName` stale unless every write updates both. This is a data-modeling / code-quality observation — drop `customerName` and read the name through the relation, or keep it as a deliberate point-in-time snapshot (the name *as billed*) and document that intent. It is **not** one of the eight categories (it is neither an observability gap nor a measured performance defect — the N+1 in finding 008 is the performance issue on this table), so it is recorded here, not scored.
- **The `/api/test/throw` route is a deliberate diagnostic affordance, not a defect.** `src/app/api/test/throw/route.ts` throws unconditionally on `GET`. In a real review this would read as a finding ("an unguarded route that 500s"), but here it is the *provided proof target* for finding 001's deliberate-throw test, documented in the README. Recording it as out-of-scope is the honest move: name why it is intentional so a future audit doesn't re-flag it, and note that it must not ship to production (gate it behind a non-production env check before launch). Not scored — it is test scaffolding, not a category defect.
These are observations, not findings. They carry no severity and no clause-by-clause score; they exist so the next pass (or the backlog) inherits the context, and so the eight-category count stays clean.

The denormalized customerName is the canonical out-of-scope entry: it is a real data-modeling smell the audit saw, but it is neither an observability gap nor a measured performance defect, so scoring it as a ninth finding would inflate the coverage count with something off-category. The /api/test/throw entry shows the other reason this file exists — naming an intentional affordance so a future audit does not re-flag the proof target as a defect. One column for findings, one for parked observations — keeping them separate is what lets the coverage number mean something.

Bonus finding 9 is on the same LCP path as finding 7, written to the same template. It is the reach above the 8/8 floor: a render-blocking third-party font on the marketing layout.

findings/009-missing-next-font.md
# Finding 009 — Marketing page loads its font via a raw `<link>` instead of `next/font`
**Category:** LCP / Core Web Vitals (chapter 094, lessons 1–2). Bonus finding — the senior reach above the 8/8 floor, on the same LCP path as finding 007.
**Severity:** medium — a render-blocking third-party font on the unauthenticated first-impression route delays LCP and risks a font-swap reflow, but the page renders correct content and the swap is a layout shift, not lost data. Above low because it sits on the same highest-traffic, search-scored marketing surface as finding 007 and compounds that finding's LCP regression.
## Rule
Web fonts are loaded through `next/font` so the font is self-hosted (no render-blocking third-party request on the LCP path) and ships with fallback metrics that match the swap-in face's dimensions, so the swap does not reflow text (chapter 094, lessons 1–2 — LCP path discipline + CLS). A raw `<link rel="stylesheet" href="https://fonts.googleapis.com/...">` violates both halves: it is a render-blocking request to a third-party origin discovered during document parse, and the swap from the fallback to the web font has no metric matching, so the text reflows when the font lands.
## Location
`src/app/(marketing)/layout.tsx`, lines 14–18: the layout renders a raw
```
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" />
```
instead of importing the font through `next/font/google`.
How it surfaced — the same audit method as finding 007: open the running app beside the source. Load `/` with the Chrome DevTools Network panel filtered to the `fonts.googleapis.com` / `fonts.gstatic.com` origins: the stylesheet request is render-blocking (it sits on the critical request chain ahead of first paint), it opens a new connection to a third-party origin (DNS + TLS on the LCP path), and the woff2 it pulls arrives after first paint, so the headline first paints in the fallback face and reflows when Inter swaps in. Confirm in source with a grep:
```
rg -n "fonts.googleapis.com|<link" "src/app/(marketing)/layout.tsx"
```
The match is the raw `<link>` in the marketing layout — the file where the `next/font` import should live.
## Consequence
User-visible on the marketing route's first paint. The render-blocking third-party stylesheet adds a DNS lookup plus a TLS handshake to a new origin before the font CSS is even parsed, late-discovering the woff2 on the LCP path and pushing the largest text block's final paint later — compounding finding 007's hero-image LCP regression on the same route. When the web font finally swaps in over the unmatched system fallback, the headline and body reflow (different glyph widths and line-box height), which registers as a Cumulative Layout Shift the moment a visitor's eye is already on the page. Both effects hit the 75th-percentile mobile-on-slow-network traffic that dominates Core Web Vitals field data and Google Search ranking.
## Fix
Documented, not patched — the marketing layout keeps the defect so the surface stays readable for the lesson. The senior reach:
1. **Self-host through `next/font`.** Import the face via `next/font/google` (or `next/font/local`) and apply its generated class on the layout's root, removing the raw `<link>` entirely. This eliminates the third-party render-blocking request — the font ships from the app's own origin, no extra DNS/TLS on the LCP path.
```tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
const MarketingLayout = ({ children }: { children: ReactNode }) => (
<div className={inter.className}>{children}</div>
);
```
2. **Fallback metrics for CLS.** `next/font` automatically computes a size-adjusted fallback (`size-adjust`, `ascent-override`, `descent-override`) that matches the web font's metrics, so the pre-swap fallback occupies the same box and the swap-in does not reflow — the CLS protection the raw `<link>` lacks.
Same LCP-path discipline as finding 007: the LCP element and everything on its critical request chain (the hero image *and* the font that paints the largest text) get first-class loading treatment. Half-credit names only the render-blocking request and stops; full credit names `next/font` for self-hosting *and* the fallback-metrics layer that prevents the swap reflow.

Finding 9 has two halves, and naming only the first is half-credit. Self-hosting through next/font kills the render-blocking third-party request — the font now ships from your own origin, no extra DNS and TLS on the critical request chain. The fallback metrics kill the swap reflow: next/font computes a size-adjusted fallback that occupies the same box as the real face, so the headline does not jump when Inter lands. A finding that names only the render-blocking request and stops has the LCP half but missed the CLS half — which is exactly the reach the answer key scores.

Bonus finding 10 is on the same /dashboard read path as findings 5 and 8, but it is a missing-index finding, so its diagnostic surface is the query plan. The reach here is two halves: declare the index and generate its migration.

findings/010-composite-index.md
# Finding 010 — `invoices` has no composite `(org_id, created_at)` index; the dashboard read seq-scans and sorts in memory
**Category:** Missing database index — `EXPLAIN ANALYZE` + leftmost-prefix composite (chapter 094, lesson 7). Bonus finding — the senior reach above the 8/8 floor, on the same `/dashboard` read path as findings 005 and 008.
**Severity:** medium — the read returns correct rows and is tolerable at seed scale, but the planner scans every invoice row and sorts the result set in memory on each render, so latency grows with the table and memory pressure rises under concurrent loads. Medium, not high: no data is lost and the fix is one schema line plus a generated migration, but it is a latency cliff that worsens as the org's invoice history accumulates.
## Rule
A query that filters on one column and orders by another should be served by a composite index whose leftmost prefix matches the filter and whose next column matches the sort, so the planner reads the matching rows in already-sorted order instead of scanning the whole table and sorting in memory (chapter 094, lesson 7 — `Indexes and EXPLAIN ANALYZE`). The org-scoped, `createdAt`-ordered invoice read (`where organization_id = $1 order by created_at desc`) is exactly that shape, so it wants a `(organization_id, created_at)` index (with `id` appended to make the order total and the cursor stable) — the leftmost-prefix `org_id` serves the filter, `created_at` serves the sort.
## Location
- `src/db/schema.ts`, lines 84–101 — the `invoices` table ships with **only its primary key**: no third-argument index array, so there is no `(organization_id, created_at)` composite index (confirm with `rg -n "index\(.*organization_id.*created_at" src/db/schema.ts` — no match). Contrast `customers` (line 66) and `exports` (lines 157–164), which do declare their indexes.
- `src/db/queries/invoices-with-customer.ts`, lines 30–35 — the read this index serves: `where(eq(invoices.organizationId, orgId)).orderBy(desc(invoices.createdAt))`.
How it surfaced — the diagnostic surface is the query plan, read with `EXPLAIN ANALYZE`. Dump the plan for the dashboard read against the seeded data:
```sql
EXPLAIN ANALYZE
SELECT * FROM invoices
WHERE organization_id = '<seeded-org-id>'
ORDER BY created_at DESC
LIMIT 30;
```
The plan shows a **`Seq Scan on invoices`** (the planner reads every row, then filters) feeding a **`Sort` node** with a `Sort Method: quicksort Memory:` line (the result set is ordered in memory because nothing arrives pre-sorted). Those two nodes are the fingerprint — a full scan plus an in-memory sort where an `Index Scan` over a matching composite index would read only the org's rows in order.
## Consequence
Every dashboard render scans the entire `invoices` table to find one org's rows, then sorts them in memory by `created_at`. At the seeded ≥30 rows the plan is cheap, but the scan cost grows linearly with the *whole table* (every org's invoices, not just the rendering org's) and the in-memory sort allocates `work_mem` per concurrent query — so as invoice history accumulates across all tenants, this read degrades for every org at once and adds memory pressure under load. Operator-visible: a query plan that gets slower as the product grows, paid on the first authenticated screen every signed-in user hits, compounding findings 005 (the read sits in the waterfall) and 008 (it is already firing 1 + N).
## Fix
Documented, not patched — the schema keeps the table index-less so `start/` and `solution/` ship identical Drizzle sets and the `EXPLAIN ANALYZE` Seq Scan stays readable for the lesson. The senior reach has two halves, and naming the first without the second is **half-credit**:
1. **Declare the composite index in `src/db/schema.ts`** — a third-argument index array on `invoices`, leftmost-prefix `organization_id`, then `created_at`, then `id` (so the order is total and the cursor is stable):
```ts
export const invoices = pgTable(
'invoices',
{
/* …columns… */
},
(t) => [
index('idx_invoices_org_created').on(
t.organizationId,
t.createdAt,
t.id,
),
],
);
```
2. **Generate the migration with `drizzle-kit`**`pnpm db:generate --name index_invoices_org_created` emits the `CREATE INDEX` migration into `drizzle/`, then `pnpm db:migrate` applies it. This is the load-bearing second half: declaring the index in the schema changes nothing in the database until the migration runs. (The answer key describes this as a by-hand student step and does **not** commit the migration — this is bonus evidence, not a shipped patch. It uses the Unit-5 migration mechanics the student already has, **not** the expand-migrate-contract workflow, which is chapter 100.)
Re-run the `EXPLAIN ANALYZE` after the migration: the `Seq Scan` + `Sort` collapses to an **`Index Scan using idx_invoices_org_created`** (the rows arrive filtered and pre-sorted, no in-memory sort node). The query itself is **not rewritten** — the same Drizzle read now hits the index. Full credit names the leftmost-prefix composite *and* the generated migration *and* verifies the plan flip with `EXPLAIN ANALYZE`; naming the index without generating the migration is half-credit.

Finding 10 is where the partial-credit rule bites hardest, so name the two halves explicitly. The declaration is the leftmost-prefix composite — organization_id serves the where, created_at serves the order by, and id is appended so the sort order is total and the cursor stays stable. The migration is the load-bearing second half: a pnpm db:generate that emits the CREATE INDEX and a pnpm db:migrate that applies it. Declaring the index in the schema changes nothing in the database until the migration runs — so a finding that names the index and stops has done the easy half and left the latency cliff exactly where it was. The proof is the plan flip: re-run EXPLAIN ANALYZE and the Seq Scan + in-memory Sort collapses to an Index Scan, without rewriting the query at all.

Step back and name what ran through this chapter, because the threads are the transferable part. The audit was hybrid: observability findings got wired — Sentry, the redactor seam, the correlation-ID scope, the consent gate — and performance findings got documented, with the lone exception of the barrel import you fixed in place to produce the analyzer before/after. That split is not arbitrary. Observability gaps lose data, so they close before launch; performance gaps are slow not bleeding, so they go to the backlog with measured impact. Getting that disposition right — wiring the one, measuring and parking the other — is the judgment call this whole chapter exists to install.

Every cross-cutting rule lived at exactly one seam. The redactor in lib/logger.ts reused by Sentry’s beforeSend and Pino, the runWithContext scope in proxy.ts, the grant/revoke pair in lib/analytics/consent.ts, the optimizePackageImports line in next.config.ts — each is the single place the team configures the discipline. A finding is a bypassed call site; the fix is the seam. That single-seam-to-lint pattern is the audit’s positive deliverable, and it is exactly what a CI gate automates later: the regressions you found by hand become checks that run on every PR.

And coverage was the ethic, not depth. A short finding in every category beats a deep dive with one category silent — eight is the floor, and the two bonus findings are the reach. The SUMMARY.md you assembled is the portable artifact: the coverage count, the per-finding reach, the personal checklist of surfaces. It is the shape you attach to every launch review you run after this one. The eight categories were this unit’s instance of the method; the method itself — read the running surface, name the rule and the seam, score yourself honestly — is what you keep.

This lesson is itself the project’s verification pass, and its surfaces are every panel the chapter used: the Sentry dashboard, the dev console, the DevTools Network panel, the PostHog dashboard, and findings/. The automated gate can only reach the last one — it reads your two Markdown deliverables and the two bonus findings off disk. Everything on the running surfaces you confirm by hand.

First, run the gate:

Terminal window
pnpm test:lesson 7

A pass looks like the Lesson 7 describe blocks green. The gate reads the source shape of your findings/ documents — it does not check exact wording, so a valid differently-phrased write passes while an unfilled placeholder fails. It asserts SUMMARY.md carries a coverage count (like 10/10), the clause-by-clause rubric with the partial- and half-credit language, the per-finding senior-reach detail across all eight findings 001–008, a personal checklist of at least five surfaces, and references to both bonus findings; out-of-scope.md records at least one substantive out-of-category observation; and the two bonus findings each fill all four template sections, with 009 naming the raw <link> font on the marketing layout and 010 naming the composite (org_id, created_at) index proven with EXPLAIN ANALYZE and a generated migration. These are the tested requirements 4 through 7.

Terminal window
✓ tests/lessons/Lesson 7.test.ts (13)
✓ Requirement 4 — findings/SUMMARY.md is the coverage scorecard (5)
✓ records a coverage count (e.g. 8/8 floor or 10/10 with bonuses)
✓ scores findings clause by clause, not just pass/fail
✓ carries the per-finding senior-reach detail across all eight findings
✓ folds the discovery surfaces into a personal checklist
✓ references both bonus findings — next/font and the composite index
✓ Requirement 5 — findings/out-of-scope.md records a deliberate cut (1)
✓ records at least one substantive out-of-category observation
✓ Requirement 6 — findings/009-missing-next-font.md (3)
✓ fills all four template sections (rule, location, consequence, fix)
✓ names the raw <link> font on the marketing layout and the next/font fix
✓ declares a justified severity
✓ Requirement 7 — findings/010-composite-index.md (4)
✓ fills all four template sections (rule, location, consequence, fix)
✓ names the missing composite (org_id, created_at) index on invoices
✓ proves it with EXPLAIN ANALYZE — Seq Scan + in-memory Sort flipping to Index Scan
✓ names the migration as actually generated, not just the index declaration
Test Files 1 passed (1)
Tests 13 passed (13)

The gate cannot judge the parts of this lesson that matter most — the running-surface confirmations, the honor system, and the quality of your scoring. Walk these by hand in order, ticking each off as the surface comes up green.

Sentry dashboard — the deliberate /api/test/throw lands tagged with the current release, with breadcrumbs, a readable (source-mapped) stack, and a requestId that matches its log line.
untested
Dev console — stripe-signature renders [REDACTED], requestId is a top-level field on every log line, and the webhook error’s Sentry breadcrumbs hold no un-redacted signature.
untested
Network panel — zero pre-consent /ingest requests; a $pageview reaches the PostHog dashboard within 30 s after Accept; capture stops on Reject. (Both belts, not just the init flag.)
untested
findings/ — all eight files (001–008) are filled, findings/006-barrel-import.md embeds both before/after analyzer screenshots, and SUMMARY.md carries the coverage count plus the final analyzer treemap as secondary evidence.
untested
The work was committed before the solution/ answer key was opened — the honor system held.
untested
Coverage was scored clause by clause against the solution/ tree (partial credit on rule + location, the senior-reach detail noted as a gap where missed), and the backlog of follow-ups was written down.
untested

The gate proves the deliverable is shaped like a scorecard; these six prove the audit actually happened on the running app and that you graded yourself honestly. The honor system and the quality of your scoring are the parts no test can reach — and they are the whole point of the rehearsal.