carlos / platform Public

plan: analytics step 3, the browser door and carlos.js, task by task

Ten tasks from the switch shape to the docs: both switches in one map,
the /__carlos/ reservation answered before the observer, the door's
token buckets, the vendored snippet behind a revalidating loader and an
immutable hashed asset, beacon shapes and the Origin rule, drop counts
on the meter, the handler, the no-wake test, and the tabs, CLI and docs.
Seven planning rulings are listed for Paul to overturn.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 42691777d340f8c1256bb011ce77c8e130e60245 parent df7d1d2
1 file changed, +1484 −0
  • docs/superpowers/plans/2026-09-07-analytics-step3-browser-door.md +1484 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-07-analytics-step3-browser-door.md b/docs/superpowers/plans/2026-09-07-analytics-step3-browser-door.md
new file mode 100644
index 000000000..5568115e2
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-analytics-step3-browser-door.md
@@ -0,0 +1,1484 @@
+# Errors & Analytics — Step 3: the browser door and `carlos.js` — 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:** A member adds one script tag and their app's JavaScript errors appear on the Errors tab and their named events on the Analytics tab, with no wake, no cookie, silent `202`s outward and honest `dropped` counts inward.
+
+**Architecture:** The edge reserves `/__carlos/` on every route between the route lookup and the observing writer: the loader and the hashed snippet are served from the binary, and `POST /__carlos/e` and `/__carlos/a` are validated (Origin, size, shape, rate), attributed to the app owning the beacon's `path` on that hostname, and handed to the step 1 error sink and the step 2 analytics meter. The host's telemetry pass now pushes both switches (`count`, `door`) per app; the console renders the `door` switch it already accepts; the tabs and the CLI show the new drop reasons and the one-line snippet instruction.
+
+**Tech Stack:** Go stdlib (net/http, crypto/sha256, hash/fnv, container/list), html/template, one vendored JavaScript string. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-errors-and-analytics-design.md` — read §3.1, §3.4, §3.6 ("Coverage line"), §4, §6 ("Browser door", "`/__carlos/` reservation", "Settings resolution"), §8 ruling 2 before the task you are on. The spec wins where this plan and it disagree; say so in the commit if you find such a place. Steps 1 and 2 are on main (`internal/apperr`, `internal/analytics`, `internal/telemetry`, `internal/edge/analytics.go`, `internal/host/telemetry_pass.go`); read them before touching them.
+
+## Global Constraints
+
+- Reserved prefix is the static `/__carlos/` (§8 ruling 2). It is answered by the edge on every route kind and never proxied upstream from this branch on.
+- The intercept sits AFTER `lookupUpstreamPath` and BEFORE the `countingWriter` wrap in `Edge.ServeHTTP` (§3.4): beacons are unbilled, unmetered, uncompressed, uncached and never reach the activator (§4). `beginRequest` is never called; `lastActive` never moves.
+- Both POSTs answer `202` with an empty body in every case: accepted, dropped, rejected, malformed, switched off (§3.4).
+- `Origin` must be `https` and resolve through `lookupIn(e.current(), originHost)` to an entry whose `route.Host` equals the request's `up.route.Host`; `Referer` is consulted only when `Origin` is absent; `Sec-Fetch-Site: cross-site` is rejected outright (§3.4).
+- Caps: body 16 KB; `stack` 8 KB (`apperr.MaxStack`); `fields` 32 keys × 256 bytes; `kind` 64 bytes; `name` 1024 bytes; `fp` 64 bytes (the sink caps it). Rate: per app 600/min for `/e` and 600/min for `/a` separately; per visitor 60/min; visitor state ≤ 10,000 entries per app, LRU (§3.4).
+- Every drop increments the app's `dropped.e|a.{rate,size,origin,malformed}` (§3.4, `analytics.DropCounts`), through the meter, when the app's `count` switch is on. Drops on a count-off app are not recorded anywhere (ruling below).
+- The snippet sends NO pageviews in edge mode (§3.4). A `pageview` arriving at `/a` is answered `202` and not counted.
+- `door: off` drops both POSTs; `count: off` drops `/a` and still accepts `/e` (§6 "Settings resolution"). Neither is a recorded drop: the owner chose it.
+- `carlos.js` is vendored in-code (a Go string), never a CDN; the loader is `Cache-Control: max-age=300, must-revalidate`, the hashed asset `public, max-age=31536000, immutable` (§3.4).
+- Sanitisation floor (§3.1) applies in the snippet before sending AND in the sink on arrival; the sink's `Add` already sanitises name, path, stack and fields.
+- Additive wire shapes: `apperr.Record` and `analytics.Day` do not change. `analytics.DropCounts` keys already exist.
+- Every commit: `gofmt -l` clean, `go vet ./...`, `go test ./internal/edge/ ./internal/analytics/ ./internal/host/ ./internal/console/ ./internal/domains/ .`; before push `GOOS=darwin GOARCH=arm64 go build ./...` and `GOOS=windows GOARCH=amd64 go build ./...`. Env: `GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp/g` (mkdir it).
+- Never `git stash`. Commit with `git commit -F <file>` and the `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` trailer.
+
+**Rulings taken while planning (Paul to overturn if wrong):**
+1. Switches travel as one map of `telemetry.Switches{Count, Door bool}` replacing step 2's `map[string]bool`. Cost if wrong: one type rename.
+2. Drops are recorded only when the attributed app's `count` is on: with counting off there is no Analytics tab to show them and nothing is written under `analytics/`. Cost if wrong: one `if`.
+3. Drops that happen before attribution (origin, size) are charged to the hostname's root app (`up`), because the body has not been read. Cost if wrong: a miscount on path-routed hostnames only.
+4. The per-visitor rate key is `fnv64(clientIP ‖ UA)` in edge memory, not the salted visitor hash: it is never stored and needs no salt. Cost if wrong: none visible.
+5. The loader is a two-line script that appends the hashed asset, so a page that cached the loader for five minutes still picks up a new snippet within five minutes of a roll. Cost if wrong: one redirect instead.
+6. `validPathPrefix` (domains) refuses `__carlos`, AND `lookupUpstreamPath` ignores a `__carlos` key defensively. Cost if wrong: one redundant check.
+7. Analytics tab coverage line: "Sources: edge" plus ", browser" once the range has a named event or a browser drop. Errors tab keeps its record-derived Sources line.
+
+---
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| `internal/telemetry/telemetry.go` | `Switches` struct and `Resolved.Switches()` |
+| `internal/edge/analytics.go` | `SetSwitches`, `switchesFor(rt)`; the `AnalyticsMeter` interface grows `Track` and `Drop` |
+| `internal/host/telemetry_pass.go` | resolves both switches per app |
+| `internal/host/agent.go:456` | `setAnalyticsSink(e.SetSwitches)` |
+| `internal/edge/reserved.go` | `ReservedPrefix`, the intercept `serveReserved`, the 404 default |
+| `internal/edge/edge.go` | the one intercept line in `ServeHTTP`; the `__carlos` guard in `lookupUpstreamPath` |
+| `internal/domains/claims.go` | `validPathPrefix` refuses `__carlos` |
+| `internal/edge/doorlimit.go` | token buckets per app/door and per visitor, LRU-bounded |
+| `internal/edge/carlosjs.go` | the vendored snippet, its hash, the loader and asset handlers |
+| `internal/edge/browserdoor.go` | beacon parsing, the Origin rule, the POST handler |
+| `internal/analytics/meter.go` | `Drop` |
+| `internal/console/templates/{app_settings,account_settings,app_analytics,app_errors}.html`, `analyticsview.go` | door switch rendered; coverage line; drop reasons; snippet instruction |
+| `analytics_cmd.go`, `docs/site/cli.md` | drop breakdown; snippet paragraph |
+
+---
+
+### Task 1: Both switches travel together
+
+**Files:**
+- Modify: `internal/telemetry/telemetry.go` (after `Resolved`)
+- Modify: `internal/edge/analytics.go:34-60`
+- Modify: `internal/host/telemetry_pass.go`
+- Modify: `internal/host/agent.go:456`
+- Test: `internal/telemetry/telemetry_test.go`, `internal/edge/analytics_test.go` (every `SetAnalyticsOn` caller), `internal/host/telemetry_pass_test.go`
+
+**Interfaces:**
+- Consumes: `telemetry.Resolved` (step 2), `Edge.analyticsOn atomic.Pointer[map[string]bool]` (step 2, replaced).
+- Produces:
+ ```go
+ // package telemetry
+ type Switches struct{ Count, Door bool }
+ func (r Resolved) Switches() Switches // Count: r.Count == CountEdge; Door: r.Door == DoorOn
+ // package edge
+ func (e *Edge) SetSwitches(m map[string]telemetry.Switches) // copies; nil clears
+ func (e *Edge) switchesFor(rt registry.Route) telemetry.Switches // zero value when unset
+ // package host
+ func telemetrySwitches(ctx, s, reg) (map[string]telemetry.Switches, error)
+ func setAnalyticsSink(fn func(map[string]telemetry.Switches))
+ ```
+ `SetAnalyticsOn` is REMOVED; `analyticsEnabled(rt)` becomes `e.switchesFor(rt).Count`.
+
+- [ ] **Step 1: Failing tests**
+
+`internal/telemetry/telemetry_test.go` — add:
+```go
+func TestResolvedSwitches(t *testing.T) {
+ r := Resolved{Count: CountEdge, Door: DoorOff}
+ if sw := r.Switches(); !sw.Count || sw.Door {
+ t.Fatalf("got %+v", sw)
+ }
+ r = Resolved{Count: CountOff, Door: DoorOn}
+ if sw := r.Switches(); sw.Count || !sw.Door {
+ t.Fatalf("got %+v", sw)
+ }
+}
+```
+`internal/edge/analytics_test.go` — replace every `e.SetAnalyticsOn(map[string]bool{k: true})` with `e.SetSwitches(map[string]telemetry.Switches{k: {Count: true, Door: true}})` and add:
+```go
+func TestSetSwitchesCopiesAndClears(t *testing.T) {
+ e := &Edge{}
+ rt := registry.Route{App: "web"}
+ m := map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true}}
+ e.SetSwitches(m)
+ delete(m, release.OpsAccount+"/web")
+ if !e.switchesFor(rt).Count {
+ t.Fatal("stored map must be a copy")
+ }
+ e.SetSwitches(nil)
+ if sw := e.switchesFor(rt); sw.Count || sw.Door {
+ t.Fatal("nil clears to the zero switches")
+ }
+}
+```
+`internal/host/telemetry_pass_test.go` — the existing test that asserts `on[key] == true` becomes `on[key] == telemetry.Switches{Count: true, Door: true}`; add a case with an app object `{"analytics":{"door":"off"}}` expecting `{Count: true, Door: false}` under an account object `{"analytics":{"count":"edge"}}`.
+
+- [ ] **Step 2: Run** `go test ./internal/telemetry/ ./internal/edge/ -run 'Switches' ./internal/host/ -run Telemetry` — FAIL: undefined `Switches`, `SetSwitches`.
+
+- [ ] **Step 3: Implement**
+
+`telemetry.go`:
+```go
+// Switches is what a box acts on for one app: the two booleans the edge
+// consults per request. Resolved carries provenance for the page; this
+// carries none, so it is what crosses into the edge.
+type Switches struct{ Count, Door bool }
+
+func (r Resolved) Switches() Switches { return Switches{Count: r.Count == CountEdge, Door: r.Door == DoorOn} }
+```
+`edge/analytics.go`: field `switches atomic.Pointer[map[string]telemetry.Switches]` (rename `analyticsOn` in the Edge struct, `edge.go`), `SetSwitches` with the same copy semantics and doc as `SetAnalyticsOn` had, `switchesFor` returning `(*m)[release.RouteAccount(rt)+"/"+rt.App]`, and `analyticsEnabled` returning `e.switchesFor(rt).Count`. Delete `SetAnalyticsOn`.
+`host/telemetry_pass.go`: `analyticsSink atomic.Pointer[func(map[string]telemetry.Switches)]`; `telemetrySwitches` builds `map[string]telemetry.Switches` with `on[key] = res.Switches()` (a pair that fails to resolve is left ABSENT, which is the zero value: off and door closed — keep the log line). `agent.go:456`: `setAnalyticsSink(e.SetSwitches)`.
+
+- [ ] **Step 4: Run** the same tests plus `go build ./... && go vet ./internal/edge/ ./internal/host/` — PASS.
+- [ ] **Step 5: Commit** `analytics: the switch map carries count and door together`.
+
+---
+
+### Task 2: `/__carlos/` is reserved on every route
+
+**Files:**
+- Create: `internal/edge/reserved.go`
+- Modify: `internal/edge/edge.go` (`ServeHTTP` after line 1271, `lookupUpstreamPath` at 1075-1090)
+- Modify: `internal/domains/claims.go:365` (`validPathPrefix`)
+- Test: `internal/edge/reserved_test.go`, `internal/domains/claims_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ // package edge
+ const ReservedPrefix = "/__carlos/"
+ const reservedSegment = "__carlos"
+ // serveReserved answers everything under ReservedPrefix. This task: 404 for all.
+ // Tasks 4 and 7 add the snippet and the beacon dispatch inside it.
+ func (e *Edge) serveReserved(w http.ResponseWriter, r *http.Request, host string, up upstream)
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+`internal/edge/reserved_test.go`:
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// A route's upstream never sees /__carlos/*, whatever it would have served.
+func TestReservedPrefixNeverProxied(t *testing.T) {
+ var hits atomic.Int32
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.Write([]byte("upstream"))
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ for _, p := range []string{"/__carlos/x", "/__carlos/", "/__carlos/e"} {
+ req, _ := http.NewRequest("GET", srv.URL+p, nil)
+ req.Host = "app.example.com"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("%s: status %d, want 404", p, resp.StatusCode)
+ }
+ }
+ if hits.Load() != 0 {
+ t.Fatalf("upstream saw %d reserved requests", hits.Load())
+ }
+ // The prefix is exact: /__carlosx and /__carlos (no slash) are the app's.
+ req, _ := http.NewRequest("GET", srv.URL+"/__carlosx", nil)
+ req.Host = "app.example.com"
+ resp, _ := http.DefaultClient.Do(req)
+ resp.Body.Close()
+ if hits.Load() != 1 {
+ t.Fatal("/__carlosx must reach the upstream")
+ }
+}
+
+// A hibernating route's activator is never invoked for a reserved path.
+func TestReservedPrefixNeverWakes(t *testing.T) {
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "sleepy.example.com", Kind: "instance", Addr: "127.0.0.1:1", Status: "ready", App: "web", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ act := &fakeActivator{}
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "http://sleepy.example.com/__carlos/a", nil)
+ e.ServeHTTP(rec, req)
+ if act.calls() != 0 {
+ t.Fatal("the activator must not see a reserved path")
+ }
+}
+
+// A prefix map cannot steer the reserved segment: a key "__carlos" is ignored.
+func TestLookupIgnoresReservedPrefixKey(t *testing.T) {
+ root := upstream{route: registry.Route{Host: "shared.example.com", App: "root"}, paths: map[string]string{"__carlos": "other.example.com", "team": "other.example.com"}}
+ other := upstream{route: registry.Route{Host: "other.example.com", App: "other"}}
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{"shared.example.com": root, "other.example.com": other})
+ if up, _ := e.lookupUpstreamPath("shared.example.com", "/__carlos/e"); up.route.App != "root" {
+ t.Fatalf("__carlos steered to %q", up.route.App)
+ }
+ if up, _ := e.lookupUpstreamPath("shared.example.com", "/team/x"); up.route.App != "other" {
+ t.Fatalf("ordinary prefix broke: %q", up.route.App)
+ }
+}
+```
+Read `fakeActivator` in `edge_test.go:251` for the call counter it exposes (add a `calls()` accessor if it lacks one) and how the live map is stored (`e.current()` — find the atomic field it loads and use it in the third test; if the map is only reachable through `Reload`, build the two routes via the registry with a claim path map instead and say so in the commit).
+
+`internal/domains/claims_test.go` — add to the existing `validPathPrefix` table (find `TestValidPathPrefix` or the nearest): `{"__carlos", false}`, `{"__carlosx", true}`.
+
+- [ ] **Step 2: Run** `go test ./internal/edge/ -run Reserved ./internal/domains/ -run PathPrefix` — FAIL.
+
+- [ ] **Step 3: Implement**
+
+`reserved.go`:
+```go
+package edge
+
+import "net/http"
+
+// ReservedPrefix is the platform's own path space on every route (spec
+// 2026-09-07 §3.4, §8 ruling 2): the browser door and its snippet. It is
+// answered here, between the route lookup and the observing writer, so
+// nothing under it is billed, compressed, cached, proxied or able to wake
+// an instance (§4).
+const ReservedPrefix = "/__carlos/"
+
+const reservedSegment = "__carlos"
+
+// serveReserved answers a request under ReservedPrefix for the route the
+// lookup resolved. Anything not explicitly served is a plain 404 — never
+// the upstream's answer.
+func (e *Edge) serveReserved(w http.ResponseWriter, r *http.Request, host string, up upstream) {
+ http.NotFound(w, r)
+}
+```
+`edge.go` `ServeHTTP`, immediately after the `if !ok { … }` block that ends at line 1271 and before the `var bytes int64` observer wrap:
+```go
+ // The platform's own path space (reserved.go). Placed AFTER the lookup
+ // (attribution needs up.route, and an unknown host must still backhaul
+ // or 404 exactly as before) and BEFORE the observing writer, so a beacon
+ // is neither metered nor compressed nor cached and never reaches the
+ // activator branches below (spec §3.4, §4).
+ if strings.HasPrefix(r.URL.Path, ReservedPrefix) {
+ e.serveReserved(w, r, host, up)
+ return
+ }
+```
+`lookupUpstreamPath`: after computing `seg` and `raw`, before consulting `up.paths`:
+```go
+ if seg == reservedSegment || raw == reservedSegment {
+ return up, true // the reserved segment belongs to the hostname's root app, never a prefix target
+ }
+```
+`domains/claims.go` `validPathPrefix`: `return p != "." && p != ".." && p != "__carlos"` with a comment naming the edge's reservation.
+
+- [ ] **Step 4: Run** `go test ./internal/edge/ ./internal/domains/` — PASS (every existing edge test still passes; none serves `/__carlos/`).
+- [ ] **Step 5: Commit** `edge: /__carlos/ is reserved on every route, answered before the observer`.
+
+---
+
+### Task 3: The door's rate limiter
+
+**Files:**
+- Create: `internal/edge/doorlimit.go`
+- Test: `internal/edge/doorlimit_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ const (
+ doorAppPerMinute = 600
+ doorVisitorPerMinute = 60
+ doorVisitorEntries = 10000
+ )
+ type doorLimiter struct { mu sync.Mutex; apps map[string]*doorAppState }
+ func newDoorLimiter() *doorLimiter
+ // allow spends one token from the app's bucket for door ('e' or 'a') AND
+ // one from the visitor's bucket; false when either is empty (neither is
+ // spent then). now is injected for tests.
+ func (l *doorLimiter) allow(app string, door byte, visitor uint64, now time.Time) bool
+ ```
+ Token bucket: capacity = per-minute figure, refill = capacity/60 per second, continuous (`tokens = min(cap, tokens + elapsed.Seconds()*cap/60)`). Visitor buckets per app live in a `container/list` LRU capped at `doorVisitorEntries`; the oldest is evicted on insert.
+
+- [ ] **Step 1: Failing test** `doorlimit_test.go`:
+```go
+package edge
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDoorLimiterAppBucket(t *testing.T) {
+ l := newDoorLimiter()
+ now := time.Unix(1_700_000_000, 0)
+ for i := 0; i < doorAppPerMinute; i++ {
+ if !l.allow("a/web", 'e', uint64(i), now) { // a fresh visitor each time: only the app bucket can refuse
+ t.Fatalf("post %d refused", i+1)
+ }
+ }
+ if l.allow("a/web", 'e', 99999, now) {
+ t.Fatal("the 601st error in a minute must be refused")
+ }
+ if !l.allow("a/web", 'a', 99999, now) {
+ t.Fatal("the events bucket is separate")
+ }
+ if !l.allow("a/other", 'e', 99999, now) {
+ t.Fatal("another app is unaffected")
+ }
+ if !l.allow("a/web", 'e', 99999, now.Add(6*time.Second)) {
+ t.Fatal("six seconds refill 60 tokens")
+ }
+}
+
+func TestDoorLimiterVisitorBucketAndLRU(t *testing.T) {
+ l := newDoorLimiter()
+ now := time.Unix(1_700_000_000, 0)
+ for i := 0; i < doorVisitorPerMinute; i++ {
+ if !l.allow("a/web", 'a', 7, now) {
+ t.Fatalf("visitor post %d refused", i+1)
+ }
+ }
+ if l.allow("a/web", 'a', 7, now) {
+ t.Fatal("the 61st post from one visitor must be refused")
+ }
+ if !l.allow("a/web", 'a', 8, now) {
+ t.Fatal("another visitor is unaffected")
+ }
+ // Refusal spends nothing: the app bucket still has its tokens.
+ if got := l.apps["a/web"].bucket['a'].tokens; got < float64(doorAppPerMinute-61) {
+ t.Fatalf("app tokens %v, refused visitor posts must not spend them", got)
+ }
+ for i := uint64(100); i < 100+doorVisitorEntries; i++ {
+ l.allow("a/web", 'a', i, now)
+ }
+ if n := l.apps["a/web"].visitors.Len(); n != doorVisitorEntries {
+ t.Fatalf("visitor table %d entries, want the cap", n)
+ }
+ if _, ok := l.apps["a/web"].visitorIdx[7]; ok {
+ t.Fatal("the oldest visitor must have been evicted")
+ }
+}
+```
+
+- [ ] **Step 2: Run** `go test ./internal/edge/ -run DoorLimiter` — FAIL.
+
+- [ ] **Step 3: Implement** `doorlimit.go`:
+```go
+package edge
+
+import (
+ "container/list"
+ "sync"
+ "time"
+)
+
+// The browser door's rate (spec §3.4): token buckets per app for each
+// door and per visitor, all in edge memory, all dropping silently and
+// counting dropped.rate. Visitor state is bounded per app with LRU
+// eviction so a scan of a million addresses costs a fixed table.
+const (
+ doorAppPerMinute = 600
+ doorVisitorPerMinute = 60
+ doorVisitorEntries = 10000
+)
+
+type tokenBucket struct {
+ tokens float64
+ cap float64
+ last time.Time
+}
+
+func (b *tokenBucket) take(now time.Time) bool {
+ if b.last.IsZero() {
+ b.tokens, b.last = b.cap, now
+ }
+ if el := now.Sub(b.last); el > 0 {
+ b.tokens += el.Seconds() * b.cap / 60
+ if b.tokens > b.cap {
+ b.tokens = b.cap
+ }
+ b.last = now
+ }
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+func (b *tokenBucket) has(now time.Time) bool { // peek: refill, but spend nothing
+ if b.last.IsZero() {
+ b.tokens, b.last = b.cap, now
+ }
+ if el := now.Sub(b.last); el > 0 {
+ b.tokens += el.Seconds() * b.cap / 60
+ if b.tokens > b.cap {
+ b.tokens = b.cap
+ }
+ b.last = now
+ }
+ return b.tokens >= 1
+}
+
+type visitorEntry struct {
+ key uint64
+ b tokenBucket
+}
+
+type doorAppState struct {
+ bucket map[byte]*tokenBucket // 'e', 'a'
+ visitors *list.List // front = most recent; *visitorEntry values
+ visitorIdx map[uint64]*list.Element
+}
+
+type doorLimiter struct {
+ mu sync.Mutex
+ apps map[string]*doorAppState
+}
+
+func newDoorLimiter() *doorLimiter { return &doorLimiter{apps: map[string]*doorAppState{}} }
+
+// allow spends one token from the app's door bucket and one from the
+// visitor's bucket; when either is empty nothing is spent and the post is
+// refused. Both are checked before either is charged.
+func (l *doorLimiter) allow(app string, door byte, visitor uint64, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ st := l.apps[app]
+ if st == nil {
+ st = &doorAppState{
+ bucket: map[byte]*tokenBucket{'e': {cap: doorAppPerMinute}, 'a': {cap: doorAppPerMinute}},
+ visitors: list.New(),
+ visitorIdx: map[uint64]*list.Element{},
+ }
+ l.apps[app] = st
+ }
+ ab := st.bucket[door]
+ if ab == nil {
+ return false
+ }
+ el := st.visitorIdx[visitor]
+ if el == nil {
+ if st.visitors.Len() >= doorVisitorEntries {
+ old := st.visitors.Back()
+ st.visitors.Remove(old)
+ delete(st.visitorIdx, old.Value.(*visitorEntry).key)
+ }
+ el = st.visitors.PushFront(&visitorEntry{key: visitor, b: tokenBucket{cap: doorVisitorPerMinute}})
+ st.visitorIdx[visitor] = el
+ } else {
+ st.visitors.MoveToFront(el)
+ }
+ vb := &el.Value.(*visitorEntry).b
+ if !ab.has(now) || !vb.has(now) {
+ return false
+ }
+ return ab.take(now) && vb.take(now)
+}
+```
+
+- [ ] **Step 4: Run** `go test ./internal/edge/ -run DoorLimiter -race` — PASS.
+- [ ] **Step 5: Commit** `edge: the browser door's token buckets, per app, per door, per visitor`.
+
+---
+
+### Task 4: `carlos.js`, vendored, hashed, served
+
+**Files:**
+- Create: `internal/edge/carlosjs.go`
+- Modify: `internal/edge/reserved.go` (dispatch the two GETs)
+- Test: `internal/edge/carlosjs_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ const carlosJS = `…` // the snippet source (below)
+ var carlosJSHash string // 12 hex chars of sha256(carlosJS), computed in init
+ func carlosJSAssetPath() string // "/__carlos/carlos." + carlosJSHash + ".js"
+ func serveCarlosJS(w http.ResponseWriter, r *http.Request) bool // true when it answered (loader, asset, or 404 for a stale hash)
+ ```
+ `serveReserved` becomes: `if r.Method == GET|HEAD && strings.HasPrefix(path, "/__carlos/carlos.") → serveCarlosJS`, else 404 (Task 7 adds the POSTs).
+
+- [ ] **Step 1: Failing test** `carlosjs_test.go`:
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestCarlosJSLoaderAndAsset(t *testing.T) {
+ e := &Edge{}
+ get := func(p string, hdr map[string]string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "http://app.example.com"+p, nil)
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ e.serveReserved(rec, req, "app.example.com", upstream{})
+ return rec
+ }
+ rec := get("/__carlos/carlos.js", nil)
+ if rec.Code != 200 || rec.Header().Get("Cache-Control") != "max-age=300, must-revalidate" {
+ t.Fatalf("loader: %d %q", rec.Code, rec.Header().Get("Cache-Control"))
+ }
+ if !strings.Contains(rec.Body.String(), carlosJSAssetPath()) {
+ t.Fatal("loader must reference the hashed asset")
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
+ t.Fatalf("content-type %q", ct)
+ }
+ etag := rec.Header().Get("ETag")
+ if etag == "" {
+ t.Fatal("loader carries an ETag so revalidation is a 304")
+ }
+ if rec := get("/__carlos/carlos.js", map[string]string{"If-None-Match": etag}); rec.Code != 304 {
+ t.Fatalf("revalidate: %d", rec.Code)
+ }
+ rec = get(carlosJSAssetPath(), nil)
+ if rec.Code != 200 || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
+ t.Fatalf("asset: %d %q", rec.Code, rec.Header().Get("Cache-Control"))
+ }
+ if rec.Body.String() != carlosJS {
+ t.Fatal("asset body is the snippet verbatim")
+ }
+ if rec := get("/__carlos/carlos.deadbeef0000.js", nil); rec.Code != 404 {
+ t.Fatalf("stale hash: %d", rec.Code)
+ }
+ if len(carlosJSHash) != 12 {
+ t.Fatalf("hash %q", carlosJSHash)
+ }
+}
+
+// The snippet's contract (spec §3.4), pinned as strings: handlers for both
+// error events, sendBeacon for events, no pageview send in edge mode.
+func TestCarlosJSContract(t *testing.T) {
+ for _, want := range []string{"'error'", "'unhandledrejection'", "navigator.sendBeacon", "/__carlos/e", "/__carlos/a", "window.carlos", "track:", "<email>", "<redacted>", "<hex>", "location.pathname"} {
+ if !strings.Contains(carlosJS, want) {
+ t.Errorf("snippet lacks %s", want)
+ }
+ }
+ if strings.Contains(carlosJS, `"pageview"`) || strings.Contains(carlosJS, `'pageview'`) {
+ t.Error("the snippet must not send pageviews in edge mode")
+ }
+}
+```
+
+- [ ] **Step 2: Run** `go test ./internal/edge/ -run CarlosJS` — FAIL.
+
+- [ ] **Step 3: Implement** `carlosjs.go`:
+```go
+package edge
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "strings"
+)
+
+// carlos.js is vendored here the way the console vendors its icons: in
+// code, never a CDN (spec §3.4). The page includes ONE line —
+//
+// <script src="/__carlos/carlos.js" defer></script>
+//
+// — the loader, revalidated every five minutes, which appends the hashed
+// asset below; the asset is immutable for a year because its URL changes
+// with its content. Edge mode: window.onerror and unhandledrejection post
+// to /__carlos/e after the §3.1 sanitisation floor; carlos.track(name,
+// fields) posts to /__carlos/a via sendBeacon. No pageview is ever sent —
+// the edge counted the page when it served the HTML.
+const carlosJS = `(function(){
+if(window.carlos&&window.carlos.__v)return;
+var E='/__carlos/e',A='/__carlos/a';
+function san(s){s=String(s==null?'':s);if(s.length>8192)s=s.slice(0,8192);
+s=s.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,'<email>');
+s=s.replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]+/gi,'$1 <redacted>');
+s=s.replace(/\b(token|key|secret|password|passwd|pwd|api_key|apikey|access_token|auth)("?\s*[=:]\s*"?)[^\s&"',;]+/gi,'$1$2<redacted>');
+s=s.replace(/\b[A-Fa-f0-9]{32,}\b/g,'<hex>');
+s=s.replace(/\b(?=[A-Za-z0-9+\/]*\d)(?=[A-Za-z0-9+\/]*[A-Z])(?=[A-Za-z0-9+\/]*[a-z])[A-Za-z0-9+\/]{32,}={0,2}/g,'<hex>');
+return s;}
+function send(u,b){try{var s=JSON.stringify(b);
+if(navigator.sendBeacon){navigator.sendBeacon(u,new Blob([s],{type:'application/json'}));}
+else{fetch(u,{method:'POST',body:s,headers:{'Content-Type':'application/json'},keepalive:true,credentials:'omit'});}}catch(e){}}
+function fields(f){var o={},n=0;if(f&&typeof f==='object'){for(var k in f){if(!Object.prototype.hasOwnProperty.call(f,k))continue;if(n++>=32)break;var v=f[k];if(v==null)continue;
+o[String(k).slice(0,64)]=san(typeof v==='object'?JSON.stringify(v):v).slice(0,256);}}return o;}
+function err(kind,name,stack,f){send(E,{kind:String(kind||'Error').slice(0,64),name:san(name).slice(0,1024),stack:san(stack||'').slice(0,8192),path:location.pathname,fields:fields(f)});}
+window.addEventListener('error',function(ev){var e=ev.error;err(e&&e.name||'Error',ev.message||(e&&e.message)||'error',e&&e.stack||'',{file:ev.filename||'',line:String(ev.lineno||'')});});
+window.addEventListener('unhandledrejection',function(ev){var r=ev.reason;var m=r&&r.message||(typeof r==='string'?r:'unhandled rejection');err(r&&r.name||'UnhandledRejection',m,r&&r.stack||'',{});});
+window.carlos={__v:1,
+track:function(name,f){send(A,{name:String(name||'').slice(0,64),path:location.pathname,fields:fields(f)});},
+error:function(e,f){err(e&&e.name||'Error',e&&e.message||String(e),e&&e.stack||'',f);}};
+})();
+`
+
+var carlosJSHash string
+
+func init() {
+ sum := sha256.Sum256([]byte(carlosJS))
+ carlosJSHash = hex.EncodeToString(sum[:])[:12]
+}
+
+func carlosJSAssetPath() string { return ReservedPrefix + "carlos." + carlosJSHash + ".js" }
+
+// serveCarlosJS answers the loader and the hashed asset. It reports false
+// for any other path so serveReserved can 404 it. HEAD is served like GET
+// (the stdlib drops the body).
+func serveCarlosJS(w http.ResponseWriter, r *http.Request) bool {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ return false
+ }
+ switch r.URL.Path {
+ case ReservedPrefix + "carlos.js":
+ etag := `"` + carlosJSHash + `"`
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ w.Header().Set("Cache-Control", "max-age=300, must-revalidate")
+ w.Header().Set("ETag", etag)
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if strings.Contains(r.Header.Get("If-None-Match"), carlosJSHash) {
+ w.WriteHeader(http.StatusNotModified)
+ return true
+ }
+ w.Write([]byte("(function(){var s=document.createElement('script');s.src='" + carlosJSAssetPath() + "';s.async=true;document.head.appendChild(s);})();\n"))
+ return true
+ case carlosJSAssetPath():
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Write([]byte(carlosJS))
+ return true
+ }
+ if strings.HasPrefix(r.URL.Path, ReservedPrefix+"carlos.") {
+ http.NotFound(w, r) // a stale hash from an old loader
+ return true
+ }
+ return false
+}
+```
+`reserved.go` `serveReserved`: `if serveCarlosJS(w, r) { return }` before the 404.
+
+- [ ] **Step 4: Run** `go test ./internal/edge/ -run 'CarlosJS|Reserved'` — PASS. Also paste the snippet into `node -e` if node is installed (`node -e "$(sed -n '/^const carlosJS = \`/,/^\`$/p' internal/edge/carlosjs.go | sed '1s/.*`//;$d')" 2>&1 | head -3` — a ReferenceError for `window` is fine, a SyntaxError is not); if node is absent, say so in the commit.
+- [ ] **Step 5: Commit** `edge: carlos.js vendored, a revalidating loader and an immutable hashed asset`.
+
+---
+
+### Task 5: Beacon shapes and the Origin rule
+
+**Files:**
+- Create: `internal/edge/browserdoor.go` (parsing and origin only; the handler is Task 7)
+- Test: `internal/edge/browserdoor_test.go`
+
+**Interfaces:**
+- Consumes: `lookupIn`, `upstream`, `apperr.Record`, `apperr.MaxStack`.
+- Produces:
+ ```go
+ const (
+ maxBeaconBody = 16 << 10
+ maxBeaconKind = 64
+ maxBeaconName = 1024
+ maxBeaconFields = 32
+ maxBeaconField = 256
+ )
+ type errorBeacon struct { Kind, Name, Stack, Path, FP string; Fields map[string]string }
+ type eventBeacon struct { Name, Path string }
+ // parseErrorBeacon decodes and caps; false when not a JSON object or name empty.
+ func parseErrorBeacon(b []byte) (errorBeacon, bool)
+ // parseEventBeacon decodes; false when not a JSON object or name empty. fields are accepted and dropped (§5).
+ func parseEventBeacon(b []byte) (eventBeacon, bool)
+ // beaconOriginOK applies §3.4: Sec-Fetch-Site cross-site → false; Origin present → must be https and resolve
+ // via lookupIn(m, host) to route.Host == want; Origin absent → Referer's host through the same rule; neither → false.
+ func beaconOriginOK(m map[string]upstream, r *http.Request, want string) bool
+ ```
+
+- [ ] **Step 1: Failing tests** `browserdoor_test.go`:
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestParseErrorBeaconCaps(t *testing.T) {
+ long := strings.Repeat("x", 9000)
+ b, ok := parseErrorBeacon([]byte(`{"kind":"` + long + `","name":"boom","stack":"` + long + `","path":"/p?q=1","fp":"abc","fields":{"a":"` + long + `"}}`))
+ if !ok || len(b.Kind) != maxBeaconKind || len(b.Stack) != 8<<10 || len(b.Fields["a"]) != maxBeaconField || b.Path != "/p?q=1" {
+ t.Fatalf("caps: %+v ok=%v", b, ok)
+ }
+ if _, ok := parseErrorBeacon([]byte(`{"kind":"E"}`)); ok {
+ t.Fatal("a beacon without a name is malformed")
+ }
+ if _, ok := parseErrorBeacon([]byte(`[1,2]`)); ok {
+ t.Fatal("not an object")
+ }
+ many := "{"
+ for i := 0; i < 40; i++ {
+ many += `"k` + string(rune('a'+i%26)) + string(rune('0'+i/26)) + `":"v",`
+ }
+ b, _ = parseErrorBeacon([]byte(`{"name":"n","fields":` + many + `"z":"v"}}`))
+ if len(b.Fields) != maxBeaconFields {
+ t.Fatalf("fields capped to %d, got %d", maxBeaconFields, len(b.Fields))
+ }
+}
+
+func TestParseEventBeacon(t *testing.T) {
+ b, ok := parseEventBeacon([]byte(`{"name":"Signup","path":"/x","fields":{"plan":"pro"}}`))
+ if !ok || b.Name != "Signup" || b.Path != "/x" {
+ t.Fatalf("%+v %v", b, ok)
+ }
+ if _, ok := parseEventBeacon([]byte(`{"path":"/x"}`)); ok {
+ t.Fatal("no name")
+ }
+}
+
+func TestBeaconOriginRule(t *testing.T) {
+ m := map[string]upstream{
+ "app.example.com": {route: registry.Route{Host: "app.example.com"}},
+ "www.example.com": {route: registry.Route{Host: "app.example.com"}, alias: true},
+ "other.example.com": {route: registry.Route{Host: "other.example.com"}},
+ }
+ req := func(hdr map[string]string) *http.Request {
+ r := httptest.NewRequest("POST", "http://app.example.com/__carlos/e", nil)
+ for k, v := range hdr {
+ r.Header.Set(k, v)
+ }
+ return r
+ }
+ cases := []struct {
+ name string
+ hdr map[string]string
+ ok bool
+ }{
+ {"origin matches", map[string]string{"Origin": "https://app.example.com"}, true},
+ {"origin with port", map[string]string{"Origin": "https://app.example.com:443"}, true},
+ {"alias origin shares the route", map[string]string{"Origin": "https://www.example.com"}, true},
+ {"http origin", map[string]string{"Origin": "http://app.example.com"}, false},
+ {"foreign origin", map[string]string{"Origin": "https://other.example.com"}, false},
+ {"unknown origin", map[string]string{"Origin": "https://evil.example.net"}, false},
+ {"null origin", map[string]string{"Origin": "null"}, false},
+ {"foreign origin, matching referer", map[string]string{"Origin": "https://other.example.com", "Referer": "https://app.example.com/page"}, false},
+ {"no origin, matching referer", map[string]string{"Referer": "https://app.example.com/page"}, true},
+ {"no origin, foreign referer", map[string]string{"Referer": "https://other.example.com/"}, false},
+ {"neither", map[string]string{}, false},
+ {"cross-site", map[string]string{"Origin": "https://app.example.com", "Sec-Fetch-Site": "cross-site"}, false},
+ {"same-origin fetch site", map[string]string{"Origin": "https://app.example.com", "Sec-Fetch-Site": "same-origin"}, true},
+ }
+ for _, c := range cases {
+ if got := beaconOriginOK(m, req(c.hdr), "app.example.com"); got != c.ok {
+ t.Errorf("%s: got %v want %v", c.name, got, c.ok)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run** `go test ./internal/edge/ -run 'Beacon'` — FAIL.
+
+- [ ] **Step 3: Implement** `browserdoor.go`:
+```go
+package edge
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/apperr"
+)
+
+// The browser door (spec 2026-09-07 §3.4): what a page may post, how much,
+// and who may post it. Parsing caps everything before the sink sees it;
+// the sink sanitises on arrival regardless of what the snippet did.
+const (
+ maxBeaconBody = 16 << 10
+ maxBeaconKind = 64
+ maxBeaconName = 1024
+ maxBeaconFields = 32
+ maxBeaconField = 256
+)
+
+type errorBeacon struct {
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Stack string `json:"stack"`
+ Path string `json:"path"`
+ FP string `json:"fp"`
+ Fields map[string]string `json:"fields"`
+}
+
+type eventBeacon struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+func capStr(s string, n int) string {
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}
+
+func parseErrorBeacon(b []byte) (errorBeacon, bool) {
+ var e errorBeacon
+ if err := json.Unmarshal(b, &e); err != nil || e.Name == "" {
+ return errorBeacon{}, false
+ }
+ e.Kind = capStr(e.Kind, maxBeaconKind)
+ e.Name = capStr(e.Name, maxBeaconName)
+ e.Stack = capStr(e.Stack, apperr.MaxStack)
+ e.FP = capStr(e.FP, 64)
+ if len(e.Fields) > 0 {
+ keys := make([]string, 0, len(e.Fields))
+ for k := range e.Fields {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys) // deterministic under the cap
+ f := make(map[string]string, maxBeaconFields)
+ for i, k := range keys {
+ if i >= maxBeaconFields {
+ break
+ }
+ f[capStr(k, 64)] = capStr(e.Fields[k], maxBeaconField)
+ }
+ e.Fields = f
+ }
+ return e, true
+}
+
+func parseEventBeacon(b []byte) (eventBeacon, bool) {
+ var e eventBeacon
+ if err := json.Unmarshal(b, &e); err != nil || e.Name == "" {
+ return eventBeacon{}, false
+ }
+ return e, true
+}
+
+// beaconOriginOK is identification, not authentication (§3.4): the page
+// that posts must be one this hostname's route serves. Origin wins when
+// present — a bad Origin is refused even if Referer would pass — and
+// Referer is consulted only when Origin is absent. Both must be https and
+// must resolve through the SAME map lookup the request took to an entry
+// whose route.Host equals want; aliases share the primary's route verbatim
+// and so pass with no registry call.
+func beaconOriginOK(m map[string]upstream, r *http.Request, want string) bool {
+ if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
+ return false
+ }
+ src := r.Header.Get("Origin")
+ if src == "" {
+ src = r.Header.Get("Referer")
+ }
+ if src == "" {
+ return false
+ }
+ u, err := url.Parse(src)
+ if err != nil || u.Scheme != "https" || u.Hostname() == "" {
+ return false
+ }
+ up, ok := lookupIn(m, strings.ToLower(u.Hostname()))
+ return ok && up.route.Host == want
+}
+```
+
+- [ ] **Step 4: Run** `go test ./internal/edge/ -run Beacon` — PASS.
+- [ ] **Step 5: Commit** `edge: beacon shapes with their caps, and the Origin rule`.
+
+---
+
+### Task 6: The meter records drops; the edge's meter contract grows
+
+**Files:**
+- Modify: `internal/analytics/meter.go` (after `Track`)
+- Modify: `internal/edge/analytics.go:19-21` (`AnalyticsMeter`)
+- Test: `internal/analytics/meter_test.go`, `internal/edge/analytics_test.go` (`memAnalytics`)
+
+**Interfaces:**
+- Produces:
+ ```go
+ // package analytics
+ const (
+ DoorErrors = 'e'; DoorEvents = 'a'
+ DropRate = "rate"; DropSize = "size"; DropOrigin = "origin"; DropMalformed = "malformed"
+ )
+ // Drop counts one refused beacon in the app's current-day Dropped block.
+ func (m *Meter) Drop(acct, app string, door byte, reason string, when time.Time)
+ // package edge
+ type AnalyticsMeter interface {
+ Observe(analytics.Obs)
+ Track(acct, app, name string, when time.Time)
+ Drop(acct, app string, door byte, reason string, when time.Time)
+ }
+ ```
+
+- [ ] **Step 1: Failing tests**
+
+`meter_test.go` — add:
+```go
+func TestMeterDropCountsByDoorAndReason(t *testing.T) {
+ now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
+ m := NewMeter(t.TempDir(), "box1", func() time.Time { return now })
+ m.Drop("acct", "web", DoorErrors, DropRate, now)
+ m.Drop("acct", "web", DoorErrors, DropSize, now)
+ m.Drop("acct", "web", DoorEvents, DropOrigin, now)
+ m.Drop("acct", "web", DoorEvents, DropMalformed, now)
+ m.Drop("acct", "web", DoorEvents, DropMalformed, now)
+ m.Drop("acct", "web", 'x', DropRate, now) // unknown door: ignored, never a panic
+ ds := m.Drain()[AppKey{Acct: "acct", App: "web"}]
+ if len(ds) != 1 {
+ t.Fatalf("deltas %d", len(ds))
+ }
+ d := ds[0].Dropped
+ if d.E.Rate != 1 || d.E.Size != 1 || d.A.Origin != 1 || d.A.Malformed != 2 || d.E.Origin != 0 {
+ t.Fatalf("dropped %+v", d)
+ }
+}
+```
+`edge/analytics_test.go` — `memAnalytics` gains:
+```go
+type trackCall struct{ Acct, App, Name string }
+type dropCall struct{ Acct, App string; Door byte; Reason string }
+// fields: tracks []trackCall; drops []dropCall (under mu)
+func (m *memAnalytics) Track(acct, app, name string, _ time.Time) { m.mu.Lock(); m.tracks = append(m.tracks, trackCall{acct, app, name}); m.mu.Unlock() }
+func (m *memAnalytics) Drop(acct, app string, door byte, reason string, _ time.Time) { m.mu.Lock(); m.drops = append(m.drops, dropCall{acct, app, door, reason}); m.mu.Unlock() }
+```
+(with `snapTracks()`/`snapDrops()` accessors mirroring `snap()`).
+
+- [ ] **Step 2: Run** `go test ./internal/analytics/ -run Drop ./internal/edge/` — FAIL (interface unsatisfied / undefined).
+
+- [ ] **Step 3: Implement** in `meter.go`:
+```go
+// Doors and drop reasons (spec §3.4): the browser door names them when it
+// refuses a beacon; the day file's Dropped block carries the counts.
+const (
+ DoorErrors = 'e'
+ DoorEvents = 'a'
+
+ DropRate = "rate"
+ DropSize = "size"
+ DropOrigin = "origin"
+ DropMalformed = "malformed"
+)
+
+// Drop counts one refused beacon for the app's current day. An unknown
+// door or reason counts nothing: the caller is this package's own door
+// constants, and a typo must not panic the serving path.
+func (m *Meter) Drop(acct, app string, door byte, reason string, when time.Time) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d := m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when))
+ var c *DropCounts
+ switch door {
+ case DoorErrors:
+ c = &d.Dropped.E
+ case DoorEvents:
+ c = &d.Dropped.A
+ default:
+ return
+ }
+ switch reason {
+ case DropRate:
+ c.Rate++
+ case DropSize:
+ c.Size++
+ case DropOrigin:
+ c.Origin++
+ case DropMalformed:
+ c.Malformed++
+ }
+}
+```
+`edge/analytics.go`: widen the interface as above (doc: "analytics.Meter satisfies it; the door calls Track and Drop, the observer calls Observe").
+
+- [ ] **Step 4: Run** `go test ./internal/analytics/ ./internal/edge/ ./internal/host/` — PASS.
+- [ ] **Step 5: Commit** `analytics: the meter records door drops by door and reason`.
+
+---
+
+### Task 7: The door handler
+
+**Files:**
+- Modify: `internal/edge/browserdoor.go` (add the handler)
+- Modify: `internal/edge/reserved.go` (dispatch the POSTs)
+- Modify: `internal/edge/edge.go` (Edge struct: `doorLimit *doorLimiter`; `New` sets `doorLimit: newDoorLimiter()`)
+- Test: `internal/edge/browserdoor_test.go` (append)
+
+**Interfaces:**
+- Consumes: Tasks 1–6.
+- Produces: `func (e *Edge) serveBeacon(w http.ResponseWriter, r *http.Request, host string, up upstream, door byte)`.
+
+The flow, in this order, every branch ending in `202` with an empty body:
+1. `up.route.Kind == "status" || up.route.App == ""` → 202, nothing (no app to attribute).
+2. `!beaconOriginOK(e.current(), r, up.route.Host)` → drop `origin` against `up`'s app → 202.
+3. Body: `io.ReadAll(io.LimitReader(r.Body, maxBeaconBody+1))`; `len > maxBeaconBody` → drop `size` against `up`'s app.
+4. Parse (`parseErrorBeacon` / `parseEventBeacon`) → failure is drop `malformed` against `up`'s app.
+5. Attribute: `tu := up`; if the beacon's `Path` is non-empty, `tu, _ = e.lookupUpstreamPath(host, beacon.Path)` (the raw path, §3.4). `acct, app := release.RouteAccount(tu.route), tu.route.App`.
+6. Switches `sw := e.switchesFor(tu.route)`: door `e` requires `sw.Door`; door `a` requires `sw.Door && sw.Count`; otherwise 202, nothing recorded.
+7. Rate: `visitor := fnv64(clientIP(r) + "\x00" + r.Header.Get("User-Agent"))` (hash/fnv 64a); `!e.doorLimit.allow(acct+"/"+app, door, visitor, now)` → drop `rate`.
+8. `e`: `errSink.Add(acct, app, apperr.Record{Host: tu.route.Host, Source: apperr.SourceBrowser, Kind: b.Kind, Name: b.Name, Stack: b.Stack, Path: b.Path, Fields: b.Fields, FP: b.FP, Release: tu.route.Version})` (FPV: set to `apperr.FPVersion` only when `b.FP != ""`; the sink stamps it otherwise). `a`: `name == "pageview"` → 202, nothing (edge mode); else `meter.Track(acct, app, b.Name, now)`.
+ A drop is recorded via `meter.Drop` ONLY when `e.switchesFor(<the charged route>).Count` is true (ruling 2); a nil meter or nil sink is a no-op.
+9. `w.WriteHeader(http.StatusAccepted)`.
+
+- [ ] **Step 1: Failing tests** — append to `browserdoor_test.go`:
+```go
+// doorFixture: one counted, door-open app on app.example.com with a fake
+// upstream that must never be reached, a memory sink and a memory meter.
+type memSink struct {
+ mu sync.Mutex
+ recs []struct{ Acct, App string; R apperr.Record }
+}
+
+func (s *memSink) Add(acct, app string, r apperr.Record) {
+ s.mu.Lock()
+ s.recs = append(s.recs, struct{ Acct, App string; R apperr.Record }{acct, app, r})
+ s.mu.Unlock()
+}
+
+func doorFixture(t *testing.T) (*Edge, *httptest.Server, *memSink, *memAnalytics) {
+ t.Helper()
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("upstream reached: %s", r.URL.Path) }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web", Version: "v7"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sink, meter := &memSink{}, &memAnalytics{}
+ e.EnableErrors(sink)
+ e.EnableAnalytics(meter)
+ e.SetSwitches(map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true, Door: true}})
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return e, srv, sink, meter
+}
+
+func postBeacon(t *testing.T, srv *httptest.Server, path, body string, hdr map[string]string) int {
+ t.Helper()
+ req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(body))
+ req.Host = "app.example.com"
+ req.Header.Set("Origin", "https://app.example.com")
+ for k, v := range hdr {
+ if v == "" {
+ req.Header.Del(k)
+ } else {
+ req.Header.Set(k, v)
+ }
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if len(b) != 0 {
+ t.Fatalf("beacon answered with a body: %q", b)
+ }
+ return resp.StatusCode
+}
+
+func TestDoorAcceptsAndAttributes(t *testing.T) {
+ _, srv, sink, meter := doorFixture(t)
+ if st := postBeacon(t, srv, "/__carlos/e", `{"kind":"TypeError","name":"x is not a function","stack":"at a (app.js:1:2)","path":"/checkout?step=2","fields":{"user":"a@b.co"}}`, nil); st != 202 {
+ t.Fatalf("status %d", st)
+ }
+ if len(sink.recs) != 1 {
+ t.Fatalf("records %d", len(sink.recs))
+ }
+ r := sink.recs[0]
+ if r.Acct != release.OpsAccount || r.App != "web" || r.R.Source != apperr.SourceBrowser || r.R.Kind != "TypeError" || r.R.Host != "app.example.com" || r.R.Release != "v7" || r.R.Path != "/checkout?step=2" {
+ t.Fatalf("record %+v", r)
+ }
+ if st := postBeacon(t, srv, "/__carlos/a", `{"name":"signup","path":"/"}`, nil); st != 202 {
+ t.Fatalf("status %d", st)
+ }
+ if tr := meter.snapTracks(); len(tr) != 1 || tr[0].Name != "signup" || tr[0].App != "web" {
+ t.Fatalf("tracks %+v", tr)
+ }
+ postBeacon(t, srv, "/__carlos/a", `{"name":"pageview","path":"/"}`, nil)
+ if len(meter.snapTracks()) != 1 {
+ t.Fatal("a browser pageview is not counted in edge mode")
+ }
+ if len(meter.snapDrops()) != 0 {
+ t.Fatalf("nothing should be dropped: %+v", meter.snapDrops())
+ }
+}
+
+func TestDoorDropsAndAlways202(t *testing.T) {
+ _, srv, sink, meter := doorFixture(t)
+ cases := []struct {
+ name, path, body string
+ hdr map[string]string
+ door byte
+ reason string
+ }{
+ {"foreign origin", "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"}, 'e', "origin"},
+ {"foreign origin, matching referer", "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net", "Referer": "https://app.example.com/"}, 'e', "origin"},
+ {"cross-site", "/__carlos/a", `{"name":"n"}`, map[string]string{"Sec-Fetch-Site": "cross-site"}, 'a', "origin"},
+ {"no origin no referer", "/__carlos/a", `{"name":"n"}`, map[string]string{"Origin": ""}, 'a', "origin"},
+ {"17 KB", "/__carlos/e", `{"name":"` + strings.Repeat("x", 17<<10) + `"}`, nil, 'e', "size"},
+ {"not json", "/__carlos/e", `nope`, nil, 'e', "malformed"},
+ {"no name", "/__carlos/a", `{"path":"/"}`, nil, 'a', "malformed"},
+ }
+ for _, c := range cases {
+ before := len(meter.snapDrops())
+ if st := postBeacon(t, srv, c.path, c.body, c.hdr); st != 202 {
+ t.Errorf("%s: status %d", c.name, st)
+ }
+ drops := meter.snapDrops()
+ if len(drops) != before+1 || drops[before].Door != c.door || drops[before].Reason != c.reason {
+ t.Errorf("%s: drops %+v", c.name, drops[before:])
+ }
+ }
+ if len(sink.recs) != 0 || len(meter.snapTracks()) != 0 {
+ t.Fatal("dropped beacons must not land")
+ }
+ // Missing Origin with a matching Referer counts.
+ if st := postBeacon(t, srv, "/__carlos/a", `{"name":"ok"}`, map[string]string{"Origin": "", "Referer": "https://app.example.com/p"}); st != 202 || len(meter.snapTracks()) != 1 {
+ t.Fatal("referer fallback")
+ }
+}
+
+func TestDoorRateLimit(t *testing.T) {
+ _, srv, sink, meter := doorFixture(t)
+ for i := 0; i < doorVisitorPerMinute; i++ {
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ }
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(sink.recs) != doorVisitorPerMinute {
+ t.Fatalf("landed %d", len(sink.recs))
+ }
+ if d := meter.snapDrops(); len(d) != 1 || d[0].Reason != "rate" || d[0].Door != 'e' {
+ t.Fatalf("drops %+v", d)
+ }
+ // The app bucket: 600 across distinct visitors, the 601st dropped.
+ e, srv2, sink2, meter2 := doorFixture(t)
+ _ = e
+ for i := 0; i <= doorAppPerMinute; i++ {
+ postBeacon(t, srv2, "/__carlos/a", `{"name":"n"}`, map[string]string{"User-Agent": "ua-" + strconv.Itoa(i)})
+ }
+ if len(meter2.snapTracks()) != doorAppPerMinute || len(meter2.snapDrops()) != 1 {
+ t.Fatalf("tracked %d dropped %d", len(meter2.snapTracks()), len(meter2.snapDrops()))
+ }
+ _ = sink2
+}
+
+func TestDoorSwitches(t *testing.T) {
+ e, srv, sink, meter := doorFixture(t)
+ key := release.OpsAccount + "/web"
+ e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}})
+ postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(meter.snapTracks()) != 0 || len(sink.recs) != 1 || len(meter.snapDrops()) != 0 {
+ t.Fatal("count off: /a dropped silently, /e accepted, nothing recorded as a drop")
+ }
+ e.SetSwitches(map[string]telemetry.Switches{key: {Count: true, Door: false}})
+ postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(meter.snapTracks()) != 0 || len(sink.recs) != 1 || len(meter.snapDrops()) != 0 {
+ t.Fatal("door off: both dropped silently")
+ }
+ // Count off: an origin drop is not recorded either (ruling 2).
+ e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}})
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"})
+ if len(meter.snapDrops()) != 0 {
+ t.Fatal("drops are recorded only for a counted app")
+ }
+}
+
+// On a path-routed hostname the beacon lands on the app owning its path.
+func TestDoorAttributesByPathPrefix(t *testing.T) {
+ e, srv, sink, _ := doorFixture(t)
+ reg := e.reg
+ if err := reg.Upsert(registry.Route{Host: "team.internal", Kind: "service", Addr: "127.0.0.1:1", Status: "ready", App: "team"}); err != nil {
+ t.Fatal(err)
+ }
+ // Give app.example.com a prefix map: /team/* → team.internal. Use the
+ // same seam the prefix-routing tests use (grep "paths:" in edge_test.go
+ // / claims tests for how a map reaches the live upstream; if only a
+ // Reload from claims does it, set the claim and Reload).
+ setPathMap(t, e, "app.example.com", map[string]string{"team": "team.internal"})
+ e.SetSwitches(map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true, Door: true}, release.OpsAccount + "/team": {Count: true, Door: true}})
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n","path":"/team/page"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n","path":"/other"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(sink.recs) != 3 || sink.recs[0].App != "team" || sink.recs[1].App != "web" || sink.recs[2].App != "web" {
+ t.Fatalf("attribution %+v", sink.recs)
+ }
+}
+```
+`setPathMap` is a test helper this task writes: find how `upstream.paths` is populated (grep `paths:` and `paths =` in `internal/edge/*.go`) and either drive it through the real path (a claim with `Paths`, then `Reload`) or, if the map build takes a source the tests can substitute, use that. Document the choice in the commit.
+
+- [ ] **Step 2: Run** `go test ./internal/edge/ -run Door` — FAIL.
+
+- [ ] **Step 3: Implement** — append to `browserdoor.go`:
+```go
+// serveBeacon is POST /__carlos/e (door 'e') and /__carlos/a (door 'a').
+// Every branch answers 202 with no body (§3.4): the endpoint reveals
+// nothing, and the owner's dropped counters are where refusals show.
+func (e *Edge) serveBeacon(w http.ResponseWriter, r *http.Request, host string, up upstream, door byte) {
+ defer w.WriteHeader(http.StatusAccepted)
+ if up.route.Kind == "status" || up.route.App == "" {
+ return
+ }
+ now := time.Now()
+ drop := func(rt registry.Route, reason string) {
+ if ref := e.analyticsMeter.Load(); ref != nil && e.switchesFor(rt).Count {
+ ref.Drop(release.RouteAccount(rt), rt.App, door, reason, now)
+ }
+ }
+ if !beaconOriginOK(e.current(), r, up.route.Host) {
+ drop(up.route, analytics.DropOrigin)
+ return
+ }
+ body, err := io.ReadAll(io.LimitReader(r.Body, maxBeaconBody+1))
+ if err != nil || len(body) > maxBeaconBody {
+ drop(up.route, analytics.DropSize)
+ return
+ }
+ var path string
+ var eb errorBeacon
+ var ab eventBeacon
+ var ok bool
+ if door == analytics.DoorErrors {
+ eb, ok = parseErrorBeacon(body)
+ path = eb.Path
+ } else {
+ ab, ok = parseEventBeacon(body)
+ path = ab.Path
+ }
+ if !ok {
+ drop(up.route, analytics.DropMalformed)
+ return
+ }
+ tu := up
+ if path != "" {
+ if t, found := e.lookupUpstreamPath(host, path); found {
+ tu = t
+ }
+ }
+ sw := e.switchesFor(tu.route)
+ if !sw.Door || (door == analytics.DoorEvents && !sw.Count) {
+ return
+ }
+ acct, app := release.RouteAccount(tu.route), tu.route.App
+ h := fnv.New64a()
+ h.Write([]byte(clientIP(r)))
+ h.Write([]byte{0})
+ h.Write([]byte(r.Header.Get("User-Agent")))
+ if !e.doorLimit.allow(acct+"/"+app, door, h.Sum64(), now) {
+ drop(tu.route, analytics.DropRate)
+ return
+ }
+ if door == analytics.DoorErrors {
+ ref := e.errSink.Load()
+ if ref == nil {
+ return
+ }
+ rec := apperr.Record{Host: tu.route.Host, Source: apperr.SourceBrowser, Kind: eb.Kind, Name: eb.Name, Stack: eb.Stack, Path: eb.Path, Fields: eb.Fields, FP: eb.FP, Release: tu.route.Version}
+ if rec.FP != "" {
+ rec.FPV = apperr.FPVersion
+ }
+ ref.Add(acct, app, rec)
+ return
+ }
+ if ab.Name == "pageview" {
+ return // edge mode: the HTML response was the pageview (§3.4)
+ }
+ if ref := e.analyticsMeter.Load(); ref != nil {
+ ref.Track(acct, app, ab.Name, now)
+ }
+}
+```
+`reserved.go` `serveReserved`:
+```go
+ if r.Method == http.MethodPost {
+ switch r.URL.Path {
+ case ReservedPrefix + "e":
+ e.serveBeacon(w, r, host, up, analytics.DoorErrors)
+ return
+ case ReservedPrefix + "a":
+ e.serveBeacon(w, r, host, up, analytics.DoorEvents)
+ return
+ }
+ }
+ if serveCarlosJS(w, r) {
+ return
+ }
+ http.NotFound(w, r)
+```
+`edge.go`: `doorLimit *doorLimiter` on `Edge`; `New` sets it; a hand-built `&Edge{}` in tests gets it lazily — make `serveBeacon` tolerate nil by calling `e.doorLimiter()` which lazily stores one under `sync.Once` (field `doorOnce sync.Once`).
+
+- [ ] **Step 4: Run** `go test ./internal/edge/ -race` — PASS, every existing test included.
+- [ ] **Step 5: Commit** `edge: the browser door — origin, size, shape, rate, attribution, then the sink or the meter`.
+
+---
+
+### Task 8: A beacon never wakes or keeps awake
+
+**Files:**
+- Test: `internal/activator/activator_test.go` (append)
+
+**Interfaces:** consumes `edge.New(reg, a)` with the real `*Service` as the `edge.Activator`, `testActivator` (`activator_test.go:350`), `arrangeAwake`, `a.entries[host].lastActive` (the seam `TestServeScheduledDoesNotStampActivity` at `:1370` uses).
+
+- [ ] **Step 1: Write the test**
+```go
+// A beacon to a hibernating route is answered by the edge before the
+// activator branch: lastActive is untouched and nothing is started
+// (spec §4: nothing here wakes an instance).
+func TestBrowserBeaconDoesNotStampActivity(t *testing.T) {
+ const host = "beacon.bab.oncarlos.com"
+ a, b, clk, reg := testActivator(t, time.Hour)
+ rt := testRoute(host)
+ arrangeAwake(t, a, rt)
+ a.mu.Lock()
+ before := a.entries[rt.Host].lastActive
+ a.mu.Unlock()
+ clk.advance(time.Minute)
+ _, _, starts0 := b.counts()
+
+ e, err := edge.New(reg, a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "https://"+host+"/__carlos/a", strings.NewReader(`{"name":"n"}`))
+ req.Host = host
+ req.Header.Set("Origin", "https://"+host)
+ e.ServeHTTP(rec, req)
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("status %d", rec.Code)
+ }
+ a.mu.Lock()
+ after := a.entries[rt.Host].lastActive
+ a.mu.Unlock()
+ if !after.Equal(before) {
+ t.Errorf("lastActive moved %v -> %v", before, after)
+ }
+ if _, _, starts := b.counts(); starts != starts0 {
+ t.Errorf("a beacon started an instance")
+ }
+}
+```
+If `testActivator`'s registry does not hold `rt` (read `arrangeAwake` and `testRoute`), `reg.Upsert(rt)` before `edge.New`. If the route the fixture makes is not `Hibernate: true` in the registry, set it so the edge takes the hibernating branch for ordinary requests — the test's point is that the reserved path never gets there.
+
+- [ ] **Step 2: Run** `go test ./internal/activator/ -run BrowserBeacon` — PASS on first run is expected (Task 2 placed the intercept before the activator); if it FAILS, the intercept is in the wrong place — fix `edge.go`, not the test.
+- [ ] **Step 3: Commit** `activator: a beacon to a hibernating route leaves lastActive alone`.
+
+---
+
+### Task 9: The switch, the tabs, the CLI and the docs say what is now true
+
+**Files:**
+- Modify: `internal/console/templates/app_settings.html:197-207`, `account_settings.html:161-172` (door select)
+- Modify: `internal/console/templates/app_analytics.html` (drop reasons, coverage line, snippet line), `app_errors.html:77` (snippet line replaces "coming in a later step")
+- Modify: `internal/console/analyticsview.go` (`analyticsWindow.Sources []string`, filled in `readAnalytics`)
+- Modify: `internal/console/apianalytics.go` (`sources` in the response), `apimode.go:2905` (mirror), `analytics_cmd.go:102` (breakdown line + sources)
+- Modify: `docs/site/cli.md` (`### carlos analytics`, `### carlos errors`)
+- Test: `internal/console/analyticsview_test.go` (or the nearest existing analytics console test), `analytics_cmd_test.go`
+
+**Interfaces:**
+- Produces: `analyticsWindow.Sources []string` — `["edge"]`, plus `"browser"` when `Sum.Events` is non-empty or any `Sum.Dropped.E`/`.A` counter is non-zero (ruling 7); JSON `"sources":["edge","browser"]` on the API and CLI responses.
+
+- [ ] **Step 1: Failing tests**
+
+Console: find the test that renders the analytics window (grep `readAnalytics\|analyticsWindow` in `internal/console/*_test.go`) and add: a summary with one named event yields `Sources == []string{"edge","browser"}`; an empty summary yields `[]string{"edge"}`. Template tests: the app and account settings pages contain `name="door"`; the analytics page for a window with `Dropped.E.Origin == 3` contains `origin` and `3` in the dropped table; the errors page contains `/__carlos/carlos.js`.
+
+CLI (`analytics_cmd_test.go`): a response with `Dropped.A.Rate = 2` prints a line containing `events: rate 2`; with all zero, no `dropped by reason` line.
+
+- [ ] **Step 2: Run** the console and root package tests named — FAIL.
+
+- [ ] **Step 3: Implement**
+
+Settings forms — add after the count `<select>` in both templates:
+```html
+ <label>Browser door (<code>/__carlos/</code> beacons):
+ <select name="door">
+ <option value="on"{{if eq .AnalyticsSetting.Door "on"}} selected{{end}}>on</option>
+ <option value="off"{{if eq .AnalyticsSetting.Door "off"}} selected{{end}}>off</option>
+ </select>
+ </label>
+```
+and extend the "currently …" sentence with `; door {{.AnalyticsSetting.Door}}, from the {{.AnalyticsSetting.DoorFrom}}`. `analyticsFormValues` already accepts both.
+
+Analytics tab — replace the headline `Dropped` cell's value with the total `{{template "dropTotal" .Sum.Dropped}}` (a small define summing the nine counters is impossible in a template: compute `DroppedTotal int64` on `analyticsWindow` in Go instead and render that) and add under the headline table:
+```html
+{{if .DroppedTotal}}<p class="small">Dropped by reason — errors: rate {{.Sum.Dropped.E.Rate}}, size {{.Sum.Dropped.E.Size}}, origin {{.Sum.Dropped.E.Origin}}, malformed {{.Sum.Dropped.E.Malformed}}; events: rate {{.Sum.Dropped.A.Rate}}, size {{.Sum.Dropped.A.Size}}, origin {{.Sum.Dropped.A.Origin}}, malformed {{.Sum.Dropped.A.Malformed}}; flush {{.Sum.Dropped.Flush}}.</p>{{end}}
+<p class="small">Sources live: {{range $i, $s := .Sources}}{{if $i}}, {{end}}{{$s}}{{end}}.</p>
+```
+and, after the named-events table:
+```html
+<p class="small">Named events and JavaScript errors need one line in the page: <code>&lt;script src="/__carlos/carlos.js" defer&gt;&lt;/script&gt;</code>. Then <code>carlos.track("signup", {plan: "pro"})</code> counts an event here (fields are accepted and not yet shown). Page views are counted at the edge already; the snippet never sends them. What edge counting misses: single-page-app route changes, pages served by a CDN in front of CARLOS, and back/forward-cache restores.</p>
+```
+Errors tab line 77 becomes: `Log lines at <code>{{.ErrWin.LogLevel}}</code> and above and responses of 500 or higher appear here on their own. A JavaScript error needs one line in the page: <code>&lt;script src="/__carlos/carlos.js" defer&gt;&lt;/script&gt;</code> — messages, stacks and fields are redacted for addresses, credentials and long tokens before they leave the page and again on arrival (a floor, not a guarantee). A server-side error needs the hook (next step). The level is set on the Settings tab. …`
+
+`analyticsview.go`: `Sources []string` and `DroppedTotal int64` on `analyticsWindow`; fill in `readAnalytics` after the summary is built:
+```go
+ win.Sources = []string{"edge"}
+ d := win.Sum.Dropped
+ win.DroppedTotal = d.E.Rate + d.E.Size + d.E.Origin + d.E.Malformed + d.A.Rate + d.A.Size + d.A.Origin + d.A.Malformed + d.Flush
+ if len(win.Sum.Events) > 0 || win.DroppedTotal-d.Flush > 0 {
+ win.Sources = append(win.Sources, "browser")
+ }
+```
+API: `Sources []string \`json:"sources"\`` on `analyticsResponse` and its `apimode.go` mirror. CLI: after the headline, when the breakdown is non-zero, print `dropped by reason — errors: rate N, size N, origin N, malformed N; events: …; flush N` muted, and `sources: edge, browser`.
+
+`docs/site/cli.md` — under `### carlos analytics`, after the existing flags, add a paragraph: "Named events come from the page: include `<script src="/__carlos/carlos.js" defer></script>` and call `carlos.track("name", {field: "value"})`. The same snippet reports JavaScript errors to the Errors tab. `--set-door off` closes both beacons for the app; page views are counted at the edge regardless." Under `### carlos errors`, add one sentence naming the `browser` source and the same script tag.
+
+- [ ] **Step 4: Run** `go test ./internal/console/ ./internal/docsite/ . ` — PASS.
+- [ ] **Step 5: Commit** `console, cli, docs: the door switch, drop reasons, the coverage line and the one-line snippet`.
+
+---
+
+### Task 10: Gate, spec status, branch description (controller)
+
+- [ ] `gofmt -l .` clean (excluding `.claude/`); `go vet ./...`; `go test ./...` with `TMPDIR=/tmp/g`; `GOOS=darwin GOARCH=arm64 go build ./...`; `GOOS=windows GOARCH=amd64 go build ./...`.
+- [ ] `grep -rn '__carlos' /home/paulca/github.com/carlosframework/platform-infrastructure` is empty (§8's checklist line; it was on 2026-09-07).
+- [ ] Spec status line: step 3 built on branch `analytics-step3`; note rulings 1–7 above in §8 as "planning rulings, step 3".
+- [ ] Whole-branch review (Opus) against §3.4, §4, §6; fix; re-review.
+- [ ] Push, `amadan ci status`, `amadan branch describe carlos/platform analytics-step3 -body -` with: what lands, the rulings, the review record, deferred minors, and the follow-up for step 4.
+
+---
+
+## Self-review
+
+**Spec coverage.** §3.4 placement (T2), attribution on shared hostname (T7, T2's lookup guard), endpoint shapes and 202 (T7), Origin/Referer/Sec-Fetch-Site (T5), caps (T5, T7), rate and LRU (T3, T7), snippet contract and vendoring, loader/asset caching (T4), `dropped.*` (T6, T7, T9), `door` and `count` switches (T1, T7), §4 no-wake (T2, T8), §3.6 coverage line and settings rendering (T9), §6 "Browser door" list: correct Origin counts (T7), missing Origin + Referer counts (T7), foreign Origin + Referer does not (T7), cross-site does not (T7), 17 KB does not (T7), 601st does not (T7 app bucket), each drop increments the right key (T6, T7), every case 202 (T7), hibernating `lastActive` (T8), path-routed attribution (T7), `__carlos` prefix key refused (T2); "`/__carlos/` reservation": never proxied, loader revalidates, asset immutable (T2, T4); "Settings resolution": `/a` dropped and `/e` accepted when off, `door: off` drops both (T7). Not in this step: server door (step 4), tombstones, `Group.Level` badge, browser-counted mode (§5).
+
+**Placeholders.** None: every step carries its code or the exact grep that finds the seam (T7's `setPathMap`, T8's fixture) with the fallback stated.
+
+**Type consistency.** `telemetry.Switches{Count, Door}` (T1) is what `SetSwitches`, `switchesFor` (T1, T7) and the host pass (T1) use. `AnalyticsMeter` = `Observe/Track/Drop` (T6) matches `memAnalytics` (T6) and `serveBeacon` (T7). `analytics.DoorErrors/DoorEvents/Drop*` (T6) are the names T7 and T9 use. `doorLimiter.allow(app, door, visitor, now)` (T3) is called with `acct+"/"+app` (T7). `serveReserved(w, r, host, up)` (T2) is what T4 and T7 extend; `serveCarlosJS(w, r) bool` (T4); `parseErrorBeacon`/`parseEventBeacon`/`beaconOriginOK` (T5) as used in T7.