Installs into .claude/skills of the current project.
Are you the author of Loom Golang?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-golang)
---
name: loom-golang
description: "Go language expertise for idiomatic, production-quality code. Not for: an LLM's context window or a stage's context budget."
triggers:
- go
- golang
- goroutine
- channel
- interface
- struct
- pointer
- slice
- map
- defer
- go context
- error
- gin
- echo
- fiber
- cobra
- viper
- gorm
- sqlx
- go mod
- go test
- effective go
- errgroup
- sync
- mutex
- waitgroup
- race detector
---
# Go Language Expertise
## Overview
Idiomatic, production-grade Go: concurrency, error handling, interfaces, testing, and version-gated behavior. The Foundations below get code working; the **Expert Practices** section is the higher bar — each item states the *mechanism* so you can apply it beyond the case shown.
## Foundations
### Error Handling
```go
// Sentinel errors for identity checks; custom types for structured context.
var ErrNotFound = errors.New("resource not found")
type ValidationError struct{ Field, Message string }
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation on %s: %s", e.Field, e.Message)
}
func fetchUser(id string) (*User, error) {
u, err := db.GetUser(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound) // wrap w/ %w
}
return nil, fmt.Errorf("fetching user %s: %w", id, err)
}
return u, nil
}
// errors.Is matches by identity down the %w chain; errors.As extracts a type.
var ve *ValidationError
if errors.As(err, &ve) { /* ve.Field, ve.Message */ }
```
`%w` vs `%v` is an API decision — see Expert Practices.
### Concurrency
```go
// Worker pool. Go 1.25+: wg.Go does Add(1)+launch+Done atomically (see Expert).
func workerPool(jobs <-chan Job, results chan<- Result, n int) {
var wg sync.WaitGroup
for range n {
wg.Go(func() { // pre-1.25: wg.Add(1); go func(){ defer wg.Done(); ... }()
for job := range jobs {
results <- process(job)
}
})
}
wg.Wait()
close(results) // sender closes, exactly once; never the receiver
}
// Context timeout — always defer cancel() even when the deadline fires.
func fetch(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body) // drain AND close — see Expert (conn reuse)
}
// select with nil-channel disable: a nil channel blocks forever, so setting a
// drained channel to nil removes it from the select without a sentinel flag.
select {
case v, ok := <-ch:
if !ok { ch = nil; continue }
use(v)
case <-ctx.Done():
return ctx.Err()
}
// RWMutex-guarded state. Pointer receiver is mandatory (copying a Mutex breaks it).
type SafeCounter struct {
mu sync.RWMutex
count map[string]int
}
func (c *SafeCounter) Inc(k string) { c.mu.Lock(); defer c.mu.Unlock(); c.count[k]++ }
func (c *SafeCounter) Get(k string) int { c.mu.RLock(); defer c.mu.RUnlock(); return c.count[k] }
```
**Bounded concurrency with `errgroup`** (preferred over hand-rolled fan-out): first non-nil error cancels the group's context and is returned by `Wait`; `g.SetLimit(n)` caps concurrent goroutines.
```go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
results := make([]Result, len(urls))
for i, url := range urls { // Go 1.22+: no `i, url := i, url` needed
g.Go(func() error {
r, err := fetchURL(ctx, url)
results[i] = r // distinct index per goroutine => no lock needed
return err
})
}
if err := g.Wait(); err != nil { return nil, err }
```
**Mutex vs channel:** use a `Mutex` to guard simple shared state (counters, caches, maps); use a channel to transfer *ownership* of data or coordinate goroutine lifecycles/pipelines. Don't reach for a channel where a mutex is plainly simpler.
### Interfaces & Embedding
```go
// Small interfaces; accept interfaces, return concrete types (see Expert).
type UserGetter interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// Embedding for composition (promotes Base's fields/methods onto User).
type Base struct{ ID string; CreatedAt, UpdatedAt time.Time }
type User struct {
Base
Email, Name string
}
```
### Functional Options
Variadic `Option func(*T)` closures over a zero-value-defaulted struct — the idiomatic way to give a constructor optional, backward-compatible parameters.
```go
type Server struct{ host string; port int; timeout time.Duration }
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func NewServer(opts ...Option) *Server {
s := &Server{host: "localhost", port: 8080, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}
// NewServer(WithPort(9000))
```
### HTTP Servers
```go
srv := &http.Server{
Addr: addr, Handler: mux,
ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second,
}
// ListenAndServe returns ErrServerClosed on graceful Shutdown — treat as non-error.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { ... }
// Go 1.22+ method+wildcard routing in the stdlib mux — no framework needed for basics.
mux.HandleFunc("GET /api/users/{id}", h.GetUser) // id := r.PathValue("id")
mux.HandleFunc("POST /api/users", h.CreateUser)
// Recovery middleware — a panic in a handler otherwise kills the whole server.
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic", slog.Any("err", err), slog.String("path", r.URL.Path))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
```
Frameworks (`gin`, `echo`, `fiber`) add routing/binding sugar over this; the timeout, graceful-shutdown, and recovery concerns are identical.
### Database Access (sqlx)
```go
type User struct {
ID string `db:"id"`
Email string `db:"email"`
}
func (r *Repo) GetByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.GetContext(ctx, &u, `SELECT id, email FROM users WHERE id=$1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
// Transaction helper: rollback on error (report both errors), commit on success.
func (r *Repo) WithTx(ctx context.Context, fn func(*sqlx.Tx) error) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
if rb := tx.Rollback(); rb != nil {
return fmt.Errorf("rollback: %v (original: %w)", rb, err)
}
return err
}
return tx.Commit()
}
```
Always use the `...Context` variants (`GetContext`, `SelectContext`, `NamedExecContext`) so queries honor cancellation.
### Structured Logging (slog)
```go
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger.Info("request", slog.String("method", r.Method), slog.Duration("latency", time.Since(start)))
logger = logger.With(slog.String("request_id", rid)) // derive a scoped child logger
```
Log errors with `slog.Any("err", err)`, never `slog.String(..., err.Error())` — see Expert. Hot paths: `slog.LogAttrs` avoids allocation.
### Testing
```go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"mixed", -1, 5, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // subtests run concurrently; Go 1.22+ needs no `tt := tt`
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d,%d)=%d want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
```
Run `go test -race ./...` in CI — the race detector (~10x slowdown) is the primary tool for the memory-model gotchas below, but only reports races actually *exercised* at runtime, so races need test coverage to surface. In `go < 1.22` modules the `tt := tt` capture workaround is required; `go fix ./...` removes it after a 1.22 bump.
### Benchmarks
```go
// Go 1.24+: for b.Loop() — setup runs once per -count (not b.N times), and the
// runtime keeps params/results alive so the compiler can't elide the body.
func BenchmarkSort(b *testing.B) {
data := generateData(1000) // runs once
for b.Loop() {
Sort(data)
}
}
```
## Loom Test Runner Adapter
**Adapter.** `go-test`, for every Go package; `loom project detect` prints it per package.
**Single-test command**, run with the package directory as cwd; `{file_dir}` is the contract file's directory:
```bash
go test ./{file_dir}/ -run '^{test}$' -v
```
**The `test` field** is the test function name, e.g. `TestAlphaPasses`. The pattern is anchored, so `TestSpool` does not also select `TestSpoolOpen`. A case inside a table-driven `t.Run` is not a contract target: give each contract its own `TestXxx` function.
**No match.** `go test` exits 0 when `-run` matches nothing (`testing: warning: no tests to run`, then `ok ... [no tests to run]`). Loom classifies the run from the `-v` output: zero executed tests is `NotSelected` whatever the exit code, so a contract whose `test` does not match fails the freeze ("the runner did not select the test").
**Writing contract tests.** Test files match `**/*_test.go`. Put the contract file in the directory of the package it tests (internal `package spool` or external `package spool_test`), one `TestXxx` function per contract, named after what it rejects.
```yaml
contracts:
- id: rejects-symlinked-spool
file: internal/spool/spool_test.go
test: TestRejectsSymlinkedSpool
scenario: makes the spool directory a symlink into t.TempDir(), then calls spool.Open
rejects: an Open that follows the symlink and writes into the link target
```
**Build failures.** `go test` compiles the package together with its test files; a contract test that calls a function the stage has not written yet fails with `FAIL <pkg> [build failed]` (`[setup failed]` for a syntax error), which loom classifies as `BuildFailed` and counts as red at freeze time. No other test in that package runs until the stage adds the function.
## Expert Practices: Idioms, Anti-Patterns & Gotchas
### Design
**Define interfaces in the consumer; return concrete types from the producer.** Go interfaces are satisfied implicitly, so a producer never needs to declare the interface its types satisfy. Per Go Code Review Comments: interfaces "belong in the package that uses values of the interface type, not the package that implements those values," and "do not define interfaces before they are used." A broad producer-side interface (created for mocking) couples every consumer to that shape instead of the minimal method set it uses. Idiomatic: the producer returns a concrete type (often `*T`, frequently an unexported struct behind an exported `func New() *myType`) so new methods can be added without breaking callers; each consumer declares the small interface it needs. **Returning an interface is a contract, not style:** it forces the producer's whole method set onto every consumer and blocks additive change — a new method on the concrete type is unusable without widening the interface (a breaking change).
```go
// consumer/service.go — declares exactly what it uses
type UserGetter interface {
GetUser(ctx context.Context, id string) (*User, error)
}
func NewService(g UserGetter) *Service { /* ... */ }
// producer/repo.go — returns a concrete type, idiomatic even when unexported
func NewUserRepository(db *sqlx.DB) *userRepository { /* ... */ }
```
**Prefer synchronous functions; let callers add concurrency.** Hiding goroutine creation forces concurrency on every caller — they lose control over lifetime, can't add timeouts, can't call synchronously, and testing is harder. The asymmetry is the point: making a sync function async is one line at the call site (`go func(){ errs <- Process(ctx, item) }()`); un-async-ing an async API requires rewriting every caller.
**Design types so the zero value is usable.** Every Go variable is zero-initialized; exploit it so a type needs no constructor and resists init-order bugs. `sync.Mutex` (zero = unlocked), `bytes.Buffer`, `sync.WaitGroup` all work at zero value. Corollaries: name bool fields so `false` is the safe default (prefer `disabled` over `enabled`); add a constructor only when init is genuinely non-trivial. Anti-patterns: requiring an `Init()` before use, or panicking on a zero value.
**`%w` makes the wrapped error part of your public API; use `%v` at boundaries.** `fmt.Errorf("...: %w", err)` lets callers `errors.Is`/`errors.As` the wrapped value, making it part of your contract. Wrap an internal sentinel like `sql.ErrNoRows` with `%w` and callers can depend on it — swapping your DB driver becomes a breaking change. Within an application, or when the sentinel is genuinely meant to be inspectable, use `%w`; at package/system boundaries (RPC, storage, external services) use `%v` to flatten to a string, or convert to your own exported sentinel first. Caveat: `%v` destroys chain identity — a later `errors.Is` against the original returns `false` with no compile error.
**Use generics for type-identical code; use an interface when you only call methods.** Reach for type parameters when you'd otherwise write the same code differing only by concrete type — containers and functions over slices/maps/channels of any element type. But (Go team's "When To Use Generics") "if all you need to do with a value is call a method on it, use an interface type, not a type parameter." `func ReadSome[T io.Reader](r T)` is strictly worse than `func ReadSome(r io.Reader)` — same speed, harder to read, no benefit.
**Expose iteration with range-over-func (Go 1.23), not a full slice or a `ForEach` callback.** Return `iter.Seq[V]` / `iter.Seq2[K,V]`; callers use ordinary `for range`. Beats returning `[]T` (allocates the whole collection even on early break) and ad-hoc callbacks (non-standard, not composable). The stdlib adopted it (`slices.All`/`Values`, `maps.Keys`/`Values`, composing with `slices.Collect`/`Sorted`). Contract: the iterator must stop and return as soon as `yield` returns `false`.
### Concurrency Gotchas
**Goroutine lifetimes must be deterministic; never silently start a background goroutine in a library.** Goroutines are not garbage collected — one blocked on a channel that never receives leaks for the life of the process. Code Review Comments: "make it clear when — or whether — goroutines exit." Treat lifetime as a contract: exit on `ctx.Done()` / a closed channel, or bound it to a function scope. The decision to start a background goroutine belongs to the application layer (main), not a library constructor.
```go
// BAD: a library constructor that silently leaks
func NewCache(size int) *Cache {
c := &Cache{}
go c.evictionLoop() // never stops; leaks when the cache is abandoned
return c
}
```
**The memory model gives no happens-before on goroutine exit or unsynchronized flags.** Sequential consistency holds only for data-race-free programs. Starting a goroutine is synchronized before its first statement (writes before `go f()` are visible inside `f`), but goroutine *exit* carries no happens-before guarantee, and a flag read/written without a channel, mutex, or atomic has none. So `for !done {}` may loop forever (the value can sit in a register), and a write just before a goroutine exits is not guaranteed visible elsewhere. The `WaitGroup`/channel you observe completion through is the synchronizer — not termination itself. Use `sync.Once`, `sync/atomic`, a channel, or a mutex — and `go test -race` to catch violations.
**`WaitGroup.Add` must run before `go`.** Calling `wg.Add(1)` *inside* the goroutine races with `wg.Wait()` — `Wait` can return before the counter increments, stopping too early; the race detector does not reliably catch it. Increment before `go`. staticcheck flags this as SA2000. Go 1.25's `wg.Go(func(){...})` does Add(1) + launch + deferred Done atomically — prefer it for new code.
**Never copy a `sync` type after first use.** Every sync primitive (`Mutex`, `RWMutex`, `WaitGroup`, `Once`, `Cond`, `Map`, `Pool`) holds internal state a copy silently invalidates. Most common trigger: a **value receiver** on a method of a struct embedding one — each call locks a *copy*, so the lock protects nothing. Always use pointer receivers on such types. `go vet`'s copylock analyzer catches most cases (3-clause `for` coverage improved in Go 1.24).
```go
func (c Cache) Set(k string, v int) { // BAD: value receiver copies the mutex
c.mu.Lock() // locks a copy; the real cache stays unprotected
defer c.mu.Unlock()
c.data[k] = v
}
```
### Context
**`context` keys must be an unexported package-local type, never a built-in.** A `string` or other built-in key lets any package using the same literal read or shadow your value (the `context` docs forbid built-in key types). An unexported named type makes the type system guarantee cross-package uniqueness — two packages each declaring `type ctxKey struct{}` produce distinct, non-equal key types. A zero-size `struct{}` key allocates nothing.
```go
type ctxKey struct{} // unexported; unique to this package
func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
```
**Never store `context.Context` in a struct — pass it as the first argument.** A context encodes the lifetime and cancellation scope of one logical operation; storing it makes it ambiguous which operations it governs, denies per-call deadlines, and invites leaks (Go team's "Contexts and structs"). The one accepted exception is retrofitting an existing API (as `net/http.Request` did), and even then duplicate methods (`CallContext` vs `Call`) are preferred. Corollary: don't drop the request context and start a fresh `context.Background()` inside helpers — thread `ctx` through every boundary.
### Language Gotchas
**The typed-nil interface trap: return the `error` interface, not a concrete error pointer.** An interface value is `nil` only when both its type and value slots are unset. Assigning a typed nil pointer (`var p *MyError = nil`) to an `error` return makes the interface hold `(T=*MyError, V=nil)`, which is **non-nil** — every `if err != nil` at the call site then fires even on success. Declare the return type as the `error` interface and return a bare untyped `nil` on success; never the concrete pointer. Applies to any interface. staticcheck flags the always-non-nil comparison as SA4023.
```go
func returnsError() error {
if bad() {
return ErrBad // concrete value ONLY on the error path
}
return nil // bare untyped nil
}
```
**`defer` evaluates arguments immediately; a named return lets a deferred closure mutate the result.** `defer f(x)` evaluates `x` when the `defer` runs, not when `f` executes — so `defer fmt.Println(i)` captures `i`'s current value. A deferred *closure* with no arguments captures by reference and sees later mutations. Combined with a named return, this is the idiomatic way to augment an error after `return` runs:
```go
func doOp(id string) (err error) { // named return
defer func() {
if err != nil {
err = fmt.Errorf("doOp %s: %w", id, err) // mutates result after return
}
}()
return riskyOp(id)
}
```
**`defer` in a loop queues until the function returns — the failure is FD exhaustion, not just memory.** Every `defer` is queued until the enclosing *function* returns, so files opened in a loop stay open simultaneously and exhaust the OS file-descriptor limit long before the loop ends. Fix: extract a per-iteration function (preferred) or an inline IIFE so each `defer` runs per iteration.
```go
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // runs at end of THIS function, once per iteration
return process(f)
}
for _, path := range files {
if err := processFile(path); err != nil {
return err
}
}
```
**A subslice shares the parent's backing array and spare capacity — cap it or copy.** Reslicing never copies; a subslice inherits capacity extending into the parent's tail, so appending while capacity remains writes silently into the parent (no panic — just corruption). Two fixes: (1) the three-index full slice `s[low:high:max]` caps capacity to `max-low` so the first append beyond `high` reallocates — use it when returning a slice a caller will append to; (2) when keeping a small excerpt of a large buffer, `copy` into a fresh, exactly-sized slice so the large backing array can be GC'd.
```go
parent := []int{1, 2, 3, 4, 5}
child := parent[1:3] // cap extends to end of parent
child = append(child, 99) // overwrites parent[3] silently -> [1 2 3 99 5]
func head(s []int) []int { return s[0:1:1] } // first append reallocates; can't reach parent
```
**Log errors with `slog.Any("err", err)`, not `slog.String("error", err.Error())`.** slog's built-in handlers special-case error-typed Attr values — `JSONHandler` calls `Error()`, `TextHandler` uses `fmt.Sprint` — so pre-stringifying is unnecessary and lossy: it discards the concrete type a custom handler or `LogValuer` could inspect. (`"err"` is convention, not a documented standard — pick a key and be consistent.)
**HTTP response bodies must be drained AND closed to reuse the connection.** Per `net/http`: "If the Body is not both read to EOF and closed, the ... RoundTripper may not be able to re-use a persistent TCP connection." `Close()` after a partial read does not return a keep-alive connection to the pool; with the default `DefaultMaxIdleConnsPerHost = 2`, leaking connections under load causes new dials and timeouts. Always `defer resp.Body.Close()` *and* consume the body — `io.ReadAll` when you need it, `io.Copy(io.Discard, resp.Body)` when you don't.
**Return errors, don't `panic`, for ordinary runtime failures.** `panic` is for programmer bugs (invariant violations), not for a missing file or bad input. Recover only at process/goroutine boundaries (e.g. HTTP recovery middleware). A library that panics on ordinary failures forces every caller to defer-recover.
### Version-Gated Behavior
Gated by the `go` directive in `go.mod`; older modules keep old behavior. The directive gates *language and runtime semantics*, not just the minimum toolchain — review release notes when bumping it.
**Go 1.22 scopes for-loop variables per iteration — delete `x := x`.** Before 1.22 a loop's variables were created once and mutated each iteration, so closures/goroutines/parallel subtests captured a shared variable; the fix was `tt := tt`. Go 1.22 creates fresh variables each iteration (all loop forms). In 1.22+ modules `tt := tt` is dead code `go fix ./...` removes. Hazard: bumping to 1.22 can make parallel subtests that passed only by reading the *last* iteration's value start failing — run `GOEXPERIMENT=loopvar go test ./...` first.
**Go 1.23 made timer/ticker channels unbuffered — the drain-before-Reset idiom can now deadlock.** Pre-1.23, timer channels had capacity 1, so a stale tick could buffer and the safe `Reset` idiom drained first. In 1.23 they are unbuffered and the runtime guarantees no stale value after `Stop`/`Reset`, so call `Reset` directly. An unconditional drain (`<-t.C` with nothing pending) now blocks forever; even `if !t.Stop() { <-t.C }` is no longer needed. Unstopped Timers/Tickers are now GC'd once unreferenced. (`GODEBUG=asynctimerchan=1` reverts.)
**Since Go 1.22 the global `math/rand` is ChaCha8Rand — but still use `crypto/rand` for secrets.** Go 1.20 auto-seeds from OS entropy; 1.22 backs it with ChaCha8Rand, so accidental `math/rand` use is "no longer a security catastrophe" — but it is *not* a substitute for `crypto/rand`. Use `crypto/rand` for any secret (`crypto/rand.Text()` in 1.24+, or read `rand.Reader`); use `math/rand/v2` (1.22+) for non-secret randomness. `math/rand.Seed` is deprecated and, if called, forces the weak Go 1 generator.
```go
token := rand.Text() // crypto/rand, Go 1.24+: secret, base32, >=128 bits
idx := mathrand.IntN(len(items)) // math/rand/v2: non-secret
```
**Modernize as part of the upgrade workflow — run `go fix` after bumping the directive:**
```text
go fix -diff ./... # preview
go fix ./... # apply
```
It rewrites `interface{}`→`any`, `sort.Slice`→`slices.SortFunc`, atomic free-functions→typed atomics (`atomic.Int64`/`Bool`/`Pointer[T]`, which also fix 32-bit alignment footguns), the `x := x` removal, and `context.WithCancel` in tests→`t.Context()`. Also adopt: `testing.T.Context()`/`B.Loop()` (1.24), `runtime.AddCleanup` (1.24, preferred over `SetFinalizer`), and the `slices`/`maps`/`cmp` packages (1.21).
### Naming
**Package names: no stutter, no `util`/`common`/`helper` grab-bags.** A package name is always visible at the call site, so a symbol repeating it reads redundantly: `http.HTTPError`, `chubby.ChubbyFile`. Pick names that form a natural phrase — `http.Error`, `chubby.File`, `io.Reader`. Second smell: `util`/`common`/`helper`/`misc`/`base` say nothing about contents, force import aliases, and accumulate unrelated code — split by domain instead.
## Verification Checklists
### Before completing any Go change
- [ ] `gofmt`/`goimports` clean; `go vet ./...` passes (catches copylock, printf, nil-func)
- [ ] `go build ./...` and `go test ./...` green
- [ ] `go test -race ./...` on any code touching goroutines/shared state
- [ ] `golangci-lint`/`staticcheck` clean (SA2000 Add-before-go, SA4023 typed-nil) if configured
- [ ] Every returned error is `%w`-wrapped with context, or deliberately `%v`-flattened at a boundary
- [ ] No ignored errors (`_ =`) except deliberate, commented cases
### Concurrency review
- [ ] Every goroutine has a deterministic exit (`ctx.Done()`, closed channel, or bounded scope) — no library-level background goroutines
- [ ] `sync` types are never copied (pointer receivers; no value passing of structs embedding them)
- [ ] Shared state read/written only under a mutex/atomic/channel — no unsynchronized flags
- [ ] Channels closed by the sender exactly once; receivers never close
- [ ] `context.Context` is a first parameter, threaded through, never stored in a struct
### API/interface review
- [ ] Interfaces declared in the consumer; constructors return concrete types
- [ ] Functions are synchronous unless concurrency is the caller's explicit request
- [ ] Zero value usable, or a constructor exists because init is non-trivial
- [ ] `error` return type is the interface; success path returns bare `nil` (no typed-nil trap)