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 unified resolver in
daemon/halton_meter/attribution/resolver.py, backed by a per-edge
slug-to-abspath registry in attribution/registry.py. The resolver
walks an ordered set of tiers. Each tier returns either a slug or
None. The first non-None result wins. The resolver is identical on
the daemon side (where Tier 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 tiers, in evaluation order
| Tier | Name | Source |
|---|---|---|
| 0 | Edge cache (edge_store) | Cached resolution from a recent request on the same connection |
| 1 | Env var | HALTON_PROJECT |
| 2 | .haltonrc (rcfile) | Walked upward from cwd |
| 3 | IDE workspace sniff (parent_ide_sniff) | Workspace flag on the cmdline of the process that spawned the request |
| 4a | Git basename (git) | Walked upward looking for .git |
| 4b | IDE workspace env label (ide_env_label) | CURSOR_WORKSPACE_LABEL / VSCODE_WORKSPACE_LABEL, strict-slug corroboration |
| 4c | IDE workspace argv label (ide_argv_label) | Electron-helper argv, strict-slug corroboration |
| 5 | Parent-PID walk | Bounded walk up the parent process chain; stops at the uid/session boundary |
| 6.5 | macOS sandbox (mac_sandbox) | Bundle id from sandbox container path (e.g. mac:com.openai.chat) |
| 7 | cwd basename (workdir) | Always, last context tier |
| 8 | Smart default (smart_default) | Configured [tagging] default_project (default misc) if nothing else fired |
Tier 6, Linux/Kubernetes container detection, is not on the macOS path.
Why ordered, not heuristic
Two design constraints shaped the resolver:
- 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 Tier 3 and Tier 4a based on "confidence" would produce different slugs for identical workflows. - Explicit over implicit. Tiers 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.
Tier ordering also encodes intent. Tier 1 (env var) wins because if
you set HALTON_PROJECT=experiments for a single command, that's a
strong signal. Tier 2 (.haltonrc) wins next because dropping a file
at a repo root is a deliberate act. Tiers 3, 4a, 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.
Tier 6.5: the macOS sandbox tier
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. Tier 7's
basename fallback collapses every one of them into a single
Data slug, useless.
Tier 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 Tier 7 so the basename
fallback never fires for sandboxed apps, but below Tier 4a so a
sandboxed app inside a developer's git checkout still gets the repo
slug.
Slug normalisation
Every tier's output passes through normalise_project_slug(). Rules:
- Lowercase
[a-z0-9]plus-,_,:,/retained- Anything else stripped
- Empty result →
None(tier falls through)
: and / are retained because Tier 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 # Tier 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.tier_hit events
(resolver.py::_emit_tier_hit) naming the tier that fired. Tail
~/.halton-meter/daemon.err.log while replaying the request:
$ tail -F ~/.halton-meter/daemon.err.log | grep attribution
The same answer is persisted per row in the requests.attribution_method
column. The canonical set is 15 values: edge_store, cwd, rcfile,
git, mac_sandbox, workdir, ide_argv_label, ide_env_label,
parent_rcfile, parent_git, parent_workdir, parent_ide_sniff,
parent_ide_argv_label, parent_ide_env_label, smart_default.
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