byob-go-cli

Content-hash the config to scope derived data; never persist host paths as keys

byob-config.4 configstorage

Problem: when a CLI persists computed results (analysis runs, build outputs, indexes) derived from its config, two failure modes appear the first time the config changes or the project moves. First, results computed under yesterday's config are read back under today's and presented as valid — numbers that are quietly wrong, the worst kind. Second, the tempting identity key for "which config produced this" is the config file's absolute path — which bakes /home/alice/... into a database or artifact that may be shared, published, or packaged, and breaks the moment the directory is renamed.

Idea: two rules, one mechanism.

Scope derived data by a content hash. At load time, hash the raw config bytes (a sha256 prefix is plenty). The write path stamps every persisted result with the hash; the read path filters to results whose hash matches the currently-loaded config. Both paths call the same function — write and read must agree on the fingerprint or the scoping silently breaks. Stale results are then invisible rather than wrong, and the "nothing matches" case gets an explicit error telling the user to re-run the compute step.

Hashing the raw bytes (not a parsed-struct encoding) is deliberate: it's path-independent (moving the repo changes nothing), struct-independent (adding a Go field doesn't invalidate results for unchanged files), and distinct per file (two configs that define the same-named entity with different parameters hash differently, so their results can't cross-contaminate). The cost: any edit invalidates, including comments and whitespace. That's the right default — a hash that tries to be clever about "semantic" changes will eventually miss one that matters.

One interaction to settle explicitly: env-var overrides (byob-config.2) change the effective config without touching the file bytes. Hashing file bytes alone is safe only when the env layer is limited to display/output knobs that can't affect derived data; if an env var can change a compute input, fold the resolved values into the fingerprint or the staleness guarantee is silently gone.

Never persist a host path as identity. Where a stable cross-machine key is needed (the same logical config checked out at different paths, or renamed), give users an explicit optional config_id field and default it from a hash of the path — the hash leaks nothing and keeps the default stable in place, while the explicit field is the escape hatch for stability across moves.

Details that bite later:

  • Pick the truncation width once and never change it — widening the stored hash desynchronizes every existing row from the read path.
  • Configs constructed in memory (tests) have no raw bytes. The fallback must be a deterministic encoding of the struct — not fmt.Sprintf("%v", cfg), which renders pointer fields as addresses and hashes differently per process.
  • Say loudly (docs, error text) that editing the config orphans existing results until the compute step re-runs. Users hit this as "it worked yesterday"; the error message should connect the dots.

Tradeoffs: one more column on every result table and one WHERE clause on every read (which the scoped-store handle of byob-storage.7 centralizes). Re-computation after every config edit is real work for heavy pipelines — if that becomes the complaint, hash sections of the config and scope per-section, but only once the coarse hash actually hurts.

Design

// internal/config/hash.go
// Both the result-write path and the scoped-read path call this —
// they must agree on the fingerprint.
func (c *Config) Hash() string {
    if c.rawBytes != nil { // set by the loader
        return hashBytes(c.rawBytes)
    }
    // In-memory fallback: deterministic encoding, NOT fmt "%v"
    // (pointer fields would render as per-process addresses).
    var buf bytes.Buffer
    if err := toml.NewEncoder(&buf).Encode(c); err == nil {
        return hashBytes(buf.Bytes())
    }
    // Unreachable in practice: guards only an encoder error — a
    // Config is always TOML-encodable. Never the primary path; see
    // the pointer-address caveat above.
    return hashBytes(fmt.Appendf(nil, "%+v", c))
}

func hashBytes(b []byte) string {
    return fmt.Sprintf("%x", sha256.Sum256(b))[:16] // width is a wire format: never change it
}
// Write path: stamp the run.
run, err := store.CreateRun(ctx, cfg.Hash())

// Read path: pin the handle (byob-storage.7) so unscoped reads
// only see results produced by this config.
store = store.WithConfigHash(cfg.Hash())

// Miss path: a clear next step, not silence.
if len(results) == 0 {
    return fmt.Errorf("no results match the current config (edited since last run?): re-run `%s compute`", exe)
}