carlos / platform Public

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.
Paul Campbell b8f2fbff6fb51d18192f6778fb0aaf0309c72881 parent 13f7ece
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.