Concepts · 02 macOS only

Project tagging

Halton Meter's Smart Attribution chain — seven ordered layers that resolve a project slug for every captured request, deterministically, before the row is written.

macOS 12+ · Python 3.11+ Reading time 2 min Updated May 11, 2026

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

#LayerResolver functionSource
0Edge cacheedge attribution store lookupCached resolution from a recent request on the same connection
1Env varresolve_via_env(env)HALTON_PROJECT
2.haltonrcresolve_via_haltonrc(start_dir)Walked upward from cwd
3IDE workspaceextract_workspace_path_from_cmdline(...) + decodersCmdline of the process that spawned the request
4Git basenameresolve_via_git(cwd)Walked upward looking for .git
6.5macOS sandboxresolve_via_mac_sandbox(cwd)Bundle id from sandbox container path (e.g. mac:com.openai.chat)
7cwd basenameresolve_via_workdir_basename(cwd)Always — last real layer
8SentinelLiteral 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:

  1. Determinism over inference. A request tagged claude-haiku-4-5 that costs $0.0042 should 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.
  2. 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

~ — see the resolved slug
$ 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.

~ — retag historical rows
$ 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_id joins to the projects table on disk