10 KiB
Verification agent guidelines (Go CLI)
Coding standards for the Databasus verification agent — a Go CLI worker that runs on a cloud-managed Linux VM, reports its capacity to the backend over HTTP, and (once the restore phase lands) claims and verifies backups. It has no Gin HTTP server and owns no database schema. The long-running goroutine in this phase is the capacity heartbeat loop; the claim/report runner arrives with the restore phase. There is no Windows daemon — the agent runs in the foreground under systemd or as a container.
For project-wide engineering philosophy, naming, and lint/format commands, see the root CLAUDE.md. For the backend (Gin/GORM/Swagger) ruleset, see backend/CLAUDE.md.
Table of Contents
- Spacing between logical statements
- Comments
- File organization
- Background services
- Testing
- Time handling
- Logging
- Modern Go
Spacing between logical statements
Add blank lines between logical blocks so the flow is visible at a glance:
- before the final
return - after variable declarations, before they're used
- between error handling and subsequent logic
- between distinct logical operations
Bad:
func encodeMessages(messages []Message) (string, error) {
if len(messages) > 0 {
messagesBytes, err := json.Marshal(messages)
if err != nil {
return "", err
}
return string(messagesBytes), nil
}
return "", nil
}
Good:
func encodeMessages(messages []Message) (string, error) {
if len(messages) > 0 {
messagesBytes, err := json.Marshal(messages)
if err != nil {
return "", err
}
return string(messagesBytes), nil
}
return "", nil
}
Comments
- No obvious comments — don't restate what the code already shows.
- Explain why, not what — code shows what happens; comments explain business rules, hidden constraints, or non-obvious optimizations.
- Prefer refactoring over commenting — better names or smaller functions usually beat a comment.
- Complex algorithms deserve comments — formulas, business rules, non-obvious optimizations.
- No "Summary" / "Conclusion" sections in
.mdfiles unless explicitly requested.
Bad (each comment restates the function name):
// Send heartbeat
sendHeartbeat(request)
// CreateValidLogItems creates valid log items for testing
func CreateValidLogItems(count int, uniqueID string) []LogItemRequestDTO {
File organization
One responsibility per file. Don't dump a whole package into one file — split by role so a reader can find a type by its filename. Conventional names within a feature package:
doc.go— package doc comment, once the package spans more than one file<feature>.go— the core type and its methods (the orchestrator/executor)dto.go— request/response and cross-package data + interface seamserrors.go— sentinel errors (var Err... = errors.New(...))enums.go— typed-constant groups (type Status string+ its values)constants.go— package-level constants that aren't an enum- background loops, reapers, and pools get their own file (
reaper.go,pool.go)
Only create a file when there is real content for it — an empty enums.go or
constants.go is noise, not structure. Test files mirror the source split:
restorer.go → restorer_test.go, diskexhaustion.go →
diskexhaustion_test.go.
Background services
The agent ships at least one long-running goroutine (the capacity Heartbeater; also BackgroundUpgrader). Calling Run() twice on the same instance is always a bug — duplicate goroutines leak resources and corrupt state. Always panic; never just log a warning.
type Heartbeater struct {
// ...
hasRun atomic.Bool
}
func (h *Heartbeater) Run(ctx context.Context) {
if h.hasRun.Swap(true) {
panic(fmt.Sprintf("%T.Run() called multiple times", h))
}
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.beat(ctx, logger)
}
}
}
atomic.Bool.Swap(true) does the check-and-set atomically — no sync.Once needed.
Testing
Always run tests after writing them and verify they pass.
Naming
Test_WhatWeDo_WhatWeExpectTest_WhatWeDo_WhichConditions_WhatWeExpect
Examples: Test_DeriveCapacity_WhenConcurrentJobsExceedCPU_ReturnsError, Test_ValidateTransport_WhenHttpAndNotTTYWithoutFlag_FailsFast, Test_Heartbeat_WhenCalled_SendsFlatEnvelopeWithBearerAndAgentPath, Test_BackgroundUpgrader_WhenRunCalledTwice_Panics.
Where tests live
- Unit / package tests: alongside the code, named
*_test.go(e.g.internal/features/heartbeat/heartbeat_test.go). - End-to-end tests: under
agent/verification/e2e/. Run viamake e2e/make e2e-clean.
The agent has no HTTP API of its own, so there are no controller tests — test exported functions of the packages, the CLI commands, and the HTTP-client/IO logic directly.
Refactor tests as you touch them
When editing existing tests, look for repetitive setup that should become a helper, oversized tests that should be split, and similar patterns across files that should be consolidated. Helpers live in a testing.go file inside the package.
Clean up test data
If a test creates files, processes, or external state, clean it up via t.Cleanup(...) or defer. Skip cleanup only when the test uses an isolated tempdir the OS reclaims, or when it explicitly validates a failure path where cleanup isn't possible.
Time handling
Always use time.Now().UTC() instead of time.Now() to keep timezones consistent across the application.
Logging
We use log/slog. Three rules.
1. Scope IDs early via logger.With(...)
Attach agent_id, verification_id, upgrade_target, etc. as soon as you know them so every downstream line carries them automatically. For background services, also scope job_id (fresh UUID per execution) and job_name (stable snake_case const) in Run().
const jobName = "verification_heartbeat"
func (h *Heartbeater) Run(ctx context.Context) {
logger := h.log.With("job_id", uuid.New(), "job_name", jobName)
logger.Info("heartbeat loop started")
// every subsequent log carries job_id + job_name
}
2. Values in message, IDs and errors as kv pairs
Sizes, counts, and status transitions go into the message via fmt.Sprintf. IDs and errors stay as structured kv pairs so they're searchable in log aggregation tools.
// good
logger.Info(fmt.Sprintf("heartbeat ok: last_seen_at=%s", seenAt))
logger.Info("agent registered", "agent_id", agentID)
logger.Error("heartbeat failed", "error", err)
// bad — ID buried in the message string, error formatted instead of attached
logger.Info(fmt.Sprintf("agent registered %s", agentID))
logger.Error(fmt.Sprintf("heartbeat failed: %v", err))
3. Style and level
- All keys
snake_case(agent_id,verification_id) — never camelCase. - Messages start lowercase, no trailing period.
- Debug: routine ops, function entry, query result counts.
- Info: significant state changes, completed actions (
"agent started","heartbeat loop stopped"). - Warn: degraded but recoverable (
"heartbeat failed, will retry next tick","upgrade skipped: same version"). - Error: failures that need attention (
"auto-update failed","failed to re-exec after upgrade").
What never goes into logs
- Passwords, tokens, API keys, full
Authorizationheaders — centralize redaction; don't redact ad-hoc at call sites (config logging masks the token viamaskSensitive). - Full request / response bodies — log only the fields you actually need.
Modern Go
Prefer modern stdlib idioms over manual equivalents.
slices package — avoid manual loops
slices.Contains(items, x)
slices.Index(items, x) // returns index or -1
slices.IndexFunc(items, func(item T) bool { return item.ID == id })
slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })
slices.Sort(items)
slices.Max(items) / slices.Min(items)
slices.Reverse(items) // in-place
slices.Compact(items) // remove consecutive duplicates
slices.Clone(s)
slices.Clip(s)
Quick wins
anyinstead ofinterface{}.for i := range len(items)instead offor i := 0; i < len(items); i++.sync.OnceFunc(fn)/sync.OnceValue(fn)instead ofsync.Once+ wrapper.t.Context()in tests instead ofcontext.WithCancel(context.Background())+defer cancel()— auto-cancels at test end.wg.Go(fn)instead ofwg.Add(1)+go func() { defer wg.Done(); fn() }().slog.DiscardHandlerfor a no-op logger in tests.
context helpers
stop := context.AfterFunc(ctx, cleanup) // run cleanup on cancellation
ctx, cancel := context.WithTimeoutCause(parent, d, ErrTimeout) // timeout with cause
ctx, cancel := context.WithDeadlineCause(parent, deadline, ErrDeadline) // deadline with cause
omitzero instead of omitempty for non-nullable types
omitempty is broken for time.Duration, time.Time, structs, slices, and maps — it doesn't omit a zero value. Use omitzero:
// good
type Config struct {
Timeout time.Duration `json:"timeout,omitzero"`
CreatedAt time.Time `json:"createdAt,omitzero"`
}
// bad
type Config struct {
Timeout time.Duration `json:"timeout,omitempty"` // broken for Duration!
CreatedAt time.Time `json:"createdAt,omitempty"`
}
new(val) for pointer literals (Go 1.26+)
new() accepts expressions, eliminating the temp-variable pattern:
// good
cfg := Config{Timeout: new(30), Debug: new(true)}
// bad
timeout := 30
debug := true
cfg := Config{Timeout: &timeout, Debug: &debug}