Halton Meter Docs
Docs /Concepts /Project tagging
CONCEPTS · 02 macOS Stable

Project tagging

Per-project LLM cost attribution in Halton Meter — a unified resolver of ordered tiers that resolves a project slug for every captured request, deterministically, before the row is written.

macOS 12+ · Python 3.11+ ·5 min read ·Updated Jun 8, 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 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

TierNameSource
0Edge cache (edge_store)Cached resolution from a recent request on the same connection
1Env varHALTON_PROJECT
2.haltonrc (rcfile)Walked upward from cwd
3IDE workspace sniff (parent_ide_sniff)Workspace flag on the cmdline of the process that spawned the request
4aGit basename (git)Walked upward looking for .git
4bIDE workspace env label (ide_env_label)CURSOR_WORKSPACE_LABEL / VSCODE_WORKSPACE_LABEL, strict-slug corroboration
4cIDE workspace argv label (ide_argv_label)Electron-helper argv, strict-slug corroboration
5Parent-PID walkBounded walk up the parent process chain; stops at the uid/session boundary
6.5macOS sandbox (mac_sandbox)Bundle id from sandbox container path (e.g. mac:com.openai.chat)
7cwd basename (workdir)Always, last context tier
8Smart 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:

  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 Tier 3 and Tier 4a based on "confidence" would produce different slugs for identical workflows.
  2. 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

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

~: 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