TIER docs

TIER — How It Works Today

v0.7.0 identity, coaching and retention: Developers and their declared viewers can open verified coaching cards, with every view logged before construction. Real-data developer startup requires an active permitted viewer credential, a nonempty admin token and a named audited operator. Shared reads are name-free. Writable developer mode starts raw-data retention at startup and daily; once armed, it continues in team/division mode. See developer access and upload identity for current commands and restrictions.

Grounded in: the source in this repository. Every behavior below is anchored to a named file, symbol, or issue number rather than a line number or commit hash, so each claim stays checkable against the code you have checked out.

Audience: engineers evaluating whether TIER is ready to run on their own repository.

Tone: what it does, what it doesn't, no marketing.


Table of contents

  1. What this tool does in one sentence
  2. What LLMs it works for
  3. How it captures the amount of tokens being used
  4. How it assigns functionality (outcome attribution)
  5. How it gives purpose (linking cost ↔ outcome)
  6. How it says "this many tokens + this much code = this much value"
  7. How it scores task size and complexity
  8. How it all comes together
  9. Is it ready to dogfood?
  10. Event 3: enabled operation

1. What this tool does in one sentence

TIER prices recorded AI usage, attributes captured calls to GitHub issues where possible, and divides recorded shipped outcomes by recorded AI reference cost to produce a TIER score. Capture completeness and inference validity require separate evidence; a healthy collector does not establish either.

Current startup and report scope: tierd serve --watch-repo <path> --aggregation team captures Claude Code JSONL live. Real-data developer mode requires #1370 subject-bound credentials; team/division dashboards publish sealed calendar months, rather than live spend. Solo users get their own Claude Code cost table and the issue-attributed share of that spend with tierd score, plus the dashboard on invented data with tierd demo; they do not get a TIER number on their own real data. See the quickstart for the trusted-use scope, capture, outcome loading and sealing steps.


2. What LLMs it works for

Two axes matter: which provider, and which capture path.

Provider Local session logs Reverse proxy Manual REST Status
Anthropic (Claude API) Yes — Claude Code JSONL (default) Yes (JSON + SSE on #14) Yes Works in v1
Codex CLI (OpenAI) Yes — rollout logs ¶ Parser exists, never live-verified — Responses API (JSON + SSE, #459 task 2) ¶ Yes Works in v1 (collectors.codex_rollout / --codex-rollout, #464)
Opencode (any provider it serves) Yes — its SQLite session store ◊ Possible in principle, and do not do both — the two keys cannot dedup, so the spend DOUBLES ◊ Yes Works in v1, for routes with an audited rate (collectors.opencode / --opencode, #719)
Muse Code (Meta) Yes — its session logs (§3d) No — the proxy has no Meta route Yes Works in v1 (collectors.muse / --muse, #895), measured on one real Muse Code 1.4.0 session; subagent spend is filed under the parent run that spawned it (#901), and some subagent spend is excluded, with a warning (§3d lists the cases)
OpenAI n/a Yes (JSON; SSE on #14) † Yes Wired; not yet tested against live traffic
xAI (Grok) / DeepSeek n/a Expected via the OpenAI-compatible parser; not tested against live traffic † Yes Untested
Google Gemini n/a Yes (JSON + SSE) ‡ Yes Wired, live-unverified (#459 task 4 — route mounted; no live Gemini traffic has hit it yet)
Self-hosted (vLLM etc.) n/a Yes via OpenAI-compat † Yes Works in v1
Anthropic Admin API — — — Wired; not yet tested against live traffic (org-level poller, collectors.anthropic_admin, #138)
OpenAI Usage API — — — Wired; not yet tested against live traffic (org-level poller, collectors.openai_usage, #139)
Cursor team with Admin API access — — — Supported for team costs — team Admin API poller, collectors.cursor_admin; requires a team plan with Admin API access; individual accounts, own-API-key requests and Cursor's fee on them not covered
GitHub Copilot organisation — — — New in v0.6.2, experimental — AI-credit gross-spend poller, collectors.copilot_billing; individual Copilot Pro not covered
ChatGPT Team / Plus — — — Deferred to v1.5

† One OpenAI-compatible upstream at a time. The proxy exposes a single /openai/ mount backed by one openai_target. Capturing xAI, DeepSeek, or a self-hosted OpenAI-compatible endpoint means repointing that single openai_target at it — not running it alongside OpenAI on a second route. xAI (Grok) and DeepSeek serve OpenAI-compatible APIs, so capture through that mount is expected to work, but neither has been tested against live traffic: verify that the recorded token counts match the provider's own usage report before relying on them.

‡ Gemini's route is now mounted (JSON and SSE), but no live Gemini traffic has verified it (#459 task 4). tierd serve mounts /gemini/ (--gemini-target / proxy.gemini_target, default https://generativelanguage.googleapis.com) through the same registerProxy path as /anthropic/ and /openai/, wired onto the parseGemini / geminiStreamParser pair (internal/proxy/proxy.go, internal/proxy/sse.go) that has existed since v1 (#1), gained thinking/cache-token handling in #122 and host stamping in #300, and has been unit-tested against synthetic bodies the whole time. What changed: pointing a Gemini SDK at TIER no longer 404s. What has NOT changed: no request has ever round-tripped through the mounted route to a real generativelanguage.googleapis.com response — that live proof is a credential-gated integration test (TestLive_GeminiProxy_RealCompletion, skips loud without TIER_LIVE_GEMINI_KEY) that has not been exercised against a real key yet. Treat it as structurally complete, not field-proven. (Manual REST POST /api/v1/costs can still import Gemini spend regardless.)

Admin / Usage pollers are org-level. The two poller rows (#138 / #139) are opt-in collectors: config blocks that poll each provider's org usage/cost API on a settled-day cadence, write coverage-remainder token_events (the gap between realtime capture and the provider aggregate) and reconcile org_actual_spend deltas. They are org-spend reconciliation feeds, not per-developer real-time capture. For one org, the poller and manual POST /api/v1/costs rows are alternatives: while a provider's poller runs, /costs refuses that provider's rows unless they declare they are billed elsewhere (POST /api/v1/costs, #854).

◊ Opencode is captured from its SQLite store, and only where a rate exists. Opencode does not write JSONL — it keeps one JSON blob per message in a message table in ~/.local/share/opencode/opencode.db. collectors.opencode (or --opencode) opens that database read-only and reads it directly: local, per-developer, per-call, no credential and no env-var change (#719). Two things about it are unlike the other local paths and are easy to get wrong. First, its token arithmetic is the OPPOSITE of Codex's: Opencode reports reasoning tokens BESIDE output rather than inside it, so billable output is output + reasoning. Measured on 2026-08-28 across the maintainer's entire GLM-5.3 corpus, total == input + output + reasoning + cache.read on 3,943 of 3,943 rows, and reasoning EXCEEDS output on 77% of them — mapping it the Codex way would drop 84.8% of the output-side bill. Second, it captures only routes with an audited per-token rate, because Opencode serves several providers and its own cost field is 0 on every row. Today that means the Z.ai coding plan (zai-coding-plan) and nothing else; Ollama's cloud tier is deliberately excluded because it publishes no per-token rate, and capturing it would add guessed spend for ~342M measured tokens to the denominator of a cost-per-outcome metric. Roughly $171 assumes every token prices at self-hosted-medium ($0.50/M); the token total alone does not establish that figure, because the guess depends on the model names and size classes (§3h). The exclusion and its reason are logged at startup and its row count after every scan, so what is left out is visible rather than inferred. The embedded table prices glm-5.3 and glm-5.3-flash per token at list price (#786), so GLM-5.3 needs no price-table override; a GLM model with no embedded row prices at the guessed fallback, and tierd serve logs a WARN naming it the first time it prices one.

¶ Codex is captured from local logs. That is the supported path and the only one verified against real Codex data: Codex writes per-session rollout logs to ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, and collectors.codex_rollout (or --codex-rollout) reads them directly — local, per-developer, per-call, no credential and no env-var change (#464). Until #459 task 2 the proxy could not have captured Codex under any configuration: Codex speaks the OpenAI Responses API (input_tokens / output_tokens), while both OpenAI proxy paths read only the Chat Completions usage shape (prompt_tokens / completion_tokens), so a Codex response routed through /openai/ yielded no TokenEvent (#463). The proxy now parses the Responses shape on both paths, but that changes little for Codex in practice and nothing about the recommendation: it captures only traffic you deliberately point at the proxy with API-key auth (ChatGPT-subscription auth never traverses it), and no live Responses response has ever reached that parser — its fixtures are synthetic, and #459 task 3 (credential-blocked) is what would verify it. Use the rollout logs.

Why Claude is special. Claude Code writes a per-session JSONL log to ~/.claude/projects/**/*.jsonl automatically. TIER reads those files — zero configuration, no proxy in front, no env-var change for the developer. That's the "Day 1" capture path.

Codex works the same way. Codex also writes local session logs, so it gets the same zero-configuration treatment: enable --codex-rollout and TIER reads ~/.codex/sessions/**/rollout-*.jsonl directly (#464). It is scoped to the same repository targets as the Claude Code watcher — --watch-repo under serve, --repo under ship — so a Codex session run in another repo on the same machine is dropped rather than mis-attributed. It inherits the same --repo-slug overrides too (#231), which is what keeps a fork's Codex and Claude Code rows on one repo identity instead of splitting its cost in two.

Muse Code works the same way, with two differences. Meta's Muse Code CLI writes one session log per session to ~/.local/share/muse/sessions/YYYY/MM/DD/<session-id>/session.jsonl, and --muse reads them under the same repository scoping and --repo-slug overrides as Codex (#895). First, a call is recorded only after the run it belongs to has finished, because Muse writes the run's git branch after the run's last model call. Second, a subagent's session log records no working directory and no branch, so its calls take both from its parent: the repository from the session log it sits under, and the branch from the parent run that the parent's log says spawned it (#901). Some subagent spend is left out, with a warning, rather than guessed onto a branch; §3d lists the cases.

Capture through the proxy. For a custom OpenAI/Anthropic SDK consumer that sends no local usage records to TIER, you can point ANTHROPIC_BASE_URL / OPENAI_BASE_URL at tierd serve, which acts as a transparent reverse proxy and reads usage blocks off the wire. (A Gemini client can be pointed at tierd serve's /gemini/ mount the same way — retarget its base URL — but Gemini authenticates upstream with an x-goog-api-key header or a ?key= query parameter rather than an env var — prefer the header, since a ?key= value can land in the access logs of anything in front of tierd (see the README's proxy section) — and this path is structurally complete and not yet live-verified; see the ‡ note above. Codex API-key traffic deliberately routed through /openai/ can be captured by the Responses parsers, but that path is live-unverified; use rollout logs (§3b), including for ChatGPT-subscription auth. See ¶.)

What the org-level pollers cover. The Anthropic Admin poller (#138) and the OpenAI Usage poller (#139) both shipped, and neither is yet tested against live traffic (both are exercised only against local mock servers; the Anthropic one's key-gated live test has not yet run): opt-in collectors: config blocks poll each provider's org usage/cost API, backfill coverage-remainder token_events, and reconcile org_actual_spend. New in v0.6.2, experimental collectors.copilot_billing reads GitHub's gross amount (before included credits and discounts) for organisation AI-credit usage per mapped developer and model, plus an unattributed organisation remainder. It needs a GitHub App installed on the organisation with Administration: Read-only for spend (preferred), or an org-admin token; organization GitHub Copilot Business: read is needed only for counting through root mappings; individual Copilot Pro accounts have no organisation billing API. collectors.cursor_admin is supported for team costs and reads team usage events with a team Admin API key on a Cursor team plan with Admin API access (Teams or Enterprise; Cursor decides which plans include it); individual Cursor accounts are not covered. Requests using a person's own API key are left out, including the small amount Cursor itself charges on them. Neither source supplies repository or issue attribution. ChatGPT Team remains uncaptured. The Cursor live check covered one team member over two days; Copilot remains experimental. See the per-plan matrix.

Cursor pricing and gaps. A token-based call on a model with an audited TIER rate is valued at list rate, whether included-plan or usage-based; any other event uses Cursor's reported cents. Events whose kind contains api key (case-insensitive; observed BYO kind: User API Key) are skipped. Their provider spend is captured by anthropic_admin or openai_usage only when the matching poller is configured for the billed organisation; Cursor's small per-request fee on those events is not counted. If a chargeable event falls back to Cursor's reported cents and the resulting cost is zero, it is uncosted; this covers non-token calls on any model and token-based calls without an audited TIER rate. An unresolved day is recorded as a lost span, and sealing that month requires tierd seal --skip. For a token call without an audited rate, adding the model's price before the day leaves lookback_days can resolve it on a clean re-read before loss recording; late discovery records loss immediately, and a fresh process treats earlier certification as possibly complete.


3. How it captures the amount of tokens being used

The capture paths in §3a–3f feed one table: token_events. The schema is the token_events table in schemaTables (internal/store/store.go).

3a. JSONL (Claude Code, default)

Two entry points share parseSessionFile / joinSessionsToCommits, so both produce byte-identical rows for the same input:

Both paths share these guarantees:

Bare #N in a commit subject is recorded as nullable pr_number; close directives and tracker keys retain precedence. JSONL events record branch_hash: lower-case SHA-256 of canonical repo + "\x00" + branch, using the full session branch after any worktree override and removing only refs/heads/. Main, master, HEAD, empty/detached branches and an unqualified repo have no hash. Raw branch names are never stored in token_events; local watcher checkpoints keep them in the plaintext JSON fields GitBranch and LastRealBranch.

At insert, an indexed (repo, pr_number) lookup resolves an unattributed event only through exactly one real-issue outcome, stamping server-owned pr-number-at-insert (rejected as client input). Explicit attribution wins, unknown PRs keep their number, and sealed months are untouched. Recording a merged PR also fills still-unattributed, unsealed events on (repo, pr_number) with that rule, in the outcome's transaction.

Branch credit fires on live webhook merges only (#1074, R-2026-10-11-3), matching the outcome's pseudonymised head_branch_hash against events in the same repository. Backfill records hashes but does not run branch credit because its newest-first order cannot establish predecessors. Live branch credit applies only to unattributed events with no PR number inside the inclusive window [max(previous merge on that repo and head branch, PR opened − 7 days), merge], stamping server-owned branch-at-merge. Existing attribution, sealed months and every sealed report's 14-day token lookback are untouched; events covered by multiple recorded PR windows stay unattributed. Reusing a branch name limits the next PR's window to the previous merge, so the next PR cannot take earlier-window events. This uses recorded PR evidence; it cannot anticipate a PR whose merge has not been recorded.

Webhook and backfill outcomes hash head.ref only when head.repo.full_name matches the outcome repository (case-insensitive); fork or missing head repositories leave NULL. They use the same hash function as events; main, master, HEAD and the repository's payload-supplied default branch have NULL hashes. The outcome never stores raw head.ref. Missing head, PR-opened or valid merge evidence disables branch credit; API outcomes have no head-ref field and leave the hash NULL. Historical outcome rows stay NULL. Hashing is pseudonymisation: guessable branch names can be recovered by hashing candidates with the known repository.

3b. Codex rollout logs (Codex CLI, opt-in)

3c. Opencode session store (opt-in)

3d. Muse Code session logs (opt-in)

3e. Reverse proxy (Anthropic / OpenAI / Gemini direct)

Import contention and capture failures (EXTREME, #1488, ruling A‴). While an import applies, live events wait and aren't dropped. They can be lost if the process dies, including being killed for memory on a small server, if the 30-minute cap passes, or if the write fails after the wait; TIER flags a report when that may have happened. Lost proxy events can't be recovered.

The lock estimate is a range: about 20 seconds per 100,000 events on a fast machine, about 60 on one core, and slower servers can take many times longer. Allow about 1.4 GB of memory for a 64 MiB bundle; a 1 GB server may be killed for memory, losing live events in flight. The byte cap does not bound memory to 64 MiB.

During the source pull and apply, proxy inserts wait on an in-process signal without a database connection. JSON and SSE responses complete while capture continues afterward, retaining only the event and tracked by the same shutdown drain. Outside imports, the existing 90-second budget and roughly 250 ms lock tries are unchanged. After the signal clears, that normal budget restarts. Preview and apply clear the 60-second HTTP write deadline after root auth. On SIGTERM, closing active HTTP connections cancels an unfinished import; capture drains before the store closes.

Each source pull or apply durably records its own unique attempt before its lock. A failed record write refuses the operation. Every exit removes only its own record with a detached context. Startup treats any leftover record as a possible loss and sets the durable data_quality.live_capture_may_be_lost flag; read-only startup reads that warning without writing it. Exhaustion and final write failures also set the flag once a write is possible, increment tier_proxy_writes_total{provider,outcome="error"}, and log an ERROR without names, credentials or underlying error text.

Webhooks keep today's 503 on write-lock contention and must be manually redelivered; GitHub does not automatically redeliver failures. Other routes, including POST /api/v1/costs, retain their documented 503 and Retry-After responses. The 64 MiB limit and all-or-nothing apply are unchanged.

3f. Manual REST

Trunk-based support: push-to-default-branch capture (closes #196)

Teams that commit straight to the default branch behind feature flags never fire a pull_request merged event, so without this their AI cost scores ~0 (exactly what the #189 tripwire warns about). With outcomes.push_capture enabled (--push-capture / TIER_PUSH_CAPTURE / config outcomes.push_capture; OFF by default), a qualifying direct commit to the default branch becomes an outcome via the same signature-verified push webhook. HMAC verification runs first, exactly as on every other webhook path — capture never sees an unverified body.

What qualifies and how it is scored (the LOCKED contract):

Push commit on the default branch Handling
Close directive (including colon forms such as fix: #42) or tracker key in the git %s subject (first paragraph, folded) Attribution via issueref.FromCommitSubject: the leftmost close directive wins, then a tracker key. Captured as one outcome per (repo, issue, UTC day). A bare #N or a reference only in the body does not attribute: logged and counted as unattributed (tier_push_unattributed_total)
Second commit, same issue, same UTC day Folds into that same one outcome (idempotent upsert — never 0.5×N)
Squash-merge push whose SHA == a stored merge_commit_sha Skipped — the PR webhook already captured it (constraint #1)
Squash-merge push that arrives before its PR event Captured, then removed by the PR outcome when it lands (#849; see Reconciliation below)
Subject starts with Merge pull request #N (digits), Merge branch , or Merge remote-tracking branch Skipped by a subject-prefix heuristic (constraint #2), not a parent-count check; the push payload has no parent list. A push carrying Merge pull request #N beside other captured commits is logged at WARN and counted in tier_push_merge_commit_captures_total: those can be the merged PR's own branch commits, counted twice (#934). A rebase merge leaves no merge commit and is not counted
Revert … Skipped by capture — the revert path degrades the original outcome instead
Non-empty changed-path list is all generated/vendored (#240) Skipped — INFO log, no counter. Missing or empty path lists do not trigger this exclusion
No resolvable issue id (or no GitHub author login, or an author login reserved for the unattributed pseudo-developer — #619, or no usable commit id — #849) Not scored, but observable: INFO log + tier_push_unattributed_total counter (never a silent drop)
Any commit on a non-default branch Ignored — captured, if at all, when its PR merges

Generated-path config. outcomes.generated_paths replaces the built-in push-capture deny-list: a trailing / matches a directory at the root or any nested segment, a leading * matches a filename suffix, and other entries match an exact basename (case-sensitive). Omit it to keep the defaults; [] disables exclusion. It does not affect PR weights (internal/config/config.go, internal/webhook/handler.go).

Weight-source (degraded fixed weight, zero new dependencies). A GitHub push payload carries no diff stats and the webhook has no local clone, so a push outcome takes the honest degraded floor weight = 0.5 (store.GitHeuristic(0,0)) with no outbound GitHub API call. Its provenance is recorded as weight_source='push' — deliberately distinct from git-heuristic so a capture-fidelity 0.5 is never pooled with a measured tiny-diff 0.5.

Aggregation grain (per-repo, per-issue, per-UTC-day). All qualifying direct commits sharing a repository and an issue within one UTC calendar day collapse into a single 0.5-weight outcome, enforced by an idempotent upsert keyed on (repo, issue_id, push_day) (partial unique index idx_outcomes_push_daily_repo, WHERE source='push'). This closes the commit-splitting inflation vector at the capture layer — direct commits have no PR/merge_commit_sha backstop, so summing 0.5×N-commits would let a developer farm points by splitting work into many commits. Replaying the same day's push writes nothing.

Reconciliation with the PR path (#849). GitHub sends a squash merge's push and pull_request events in no fixed order, and with push capture on the push-first order used to store the merge twice. Every captured commit is now recorded in a per-commit ledger (push_outcome_commits, unique per (repo, commit SHA)), and each direction runs in one write-locked store transaction. A push commit whose SHA is already a PR outcome's merge_commit_sha is skipped; a PR outcome removes its merge commit from the ledger and deletes the push outcome only if that was its last commit. Otherwise the push outcome stays and its developer and ts are re-derived from its earliest remaining commit, so the same deliveries give the same rows, owner included, in any order. A redelivered event writes nothing, and concurrent deliveries cannot both insert. Each deletion or re-derivation is recorded in push_outcome_audit, which the report manifest watermarks. Push outcomes written before the upgrade hold commits the ledger never saw, so they are never deleted: a double count already stored stays. Every PR insert path reconciles the ledger: the pull_request webhook, tierd backfill and POST /api/v1/outcomes. The merge-commit check and the removal both match the SHA in any repository, as the install-wide merge_commit_sha index does. The webhook's writes wait for SQLite's write lock (the 5s busy timeout) rather than answering 503 at once, because GitHub does not redeliver a failed webhook.

Who owns a push outcome (#938). A push outcome is credited to the developer of its earliest commit, ordered by GitHub's push time (repository.pushed_at in the push payload, which GitHub sets), then by commit time, then by SHA. Commit time alone is not used, because whoever makes a commit sets it: before #938 a later push of a commit dated earlier the same UTC day took another developer's row. The commit's own date still decides which UTC day's row a commit joins, so a push of a commit dated on a later day takes that day's row unless another commit on it was pushed earlier. Commits in one push share its push time, and pushed_at counts whole seconds, so two different pushes in the same second also tie; in both cases the commit time decides. The push time is stored as each commit's push_order in push_outcome_commits. A commit that reaches the default branch in more than one push (removed, then reintroduced) keeps its earliest push time, whichever of those pushes is delivered first. Commits recorded before the upgrade, and the marker for a push outcome written before the ledger, have push_order 0, so they keep precedence and no stored outcome changes owner on upgrade. A payload with no usable pushed_at (absent, null, a non-positive value, or neither an integer of Unix seconds nor an RFC 3339 string) is still recorded, keeping its credit and the double-count guard, but it sorts after every push time and, among commits with no push time, after every one recorded before it, so it never takes a row over; TIER never substitutes its own clock. Such a push is logged at WARN, and each commit it records is counted in tier_push_missing_pushed_at_total.

What this does not change: push credit goes to the commit's GitHub author (author.username, which GitHub derives from the commit's author email), which the committer asserts, so anyone who can push to the default branch can credit a commit to someone else. For strong attribution, make the default branch PR-only (branch protection or a ruleset), so work arrives through the pull_request path. Every re-own is recorded in push_outcome_audit: as a rederived_from / rederived_to pair, except that a re-own caused by erasing a developer writes the rederived_to row alone, since the before-image would name the erased developer.

Comparability caveat. Push-grain outcomes are NOT directly comparable to PR-grain outcomes: they carry a degraded (0.5) weight and per-repo-per-issue-per-day granularity rather than a per-PR label/diff weight. Scoring and audit views can segment on outcomes.source (github-webhook / api-outcome / push) to keep the grains apart; mixing them in one column would under-represent trunk-based work relative to PR-based work of the same size.

Known quality gap (documented). #134 CI signals (workflow_run) resolve their target outcome by merge_commit_sha, which push outcomes leave NULL, so CI pass/fail floors do not reach a push-captured outcome. Revert degradation still applies via the issue-id tier. Closing the CI gap — and upgrading a push outcome's weight_source: push → push_enriched from a later periodic diff-enrichment reconciler (Option C) — is a deliberately deferred future upgrade, strictly additive, and not built here.

Coexistence with the merge_commit_sha UNIQUE (#60). A push outcome aggregates several commits and has no single merge commit, so it stores merge_commit_sha = NULL and lives entirely outside the idx_outcomes_merge_commit_sha_uq partial index. Its idempotency rests instead on the disjoint (repo, issue_id, push_day) WHERE source='push' partial unique index — the two dedup domains never overlap. Both indexes are shaped so a future tenant_id can become their leading column without a redesign.

3g. Dedup model

The idempotency_key column has a partial unique index over non-NULL values (idx_token_events_idempotency in schemaPostMigration, internal/store/store.go):

3h. Cost calculation

ComputeCost in internal/store/prices.go. The reference price table is the embedded internal/store/prices.yaml (//go:embed, the single source of truth per #68), in v0.6.0, version 14, effective 2026-10-12, with 93 models, parsed once at startup and overridable at runtime with tierd serve --aggregation team --prices /path/to/prices.yaml (an override needs a version: the embedded table does not use (conventionally 1000 or more) and that this database has not recorded for a different table, and any change to the resolved table — not only a rate — needs a new one; see §9). NormalizeModel strips date/version suffixes (claude-sonnet-4-20250514 → claude-sonnet-4) before the lookup. Models without a table entry first pass through selfHostedClass: case-insensitive substrings nemotron-ultra or deepseek-r1-full select large; otherwise phi-4-mini, embeddings, or reranker select small; otherwise the first parameter count in billions matched by paramCountRE selects large at ≥70B, medium at 7–<70B, or small below 7B. The embedded combined rates are $2.00/M large, $0.50/M medium, and $0.10/M small. Only when nothing matches does the guess default to self-hosted-medium at $0.50/M combined; both guess paths emit a one-time WARN per normalized model, except for placeholder names (empty or containing < or >), subject to a shared process-wide nominal cap of 1,024 models (a small concurrent overshoot is possible), after which further models are still priced and counted but no longer warned individually (closes #3). The WARN exists precisely so a new minor version like claude-opus-4-8 shipping before the table is updated doesn't quietly bill at the wrong rate.

3i. Bulk export of raw rows — paginated JSON / CSV (closes #191)

/scores returns only the computed roll-up. A CFO reconciling spend against an invoice, or a BI pipeline loading a warehouse, needs the underlying rows — but an all-rows dump would be unbounded for a 500-developer org. Four read endpoints add a bounded, paginated export:

(The POST halves of the events/outcomes paths are the ingest endpoints in §3f (Manual REST); Go's net/http ServeMux routes by method+path, so the GET export and POST ingest coexist on one path.)

The two quality exports (#242) make the multiplier chain re-derivable from a BI export — quality == last new_quality — closing the gap where the audit chain was only reachable via the erasure-scoped DSAR export (GET /api/v1/developer/{id}/export), which is subject-scoped, not BI-scoped. They share the auth, team-mode, windowing, keyset, page-size, and content-negotiation rules below. quality_history.ts is written by SQLite CURRENT_TIMESTAMP (second precision) rather than in Go form, so its export keysets on that column with second-precision bounds; this is internal and invisible to the client, which still just echoes the opaque cursor.

Auth (#190): all four are read-scoped — the read-only viewer token is accepted alongside the write/admin token (this is the CFO/BI read use case), so a viewer can pull the data without the write/erase power the api-token confers. No token → 401; a wrong token → 401.

Anonymized aggregation modes (team #185, division #270) → 403. Raw rows carry a per-developer developer column. In team or division mode the deployment has committed (works-council / GDPR) to not exposing individual-level data, and a row-level export cannot be k-anonymized while staying a useful export — so all four endpoints return 403 in either mode, checked before any query. In developer mode they work normally. (Do not "fix" this by filtering/aggregating the developer column — that silently breaks the same guarantee /scores and /scores/{developer} enforce.)

Windowing. ?since= and ?until= accept YYYY-MM-DD, YYYY-MM, or YYYY and are interpreted as UTC (the same since-window handling as /scores, #180). The window is half-open [since, until) — a row exactly at since is included, a row exactly at until is excluded. since omitted defaults to the start of the UTC day 90 days ago (#746 — the bound is snapped backward so the window opens on a whole day, which is what makes a default report re-runnable); until omitted is an open upper bound (all newer rows).

Keyset (cursor) pagination. Rows are returned in strict (ts, id) order. Each response carries an opaque next_cursor (JSON body field next_cursor and the X-Next-Cursor response header on both JSON and CSV); pass it back as ?cursor= to fetch the next page. An empty cursor means the window is exhausted — stop paging. Keyset (not limit/offset) is used deliberately: these are append-heavy tables, so a cursor is stable under concurrent inserts and never does a deep-offset scan (a (ts, id) covering index backs the scan). A malformed cursor is a 400, never a 500 or a full-table scan.

Page size. ?limit= defaults to 1000 and is hard-capped at 10000. A request over the cap is rejected with 400 (loud, not silently clamped); limit=0, a negative, or a non-integer is also 400. The store enforces the cap again server-side, so a single page never buffers more than 10000 rows in memory regardless of the request.

Content negotiation. Accept: text/csv yields CSV (RFC 4180, with a header row; commas/quotes/newlines in any field are escaped by the standard encoder). Anything else — including no Accept header — yields JSON, the default. CSV clients read the next cursor from the X-Next-Cursor header.

CSV formula protection (#1095). All four bulk exports prefix a single quote (') to text cells beginning with =, +, -, @, tab or carriage return. Numeric columns remain numeric (including negative values such as -1.5); JSON values are unchanged. Use JSON when the original text is required without the CSV safety prefix.

CSV column order (a stable compatibility contract — columns are only ever appended, never reordered or removed). ts is RFC3339 UTC; cost_micro is integer micro-dollars (1 USD = 1_000_000).

GET /api/v1/events:

id, ts, developer, issue_id, model, input_tokens, output_tokens,
cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens,
cost_micro, source, fidelity, idempotency_key, repo, session_id,
price_version, host, billing_mode, billed_to, attribution_rule,
pricing_basis, rate_snapshot, cache_write_evidence, issue_rule

GET /api/v1/outcomes:

id, ts, developer, issue_id, pr_number, weight, weight_source, quality,
merge_commit_sha, additions, deletions, changed_files, source,
work_type, work_type_source, repo, push_day, quality_status, issue_rule

issue_rule is appended last on both exports, after conditional Event 3 outcome columns when enabled. NULL renders as legacy; recorded labels render verbatim. The schema-12 singleton issue_rule_cutover records the UTC migration time and maximum outcome/event IDs once, distinguishing the pre-upgrade band from later NULL rows without changing extraction or historical assignments (cutover details). Outcome provenance comes only from server extraction in webhooks/backfill. Event provenance is a closed-set client assertion; unknown or absent values store NULL, and an unknown value warns once. It never supplies outcome evidence or upgrades a row to strict. Older servers use the shipper's existing single-resend unknown-field fallback. See the wire contract.

On the events header, repo (#231) and session_id (#238) are likewise appended at the end — a consumer pinned to the earlier columns is unbroken. session_id is the opaque Claude Code session UUID and is empty for rows a session-blind producer (proxy / poller) captured.

On the outcomes header, repo (#231), push_day (#242), and quality_status (#1228) are append-only columns. push_day is the UTC calendar day a source='push' outcome aggregates to — the per-day half of the (repo, issue_id, push_day) dedup key the #196 partial unique index is built on — and is empty for a PR outcome (the NULL column), matching merge_commit_sha. Without it a push row exported with pr_number=0 and merge_commit_sha="" had no visible aggregation key, so an external org running trunk-based capture could not verify the one-outcome-per-day dedup from its own export.

price_version (#233), host, and billing_mode (#304) are append-only columns after session_id — a consumer pinned to any earlier column index keeps reading the same field. price_version is the price-provenance column (which price table produced cost_micro); host and billing_mode are the host-aware pricing discriminator (the serving host that priced the row, and whether cost_micro is canonical per-token or a derived/approximate figure).

billed_to (#854) is appended after them: other on a POST /api/v1/costs row that declared its spend is not billed to an org whose usage poller the server runs, and empty on every other row. See POST /api/v1/costs.

attribution_rule (#823) is appended after billed_to: the rule that assigned the row's issue_id — branch, worktree-cwd, worktree-toolpath, carry, pr-number-at-insert or branch-at-merge (#1074: server-assigned, never client-sent) — with legacy where no rule was recorded and unknown for a stored value outside that set. See GET /api/v1/events.

work_type and work_type_source (#187) were appended to the outcomes header, and repo and push_day follow them — the append-only contract means a consumer pinned to the pre-#187 columns is unbroken. The GDPR data-subject export (ExportDeveloper, #184) carries the same two fields on every outcome row.

GET /api/v1/quality_events (#242) — event_ts and recorded_at are RFC3339 UTC:

id, outcome_id, developer, issue_id, event_type, source_ref, event_ts, recorded_at, reason, status, evidence

reason, status, and evidence (#1228) are appended after the original eight columns. An ambiguous revert records revert_ambiguous, reason="ambiguous", status="unknown", and evidence=false for each qualified candidate, without a penalty or quality-history transition. Recognized CI and resolved revert events are observed/true; unrecognized event types are unknown/false. Count distinct revert source_ref SHAs rather than candidate rows or webhook deliveries.

The outcomes quality_status column is unknown when an unresolved or unrecognized check exists, otherwise empty (omitted in JSON). Empty does not establish observed quality. This diagnostic never changes numeric quality.

GET /api/v1/quality_history (#242) — ts is RFC3339 UTC; old_quality/new_quality are the multiplier before/after the transition:

id, outcome_id, developer, issue_id, old_quality, new_quality, reason, source_ref, ts

The JSON row objects carry the same fields under the same names (e.g. input_tokens, cost_micro), plus the top-level next_cursor; the row arrays are keyed events / outcomes respectively.


Pricing provenance and daily approximations (#1231, #1232)

New token-derived rows retain an immutable, hash-addressed effective rate snapshot and a retained pricing implementation (request-v1 or aggregate-v1), alongside the original host, billing mode and table version. rate_snapshot resolves to pricing_rate_snapshots.body; its SHA-256 identity includes the resolved rates, table hash and rule. A new rule implementation requires a new rule name. Rate snapshots contain no developer observations. Initial observations, promotions and conflicts are kept in the developer's exportable, erasable pricing_observation_audit.

A partial-to-final promotion selects one whole token vector and updates its counts and cost in one bounded write transaction under the original basis. Explicit nested TTL evidence outranks legacy all-5m allocation; incompatible observations remain recorded as conflicts. An identical replay neither reprices nor adds an audit. A price-table increase does not change the basis of a replay. Caller-supplied dollars and overrides keep their costs, the $10,000 row clamp still applies, and sealed months and their input bands are protected. A pre-existing mismatch blocks automatic promotion until explicitly repaired.

tierd doctor cost-consistency --db <path> reports consistent, inconsistent or unverifiable. Missing snapshots or unsupported rules are basis_unavailable; potentially affected cache rows without retained contributor observations are ttl_split_unverifiable. Neither is reconstructed using today's table. A historical provider aggregate with a retained tiered rule is poller_aggregate_tiered: potential aggregate-tier exposure, not proven overbilling, because individual request lengths are absent.

Historical correction is a separate operation from tierd reprice (normalising to the currently loaded table):

tierd doctor cost-consistency --db <path> --version <exact-original-version> --dry-run
tierd doctor cost-consistency --db <path> --version <exact-original-version> --apply

If the snapshot is absent, explicitly declare a preserved original YAML with --original-table <original.yaml> --original-rule request-v1 on both commands. The YAML must match the unique recorded registry identity of that exact version, including forgotten identities; reused or missing identities fail closed. This does not load those rates as the active table. Dry-run recovery rolls back its references and snapshots; apply records basis recovery and each cost repair atomically. Output reports gross increases and gross decreases separately. Unknown cache contributor provenance is still unverifiable after recovering rates. The old automatic cache-cost recompute and version-0 stamp are retired on upgrade. original-basis-history-preserved-v1-2026-10-14 distinguishes that retirement from a migration that actually repriced rows; compatibility markers prevent an older binary from running those retired transforms. Unit conversion preserves the stored dollar value; unknown original version 0 stays unknown. No historical repair is implied by upgrading the binary; completed poller rows, caller-authoritative costs and sealed bands remain protected, including from general repricing.

The named prospective cutover is aggregate-approx-v1-2026-10-14, recorded once with its installation activation time in tier_migrations.applied_at. Newly started provider poll days, including previously unpolled backfill dates, use ComputeAggregateCostHost: subtract captured usage per class first, then price the remainder at base rates with request-tier thresholds disabled. These rows store pricing_basis=aggregate_approx and aggregate-v1. Request pricing is unchanged. Existing poll days inherit their first pricing policy when a missing model is added; completed rows keep their counts, cost and basis on re-poll. Actual-paid delta reconciliation is unchanged.

Daily remainders lack request-length evidence. Two 60K requests and one 120K request cannot be distinguished from a 120K aggregate: the approximation avoids charging the two small requests as one large request, but may understate genuinely long-context cost and raise TIER. /scores, both /scores/compare windows and the dashboard disclose this. Historical windows also state that stored provider aggregates can differ from request-level billing in either direction. When all original rates are resolvable and per-token, the disclosure includes an highest-rate effect ceiling using the higher base/tier rate for each of input, output, read, 5m-write and 1h-write; for the shipped monotone tiers this is the all-premium case. Missing or estimated bases explicitly make a complete ceiling unavailable; active rates never fill the gap. This is a rate-effect bound on aggregate counts, with one aggregate rounding step. Per-request rounding and unrecorded fees are outside it; it is not a provider-invoice bound. The dashboard rounds it up to the cent. Anonymous views show a constant conditional policy notice and suppress population and ceiling amounts; provider pseudo-developer remainders are outside identified-group denominators.

The new raw export columns are appended after attribution_rule: pricing_basis, rate_snapshot, cache_write_evidence (nested, legacy, or empty for unknown). Older agents may omit evidence; mixed-version shippers retry once only on an exact unknown-additive-field 400. Older receivers cannot retain that new evidence. The constant X-TIER-Aggregate-Pricing read-response header and a dashboard fallback keep the policy visible for array/CSV views and older sealed bodies without changing their stored bytes or exposing row presence. Live provenance disclosures are operational diagnostics outside the existing numeric input-digest format; newly sealed bodies bind their emitted disclosure through body_digest. This is not an independent billing reconciliation or a reproduce-it-yourself claim.

4. How it assigns functionality (outcome attribution)

Attribution

The outcomes side runs on GitHub webhooks. Handler at internal/webhook/handler.go.

The handler processes three GitHub event types (internal/webhook/handler.go — pull_request, push, workflow_run); every other delivery is ignored.

GitHub event Trigger Effect
pull_request closed+merged PR merged into base Inserts an outcomes row: weight from PR labels (or git heuristic fallback), quality = 1.0
workflow_run completed A CI run finishes on the merge commit's default-branch pipeline (#134) A failure within the 48h observation window appends a ci_fail event that floors that outcome's quality to 0.7; a same-SHA success for the same workflow_id, with a strictly later run_attempt and finishing within 30 minutes of the failure, appends ci_fail_flaky to neutralise that failure only if the matching failure is already stored when the success arrives. Otherwise it appends ci_pass (no penalty); a failure delivered later stays penalised.
push (revert) A commit pushed to the repository's default branch only, whose subject starts with Revert ... Resolves the original outcome (by the merge_commit_sha footer, then by issue-id) and appends a revert quality event within a 60-day window (#134): a quality revert floors to 0.1, a strategic revert (keyword-classified as a business decision) floors to 0.8.
push (default branch, opt-in outcomes.push_capture) A qualifying direct commit to the default branch (#196) Captures a degraded outcome so trunk-based teams aren't scored ~0: weight 0.5 (weight_source='push'), source='push', aggregated to one outcome per (repo, issue, UTC day). Issue attribution uses only the commit's git %s subject (first paragraph, folded) via issueref.FromCommitSubject, following the same rule as session/cost attribution (#1017, #1069): the leftmost close directive (closes/fixes/resolves #N, including colon forms such as fix: #42) wins, then a tracker key (PROJ-123); a bare #N or no reference is unattributed, never issue-N. The commit body is not read for attribution. Skips reverts, commits whose subject (git's %s: leading blank lines skipped, first paragraph joined) starts with Merge pull request #N (digits), Merge branch or Merge remote-tracking branch (a subject-prefix heuristic, not a parent-count check; the push payload has no parent list), commits whose non-empty changed-path list is all generated/vendored (INFO log, no counter, #240), and squash-merge pushes already captured by the PR path (SHA dedup); a squash push that arrives first is removed when its PR outcome lands (#849). Unattributed commits are logged + counted (tier_push_unattributed_total).

HMAC signature. When TIER_WEBHOOK_SECRET is set, every request must carry X-Hub-Signature-256 validated via verifySignature (handler.go). When it is unset, the webhook fails closed (#60): tierd serve logs a warning and does not mount POST /webhook/github at all (cmd/tierd/main.go), and the handler itself answers 403 to every request if it is ever built without a secret (handler.go). Nothing arrives from GitHub webhooks until you set the secret.

Weight assignment. handlePR extracts weight in two steps, both behind the shared store.ResolveWeight branching (handler.go):

  1. Look at PR labels. prderive.SizeWeight (internal/prderive) maps size/xs … size/xl (or xs … xl) to:

    Label Weight
    size/xs 0.5
    size/s 1.0
    size/m 3.0
    size/l 5.0
    size/xl 8.0
  2. If no recognised label, fall back to store.GitHeuristic(linesChanged, filesChanged) — two arguments, the combining into an effort proxy happens inside the function. It is a bucketed step function, not a continuous formula, and it emits only the five weights the label table above uses (#132):

    effort = lines + files × 10 Weight
    ≤ 15 0.5
    ≤ 60 1.0
    ≤ 200 3.0
    ≤ 1000 5.0
    otherwise 8.0

    A 50-line PR touching 3 files: effort = 50 + 3 × 10 = 80, which falls in the ≤ 200 bucket → weight 3.0. A 1-line README tweak: effort = 1 + 1 × 10 = 11, the ≤ 15 bucket → weight 0.5.

The diff-size heuristic is a fallback PROXY, not a measure of value (won't-fix, #287). The PR size-label path is the defensible weight source: when a size/* label is present, ResolveWeight (internal/store) takes the label weight verbatim and stamps weight_source='label', and the raw diff numbers are ignored entirely. Only an unlabeled PR falls through to GitHeuristic(additions+deletions, changed_files), stamped weight_source='git-heuristic' -- the honest fallback for a PR nobody sized by hand.

Accepted trade-off: on the unlabeled path, generated or vendored churn inflates the weight, because the fallback buckets on the PR's aggregate additions/deletions/changed_files and cannot subtract generated lines it never sees. The pull_request webhook payload carries only those aggregates -- no per-file line data -- and TIER makes no outbound GitHub API call and keeps no local clone by design (it never fetches GET /pulls/{n}/files), so the heuristic physically has no per-file breakdown to exclude generated paths from. Fetching per-file data was considered (#287 Option A) and rejected: it would add an outbound-API dependency (auth, rate limits, failure modes) that TIER deliberately avoids.

Mitigation: an operator applies a size/* label. That moves the outcome onto the weight_source='label' path above, which discards the diff entirely -- so a human-sized PR is immune to generated-churn inflation regardless of how much boilerplate the diff contains. The label path is the intended weight source for any PR whose diff size is not a faithful proxy for its value; the diff-size heuristic is only the floor for PRs left unlabeled.

Quality is DERIVED, not mutated (#134). Every merged PR starts at quality 1.0. Each CI and revert signal is appended to the append-only quality_events log, and the affected outcome's quality is recomputed as the worst-of the applicable floors (internal/quality.Resolve). The unique (outcome_id, event_type, source_ref) key makes replayed deliveries idempotent — re-deriving the same event set yields the same quality. The shipped floors are:

Signal (quality event) Floor Window Trigger event
Clean merge (ci_pass, or no signal) 1.0 — —
CI failure on the merge commit (ci_fail) 0.7 48h workflow_run
Strategic revert — business decision (revert_strategic) 0.8 60d push
Quality revert — code problem (revert_quality) 0.1 60d push

The 30-minute flaky re-run rule is scoped to ONE workflow (#687). A CI failure is neutralised (recorded as ci_fail_flaky, leaving quality at 1.0) only by a success that is a genuine re-run of the failing run: same merge commit, same workflow_id, a strictly later run_attempt, within 30 minutes. A green run of a different workflow on the same commit — the ordinary case in a repo with CI + lint + docs + CodeQL, which all finish seconds apart — leaves the failure standing. Before #687 the match was the commit SHA and the 30-minute window alone, so any green workflow cleared any red one, and anyone with push rights could make a repo's CI failures un-scorable by adding one trivially-green workflow.

When several floors apply to one outcome, the minimum wins; event-derived quality is clamped to [0.1, 1.0]. The manual POST /api/v1/outcomes API accepts quality in [0, 1], including 0. (This is a subset of the full 8-event model in quality-degradation-spec.md — follow-up fixes, partial reverts, incidents, hotfixes, and downstream-CI penalties are specified but not yet enforced.)

Revert targeting. handlePush resolves which outcome a revert degrades in two resolution tiers, plus a fallback (#20), then classifies the revert reason (strategic vs quality) by keyword before appending the event:

  1. "This reverts commit <sha>" footer — git revert adds this footer to every auto-generated revert message. The handler matches it against revertsCommitRE (full 40-char lowercase SHA only) and looks up the original outcome via store.OutcomeByMergeCommit.
  2. Issue id in the git %s subject — reads the first paragraph of the revert message, folding its lines into one subject. Only close directives (such as closes #N) or tracker keys (such as PROJ-42) identify an issue; a bare #N or any reference in the body never does. This fires when git revert propagated such a reference from the original commit subject. The handler uses store.RevertIssueCandidates and applies a penalty only when exactly one outcome matches that issue in the pushed repository, timestamped at or before the revert and within the preceding 60 days (inclusive). Several candidates receive no new penalty and each gets a durable revert_ambiguous marker. Repository-less legacy rows and outcomes from another repository never match.

Fallback (no resolution). When neither tier succeeds, the handler logs the revert commit hash, subject, and author via slog.Info so the gap is at least discoverable. Previously this case no-opped silently.

The penalty target is always the developer who shipped the bug, never the developer who reverted it. Before #20 the handler called UpdateQuality(c.Author.Username, issueID, 0.5) — passing the reverter's username — and the UPDATE silently matched no row because the outcome row was owned by the original author. Both lookup paths now resolve through the original outcome explicitly.

The merge commit SHA is captured at PR-merge time from pull_request.merge_commit_sha and stored in outcomes.merge_commit_sha (#20). A partial index on the column makes the lookup O(log N).

In v0.6.0, a recognised revert PR earns no outcome credit: webhook ingestion and backfill exclude a PR whose branch starts with revert-<PR number>- or whose title starts with Revert ". Unrecognised forms can still pass through ordinary issue attribution and earn an outcome. A separately recognised revert push can reduce the matched original outcome within the supported window. The reverter is not penalised merely for reverting.

TIER does not measure the fraction reversed. A partial reversal that matches its supported revert rule receives the same message-selected floor as a full reversal—0.1 or 0.8. Other partial reversals can remain unobserved. Footer matching identifies recorded merge or squash SHAs, not constituent-commit ancestry.

Work-type taxonomy and type-scoped scoring (closes #187)

An output-per-token metric with only a "feature" notion scores whole job families near zero: a security engineer, an SRE on-call, or a researcher spends many tokens and ships few merge-shaped "features", so their TIER craters against feature developers. That is a category error — their work is a different category, not worse feature work. Every outcome therefore carries a work_type drawn from a fixed taxonomy, and scores are compared within a type, never across types.

The taxonomy (fixed enum): feature | bug | security | incident | tech-debt | research | compliance. This set is closed — every ingress validates against it (store.ValidWorkType), and adding a member is a schema + docs change, not a config toggle.

How work_type is derived, with provenance (work_type_source):

Path Derivation work_type_source
GitHub webhook (merged PR) From the PR labels — see the label convention below label (matched) / default (no match → feature)
POST /api/v1/outcomes Optional work_type field, validated against the enum (invalid → 400) api (set) / default (absent → feature)
Push-to-default-branch capture (#196) Bare commits carry no labels → always feature default
Pre-#187 rows (migration backfill) Category unknowable → feature legacy

Label convention. A PR label maps to a category when it equals a canonical type name (security) or carries a type:<name> / kind:<name> prefix (type:incident, kind:research), matched case-insensitively after trimming whitespace. When several type labels are present, a fixed impact precedence breaks the tie deterministically (regardless of the order GitHub serialises the labels), so a PR labelled both security and feature is scored as security:

security > incident > compliance > bug > tech-debt > research > feature

feature sits last, so it only wins as the sole type label — indistinguishable in score terms from the no-label default.

Type-scoped reads. GET /api/v1/scores returns a work_types[] array: one segment per category present. Each segment is scored separately: its TIER denominator is the cost of only that category's (developer, issue) pairs (cost is attributed at issue grain, so a security engineer's security TIER divides their security points by the cost of their security issues, not their whole-window spend). ?work_type=<type> restricts the response to one segment (an invalid value is a 400). Segments are a developer-mode view: in team or division mode work_types is absent and ?work_type= is a 400 (#864), because pooled team rows and work-type totals difference to a group below the k floor even when every published row clears it. The dashboard renders these as per-type sections rather than one global sort.

The segment cost basis, stated explicitly (#466). A segment's denominator is outcome-linked cost only. work_type is a property of the outcome, so spend on an issue that produced no outcome in the window — abandoned work, work still in flight, a PR that never merged — has no category to be filed under and appears in no segment. The pooled headline score does not work this way: it divides by the developer's whole-window spend with no join to outcomes, so that spend stays in its denominator and correctly lowers the score.

Left unreported, that difference makes every per-type TIER systematically better than the pooled score, and it hides the thrash it is evidence of exactly where a reader goes looking for it. So GET /api/v1/scores also returns a top-level segment_reconciliation block that accounts for the whole window:

outcome_linked_cost_micro + no_outcome_cost_micro + unattributed_cost_micro == window_cost_micro

Two properties are worth knowing before you consume it. First, the invariant holds on the _cost_micro integer fields and only on those — the _usd companions are independent float conversions, so a + b + c === d on them is false for roughly one realistic triple in ten (22601 of 216000 in a synthetic sweep spanning $0.008–$78 per component; the same sweep fails 0 of 216000 on the integers). Assert on the integers, display the dollars. Second, the block reconciles against the underlying cost rows, each counted exactly once — not against the sum of the segments' totals. The segments can legitimately double-count a row (an issue carrying two work types is charged to both segments; a repo-blind cost row is charged to every qualified outcome sharing its issue id), so "segments + gap == window" is false on ordinary data. Both over-counts are deliberate — they lower TIER, so ambiguity never flatters a developer — but together they mean a subtractive gap (window minus the segments) could come out negative on ordinary data, which is why the reconciliation partitions rows rather than subtracting totals.

The block is per-developer plus a name-free rollup, and like the segments it reconciles it is developer-mode only (#864). It is not narrowed by ?work_type — the gap is a property of the window, not of the segment you asked for.

Dashboard reconciliation. The dashboard shows the pooled segment_reconciliation.total figures and the segment cost caveat under each developer-mode work-type panel, including on filtered responses. The caveat explains that segments omit no-outcome and unattributed cost and can overlap, so their costs must not be added. If the total is unavailable, each panel keeps the caveat and shows “Reconciliation unavailable.”

Do not compare TIER across work types. A security TIER and a feature TIER measure different kinds of work, and the categories exist to keep them apart. The top-level developers / teams list is kept for backward compatibility and feeds only the org-total summary. The org total block adds up all categories; it does not compare them.


5. How it gives purpose (linking cost ↔ outcome)

The shared key on both sides is the issue id. Extraction lives in one place — internal/issueref/extract.go — so all data paths agree.

Branch  "fix/15-jsonl-cwd-filter"       → FromBranch → "issue-15"
Branch  "feature/TIER-42-auth"          → FromBranch → "TIER-42"
PR body "closes #11"                    → FromPRBody → "issue-11"
PR body "## Section" (markdown heading) → FromPRBody → ""        (correctly rejected)

Functions used:

Issue-id misfiling. Pull-request points and captured spend do not depend on issue ids. Misfiling can merge or split direct-push credit (one 0.5 per issue per UTC day per repository) or change which outcome a footerless revert penalises, so headline TIER can move and per-issue rows can be wrong. See the measured rate and how to avoid it.

Multi-issue PR attribution rule (#189). A PR can close several issues at once — closes #12, #15, fixes #12 and #15, closes #12, closes #15. TIER attributes the one merged PR to a single PRIMARY issue, chosen deterministically: the branch-derived id if the branch carries one, else the leftmost close directive in the body (FromBranchOrBody == ClosedIssues(body)[0]). The remaining closed issues are not each given their own outcome — one merged PR yields exactly one outcome because outcomes.merge_commit_sha is UNIQUE (#60), and crediting full outcome weight to every closed issue would multiply a single PR's contribution to team TIER. So the secondaries are not silently dropped: when a PR closes more than one issue the webhook logs an INFO line (PR closes multiple issues; outcome attributed to the primary only (#189)) naming the primary and the full closed set, so the un-credited issues are observable. If you want each issue scored independently, open a separate PR per issue.

The JSONL collector's gitLog (collector/jsonl.go) uses FromBranch, then FromCommitSubject for subject fallback. The latter accepts close directives and tracker keys but leaves bare #N mentions unattributed. Matching issue references produce the same issue_id values in token_events and outcomes, allowing the scoring engine to join them.

Worktree attribution (#823, off by default)

The problem it solves. A Claude Code session file records, on every line, the folder the session started in (cwd) and that folder's branch (gitBranch). If you start Claude Code in your main checkout and then work in a git worktree (a second checkout of the same repository, on its own branch, in its own folder, made with git worktree add), every line still says main. The branch rule above then books that spend to unattributed:main, even though the work was for the issue named by the worktree's branch. Subagents inherit their parent's cwd and gitBranch, so they are booked the same way. Worktree attribution looks instead at the paths the session's tool calls named, finds the worktree they belong to, and reads which branch that worktree had checked out at that moment.

It reads more of the session file than the token parser does (tool-call paths, never contents or commands), so it is off by default. privacy.md lists exactly what it reads and stores.

Parents no longer carry (#1016); only message groups labelled isSidechain:true can use carry. Worktree attribution stays opt-in and off by default. Activation requires recorded gates, the operator's security rulings and an explicit operator decision. No new accuracy figures have been measured.

Measured accuracy: read this before turning it on

The figures below describe the earlier v0.5.2 rule, which allowed parent carry; they do not measure the #1016 change.

The switch shipped in v0.5.2 as an opt-in preview, not switched on, because it failed two of its three release gates. The measurement read one developer's Claude Code session files from 2026-09-23 to 2026-10-01 (UTC), with one repository configured: 35,729 messages in the TIER repository's own sessions, run through the code released as v0.5.2.

A parent session here is any top-level session file, an ordinary interactive one included; a subagent session is the file Claude Code writes for a subagent. The parent figures come from 3 orchestrator session files, one of which holds 88% of the parent carry messages. Plain interactive sessions were not measured; the cause given under gate 2 applies to any session that works in a worktree through shell commands.

Before you turn it on:

Turning it on

One switch, --worktree-attribution, on the three commands that capture or preview Claude Code spend (cmd/tierd/worktreeattr.go, resolveWorktreeAttribution). tierd doctor also reads Claude Code session files, but it ignores the switch: its attribution check always measures the rule the switch-off path uses. The first source that is set wins:

Command Flag Environment variable Config file key If none is set
tierd serve (its live watcher) --worktree-attribution TIER_WORKTREE_ATTRIBUTION watch.worktree_attribution off
tierd ship --worktree-attribution TIER_WORKTREE_ATTRIBUTION none: ship reads no config file off
tierd score (runs the audit below) --worktree-attribution TIER_WORKTREE_ATTRIBUTION none: score --config reads prices_file (and subscriptions:, only to warn), never this key off

So the order is flag, then environment variable, then config file (serve only), then off. The flag wins in both directions: --worktree-attribution=false turns it off even when the environment variable says true. The environment variable takes any spelling Go's strconv.ParseBool accepts (1, t, T, TRUE, true, True for on; 0, f, F, FALSE, false, False for off); an empty value counts as unset. Any other value stops serve, ship and score with an error, whether or not the flag is also given, rather than guessing. The config key sits under watch: in the same YAML file you pass to tierd serve --config; config.example.yaml ships it as worktree_attribution: false.

Only after the recorded gates, operator rulings and explicit activation decision, on a machine that ships to a server already upgraded (see below):

tierd ship --server https://tier.example.com --api-token @/path/to/ingest-token --repo ~/src/app --worktree-attribution
# or, for every ship run from this shell:
export TIER_WORKTREE_ATTRIBUTION=1

--server is your own tierd serve's base URL and --api-token the server's recommended --ingest-token credential (the admin token also works), as for any tierd ship run (the @ form reads the token from a file); --repo is the local checkout whose sessions you ship.

How to tell it is on. tierd ship prints ship: worktree attribution (#823): on (from flag --worktree-attribution) (or from env TIER_WORKTREE_ATTRIBUTION) to stderr, and tierd score prints the same line prefixed score:; both print nothing about it when the switch is off. tierd serve always logs worktree attribution (#823): on (from …) or off (from default) at startup. After a run with the switch on, the events it stored show an attribution_rule other than legacy in GET /api/v1/events. tierd ship with the switch on resolves every --repo path before it ships anything, so a wrong path stops the run instead of shipping half of it.

How to turn it off. Remove whatever turned it on (the startup line names the source): drop the flag, unset the environment variable, or set the config key to false. Events stored after that carry no rule (they export as legacy); rows already stored keep the issue and rule they were stored with.

Rolling tierd serve back. A tierd older than #823 refuses to start when its config file contains watch.worktree_attribution, because it rejects unknown keys. Delete that line before rolling back (the comment above the key in config.example.yaml says so).

Upgrade the server first, before turning the switch on for tierd ship. This applies to ship only: serve's watcher writes to its own server's store, so a server needs no upgrade order for its own switch. With the switch on, every Claude Code event ship sends carries a new field, attribution_rule (a branch event included), and some may carry the new issue label unattributed:foreign-repo. A tierd server older than #823 rejects any batch containing either with HTTP 400, and tierd ship treats a 4xx as final. The symptom: tierd ship prints an error starting ship <repo path>: that contains server returned 400, and exits with status 1. The whole run stops at the first rejected batch, so the later --repo targets and that run's --codex-rollout, --opencode and --muse passes do not ship either. To check the server first: run tierd version on the server's machine, or fetch GET /api/v1/version from it (it needs no token), and confirm it is a release that includes #823 (its CHANGELOG entry names worktree attribution).

If the order went wrong, upgrade the server, then re-run the host's usual full tierd ship command, with all its flags, plus --worktree-attribution, with a --since date on or before the last tierd ship run that succeeded (or omit --since: the default, 90 days ago, covers it). ship keeps no record of earlier runs, so the missing events are everything after the last run that succeeded, not after the first one that failed. The server de-duplicates by idempotency key, so re-sending events it already has costs nothing. Two limits apply:

TestRunShip_WorktreeAttribution_WrongOrderUpgradeRecovers (cmd/tierd/worktreeattr_test.go) pins that recovery. Any rule value added later has the same ordering requirement, because the shipper's list of rules and the server's can differ between versions.

The rules, in order

A message is one model reply. Its lines are merged first (messageMerger, internal/collector/messagemerge.go), so a tool call written on a later line of the same reply counts. Then the first rule that decides wins (worktreeResolver.resolve, internal/collector/worktreeattr.go):

  1. worktree-cwd — the message's cwd is inside a linked worktree of a repository you configured, and its gitBranch is a real branch name that matches the branch the worktree's reflog says was checked out at the message's first and last line. (A transcript often reports the parent checkout's branch from inside a worktree, so the two must agree.) If cwd is inside a worktree of a repository you did not configure, the message goes to unattributed:foreign-repo, unless its own tool paths name a configured worktree, which then decides.
  2. worktree-toolpath — the message's own tool paths name exactly one linked worktree of a configured repository, and no other path that counts (the paths that are ignored are listed under "How the carry moves"). The issue comes from that worktree's branch at the message's time. If every path that counts is inside a worktree of an unconfigured repository, the message goes to unattributed:foreign-repo.
  3. carry — only for a subagent message group whose JSONL isSidechain is true. Parent groups skip this rule, even if they have an agentId. The message names no worktree, but an earlier message in the same session file did, and nothing since has reset it. The issue comes from the carried worktree's branch at this message's time.
  4. branch — none of the above: the rule described at the top of this section, from the transcript's gitBranch and the ±30-minute commit window.

Rules 1 to 3 hand a branch to the same issue resolution rule 4 uses, against the commits of the repository the worktree belongs to. If a message names a worktree of one configured repository while its session started in another, it is booked to the repository the worktree belongs to.

How the carry moves. Only sidechain groups set, reset or use the carry. Parent messages never move it, even when their worktree branch cannot be read or is a harness branch. For sidechain messages, paths under /tmp and ~/.claude, relative paths, and paths in no git repository are ignored. Naming one worktree sets the carry; naming a different one attributes by rule 2 and clears it, and the next reference sets it. These sidechain messages also reset it: paths in a configured main checkout, an unconfigured checkout, or a git folder that does not verify; two worktrees in one message; more paths than are recorded; or an unreadable or harness branch. The latch follows file order and never crosses session files; each subagent file starts empty. The cwd never moves it. Bash commands and Agent prompt text are not read.

When the branch cannot be proven, the answer is rule 4. The branch at a moment comes from the worktree's reflog (branchAt, internal/collector/worktreereflog.go) and never from running git or from guessing at a folder name. It gives no answer, and the message falls back to rule 4, when: the moment is more than 30 days ago (git's default expiry for unreachable reflog entries, reflogExpireUnreachable); the worktree was moved after that moment; the reflog is missing, over 1 MiB, not in git's format, owned by another user, or writable by everyone; HEAD was detached; the message's first and last lines fall on different branches; or the branch is a name Claude Code's agent harness generates for its own worktrees (those carry no issue, IsHarnessWorktreeBranch). On Windows, and any other system that is not Unix-like, no worktree verifies (TIER cannot read the file owner there), so every message takes rule 4.

What it does not change. A session whose cwd is outside every configured repository still stores nothing, as before. With the switch on, ship and score do read its tool paths, and the git files of the worktrees those paths name, before they drop it (privacy.md, "Which session files"). Only Claude Code capture uses these rules; TIER's Codex, Opencode and Muse Code collectors, the proxy and POST /api/v1/costs record no rule. Stored rows keep the issue they were stored with: turning the switch on attributes new events only (re-attributing stored rows is #489).

What is recorded. With the switch on, every Claude Code event records the rule that chose its issue in token_events.attribution_rule: branch, worktree-cwd, worktree-toolpath or carry. The server can instead assign pr-number-at-insert (#1074) when it resolves an unattributed event at insert, or branch-at-merge (#1074) when a merged PR's head-branch fill credits one at merge time; neither rule is ever client-sent. The issue and rule are written in the same insert and never updated. With the switch off and no server resolution, no rule is recorded; such a row, and every row stored before #823, exports as legacy from GET /api/v1/events. A stored value outside the set exports as unknown. Spend in a worktree of an unconfigured repository has the issue unattributed:foreign-repo and the repository unqualified, so the other repository is never named; like every unattributed: label it stays in the developer's spend, and it is not counted as exploratory spend.

Messages held back (live watcher only). One reply can be written as several lines over minutes, so with the switch on the watcher holds back the message still open at the end of a file until it has all its lines, and releases it once the file has had no new line for 600 seconds (idleRelease, internal/collector/watcherattr.go). A line that arrives after its message was released is stored again under the same message key; the store keeps the larger token counts and the first row's issue, rule, cost and price version, so that row's cost stays priced on its first counts. privacy.md gives the measurement behind the 600 seconds, and its erasure limits say when a held-back message can be stored after an erasure. tierd ship reads each file whole and holds nothing back.

Previewing the effect: tierd score --worktree-attribution

tierd score --repo <path> --worktree-attribution reads the same session files twice, with the switch off and on, prints the usual cost report as it would be with the switch on, and then prints an audit of what changed between the two local scans. score has no database, so it stores nothing; the audit prints counts and USD cost separately for tool-path and carry attributions (including zero totals), repository names, issue labels, session ids and times, and never a path or message content. Up to 20 changed messages are listed, oldest first (worktreeAuditSample).

This is the audit from the test fixture in TestRunScore_WorktreeAttributionAudit (cmd/tierd/worktreeattr_test.go): one message, whose session started on main in a repository with no origin remote (so its name is "unqualified"), and whose Read call named a file in a worktree on branch feature/44-wt:

Worktree attribution audit (#823): a dry run, nothing is stored
  compared with a local flag-off scan; the server keeps the first issue, repo and rule it stored for a message, so only messages it has not stored yet change
  messages: 1 with the flag off, 1 with it on
  changed attribution (issue or repo): 1
  only with the flag off (booked to another target): 0
  only with the flag on: 0
  unattributed:foreign-repo: 0
  by rule (flag on; messages, cost USD):
    carry                              0  $0.000000
    worktree-toolpath                  1  $0.010500
  by repo (flag on):
    "unqualified"                      1
  changed messages:
    2026-09-30T20:29:32Z  session "sess-wt"  "unqualified" unattributed: main/master -> "unqualified" issue-44 (worktree-toolpath)

The costs use the fixture's deterministic test price table. Read it as: one message moved from unattributed:main to issue-44, decided by rule 2. The two "only with the flag" lines count messages that one run reports and the other does not. The time on the sample line is the test run's own; the fixture dates its message from the clock.

Two things the audit cannot see, so read it as a comparison of two local scans, not a forecast of what your server will store:


6. How it says "this many tokens + this much code = this much value"

The formula is one line, in ComputeDeveloper in internal/scoring/engine.go:

TIER = Σ(outcome_weight × quality_multiplier) / (total_AI_cost_USD / $1,000)

A team that ships 100 weighted outcome points on $1,000 of AI spend scores TIER = 100. Spend $10,000 for the same outcomes and they score 10. Same outcomes for $250 and they score 400.

Before you trust a TIER number, read Interpreting the Number. The numerator (outcome) and denominator (cost) are windowed independently: cost is timestamped when tokens are spent, but PR outcomes use merge time (merged_at, with webhook receipt time as the fallback when it is missing, malformed, zero or future), and push outcomes use commit time (server time if the commit timestamp is missing or zero) -- days or weeks after the spend. So a recent or short window shows cost that has already landed against outcomes that have not been credited yet, and the score reads artificially low until that work lands. This is a measurement-timing artifact, not a productivity signal -- trust wide, settled windows; distrust recent, short ones.

Why dollars, not tokens:

Why this exact formula:

Sidecars — Coverage % and Spend Leverage

Two CFO-facing numbers ship next to TIER itself (added in #17):

Coverage % (internal/scoring/engine.go, CoveragePercent) is the fraction of total_cost_usd that came from realtime sources (JSONL + proxy) vs. imputed/extrapolated sources. "Realtime" is keyed strictly on fidelity='realtime', which only the local per-call collectors and proxy can assert. Graft apply accepts only the shared shippable local-collector sources at their realtime fidelity; it refuses poller, proxy and manual source claims. Manual REST imports land as estimated and never count toward Coverage (#82). With positive total captured cost, JSONL-only collection reads 100%; zero-cost estimates do not change it. It describes how the spend TIER saw was recorded, not how much spend TIER missed. Uncaptured tools technical reference, "Coverage of tools" do not lower it. It falls below 100% when positive-cost poller remainders or manual estimates are present.

Spend Leverage (internal/scoring/engine.go, SpendLeverage = total_cost_usd / actual_paid_usd) is the multiplier between Reference-Price-Table list value and the enterprise-contract invoice total. Finance posts the per-month invoice via POST /api/v1/actual_spend (handlePostActualSpend, period as YYYY-MM). If list value is $1,000 and the contract billed $400, Spend Leverage is 2.5× — that's the number the CFO compares to retail Claude pricing.


7. How it scores task size and complexity

v1 today. Two signals, in order:

  1. PR size labels (size/xs … size/xl) if a human applied one. Fibonacci-ish weights 0.5 / 1.0 / 3.0 / 5.0 / 8.0.
  2. Git heuristic otherwise: effort = lines + files*10, bucketed onto that same 0.5 / 1.0 / 3.0 / 5.0 / 8.0 scale (≤ 15, ≤ 60, ≤ 200, ≤ 1000, else) — a step function, not a continuous formula.

That's the whole "complexity model" today. No semantic understanding, no AST diff, no churn analysis, no test-coverage weighting. A subtle 5-line concurrency fix that prevents a data race scores the same as a 5-line typo fix. This is a known approximation.

v2 (deferred). A design note that is not part of the public tree specifies a 10-signal auto-scorer (code complexity, blast radius, novelty, test coverage delta, etc.) and a separate Context Complexity Index sidecar. Not in v1.

Practical implication for dogfooding. Either label every PR with size/* or accept that small-but-hard PRs will under-weight. The label path takes ~2 seconds per PR and is the cleanest input you can give the system.


8. How it all comes together

flowchart LR
    subgraph DEV["Developer machine"]
        CC[Claude Code<br/>writes JSONL]
        TC[tierd score<br/>CLI]
    end
    subgraph FS["~/.claude/projects/"]
        JSONL[(*.jsonl<br/>per-session)]
    end
    subgraph GH["GitHub"]
        PR[PR merged]
        REV[Revert push]
    end
    subgraph TIERD["tierd serve"]
        WATCH[fsnotify watcher<br/>internal/collector/watcher.go]
        WH[webhook handler]
        API[REST API]
        DB[(SQLite<br/>token_events<br/>+ outcomes<br/>+ actual_spend)]
        SCORE[scoring engine]
    end

    CC --> JSONL
    JSONL --> TC
    JSONL --> WATCH
    TC -->|on-demand scan<br/>filter by CWD| RPT[terminal cost report<br/>stores nothing]
    WATCH -->|live ingest<br/>debounced 1s<br/>filter by CWD| DB

    PR -->|HMAC POST| WH
    REV -->|HMAC POST| WH
    WH -->|InsertOutcome| DB

    DB -->|cost + outcomes<br/>joined on issue_id| SCORE
    SCORE -->|TIER per developer| API
    API --> DASH[Dashboard / JSON]

    style SCORE fill:#e6f3ff

The watcher node is the live path (tierd serve --watch-repo <path> --aggregation team); the tierd score CLI is the on-demand path, which prints a cost report and writes nothing to the store. Both share parseSessionFile and joinSessionsToCommits, so a session reads the same either way. actual_spend enters via POST /api/v1/actual_spend (omitted from the diagram for brevity; see section 6).

Sequence of a single Claude Code session through to a TIER score:

sequenceDiagram
    participant Dev as Developer
    participant CC as Claude Code
    participant FS as JSONL file
    participant T as tierd serve
    participant GH as GitHub
    participant DB as SQLite

    Dev->>CC: prompt on branch fix/15-foo
    CC->>FS: append assistant entry<br/>(streaming chunks + final)
    T->>FS: fsnotify tail (live, 1s debounce)
    T->>T: dedup by message.id<br/>filter by CWD<br/>extract issue_id from branch
    T->>DB: InsertTokenEvent<br/>(idempotency_key on message_id or client key)
    Dev->>GH: open PR, merge
    GH->>T: webhook pull_request closed+merged
    T->>T: weight = SizeWeight or GitHeuristic<br/>issue_id from branch or body
    T->>DB: InsertOutcome (quality=1.0)
    Dev->>T: GET /api/v1/scores/{dev}
    T->>DB: join token_events + outcomes on issue_id
    T-->>Dev: TIER score, cost, weighted points, coverage

9. Is it ready to dogfood?

Short answer: yes for cost attribution, not yet for TIER scores.

Works today

Required before TIER produces actual scores (not just costs)

Need Status
tierd serve running somewhere with a public URL You need ngrok / tailscale-funnel; not yet set up
GitHub webhook configured on the target repo(s) Manual step in repo settings
TIER_WEBHOOK_SECRET set in tierd serve env Manual
TIER_API_TOKEN set if exposing POST endpoints beyond loopback Manual; closes #22
size/* labels on PRs, or accept the GitHeuristic Workflow choice
Finance posts per-month actual_spend (for Spend Leverage) New endpoint live as of #17
JSONL live tailer (fsnotify) feeding tierd serve directly Done — issue #18
Coverage % / Spend Leverage metric visible in the dashboard Done — issue #17

Live ingestion: pass one or more --watch-repo <path> flags to tierd serve (along with the required --aggregation team|division (developer: subject-bound credentials required on real data), #185). The watcher (internal/collector/watcher.go) attaches to ~/.claude/projects/ via fsnotify, debounces rapid writes (1 second by default — a single streaming Claude Code response writes 30-50 times per second, and we want one re-parse after the stream settles), filters incoming sessions by CWD against the configured repos (the #15 cross-repo bleed protection), and inserts via the same SQLite path the proxy uses. Each message is keyed by MessageIdempotencyKey("anthropic", message.id); a message with no id falls back to a key built from the session id and the message's position in the file (internal/collector/jsonl.go). A re-parse of the same file produces the same keys, so it collides on the partial unique index and adds no rows. New project subdirs created after the watcher starts are picked up dynamically. The watcher reads only session files written to after it starts, and it reads a file it holds no saved offset for from its first line, so resuming an old session loads that session's earlier events too. Load the rest of your history with tierd ship, run as in the re-ship step under 3g (tierd score writes nothing to the store).

Not in v1 at all

Honest recommendation for adopting TIER

  1. Run tierd serve --watch-repo . --aggregation team on the dev box. JSONL is now ingested live. (--aggregation team|division (developer: subject-bound credentials required on real data) is required — serve will not start without it, #185.)
  2. Apply size/* labels on PRs as you merge them.
  3. Expose the listener via Tailscale-funnel with TIER_WEBHOOK_SECRET and TIER_API_TOKEN both set; point the GitHub webhook at it. Outcomes start flowing on merge. Without TIER_API_TOKEN, tierd serve will log a startup warning and the POST endpoints stay unauthenticated — fine on loopback, not fine on the funnel.
  4. Open http://localhost:8080/ (or the tunnel address). Capture Fidelity (Coverage %) renders without a finance invoice. Spend Leverage requires billed spend — either per-developer via POST /api/v1/actual_spend (Cursor-style per-seat bills) or org-level via POST /api/v1/org_actual_spend (the common Anthropic/OpenAI one-bill-for-N-seats pattern; the store divides by the active-in-period seat count automatically, #41).
  5. tierd score --repo . still works for ad-hoc historical scans without the server.

The honest framing: TIER's measurement primitives are correct on main. The wiring that makes them produce a live TIER score with zero setup is two tickets away.

Recognised GitHub revert PRs

A merged PR whose head ref matches ^revert-[0-9]+- or whose title starts with Revert " earns no credit. An ordinary PR titled "Revert to v2 API" on a normal branch still scores. The original's penalty depends on a separately recognised revert push; revert-of-revert restoration is unsupported.

Webhook and backfill classify before issue attribution. One write transaction moves any existing outcome whole to outcome_exclusions, retaining its original id for quality/audit references, deletes it from scoring inputs, records an author-owned tombstone, and reconciles already-granted push credit. A missing merge SHA is refused without granting credit, pending complete merge evidence. PR and API inserts and later pushes honour that tombstone. The new composite unique key is (repo, PR number, merge SHA); a future tenant_id can lead it mechanically.

Exclusion advances the erase epoch so a seal computed before credit removal cannot commit stale figures. Later developer erasure deletes exclusions and advances the epoch again. Erasure removes the dedup tombstone too: redelivery can store new data afterwards, as with other erased ingestion records. Earlier unsealed manifests covering an excluded window can fail verify-report with a mismatch. Sealed reports replay stored bytes unchanged. Historical discovery is available through tierd outcomes exclude-reverts: a read-only dry run builds a plan from retained PR payloads or GitHub API evidence, and an operator can approve its raw file SHA-256 and apply the exact plan. Discovery uses the shared recognised-revert classifier; issue numbers do not establish exclusions. See the operator procedure.

Event 3: enabled operation

Event 3 capability

TIER accounts for confirmed corrective work — this applies when the operator turns Event 3 scoring on for their installation; scoring is off by default. Corrective work means a PR that declares Corrects: #N; TIER does not detect undeclared repairs. The switch is an installation choice, not a product-wide default change.

See Enable Event 3 scoring for configuration, rollback and startup behavior.

Reviewed growth and allocated points

A captured Corrects: #N declaration links a follow-up to its original. With scoring enabled, operator confirmation applies reviewed sizes: the original's scope, the follow-up's own size, and cumulative delivered scope before and after (or an explicit pure-repair assessment). A raw PR size label is not reviewed growth evidence. Positive reviewed cumulative growth supplies marginal credit; pure repair supplies zero growth. The allocation charges the original first, keeps its floor, and assigns the remaining budget to follow-ups in canonical order, capped by each follow-up's own reviewed size. Each follow-up's allocated share is multiplied by its own quality. A repair can therefore receive individual points without adding delivered growth or increasing the eligible sample count. See rubric 2 for the arithmetic.

Before review, eligible same-author declarations use a provisional approximation from recorded churn and file counts. This is not measured delivered scope. Unconfirmed cross-author declarations cannot move another author's points; they remain pending. Missing sizing can also leave a relation pending. Reviewed per-root shares for multiple originals are planned, not shipped. Foreign roots remain pending, and an own-root declaration takes the least-favourable own-root result.

Review, disputes and sealed reports

Actor and role are self-asserted local CLI input, not authentication. The operator reviews relations on a single-operator installation, including their own pairs. The CLI requires an actor label distinct from the declarer for confirmation, but that advisory label check does not prove independent review.

A dispute or withdrawal after confirmation keeps the prior allocation until the operator settles it by upholding, re-assessing or removing it. Disputed state is visible in developer/operator views, exports, the corrective digest and new seals. Team/division views return not_shown for these diagnostics. Existing sealed reports and their digests never change, including after late evidence, settlement, or a scoring switch change; live windows and future seals can change.

See Event 3 coverage for recorded declarations and limits.

Time to merge (v0.6.2)

Time to merge describes PR created → merged (includes draft time), with privacy floors on published figures; smaller or later-created PRs merge faster, so it is not a quality measure. See the technical reference for calculation, exclusions and sealed-month read behaviour.

Org tree compatibility and audit (schema 18)

The legacy hierarchy PUT and CSV import build or find the same org → division → team path in org_nodes and dated org_node_edges, and enrol the person in org_people. Optional blank org/division labels skip a level. The current org_hierarchy labels are derived from the tree; renaming or moving a node refreshes that projection in the same transaction. A person's team move closes one hierarchy_membership span and opens another at the server clock. Earlier work keeps its earlier placement. Renaming or moving a node does the same for everyone placed in its subtree, at the edit instant; renaming an org (level 0) is refused for now. Memberships and retained cost cells carry a node_id; a suppressed other team cell carries its surviving division's node only when every row in it shares that node, and never a leaf ID.

Upgrading to schema 18 converts existing assignments once, with random node IDs. An empty map produces no nodes. The upgrade refuses only ambiguous LEGACY rows, and names them: a team name under two divisions (legacy team-mode reports group by the team label alone), a team/division/org name that would sit at two levels under one parent, and a membership or retained cost row that matches more than one org path. Before upgrading, run tierd org check --db /path/to/tier.db; it reads without migration through the same decision the upgrade uses, prints every conflicting row, and exits 1 for conflicts or errors, 0 when clean; on a schema-18 database it says it is already migrated and exits 0. A membership row is placed through its own developer's org first; only rows with no placement of their own (and retained cost cells) fall back to a unique team/division match. A historical placement no current assignment holds is kept under that developer's org as REMOVED nodes, never as a live node. Older binaries refuse schema 18.

From schema 18 on, team names are unique per parent rather than across the whole map, so the hierarchy API may place the same team name under two divisions; legacy team-mode reports still group by the team label. Hierarchy writes now refuse (400) labels with leading or trailing whitespace or control characters, and a name already used at another level under the same parent (for example a team "Data" directly under an org that also has a division "Data"); a bulk import names the offending row and writes nothing.

Hierarchy writes that change a placement or the declared viewers append org_audit rows in the same transaction, using an opaque actor credential ID or host-cli / break-glass (any other writer is refused), with frozen node IDs and names in separate columns. Audit names age out at the developer retention window in every aggregation mode, including servers that have never enabled developer history folding. Audit rows never keep a person active. Erasure redacts names in place, retaining actions, times, actor credential IDs, and node IDs; developer exports include the same rows erasure redacts (about the subject, their actions, or naming them in detail), with other people's names left out of rows not about the subject. No new hierarchy writer ID is derived from a bearer token.