ADR 0007: the cache decision worth recording
The review is written. Now you do the other half of the discipline this chapter installed: you record the one decision in the surface that deserves to outlive the diff, and you self-grade everything you’ve produced against the answer key you’ve been sitting next to without opening.
Your goal in this lesson is to fill the shipped docs/adr/0007-cache-entitlement-reads-with-cacheTag.md scaffold with the four Nygard sections, then grade your review and your ADR side-by-side against the reference under solution/. The decision worth recording is the one the running code already made silently: getPlanEntitlement is cached with cacheTag, and nothing in the repo says why. When you finish, 0007 carries a Status with a date, a Context that names the read pattern and the alternative it beat, a single declarative Decision line with zero hedging, and a Consequences list that names every mutation seam owing an updateTag call rather than waving at “always invalidate” — plus its matching row appended to the docs/adr/README.md index. There’s no screenshot to chase; the deliverable is a Markdown file, so the finished result is the file itself, and the proof is reading it next to the reference. In ADRs: one decision per file you learned the template and the three-test inclusion check in the abstract. This is where you point both at a real decision the code made for you.
Your mission
Section titled “Your mission”Not every change earns an ADR, and the first move of this lesson is the restraint to prove it. Run the three-test inclusion check from ADRs: one decision per file across the /plan surface’s candidate decisions — affects multiple files, a reasonable alternative exists, reversing costs more than one PR — and reject the candidates that don’t clear all three. Adding the planLabel field has no real architectural alternative and reverses in a single PR, so it fails. Co-locating the src/lib/plan/ module is convention application — Principle #1, the same shape every feature in the repo follows — not a new decision, so it fails too. Only one candidate survives: caching the entitlement read. It shapes every future plan-touching surface (the overview, the seat counter, any gating read), it has a genuine alternative (per-request reads, or revalidatePath), and undoing it would cost a sweep of updateTag call sites. That is the one you record, and recognizing why the other two don’t qualify is as much the lesson as writing the ADR is.
You fill the scaffold that already ships in the starter — its H1 title and four empty ## sections are in place, and you write into them, deleting the <!-- TODO(L4) --> marker as you go. Three sections carry the weight. The Decision is one declarative sentence with no hedging: “we will cache…”, never “we’re considering” or “we should” or “maybe”. This is the headline trap, because writing a decision down makes people nervous about committing, and the nervousness leaks out as a hedge. The hedge has a home — it belongs in Context as the rejected alternative, or in Consequences as the reversal cost — but it never belongs in the Decision line. The Context must name the read’s access pattern and its scale and name the rejected alternative with a reason it lost; an ADR that pretends there was no choice records nothing a future maintainer can use. The Consequences is the honesty test. Enumerate every mutation seam that must own an updateTag call rather than writing “every mutation must invalidate” — a rule nobody can grep is a rule nobody enforces. Name the updateTag-versus-revalidateTag cut for the background-job path with its lesson reference. State the staleness window the 'minutes' profile buys and why it’s acceptable. State the reversal cost plainly. Three bullets that are all upsides is a sales pitch, not a record; the future reader needs the trade-off to judge whether the decision still holds.
The filename is part of the contract, and it ships correct, so the discipline here is reading why. The slug cache-entitlement-reads-with-cacheTag is a noun phrase of the decision, not a verb phrase of the change — not add-use-cache-to-getplanentitlement. An ADR names what the team decided, which is durable, not the patch that implemented it, which is ephemeral. Appending the 0007 row to the docs/adr/README.md index — which already lists 0001–0006 — in the same edit is in scope by convention. No new tools: you’re reusing the Nygard template and the three-test check from ADRs: one decision per file, the cacheTag/updateTag/revalidateTag decision tree and cacheLife profiles from earlier in the course, and the precedent already living in src/lib/invoices/queries.ts and its mutation seams. Out of scope: choosing a different cache-key strategy or re-litigating the caching decision itself — you are recording the decision the code already made, not making a new one. And as all chapter, editing the audited source stays out of scope; the only files that grow here are the ADR and the index.
<!-- TODO(L4) --> marker is gone.revalidatePath) with the reason it lost.cacheTag read and the updateTag invalidation commitment, with no hedging language.updateTag, name the revalidateTag(tag, 'max') background-job path with its lesson reference, state the 'minutes' staleness window, and state the one-PR reversal cost honestly.docs/adr/README.md index (already seeded with 0001–0006) carries an appended one-line 0007 entry.Coding time
Section titled “Coding time”Fill the ADR scaffold’s four sections and append the index row against the brief, then open the reference. Write your own version first — the value is in committing to a Decision line before you see how the reference phrased it.
Reference solution and walkthrough
The deliverable is one Markdown file plus one index line. We’ll read the filled ADR section by section against what the brief asked for, then cover the index row and the self-grade.
Here is the reference docs/adr/0007-cache-entitlement-reads-with-cacheTag.md, filled:
# ADR 0007 — Cache entitlement reads with cacheTag
## Status
Accepted — 2026-06-15
## Context
`getPlanEntitlement(orgId)` is the entitlement read behind `/plan` and every gate that asks "what plan is this org on, and how many seats does it have." It is a hot, org-scoped read: it runs on most app navigations, the value changes rarely (only when a mutation touches plan or entitlement state), and the surfaces that consume it — the `/plan` overview, seat counters, feature gates — tolerate a short staleness window but must reflect a write the moment the user who made it lands back on the surface (read-your-writes). This mirrors the existing `'use cache'` reads in `lib/invoices/queries.ts`, so a cached read is the established pattern, not a new invention.
Two alternatives were considered and rejected. **Per-request reads with no cache** keep the value trivially fresh but re-run the entitlement lookup on every navigation for a value that almost never changes — needless work on the hot path, and it forfeits the tag-based invalidation the rest of the stack uses. **`revalidatePath`** ties invalidation to routes, not to data: it would force every plan-touching route to enumerate itself at each mutation seam, and any new surface reading the entitlement would silently miss invalidation — the coupling points the wrong way. A data-keyed cache tag is the shape that matches the access pattern.
## Decision
We will cache `getPlanEntitlement(orgId)` with `cacheTag(orgPlanEntitlementTag(orgId))` (the tag string `org:{orgId}:plan-entitlement`) and `cacheLife('minutes')`, and invalidate it via `updateTag(orgPlanEntitlementTag(orgId))` from every mutation seam that touches plan or entitlement state.
## Consequences
- **Every plan/entitlement mutation seam now owns an `updateTag` call.** Today that is the `updatePlanLabel` action in `src/app/(app)/plan/actions.ts`; every future seam that writes plan or entitlement state (a Stripe-driven plan change, a seat-count adjustment) must call `updateTag(orgPlanEntitlementTag(orgId))` after the commit, before the redirect, through the `tags.ts` helper — never a raw string.- **Background jobs and webhooks invalidate from the non-action path.** A job or webhook that mutates entitlement state outside a Server Action — no user sitting on a redirect — invalidates with `revalidateTag(orgPlanEntitlementTag(orgId), 'max')` (the eventual primitive, chapter 032), exactly as `src/server/jobs/summary-recompute.ts` does for the summary tag. The second `cacheLife` profile argument is mandatory in Next.js 16; the single-argument form is a type error.- **Reads tolerate a bounded staleness window.** Between invalidations, the value can be up to one `'minutes'` profile stale. That is acceptable for the consuming surfaces; a write the user just made is fresh because `updateTag` runs synchronously in their action before the redirect.- **The failure mode is a forgotten invalidation.** A mutation that touches plan or entitlement state without calling `updateTag` leaves the entitlement stale for the staleness window — silent, no error. Routing every write through the `tags.ts` helper and reviewing new mutation seams for the `updateTag` call is the guard.- **Reversal is cheap.** Backing this out is one PR: delete the `'use cache'`/`cacheTag`/`cacheLife` annotation on the read and the `updateTag`/`revalidateTag` calls at the mutation seams. No data migration, no schema change.Read the Decision line first, because it’s the one most students get wrong. One sentence, present tense, declarative: we will cache, and invalidate via updateTag. There is no “we think”, no “probably”, no “for now”. Everything that could have become a hedge has been routed to where it belongs — the no-cache option lives in Context as a rejected alternative, the reversal cost lives in Consequences as its own bullet. That redistribution is the whole craft of the Decision line: it sounds confident not because the author is certain nothing will change, but because the doubt has a home elsewhere in the document.
The Context earns its length by naming the access pattern in concrete terms — a hot, org-scoped read that runs on most navigations for a value that changes rarely — and then naming the precedent. Pointing at the existing 'use cache' reads in src/lib/invoices/queries.ts is what turns this from a novel invention into a pattern the codebase already trusts, which is exactly the argument a reviewer needs. And it rejects two alternatives with reasons, not zero: per-request reads (needless work on a near-static value) and revalidatePath (couples invalidation to routes instead of data). An ADR whose Context says “we cached it” and stops has recorded a fact, not a decision — the rejected alternatives are the decision.
The Consequences list is where the honesty shows. Notice it doesn’t say “every mutation must invalidate the tag.” It says: today the only such seam is updatePlanLabel in src/app/(app)/plan/actions.ts, and here is the rule the next seam inherits. That distinction matters more than it looks — and it’s worth pausing on why.
Why enumerate seams instead of stating a rule
Section titled “Why enumerate seams instead of stating a rule”“Remember to invalidate after every plan mutation” is the kind of instruction that feels complete and enforces nothing. Six months from now, someone adds a Stripe-webhook handler that flips an org’s plan, ships it green, and never touches the entitlement tag — because nothing pointed them at the obligation. The cache goes stale and a feature gate reads the old plan, silently, with no error to trace.
Naming the seam converts that vague rule into something a future maintainer can act on: a list to grep against, a contract the next write inherits explicitly. The list is short today — one seam — and that’s exactly the point. You write the ADR while the surface area is small enough to enumerate, so that the contract is on the record before there are five seams to find. The TSDoc on getPlanEntitlement (the bonus suggestion: from the review) is the second half of the same guard: it puts the invalidation obligation right on the read’s signature, where the person about to call it will see it.
The background-job cut, and where it’s owned
Section titled “The background-job cut, and where it’s owned”One bullet draws the line every cache decision eventually hits: a mutation inside a Server Action invalidates with updateTag, but a background job or webhook — where no user is waiting on a redirect — uses revalidateTag(orgPlanEntitlementTag(orgId), 'max') instead. The ADR doesn’t re-explain that decision tree; it points at the precedent (src/server/jobs/summary-recompute.ts already does this for the summary tag) and names where the rule was taught. That updateTag-versus-revalidateTag cut and the cacheLife profiles are owned by the caching chapters earlier in the course — recorded again here would be drift, so the ADR cites and moves on.
The 'minutes' profile is a recorded trade-off
Section titled “The 'minutes' profile is a recorded trade-off”The staleness bullet names the window — up to one 'minutes' profile between invalidations — and says why it’s acceptable: entitlements change rarely, and a write the user just made is fresh anyway because updateTag fires synchronously before the redirect. That sentence is the difference between a recorded decision and a magic number. A bare cacheLife('minutes') in the source is a value with no explanation; the ADR is where it becomes a trade-off someone chose on purpose, with the reasoning attached for whoever later asks “why minutes and not seconds?”
The index row
Section titled “The index row”Append one line to docs/adr/README.md, beneath the existing 0006 row:
0007 — Cache entitlement reads with cacheTag — Accepted — 2026-06-15The row mirrors the title and Status of the file, nothing more — the index is the at-a-glance map, the file is the detail. And the slug deserves one last look: cache-entitlement-reads-with-cacheTag is a noun phrase naming the decision, not a verb phrase like add-use-cache-to-getplanentitlement naming the change. The decision is what’s durable and what a future reader searches for; the patch that implemented it is forgotten the day after it merges.
Self-grade against the reference
Section titled “Self-grade against the reference”Now the part the whole chapter has been building toward. Open the two reference deliverables under solution/ next to your own:
solution/reviews/chapter 104.mdsolution/docs/adr/0007-cache-entitlement-reads-with-cacheTag.md
This is the answer key — open it only now, after your review and your ADR are written. Read them side-by-side and score honestly. The discipline you’re rehearsing is the one a real review never gives you: a real PR has no rubric, so the reflex you train here is grading your own pass against what a thorough one would have caught.
The original 2011 essay that defines the four sections you're filling — Status, Context, Decision, Consequences.
Canonical ADR hub: template variants, the supersession lifecycle, and tooling for keeping records next to the code.
Moment of truth
Section titled “Moment of truth”There is no automated checker for this chapter. lesson-verification/ ships empty — there is no pnpm test:lesson 4, because nothing machine-asserts on a written .md. Verification is a by-hand self-grade against solution/, which is why every item below is marked untested: the only judge is you, reading your work next to the reference.
Open solution/reviews/chapter 104.md and solution/docs/adr/0007-cache-entitlement-reads-with-cacheTag.md and grade side-by-side, ticking each off as you go:
<!-- TODO(L4) --> marker is gone.revalidateTag(tag, 'max') background path with its lesson reference, name the 'minutes' window, and state the reversal cost.blocking: is the expectation; a 3/5 review that goes deep on cacheTag while silencing the Date math is a fail.That last item is the one that compounds. The point of grading yourself isn’t the score on this one surface — it’s the sharper checklist you carry into the next PR. Every miss here is a blind spot named, and a named blind spot is one you’ll catch next time.
ADR 0007 is not the last word on this decision, and that’s by design. When the cache eventually moves to Redis, or the entitlement model changes shape, you don’t edit 0007 to match the new world and you don’t delete it. You write a new ADR that references this one, you flip this one’s Status to “superseded by ADR XXXX”, and the file stays exactly where it is — the supersession discipline from ADRs: one decision per file. The record of why the team once decided to cache is worth keeping even after the decision is reversed, because the next person reasoning about the cache needs the history, not just the present.
That’s the chapter. You ran a real surface through the five-layer stack, wrote five findings in the shape a teammate can act on, drew the line between what earns an ADR and what doesn’t, and recorded the one decision that did. This review-and-ADR cadence isn’t a one-off exercise — it’s the daily craft of an experienced engineer, the same pass that runs on every PR you’ll touch from here on, including the AI work in the chapters ahead.