Every row in ~/.halton-meter/db.sqlite carries a project_id. That id
links into the projects table, where the slug lives. The slug is what
halton-meter report --by project groups on, what the HTTP API splits
totals by, and what shows up everywhere a cost roll-up does.
The slug is resolved by the Smart Attribution chain — a stack of
pure functions in daemon/halton_meter/attribution/layers.py. Each
function returns either a slug or None. The first non-None result
wins. The chain is identical on the daemon side (where Layer 0 sits in
front of it as a cache) and on the edge side, so the slug a request is
attributed to is independent of which path the bytes took.
For the user-facing knobs, see Configure projects. This page is the architecture.
The chain, in evaluation order
| # | Layer | Resolver function | Source |
|---|---|---|---|
| 0 | Edge cache | edge attribution store lookup | Cached resolution from a recent request on the same connection |
| 1 | Env var | resolve_via_env(env) | HALTON_PROJECT |
| 2 | .haltonrc | resolve_via_haltonrc(start_dir) | Walked upward from cwd |
| 3 | IDE workspace | extract_workspace_path_from_cmdline(...) + decoders | Cmdline of the process that spawned the request |
| 4 | Git basename | resolve_via_git(cwd) | Walked upward looking for .git |
| 6.5 | macOS sandbox | resolve_via_mac_sandbox(cwd) | Bundle id from sandbox container path (e.g. mac:com.openai.chat) |
| 7 | cwd basename | resolve_via_workdir_basename(cwd) | Always — last real layer |
| 8 | Sentinel | — | Literal unattributed if nothing else fired |
Layers 5 and 6 are reserved. Layer 5 — monorepo workspace detection — is parked indefinitely; the heuristics overlapped with Layer 3 and were a source of false splits. Layer 6 — Linux/Kubernetes cgroup detection — is not on the macOS path.
Why ordered, not heuristic
Two design constraints shaped the chain:
- Determinism over inference. A request tagged
claude-haiku-4-5that costs$0.0042should land under the same slug every time, on the same machine, regardless of which IDE happens to be foreground. A heuristic that chose between Layer 3 and Layer 4 based on “confidence” would produce different slugs for identical workflows. - Explicit over implicit. Layers 1 and 2 — env var and
.haltonrc— exist precisely so a developer who cares about correctness can force a slug. Everything below them is a fallback for the case where no one bothered.
Layer ordering also encodes intent. Layer 1 (env var) wins because if
you set HALTON_PROJECT=experiments for a single command, that’s a
strong signal. Layer 2 (.haltonrc) wins next because dropping a file
at a repo root is a deliberate act. Layers 3, 4, and 7 are all “guess
from context” and they’re ordered by specificity — the IDE knows more
than git, git knows more than the cwd basename.
Layer 6.5 — the macOS sandbox layer
This is the v0.3 PR2 addition (2026-05-02). Sandboxed apps on macOS run
under containers like
~/Library/Containers/com.openai.chat/Data/.... Every request from
inside the sandbox has the same cwd: the sandbox root. Layer 7’s
basename fallback collapses every one of them into a single
Data slug — useless.
Layer 6.5 reads the sandbox container path, extracts the bundle id, and
emits a mac: prefix to namespace it: mac:com.openai.chat,
mac:ai.perplexity.mac. This sits above Layer 7 so the basename
fallback never fires for sandboxed apps, but below Layer 4 so a
sandboxed app inside a developer’s git checkout still gets the repo
slug.
Slug normalisation
Every layer’s output passes through normalise_project_slug(). Rules:
- Lowercase
[a-z0-9]plus-,_,:,/retained- Anything else stripped
- Empty result →
None(layer falls through)
: and / are retained because Layer 6.5 emits mac:com.foo.bar and
some users tag with paths like client/billing. Both are valid slugs;
report tooling treats them as opaque strings.
Inspecting attribution at runtime
$ halton-meter run -- env | grep HALTON_PROJECT # Layer 1, if set
$ halton-meter project show billing # settings + recent rows For a forensic view of why a particular row got the slug it did, the
daemon emits structured attribution.resolved events with the winning
layer name. Tail ~/.halton-meter/daemon.err.log while replaying the
request:
tail -F ~/.halton-meter/daemon.err.log | grep attribution Rewriting attributions after the fact
halton-meter retag rewrites the project_id foreign key on
historical rows. It is dry-run by default and writes a _migrations
sentinel so an idempotent re-run is a no-op.
$ halton-meter retag --from old-slug --to new-slug
$ halton-meter retag --from old-slug --to new-slug --apply The same slug, resolved the same way on every machine, is what lets a project be scoped to a project across a team without changing how attribution runs locally.
What’s next
- Configure projects — the
knobs (
HALTON_PROJECT,.haltonrc,halton-meter project set) - SQLite schema — how
project_idjoins to theprojectstable on disk