Day 86 — Capstone build 4 (API / CLI surface)

Updated

July 30, 2026

Day 86 — Capstone build 4 (API / CLI surface)

Stage VIII · ~3h · Capstone day 4/6
Goal: Complete the user-facing surface defined in the design doc—routes or commands, validation, auth (if planned), consistent errors, and help text—mapped cleanly onto application use cases. Go 1.26.

Why this day exists

Domain + DB without a finished surface cannot demo. Today is edge-adapter day: HTTP/CLI only talks to application services. Business rules stay in app/domain (Days 84–85); the surface translates DTOs, status codes, and flags.


Daily goals by option

Option A — Agent API

Surface Notes
GET /healthz /readyz Ready when collector loop running / store open
GET /v1/snapshot Latest sample
GET /v1/history Last N samples (cap N)
Optional auth Shared bearer token from config
Optional control POST /v1/collect force sample—validate carefully

Document that control plane is local/trusted unless auth is on.

Option B — Microservice API

Surface Notes
CRUD or workflow routes Match CAPSTONE demo script
JSON errors Consistent envelope
Auth As designed—must protect writes
Validation 400 on bad input
Status mapping 404/409/401/403

Prefer Go 1.22+ ServeMux patterns: POST /v1/books, GET /v1/books/{id}.

Option C — CLI surface

Surface Notes
All planned subcommands Help for each
Global flags --config, --verbose
Exit codes 0/1/2 discipline (Day 71)
Examples README snippets accurate
completion optional Stretch
mytool init
mytool apply --dry-run
mytool apply
mytool status
mytool version

Theory 1 — DTO vs domain

Adapters translate at the edge:

type createBookRequest struct {
    Title string `json:"title"`
}

func (h Handler) CreateBook(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    var req createBookRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeErr(w, domain.ErrInvalid)
        return
    }
    out, err := h.Create.Exec(r.Context(), app.CreateBookInput{Title: req.Title})
    if err != nil {
        writeErr(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, bookResponse{ID: out.ID, Title: out.Title})
}

Do not leak SQL errors or stack traces to clients.


Theory 2 — Error mapping table

func writeErr(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, domain.ErrNotFound):
        writeJSON(w, http.StatusNotFound, errBody{"not_found", "not found"})
    case errors.Is(err, domain.ErrInvalid):
        writeJSON(w, http.StatusBadRequest, errBody{"invalid", "invalid input"})
    case errors.Is(err, domain.ErrConflict):
        writeJSON(w, http.StatusConflict, errBody{"conflict", "conflict"})
    case errors.Is(err, domain.ErrUnauthorized):
        writeJSON(w, http.StatusUnauthorized, errBody{"unauthorized", "unauthorized"})
    default:
        // log err server-side
        writeJSON(w, http.StatusInternalServerError, errBody{"internal", "internal error"})
    }
}
Domain HTTP CLI exit (suggested)
nil 2xx 0
ErrInvalid / usage 400 2
ErrUnauthorized 401 1
ErrNotFound 404 1
ErrConflict 409 1
other 500 1

Theory 3 — Middleware chain

Order matters:

request ID → recover → logging → auth → max body → handler
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

Recover middleware must log panics and return 500—never expose panic text. Auth bugs should not be “fixed” by recover swallowing them silently without metrics.


Theory 4 — Auth surface honesty

Design promise Today’s bar
Auth required Default deny on /v1/* mutating routes
Token empty in dev Document risk; prefer default secure
Agent local only Bind 127.0.0.1 or require bearer
func requireBearer(token string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if token == "" {
            // only if design explicitly allows open lab mode
            next.ServeHTTP(w, r)
            return
        }
        h := r.Header.Get("Authorization")
        if h != "Bearer "+token {
            http.Error(w, `{"error":{"code":"unauthorized"}}`, http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

If you cut auth scope, update CAPSTONE.md today—not on demo day.


Theory 5 — CLI surface ergonomics

func exitCode(err error) int {
    if err == nil {
        return 0
    }
    if errors.Is(err, ErrUsage) {
        return 2
    }
    return 1
}
Rule Practice
Help Every subcommand -h works
Errors to stderr stdout clean for piping
Dry-run No side effects when set
Timeouts context.WithTimeout on network
mytool apply --dry-run 2>/dev/null | jq .   # if JSON plan

Worked example — httptest (B)

func TestCreateBookHTTP(t *testing.T) {
    uc := app.CreateBook{Books: memBooks{}, IDs: seqID{}, Clock: fixedClock{}}
    h := httpapi.New(uc, "secret-token")
    srv := httptest.NewServer(h.Routes())
    t.Cleanup(srv.Close)

    req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/books",
        strings.NewReader(`{"title":"x"}`))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer secret-token")
    res, err := http.DefaultClient.Do(req)
    if err != nil || res.StatusCode != http.StatusCreated {
        t.Fatalf("%v %v", err, res)
    }
}

ServeMux route sketch (Go 1.22+)

mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.Health)
mux.HandleFunc("GET /v1/books/{id}", h.GetBook)
mux.HandleFunc("POST /v1/books", h.CreateBook)

Lab 1 — Inventory vs design § surface

  1. Open CAPSTONE.md surface section.
  2. List each route/command: done / missing / cut.
  3. Implement missing pieces in priority of demo path.
  4. Do not add unplanned resources.

Lab 2 — Error mapping + validation

  1. Centralize writeErr / CLI exit mapping.
  2. Invalid JSON → 400.
  3. Empty required fields → 400.
  4. Not found → 404.
  5. httptest or CLI test for success + one error.

Lab 3 — Auth or explicit cut

  1. If design requires auth: protect writes; test 401.
  2. If cutting: update design + README risk note.
  3. Never leave half-protected routes (create open, delete locked).

Lab 4 — Smoke script (rough demo)

Write scripts/smoke.sh exercising the full user story:

#!/usr/bin/env bash
set -euo pipefail
base="${BASE_URL:-http://127.0.0.1:8080}"
curl -sf "$base/healthz" >/dev/null
# login/token if needed
# create → get → (optional list)
echo OK

CLI:

./bin/mytool version
./bin/mytool apply --dry-run
./bin/mytool apply
./bin/mytool status

Day 88 will polish this into the formal demo—today it must already work roughly.


Lab 5 — CAPSTONE milestone

## Milestone 86 — surface
- [x] Routes/commands match design (or design updated)
- [x] Error mapping consistent
- [x] Auth story honest
- [x] smoke.sh rough path
- Leftovers for 87–88: metrics, docker, polish

Hour-by-hour suggestion

Time Focus
0:00–0:40 Route/command inventory vs design
0:40–1:40 Implement missing surface
1:40–2:20 Error mapping + auth
2:20–2:50 Smoke script / httptest
2:50–3:00 Update CAPSTONE if scope cut

Common gotchas

Gotcha Fix
Business rules in handlers Call app services only
Inconsistent JSON field names Document OpenAPI-lite in README
Auth half-applied Default deny on /v1
CLI flags global parse bugs Per-command FlagSet / Cobra
Breaking Day 85 persistence Smoke after API changes
Returning 500 for validation Map ErrInvalid → 400
Unbounded history query Cap ?limit=
Forgetting MaxBytesReader Add on JSON POST
Help text lies Run every command -h

Checkpoint

  • Public surface matches design (or doc updated)
  • Error mapping consistent
  • Auth story honest
  • Smoke path works
  • httptest or CLI error test present
  • Milestone 86 done
  • go test ./... green on Go 1.26

Commit

git add .
git commit -m "day86: capstone api cli surface"

Write three personal gotchas before continuing.


Tomorrow

Day 87 — Capstone build 5: observability and production hooks—metrics, structured logs, shutdown, config polish on top of today’s surface.