spec: edge cache-before-wake β bots shouldn't wake hibernated instances
Public GET traffic (crawlers, LLM scrapers, search bots) hitting a hibernating app's pages defeats the point of hibernation if every hit wakes the instance. Adds an edge-local response cache in front of the activator wake path, kept fresh by purge-on-write (writes already require the instance awake, so purging costs nothing extra) rather than tolerated staleness. Titogo is the motivating consumer: cacheable page shell, a separate lazy-loaded ticket-availability fragment, and closing the always-open-forever gap in SalesEndAt defaults.
1 file changed,
+270
−0
docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md+270 −0
diff --git a/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md b/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md| new file mode 100644 |
| index 000000000..412722727 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md |
| @@ -0,0 +1,270 @@ |
| +# π€ Edge Cache-Before-Wake β Keeping Bots From Needlessly Waking Hibernated Instances |
| + |
| +> π€ This document was written by an LLM (Claude), on the ideas, instruction, |
| +> and editing of humans, per [factor X](https://11factor.org/#x). |
| + |
| +**Status:** Concept spec, approved in brainstorming 2026-08-02. |
| + |
| +**Relationship to the platform spec:** the |
| +[CARLOS platform design](2026-08-01-carlos-platform-design.md) defines |
| +hibernation, wake, and the edge/activator split in the abstract (Β§4, Β§5). |
| +This document adds one capability to that picture β caching GET responses |
| +at the edge so a wake is never triggered for traffic a cache can already |
| +answer β without changing any of the platform's hibernation semantics. |
| +Where the two could diverge, the platform spec wins. |
| + |
| +## 1. Purpose |
| + |
| +Once an app hibernates (spec Β§4), any HTTP request to a parked instance |
| +wakes it β that's the whole point of the activator. But not all GET |
| +traffic represents a real visitor: search-engine crawlers, LLM/agent |
| +scrapers, and general bot traffic hit public pages too, and none of them |
| +care whether the response is five seconds or five minutes old. Left |
| +unmitigated, that traffic defeats hibernation's entire cost story for any |
| +app with public, crawlable pages β an instance that would otherwise park |
| +for hours gets woken every few minutes by traffic that was never going to |
| +buy anything. |
| + |
| +Motivating case: **Tito Go** (`paulca/titogo`, self-hosted on the Tito |
| +CARLOS deployment per |
| +[2026-08-01-tito-production-infra-design.md](2026-08-01-tito-production-infra-design.md)). |
| +Titogo hasn't yet adopted the platform's hibernating activator β that |
| +migration ("Stage 2 adoption") is still open β but every event page it |
| +serves is exactly this kind of public, crawlable, bot-attractive surface, |
| +so this is a real blocker worth solving before that migration lands, not |
| +after. |
| + |
| +## 2. Decisions (settled in brainstorming, 2026-08-02) |
| + |
| +1. **This is a platform capability, not an app-level workaround.** It |
| + lives at the edge, general to any `routed` app, the same way static |
| + serving (spec Β§5) is a platform capability rather than something each |
| + static app reimplements. Titogo is the motivating and first consumer, |
| + not the only one. |
| +2. **Built now, independent of titogo's Stage 2 hibernation migration.** |
| + The capability is valuable even for always-on apps today (less load, |
| + faster bot responses, lower bandwidth), and whichever app adopts |
| + hibernation later gets the wake-prevention benefit automatically β |
| + there's no reason to gate this work on Stage 2's undated completion. |
| +3. **Correctness over cleverness: cache freshness is enforced by |
| + purge-on-write, not by tolerating staleness.** A real write (event |
| + edit, ticket sale, sales open/close) always already requires the |
| + instance to be awake β so having it also signal a purge afterward |
| + costs nothing in extra wakes. This is what makes "cache everything |
| + public" safe rather than a staleness trade-off: bots and real visitors |
| + are served the identical, always-fresh cache, so no bot-detection is |
| + needed anywhere in this design. |
| +4. **A safety-net TTL bounds the worst case.** Purge is best-effort, |
| + fire-and-forget from the instance's side β it doesn't block or retry |
| + the write it followed. A short fixed ceiling (exact value: an |
| + implementation-plan tuning choice, not fixed here) means a lost or |
| + buggy purge call self-heals within that window rather than serving |
| + stale data indefinitely. |
| + |
| +## 3. Architecture |
| + |
| +`internal/edge/edge.go`'s `Edge.ServeHTTP` already has the right seam: for |
| +a hibernating route it calls `e.act.ServeHTTP(w, r, up.route, up.proxy)` β |
| +the `Activator` interface that triggers or joins a wake β and only falls |
| +through to the raw `proxy` once the instance is confirmed up. The cache |
| +check slots in **immediately before that activator call**: for GET/HEAD |
| +requests, look up a cached response by cache key (Β§5); on a hit, write it |
| +directly and return. The activator, and therefore any wake, is never |
| +reached. On a miss, today's behavior is unchanged, except that a |
| +cacheable response is captured on the way back through. |
| + |
| +New components, all inside the `carlos agent` process (edge and activator |
| +already share one process per box, per `edge.RunWithActivator`): |
| + |
| +- **`internal/edge/cache.go`** (new) β an in-memory, per-host response |
| + cache, keyed as in Β§5. Populated only from responses the app marked |
| + cacheable via the standard `Cache-Control: max-age=N` response header |
| + (titogo already emits `Cache-Control: no-store` on its checkout render |
| + today β this reuses the same mechanism in the positive direction). No |
| + new manifest schema. |
| +- **A local purge channel** (new) β instances currently have no way to |
| + call back into their own box's `carlos agent` process; this needs a |
| + small new one (a unix socket or loopback endpoint the co-located edge |
| + exposes), reachable **only locally, from the same box** β never over |
| + the public network. An instance calls it right after a write to |
| + invalidate the cache key(s) that write affected. |
| +- **A hard cache size cap with LRU eviction.** This project exists to |
| + save RAM; an unbounded cache would be self-defeating. Exact |
| + count/byte-size limits: an implementation-plan tuning choice. |
| + |
| +## 4. Data flow |
| + |
| +**Cache hit** (the common case for bot/crawler traffic once warm): the |
| +edge finds a valid entry for the request's cache key and writes it |
| +directly. The instance is never touched. |
| + |
| +**Cache miss** (cold cache, or first request after purge/TTL expiry): the |
| +edge proceeds exactly as today β the activator wakes the instance (or |
| +joins an in-progress wake; this existing coalescing behavior, |
| +`TestUnitTenantWakeServesHeldRequest`, is unchanged and the cache layer |
| +does not reimplement it) and proxies the request through. The edge |
| +inspects the real response on the way back; if it's a 2xx carrying a |
| +cacheable `Cache-Control`, the edge stores it before returning it to the |
| +client. Non-2xx responses are never cached, at any TTL β a transient |
| +error must not get stuck serving itself for the whole window. |
| + |
| +**Purge**: the instance is already awake (it just wrote something) and |
| +calls the local purge channel with the affected cache key(s) β e.g. an |
| +event edit purges the page shell key; a ticket sale purges the |
| +availability-fragment key (Β§6). The edge drops those entries immediately; |
| +the next request for them is a miss and repopulates on the next real |
| +response. |
| + |
| +A request in flight when a write commits could theoretically finish and |
| +populate the cache with pre-write data *after* the purge fires, briefly |
| +re-poisoning it. This is accepted, bounded by the safety-net TTL, not |
| +solved with stronger consistency machinery β consistent with decision 4. |
| + |
| +## 5. Cache key and locale |
| + |
| +Cache key = request URL (path + query string) **plus a normalized locale |
| +bucket**, negotiated the same way titogo's `publicLocale` (Β§6) does today |
| +from `Accept-Language`, clamped to the event's enabled locale set. This |
| +means one cache entry per event per *enabled* locale β a small, bounded |
| +fan-out, not one per visitor. |
| + |
| +The `titogo_lang` cookie (an explicit switcher preference, per |
| +`internal/instance/locale.go`) is **deliberately not part of the cache |
| +key**. Varying the cache by cookie was considered and rejected: it tends |
| +to make every visitor a cache miss (cookies differ per-visitor for |
| +unrelated reasons β analytics, sessions), which would defeat the point. |
| +The accepted consequence: a returning visitor whose cookie preference |
| +differs from what their browser's `Accept-Language` negotiates to sees |
| +the negotiated version on a cache **hit**, not their saved cookie choice. |
| +An explicit `?lang=xx` URL β already how the switcher persists a choice |
| +today β is its own distinct, independently cacheable and purgeable key, |
| +so a visitor who actually clicks the switcher still gets exactly what |
| +they asked for; only the passive cookie-remembered case is affected, and |
| +only on a page they haven't explicitly re-chosen the language on this |
| +visit. |
| + |
| +## 6. Titogo-side changes (`paulca/titogo`) |
| + |
| +None of these touch the platform's caching mechanism β they're titogo |
| +being a well-behaved consumer of it. |
| + |
| +1. **Cache headers on public GET routes.** `handleHome`, `handleEvent` / |
| + `handleEventFlow`, and published custom pages (`pages.go`) set |
| + `Cache-Control: max-age=N` (N: a plan-level tuning choice β the purge |
| + path is what keeps things fresh, so this ceiling only bounds the |
| + worst case of a missed purge). Every token-gated route (orders, |
| + tickets, portals, checkout, quotes) stays uncached, exactly as today β |
| + already effectively private by unguessable token, and caching it would |
| + leak state across requests. |
| +2. **A cacheable ticket-availability fragment.** Today `handleEventFlow` |
| + computes `onSaleOnly`/`ticketSalesOpen` (`shop.go`) and bakes |
| + availability straight into the full server-rendered page β exactly the |
| + part that changes on every sale. This splits: the event page shell |
| + (title, description, dates, images, price display) becomes cacheable; |
| + live availability becomes a small separate endpoint the page fetches |
| + client-side after load. That fragment is cacheable and purgeable via |
| + the same mechanism as the shell β a second cache key per event, not a |
| + special case. |
| +3. **Purge calls on writes.** Any admin write that changes a cached |
| + shell (event save) or fragment (ticket sale, inventory change, sales |
| + open/close) calls the local purge channel for the affected key(s) |
| + right after committing. |
| + |
| +**Also, a data-hygiene fix surfaced during this brainstorm, bundled here |
| +because it shrinks the set of pages that can mutate indefinitely (which |
| +is part of what makes long-lived caching safe):** default `SalesEndAt` to |
| +the event's own end date when the organizer hasn't configured one |
| +explicitly, instead of leaving sales open forever |
| +(`ticketSalesOpen := ev.SalesEndAt == 0 || now() < ev.SalesEndAt` in |
| +`shop.go` currently treats unset as "always open"). **Explicit |
| +configuration always overrides the derived default** β this is a change |
| +to the default only, not to what's configurable, so a deliberate future |
| +opt-in for keeping sales open past an event (e.g. collecting payment at |
| +the door from walk-ins who didn't pre-pay β `door.go`'s existing on-site |
| +payment flow is exactly this case) stays a simple config exposure, not a |
| +fight against this change. That opt-in is explicitly **out of scope for |
| +this spec** β noted so it isn't lost, not designed here. |
| + |
| +## 7. Error handling and edge cases |
| + |
| +- **Purge channel is local-only,** reachable exclusively by the |
| + instance's own process on the same box. This is a deliberate security |
| + boundary, not an oversight: an externally-reachable purge endpoint |
| + would be a new DoS vector β force-purging popular pages to manufacture |
| + wake storms is exactly the failure mode this whole design exists to |
| + prevent. |
| +- **Never cache non-2xx responses,** at any TTL. |
| +- **Memory bounds:** hard cap + LRU eviction (Β§3). |
| +- **Wake-coalescing** is unchanged, existing activator behavior; the |
| + cache sits strictly in front of it. |
| +- **Purge/populate race:** accepted, bounded by the safety-net TTL (Β§4). |
| +- **Host relocation/failover:** the cache is per-host and in-memory; a |
| + new or recovered host starts cold. The first real request after a move |
| + is one ordinary wake β consistent with how this platform already |
| + treats hosts as disposable (platform spec Β§4). |
| + |
| +## 8. Testing |
| + |
| +- **Edge cache unit tests** (`internal/edge`): hit/miss/TTL-expiry/ |
| + eviction. The test that actually proves the mitigation: a cache hit |
| + must never invoke the `Activator` β asserted with a fake activator that |
| + fails the test if called at all. |
| +- **Purge channel tests:** a purge invalidates exactly the targeted |
| + key(s) and nothing else; the channel is unreachable except locally (an |
| + isolation test, not just a happy path, given Β§7's DoS concern). |
| +- **Locale bucketing tests:** two `Accept-Language` values negotiating to |
| + the same enabled locale share one cache entry; different negotiated |
| + locales get separate entries; the `titogo_lang` cookie never affects |
| + the cache key. |
| +- **titogo-side tests:** public routes emit the expected `Cache-Control`; |
| + every token-gated route never does; writes call purge for the correct |
| + key(s); the default-`SalesEndAt`-from-event-date logic (extending the |
| + existing `expiry_test.go` pattern), with explicit configuration still |
| + winning over the derived default. |
| +- **End-to-end**, alongside the platform's existing `e2e` suite: cold |
| + miss wakes the instance and populates the cache β repeat request hits |
| + and does **not** wake it β a write plus its purge call makes the next |
| + request miss again and reflect the change. This three-step sequence is |
| + the whole point of the design and should survive even if everything |
| + else here is cut for time. |
| + |
| +## 9. Considered and rejected |
| + |
| +- **A CDN (CloudFront) fronting `routed` apps,** extending the existing |
| + static-only CDN binding (platform spec Β§5). Would offload bot traffic |
| + even further β it would never reach the box at all β but it's |
| + AWS-specific infrastructure, conflicts with this platform's explicit |
| + "no load balancers, self-hostable without a cloud provider" stance, and |
| + CloudFront invalidation isn't instant (seconds, not immediate), a worse |
| + fit given decision 3's freshness requirement. Left as a possible future |
| + accelerator layered on top of this design for deployments that want it |
| + β never the default mechanism. |
| +- **Bot-detection / user-agent gating** instead of caching: serving |
| + crawlers a degraded response while real users always wake the instance |
| + reintroduces exactly the brittleness (UA sniffing, false positives |
| + hurting real SEO) caching avoids, and is unnecessary once purge-on-write |
| + makes the cache always-fresh for everyone regardless of who's asking. |
| + |
| +## 10. Open questions |
| + |
| +1. **Exact purge-channel transport.** Unix socket vs. an extension of an |
| + existing local channel β no existing instanceβhost-agent control path |
| + was found in `internal/activator` or `internal/host`, so this is new |
| + plumbing either way. A plan-level decision. |
| +2. **TTL and cache-size tuning** (Β§3, Β§4, Β§6): concrete numbers, not |
| + fixed here. |
| +3. **Exact shell/fragment split boundary** for titogo's event page: this |
| + spec establishes that availability must be a separate fetch from the |
| + cacheable shell, not the precise template/markup boundary β a |
| + titogo-repo implementation detail. |
| + |
| +## 11. Provenance |
| + |
| +Brainstormed 2026-08-02, prompted directly by Paul's cost analysis of |
| +Tito Go's AWS footprint (see the platform-repo session's cost-scope |
| +discussion) surfacing that titogo hasn't yet migrated to the platform's |
| +hibernating activator, and that migration's cost benefit would be |
| +undermined by unmitigated bot/crawler traffic if solved after the fact |
| +rather than before. Three ideas from that discussion became this design's |
| +core: caching public pages, lazy-loading dynamic ticket availability, and |
| +defaulting ticket sales closed for events with no explicit configured end. |