Files
fathom/TODO.md
zzgandClaude Opus 5 cbe6d944f9 Add TODO.md: ten findings from a six-expert review panel
Six independent reviewers (empirical rigour, security, storage/durability,
applied ML, reliability, DX) read the repo at main. The ten items here are
the findings that survived cross-checking against the source.

The two that change the most:

recoverTail (internal/store/shard.go:107-155) scans frames from offset 0,
breaks at the first CRC mismatch and unconditionally truncates, on the
assumption that only the tail can be bad. A probe flipping one bit in chunk 0
of a 10-chunk file took it from 2130 bytes to 0. A read query against a
not-yet-opened shard can trigger it.

There is no auth primitive in the repository, and the default bind is :3000 on
all interfaces, while the README instructs users to enable prompt, tool and
response content logging. The data directory is a plaintext secrets store
served to the network, with no redaction and day-granularity deletion.

go test -race ./... passes clean on all 10 packages; the suite never corrupts
a byte, fails a write, or races retention against a reader, which is where
four of these ten live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:34:15 -07:00

15 KiB

TODO

Ten items, synthesised from a six-expert review panel (empirical rigour, security, storage/durability, applied ML, reliability, developer experience). Each expert read this repo and costslayer at main and reported independently; the findings below are the ones that survived cross-checking against the code.

Ordered by irreversibility first — silent data loss and credential exposure above correctness, correctness above polish. Every claim marked verified was re-checked directly against the source, not taken on an expert's word.


1. recoverTail destroys the data it exists to protect

internal/store/shard.go:107-155 walks frames from offset 0, breaks at the first CRC mismatch, then unconditionally truncates:

if crc32.ChecksumIEEE(body) != want {
    break // body landed partially, or the disk lied
}
good = end
}
if good < size { return f.Truncate(good) }

The comment at shard.go:13-22 assumes the only bad chunk is the tail. Nothing enforces that. Demonstrated with a probe on a scratch copy:

10 chunks written, file=2130 bytes, rows readable=10
after 1-bit flip in chunk 0, rows readable=0
after writer open (recoverTail): file=0 bytes (was 2130)
RESULT: recoverTail PERMANENTLY DELETED 2130 of 2130 bytes

Worse, store.go:255-259 means a read query against a not-yet-opened old shard goes through OpenShard → recoverTail, so browsing an old day in the UI can trigger the deletion.

Only truncate when the first bad frame is the last frame. For interior damage, log the offset, skip the frame and keep scanning so later chunks stay reachable — or quarantine the shard to .corrupt. Also check the n returned by s.f.Write(frame) at shard.go:184; a short write is currently ignored.

2. Everything is unauthenticated, on every interface, storing secrets in cleartext

Verified: config.go:66 sets c.Server.Addr = ":3000" — empty host, i.e. all interfaces. Handler() (server.go:57-82) wraps the mux in logRequests and nothing else. A grep for Bearer|Authorization across internal/ and cmd/ returns nothing; there is no auth primitive in the repository.

The README instructs users to set OTEL_LOG_USER_PROMPTS=1, OTEL_LOG_TOOL_CONTENT=1, OTEL_LOG_ASSISTANT_RESPONSES=1. So the data directory holds source code, file contents, shell output and — with high probability — any key that passed through a session. Anyone who can reach port 3000 reads all of it via /api/traces, and gets a query engine for free via POST /api/query.

There is no redaction anywhere (grep -rni redact|scrub|encrypt returns zero hits outside fixtures), files are 0o644 in a 0o755 directory, and deletion granularity is a whole day — so the answer to "I pasted a prod key into a prompt" is rm -rf that day's traces.

Bind loopback by default; refuse a non-loopback bind without an explicit token; require Authorization on /api/* and /v1/*; chmod to 0o600/0o700; add a credential-shape redactor on the ingest path; add fathom purge --trace / --grep. The [privacy] section currently drops five identity keys while writing every prompt verbatim, which reads as more protection than it is.

3. DNS rebinding drains the store from any web page the operator visits

No Host allow-list, no Origin check, no CSRF token — grep for any of them in internal/web/ returns nothing outside tests.

An attacker page on a 1-second-TTL domain that rebinds to 127.0.0.1 makes fetch('http://evil.tld:3000/api/traces?from=0&to=9e14') same-origin by the browser's rules, so the response is readable and the whole corpus is POSTed out. Binding to loopback does not help; only Host validation does. This needs no network adjacency at all — just a browser tab.

Ingest is separately CSRF-able today: handleLogs (server.go:148-164) reads the body and unmarshals with no Content-Type check, so a text/plain form post is a CORS-simple request that lands without preflight. Same for the insights mutation endpoints, which take no token and in one case no body.

Reject requests whose Host is not in the configured set, reject foreign Origin, and require application/json on every POST.

4. Failed flushes discard spans that were already acknowledged

store.go:165-168 detaches the buffer before attempting any I/O:

s.mu.Lock()
spans, logs := s.spanBuf, s.logBuf
s.spanBuf, s.logBuf = nil, nil
s.mu.Unlock()

Every write failure is fail(err); continue — nothing is ever put back. The exporter was told {"partialSuccess":{}} and 200 OK a second earlier, so it will never retry. Then AddSpans (store.go:126-130) hands the error to the next caller and clears it, so one unrelated batch eats a 500 and the store reports healthy again. background() logs nothing (the package imports no logger at all). Close() discards the same way on shutdown.

Re-buffer unwritten rows under the mutex with a bounded cap, keep flushErr sticky until a flush actually succeeds, log every failure with row counts, and return an error before acknowledging when the buffer is over cap so the exporter retries instead of the store silently eating the batch.

This compounds across the pipeline: costslayer's exporter discards the HTTP status entirely, so fathom can reject everything and the sender will never know. Fixing that side is the cheaper half.

5. Corruption, version mismatch and dropped spans are all indistinguishable from "no data"

Six separate error paths in iterChunks (shard.go:208-239) return nil: CRC mismatch, decode error, implausible length, read error. read.go:88-93 then continues per shard. In the probe above, ScanSpans returned err == nil with zero rows against a file that had ten good chunks.

Two consequences. Interior bit rot makes every later chunk permanently invisible while queries return 200 with a smaller rows_scanned. And chunk.go:254-256 rejects any ver != chunkVersion, so the day chunkVersion goes to 2, every existing data directory decodes to nothing, silently — recoverTail only checks CRC, not version, so it will not even complain.

Ingest has the same shape: otlp.go:252-254 drops span-ID-less spans with continue and no counter, while otlpOK always returns an empty partialSuccess — the OTLP field that exists precisely to report rejections. A body with the wrong shape decodes to zero spans and returns 200. And nanoToMicro returns 0 on unparsable input, so shardDate(0) files the span under 1970-01-01, invisible to every default query and deleted by the first retention sweep.

Thread errors out, count and report Corrupt/Skipped/Rejected in QueryStats and partialSuccess, add an explicit backward-compat branch to DecodeChunk, and never file a span under 1970.

6. Nothing stops two processes opening the same data directory

No lockfile anywhere — grep -rn "Flock|flock|O_EXCL" matches nothing. BlobFile tracks the append offset in process memory (blob.go:16-22) and assigns off := b.size at blob.go:71, while the file is opened O_APPEND so the kernel writes at the real end:

off := b.size
n, err := b.f.WriteString(s)
return BlobRef{Off: off, Len: uint32(n)}, nil

Two processes (a stray unit, a manual fathom serve, an overlapping container restart) and process A records an offset where process B's bytes now live. The chunk is valid, the CRC is correct, the ref is in range — and reading it returns another trace's text. Silent content substitution, undetectable by any check in the tree.

Take an exclusive lock on the data dir and fail startup with a clear message. Derive the blob offset from the actual write position rather than a process-local counter.

7. Durability gaps: no directory fsync, and retention closes handles readers hold

No directory is ever fsynced — only blob.go:115 and shard.go:190 sync files. A new day's shard can have its contents synced while the directory entry naming them is not, so a power cut leaves the day absent rather than corrupt, and ListShards simply does not enumerate it.

Separately, dropShard (store.go:419-432) calls sh.Close() on a handle the shared cache handed out, which store.go:337 documents as something readers must not close. Nothing refcounts. Probe output:

before evict: blob read = "THE-PROMPT-TEXT" err=<nil>
after evict, SAME held handle: err=... file already closed
post-evict scan: rows=0 (no error surfaced)

The hourly sweep firing while a trace-detail view is open makes the prompt render empty. In the other direction, a query that reopens the file after the map entry is deleted leaves a live handle to a deleted file and corrupts the size accounting.

Add a syncDir helper on every create path; refcount shards with an explicit Release(), unlink immediately and defer Close() to zero readers.

8. Bound query and decode memory

LIMIT is applied only after the whole result is materialised (exec.go:616). One goroutine per shard, no cap on a.rows, one *group per distinct key in an unbounded map, and every sample retained for percentiles (exec.go:563-566). SELECT p99(duration) FROM spans WHERE time >= 0 GROUP BY trace_id passes the time-range check and can be several GB on a year of traces. handleQuery passes no context, so the 60s WriteTimeout kills the response, not the goroutines.

chunk.go:298-303 pre-sizes slices from an untrusted count capped at maxRows*maxColumns = 2^32 — roughly 36 GB of allocation from one frame before a single attribute byte is read. recoverTail has the same shape at shard.go:141.

Add a maxGroups cap and a truncated flag, cap rows at q.Limit where the query allows it, use a bounded sketch for percentiles, semaphore the shard fan-out, thread a deadline context through, and never pre-size from an untrusted length beyond what the remaining buffer can hold.

Related: config.go:71 sets MaxBytes = 0 and store.go:398 only evicts when MaxBytes > 0, so size-based eviction is off by default — an unauthenticated writer can fill the disk with a 30-day window to work in.

9. The insights findings have no null model and no precision measurement

Not one threshold in internal/insights/ has been validated against labelled data — grep for precision|labeled|ground truth returns one misleading comment. The free parameters (MinRuns:3, MinLen:3, PromptSimilarity:0.55, embedSimilarity:0.82, RereadThreshold:3, MinCostUSD:0.50) are tuned against nothing, and all 40 tests are fixtures asserting the mechanism fires.

A faithful replication of crystallize.go's miner on traces whose tool calls are drawn i.i.d. with no sequential structure produced ~300 qualifying findings at 150 traces, top one "the same 3 steps in 21 runs". The panel is guaranteed to be full whether or not the user has a single real habit.

Specific defects behind that:

  • Cost is not a saving. crystallize.go:102 allocates trace cost uniformly across tool calls (false for agentic runs — cost scales with accumulated context), and four detectors attribute the entire trace. The UI renders it as "costing $X", which sends users to spend an hour recovering money that was never on the table.
  • No denominator. context_waste.go:258-272 continues past successes before counting, so a failure rate is unknowable and go test failing 3 of 500 reads identically to 3 of 3.
  • Severity measures window length. insights.go:360-368 thresholds on absolute cost/occurrences, so widening WindowDays promotes everything; the bare occurrences >= 20 disjunct promotes with zero cost input.
  • AddressableCost is computed from the truncated evidence list (capped at 8), so the metric that exists to prevent overclaiming undercounts more as findings get stronger.
  • Threshold mismatch. MinOccurrences is advertised to the client as the floor, but three detectors fire at n=2 and Waste counts calls, so one trace can produce a finding.

Add a within-trace permutation null with BH correction; split cost_observed_usd from cost_recoverable_usd; compute failure rates against a base rate; normalise severity to a rate; compute AddressableCost from full trace IDs.

10. Make "working" distinguishable from "loaded but inert"

No /healthz, no /readyz (grep returns only test hits), and /api/stats reports shard byte sizes only — no ingest counters, no last-ingest timestamp, no error counts. store.Open (store.go:97-101) only calls MkdirAll, which succeeds on an existing read-only directory, so fathom starts, logs listening, accepts every POST with 200, and every flush fails into the silent void of item 4. Retention deletes days with no log at all. A failed insights analysis keeps serving the previous result with ready: true and no timestamp rendered.

Add /healthz and /readyz, a startup write-probe that fails loudly, ingest and flush counters plus last_ingest_at / last_flush_error on /api/stats, eviction logging, and stale: true + GeneratedAt on the insights response.

The pattern already exists in this codebase and is worth copying: LocalStatus (analyzer.go:96-101) distinguishes "a model is available" from "a model was actually used". That is exactly the loaded-vs-inert discrimination missing everywhere else.


Notes

go test -race ./... passes clean on all 10 packages. That is worth stating plainly because it is not evidence of durability: the suite never corrupts a byte, never fails a write, and never races retention against a live reader. Items 1, 4, 6 and 7 all live in that gap. Tests that inject those faults are prerequisites for trusting the fixes.

Two positives the panel checked and confirmed, worth protecting: the web UI has no XSS — app.js contains zero innerHTML/eval, every node goes through el()'s textContent/createTextNode, and trace content is attacker- influenceable and rendered safely anyway. And there is no dependency surface at all: go.mod has zero requires. A future innerHTML in app.js would turn item 3 into full store theft.

One cross-project contradiction. context_waste.go:230 suggests splitting long runs in two — which forfeits the prompt-cache prefix that costslayer's L3 exists to preserve. Acting on that finding can increase spend. Estimate the cache-rebuild cost before emitting the suggestion.

One documented guarantee the code does not keep. local.go:1-12 and config.go:46-52 promise insights data "never leaves the machine", but candidates() puts OLLAMA_HOST and FATHOM_LOCAL_MODEL_URL ahead of the localhost defaults with no loopback check — and what travels is up to six user prompts, not a hash. Anyone with OLLAMA_HOST pointed at a GPU box silently ships real prompts over plaintext HTTP. Either enforce loopback or correct the comment. GET /api/insights?from=&to= also triggers on-demand full-store analysis despite the comment above it promising the opposite.