Skip to content

ADR-0027 · Card freshness: purge-on-write, never TTL ​

Status: 🟡 Proposed — authored 2026-08-09, awaiting owner approval · Amends:ADR-0019 (Tier-0 static rendering), ADR-0025 (D2, the first render-time temporal gate) · Tracker: QRS-467

Context ​

Every decision about the public Setu Card has kept temporal and mutable inputs away from rendering, because the card is server-rendered once and served from an edge cache at a >95% hit-rate target. ADR-0025 D2 was the first breach and called it "the hard part".

Within a single day, that pressure arrived from three independent directions:

#RequirementWants to change what renders when…Source
1Scheduled launch offers…a time boundary passesADR-0025 D2, now R1 via QRS-461
2Metal-rate item pricing…a mutable input changesQRS-465
3Today's published gold/silver rate…a mutable input changesQRS-466

⚠ Three instances is a pattern, not a coincidence, and it needs ONE answer. The failure mode this ADR exists to prevent is not any single feature getting it wrong — it is three features each solving it locally, which is how an invariant dies quietly: nobody removes it, and one day nothing enforces it either.

And a fourth is already visible: revoking a vendor's verification badge would need to propagate the same way (ADR-0026 D5).

Alignment ​

  • ADR-0019 D11 fixes Tier 0 at 0 KB — the card renders and looks finished with JavaScript disabled. Any answer that moves freshness to the client forfeits this, and with it the OG image and SEO.
  • QRS-381 already rejected short TTLs.
  • ADR-0010's analytics posture is counts-only; nothing here introduces a per-visitor decision, which is what would make the card uncacheable per-slug (the constraint ADR-0004 records for ads).

Decision ​

D1 — Freshness is achieved by PURGE-ON-WRITE via the outbox. Not TTL, not client-side ​

The card stays statically rendered and edge-cached. When a fact it depends on changes, the write path enqueues an outbox row and the edge entry is purged. The next request re-renders.

Three rejected alternatives, each for a specific reason rather than a general preference:

RejectedWhy
A short TTL (5 min, 1 hour)Contradicts QRS-381, and it is wrong in both directions at once: still stale for up to the TTL, while destroying the hit rate for cards that never change — which is almost all of them, almost all the time
Client-side rendering of the dynamic partForfeits Tier 0 (ADR-0019 D11), and a price or a rate that arrives after paint is the one element on the page that must not flicker or arrive late
Per-visitor resolutionMakes the card uncacheable per-slug. Already recorded as the constraint that any future ad implementation must respect (ADR-0004)

D2 — ⚠ A VENDOR-TRIGGERED PURGE IS THE PREFERRED SHAPE, AND TWO OF THE THREE CASES QUALIFY ​

This is the useful realisation, and it makes the general answer easier than the case ADR-0025 D2 was written for.

  • Instances 2 and 3 are vendor-triggered. The jeweller saves the morning rate. That is an explicit human action, at a moment they chose, and it already runs through a write path that can enqueue an outbox row. One purge a day, observable, bounded, and needing no clock at all.
  • Instance 1 is clock-triggered, and only instance 1. A scheduled offer's start and end are the only boundaries requiring a timer.

So the design rule: prefer a vendor-triggered purge; introduce a scheduled purge only where a real temporal boundary exists. A feature that could be expressed as a vendor toggle should not be given an expiry date merely because dates feel more sophisticated — which is exactly why QRS-461 recommends shipping R1 launch offers as a manual flag with no expiry. That recommendation is now a general principle rather than a one-off convenience.

D3 — Purge is at-least-once and the render must be idempotent ​

An outbox purge can fire twice, or fire before the write is visible to a reader, or be retried after a Cloudflare 5xx. So:

  • Re-rendering must be safe to do at any time, which it is, because the render is a pure function of stored state. Nothing about it consumes anything.
  • Enqueue the outbox row in the SAME TRANSACTION as the write. Purging before commit means the re-render can serve the old value and then cache it — a stale entry created by the very mechanism meant to prevent one, and it would be maddening to diagnose.
  • A failed purge must be visible, not silent. A card that quietly serves yesterday's gold rate is indistinguishable from a broken product (QRS-350's lesson), and it is worse for a number a customer may act on than for a catalogue edit.

D4 — A mutable input must be resolved at RENDER time and never snapshotted onto the item ​

For metal-rate pricing, the price shown is computed from rate × weight + charges at render. It must not be written back onto catalog_items.price_minor.

This is ADR-0025 D5's rule ("a campaign never mutates item prices") generalised: a derived value that depends on a mutable input must not be stored, or the store becomes a second source of truth that disagrees with its input the moment the input moves. Same reasoning that keeps 'from ₹X' derived in QRS-456 rather than a third pricing_mode.

⚠ The one legitimate exception, and it is a different thing entirely: a TRANSACTION snapshots the price it charged. An order records what the customer actually paid; that is a historical fact, not a derived display value. Conflating the two is how a catalogue starts lying about its own history.

D5 — ⚠ A CLOCK-DERIVED VALUE IS COMPUTED CLIENT-SIDE OVER STATIC DATA, AND NEVER PURGED ​

Amendment, 2026-08-10, and it completes the taxonomy. Found by a design exercising the requirement one day after this ADR was drafted, not by this ADR's own reasoning — see the note at the end.

A fourth instance arrived: the public card's availability badge (Open now · closes 9 PM / Closed today / Opens at 10 AM / By appointment). The returned design implements it as computeAvailability(schedule) calling new Date().

⚠ Computed at render and then edge-cached, that is wrong in the most damaging possible way: a card cached at 10 AM tells a customer the shop is open at midnight. A wrong price loses a sale; a wrong "Open now" sends somebody across a city to a closed shutter.

And D1's mechanism cannot rescue it, which is the whole point of adding this decision. The input is the clock, not vendor state, so there is no write to enqueue an outbox row from. Hooking it to a timer would mean two or more purges per vendor per day — against a design that chose purge-on-write precisely because purges are rare, and that cited the jeweller's one daily purge as the good case. At launch scale that is a purge storm generated by the freshness mechanism itself.

The answer is to split the cacheable fact from the clock-derived one:

  • The weekly schedule IS vendor state. It changes a few times a year, it is an ordinary projection, and it renders completely at Tier 0 as a readable hours table with JavaScript disabled — which is better for accessibility and for SEO than a badge, because the full opening hours become machine-readable (schema.org/openingHoursSpecification).
  • The badge is a Tier-1 client-side computation over data already delivered in the HTML. No network call, no purge, no staleness, and it degrades to absent rather than to wrong — the one property that matters here.

So the three cases are now distinguished by WHAT THE INPUT IS, which is the rule to remember:

InputAnswerDecision
Vendor state (an edit, a toggle, a published rate)Purge on write via the outboxD1 / D2
A mutable vendor value feeding a formula (metal rate × weight)Compute server-side at render, never snapshot onto the rowD4
The clock alone (open now, a scheduled boundary)Compute client-side over statically-rendered data, never purgeD5

⚠ This also revises QRS-461. A manual offer toggle is vendor state and purges (D2, unchanged). A scheduled offer boundary is the clock, so it belongs under D5 — which removes the last reason this ADR needed a scheduler at all, and retires D2's "instance 1 still needs a timer" caveat.

The meta-lesson, recorded because it is the reusable part: this ADR was written against three instances, generalised correctly over those three, and silently assumed its taxonomy was complete. The fourth was surfaced by a design that had to actually render the requirement, one day later. That is an argument for building a thin real surface early rather than for reasoning harder — and it is the same lesson as QRS-451 and QRS-470, where the search space was likewise larger than the analysis assumed.

Consequences ​

Accepted:

  • A vendor's rate change is visible only after a purge completes. Seconds, not instant, and it must be communicated in the vendor UI as "published at HH:MM" rather than implied to be immediate.
  • Purge volume rises with the number of vendors publishing daily. Cloudflare purge-by-tag has plan-dependent rate limits, already flagged as a trap for template switching in ADR-0019. Monitor; do not pre-optimise.
  • Instance 1 still needs a scheduler. This ADR narrows that need to one case rather than removing it.

Gained:

  • One mechanism for all four instances, including verification revocation, rather than four local solutions.
  • The >95% hit-rate target survives, because cards that do not change are never purged.
  • Tier 0 stays 0 KB, so SEO, the OG image and the JS-disabled guarantee are all untouched.

Recommendation ​

Approve D1-D4. The load-bearing content is D2 — that most of this pressure is vendor-triggered and therefore simpler than the scheduled case that prompted the worry — and D4, which stops a derived price being written back to the row it was derived from.

⚠ The reason to approve now rather than with the first feature: three of the four consumers are already in flight, and the fourth is in an ADR authored the same day. Deciding this once is cheap; discovering that three features each invented their own freshness story is a cross-cutting repair.