carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

carlos db Unit 1 Implementation Plan — the door, the client, pull and query

> 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: Let an account member pull a restored copy of their app's replicated SQLite to their own machine and query it read-only, with no AWS credentials and no box access.

Architecture: The console gains an in-region, read-only S3 client on the data bucket and serves LTX files by coordinate (level + TXID range), never by key. The CLI implements litestream's ReplicaClient over that door with the three mutating methods stubbed to error, restores in-process into a 0700 temp directory, and opens the result read-only. The console never executes tenant SQL and never holds a restored database.

Tech Stack: Go; litestream v0.5.14 as a library (lease.Restore, lss3.ReplicaClient); modernc.org/sqlite (pure Go, no cgo); the existing internal/console CLI-API gate.

Spec: docs/superpowers/specs/2026-09-02-carlos-db-design.md — read it alongside this plan. §6 is the door, §12 is why the safety claim holds, §10 is the failure matrix every task draws its tests from.

Global Constraints

  • Option A is decided (spec §6): the console reads the region data bucket directly. Do not add an edges-file bucket lookup, and do not mirror through the deployment bucket.
  • In-region, non-pooled, activator-replicated instances only. Fleet-homed (region == "fleet/<name>"), cross-region, and Pool != "" are residency refusals made before any S3 call.
  • PrincipalUser only. Every service principal — legacy app token and every named service credential including admin — gets http.NotFound, never 403 (spec §6, "a 403 would be an oracle").
  • The client must not implement ReplicaClientV3. Restore type-asserts for it (replica.go:612) and would take the v0.3 path. A test asserts the negative.
  • Never set RestoreOptions.Follow. It writes a -txid sidecar and keeps polling.
  • Run lease.CheckDB after every restore rather than relying on opt.IntegrityCheck.
  • The restore retry wraps the whole Restore, never an individual read: ResumableReader treats ErrNotExist as fatal (resumable_reader.go:72-75), so a vanished planned file needs a fresh plan.
  • list answers levels 0–9 inclusive, returning an empty list (never 404) for levels the activator never writes. SnapshotLevel = 9.
  • Listings ascend by min_txid. size, min_txid, max_txid are mandatory on every entry.
  • Store errors are logged, not returned (apischedule.go:145-147) — they carry bucket names and request ids.
  • No user-facing string ships without the copy-review gate. Task 7 is where copy is finalised; earlier tasks may use placeholder wording in code but must not be merged to main with it.
  • Every command needs a docs/site/cli.md heading or internal/docsite's TestCLICommandsAreDocumented fails.
  • Gate before commit: go build ./..., go vet ./..., gofmt -l . clean, go test ./... -count=1.

---

File Structure

Create:

  • internal/dbdoor/coords.go — the wire types shared by console and CLI (LTXFile, the list/get path builders). Leaf package, no console or CLI imports, so both sides agree on one shape.
  • internal/dbdoor/coords_test.go
  • internal/dbdoor/client.go — the CLI's read-only litestream.ReplicaClient over HTTP.
  • internal/dbdoor/client_test.go
  • internal/dbdoor/restore.go — restore into a 0700 temp dir, retry, CheckDB.
  • internal/dbdoor/restore_test.go
  • internal/console/apidb.go — resolution, refusals, and the two handlers.
  • internal/console/apidb_test.go
  • db.go — the carlos db CLI verb (pull, query).
  • db_test.go

Modify:

  • internal/lease/endpoint.go — export a read-only replica-client constructor (currently newReplicaClient, unexported).
  • internal/console/authcli.go — two route registrations.
  • cmd/console/main.go — read CARLOS_HIBERNATE_BUCKET/_REGION/_PREFIX and CARLOS_REGION.
  • internal/console/server.go — carry the hibernate config and the S3 client on Config/Server.
  • apimode.go — session methods for the two door calls.
  • main.go — case "db" and the usage entry.
  • docs/site/cli.md — the carlos db heading.

Why internal/dbdoor rather than putting the client in the root package: the console (server) and the CLI (client) must agree byte-for-byte on the wire shape, and a leaf package is the only way both can import it without internal/console and main importing each other. This mirrors internal/dbverify's own reason for existing.

---

Task 1: Export a read-only replica client from internal/lease

The console needs lss3.ReplicaClient construction — endpoint override, path style, region — that internal/lease already gets right. Duplicating it is how the two drift.

Files:

  • Modify: internal/lease/endpoint.go:120-131
  • Test: internal/lease/endpoint_test.go

Interfaces:

  • Produces: func ReadReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient
  • [ ] Step 1: Write the failing test
// internal/lease/endpoint_test.go
func TestReadReplicaClientCarriesBucketPrefixRegion(t *testing.T) {
	c := ReadReplicaClient("data-bucket", "instances/a.example.com", "eu-west-1")
	if c.Bucket != "data-bucket" {
		t.Errorf("Bucket = %q, want data-bucket", c.Bucket)
	}
	if c.Path != "instances/a.example.com" {
		t.Errorf("Path = %q, want instances/a.example.com", c.Path)
	}
	if c.Region != "eu-west-1" {
		t.Errorf("Region = %q, want eu-west-1", c.Region)
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/lease/ -run TestReadReplicaClient Expected: FAIL — undefined: ReadReplicaClient

  • [ ] Step 3: Write minimal implementation
// internal/lease/endpoint.go — beneath newReplicaClient

// ReadReplicaClient is newReplicaClient for READERS outside this package —
// the console's db door (spec 2026-09-02 §6). Identical construction on
// purpose: a second hand-rolled client is how the endpoint override and the
// path-style flag drift apart between the writer and the reader of the same
// bucket.
//
// It returns the same type the writer uses, so nothing here can enforce
// read-only by construction; that guarantee lives in internal/dbdoor's
// client, which has no write methods at all.
func ReadReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient {
	return newReplicaClient(bucket, prefix, region)
}
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/lease/ -run TestReadReplicaClient Expected: PASS

  • [ ] Step 5: Commit
git add internal/lease/endpoint.go internal/lease/endpoint_test.go
git commit -m "lease: export ReadReplicaClient for the db door"

---

Task 2: The wire shape

One package both sides import, so the door and the client cannot disagree about coordinates.

Files:

  • Create: internal/dbdoor/coords.go
  • Test: internal/dbdoor/coords_test.go

Interfaces:

  • Produces: - const MaxLevel = 9 - type LTXFile struct { Level int; MinTXID, MaxTXID uint64; Size int64; CreatedAt time.Time } - type ListResponse struct { Files []LTXFile } - func ListPath(acct, app, host string, level int) string - func GetPath(acct, app, host string, level int, minTXID, maxTXID uint64) string - func RangeHeader(offset, size int64) string
  • [ ] Step 1: Write the failing test
// internal/dbdoor/coords_test.go
package dbdoor

import "testing"

// Range semantics must match litestream's S3 client exactly
// (s3/replica_client.go:647-652), or a resumed read returns the wrong bytes.
func TestRangeHeaderMatchesTheS3Client(t *testing.T) {
	cases := []struct {
		name           string
		offset, size   int64
		want           string
	}{
		{"whole file from an offset", 0, 0, "bytes=0-"},
		{"open-ended from an offset", 512, 0, "bytes=512-"},
		{"sized read", 0, 100, "bytes=0-99"},
		{"sized read at an offset", 512, 100, "bytes=512-611"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := RangeHeader(tc.offset, tc.size); got != tc.want {
				t.Errorf("RangeHeader(%d, %d) = %q, want %q", tc.offset, tc.size, got, tc.want)
			}
		})
	}
}

// Coordinates, never keys: the caller names a level and a TXID range, and
// the server decides what object that is. lock.json has no coordinate.
func TestGetPathIsCoordinateAddressed(t *testing.T) {
	got := GetPath("bab", "titogo", "a.bab.oncarlos.com", 3, 16, 32)
	for _, want := range []string{"bab", "titogo", "a.bab.oncarlos.com", "3", "16", "32"} {
		if !strings.Contains(got, want) {
			t.Fatalf("GetPath = %q, missing %q", got, want)
		}
	}
	if strings.Contains(got, ".ltx") || strings.Contains(got, "lock") {
		t.Fatalf("GetPath = %q leaks the key layout", got)
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/dbdoor/ -run 'TestRangeHeader|TestGetPath' Expected: FAIL — undefined: RangeHeader, undefined: GetPath

  • [ ] Step 3: Write minimal implementation
// internal/dbdoor/coords.go

// Package dbdoor is the wire shape of the `carlos db` replica door, shared
// by the console that serves it and the CLI that reads it (spec
// 2026-09-02 §6).
//
// It is a LEAF package for the reason internal/dbverify is one: the writer
// (internal/console) and the reader (the CLI's main package) must agree on
// one shape, and neither can import the other.
//
// Files are addressed by COORDINATE — level plus TXID range — never by key.
// That is not decoration: lock.json lives under the same prefix as the LTX
// files and is not a member's to fetch, and a coordinate has no way to name
// it. See spec §6 "The door".
package dbdoor

import (
	"fmt"
	"net/url"
	"time"
)

// MaxLevel is the highest level a list may name. litestream's
// CalcRestorePlan asks for SnapshotLevel (9) first, then 8 down to 0
// (replica.go:1452, 1476-1485). The activator writes only 0-3 and 9, so
// most of that range is legitimately EMPTY — and empty is an empty list,
// never a 404, or the restore stops at the first gap.
const MaxLevel = 9

// LTXFile is one replica file as the door reports it. Size is MANDATORY:
// Restore rejects Size < ltx.HeaderSize (replica.go:630) and
// ResumableReader uses it to detect a premature EOF
// (resumable_reader.go:96).
type LTXFile struct {
	Level     int       `json:"level"`
	MinTXID   uint64    `json:"min_txid"`
	MaxTXID   uint64    `json:"max_txid"`
	Size      int64     `json:"size"`
	CreatedAt time.Time `json:"created_at"`
}

// ListResponse is ordered by MinTXID ASCENDING — restoreLevelCursor assumes
// filename order, which S3 gives because the names are zero-padded hex.
type ListResponse struct {
	Files []LTXFile `json:"files"`
}

func base(acct, app, host string) string {
	return fmt.Sprintf("/api/cli/apps/%s/%s/db/%s",
		url.PathEscape(acct), url.PathEscape(app), url.PathEscape(host))
}

func ListPath(acct, app, host string, level int) string {
	return fmt.Sprintf("%s/ltx/%d", base(acct, app, host), level)
}

func GetPath(acct, app, host string, level int, minTXID, maxTXID uint64) string {
	return fmt.Sprintf("%s/ltx/%d/%d-%d", base(acct, app, host), level, minTXID, maxTXID)
}

// RangeHeader mirrors litestream's own S3 client byte-for-byte
// (s3/replica_client.go:647-652): open-ended when size is 0, inclusive
// end otherwise.
func RangeHeader(offset, size int64) string {
	if size == 0 {
		return fmt.Sprintf("bytes=%d-", offset)
	}
	return fmt.Sprintf("bytes=%d-%d", offset, offset+size-1)
}

Add "strings" to the test's imports.

  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/dbdoor/ Expected: PASS

  • [ ] Step 5: Commit
git add internal/dbdoor/coords.go internal/dbdoor/coords_test.go
git commit -m "dbdoor: the coordinate wire shape the door and client share"

---

Task 3: Resolution and the residency refusals

The half of the door that makes no S3 call. Written and tested first because every refusal in the spec's matrix happens here, and none of them should ever reach a bucket.

Files:

  • Create: internal/console/apidb.go
  • Test: internal/console/apidb_test.go

Interfaces:

  • Consumes: provision.Record (fields Host, App, Account, Pool, Region), provision.ValidHostShape, activator.HibernatePrefix.
  • Produces: func (s *Server) resolveReplica(rec provision.Record, host string) (prefix string, err error) and var ErrResidency, ErrPooled, ErrBadHost error.
  • [ ] Step 1: Write the failing test
// internal/console/apidb_test.go
package console

import (
	"errors"
	"testing"

	"github.com/carlosframework/platform/internal/provision"
)

// Every one of these is refused BEFORE any S3 call. A door that reached the
// bucket and mapped AccessDenied would leak both latency and the fact that
// a bucket was tried.
func TestResolveReplicaRefusesBeforeTouchingS3(t *testing.T) {
	s := &Server{}
	s.cfg.Region = "eu-west-1"
	s.cfg.HibernatePrefix = "instances"

	cases := []struct {
		name string
		rec  provision.Record
		host string
		want error
	}{
		{
			name: "another region",
			rec:  provision.Record{Host: "a.bab.oncarlos.com", Region: "us-east-1"},
			host: "a.bab.oncarlos.com",
			want: ErrResidency,
		},
		{
			name: "fleet-homed",
			rec:  provision.Record{Host: "a.bab.oncarlos.com", Region: "fleet/acme"},
			host: "a.bab.oncarlos.com",
			want: ErrResidency,
		},
		{
			name: "pooled",
			rec:  provision.Record{Host: "a.bab.oncarlos.com", Region: "eu-west-1", Pool: "shared"},
			host: "a.bab.oncarlos.com",
			want: ErrPooled,
		},
		{
			name: "hostile host",
			rec:  provision.Record{Host: "../../etc", Region: "eu-west-1"},
			host: "../../etc",
			want: ErrBadHost,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := s.resolveReplica(tc.rec, tc.host)
			if !errors.Is(err, tc.want) {
				t.Fatalf("resolveReplica = %v, want %v", err, tc.want)
			}
		})
	}
}

// The happy path is an in-region, non-pooled instance, and the prefix is
// the activator's own layout — not a string this package invents.
func TestResolveReplicaBuildsTheActivatorPrefix(t *testing.T) {
	s := &Server{}
	s.cfg.Region = "eu-west-1"
	s.cfg.HibernatePrefix = "instances"
	got, err := s.resolveReplica(
		provision.Record{Host: "a.bab.oncarlos.com", Region: "eu-west-1"},
		"a.bab.oncarlos.com")
	if err != nil {
		t.Fatal(err)
	}
	if got != "instances/a.bab.oncarlos.com" {
		t.Fatalf("prefix = %q, want instances/a.bab.oncarlos.com", got)
	}
}

// A single-region deployment leaves both empty, and "" == "" must be a
// match rather than a refusal — otherwise the door never works anywhere
// that has not set CARLOS_REGION.
func TestResolveReplicaTreatsEmptyRegionsAsAMatch(t *testing.T) {
	s := &Server{}
	s.cfg.HibernatePrefix = "instances"
	if _, err := s.resolveReplica(
		provision.Record{Host: "a.bab.oncarlos.com"}, "a.bab.oncarlos.com"); err != nil {
		t.Fatalf("single-region deployment refused: %v", err)
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/console/ -run TestResolveReplica Expected: FAIL — undefined: ErrResidency, s.resolveReplica undefined, s.cfg.Region undefined

  • [ ] Step 3: Write minimal implementation

Add to internal/console/server.go's Config struct:

	// The region data bucket the db door reads (spec 2026-09-02 §6,
	// Option A). Read-only, in-region, instances/* only. Empty disables
	// the door rather than failing it open.
	HibernateBucket string
	HibernateRegion string
	HibernatePrefix string
	// Region is this console's own placement, compared against an
	// instance record's before any S3 call. Empty in a single-region
	// deployment, where every record's is empty too.
	Region string

Then internal/console/apidb.go:

// The `carlos db` replica door (spec 2026-09-02 §6). This file is the half
// that makes no S3 call: resolving an instance record to one replica
// prefix, and refusing everything that is not this console's to read.
//
// Every refusal here happens BEFORE the bucket is touched. That is
// deliberate — a door that tried and mapped AccessDenied would leak the
// attempt, the latency, and eventually a bucket name into a log a member
// can provoke.

package console

import (
	"errors"
	"fmt"

	"github.com/carlosframework/platform/internal/activator"
	"github.com/carlosframework/platform/internal/provision"
)

var (
	// ErrResidency: the data is in another region's bucket, or on a
	// customer's own fleet box. The instance-move design makes those reads
	// impossible at IAM by design, so this is the design working rather
	// than a fault to retry.
	ErrResidency = errors.New("db: the instance's data is outside this region")
	// ErrPooled: a pooled record's data lives on the pool box, the same
	// class movespass.go:150-153 refuses.
	ErrPooled = errors.New("db: pooled instances have no replica of their own")
	// ErrBadHost: path.Join cleans "../x" to "x" rather than refusing it,
	// so a host becomes a key segment only after ValidHostShape.
	ErrBadHost = errors.New("db: invalid host")
)

func (s *Server) resolveReplica(rec provision.Record, host string) (string, error) {
	if !provision.ValidHostShape(host) {
		return "", fmt.Errorf("%w: %q", ErrBadHost, host)
	}
	if rec.Pool != "" {
		return "", fmt.Errorf("%w: %s is in pool %q", ErrPooled, host, rec.Pool)
	}
	if rec.Region != s.cfg.Region {
		return "", fmt.Errorf("%w: %s is homed in %q, this console serves %q",
			ErrResidency, host, rec.Region, s.cfg.Region)
	}
	return activator.HibernatePrefix(s.cfg.HibernatePrefix, host), nil
}

Note: a fleet/<name> region is caught by the same rec.Region != s.cfg.Region compare, because a console's own region is never fleet/…. Keep the dedicated test case anyway — it documents the intent, and would catch a future change that special-cased the compare.

  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/console/ -run TestResolveReplica Expected: PASS

  • [ ] Step 5: Verify ValidHostShape is exported

Run: grep -n "func ValidHostShape" internal/provision/provision.go Expected: it exists and is exported. If it is not, export it in this task and note the rename in the commit.

  • [ ] Step 6: Commit
git add internal/console/apidb.go internal/console/apidb_test.go internal/console/server.go
git commit -m "console: resolve an instance to one replica prefix, refusing before S3"

---

Task 4: The door handlers

Files:

  • Modify: internal/console/apidb.go
  • Modify: internal/console/authcli.go
  • Test: internal/console/apidb_test.go

Interfaces:

  • Consumes: Task 1's lease.ReadReplicaClient, Task 2's dbdoor.LTXFile/ListResponse/MaxLevel, Task 3's resolveReplica.
  • Produces: POST-free routes GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level} and GET …/ltx/{level}/{min}-{max}.
  • [ ] Step 1: Write the failing test
// internal/console/apidb_test.go — append

// A level the activator never writes is EMPTY, not missing. A 404 here
// stops CalcRestorePlan at the first gap and the restore fails.
func TestAPIDBListAnswersEmptyForUnwrittenLevels(t *testing.T) {
	fx, token := dbFixture(t)
	for level := 0; level <= dbdoor.MaxLevel; level++ {
		status, out := get(t, fx.ts.URL+dbdoor.ListPath("bab", "titogo", dbTestHost, level), token)
		if status != http.StatusOK {
			t.Fatalf("level %d = %d %s, want 200 with an empty list", level, status, out)
		}
	}
}

// Coordinate addressing exists to make lock.json unreachable. This is the
// test that keeps the door from decaying into an object proxy.
func TestAPIDBServesNoKeyOutsideItsCoordinates(t *testing.T) {
	fx, token := dbFixture(t)
	for _, bad := range []string{
		"/api/cli/apps/bab/titogo/db/" + dbTestHost + "/ltx/0/../../lock.json",
		"/api/cli/apps/bab/titogo/db/" + dbTestHost + "/lock.json",
		"/api/cli/apps/bab/titogo/db/" + dbTestHost + "/ltx/10",
	} {
		status, _ := get(t, fx.ts.URL+bad, token)
		if status == http.StatusOK {
			t.Fatalf("%s = 200, want a refusal", bad)
		}
	}
}

// Not a 403: a 403 tells any token holder that a db door exists for this
// app. Service principals get the same answer as a stranger.
func TestAPIDBRefusesServicePrincipalsWithA404(t *testing.T) {
	fx, svcToken := dbServiceFixture(t)
	status, _ := get(t, fx.ts.URL+dbdoor.ListPath("bab", "titogo", dbTestHost, 0), svcToken)
	if status != http.StatusNotFound {
		t.Fatalf("service principal = %d, want 404", status)
	}
}

Write dbFixture, dbServiceFixture, dbTestHost and get alongside, modelled on scheduleFixture and postBody in apischedule_test.go. The fixture must seed an apps/<sqid>/<app>/instances/<host>.json record with an empty region, and point cfg.HibernateBucket at a release.NewDirStore-backed fake or leave it empty so the empty-list path is exercised without S3.

  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/console/ -run TestAPIDB Expected: FAIL — routes are 404 for every case, including the empty-list one that should be 200.

  • [ ] Step 3: Write minimal implementation

Append to internal/console/apidb.go:

// handleAPIDBList answers one level's listing. An out-of-range level is a
// 404 (it is not a coordinate); a level with nothing in it is 200 and an
// empty list (it is a coordinate that happens to be empty).
func (s *Server) handleAPIDBList(w http.ResponseWriter, r *http.Request) {
	prefix, acct, app, host, ok := s.dbGate(w, r)
	if !ok {
		return
	}
	level, err := strconv.Atoi(r.PathValue("level"))
	if err != nil || level < 0 || level > dbdoor.MaxLevel {
		http.NotFound(w, r)
		return
	}
	client := lease.ReadReplicaClient(s.cfg.HibernateBucket, prefix, s.cfg.HibernateRegion)
	itr, err := client.LTXFiles(r.Context(), level, 0, false)
	if err != nil {
		log.Printf("console: api db list %s/%s %s level %d: %v", acct, app, host, level, err)
		jsonError(w, http.StatusBadGateway, "could not read the replica; try again")
		return
	}
	defer itr.Close()
	out := dbdoor.ListResponse{Files: []dbdoor.LTXFile{}}
	for itr.Next() {
		f := itr.Item()
		out.Files = append(out.Files, dbdoor.LTXFile{
			Level: level, MinTXID: uint64(f.MinTXID), MaxTXID: uint64(f.MaxTXID),
			Size: f.Size, CreatedAt: f.CreatedAt,
		})
	}
	sort.Slice(out.Files, func(i, j int) bool { return out.Files[i].MinTXID < out.Files[j].MinTXID })
	writeJSON(w, http.StatusOK, out)
}

and the get handler, which opens by coordinate and streams (F8: never a presigned redirect), honouring an inbound Range via dbdoor.RangeHeader's inverse. dbGate wraps apiScheduleApp(w, r, false, …), reads the instance record, calls resolveReplica, maps ErrResidency/ErrPooled to a 409 with the residency sentence and ErrBadHost to http.NotFound, and returns a 503 when HibernateBucket is empty.

Register in authcli.go beside the schedule routes, but with svcrole.Deny — or, if no such helper exists, a PrincipalUser-only check inside dbGate; the test in Step 1 is the arbiter.

  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/console/ -run TestAPIDB Expected: PASS

  • [ ] Step 5: Commit
git add internal/console/apidb.go internal/console/apidb_test.go internal/console/authcli.go
git commit -m "console: serve replica LTX files by coordinate, members only"

---

Task 5: The read-only replica client

The spec's central safety claim lives here (§6, §12). Two tests, not one: the mutating methods must error, and they must never be called during a real restore.

Files:

  • Create: internal/dbdoor/client.go
  • Test: internal/dbdoor/client_test.go

Interfaces:

  • Produces: func NewClient(base, acct, app, host string, do func(*http.Request) (*http.Response, error)) *Client, satisfying litestream.ReplicaClient.
  • [ ] Step 1: Write the failing test
// internal/dbdoor/client_test.go
package dbdoor

import (
	"context"
	"testing"

	"github.com/benbjohnson/litestream"
)

// The claim the whole design rests on: this client cannot corrupt a
// replica, because it has no code that writes to one.
func TestClientMutatingMethodsAlwaysError(t *testing.T) {
	c := NewClient("http://console.invalid", "bab", "titogo", "a.example.com", nil)
	ctx := context.Background()
	if _, err := c.WriteLTXFile(ctx, 0, 1, 2, nil); err == nil {
		t.Error("WriteLTXFile returned nil — this client must never write")
	}
	if err := c.DeleteLTXFiles(ctx, nil); err == nil {
		t.Error("DeleteLTXFiles returned nil — this client must never delete")
	}
	if err := c.DeleteAll(ctx); err == nil {
		t.Error("DeleteAll returned nil — this client must never delete")
	}
}

// Restore type-asserts for ReplicaClientV3 (replica.go:612) and would take
// the v0.3 path if this satisfied it. It must not.
func TestClientIsNotAV3ReplicaClient(t *testing.T) {
	var c any = NewClient("http://console.invalid", "bab", "titogo", "a.example.com", nil)
	if _, ok := c.(litestream.ReplicaClientV3); ok {
		t.Fatal("client satisfies ReplicaClientV3 — Restore would take the v0.3 path")
	}
}

var _ litestream.ReplicaClient = (*Client)(nil)
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/dbdoor/ -run TestClient Expected: FAIL — undefined: NewClient

  • [ ] Step 3: Write minimal implementation

Client holds the base URL, the identifiers, and a do func (defaulting to http.DefaultClient.Do, injected in tests). Implement:

  • Type() string → "carlos-db"
  • Init(ctx) error → nil (idempotent no-op; the HTTP client needs no setup)
  • SetLogger(*slog.Logger) → store it
  • LTXFiles(ctx, level, seek, useMetadata) → GET ListPath, decode ListResponse, filter >= seek locally (seek is not on the wire), return an ltx.FileIterator
  • OpenLTXFile(ctx, level, min, max, offset, size) → GET GetPath with Range: RangeHeader(offset, size); a 404 maps to os.ErrNotExist
  • WriteLTXFile, DeleteLTXFiles, DeleteAll → return errReadOnly unconditionally
// errReadOnly is not a guard that can be switched off: these three methods
// have no other body. The two-replicators hazard internal/caps and
// internal/lease both warn about is unreachable from this client by
// construction, and a future edit that wanted to reach it would have to add
// the capability first.
var errReadOnly = errors.New("dbdoor: this client is read-only — it restores replicas and never writes them")
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/dbdoor/ -run TestClient Expected: PASS

  • [ ] Step 5: Add the call-counting test — the other half
// The methods erroring is not enough: a restore that CALLED one would be
// relying on an error path. This asserts a full restore never reaches them,
// which is what catches a litestream upgrade changing the restore path.
func TestRestoreNeverCallsAMutatingMethod(t *testing.T) {
	// Serve a real replica from a DirStore-backed fake console (build one
	// by replicating a small SQLite through lease.ReplicateWithLease into a
	// temp dir, then serving its files over httptest).
	// Wrap Client in a counter that increments per method name.
	// Restore through it, then assert:
	if counts["WriteLTXFile"]+counts["DeleteLTXFiles"]+counts["DeleteAll"] != 0 {
		t.Fatalf("restore called a mutating method: %v", counts)
	}
}
  • [ ] Step 6: Run it, then commit
go test ./internal/dbdoor/
git add internal/dbdoor/client.go internal/dbdoor/client_test.go
git commit -m "dbdoor: a replica client with no write methods"

---

Task 6: Restore into a private directory

Files:

  • Create: internal/dbdoor/restore.go
  • Test: internal/dbdoor/restore_test.go

Interfaces:

  • Produces: func RestoreTo(ctx context.Context, c *Client, dir string) (path string, err error)
  • [ ] Step 1: Write the failing test
// litestream creates <out>.tmp itself with os.Create, at the process umask,
// so a 0600 file cannot be pre-created for it. The containing directory is
// the only thing that can be private — and it must also confine the -wal
// and -shm a read-only open still needs.
func TestRestoreToUsesAPrivateDirectory(t *testing.T) {
	dir := t.TempDir()
	// ... restore a known replica ...
	fi, err := os.Stat(filepath.Dir(path))
	if err != nil {
		t.Fatal(err)
	}
	if perm := fi.Mode().Perm(); perm != 0o700 {
		t.Fatalf("restore directory is %04o, want 0700", perm)
	}
}

// A planned file can vanish under the restore when the writer's L0
// retention fires. ResumableReader treats ErrNotExist as FATAL and does not
// retry, so the retry has to wrap the whole Restore — a fresh plan is the
// only recovery.
func TestRestoreToRetriesTheWholeRestoreOnce(t *testing.T) {
	// A client that returns os.ErrNotExist for the first OpenLTXFile and
	// serves normally afterwards.
	// Assert: RestoreTo succeeds, and the client saw two CalcRestorePlan
	// listing passes rather than one.
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/dbdoor/ -run TestRestoreTo Expected: FAIL — undefined: RestoreTo

  • [ ] Step 3: Implement

RestoreTo creates os.MkdirTemp(dir, "carlos-db-"), chmods it 0700, builds litestream.NewReplicaWithClient(nil, c), sets RestoreOptions{OutputPath: …} — never Follow — calls Restore, retries the whole call once on os.ErrNotExist, then runs lease.CheckDB on the result and returns the path.

  • [ ] Step 4: Run to verify it passes, then commit
go test ./internal/dbdoor/
git add internal/dbdoor/restore.go internal/dbdoor/restore_test.go
git commit -m "dbdoor: restore into a 0700 directory, retrying the whole plan once"

---

Task 7: carlos db pull and carlos db query

Files:

  • Create: db.go, db_test.go
  • Modify: apimode.go, main.go, docs/site/cli.md
  • [ ] Step 1: Write the failing tests
// A copy is harmless to write to. Read-only exists so a caller cannot
// BELIEVE it changed production: an agent that sees "1 row affected"
// concludes it fixed the bug. The error is the truth.
func TestQueryRefusesAWriteWithSQLitesOwnError(t *testing.T) {
	// open a restored fixture through the same helper db.go uses
	err := runQuery(path, "UPDATE users SET name='x'")
	if err == nil || !strings.Contains(err.Error(), "attempt to write a readonly database") {
		t.Fatalf("write error = %v, want SQLite's readonly refusal", err)
	}
}

func TestQueryLeavesTheFileByteIdentical(t *testing.T) { /* sha256 before and after */ }

func TestPullRefusesAnExistingOutAndLands0600(t *testing.T) { /* both halves */ }

func TestPullCopiesAcrossFilesystems(t *testing.T) {
	// os.Rename fails across devices; the temp dir and --out routinely
	// differ. Assert a copy path, not a rename.
}
  • [ ] Step 2: Run to verify they fail, then implement

db.go adds runDB(args) dispatching pull and query; apimode.go gains a session method returning a configured *dbdoor.Client; main.go gains case "db": runDB(args) and a usage entry.

  • [ ] Step 3: Add the cli.md heading

Required — internal/docsite's TestCLICommandsAreDocumented fails without it. The prose is where "this is a copy, not your live database" gets said to the person most likely to assume otherwise.

  • [ ] Step 4: Run the copy-review gate

Do not merge without this. Extract every user-facing string added in Tasks 3–7 — the residency refusal, the pooled refusal, the handover line, the read-only refusal, the cli.md prose — and run the copy-review skill. Apply the user's edits verbatim. Requirements R1–R6 in spec §5 are what the copy has to satisfy; R2 (age as a number carrying its bound, never an adjective, never defaulting to stream) lands in Unit 2 but the handover line's "this is a copy" half is Unit 1's.

  • [ ] Step 5: Full gate, then commit
go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1
git add db.go db_test.go apimode.go main.go docs/site/cli.md
git commit -m "carlos db: pull and query a restored replica, read-only"

---

Self-Review

Spec coverage. §6 configuration → Task 3 (Config fields) + Task 4. §6 resolution and refusals → Task 3. §6 door and wire specifics 1–5 → Tasks 2 and 4. §6 principal rule → Task 4. §6 read-only client → Task 5. §7 one-database → implicit: no --db flag exists anywhere in this plan. §12 conditions (i) no V3, (ii) no Follow, (iii) CheckDB → Task 5 Step 1 and Task 6 Step 3, and all three are in Global Constraints. §4 private directory and 0600 --out → Tasks 6 and 7. §4 read-only enforcement → Task 7.

Deliberately not in this plan (Unit 2 and Unit 3 in spec §13): ls and every freshness field — lease_held, sync_class with its mandatory unknown, LTX-header age, the dbverify verdict, and the --json field set — plus shell. Wire specific 6 (lease_held) is Unit 2's, because nothing in pull/query reads it. Two follow-on plans, written once Unit 1 lands and its shapes are real rather than predicted.

Placeholders. Tasks 4, 5 Step 5, 6 and 7 describe some test bodies rather than spelling them out — specifically the ones that need a real replica fixture. That fixture does not exist yet and its shape depends on Task 4's fixture helpers. Before executing Task 4, write the fixture helper first and fill those bodies in. Flagging rather than pretending: a test body invented here against a fixture that does not exist would be worse than a named gap.

Type consistency. dbdoor.LTXFile fields are used identically in Tasks 2, 4 and 5. lease.ReadReplicaClient(bucket, prefix, region) is defined in Task 1 and called in Task 4 with that order. resolveReplica(rec, host) (string, error) is defined in Task 3 and consumed by dbGate in Task 4. NewClient(base, acct, app, host, do) is defined in Task 5 and consumed in Tasks 6 and 7.