Day 29 — Capstone Build: Core to Polish
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.
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.26Version 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)
- Create module; set
go 1.26.
- Implement layout for your option.
- Runnable spike (health/heartbeat/version).
- At least one unit test (even config validate or greeter).
- Update
CAPSTONE.mdchecklist for day 83.
- 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/... --helpMakefile 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.mdWorked 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.
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
- Open
CAPSTONE.md; re-read primary user story.
- Implement domain entities and sentinels under
internal/domain(or option-appropriate package).
- Reject empty/invalid inputs at the use-case boundary.
go build ./...
Lab 2 — Primary use case / collector / plan
- Implement the single most important behavior.
- Keep HTTP/CLI thin—call into
app/ library.
- Use
context.Contexton anything that may later block (I/O).
- 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
- Fake the driven port (mem repo / mock collector / buffer FS).
- Cover happy path + invalid + not found (or conflict).
- Ensure tests do not require root, Docker, or network unless tagged.
go test ./internal/... -count=1Lab 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 ./testdataLab 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.
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)
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
- Write schema SQL or state file version.
- Automate apply on startup or
migrate/initcommand.
- Document in README.
- Fail fast if migrate fails (do not serve ready).
Lab 2 — Implement durable adapter
- Implement port methods with
Context.
- Map driver errors to domain sentinels.
- Keep domain free of
database/sqlimports.
go test ./internal/appstill passes with fakes.
Lab 3 — Integration test
- SQLite
:memory:or temp file.
- Or
t.TempDir()for file/JSONL adapters.
- Round-trip Save/Find or Append/Latest.
- Optional conflict test.
go test ./internal/adapter/... -count=1
go test ./... -count=1Lab 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
- Wire concrete adapter only in
cmd/*/main.go.
- Config: DSN/path from env/flags.
- 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 versionTheory 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 planWorked 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
- Open
CAPSTONE.mdsurface section.
- List each route/command: done / missing / cut.
- Implement missing pieces in priority of demo path.
- Do not add unplanned resources.
Lab 2 — Error mapping + validation
- Centralize
writeErr/ CLI exit mapping.
- Invalid JSON → 400.
- Empty required fields → 400.
- Not found → 404.
- httptest or CLI test for success + one error.
Lab 3 — Auth or explicit cut
- If design requires auth: protect writes; test 401.
- If cutting: update design + README risk note.
- 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 OKCLI:
./bin/mytool version
./bin/mytool apply --dry-run
./bin/mytool apply
./bin/mytool statusDay 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, polishHour-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
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)
versionincludes 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 dbAgent 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/appLab 1 — Logging + config completeness
- JSON
slogas default for services (CLI: text or JSON via flag).
- Log level from config/env.
- On startup, log non-secret config summary.
- 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
- Services:
/metricswith at least request counter + one domain metric.
- CLI: structured
cmd_donelines on primary commands.
- Hit smoke traffic; confirm metrics non-empty.
curl -s localhost:8080/metrics | head
# after traffic:
curl -s localhost:8080/metrics | grep app_requests_totalLab 3 — Shutdown + probes (services)
- Implement SIGINT/SIGTERM shutdown order.
- Live + ready endpoints.
- Prove ready fails during shutdown or before DB.
kill -TERM $PID→ clean exit within timeout.
./bin/service &
PID=$!
curl -sf localhost:8080/healthz
curl -sf localhost:8080/readyz
kill -TERM $PID
wait $PIDLab 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.mdwritten
- 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.
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:
- Idempotent enough to re-run (or deletes temp dir first)
- Fails loud (
set -euo pipefail)
- No secrets required beyond
.env.example
- Prints what a viewer should notice
- 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.shConfiguration
| 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 finaldocker 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"
doneLab runbook
- Write/repair
demo.shuntil three consecutive passes.
- Dockerfile or release script per option.
- README cold-start section.
- Fix bugs found by demo only.
- Tag mental RC1; list residual risks for Day 89.
./demo.sh && ./demo.sh && ./demo.shResidual risks list
## RC1 residual risks
- load test only local
- no mTLS
- SQLite not PostgresDay 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 OKWorked example — SIGTERM in demo (A/B)
kill -TERM "$PID"
wait "$PID" || true
echo "process exited cleanly"
trap - EXITHour-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.shpasses 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.