Day 29 — Capstone Build: Core to Polish

Updated

July 30, 2025

Day 29 — Capstone Build: Core to Polish

Day 83 — Capstone build 1 (skeleton)

Stage VIII · ~3h · Capstone day 1/6
Goal: Create the capstone module skeleton that builds, tests, and runs a thin vertical spike—layout, tooling scripts, and composition root aligned with CAPSTONE.md.

Note

Day 82 planned. Day 83 makes the plan physical. Resist building Day 88 features. Skeleton + spike only.

Why this day exists

Directories, go.mod, cmd/, lint script, and a “hello, architecture” path mean Days 84–88 never start from zero. Empty theory folders without a runnable binary are not a skeleton—they are procrastination in tree form.


Daily goals by option

Option A — Agent

Done when
Module go 1.26 builds
cmd/agent starts and logs heartbeat via slog
Config load (addr, interval) works
/healthz responds
scripts/lint.sh runs test+vet
CAPSTONE.md milestone 83 checked

Suggested layout:

cmd/agent/main.go
internal/config/
internal/agent/loop.go
internal/api/http.go
scripts/lint.sh
CAPSTONE.md

Spike behavior: ticker logs tick with timestamp; HTTP serves health.

Option B — Microservice

Done when
cmd/service listens
Config + logger wired
One route returns JSON
Package layout matches hexagon/boundaries plan
DB connection optional stub interface ready
lint script green

Suggested layout:

cmd/service/main.go
internal/config/
internal/domain/
internal/port/
internal/app/
internal/adapter/http/
internal/adapter/sqlite/   # may be stub
migrations/                # empty or first no-op

Spike: GET /v1/health + GET /v1/version.

Option C — CLI

Done when
cmd/<name> multi-command skeleton
version and help work
One real command stub (init or run --dry-run)
internal/ logic package with unit test
ldflags version embed
lint script green
cmd/mytool/main.go
internal/cli/
internal/<domain>/
scripts/build-release.sh  # can be stub calling go build

Theory 1 — Composition root first

func main() {
    cfg, err := config.Load(os.Args[1:])
    if err != nil {
        fmt.Fprintf(os.Stderr, "config: %v\n", err)
        os.Exit(2)
    }
    log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: parseLevel(cfg.LogLevel),
    }))
    if err := run(cfg, log); err != nil {
        log.Error("exit", "err", err)
        os.Exit(1)
    }
}

Keep run testable later; avoid business logic in main beyond wiring.

run shape (services)

func run(cfg config.Config, log *slog.Logger) error {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte("ok"))
    })
    mux.HandleFunc("GET /v1/version", func(w http.ResponseWriter, r *http.Request) {
        _ = json.NewEncoder(w).Encode(map[string]string{"version": version})
    })

    srv := &http.Server{Addr: cfg.Addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
    errCh := make(chan error, 1)
    go func() {
        log.Info("listen", "addr", cfg.Addr)
        errCh <- srv.ListenAndServe()
    }()

    select {
    case <-ctx.Done():
        shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        return srv.Shutdown(shCtx)
    case err := <-errCh:
        if errors.Is(err, http.ErrServerClosed) {
            return nil
        }
        return err
    }
}

Full readiness/liveness split can wait—but a clean SIGTERM path early is free insurance.


Theory 2 — Tooling baseline

#!/usr/bin/env bash
# scripts/lint.sh
set -euo pipefail
go test ./...
go vet ./...
command -v staticcheck >/dev/null && staticcheck ./... || echo "staticcheck not installed"
# .gitignore
/bin/
/dist/
*.out
*.pb.gz
.env
*.db
coverage.html

go.mod

go mod init example.com/capstone   # or your VCS path
# ensure:
# go 1.26

Version embedding (CLI and services)

go build -ldflags "-X example.com/capstone/internal/version.Version=dev" -o bin/app ./cmd/...
// internal/version/version.go
package version

var Version = "dev"

Theory 3 — Package boundaries on day one

Do not create fifteen empty packages. Create the minimum that matches CAPSTONE.md:

Need Package
Process entry cmd/...
Config internal/config
Domain types internal/domain (B) or internal/agent (A)
HTTP/CLI surface internal/adapter/http or internal/cli

Ports/interfaces can be one file until a second implementation exists (Day 75/76 wisdom).


Theory 4 — First test exists today

package config_test

import "testing"

func TestLoadRequiresDB(t *testing.T) {
    t.Setenv("DATABASE_URL", "")
    // for option B; adapt for A/C required fields
    _, err := config.Load(nil)
    if err == nil {
        t.Fatal("expected error")
    }
}

CLI variant:

func TestGreet(t *testing.T) {
    if greeter.Greet("Ada") != "hello, Ada" {
        t.Fatal()
    }
}

Agent variant: test that ParseInterval rejects zero.


Lab runbook (all options)

  1. Create module; set go 1.26.
  2. Implement layout for your option.
  3. Runnable spike (health/heartbeat/version).
  4. At least one unit test (even config validate or greeter).
  5. Update CAPSTONE.md checklist for day 83.
  6. Commit with green scripts/lint.sh.

Suggested workspace: ~/lab/90daysofx/01-go/capstone (or repo path in design doc).

Demo to yourself end of day

./scripts/lint.sh
# A/B:
go run ./cmd/...
curl -s localhost:8080/healthz
# C:
go run ./cmd/... version
go run ./cmd/... --help

Makefile optional

.PHONY: test lint run
test:
    go test ./...
lint:
    ./scripts/lint.sh
run:
    go run ./cmd/service

README stub (10 lines)

# Capstone
Option: B
## Dev
go test ./...
go run ./cmd/service
## Config
See CONFIG.md / CAPSTONE.md

Worked example — Option A heartbeat loop

func Loop(ctx context.Context, log *slog.Logger, every time.Duration) {
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case now := <-t.C:
            log.Info("tick", "at", now.UTC().Format(time.RFC3339))
        }
    }
}

Worked example — Option C command router

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: mytool <command>")
        os.Exit(2)
    }
    switch os.Args[1] {
    case "version":
        fmt.Println(version.Version)
    case "help", "--help", "-h":
        fmt.Println("commands: version, init, help")
    case "init":
        if err := cmdInit(os.Args[2:]); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    default:
        fmt.Fprintln(os.Stderr, "unknown command")
        os.Exit(2)
    }
}

Hour-by-hour suggestion

Time Focus
0:00–0:30 Module + dirs from CAPSTONE.md
0:30–1:20 main + config + logger
1:20–2:00 Spike route/command
2:00–2:30 lint script + first test
2:30–3:00 README stub + milestone check

Common gotchas

Gotcha Fix
Building features from day 88 Stop; skeleton only
No module path chosen Use example.com/capstone or your VCS path
Skipping tests “until later” One test today
Diverging from CAPSTONE.md Update doc if plan changed
Copy-paste entire old service blindly Import patterns, not mess
go 1.22 in go.mod by habit Set go 1.26 for this volume
Fifteen empty packages Create on demand
Global flag.Parse in library FlagSet in config.Load

Checkpoint

  • Module builds on Go 1.26
  • Spike runs
  • lint script exists and passes
  • Layout matches design
  • At least one unit test
  • Milestone 83 marked done

Commit

git add .
git commit -m "day83: capstone skeleton and spike"

Write three personal gotchas before continuing.


Tomorrow

Day 84 — Capstone build 2: core domain logic—the primary use case without caring about perfect polish.


Day 84 — Capstone build 2 (core domain)

Stage VIII · ~3h · Capstone day 2/6
Goal: Implement the primary domain capability end-to-end in memory or with a minimal adapter—table-tested, interface-bound, still thin on operability polish. Go 1.26.

Why this day exists

Day 83 built the skeleton. Skeletons do not demo. Today you build the heart:

Option Heart
A Collect and model host/process observations
B Core domain use case (create/get resource, business rules)
C Core command logic (transform, analyze, apply)

UI/HTTP chrome can be incomplete; rules and types must exist. Day 85 will swap in durable adapters—do not block core logic on SQLite polish today.

Note

Vertical slice > horizontal sprawl. One use case complete (domain + port + fake + test + thin adapter) beats five empty DTO files.


Daily goals by option

Option A — Agent core

Goal Detail
Sampler Collect ≥1 signal (process count, load avg, Docker ps if available, or /proc on Linux)
Model Typed structs for samples
Store Ring buffer / in-mem history of last N samples
Test Pure functions parse/format without HTTP
API hook Optional: GET /v1/snapshot returns latest sample JSON
Safety Dangerous actions out (no kill -9 by default)
type Snapshot struct {
    TS         time.Time `json:"ts"`
    NumProcs   int       `json:"num_procs"`
    GoRoutines int       `json:"go_routines"`
    Load1      float64   `json:"load1,omitempty"`
}

type Collector interface {
    Collect(ctx context.Context) (Snapshot, error)
}

type Store interface {
    Append(Snapshot) error
    Latest() (Snapshot, bool)
    LastN(n int) []Snapshot
}

Option B — Microservice core

Goal Detail
Domain types Entities + validation errors
Use case Create + Get (or List) for primary resource
Port Repository interface
Fake repo Unit tests for rules
HTTP Wire create/get even if still in-mem adapter

Example rules: unique names, non-empty fields, state transitions (loan only if available).

func (s CreateBook) Exec(ctx context.Context, in CreateBookInput) (Book, error) {
    title := strings.TrimSpace(in.Title)
    if title == "" {
        return Book{}, domain.ErrInvalid
    }
    b := Book{ID: s.IDs.New(), Title: title, CreatedAt: s.Clock.Now().UTC()}
    if err := s.Books.Save(ctx, b); err != nil {
        return Book{}, err
    }
    return b, nil
}

Option C — CLI core

Goal Detail
Library package ~80% of logic outside main
Primary command Real work (parse file, call API, plan migration, etc.)
Tests Table tests for pure functions
Errors Typed/sentinel errors mapped to exit codes
Dry-run --dry-run if command mutates
type Input struct {
    Paths  []string
    DryRun bool
}

type Plan struct {
    DryRun bool
    Steps  []Step
}

func BuildPlan(in Input) (Plan, error) { /* pure-ish */ }

Theory 1 — Vertical slice over horizontal layers

Prefer:

one use case complete
  domain types
  + port interface
  + fake implementation
  + table tests
  + thin driving adapter (HTTP/CLI)

over:

all DTOs defined, zero behavior
all packages created, empty files

Update CAPSTONE.md milestone 84 only when the slice runs.


Theory 2 — Error model early

Define semantics now even if HTTP mapping is thin:

package domain

import "errors"

var (
    ErrNotFound = errors.New("not found")
    ErrInvalid  = errors.New("invalid")
    ErrConflict = errors.New("conflict")
)

Map at edges later (HTTP status / CLI exit) on Day 86 if needed—but call sites should already use errors.Is.

if errors.Is(err, domain.ErrNotFound) {
    // 404 or exit 2
}

Avoid stringly errors for core paths (errors.New("not found: "+id) as the only type)—wrap if you need detail:

fmt.Errorf("%w: id=%s", domain.ErrNotFound, id)

Theory 3 — Ports for testability

Even with in-memory data, name the port you will implement with SQLite tomorrow:

// Option B
type BookRepository interface {
    Save(ctx context.Context, b Book) error
    Find(ctx context.Context, id string) (Book, error)
}

// Option A
type Collector interface {
    Collect(ctx context.Context) (Snapshot, error)
}

// Option C
type FS interface {
    ReadFile(name string) ([]byte, error)
    // or accept io.Reader at boundaries
}

Day 85 should be an adapter swap, not a rewrite of rules.


Theory 4 — What “done” looks like tonight

Signal Pass
go test ./internal/... Green with happy + ≥2 error paths
Manual invoke One curl or CLI command shows core behavior
CAPSTONE.md Milestone 84 checked; leftovers listed
No new product scope Still matches Day 82 design

Operability (metrics, fancy shutdown) can wait for Day 87—unless skeleton already has health.


Worked example — table test for a use case (B)

func TestCreateBook(t *testing.T) {
    tests := []struct {
        name    string
        title   string
        wantErr error
    }{
        {"ok", "Learning Go", nil},
        {"empty", "  ", domain.ErrInvalid},
        {"conflict", "dup", domain.ErrConflict},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            repo := memBooks{}
            if tt.name == "conflict" {
                _ = repo.Save(context.Background(), Book{ID: "1", Title: "dup"})
            }
            uc := app.CreateBook{Books: repo, IDs: seqID{}, Clock: fixedClock{t: time.Unix(0, 0)}}
            _, err := uc.Exec(context.Background(), app.CreateBookInput{Title: tt.title})
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("got %v want %v", err, tt.wantErr)
            }
        })
    }
}

Option A — pure parse helper

func parseLoadAvg(line string) (float64, error) {
    fields := strings.Fields(line)
    if len(fields) < 1 {
        return 0, fmt.Errorf("%w: bad loadavg", domain.ErrInvalid)
    }
    return strconv.ParseFloat(fields[0], 64)
}

Test with fixture strings so CI never needs /proc:

func TestParseLoadAvg(t *testing.T) {
    v, err := parseLoadAvg("0.52 0.58 0.59 1/234 12345")
    if err != nil || v < 0.5 || v > 0.6 {
        t.Fatalf("%v %v", v, err)
    }
}

Option C — table-driven pure core

func TestBuildPlan(t *testing.T) {
    got, err := BuildPlan(Input{DryRun: true, Paths: []string{"a"}})
    if err != nil || !got.DryRun || len(got.Steps) == 0 {
        t.Fatalf("%+v %v", got, err)
    }
}

Worked example — in-memory ring store (A)

type Ring struct {
    mu   sync.Mutex
    buf  []Snapshot
    size int
    next int
    full bool
}

func NewRing(n int) *Ring {
    return &Ring{buf: make([]Snapshot, n), size: n}
}

func (r *Ring) Append(s Snapshot) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.buf[r.next] = s
    r.next = (r.next + 1) % r.size
    if r.next == 0 {
        r.full = true
    }
    return nil
}

Unit-test append/latest without starting the agent loop.


Lab 1 — Domain types + errors

  1. Open CAPSTONE.md; re-read primary user story.
  2. Implement domain entities and sentinels under internal/domain (or option-appropriate package).
  3. Reject empty/invalid inputs at the use-case boundary.
  4. go build ./...

Lab 2 — Primary use case / collector / plan

  1. Implement the single most important behavior.
  2. Keep HTTP/CLI thin—call into app / library.
  3. Use context.Context on anything that may later block (I/O).
  4. No unbounded goroutine loops without cancel (agent ticker must observe ctx).
// agent loop sketch
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-ticker.C:
        snap, err := collector.Collect(ctx)
        // append / log
        _ = snap
        _ = err
    }
}

Lab 3 — Fake + table tests

  1. Fake the driven port (mem repo / mock collector / buffer FS).
  2. Cover happy path + invalid + not found (or conflict).
  3. Ensure tests do not require root, Docker, or network unless tagged.
go test ./internal/... -count=1

Lab 4 — Thin adapter spike

Option Spike
A GET /v1/snapshot or log line on tick
B POST/GET /v1/<resource> against mem repo
C mytool plan --dry-run prints steps
# examples — adapt to your binary
curl -s localhost:8080/v1/snapshot | jq .
go run ./cmd/service &
# or
go run ./cmd/mytool plan --dry-run ./testdata

Lab 5 — CAPSTONE.md update

## Milestone 84 — core domain
- [x] Primary behavior implemented
- [x] Tests: happy + 2 errors
- [x] Port named for Day 85 adapter
- Leftovers: ...

List explicitly what Day 85 must replace (mem → SQLite, mock collector → /proc, etc.).


Hour-by-hour suggestion

Time Focus
0:00–0:30 Domain types + errors
0:30–1:30 Primary use case / collector / plan
1:30–2:20 Fake + table tests
2:20–2:50 Thin adapter to prove manually
2:50–3:00 CAPSTONE.md update

Common gotchas

Gotcha Fix
HTTP-only logic Move to internal/app
No tests because “DB later” Fake repository
Scope explosion (5 entities) One entity done well
Agent that needs root/host mounts Document; mock collector in tests
CLI calling network without timeout context.WithTimeout
Unexported everything untestable Export funcs or test from same package
Business rules in SQL strings Rules in app; SQL only persists
Skipping error sentinels Define ErrInvalid / ErrNotFound today
Feature creep from Day 82 non-goals Re-read design non-goals

Checkpoint

  • Primary domain behavior implemented
  • Table/unit tests green (happy + ≥2 errors)
  • Port/interface for driven dependency
  • Manual demo path works
  • CAPSTONE milestone 84 done
  • Day 85 leftovers listed
  • go test ./... on Go 1.26

Commit

git add .
git commit -m "day84: capstone core domain"

Write three personal gotchas before continuing.


Tomorrow

Day 85 — Capstone build 3: persistence and real adapters—SQLite/files/APIs that survive restart. Keep today’s tests green by swapping implementations behind ports.


Day 85 — Capstone build 3 (persistence & adapters)

Stage VIII · ~3h · Capstone day 3/6
Goal: Replace in-memory stubs with durable adapters—database, files, or external APIs—plus migrations or schema bootstrap, without breaking Day 84 domain tests. Go 1.26.

Why this day exists

Demos that lose state on restart feel fake. Driven adapters prove hexagonal wiring (Day 76) under real I/O. Today is adapter day, not rewrite-the-domain day: ports stay stable; implementations get real.

Note

Keep Day 84 fake-based unit tests green. Add integration tests beside them—do not delete fakes “because SQL exists now.”


Daily goals by option

Option A — Agent

Goal Detail
Durability Append samples to SQLite or JSONL file
Collector real OS-backed collector; build tags if needed (//go:build linux)
Fallback Mock collector for tests/CI
Config Paths, sample interval, retention N
Safety Read-only observation default

JSONL is acceptable:

func (s *FileStore) Append(snap Snapshot) error {
    f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
    if err != nil {
        return err
    }
    defer f.Close()
    return json.NewEncoder(f).Encode(snap)
}

Option B — Microservice

Goal Detail
Schema Migration SQL or embed bootstrap
Repository SQLite or Postgres implementing port
Context All queries *Context
Pool SetMaxOpenConns sensible defaults
Test Integration with :memory: or temp file DB
//go:embed schema.sql
var schema string

func Migrate(ctx context.Context, db *sql.DB) error {
    _, err := db.ExecContext(ctx, schema)
    return err
}

Option C — CLI

Goal Detail
Workspace Config/project file on disk (./.mytool.yaml or XDG)
State Local DB or file store for indexes/cache
Idempotent writes Safe re-run
Tests t.TempDir() fixtures
Migration Version field in state file

Theory 1 — Adapter testing strategy

Layer Test
Domain/app Fake ports (Day 84)—fast, always on
SQL adapter Integration with real driver
Files Temp dirs (t.TempDir())
func TestUserRepo_SQLite(t *testing.T) {
    db, err := sql.Open("sqlite", ":memory:")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = db.Close() })
    if err := Migrate(context.Background(), db); err != nil {
        t.Fatal(err)
    }
    repo := NewUserRepo(db)
    // Save + FindByID assertions
}

Pure-Go SQLite drivers ease CGO_ENABLED=0 containers (Day 68/88).


Theory 2 — Failure modes to handle

Failure Map to
sql.ErrNoRows domain.ErrNotFound
Unique constraint domain.ErrConflict
Context deadline return ctx.Err() / wrap
Disk permission surface as internal; log path
Partial multi-row write transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()
// multiple ExecContext on tx ...
return tx.Commit()

Always use placeholders—Day 81 rule still applies when you write SQL by hand today.


Theory 3 — Pool and DSN hygiene

db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
Do Don’t
DSN from env Commit passwords
File SQLite for restart demos :memory: as only gate path
Ping on startup before ready Mark ready before migrate
# example SQLite DSN
file:capstone.db?_pragma=foreign_keys(1)

Theory 4 — Build tags for OS-specific collectors (A)

// collector_linux.go
//go:build linux

package agent

func readLoadAvg() (float64, error) { /* /proc/loadavg */ }
// collector_stub.go
//go:build !linux

package agent

func readLoadAvg() (float64, error) {
    return 0, errUnsupported
}

CI on macOS can still unit-test parse helpers with fixtures (Day 84).


Worked example — SQLite repository (option B)

package sqlite

type UserRepo struct{ db *sql.DB }

func NewUserRepo(db *sql.DB) *UserRepo { return &UserRepo{db: db} }

func (r *UserRepo) Save(ctx context.Context, u domain.User) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO users (id, name, created_at) VALUES (?, ?, ?)`,
        u.ID, u.Name, u.CreatedAt.UTC().Format(time.RFC3339Nano),
    )
    // map unique violations → domain.ErrConflict for your driver
    return err
}

func (r *UserRepo) FindByID(ctx context.Context, id string) (domain.User, error) {
    var u domain.User
    var created string
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name, created_at FROM users WHERE id = ?`, id,
    ).Scan(&u.ID, &u.Name, &created)
    if errors.Is(err, sql.ErrNoRows) {
        return domain.User{}, domain.ErrNotFound
    }
    if err != nil {
        return domain.User{}, err
    }
    t, err := time.Parse(time.RFC3339Nano, created)
    if err != nil {
        return domain.User{}, err
    }
    u.CreatedAt = t
    return u, nil
}

Wire in main only:

db := mustOpen(cfg.DatabaseURL)
if err := sqlite.Migrate(ctx, db); err != nil {
    log.Fatal(err)
}
repo := sqlite.NewUserRepo(db)
uc := app.CreateUser{Users: repo, IDs: ids, Clock: clock}

Worked example — CLI state file (option C)

type State struct {
    Version int               `json:"version"`
    Entries map[string]string `json:"entries"`
}

func LoadState(path string) (State, error) {
    b, err := os.ReadFile(path)
    if errors.Is(err, os.ErrNotExist) {
        return State{Version: 1, Entries: map[string]string{}}, nil
    }
    // unmarshal ...
}

func SaveState(path string, st State) error {
    tmp := path + ".tmp"
    // write tmp, fsync, rename — idempotent-ish
}

Tests use filepath.Join(t.TempDir(), "state.json").


Lab 1 — Schema / bootstrap path

  1. Write schema SQL or state file version.
  2. Automate apply on startup or migrate / init command.
  3. Document in README.
  4. Fail fast if migrate fails (do not serve ready).

Lab 2 — Implement durable adapter

  1. Implement port methods with Context.
  2. Map driver errors to domain sentinels.
  3. Keep domain free of database/sql imports.
  4. go test ./internal/app still passes with fakes.

Lab 3 — Integration test

  1. SQLite :memory: or temp file.
  2. Or t.TempDir() for file/JSONL adapters.
  3. Round-trip Save/Find or Append/Latest.
  4. Optional conflict test.
go test ./internal/adapter/... -count=1
go test ./... -count=1

Lab 4 — Restart persistence proof

# B example
curl -sf -X POST localhost:8080/v1/books -H 'Content-Type: application/json' \
  -d '{"title":"Go"}' | tee /tmp/created.json
# kill -TERM service; start again with same DSN
curl -sf localhost:8080/v1/books/$(jq -r .id /tmp/created.json)
Option Proof
A Samples present after restart (file/SQLite)
B Entity GET after restart
C State file survives process exit

Record commands in CAPSTONE.md or PERSISTENCE.md.


Lab 5 — Composition root + CAPSTONE

  1. Wire concrete adapter only in cmd/*/main.go.
  2. Config: DSN/path from env/flags.
  3. Check milestone 85; list Day 86 surface leftovers.
## Milestone 85 — persistence
- [x] Durable adapter
- [x] Migrate/bootstrap
- [x] Restart proof
- Leftovers: ...

Hour-by-hour suggestion

Time Focus
0:00–0:20 Schema + migrate path
0:20–1:20 Repository / file methods + error mapping
1:20–2:00 Integration test
2:00–2:40 Wire composition root; restart proof
2:40–3:00 CAPSTONE.md; lint

Common gotchas

Gotcha Fix
CGO sqlite breaks distroless Pure Go driver or glibc base
Migrations only in README Automate on boot or migrate cmd
Domain imports database/sql Keep in adapter
Leaking open files defer Close; integration test
Absolute paths in tests t.TempDir()
Timezones in DB Store UTC; document format
Forgetting indexes Add PK/unique needed for conflicts
Replacing fakes entirely Keep unit tests on fakes
:memory: only File DSN for restart demo

Checkpoint

  • Durable adapter implemented
  • Schema/migration path exists
  • Restart proof documented
  • Integration or file test green
  • Day 84 unit tests still green
  • Milestone 85 done
  • Go 1.26 go test ./... pass

Commit

git add .
git commit -m "day85: capstone persistence adapters"

Write three personal gotchas before continuing.


Tomorrow

Day 86 — Capstone build 4: finish the external surface—HTTP API completeness or CLI UX polish users touch—mapped onto today’s durable ports.


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.


Day 87 — Capstone build 5 (observability & ops)

Stage VIII · ~3h · Capstone day 5/6
Goal: Wire production hooks into the capstone: structured logs, metrics (and optional traces), pprof guardrails, graceful shutdown, and config completeness—so Days 88–89 are polish and evidence, not first-time ops. Go 1.26.

Why this day exists

Capstones fail demos when:

  • Nobody can see what the process is doing
  • Deploy kill drops in-flight work messily
  • Metrics missing for any load story
  • Config knobs exist only in the author’s head

You already practiced these skills in Stage VII (Days 65–70, 78)—apply them to the capstone binary. Day 88 packages; Day 89 hardens. Today makes the process operable.


Daily goals by option

Shared (A/B services; C where applicable)

Hook Target
slog JSON default for service logs
/metrics Prometheus counters/histograms on core path
Graceful shutdown SIGTERM drain (A/B)
Health live + ready (A/B)
Config all knobs documented in README table
pprof localhost-only debug server or build tag

Option A extras

  • Metric agent_samples_total
  • Gauge agent_last_sample_unixtime
  • Counter agent_collect_errors_total
  • Log each sample at debug; info on errors

Option B extras

  • RED metrics on HTTP (rate, errors, duration)
  • DB pool metrics optional
  • OTel root span on one write path (optional light, Day 70)

Option C extras

  • --verbose / log level
  • Command timing via log fields (dur_ms)
  • version includes commit via ldflags
  • Optional: emit JSON logs for scripting

Theory 1 — Minimum metrics set

var (
    requests = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "app_requests_total",
        Help: "HTTP requests partitioned by route template and code",
    }, []string{"route", "code"})

    inFlight = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "app_in_flight_requests",
        Help: "In-flight HTTP requests",
    })

    reqDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "app_request_duration_seconds",
        Help:    "Request latency",
        Buckets: prometheus.DefBuckets,
    }, []string{"route"})
)

CLI substitute: structured log line event=cmd_done dur_ms=... err=....

Cardinality rule

Label with route patterns (/v1/items/:id), not raw URLs with IDs. High-cardinality labels will melt Prometheus—and your demo laptop.


Theory 2 — slog defaults

func newLogger(level string) *slog.Logger {
    var lv slog.Level
    _ = lv.UnmarshalText([]byte(level))
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lv})
    return slog.New(h)
}
Do Don’t
Log route, status, dur_ms Log Authorization headers
Log error strings server-side Dump stack traces to clients
Use slog.Error for failures fmt.Println in hot paths

Redact config when printing startup summary (DSN → postgres://***).


Theory 3 — Shutdown order (services)

1. signal received (SIGINT/SIGTERM)
2. readiness = false   // fail k8s ready probe
3. http.Server.Shutdown(ctx)
4. stop background loops (agent ticker)
5. otel provider Shutdown if any
6. db.Close()
7. process exit 0
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Error("serve", "err", err)
        stop()
    }
}()

<-ctx.Done()
log.Info("shutting down")
ready.Store(false)

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Error("shutdown", "err", err)
}
// stop agent, close db

Agent loop

func (a *Agent) Run(ctx context.Context) error {
    t := time.NewTicker(a.Interval)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            a.Log.Info("agent stop")
            return nil
        case <-t.C:
            cctx, cancel := context.WithTimeout(ctx, a.CollectTimeout)
            snap, err := a.Collector.Collect(cctx)
            cancel()
            if err != nil {
                a.Log.Error("collect", "err", err)
                collectErrs.Inc()
                continue
            }
            samples.Inc()
            lastSample.Set(float64(snap.TS.Unix()))
            _ = a.Store.Append(snap)
        }
    }
}

Theory 4 — Health vs readiness

Probe Meaning Fail when
Live (/healthz) Process up Never (or only total deadlock)
Ready (/readyz) Accept traffic DB down, still migrating, shutting down
func (h *Handler) Ready(w http.ResponseWriter, r *http.Request) {
    if !h.ReadyFlag.Load() {
        http.Error(w, "not ready", http.StatusServiceUnavailable)
        return
    }
    if err := h.DB.PingContext(r.Context()); err != nil {
        http.Error(w, "db", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(`{"status":"ready"}`))
}

Set ready true only after migrations succeed.


Theory 5 — pprof guardrails

// separate server — never mount on public :8080 without auth
go func() {
    addr := "127.0.0.1:6060"
    mux := http.NewServeMux()
    mux.HandleFunc("/debug/pprof/", pprof.Index)
    mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
    mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
    mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
    mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
    log.Info("pprof", "addr", addr)
    _ = http.ListenAndServe(addr, mux)
}()

Or gate with build tag //go:build debug. Document in OPS.md.


Theory 6 — Feature flag hook (optional)

If Day 79 patterns help demo a progressive path, gate one non-critical behavior. Do not introduce flag debt on the primary path unless designed in Day 82.

if cfg.Flags.EnableExperimentalList {
    // optional route
}

Worked example — slog + metrics middleware (A/B)

type statusWriter struct {
    http.ResponseWriter
    code int
}

func (w *statusWriter) WriteHeader(code int) {
    w.code = code
    w.ResponseWriter.WriteHeader(code)
}

func withOps(log *slog.Logger, route string, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        inFlight.Inc()
        defer inFlight.Dec()
        sw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
        next(sw, r)
        code := strconv.Itoa(sw.code)
        requests.WithLabelValues(route, code).Inc()
        reqDuration.WithLabelValues(route).Observe(time.Since(start).Seconds())
        log.Info("request",
            "route", route,
            "method", r.Method,
            "code", sw.code,
            "dur_ms", time.Since(start).Milliseconds(),
        )
    }
}

Register metrics handler:

mux.Handle("GET /metrics", promhttp.Handler())

CLI structured completion

start := time.Now()
err := runApply(ctx, cfg)
log.Info("cmd_done",
    "cmd", "apply",
    "dur_ms", time.Since(start).Milliseconds(),
    "err", errString(err),
)
if err != nil {
    os.Exit(mapExit(err))
}

ldflags version (C and services)

go build -ldflags "-X example.com/app/internal/version.Commit=$(git rev-parse --short HEAD)" -o bin/app ./cmd/app

Lab 1 — Logging + config completeness

  1. JSON slog as default for services (CLI: text or JSON via flag).
  2. Log level from config/env.
  3. On startup, log non-secret config summary.
  4. Fill README env table: ADDR, DSN, LOG_LEVEL, intervals, etc.
| Variable | Default | Meaning |
|----------|---------|---------|
| ADDR | :8080 | HTTP listen |
| LOG_LEVEL | info | slog level |
| DSN | | database |

Lab 2 — Metrics or CLI telemetry

  1. Services: /metrics with at least request counter + one domain metric.
  2. CLI: structured cmd_done lines on primary commands.
  3. Hit smoke traffic; confirm metrics non-empty.
curl -s localhost:8080/metrics | head
# after traffic:
curl -s localhost:8080/metrics | grep app_requests_total

Lab 3 — Shutdown + probes (services)

  1. Implement SIGINT/SIGTERM shutdown order.
  2. Live + ready endpoints.
  3. Prove ready fails during shutdown or before DB.
  4. kill -TERM $PID → clean exit within timeout.
./bin/service &
PID=$!
curl -sf localhost:8080/healthz
curl -sf localhost:8080/readyz
kill -TERM $PID
wait $PID

Lab 4 — Write OPS.md runbook

# Ops runbook — capstone

## Start
...

## Probes
curl -sf localhost:8080/healthz
curl -sf localhost:8080/readyz

## Metrics
curl -s localhost:8080/metrics | head

## pprof (local only)
curl -o cpu.pb.gz http://127.0.0.1:6060/debug/pprof/cpu?seconds=5

## Stop
kill -TERM <pid>

Keep it short—10–30 lines. Day 88 demo script will call these paths.


Lab 5 — CAPSTONE milestone + residual gaps

## Milestone 87 — observability & ops
- [x] slog JSON
- [x] metrics or CLI telemetry
- [x] shutdown + probes (if service)
- [x] OPS.md
- Leftovers for 88/89: ...

List anything still missing (tracing, histograms, rate limit) without starting them unless critical for demo.


Hour-by-hour suggestion

Time Focus
0:00–0:40 slog handler + config log level
0:40–1:30 metrics or CLI telemetry
1:30–2:20 shutdown + readiness (services)
2:20–2:50 OPS.md + smoke metrics
2:50–3:00 CAPSTONE milestone check

Common gotchas

Gotcha Fix
Metrics without load Hit smoke script first
Ready=true before DB migrate Order startup
Agent goroutine leak cancel context on shutdown
Logging secrets redact config
pprof on :8080 public separate bind 127.0.0.1:6060
Cardinality on routes use patterns not raw URLs
Double-register Prometheus promauto once at package init
Shutdown without timeout context.WithTimeout
CLI still using log.Printf only add structured fields for demo
Histogram buckets unconsidered DefBuckets OK for lab

Checkpoint

  • slog (or equivalent) configured
  • Metrics or structured command telemetry
  • Shutdown/probes for services
  • pprof exposure documented and safe
  • README env table complete
  • OPS.md written
  • Milestone 87 done
  • go test ./... still green

Commit

git add .
git commit -m "day87: capstone observability ops hooks"

Write three personal gotchas before continuing.


Tomorrow

Day 88 — Capstone build 6: integration polish, Docker/release artifacts, and a repeatable demo script that walks the story without improvisation—using today’s ops hooks as proof points.


Day 88 — Capstone build 6 (polish & demo)

Stage VIII · ~3h · Capstone day 6/6
Goal: Finish integration polish—Docker or release artifacts, README that runs cold, and a demo.sh (or documented script) that walks the full story without improvisation.

Important

Day 89 is evidence. Day 90 is retrospective. Today is the last “feature and packaging” day. If the demo is flaky, fix script and UX, not new scope.

Why this day exists

Six build days end here. The deliverable is not “more code”—it is a repeatable story a cold reader can run. Unscripted demos fail under nerves; demo.sh is your insurance for Day 89–90.


Daily goals by option

Option A — Agent

Deliverable Detail
Image Distroless multi-stage Dockerfile
Compose optional Single service compose for local
demo.sh build → run → snapshot → metrics → SIGTERM
README Host permissions if any (docker socket, etc.)
Safety Control API documented as dangerous/local

Option B — Microservice

Deliverable Detail
Image Distroless + health
migrate Automatic or migrate command in demo
demo.sh full user story curls
Seed data optional deterministic demo IDs
OpenAPI-lite route table in README

Option C — CLI

Deliverable Detail
scripts/build-release.sh multi GOOS/GOARCH
checksums optional sha256sum list
demo.sh init → dry-run → apply → status
Install docs go install path
Fixtures sample input files in testdata/

Theory 1 — Demo script design

Properties of a good demo.sh:

  1. Idempotent enough to re-run (or deletes temp dir first)
  2. Fails loud (set -euo pipefail)
  3. No secrets required beyond .env.example
  4. Prints what a viewer should notice
  5. Completes in <3 minutes
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$ROOT"

echo "== build =="
go build -o bin/app ./cmd/service

echo "== run =="
export DATABASE_URL="${DATABASE_URL:-sqlite:file:demo.db?cache=shared}"
export ADDR="${ADDR:-127.0.0.1:8080}"
./bin/app &
PID=$!
trap 'kill -TERM $PID 2>/dev/null || true' EXIT

wait_http "http://${ADDR#http://}/healthz"

echo "== smoke =="
curl -sf "http://$ADDR/healthz"
curl -sf -X POST "http://$ADDR/v1/items" \
  -d '{"name":"demo"}' -H 'Content-Type: application/json'
curl -sf "http://$ADDR/v1/items" | tee /tmp/demo-items.json

echo "== metrics =="
curl -sf "http://$ADDR/metrics" | head -n 5

echo "OK demo finished"

Adapt ruthlessly to your option. Put demo.sh at repo root or scripts/demo.sh and document the path.


Theory 2 — Wait-for-port helpers

Race-before-listen is the #1 flaky demo cause:

wait_http() {
  local url=$1
  for i in $(seq 1 50); do
    if curl -sf "$url" >/dev/null; then
      return 0
    fi
    sleep 0.1
  done
  echo "timeout waiting for $url" >&2
  return 1
}

For CLIs, wait is usually unnecessary—build then run commands synchronously.


Theory 3 — README cold-start test

Hand the repo path to “past you”:

1. Prerequisites (Go 1.26, Docker?)
2. go test ./...
3. how to run
4. demo.sh
5. config table
6. architecture paragraph

If any step is only in your head, write it down today.

README skeleton

# Capstone name
One sentence.

## Requirements
- Go 1.26.x
- Docker (optional)

## Quick start
```bash
go test ./...
./demo.sh

Configuration

Env Default Meaning

Architecture

(short paragraph + package list)

Safety (Option A)

What the agent will not do by default.


---

## Theory 4 — Freeze scope

Allowed today:

- Bug fixes  
- UX clarity  
- Packaging  
- Demo reliability  

Not allowed:

- New entity types  
- Extra flag systems  
- Rewrites of working architecture  

Park ideas under `CAPSTONE.md` → Future.

```markdown
## Future (not day 88)
- multi-tenant
- gRPC twin

Theory 5 — Packaging checklists

Dockerfile checklist (A/B)

# - CGO_ENABLED=0
# - nonroot USER
# - EXPOSE documented port
# - ENTRYPOINT exec form
# - multi-stage; no toolchain in final
docker build -t capstone:rc1 .
docker run --rm -p 8080:8080 capstone:rc1
docker images capstone:rc1 --format '{{.Repository}}:{{.Tag}} {{.Size}}'

Release proof (C)

#!/usr/bin/env bash
# scripts/build-release.sh
set -euo pipefail
mkdir -p dist
for pair in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
  GOOS=${pair%/*} GOARCH=${pair#*/}
  out="dist/mytool_${GOOS}_${GOARCH}"
  CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "$out" ./cmd/mytool
  file "$out"
done

Lab runbook

  1. Write/repair demo.sh until three consecutive passes.
  2. Dockerfile or release script per option.
  3. README cold-start section.
  4. Fix bugs found by demo only.
  5. Tag mental RC1; list residual risks for Day 89.
./demo.sh && ./demo.sh && ./demo.sh

Residual risks list

## RC1 residual risks
- load test only local
- no mTLS
- SQLite not Postgres

Day 89 will not fix product ambition—only quality evidence.


Worked example — Option C demo skeleton

#!/usr/bin/env bash
set -euo pipefail
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
go build -o "$TMP/tool" ./cmd/mytool
"$TMP/tool" init --path "$TMP/work"
"$TMP/tool" apply --path "$TMP/work" --dry-run
"$TMP/tool" apply --path "$TMP/work"
"$TMP/tool" status --path "$TMP/work"
echo OK

Worked example — SIGTERM in demo (A/B)

kill -TERM "$PID"
wait "$PID" || true
echo "process exited cleanly"
trap - EXIT

Hour-by-hour suggestion

Time Focus
0:00–0:50 demo.sh reliability (3 passes)
0:50–1:40 Docker or release script
1:40–2:20 README cold start
2:20–2:50 Bugfixes only
2:50–3:00 Freeze scope; residual risks list

Common gotchas

Gotcha Fix
demo relies on preexisting DB state migrate/seed in script
Port already in use configurable ADDR; document
Race before server listens retry loop in demo.sh
Docker needs env not passed -e flags in demo
README drift run demo while editing README
Demo needs GUI browser prefer curl-only script
Scope creep “one more endpoint” Future list
demo.sh not executable chmod +x

Checkpoint

  • demo.sh passes 3×
  • Container or multi-arch binaries
  • README cold start works
  • Scope frozen
  • Residual risks listed
  • Milestone 88 done

Commit

git add .
git commit -m "day88: capstone polish demo packaging"

Write three personal gotchas before continuing.


Tomorrow

Day 89 — Harden & evidence pack: tests, govulncheck, profile/load notes, security re-check—artifacts for the final demo.