carlos / platform Public

plan: edge cache-before-wake, platform side

Five-task TDD plan implementing the spec's platform-repo half: the
LRU+TTL response cache, the Edge.ServeHTTP insertion point with
Vary-aware keys, the local per-instance purge listener, and wiring it
into ExecBackend's spawn path via a new --control-socket flag derived
from rt.Socket (no registry schema change needed). Grounded against
the real edge.go/backend_exec.go/agent.go code, including a
construction-order fix (RunAgent now builds the Cache first and
shares it with both ExecBackend and edge.RunWithActivator) discovered
while tracing how edge and activator actually compose in carlos agent.
Paul Campbell 882219fbaabfbba5d07301dd9f0a49449b5b2f8c parent b8f2fbf
1 file changed, +1363 −0
  • docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md +1363 −0
diff --git a/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md b/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md
new file mode 100644
index 000000000..be4598fc2
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-edge-cache-before-wake-platform.md
@@ -0,0 +1,1363 @@
+# Edge Cache-Before-Wake (Platform) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give the CARLOS edge a response cache that serves cacheable GET/HEAD
+requests without ever invoking the hibernation activator, kept fresh by a
+purge channel each hibernating instance can call right after a write.
+
+**Architecture:** A new `internal/edge/cache.go` holds an in-memory,
+LRU+TTL, Vary-aware response cache. `Edge.ServeHTTP` (`internal/edge/edge.go`)
+checks it before calling the activator; a miss falls through to today's
+proxy/wake behavior and populates the cache from the real response on the
+way back. A new `internal/edge/purge.go` exposes a per-instance local-only
+unix-socket listener so an instance can invalidate its own cache entries.
+`internal/activator/backend_exec.go` spawns that listener alongside each
+instance process and passes it the socket path via a new `--control-socket`
+flag, mirroring the existing `--socket`/`--db` flags.
+
+**Tech Stack:** Go (matches the rest of `carlosframework/platform`), standard
+library only (`net/http`, `net`, `container/list`) — no new dependencies.
+
+## Global Constraints
+
+- Go, same module as the rest of `carlosframework/platform`; `go build ./...`
+ and `go vet ./...` must stay clean after every task.
+- Existing test conventions: table-driven where natural, `_test.go`
+ colocated with the code it tests, `go test ./...` for the full suite.
+- Several new tests create real unix sockets (`net.Listen("unix", ...)`).
+ If your shell sandboxes filesystem/socket access, run those tests with
+ sandboxing disabled — this is a known, pre-existing constraint in this
+ repo's test suite, not new to this work.
+- No new third-party dependencies for this feature — the cache, LRU, and
+ purge protocol are all small enough for the standard library.
+- Nil-means-disabled is this package's existing idiom (`Activator` is nil
+ for the pure-proxy `carlos edge` role). The new `*Cache` field on
+ `ExecBackend` follows the same rule: nil disables purge-listener startup
+ without an error.
+
+---
+
+### Task 1: Cache store
+
+**Files:**
+- Create: `internal/edge/cache.go`
+- Test: `internal/edge/cache_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type CacheKey struct {
+ Host string // request Host header, exact match
+ Method string // "GET" or "HEAD"
+ Path string
+ RawQuery string
+ VaryValue string // concatenation of request-header values named by
+ // the response's own Vary header; "" if Vary absent
+ }
+
+ type CachedResponse struct {
+ StatusCode int
+ Header http.Header
+ Body []byte
+ StoredAt time.Time
+ TTL time.Duration
+ }
+
+ func NewCache(maxEntries, maxBodyBytes int) *Cache
+ func (c *Cache) Get(key CacheKey) (*CachedResponse, bool)
+ func (c *Cache) Put(key CacheKey, resp *CachedResponse)
+ func (c *Cache) PurgeURL(host, path, rawQuery string) int // returns count purged
+ ```
+ `Get` returns `(nil, false)` for a miss or an expired (`StoredAt+TTL <
+ now`) entry — expired entries are evicted lazily on lookup, not swept on a
+ timer. `Put` silently drops a response whose body exceeds `maxBodyBytes`
+ (never caches it, no error — a cache is allowed to say no) and evicts the
+ least-recently-used entry when at `maxEntries`. `PurgeURL` drops every
+ cached variant (every `VaryValue`) of `(host, path, rawQuery)` regardless
+ of which locale/Vary-bucket it was stored under, and returns how many
+ entries it actually removed (0 if none were cached — not an error).
+
+- [ ] **Step 1: Write the failing tests**
+
+ ```go
+ package edge
+
+ import (
+ "net/http"
+ "testing"
+ "time"
+ )
+
+ func TestCacheGetMissOnEmpty(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ if _, ok := c.Get(CacheKey{Host: "a", Method: "GET", Path: "/"}); ok {
+ t.Fatal("expected miss on empty cache")
+ }
+ }
+
+ func TestCachePutThenGetHits(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, resp)
+ got, ok := c.Get(key)
+ if !ok {
+ t.Fatal("expected hit after put")
+ }
+ if string(got.Body) != "hi" {
+ t.Fatalf("body = %q, want %q", got.Body, "hi")
+ }
+ }
+
+ func TestCacheEntryExpiresAfterTTL(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute}
+ c.Put(key, resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected miss on expired entry")
+ }
+ }
+
+ func TestCachePutOverMaxBodyBytesIsDropped(t *testing.T) {
+ c := NewCache(10, 4) // 4-byte cap
+ key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
+ resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("way too big"), StoredAt: time.Now(), TTL: time.Minute}
+ c.Put(key, resp)
+ if _, ok := c.Get(key); ok {
+ t.Fatal("expected oversized body to never be cached")
+ }
+ }
+
+ func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) {
+ c := NewCache(2, 1<<20)
+ mk := func(p string) CacheKey { return CacheKey{Host: "a", Method: "GET", Path: p} }
+ put := func(p string) {
+ c.Put(mk(p), &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(p), StoredAt: time.Now(), TTL: time.Minute})
+ }
+ put("/one")
+ put("/two")
+ c.Get(mk("/one")) // touch /one so /two becomes the LRU victim
+ put("/three")
+ if _, ok := c.Get(mk("/two")); ok {
+ t.Fatal("expected /two to have been evicted as least-recently-used")
+ }
+ if _, ok := c.Get(mk("/one")); !ok {
+ t.Fatal("expected /one to survive (recently touched)")
+ }
+ if _, ok := c.Get(mk("/three")); !ok {
+ t.Fatal("expected /three to have been inserted")
+ }
+ }
+
+ func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) {
+ c := NewCache(10, 1<<20)
+ en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"}
+ fr := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "fr"}
+ other := CacheKey{Host: "a", Method: "GET", Path: "/other"}
+ mkResp := func(b string) *CachedResponse {
+ return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
+ }
+ c.Put(en, mkResp("en"))
+ c.Put(fr, mkResp("fr"))
+ c.Put(other, mkResp("other"))
+ n := c.PurgeURL("a", "/event", "")
+ if n != 2 {
+ t.Fatalf("purged %d entries, want 2", n)
+ }
+ if _, ok := c.Get(en); ok {
+ t.Fatal("expected en variant purged")
+ }
+ if _, ok := c.Get(fr); ok {
+ t.Fatal("expected fr variant purged")
+ }
+ if _, ok := c.Get(other); !ok {
+ t.Fatal("expected unrelated URL to survive purge")
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/edge/ -run TestCache -v`
+ Expected: FAIL — `Cache`, `CacheKey`, `CachedResponse`, `NewCache` undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+ ```go
+ package edge
+
+ import (
+ "container/list"
+ "sync"
+ "time"
+ )
+
+ // Cache is an in-memory, per-host response cache for cacheable GET/HEAD
+ // responses. It exists to let the edge answer bot/crawler/repeat traffic
+ // without invoking the hibernation activator (edge.go's ServeHTTP checks
+ // it before ever reaching the activator) — see
+ // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md.
+ // A nil *Cache disables caching entirely; callers must nil-check before use.
+ type Cache struct {
+ mu sync.Mutex
+ maxEntries int
+ maxBodyBytes int
+ order *list.List // MRU at front, LRU at back
+ entries map[cacheFullKey]*list.Element
+ byURL map[cacheURLKey]map[string]struct{} // urlKey -> set of VaryValue
+ }
+
+ type cacheURLKey struct {
+ Host, Method, Path, RawQuery string
+ }
+
+ type cacheFullKey struct {
+ cacheURLKey
+ VaryValue string
+ }
+
+ type cacheEntry struct {
+ key cacheFullKey
+ resp *CachedResponse
+ }
+
+ func NewCache(maxEntries, maxBodyBytes int) *Cache {
+ return &Cache{
+ maxEntries: maxEntries,
+ maxBodyBytes: maxBodyBytes,
+ order: list.New(),
+ entries: make(map[cacheFullKey]*list.Element),
+ byURL: make(map[cacheURLKey]map[string]struct{}),
+ }
+ }
+
+ func (k CacheKey) urlKey() cacheURLKey {
+ return cacheURLKey{Host: k.Host, Method: k.Method, Path: k.Path, RawQuery: k.RawQuery}
+ }
+
+ func (k CacheKey) fullKey() cacheFullKey {
+ return cacheFullKey{cacheURLKey: k.urlKey(), VaryValue: k.VaryValue}
+ }
+
+ func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ el, ok := c.entries[fk]
+ if !ok {
+ return nil, false
+ }
+ e := el.Value.(*cacheEntry)
+ if time.Since(e.resp.StoredAt) > e.resp.TTL {
+ c.removeLocked(el)
+ return nil, false
+ }
+ c.order.MoveToFront(el)
+ return e.resp, true
+ }
+
+ func (c *Cache) Put(key CacheKey, resp *CachedResponse) {
+ if len(resp.Body) > c.maxBodyBytes {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ fk := key.fullKey()
+ if el, ok := c.entries[fk]; ok {
+ el.Value.(*cacheEntry).resp = resp
+ c.order.MoveToFront(el)
+ return
+ }
+ el := c.order.PushFront(&cacheEntry{key: fk, resp: resp})
+ c.entries[fk] = el
+ uk := fk.cacheURLKey
+ if c.byURL[uk] == nil {
+ c.byURL[uk] = make(map[string]struct{})
+ }
+ c.byURL[uk][fk.VaryValue] = struct{}{}
+ for len(c.entries) > c.maxEntries {
+ victim := c.order.Back()
+ if victim == nil {
+ break
+ }
+ c.removeLocked(victim)
+ }
+ }
+
+ // removeLocked drops el from every index. Caller must hold c.mu.
+ func (c *Cache) removeLocked(el *list.Element) {
+ e := el.Value.(*cacheEntry)
+ c.order.Remove(el)
+ delete(c.entries, e.key)
+ if variants := c.byURL[e.key.cacheURLKey]; variants != nil {
+ delete(variants, e.key.VaryValue)
+ if len(variants) == 0 {
+ delete(c.byURL, e.key.cacheURLKey)
+ }
+ }
+ }
+
+ func (c *Cache) PurgeURL(host, path, rawQuery string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ // GET and HEAD are purged together — a write invalidates both.
+ for _, method := range [...]string{"GET", "HEAD"} {
+ uk := cacheURLKey{Host: host, Method: method, Path: path, RawQuery: rawQuery}
+ variants := c.byURL[uk]
+ for vary := range variants {
+ fk := cacheFullKey{cacheURLKey: uk, VaryValue: vary}
+ if el, ok := c.entries[fk]; ok {
+ c.removeLocked(el)
+ n++
+ }
+ }
+ }
+ return n
+ }
+ ```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/edge/ -run TestCache -v`
+ Expected: PASS (all six).
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add internal/edge/cache.go internal/edge/cache_test.go
+ git commit -m "edge: add LRU+TTL response cache, Vary- and purge-aware"
+ ```
+
+---
+
+### Task 2: Cache-key computation and the ServeHTTP insertion point
+
+**Files:**
+- Modify: `internal/edge/edge.go`
+- Test: `internal/edge/edge_test.go`
+
+**Interfaces:**
+- Consumes: `Cache`, `CacheKey`, `CachedResponse`, `NewCache` (Task 1).
+- Produces:
+ ```go
+ const (
+ DefaultCacheMaxEntries = 4096
+ DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB
+ )
+
+ func (e *Edge) EnableCache(cache *Cache) // sets e.cache; nil is a valid no-op
+ ```
+ and the new field `Edge.cache *Cache` (unexported — only `ServeHTTP`
+ reads it). `RunWithActivator` gains a third parameter:
+ `func RunWithActivator(args []string, act Activator, cache *Cache)`.
+ `Run(args []string)` keeps its existing single-argument signature and
+ builds its own private cache internally, so `carlos edge`'s pure-proxy
+ role gets caching too even though it has no activator.
+
+- [ ] **Step 1: Write the failing tests**
+
+ Add to `internal/edge/edge_test.go`. This reuses the file's existing
+ fixtures verbatim: `openReg(t)` (registry against a temp DB),
+ `reg.Upsert(registry.Route{...})`, and `New(reg, act)` — the exact
+ pattern `TestHibernatingRouteGoesThroughActivator` (already in this
+ file) uses for a hibernating route, and the exact pattern
+ `testEdgeWithStore`'s doc comment recommends (`httptest.NewRecorder` +
+ `httptest.NewRequest`, no real listener) for everything else. No new
+ shared helpers are introduced by this task; `refusingActivator` below is
+ a second, deliberately different fake from the file's existing
+ `fakeActivator` because it needs to fail the test on any call, not just
+ count calls.
+
+ ```go
+ // refusingActivator fails the test immediately if ever invoked — used to
+ // prove a cache hit never reaches the activator at all.
+ type refusingActivator struct{ t *testing.T }
+
+ func (r refusingActivator) ServeHTTP(w http.ResponseWriter, req *http.Request, rt registry.Route, proxy http.Handler) bool {
+ r.t.Fatal("activator invoked on what should have been a cache hit — this defeats the whole point of caching")
+ return true
+ }
+
+ func TestServeHTTPCacheHitNeverInvokesActivator(t *testing.T) {
+ reg := openReg(t)
+ // Addr is deliberately a bogus, never-dialed address: if the cache
+ // check is ever bypassed, proxying to it fails loudly instead of
+ // silently succeeding, which would mask the bug.
+ if err := reg.Upsert(registry.Route{Host: "cached.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, refusingActivator{t: t})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ key := CacheKey{Host: "cached.example.com", Method: "GET", Path: "/event"}
+ e.cache.Put(key, &CachedResponse{
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": {"text/html"}},
+ Body: []byte("cached page"),
+ StoredAt: time.Now(),
+ TTL: time.Minute,
+ })
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cached.example.com/event", nil))
+
+ if rec.Code != 200 {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if rec.Body.String() != "cached page" {
+ t.Fatalf("body = %q, want %q", rec.Body.String(), "cached page")
+ }
+ }
+
+ func TestServeHTTPCacheMissPopulatesCacheFromResponse(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte("fresh page"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "miss.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ rec1 := httptest.NewRecorder()
+ e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://miss.example.com/event", nil))
+ if rec1.Body.String() != "fresh page" {
+ t.Fatalf("first response body = %q, want %q", rec1.Body.String(), "fresh page")
+ }
+
+ // Close the backend so a second real request would fail to dial —
+ // proves the second request is answered from cache, not the backend.
+ backend.Close()
+ rec2 := httptest.NewRecorder()
+ e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://miss.example.com/event", nil))
+ if rec2.Body.String() != "fresh page" {
+ t.Fatalf("second response body = %q, want %q (should be served from cache)", rec2.Body.String(), "fresh page")
+ }
+ }
+
+ func TestServeHTTPNeverCachesNon2xx(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(500)
+ w.Write([]byte("boom"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "err.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://err.example.com/event", nil))
+
+ if _, ok := e.cache.Get(CacheKey{Host: "err.example.com", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("a 500 response must never be cached")
+ }
+ }
+
+ func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "Accept-Language")
+ w.WriteHeader(200)
+ w.Write([]byte("lang=" + r.Header.Get("Accept-Language")))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "i18n.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ reqEn := httptest.NewRequest("GET", "http://i18n.example.com/event", nil)
+ reqEn.Header.Set("Accept-Language", "en")
+ recEn := httptest.NewRecorder()
+ e.ServeHTTP(recEn, reqEn)
+
+ reqFr := httptest.NewRequest("GET", "http://i18n.example.com/event", nil)
+ reqFr.Header.Set("Accept-Language", "fr")
+ recFr := httptest.NewRecorder()
+ e.ServeHTTP(recFr, reqFr)
+
+ if recEn.Body.String() == recFr.Body.String() {
+ t.Fatalf("expected distinct cached responses per Accept-Language, got %q for both", recEn.Body.String())
+ }
+ }
+
+ func TestServeHTTPRefusesVaryCookie(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.Header().Set("Vary", "Cookie")
+ w.WriteHeader(200)
+ w.Write([]byte("should not be cached"))
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cookie.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie.example.com/event", nil))
+
+ if _, ok := e.cache.Get(CacheKey{Host: "cookie.example.com", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("a response Vary-ing on Cookie must never be cached — it would defeat per-visitor correctness")
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/edge/ -run TestServeHTTP -v`
+ Expected: FAIL — `e.cache`/`EnableCache` undefined, or (for the hit test)
+ the activator gets invoked because there's no cache check yet.
+
+- [ ] **Step 3: Implement**
+
+ Add to `edge.go` near the other package-level consts:
+
+ ```go
+ const (
+ DefaultCacheMaxEntries = 4096
+ DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB
+ )
+ ```
+
+ Add the field to the `Edge` struct (alongside the existing unexported
+ fields — find that struct definition in `edge.go`):
+
+ ```go
+ cache *Cache // nil disables caching entirely
+ ```
+
+ Add the enabler, next to `EnableStatic`:
+
+ ```go
+ // EnableCache wires a response cache into the edge. Called once at
+ // startup (RunWithActivator or Run); nil is a valid no-op, matching the
+ // package's existing nil-means-disabled convention for Activator.
+ func (e *Edge) EnableCache(cache *Cache) {
+ e.cache = cache
+ }
+ ```
+
+ Modify `ServeHTTP` — locate the existing body (from the earlier read,
+ it looks up `up` from `e.current()`, then does the hibernating check
+ before proxying). Insert the cache check right before the existing
+ `if up.hibernating { ... e.act.ServeHTTP(...) }` block, and wrap the
+ fallthrough proxy call so a cacheable response gets captured:
+
+ ```go
+ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ host := hostOnly(r.Host)
+ m := e.current()
+ up, ok := m[host]
+ if !ok {
+ e.unknownHost(w, r)
+ return
+ }
+
+ cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead)
+ var key CacheKey
+ if cacheable {
+ key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery}
+ if resp, hit := e.cache.Get(key); hit {
+ writeCached(w, resp)
+ return
+ }
+ }
+
+ if up.hibernating {
+ rec := &recordingResponseWriter{ResponseWriter: w, cacheable: cacheable, maxBody: e.cacheMaxBody()}
+ if e.act.ServeHTTP(rec, r, up.route, up.proxy) {
+ e.maybeStore(key, rec)
+ return
+ }
+ up.proxy.ServeHTTP(rec, r)
+ e.maybeStore(key, rec)
+ return
+ }
+
+ if cacheable {
+ rec := &recordingResponseWriter{ResponseWriter: w, cacheable: true, maxBody: e.cacheMaxBody()}
+ up.proxy.ServeHTTP(rec, r)
+ e.maybeStore(key, rec)
+ return
+ }
+ up.proxy.ServeHTTP(w, r)
+ }
+
+ func (e *Edge) cacheMaxBody() int {
+ if e.cache == nil {
+ return 0
+ }
+ return e.cache.maxBodyBytes
+ }
+
+ // maybeStore inspects a recorded response and caches it if it qualifies:
+ // 2xx status, a Cache-Control that names a positive max-age, and no
+ // Vary: Cookie (varying by cookie would defeat correctness per-visitor —
+ // see docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §5).
+ func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) {
+ if e.cache == nil || !rec.cacheable || rec.truncated {
+ return
+ }
+ if rec.status < 200 || rec.status >= 300 {
+ return
+ }
+ ttl, ok := parseMaxAge(rec.Header().Get("Cache-Control"))
+ if !ok || ttl <= 0 {
+ return
+ }
+ vary := rec.Header().Get("Vary")
+ if vary != "" {
+ for _, h := range strings.Split(vary, ",") {
+ if strings.EqualFold(strings.TrimSpace(h), "Cookie") {
+ log.Printf("edge: refusing to cache %s%s — Vary: Cookie would break per-visitor correctness", key.Host, key.Path)
+ return
+ }
+ }
+ key.VaryValue = varyValue(rec.request, vary)
+ }
+ e.cache.Put(key, &CachedResponse{
+ StatusCode: rec.status,
+ Header: rec.Header().Clone(),
+ Body: rec.body,
+ StoredAt: time.Now(),
+ TTL: ttl,
+ })
+ }
+
+ // varyValue concatenates r's values for each header named in the
+ // response's Vary list, in the order Vary names them — the same raw
+ // string always produces the same bucket, which is all correctness
+ // requires (standard HTTP Vary semantics; RFC 7234 §4.1).
+ func varyValue(r *http.Request, vary string) string {
+ var b strings.Builder
+ for _, h := range strings.Split(vary, ",") {
+ h = strings.TrimSpace(h)
+ b.WriteString(h)
+ b.WriteByte('=')
+ b.WriteString(r.Header.Get(h))
+ b.WriteByte(';')
+ }
+ return b.String()
+ }
+
+ // parseMaxAge extracts max-age from a Cache-Control header. Returns
+ // ok=false for "no-store", "no-cache", or a missing/malformed max-age —
+ // any of which mean "don't cache this."
+ func parseMaxAge(cc string) (time.Duration, bool) {
+ for _, part := range strings.Split(cc, ",") {
+ part = strings.TrimSpace(part)
+ if strings.EqualFold(part, "no-store") || strings.EqualFold(part, "no-cache") {
+ return 0, false
+ }
+ if v, ok := strings.CutPrefix(strings.ToLower(part), "max-age="); ok {
+ secs, err := strconv.Atoi(v)
+ if err != nil {
+ return 0, false
+ }
+ return time.Duration(secs) * time.Second, true
+ }
+ }
+ return 0, false
+ }
+
+ func writeCached(w http.ResponseWriter, resp *CachedResponse) {
+ h := w.Header()
+ for k, v := range resp.Header {
+ h[k] = v
+ }
+ w.WriteHeader(resp.StatusCode)
+ w.Write(resp.Body)
+ }
+
+ // recordingResponseWriter captures a response as it's written so it can
+ // be cached afterward, while still passing bytes through to the real
+ // client immediately (no buffering delay for the requester). Recording
+ // stops (silently, not an error) once the body exceeds maxBody — that
+ // response is simply never cached, per maybeStore's rec.truncated check.
+ type recordingResponseWriter struct {
+ http.ResponseWriter
+ request *http.Request
+ cacheable bool
+ maxBody int
+ status int
+ body []byte
+ truncated bool
+ wroteHdr bool
+ }
+
+ func (r *recordingResponseWriter) WriteHeader(status int) {
+ if !r.wroteHdr {
+ r.status = status
+ r.wroteHdr = true
+ }
+ r.ResponseWriter.WriteHeader(status)
+ }
+
+ func (r *recordingResponseWriter) Write(p []byte) (int, error) {
+ if !r.wroteHdr {
+ r.WriteHeader(http.StatusOK)
+ }
+ if r.cacheable && !r.truncated {
+ if len(r.body)+len(p) > r.maxBody {
+ r.truncated = true
+ r.body = nil
+ } else {
+ r.body = append(r.body, p...)
+ }
+ }
+ return r.ResponseWriter.Write(p)
+ }
+ ```
+
+ Add imports as needed (`strconv`, `strings`, `log` if not already
+ present in `edge.go`).
+
+ Update `Run` and `RunWithActivator`:
+
+ ```go
+ func Run(args []string) {
+ RunWithActivator(args, nil, NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ }
+
+ func RunWithActivator(args []string, act Activator, cache *Cache) {
+ // ... unchanged up through:
+ e, err := New(reg, act)
+ if err != nil {
+ log.Fatalf("edge: load routes: %v", err)
+ }
+ e.fallbackURL = *fallbackURL
+ e.EnableCache(cache)
+ // ... rest unchanged
+ }
+ ```
+
+ Update the one other call site, `main.go:27` (`edge.Run(args)`), does
+ **not** need to change — `Run`'s signature is unchanged.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/edge/... -v`
+ Expected: PASS, including all pre-existing `edge_test.go` tests
+ (unchanged behavior for non-cacheable/non-hibernating routes).
+
+- [ ] **Step 5: Run go vet and the full build**
+
+ Run: `go vet ./... && go build ./...`
+ Expected: clean.
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add internal/edge/edge.go internal/edge/edge_test.go
+ git commit -m "edge: check the response cache before waking a hibernating instance"
+ ```
+
+---
+
+### Task 3: Local purge listener
+
+**Files:**
+- Create: `internal/edge/purge.go`
+- Test: `internal/edge/purge_test.go`
+
+**Interfaces:**
+- Consumes: `*Cache` (Task 1), `Cache.PurgeURL`.
+- Produces:
+ ```go
+ type PurgeListener struct{ ... }
+ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error)
+ func (p *PurgeListener) Close() error
+ ```
+ The listener binds a unix socket at `socketPath` (removing any stale
+ file left from a previous run first), mode `0600`, and serves exactly
+ one route: `POST /purge` with a JSON body `{"path":"/event","query":""}`.
+ `host` is fixed at listener-construction time — the request body never
+ names a host, so an instance can only ever purge its own route's cache
+ entries, by construction (not just by socket-file permission).
+
+- [ ] **Step 1: Write the failing tests**
+
+ ```go
+ package edge
+
+ import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+ )
+
+ func dialPurgeSocket(t *testing.T, socketPath string) *http.Client {
+ t.Helper()
+ return &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", socketPath)
+ },
+ },
+ }
+ }
+
+ func TestListenPurgeRemovesCachedEntry(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache)
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+ client := dialPurgeSocket(t, sock)
+ resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("purge POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("purge status = %d, want 200", resp.StatusCode)
+ }
+
+ if _, ok := cache.Get(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}); ok {
+ t.Fatal("expected entry to be purged")
+ }
+ }
+
+ func TestListenPurgeCannotAffectAnotherHost(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}, &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache) // bound to a.example, not other.example
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+ client := dialPurgeSocket(t, sock)
+ resp, _ := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ resp.Body.Close()
+
+ if _, ok := cache.Get(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}); !ok {
+ t.Fatal("a purge listener bound to a.example must never affect other.example's cache entries")
+ }
+ }
+
+ func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ fi, err := os.Stat(sock)
+ if err != nil {
+ t.Fatalf("stat socket: %v", err)
+ }
+ if mode := fi.Mode().Perm(); mode != 0600 {
+ t.Fatalf("socket mode = %o, want 0600", mode)
+ }
+ }
+
+ func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ if err := os.WriteFile(sock, []byte("stale"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge should clean up a stale socket file, got: %v", err)
+ }
+ pl.Close()
+ }
+ ```
+
+ Add `"context"` to the imports used by `dialPurgeSocket`.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/edge/ -run TestListenPurge -v`
+ Expected: FAIL — `ListenPurge` undefined.
+
+- [ ] **Step 3: Implement**
+
+ ```go
+ package edge
+
+ import (
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "os"
+ )
+
+ // PurgeListener is a per-instance, local-only channel for invalidating
+ // that instance's cache entries right after a write — see
+ // docs/superpowers/specs/2026-08-02-edge-cache-before-wake-design.md §3-4.
+ // It is deliberately NOT reachable over the network: an externally
+ // reachable purge endpoint would let anyone manufacture wake storms by
+ // force-purging popular pages, exactly what this feature exists to
+ // prevent.
+ type PurgeListener struct {
+ ln net.Listener
+ srv *http.Server
+ }
+
+ type purgeRequest struct {
+ Path string `json:"path"`
+ Query string `json:"query"`
+ }
+
+ // ListenPurge binds socketPath (removing any stale file left behind by a
+ // crashed previous instance) and serves purge requests scoped to host —
+ // host is fixed here, never taken from the request, so a compromised or
+ // buggy instance can only ever purge its own cache entries.
+ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) {
+ if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ ln, err := net.Listen("unix", socketPath)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.Chmod(socketPath, 0600); err != nil {
+ ln.Close()
+ return nil, err
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) {
+ var req purgeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ cache.PurgeURL(host, req.Path, req.Query)
+ w.WriteHeader(http.StatusOK)
+ })
+
+ srv := &http.Server{Handler: mux}
+ p := &PurgeListener{ln: ln, srv: srv}
+ go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed
+ return p, nil
+ }
+
+ func (p *PurgeListener) Close() error {
+ return p.srv.Close()
+ }
+ ```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/edge/ -run TestListenPurge -v`
+ Expected: PASS (all four).
+
+- [ ] **Step 5: Commit**
+
+ ```bash
+ git add internal/edge/purge.go internal/edge/purge_test.go
+ git commit -m "edge: add local-only per-instance purge listener"
+ ```
+
+---
+
+### Task 4: Wire the purge listener into instance spawn
+
+**Files:**
+- Modify: `internal/activator/backend_exec.go`
+- Modify: `internal/host/agent.go`
+- Test: `internal/activator/backend_exec_test.go` (or wherever
+ `ExecBackend`'s existing tests live — check `backend_unit_test.go`'s
+ neighbor file for the exec-specific test file name first)
+
+**Interfaces:**
+- Consumes: `edge.Cache`, `edge.ListenPurge`, `edge.NewCache`,
+ `edge.DefaultCacheMaxEntries`, `edge.DefaultCacheMaxBodyBytes` (Tasks 1
+ and 3).
+- Produces: `ExecBackend.Cache *edge.Cache` field (nil disables
+ purge-listener startup, per this plan's Global Constraints); the
+ spawned instance process now also receives a `--control-socket <path>`
+ flag.
+
+- [ ] **Step 1: Write the failing test**
+
+ `ExecBackend`'s existing tests live in `internal/activator/activator_test.go`
+ (search for `func testExecBackend(t *testing.T) *ExecBackend` and
+ `func stageLive(t *testing.T, b *ExecBackend, host, ver string)` — both
+ already exist there and are reused below verbatim). Note there is no
+ existing test that runs `ExecBackend.Start` all the way through a
+ successful health-check — `stageLive`'s fake binary is just
+ `#!/bin/sh\n`, which never actually binds `rt.Socket`, so `Start` is
+ expected to return a health-wait error in this test too. That's fine:
+ the purge listener starts (per Step 3's placement, right after
+ `cmd.Start()` succeeds) *before* the health-wait runs, so the control
+ socket file exists by the time `Start` returns, regardless of whether
+ it returns an error. The test asserts on the control socket file
+ directly and explicitly ignores `Start`'s returned error.
+
+ ```go
+ func TestExecBackendStartPassesControlSocketFlagAndStartsPurgeListener(t *testing.T) {
+ b := testExecBackend(t)
+ b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ stageLive(t, b, "control-socket.example.com", "v1")
+
+ rt := registry.Route{
+ Host: "control-socket.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+ wantControlSocket := rt.Socket + ".control"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt) // health-wait error from the fake binary is expected and irrelevant here
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ if _, err := os.Stat(wantControlSocket); err != nil {
+ t.Fatalf("expected control socket %s to exist after Start, got: %v", wantControlSocket, err)
+ }
+ }
+
+ func TestExecBackendStartWithNilCacheSkipsPurgeListener(t *testing.T) {
+ b := testExecBackend(t) // b.Cache left nil
+ stageLive(t, b, "no-cache.example.com", "v1")
+
+ rt := registry.Route{
+ Host: "no-cache.example.com",
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ if _, err := os.Stat(rt.Socket + ".control"); err == nil {
+ t.Fatal("expected no control socket when ExecBackend.Cache is nil")
+ }
+ }
+ ```
+
+ Add `"github.com/carlosframework/platform/internal/edge"` to this test
+ file's imports if not already present.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+ Run: `go test ./internal/activator/ -run TestExecBackendStartPassesControlSocketFlag -v`
+ Run: `go test ./internal/activator/ -run TestExecBackendStartWithNilCacheSkipsPurgeListener -v`
+ Expected: FAIL — `ExecBackend.Cache` field doesn't exist yet (compile
+ error).
+
+- [ ] **Step 3: Implement**
+
+ In `internal/activator/backend_exec.go`, add the field to `ExecBackend`
+ (find its struct definition near the top of the file, alongside `Cfg`,
+ `OptDir`, `DataDir`):
+
+ ```go
+ // Cache, when non-nil, gets a purge listener started for each spawned
+ // instance so it can invalidate its own cached responses right after a
+ // write. Nil disables this — no purge listener, no error.
+ Cache *edge.Cache
+ ```
+
+ Add the import: `"github.com/carlosframework/platform/internal/edge"`
+ (match this repo's actual module path — check `go.mod`'s `module` line
+ if this isn't it verbatim).
+
+ Modify `Start` — locate the existing
+ `cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)` line
+ and the `childProc{cmd: cmd, ...}` construction just after `cmd.Start()`
+ succeeds:
+
+ ```go
+ controlSocket := rt.Socket + ".control"
+ cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath, "--control-socket", controlSocket)
+ cmd.Env = childEnv(fileEnv)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ spawnVer, _ := b.LiveVersion(host)
+ if err := cmd.Start(); err != nil {
+ return nil, "", err
+ }
+
+ p := &childProc{cmd: cmd, done: make(chan struct{})}
+ if b.Cache != nil {
+ pl, err := edge.ListenPurge(controlSocket, rt.Host, b.Cache)
+ if err != nil {
+ // Not fatal to the wake — the instance still serves requests,
+ // it just can't purge its own cache until the next restart.
+ // A stuck-stale cache self-heals via the safety-net TTL anyway.
+ log.Printf("activator: purge listener for %s: %v (continuing without it)", rt.Host, err)
+ } else {
+ p.purge = pl
+ }
+ }
+ go func() {
+ p.err = cmd.Wait()
+ close(p.done)
+ }()
+ ```
+
+ Add the field to `childProc` (find its struct definition — near
+ `func (p *childProc) Stop` seen earlier):
+
+ ```go
+ purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed
+ ```
+
+ Modify `childProc.Stop` to close it:
+
+ ```go
+ func (p *childProc) Stop(ctx context.Context) error {
+ if p.purge != nil {
+ p.purge.Close()
+ }
+ // ... existing body unchanged
+ }
+ ```
+
+ In `internal/host/agent.go`'s `RunAgent`, construct the cache before
+ the activator so both `ExecBackend` and `edge.RunWithActivator` share
+ the same instance:
+
+ ```go
+ cache := edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+
+ cfg, err := activator.ConfigFromEnv()
+ if err != nil {
+ log.Fatalf("agent: %v", err)
+ }
+ var act edge.Activator
+ var svc *activator.Service
+ if cfg != nil {
+ svc = activator.New(*cfg, &activator.SplitBackend{
+ Exec: &activator.ExecBackend{
+ Cfg: *cfg,
+ OptDir: release.OptDir(),
+ DataDir: dd,
+ Cache: cache,
+ },
+ Unit: &activator.UnitBackend{OptDir: release.OptDir()},
+ }, reg)
+ svc.Start()
+ act = svc
+ log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle)
+ }
+
+ // ... adoptLoop/canaryAdoptLoop/statusLoop/domainsLoop goroutines unchanged ...
+
+ edge.RunWithActivator(args, act, cache)
+ ```
+
+ (Only the two lines constructing `cache` and passing it into both the
+ `ExecBackend` literal and the final `edge.RunWithActivator` call
+ change; everything else in `RunAgent` stays as-is.)
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+ Run: `go test ./internal/activator/... ./internal/host/... -v`
+ Expected: PASS, including all pre-existing tests in both packages
+ (`UnitBackend`'s tests are untouched by this task — only `ExecBackend`
+ changed).
+
+- [ ] **Step 5: Run the full build and vet**
+
+ Run: `go build ./... && go vet ./...`
+ Expected: clean. This is the step that catches a missed call site if
+ `RunWithActivator`'s new third parameter wasn't threaded everywhere.
+
+- [ ] **Step 6: Commit**
+
+ ```bash
+ git add internal/activator/backend_exec.go internal/host/agent.go internal/activator/backend_exec_test.go
+ git commit -m "activator: start a purge listener alongside each spawned instance"
+ ```
+
+---
+
+### Task 5: End-to-end cache-before-wake test
+
+**Files:**
+- Modify: `internal/edge/edge_test.go` (or a new
+ `internal/edge/cache_before_wake_test.go` if the existing file is
+ already large — match this repo's existing file-size norms, checked
+ during Task 2)
+
+**Interfaces:**
+- Consumes: everything from Tasks 1-4. No new production code — this
+ task is purely a test that exercises the whole path together, standing
+ in for the spec's §8 "one test that has to exist even if everything
+ else is cut for time."
+
+- [ ] **Step 1: Write the test**
+
+ Uses the same `openReg`/`reg.Upsert`/`New(reg, act)` fixtures as
+ Task 2, plus a new `activatorFunc` adapter (a plain-function `Activator`
+ for tests that want custom per-call behavior — distinct from the file's
+ existing `fakeActivator`, which always proxies straight through and
+ can't count wakes the way this test needs).
+
+ ```go
+ // activatorFunc adapts a plain function to the Activator interface, for
+ // tests that want custom wake-counting behavior without a full fake type.
+ type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool
+
+ func (f activatorFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ return f(w, r, rt, proxy)
+ }
+
+ // TestCacheBeforeWakeFullCycle exercises the sequence the whole feature
+ // exists for: a cold miss wakes the instance and populates the cache; a
+ // repeat request hits the cache and does NOT wake the instance again; a
+ // write's purge call makes the next request miss and see fresh content.
+ func TestCacheBeforeWakeFullCycle(t *testing.T) {
+ body := "version 1"
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(200)
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(backend.Close)
+
+ wakes := 0
+ act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
+ wakes++
+ proxy.ServeHTTP(w, r)
+ return true
+ })
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ e.EnableCache(cache)
+
+ get := func() string {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://cycle.example.com/event", nil))
+ return rec.Body.String()
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("first request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes)
+ }
+
+ if got := get(); got != "version 1" {
+ t.Fatalf("second request body = %q, want %q", got, "version 1")
+ }
+ if wakes != 1 {
+ t.Fatalf("expected the second request to hit cache without waking again, wakes = %d", wakes)
+ }
+
+ // Simulate the write + purge an instance does after a real change.
+ body = "version 2"
+ cache.PurgeURL("cycle.example.com", "/event", "")
+
+ if got := get(); got != "version 2" {
+ t.Fatalf("post-purge request body = %q, want %q", got, "version 2")
+ }
+ if wakes != 2 {
+ t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes)
+ }
+ }
+ ```
+
+- [ ] **Step 2: Run the test to verify it fails (before this task, it doesn't exist; run it right after writing to confirm the harness compiles and the assertions are meaningful)**
+
+ Run: `go test ./internal/edge/ -run TestCacheBeforeWakeFullCycle -v`
+ Expected: PASS immediately if Tasks 1-4 are correctly implemented — this
+ task adds coverage, it doesn't drive new production code. If it fails,
+ that's a real regression in one of Tasks 1-4 to fix before proceeding,
+ not a signal to weaken this test.
+
+- [ ] **Step 3: Run the full edge package test suite**
+
+ Run: `go test ./internal/edge/... -v`
+ Expected: PASS, all tests from every task in this plan plus every
+ pre-existing test in the package.
+
+- [ ] **Step 4: Commit**
+
+ ```bash
+ git add internal/edge/edge_test.go
+ git commit -m "edge: add end-to-end cache-before-wake test"
+ ```
+
+---
+
+## After this plan
+
+The platform side is done: any `routed` app that sets `Cache-Control` on
+its public GET responses gets cache-before-wake for free, and any
+hibernating instance can purge its own entries via
+`<its-socket-path>.control`. Nothing here is titogo-specific — titogo's
+own consumer-side changes (cache headers, the availability-fragment split,
+purge calls on writes, and the `SalesEndAt` default) are planned
+separately in `paulca/titogo`'s own
+`docs/superpowers/plans/2026-08-02-edge-cache-before-wake-titogo.md`,
+which depends on this plan's `--control-socket` flag and purge wire
+protocol (`POST /purge` with `{"path":"...","query":"..."}`) existing
+first.