pricing: implementation plan for the store egress and durability meters
Plan two of four: spec build steps 4, 5 and 6, plus the box-side durable usage writes that plan one deferred here by name. Both meters come from one mechanism. S3 server access logging goes on the regional data bucket, which carries every litestream replica, and on each tenant store bucket at mint time. One bucket per app means the bucket name is the tenant, and litestream keys are matched against each route's own DBPath rather than parsed apart, the same way unitOwner builds a route's expected unit names instead of guessing. Logging is the only affordable source. Per-bucket CloudWatch metrics cost $0.30 a month against a EUR 24 a year subscription, which would invert the economics the spec publishes; the alternatives table records why each other option loses. Access logs carry object keys and end-user addresses, so they rest in the data bucket, never the deployment bucket whose stated contract is routing metadata only. They expire at 30 days. Only aggregates leave. AWS delivers these logs best-effort with no completeness guarantee, so every task fails toward Unswept rather than a zero, and the plan says plainly that whether such a source may back an invoice line is the billing plan's decision, not this one's. Task 5 also removes a live bug: edge HTTP request counts are priced at an S3 request rate in both the Go and the mirrored JavaScript, and the page even labels the result s3requests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+1107
−0
docs/superpowers/plans/2026-09-10-store-egress-and-durability-meters.md+1107 −0
diff --git a/docs/superpowers/plans/2026-09-10-store-egress-and-durability-meters.md b/docs/superpowers/plans/2026-09-10-store-egress-and-durability-meters.md| new file mode 100644 |
| index 000000000..c51e0f2a7 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-10-store-egress-and-durability-meters.md |
| @@ -0,0 +1,1107 @@ |
| +# Store egress and durability meters — 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:** Meter the two costs the platform currently charges for but cannot see — bytes an app serves straight from its own object-store bucket, and the S3 writes its database replication makes — and make the cost model consume them instead of pricing edge HTTP requests at an S3 rate. |
| + |
| +**Architecture:** Both meters come from one mechanism. S3 server access logging is switched on for the regional data bucket (which carries every litestream replica, keyed by database path) and for each tenant store bucket at mint time (one bucket per app, so the bucket name *is* the tenant). A console-side pass parses the previous day's logs into per-tenant sums, exactly as `storagePass` already builds a per-day storage object, and carries the same "unswept, never zero" honesty flag. Nothing runs on a box, because the bytes never cross one. |
| + |
| +**Tech Stack:** Go, AWS SDK v2 (`s3.PutBucketLogging`), Terraform for the data bucket. No new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md` — build steps 4, 5 and 6, plus the box-side durable-usage-write half of step 3 that `2026-09-08-invoice-grade-statements.md` deferred here by name. |
| + |
| +**Depends on:** `docs/superpowers/plans/2026-09-08-invoice-grade-statements.md` (plan one). Task 3 and Task 4 register new sources with `usage.MeterSource`, which that plan introduces. Do not start this plan until it has landed. |
| + |
| +## Global Constraints |
| + |
| +- **Access logs are customer-shaped data.** They record object keys and end-user IP addresses. `infra/modules/deployment-buckets/main.tf:8-10` states the deployment bucket holds "routing metadata only — nothing customer-shaped rests here", so **logs go to the regional data bucket**, which is the residency promise, and never to the deployment bucket. The parsed daily totals — bytes and counts, no keys, no addresses — are what reaches the deployment bucket. |
| +- **Raw logs are deleted after parsing.** A lifecycle rule expires them at 30 days. We keep the aggregate, not the trail. No console view ever renders a raw log line. |
| +- **Server access logging is best-effort.** AWS delivers "on a best-effort basis" with no completeness guarantee and hours of latency. So a day with logs present is *covered*, never automatically *complete* — see Task 3's `Unswept` handling. Whether that is good enough to bill from is the billing plan's decision, and this plan deliberately does not make it. |
| +- **Wire shapes are additive.** New day objects are new keys; new fields carry `omitempty`. Never remove or retype an existing field. (AGENTS.md, "Rules this repo has paid for".) |
| +- **The Go and JS cost arithmetic are one definition in two files.** `internal/economics/compute.go:93-96` says so explicitly: `PriceAccount` is mirrored by `priceAccount` in `internal/console/templates/economics.html` (`econ_maths_js`, :545). Task 5 changes both in one commit or the page disagrees with the server that served it. |
| +- **Build gate before any commit lands:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean tree-wide. `make test` covers the last three. |
| +- **Sandbox note (AGENTS.md:167):** `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go may need the sandbox disabled, "read-only file system" from the toolchain is the tell. |
| +- **Cross-compile gate.** `20-cross@5` builds darwin/windows/linux-arm64. Task 6 touches `internal/host`, which links into the CLI people run on Macs; any Linux-only assumption breaks their build. |
| +- **CI is the `amadan` remote.** A branch pushed only to `origin` has been gated by nothing. |
| + |
| +## Why not the alternatives |
| + |
| +Recorded so nobody re-litigates it mid-implementation. |
| + |
| +| Option | Why not | |
| +| --- | --- | |
| +| CloudWatch S3 request metrics (`BytesDownloaded` per bucket) | $0.30 per bucket per month. One bucket per app, and Personal is €24/**year** — a single bucket would eat 15% of the subscription. It would invert the unit economics the spec publishes. | |
| +| CloudTrail data events | $0.10 per 100k events, near-real-time and far more complete. The right upgrade if best-effort logging ever blocks billing, but not the place to start. | |
| +| Cost Explorer by usage type | `cepull.go:91-101` groups by SERVICE; CE has no bucket dimension. Deployment-wide totals only, never per tenant. | |
| +| Proxy store traffic through the edge | Defeats the entire point of handing apps direct credentials, and `internal/objstore/objstore.go:1-5` states the package "never touches the data plane" as a design property. | |
| +| Model durability from replication class × uptime | An upper bound, not a measurement. Fine for a dashboard estimate, wrong for an invoice line the customer can dispute. | |
| + |
| +--- |
| + |
| +### Task 1: Parse an S3 server access log line |
| + |
| +Everything downstream consumes this, and it is a pure function, so it goes first. The format is space-separated with bracketed timestamps and quoted strings, and any field may be `-`. |
| + |
| +**Files:** |
| +- Create: `internal/usage/accesslog.go` |
| +- Test: `internal/usage/accesslog_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- Produces: |
| + - `usage.AccessLogRecord{Bucket, Operation, Key string; BytesSent int64; Status int}` |
| + - `func usage.ParseAccessLogLine(line string) (AccessLogRecord, bool)` — `false` for a line too short or malformed to trust. |
| + - `func usage.ParseAccessLog(r io.Reader, fn func(AccessLogRecord)) (parsed, skipped int, err error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Create `internal/usage/accesslog_test.go`: |
| + |
| +```go |
| +package usage |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// A real GET line, fields per the S3 server access log format. Bytes sent is |
| +// field 13 (1-indexed), operation is field 8, and the bracketed time plus the |
| +// quoted request URI both contain spaces. |
| +const getLine = `79a5 my-bucket [06/Feb/2026:00:00:38 +0000] 192.0.2.3 79a5 3E57427F33A59F07 REST.GET.OBJECT photo.jpg "GET /photo.jpg HTTP/1.1" 200 - 3462992 3462992 73 72 "-" "S3Console/0.4" - abc SigV4 ECDHE-RSA-AES128-GCM-SHA256 AuthHeader s3.eu-west-1.amazonaws.com TLSv1.2 - -` |
| + |
| +func TestParseAccessLogLineGet(t *testing.T) { |
| + rec, ok := ParseAccessLogLine(getLine) |
| + if !ok { |
| + t.Fatal("a well-formed GET line must parse") |
| + } |
| + if rec.Bucket != "my-bucket" { |
| + t.Errorf("bucket = %q, want my-bucket", rec.Bucket) |
| + } |
| + if rec.Operation != "REST.GET.OBJECT" { |
| + t.Errorf("operation = %q", rec.Operation) |
| + } |
| + if rec.Key != "photo.jpg" { |
| + t.Errorf("key = %q", rec.Key) |
| + } |
| + if rec.BytesSent != 3462992 { |
| + t.Errorf("bytes_sent = %d, want 3462992", rec.BytesSent) |
| + } |
| + if rec.Status != 200 { |
| + t.Errorf("status = %d, want 200", rec.Status) |
| + } |
| +} |
| + |
| +// A PUT sends no bytes back: bytes_sent is "-", which must read as 0 and |
| +// must not fail the line — we still need the operation to count writes. |
| +func TestParseAccessLogLineDashBytes(t *testing.T) { |
| + line := strings.Replace(getLine, "REST.GET.OBJECT", "REST.PUT.OBJECT", 1) |
| + line = strings.Replace(line, "200 - 3462992 3462992", "200 - - 3462992", 1) |
| + rec, ok := ParseAccessLogLine(line) |
| + if !ok { |
| + t.Fatal("a PUT line must parse") |
| + } |
| + if rec.BytesSent != 0 { |
| + t.Errorf("bytes_sent = %d, want 0 for %q", rec.BytesSent, "-") |
| + } |
| + if rec.Operation != "REST.PUT.OBJECT" { |
| + t.Errorf("operation = %q", rec.Operation) |
| + } |
| +} |
| + |
| +func TestParseAccessLogLineRejectsJunk(t *testing.T) { |
| + for _, line := range []string{"", " ", "too few fields here", `79a5 b [06/Feb/2026:00:00:38 +0000] ip req id`} { |
| + if _, ok := ParseAccessLogLine(line); ok { |
| + t.Errorf("ParseAccessLogLine(%q) = ok, want rejected", line) |
| + } |
| + } |
| +} |
| + |
| +// An unterminated quote must not swallow the rest of the file. |
| +func TestParseAccessLogUnterminatedQuote(t *testing.T) { |
| + bad := `79a5 my-bucket [06/Feb/2026:00:00:38 +0000] 192.0.2.3 79a5 REQ REST.GET.OBJECT k "GET /k HTTP/1.1 200 - 1 1 1 1 "-" "ua" - - - - - - - - -` |
| + if _, ok := ParseAccessLogLine(bad); ok { |
| + t.Error("an unterminated quote must be rejected, not parsed") |
| + } |
| +} |
| + |
| +func TestParseAccessLogCountsAndSkips(t *testing.T) { |
| + in := getLine + "\n\njunk line\n" + getLine + "\n" |
| + var total int64 |
| + parsed, skipped, err := ParseAccessLog(strings.NewReader(in), func(r AccessLogRecord) { |
| + total += r.BytesSent |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if parsed != 2 { |
| + t.Errorf("parsed = %d, want 2", parsed) |
| + } |
| + if skipped != 1 { |
| + t.Errorf("skipped = %d, want 1 (blank lines are not skips)", skipped) |
| + } |
| + if total != 2*3462992 { |
| + t.Errorf("total bytes = %d", total) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -run TestParseAccessLog -count=1 -v` |
| + |
| +Expected: FAIL to compile — `undefined: ParseAccessLogLine`. |
| + |
| +- [ ] **Step 3: Write the parser** |
| + |
| +Create `internal/usage/accesslog.go`: |
| + |
| +```go |
| +package usage |
| + |
| +import ( |
| + "bufio" |
| + "io" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// AccessLogRecord is the handful of fields billing needs from one S3 server |
| +// access log line. Everything else in the format — requester, IP, user |
| +// agent, TLS details — is deliberately dropped on the floor at parse time: |
| +// the raw logs are customer-shaped, and the only thing that outlives this |
| +// function is a byte count. |
| +type AccessLogRecord struct { |
| + Bucket string |
| + Operation string // e.g. REST.GET.OBJECT, REST.PUT.OBJECT |
| + Key string |
| + BytesSent int64 |
| + Status int |
| +} |
| + |
| +// Field positions in the S3 server access log format, 0-indexed after |
| +// tokenisation (the bracketed time counts as ONE token, each quoted string |
| +// as one token). |
| +const ( |
| + fieldBucket = 1 |
| + fieldOperation = 7 |
| + fieldKey = 8 |
| + fieldStatus = 10 |
| + fieldBytesSent = 12 |
| + minFields = 13 // through bytes_sent; later fields have grown over time |
| +) |
| + |
| +// splitAccessLog tokenises a line: whitespace-separated, except that |
| +// [bracketed] and "quoted" runs are single tokens. Returns false on an |
| +// unterminated bracket or quote rather than swallowing the remainder — a |
| +// truncated line must be skipped, never half-read into a byte count. |
| +func splitAccessLog(line string) ([]string, bool) { |
| + var out []string |
| + for i := 0; i < len(line); { |
| + switch line[i] { |
| + case ' ', '\t': |
| + i++ |
| + case '[', '"': |
| + closer := byte(']') |
| + if line[i] == '"' { |
| + closer = '"' |
| + } |
| + j := strings.IndexByte(line[i+1:], closer) |
| + if j < 0 { |
| + return nil, false |
| + } |
| + out = append(out, line[i+1:i+1+j]) |
| + i += j + 2 |
| + default: |
| + j := strings.IndexAny(line[i:], " \t") |
| + if j < 0 { |
| + out = append(out, line[i:]) |
| + i = len(line) |
| + continue |
| + } |
| + out = append(out, line[i:i+j]) |
| + i += j |
| + } |
| + } |
| + return out, true |
| +} |
| + |
| +// dashInt reads a numeric field, treating the format's "-" (not applicable) |
| +// as zero. A field that is neither is a malformed line. |
| +func dashInt(s string) (int64, bool) { |
| + if s == "-" { |
| + return 0, true |
| + } |
| + n, err := strconv.ParseInt(s, 10, 64) |
| + if err != nil { |
| + return 0, false |
| + } |
| + return n, true |
| +} |
| + |
| +// ParseAccessLogLine reads one line, reporting false for anything it cannot |
| +// trust. Callers count the rejects: a day whose log files are largely |
| +// unparseable is a day we must not claim to have measured. |
| +func ParseAccessLogLine(line string) (AccessLogRecord, bool) { |
| + f, ok := splitAccessLog(line) |
| + if !ok || len(f) < minFields { |
| + return AccessLogRecord{}, false |
| + } |
| + bytesSent, ok := dashInt(f[fieldBytesSent]) |
| + if !ok { |
| + return AccessLogRecord{}, false |
| + } |
| + status, ok := dashInt(f[fieldStatus]) |
| + if !ok { |
| + return AccessLogRecord{}, false |
| + } |
| + return AccessLogRecord{ |
| + Bucket: f[fieldBucket], |
| + Operation: f[fieldOperation], |
| + Key: f[fieldKey], |
| + BytesSent: bytesSent, |
| + Status: int(status), |
| + }, true |
| +} |
| + |
| +// ParseAccessLog streams r, calling fn for every line that parses. Blank |
| +// lines are neither parsed nor skipped. Returns the two counts so a caller |
| +// can judge whether the day is trustworthy. |
| +func ParseAccessLog(r io.Reader, fn func(AccessLogRecord)) (parsed, skipped int, err error) { |
| + sc := bufio.NewScanner(r) |
| + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| + for sc.Scan() { |
| + line := strings.TrimSpace(sc.Text()) |
| + if line == "" { |
| + continue |
| + } |
| + rec, ok := ParseAccessLogLine(line) |
| + if !ok { |
| + skipped++ |
| + continue |
| + } |
| + parsed++ |
| + fn(rec) |
| + } |
| + return parsed, skipped, sc.Err() |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -run TestParseAccessLog -count=1 -v` |
| + |
| +Expected: PASS, all five. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/usage/accesslog.go internal/usage/accesslog_test.go |
| +git commit -m "usage: parse S3 server access log lines |
| + |
| +The two costs the platform charges for but cannot see — bytes served |
| +straight from a tenant's own bucket, and replication writes — are both |
| +visible in S3 server access logs and nowhere else affordable. This is |
| +the parser both meters will read. |
| + |
| +It keeps five fields and drops the rest at parse time: the raw logs |
| +carry object keys and end-user addresses, and the only thing that |
| +should outlive parsing is a byte count. An unterminated quote is a |
| +skipped line, never a half-read one." |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Turn logging on for tenant buckets |
| + |
| +Tenant buckets are minted at runtime by `internal/objstore/aws.go`, not by Terraform (`walk.go:60-64`, `MintedBucket = prefix + acct + "-" + app`), so logging is configured where the bucket is created. One bucket per app means the bucket name identifies the tenant with no parsing. |
| + |
| +**Files:** |
| +- Modify: `internal/objstore/provisioner.go` (the `Provisioner` interface) |
| +- Modify: `internal/objstore/aws.go` (implement), `internal/objstore/memprovisioner.go` (fake) |
| +- Modify: `internal/objstore/walk.go` (call it from the grant walk) |
| +- Test: `internal/objstore/walk_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from Task 1. |
| +- Produces: `Provisioner.EnsureBucketLogging(ctx context.Context, name, targetBucket, targetPrefix string) error` — idempotent, like every other Ensure verb in this interface. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/objstore/walk_test.go`, following the file's existing `memProvisioner` setup: |
| + |
| +```go |
| +// A minted bucket must be logging before any app can write to it: logging |
| +// turned on later cannot recover the bytes served in between. |
| +func TestGrantEnablesAccessLogging(t *testing.T) { |
| + p := newMemProvisioner() |
| + // ... build the same Deployment/store fixture the neighbouring grant |
| + // tests use, with LogBucket set on the Deployment. |
| + if err := Grant(ctx, s, sealPub, p, d, "acme", "app"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, ok := p.logging[MintedBucket(d, "acme", "app")] |
| + if !ok { |
| + t.Fatal("grant did not enable access logging on the minted bucket") |
| + } |
| + if got.target != d.LogBucket { |
| + t.Errorf("log target = %q, want %q", got.target, d.LogBucket) |
| + } |
| + if want := "store/acme/app/"; got.prefix != want { |
| + t.Errorf("log prefix = %q, want %q", got.prefix, want) |
| + } |
| +} |
| +``` |
| + |
| +Read the neighbouring tests first and mirror their fixture construction exactly: |
| + |
| +Run: `grep -n 'func TestGrant' -A 20 internal/objstore/walk_test.go | head -60` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/objstore/ -run TestGrantEnablesAccessLogging -count=1 -v` |
| + |
| +Expected: FAIL to compile — no `logging` field on the fake, no `LogBucket` on `Deployment`. |
| + |
| +- [ ] **Step 3: Extend the seam** |
| + |
| +In `internal/objstore/provisioner.go`, add to the `Provisioner` interface, after `EnsureBucket`: |
| + |
| +```go |
| + // EnsureBucketLogging points the bucket's server access logs at |
| + // targetBucket under targetPrefix. Idempotent: re-applying the same |
| + // configuration is a no-op, and it is called on every grant walk so a |
| + // bucket minted before logging existed picks it up on the next repair. |
| + // targetBucket == "" disables the call entirely, for deployments that |
| + // have not provisioned a log bucket. |
| + EnsureBucketLogging(ctx context.Context, name, targetBucket, targetPrefix string) error |
| +``` |
| + |
| +Add a `LogBucket string` field to `Deployment` in `internal/objstore/walk.go` beside `BucketPrefix`, documented as "the regional data bucket; empty disables access logging". |
| + |
| +- [ ] **Step 4: Implement it on both provisioners** |
| + |
| +In `internal/objstore/aws.go`, beside `EnsureBucket`: |
| + |
| +```go |
| +func (p *AWSProvisioner) EnsureBucketLogging(ctx context.Context, name, targetBucket, targetPrefix string) error { |
| + if targetBucket == "" { |
| + return nil |
| + } |
| + cl, err := p.s3(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = cl.PutBucketLogging(ctx, &s3.PutBucketLoggingInput{ |
| + Bucket: aws.String(name), |
| + BucketLoggingStatus: &s3types.BucketLoggingStatus{ |
| + LoggingEnabled: &s3types.LoggingEnabled{ |
| + TargetBucket: aws.String(targetBucket), |
| + TargetPrefix: aws.String(targetPrefix), |
| + }, |
| + }, |
| + }) |
| + return err |
| +} |
| +``` |
| + |
| +Match the existing client accessor and type import aliases in that file rather than the names above — check them first: |
| + |
| +Run: `grep -n 'func (p \*AWSProvisioner) s3\|s3types\|"github.com/aws/aws-sdk-go-v2/service/s3' internal/objstore/aws.go | head` |
| + |
| +In `internal/objstore/memprovisioner.go`, record the call so tests can assert on it: |
| + |
| +```go |
| +type memLogging struct{ target, prefix string } |
| +``` |
| + |
| +with a `logging map[string]memLogging` field on the fake, initialised in its constructor, and: |
| + |
| +```go |
| +func (p *memProvisioner) EnsureBucketLogging(ctx context.Context, name, targetBucket, targetPrefix string) error { |
| + if targetBucket == "" { |
| + return nil |
| + } |
| + p.logging[name] = memLogging{target: targetBucket, prefix: targetPrefix} |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Call it from the grant walk** |
| + |
| +In `internal/objstore/walk.go`, immediately after the `EnsureBucket` step in `Grant`, add: |
| + |
| +```go |
| + // Logging goes on before any credential is delivered: bytes served |
| + // between mint and logging are invisible forever, and this walk is |
| + // idempotent precisely so a bucket minted before logging existed picks |
| + // it up on the next repair pass. |
| + if err := p.EnsureBucketLogging(ctx, bucket, d.LogBucket, StoreLogPrefix(acct, app)); err != nil { |
| + return fmt.Errorf("objstore: enable logging on %s: %w", bucket, err) |
| + } |
| +``` |
| + |
| +and add the prefix helper beside `MintedBucket`: |
| + |
| +```go |
| +// StoreLogPrefix is where a tenant bucket's access logs land in the data |
| +// bucket. One prefix per app, so the day pass reads exactly one tenant's |
| +// logs without parsing bucket names back apart. |
| +func StoreLogPrefix(acct, app string) string { |
| + return "storelogs/" + acct + "/" + app + "/" |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/objstore/ -count=1` |
| + |
| +Expected: PASS, including every existing walk and contract test. Any other implementation of `Provisioner` in the tree must gain the method — find them first: |
| + |
| +Run: `grep -rn 'EnsureBucket(ctx context.Context' --include=*.go .` |
| + |
| +- [ ] **Step 7: Whole gate, then commit** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +```bash |
| +git add internal/objstore/ |
| +git commit -m "objstore: enable access logging on tenant buckets at mint |
| + |
| +Apps hold direct bucket credentials, so bytes they serve never cross a |
| +box and no meter sees them. Server access logging is the only source |
| +that does not invert the unit economics: per-bucket CloudWatch metrics |
| +cost \$0.30 a month against a €24 a year subscription. |
| + |
| +Logging goes on inside the grant walk, before any credential is |
| +delivered, because bytes served between mint and logging are invisible |
| +forever. The walk is idempotent, so buckets minted before this picks it |
| +up on the next repair." |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: The `store_egress_gb` meter |
| + |
| +A console-side pass on `storagePass`'s shape (`internal/console/economicspass.go:136-195`): create-only per day, one object per day, an explicit unswept flag rather than a zero. |
| + |
| +**Files:** |
| +- Modify: `internal/usage/usage.go` (meter constant, and `MeterSource` from plan one) |
| +- Create: `internal/usage/storeegress.go` |
| +- Modify: `internal/console/economicspass.go` (new pass, wired into `economicsPass`) |
| +- Test: `internal/usage/storeegress_test.go`, `internal/console/economicspass_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `usage.ParseAccessLog` (Task 1), `usage.SourceStoreEgress` naming convention from plan one's `MeterSource`. |
| +- Produces: |
| + - `usage.MeterStoreEgressGB = "store_egress_gb"` |
| + - `usage.StoreEgressDay{V, Date, Apps map[string]AppEgress}` with `AppEgress{BytesOut int64; Requests int64; Unswept bool}` |
| + - `func usage.StoreEgressDayKey(date string) string` |
| + - `func (s *Server) storeEgressPass(ctx context.Context, date string) error` |
| + |
| +- [ ] **Step 1: Write the failing test for the day shape** |
| + |
| +Create `internal/usage/storeegress_test.go`: |
| + |
| +```go |
| +package usage |
| + |
| +import "testing" |
| + |
| +func TestStoreEgressDayKey(t *testing.T) { |
| + if got, want := StoreEgressDayKey("2026-08-01"), "usage/storeegress/2026-08-01.json"; got != want { |
| + t.Errorf("StoreEgressDayKey = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestStoreEgressMeterHasASource(t *testing.T) { |
| + if MeterSource(MeterStoreEgressGB) != SourceStoreEgress { |
| + t.Errorf("store_egress_gb must map to its own source, not %q", MeterSource(MeterStoreEgressGB)) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -run TestStoreEgress -count=1 -v` |
| + |
| +Expected: FAIL to compile — undefined identifiers. |
| + |
| +- [ ] **Step 3: Add the meter, source and day shape** |
| + |
| +In `internal/usage/usage.go`, add to the meter const block: |
| + |
| +```go |
| + // MeterStoreEgressGB is bytes an app served from its OWN object-store |
| + // bucket, which never cross the edge and so never appear in |
| + // MeterEgressGB. Shares the plan's egress allowance; priced at the same |
| + // rate. Sourced from S3 server access logs, which AWS delivers |
| + // best-effort — see SourceStoreEgress. |
| + MeterStoreEgressGB = "store_egress_gb" |
| +``` |
| + |
| +and to the source const block from plan one: |
| + |
| +```go |
| + // SourceStoreEgress is its own source, not SourceStorage: the storage |
| + // sweep lists object sizes and the egress pass parses access logs, and |
| + // one succeeding says nothing about the other. AWS delivers these logs |
| + // best-effort with no completeness guarantee, so a day present here is |
| + // covered, never automatically complete. |
| + SourceStoreEgress = "storeegress" |
| +``` |
| + |
| +Add `case MeterStoreEgressGB: return SourceStoreEgress` to `MeterSource`, and add the meter to `TestMeterSource`'s table in `passes_test.go`. |
| + |
| +Create `internal/usage/storeegress.go`: |
| + |
| +```go |
| +package usage |
| + |
| +// AppEgress is one app's object-store transfer on one day, parsed from that |
| +// bucket's server access logs. Unswept marks a day whose logs could not be |
| +// read or were largely unparseable — the dashboard must show "unswept", |
| +// never 0, exactly as AppStorage.StoreUnswept does for occupancy. |
| +type AppEgress struct { |
| + BytesOut int64 `json:"bytes_out"` |
| + Requests int64 `json:"requests,omitempty"` |
| + Unswept bool `json:"unswept,omitempty"` |
| +} |
| + |
| +// StoreEgressDay is usage/storeegress/<date>.json, keyed "<acct>/<app>". |
| +type StoreEgressDay struct { |
| + V int `json:"v"` // 1 |
| + Date string `json:"date"` |
| + Apps map[string]AppEgress `json:"apps"` |
| +} |
| + |
| +// StoreEgressDayKey is the bucket key for a day's store-egress sweep. |
| +func StoreEgressDayKey(date string) string { return "usage/storeegress/" + date + ".json" } |
| + |
| +// StoreEgressPrefix is the List prefix for store-egress days. |
| +const StoreEgressPrefix = "usage/storeegress/" |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -count=1` |
| + |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Write the failing pass test** |
| + |
| +In `internal/console/economicspass_test.go`: |
| + |
| +```go |
| +// Logs that cannot be read must produce Unswept, never a zero that reads as |
| +// "this app served nothing". |
| +func TestStoreEgressPassMarksUnreadableUnswept(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + fx.srv.egressLogs = func(context.Context, string, string) ([]byte, error) { |
| + return nil, errors.New("endpoint down") |
| + } |
| + putStoreJSON(t, fx.rel, objstore.Key(fixtureAcct, fixtureApp), objstore.Record{ |
| + Provisioned: &objstore.Provisioned{Bucket: "b", Region: "eu-west-1"}, |
| + }) |
| + |
| + if err := fx.srv.storeEgressPass(ctx, "2026-08-01"); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StoreEgressDayKey("2026-08-01")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var day usage.StoreEgressDay |
| + if err := json.Unmarshal(raw, &day); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := day.Apps[fixtureAcct+"/"+fixtureApp] |
| + if !got.Unswept { |
| + t.Error("unreadable logs must mark the app unswept") |
| + } |
| + if got.BytesOut != 0 { |
| + t.Errorf("BytesOut = %d, want 0 alongside Unswept", got.BytesOut) |
| + } |
| +} |
| +``` |
| + |
| +Confirm `objstore.Key` and `objstore.Record`/`Provisioned` field names before writing this: |
| + |
| +Run: `grep -n 'func Key\|type Record struct' -A 12 internal/objstore/objstore.go` |
| + |
| +- [ ] **Step 6: Implement the pass** |
| + |
| +In `internal/console/economicspass.go`, add a `egressLogs func(ctx context.Context, bucket, prefix string) ([]byte, error)` field to `Server` (the test seam, mirroring `storeSizer`), and: |
| + |
| +```go |
| +// storeEgressPass parses the previous day's tenant access logs into one |
| +// create-only day object. Shape follows storagePass deliberately: same |
| +// create-only guard, same walk over apps/, same "unswept, never zero" |
| +// convention — a store whose logs we could not read is not a store that |
| +// served nothing. |
| +func (s *Server) storeEgressPass(ctx context.Context, date string) error { |
| + key := usage.StoreEgressDayKey(date) |
| + if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil { |
| + return nil // already written by us or a sibling console |
| + } |
| + acctPrefixes, err := s.cfg.Releases.ListDirs(ctx, "apps/") |
| + if err != nil { |
| + return err |
| + } |
| + day := usage.StoreEgressDay{V: 1, Date: date, Apps: map[string]usage.AppEgress{}} |
| + for _, acctPrefix := range acctPrefixes { |
| + acct := strings.TrimSuffix(strings.TrimPrefix(acctPrefix, "apps/"), "/") |
| + appPrefixes, err := s.cfg.Releases.ListDirs(ctx, acctPrefix) |
| + if err != nil { |
| + return err |
| + } |
| + for _, appPrefix := range appPrefixes { |
| + app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, acctPrefix), "/") |
| + rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct, app) |
| + if gerr != nil { |
| + log.Printf("console: store egress pass: record %s/%s: %v", acct, app, gerr) |
| + day.Apps[acct+"/"+app] = usage.AppEgress{Unswept: true} |
| + continue |
| + } |
| + if rec == nil || rec.Provisioned == nil { |
| + continue // no store, no egress, nothing to say |
| + } |
| + day.Apps[acct+"/"+app] = s.egressForApp(ctx, acct, app, date) |
| + } |
| + } |
| + _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day) |
| + return err |
| +} |
| + |
| +// egressForApp sums one app's GET traffic for a day. A read failure, or a |
| +// day whose lines are more than 1% unparseable, is Unswept rather than a |
| +// number we would have to defend on an invoice. |
| +func (s *Server) egressForApp(ctx context.Context, acct, app, date string) usage.AppEgress { |
| + raw, err := s.egressLogs(ctx, s.cfg.LogBucket, objstore.StoreLogPrefix(acct, app)+date) |
| + if err != nil { |
| + log.Printf("console: store egress pass: logs %s/%s %s: %v", acct, app, date, err) |
| + return usage.AppEgress{Unswept: true} |
| + } |
| + var out usage.AppEgress |
| + parsed, skipped, perr := usage.ParseAccessLog(bytes.NewReader(raw), func(r usage.AccessLogRecord) { |
| + if strings.HasPrefix(r.Operation, "REST.GET") || strings.HasPrefix(r.Operation, "REST.HEAD") { |
| + out.BytesOut += r.BytesSent |
| + out.Requests++ |
| + } |
| + }) |
| + if perr != nil || (parsed+skipped > 0 && skipped*100 > parsed+skipped) { |
| + log.Printf("console: store egress pass: %s/%s %s: %d parsed, %d skipped: %v", |
| + acct, app, date, parsed, skipped, perr) |
| + return usage.AppEgress{Unswept: true} |
| + } |
| + return out |
| +} |
| +``` |
| + |
| +Wire it into `economicsPass` beside `storagePass` (`:59-73`), keeping the "each stage degrades independently" rule — log the error, never return it. Add `LogBucket string` to the server config, read in `cmd/console/main.go` `serve()` from `CARLOS_LOG_BUCKET`, defaulting to empty (which disables the pass with one log line). |
| + |
| +- [ ] **Step 7: Fold the meter into statements** |
| + |
| +In `foldStorageAndCensus` (`:466`), or a sibling fold beside it, read `usage.StoreEgressDayKey(date)` per day and add `BytesOut/1e9` into `MeterStoreEgressGB` for each app's account, skipping any app marked `Unswept`. Mirror the existing storage fold exactly, including its per-day denominator convention documented at `:479-490`. |
| + |
| +Register the source day count so plan one's `MeterComplete` works: in `sweepUsage`'s day loop, add a `storeEgressOK` flag set when the day object decodes and no app in it is `Unswept`, and accumulate `sweep.SourceDays[usage.SourceStoreEgress]`. |
| + |
| +- [ ] **Step 8: Run the tests, then the gate** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ ./internal/usage/ -count=1` |
| + |
| +Then: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +- [ ] **Step 9: Commit** |
| + |
| +```bash |
| +git add internal/usage/ internal/console/ cmd/console/ |
| +git commit -m "usage: meter object-store egress |
| + |
| +Bytes an app serves from its own bucket never cross the edge, so the |
| +egress meter has never seen them. The pass parses that bucket's access |
| +logs into a create-only day object, on storagePass's shape and with its |
| +honesty convention: logs we could not read, or a day more than 1% |
| +unparseable, mark the app unswept rather than reporting zero bytes. |
| + |
| +The source is its own, not storage: listing object sizes succeeding |
| +says nothing about whether the log parse did." |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: The `durability_writes_1k` meter |
| + |
| +The regional data bucket carries every litestream replica, and the key is the database's full path (`internal/ops/litestream.go:170-172`, `replicaKey` trims the leading slash and keeps the rest precisely so two tenants' `registry.db` never collide). So one logging configuration on one bucket covers every tenant, and attribution is a path-to-route match — the problem `unitOwner` already solves for systemd units. |
| + |
| +**Files:** |
| +- Modify: `infra/modules/deployment-buckets/main.tf` (logging + lifecycle on the data bucket) |
| +- Modify: `internal/usage/usage.go`, `internal/usage/storeegress.go` (day shape reuse) |
| +- Modify: `internal/console/economicspass.go` |
| +- Test: `internal/console/economicspass_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `usage.ParseAccessLog` (Task 1). |
| +- Produces: `usage.MeterDurabilityWrites1k = "durability_writes_1k"`, `usage.SourceDurability`, `func (s *Server) durabilityPass(ctx context.Context, date string) error`. |
| + |
| +- [ ] **Step 1: Add the Terraform** |
| + |
| +In `infra/modules/deployment-buckets/main.tf`, after the encryption resources: |
| + |
| +```hcl |
| +# Access logs for the data bucket: the only affordable source for |
| +# per-tenant replication writes and object-store egress. They are |
| +# customer-shaped (object keys, client addresses), so they rest in the DATA |
| +# bucket — the residency promise — never the deployment bucket, and they |
| +# expire once the daily passes have aggregated them. |
| +resource "aws_s3_bucket_logging" "data" { |
| + bucket = aws_s3_bucket.data.id |
| + target_bucket = aws_s3_bucket.data.id |
| + target_prefix = "accesslogs/data/" |
| +} |
| + |
| +resource "aws_s3_bucket_lifecycle_configuration" "data_logs" { |
| + bucket = aws_s3_bucket.data.id |
| + rule { |
| + id = "expire-access-logs" |
| + status = "Enabled" |
| + filter { prefix = "accesslogs/" } |
| + expiration { days = 30 } |
| + } |
| + rule { |
| + id = "expire-store-logs" |
| + status = "Enabled" |
| + filter { prefix = "storelogs/" } |
| + expiration { days = 30 } |
| + } |
| +} |
| +``` |
| + |
| +**This is a manual apply, not part of the Go gate.** Record in the PR description that the module must be applied before the pass produces anything, and that until it is applied every day is `Unswept` — which is the correct reading, not a silent zero. |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +In `internal/console/economicspass_test.go`: |
| + |
| +```go |
| +// A replica PUT is attributed to the route whose data dir the key names. |
| +func TestDurabilityPassAttributesByPath(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "a.example.com", Kind: "instance", Status: "ready", |
| + App: fixtureApp, Account: "acme", Version: "v1", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + |
| + // Two PUTs under this route's data dir, one GET that must not count. |
| + fx.srv.egressLogs = func(context.Context, string, string) ([]byte, error) { |
| + line := func(op, key string) string { |
| + return `79a5 data-bucket [06/Feb/2026:00:00:38 +0000] 192.0.2.3 79a5 REQ ` + |
| + op + ` ` + key + ` "PUT /k HTTP/1.1" 200 - - 100 1 1 "-" "ua" - - - - - - - -` |
| + } |
| + k := "data/acme/" + fixtureApp + "/app.db/generations/x/wal/000001.wal.lz4" |
| + return []byte(line("REST.PUT.OBJECT", k) + "\n" + |
| + line("REST.PUT.OBJECT", k) + "\n" + |
| + line("REST.GET.OBJECT", k) + "\n"), nil |
| + } |
| + |
| + if err := fx.srv.durabilityPass(ctx, "2026-08-01"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // assert two writes land against acme, zero against ops |
| +} |
| +``` |
| + |
| +**The replica key does not contain the account or the app, and it does not need to.** A tenant's database path comes from its route's `DBPath` column, and unit tenants' paths "typically live entirely outside dataDir, at their StateDirectory path" (`internal/ops/litestream.go:390-392`). So parsing a key apart would be guesswork. |
| + |
| +Instead, match each route's **own** expected replica key, exactly as `unitOwner` builds each route's own expected unit names rather than parsing a unit string back into a host (`economicspass.go:196-210`). The route knows its `DBPath`; `replicaKey` is a pure function of it; the log key either starts with that or it does not. |
| + |
| +Replace the guessed key in the test above with one built the same way the production code will build it: |
| + |
| +```go |
| + k := strings.TrimPrefix(filepath.Clean("/var/lib/tenant/acme/"+fixtureApp+"/app.db"), "/") + |
| + "/generations/x/wal/000001.wal.lz4" |
| +``` |
| + |
| +and set that route's `DBPath` to `/var/lib/tenant/acme/<app>/app.db` when you `Upsert` it, so the test exercises the real match rather than a coincidence of naming. |
| + |
| +- [ ] **Step 3: Implement attribution and the pass** |
| + |
| +Write `func replicaOwner(routes []registry.Route, key string) (acct, app string, ok bool)` in `economicspass.go` beside `unitOwner`, matching each route's own expected replica prefix rather than parsing the key apart — the same reasoning `unitOwner`'s doc comment gives at `:196-210`, and the same reason it is robust: |
| + |
| +```go |
| +// replicaOwner attributes a litestream replica object to the route whose |
| +// database it holds. Like unitOwner, it builds each route's OWN expected |
| +// key from registry.Route.DBPath rather than parsing the key back apart: |
| +// a unit tenant's database usually lives outside dataDir at its |
| +// StateDirectory path (ops/litestream.go:390-392), so the key carries no |
| +// account or app to parse, and the route is the only thing that knows. |
| +func replicaOwner(routes []registry.Route, key string) (string, string, bool) { |
| + for _, rt := range routes { |
| + if rt.DBPath == "" { |
| + continue |
| + } |
| + want := strings.TrimPrefix(filepath.Clean(rt.DBPath), "/") |
| + if key == want || strings.HasPrefix(key, want+"/") { |
| + return release.RouteAccount(rt), rt.App, true |
| + } |
| + } |
| + return release.OpsAccount, "", false |
| +} |
| +``` |
| + |
| +Confirm `DBPath` is the field's real name and that `replicaKey`'s trimming matches the expression above: |
| + |
| +Run: `grep -n 'DBPath' internal/registry/registry.go | head -3; sed -n 165,175p internal/ops/litestream.go` |
| + |
| +**A deleted route takes its replication history with it**, the same failure plan one fixed for units. Reuse that fix: `censusPass` already records every name a route answers to in `CensusDay.Units`, and a replica key is simply another such name. Add it there, in the same loop: |
| + |
| +```go |
| + if rt.DBPath != "" { |
| + units[strings.TrimPrefix(filepath.Clean(rt.DBPath), "/")] = |
| + usage.CensusUnit{Account: acct, App: rt.App} |
| + } |
| +``` |
| + |
| +Then `durabilityPass` resolves an object's owner by walking the key's prefixes against that day's census before falling back to `replicaOwner` on the live registry — the same asymmetry plan one established, and for the same reason. |
| + |
| +Then `durabilityPass` follows `storeEgressPass` exactly: create-only day object, count `REST.PUT` operations per owner, `Unswept` on a read failure or a high skip rate, one object per day at `usage.DurabilityDayKey(date)`. |
| + |
| +Fold into statements as `MeterDurabilityWrites1k` in thousands (`puts/1000`), and register `SourceDurability` in `sweepUsage`'s day loop the same way Task 3 registers store egress. |
| + |
| +- [ ] **Step 4: Run the tests, then the gate** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add infra/modules/deployment-buckets/main.tf internal/usage/ internal/console/ |
| +git commit -m "usage: meter replication writes per tenant |
| + |
| +Tight durability is a Business inclusion and an overage line, and until |
| +now nothing counted the S3 writes it pays for. One logging config on the |
| +data bucket covers every tenant, because litestream keys carry the |
| +database's full path — which is also why two tenants' registry.db never |
| +collide. |
| + |
| +Attribution matches each route's own expected replica prefix rather than |
| +parsing keys apart, for the reason unitOwner gives: a route knows its own |
| +names, and a parser has to guess." |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Make the cost model consume the new meters |
| + |
| +Adding a meter key to a statement prices nothing. `PriceAccount` enumerates its inputs explicitly (`internal/economics/compute.go:98-101`), and so does its mirrored JavaScript (`internal/console/templates/economics.html:665-667`). Until both consume the new meters, the dashboard's cost figure is wrong — and it feeds pool draw directly (`internal/console/economicsview.go:568`). |
| + |
| +This task also removes a live bug the spec named: `requests_1k` is fed from **edge HTTP request counts** (`economicspass.go:407`) but priced at the **S3 request rate** (`compute.go:100`, and the page even labels it `$s3requests` at `templates/economics.html:165`). |
| + |
| +**Files:** |
| +- Modify: `internal/economics/scenario.go` (`RateCard`, `DefaultScenario`) |
| +- Modify: `internal/economics/compute.go:98-130` |
| +- Modify: `internal/console/templates/economics.html` (`econ_maths_js` at :545, the model table at :164-166, the knob rows at :449) |
| +- Test: `internal/economics/compute_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `usage.MeterStoreEgressGB`, `usage.MeterDurabilityWrites1k`. |
| +- Produces: `RateCard.DurabilityPer1kUSD float64` (`omitempty`, additive — a scenario saved before it existed reads 0 and shows $0 rather than a silent default price, the convention `InterRegionGBUSD` set at `scenario.go:88-95`). |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/economics/compute_test.go`, following the file's existing scenario construction: |
| + |
| +```go |
| +func TestPriceAccountCountsStoreEgressAndDurability(t *testing.T) { |
| + sc := DefaultScenario() |
| + base := PriceAccount(sc, "acme", map[string]float64{}, 0) |
| + with := PriceAccount(sc, "acme", map[string]float64{ |
| + usage.MeterStoreEgressGB: 10, |
| + usage.MeterDurabilityWrites1k: 100, |
| + }, 0) |
| + if with.CostUSD <= base.CostUSD { |
| + t.Errorf("store egress and replication writes must cost something: %v vs %v", |
| + with.CostUSD, base.CostUSD) |
| + } |
| +} |
| + |
| +// Edge HTTP requests must not be priced at an S3 request rate. |
| +func TestPriceAccountDoesNotChargeS3RatesForEdgeRequests(t *testing.T) { |
| + sc := DefaultScenario() |
| + a := PriceAccount(sc, "acme", map[string]float64{}, 0) |
| + b := PriceAccount(sc, "acme", map[string]float64{usage.MeterRequests1k: 1000}, 0) |
| + if b.CostUSD != a.CostUSD { |
| + t.Errorf("edge requests changed cost by %v — they are not S3 requests", |
| + b.CostUSD-a.CostUSD) |
| + } |
| +} |
| +``` |
| + |
| +Check `AccountEconomics`'s actual field names before writing the assertions: |
| + |
| +Run: `grep -n 'type AccountEconomics struct' -A 20 internal/economics/compute.go` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/economics/ -run TestPriceAccount -count=1 -v` |
| + |
| +- [ ] **Step 3: Change the Go arithmetic** |
| + |
| +In `compute.go`, the cost term becomes: |
| + |
| +```go |
| + cost := allocatedUSD + |
| + meters[usage.MeterEgressGB]*rc.EgressGBUSD + |
| + meters[usage.MeterStoreEgressGB]*rc.EgressGBUSD + |
| + meters[usage.MeterDurabilityWrites1k]*rc.RequestsPer1kUSD + |
| + meters[usage.MeterStorageGBMonth]*rc.StorageGBMonthUSD |
| +``` |
| + |
| +Note what changed: `MeterRequests1k` is **gone** from the cost, and `MeterDurabilityWrites1k` takes the S3 request rate that term was misusing. Store egress shares `EgressGBUSD` because a byte off a box and a byte off S3 cost the same. Apply the same substitution in the `billableCost` term below it, and add store egress to `bEgress`'s allowance so the two share one inclusion as the spec requires. |
| + |
| +- [ ] **Step 4: Change the JavaScript, in the same commit** |
| + |
| +In `internal/console/templates/economics.html`, mirror it exactly: `priceAccount` in `econ_maths_js` (:665-667), the `$s3requests` model row (:165) — which should now read from `durability_writes_1k` and be relabelled — and add a knob row for the new rate beside :449. |
| + |
| +- [ ] **Step 5: Run the tests and read the page** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +Then confirm the two definitions agree by eye: the Go expression and the JS expression must have the same terms in the same order. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/economics/ internal/console/templates/economics.html |
| +git commit -m "economics: price the new meters, stop charging S3 rates for edge requests |
| + |
| +Adding a meter key to a statement prices nothing: PriceAccount lists its |
| +inputs explicitly, and so does its mirrored JavaScript. Both now consume |
| +store egress and replication writes, and both stop multiplying edge HTTP |
| +request counts by an S3 request rate — a substitution the page itself |
| +labelled \$s3requests while the meter was fed from edge rows. |
| + |
| +Store egress takes the egress rate and shares the egress allowance: a |
| +byte off a box and a byte off S3 cost the same and should read the same." |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Durable usage writes on the box |
| + |
| +Plan one deferred this here by name. `usageFlush` passes `drain()` as an argument (`internal/host/usage_loop.go:25`), so the edge meter's counters are emptied whether or not the write succeeds, and the caller only logs the failure — the doc at `:48-51` concedes "the drained window is lost". The analytics half of the very same function already solves this with `am.Requeue(failed)` at `:39`. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/usagemeter.go` (add `Requeue`) |
| +- Modify: `internal/host/usage_loop.go:24-42` |
| +- Test: `internal/edge/usagemeter_test.go`, `internal/host/usage_loop_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from Tasks 1–5. This task is independent and may be done first if convenient. |
| +- Produces: `func (m *UsageMeter) Requeue(rows map[string]usage.EdgeRow)` — adds rows back, summing into anything accumulated since the drain. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/edge/usagemeter_test.go`: |
| + |
| +```go |
| +func TestUsageMeterRequeueSums(t *testing.T) { |
| + m := NewUsageMeter() // match the real constructor name |
| + m.Add("h1", usage.EdgeRow{Requests: 1, BytesOut: 10}) |
| + drained := m.Drain() |
| + |
| + // New traffic arrives while the write is failing. |
| + m.Add("h1", usage.EdgeRow{Requests: 2, BytesOut: 20}) |
| + m.Requeue(drained) |
| + |
| + got := m.Drain()["h1"] |
| + if got.Requests != 3 || got.BytesOut != 30 { |
| + t.Errorf("after requeue got %+v, want Requests 3 BytesOut 30", got) |
| + } |
| +} |
| +``` |
| + |
| +Confirm the constructor and `Add` method names first: |
| + |
| +Run: `grep -n 'func NewUsageMeter\|func (m \*UsageMeter)' internal/edge/usagemeter.go` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/edge/ -run TestUsageMeterRequeue -count=1 -v` |
| + |
| +- [ ] **Step 3: Implement `Requeue`** |
| + |
| +Mirror `analytics.Meter.Requeue` — read it first and follow its locking exactly: |
| + |
| +Run: `grep -n 'func (m \*Meter) Requeue' -A 15 internal/analytics/*.go` |
| + |
| +- [ ] **Step 4: Requeue on a failed usage write** |
| + |
| +In `internal/host/usage_loop.go`, restructure `usageFlush` so the drained rows survive a failed write: |
| + |
| +```go |
| + rows := drain() |
| + err := usage.UpsertEdgeDay(ctx, s, box, now, rows) |
| + if err != nil { |
| + log.Printf("agent: usage flush %s: %v (re-queued)", box, err) |
| + um.Requeue(rows) |
| + } |
| +``` |
| + |
| +This needs the meter itself, not just its `drain` closure, so change the parameter from `drain func() map[string]usage.EdgeRow` to the meter. Update the caller in `usageLoop` (`:52-79`) and every test that constructs `usageFlush`. Update the doc comment at `:21-23`, which currently states the opposite behaviour, and the `usageLoop` doc at `:48-51` conceding the lost window. |
| + |
| +**Bound the requeue.** An endpoint down for a day would otherwise grow the map without limit. Cap retained rows at a stated number of flush cycles, drop the oldest with a log line when exceeded, and say so in the doc comment — an unbounded retry queue on a box is a memory leak with a nicer name. |
| + |
| +- [ ] **Step 5: Run the tests and the cross-compile check** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +Then, because this touches `internal/host` which links into the CLI: |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./... && GOOS=windows go build ./...` |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/edge/usagemeter.go internal/edge/usagemeter_test.go internal/host/usage_loop.go internal/host/usage_loop_test.go |
| +git commit -m "host: re-queue a failed edge usage write instead of losing it |
| + |
| +usageFlush passed drain() as an argument, so counters emptied whether or |
| +not the write landed, and the caller only logged the failure. The doc |
| +conceded the window was lost — acceptable at modelling grade, not when |
| +the number becomes an invoice line. |
| + |
| +The analytics half of this same function already had the answer. The |
| +queue is bounded: an endpoint down for a day drops the oldest rows with |
| +a log line rather than growing a box's memory without limit." |
| +``` |
| + |
| +--- |
| + |
| +## Self-review |
| + |
| +**Spec coverage.** Step 4 (`store_egress_gb`) is Tasks 1–3. Step 5 (`durability_writes_1k`) is Tasks 1 and 4. Step 6 (migrate the cost consumers) is Task 5. The half of step 3 plan one deferred here by name is Task 6. |
| + |
| +**Step 5's other half is not here.** The spec pairs the durability meter with a flush-only replication class and plan propagation. That needs an entitlement layer to read a plan from, which does not exist until plan three, so it stays assigned to enforcement (plan one's Task 2 note records the same split). |
| + |
| +**Two things this plan cannot settle, both stated rather than hidden.** |
| + |
| +1. **Best-effort delivery.** AWS makes no completeness guarantee for server access logs. Every task here fails toward `Unswept`, so a doubtful day is never billed, but whether a source with no completeness guarantee may back an invoice line at all is the billing plan's decision. The upgrade path, if the answer is no, is CloudTrail data events at $0.10 per 100k. |
| +2. **Whether a Business account's replication is actually attributable at all** depends on every tenant database being named by a route's `DBPath`. `carlos ops litestream` refuses to shrink coverage and reports databases the registry does not describe (`internal/ops/ops.go:133-146`), so an unnamed database is already a known, surfaced condition — but it would meter to the platform account rather than the tenant. Task 4's `replicaOwner` returns `ok=false` for exactly that case, and the pass should log it rather than silently folding those writes into ops. |
| + |
| +**One risk closed during writing.** An earlier draft of Task 4 assumed the replica key contained the account and app, and told the implementer to stop if it did not. It does not: unit tenants' databases live outside the data directory at their StateDirectory path. Matching each route's own `DBPath` instead removes the assumption entirely, and reusing plan one's census for it means a deleted route does not take its replication history with it. |
| + |
| +**Placeholder scan.** Task 4 Step 3 and Task 3 Step 7 describe implementations by reference to a sibling rather than quoting full code, because they are near-copies of functions the plan has already quoted in full. Every other step carries its code. Task 2 Step 1 and Task 4 Step 2 deliberately instruct the implementer to read neighbouring fixtures before writing assertions, because the fixture shapes are local conventions this plan should not fossilise by guessing. |
| + |
| +**Type consistency.** `AppEgress` is the shape both Task 3 and Task 4 write. `Unswept` carries the same meaning as `AppStorage.StoreUnswept`. `egressLogs` is one seam used by both passes. `SourceStoreEgress` and `SourceDurability` are registered in `MeterSource` and counted in `sweepUsage`, matching plan one's `SourceDays` contract. |