TIER — technical reference
Use this reference to trace how TIER captures AI usage, prices it, records accepted software changes and computes merged work per $1,000. It gives engineers the capture paths, scoring rules, reporting modes and deployment steps behind the number.
How TIER captures cost, records outcomes and computes the number (v0.7.0)
Reference version: 1.10 — UNRELEASED-DATE (v0.7.0 release). Describes TIER v0.7.0 (embedded price table version 14, effective 2026-10-12, 93 models; rubric version 1 by default, which versions the size scale, work types and quality floors, or rubric 2 with Event 3 enabled; Go 1.27.3 for v0.7.0; source builds at this revision or later require Go 1.27.3 or newer, raised for GO-2026-6809). Earlier releases lack parts of what follows: v0.4.1, for example, has no Opencode collector, carries price table version 9 and has no tierd prices forget-version. TIER is released under the Apache License 2.0. TIER v0.6.0 includes operator-enabled Event 3 scoring (rubric 2; see CHANGELOG.md, 0.6.0); it remains off by default.
What this document is. The companion to the TIER white paper, which says what the number means and which decisions it may and may not inform. This reference holds the mechanics: capture paths, pricing, outcome producers, attribution and windows, aggregation and privacy, statistics, Spend Leverage accounting, team deployment, the API, and the known ways the number misleads. Where the two overlap they say the same thing; where they seem to differ, this reference is the more detailed statement and the white paper is the defect.
How to read it. Every behaviour the reference describes is stated in it. Operator procedures it does not reproduce are named with the repository file that holds them each time: request and response schemas, the full configuration schema, backup and restore, the binary download and checksum commands, proxy and TLS hardening, each command's failure output and full flag list (tierd <command> -h). All file names refer to the public repository, tiermetric/tier. Where a behaviour is configurable, the reference names the flag or setting. "Planned" marks designed work that has an operator ruling behind it; "specified but not enforced" marks design text with no build commitment. Version 1.1 was read against the source of the time during the maintainer's review; version 1.2 checks each change made since then against the source prepared for v0.5.2. Measured figures carry their date and source.
Two roles. The operator is the project maintainer, who decides disputed design questions. Each ruling is recorded with its date in the maintainer's private rulings ledger and on the issue it answers; this reference states in full every ruling it relies on. Issue numbers (#826 and others) refer to the maintainer's issue tracker, which may not be public; they are provenance only. Whoever installs and runs TIER is the administrator.
1. The problem
AI bills report spending, not the software changes that spending produced. Two teams can spend the same amount and merge very different amounts of work, and some spend goes to work that never merges.
The common engineering frameworks do not cover this. DORA (DevOps Research and Assessment) measures delivery speed and stability. SPACE and DX Core 4 are frameworks for developer productivity and experience. As the project reads their published definitions (docs/how-tier-relates.md sets out that reading), none uses dollars of AI spend as a denominator. TIER adds a measure in dollars of AI spend, for use alongside them.
A useful measure has to deal with four things. Tokens are not comparable across models: the same token count costs very different amounts on an expensive and a cheap model. Invoices are not comparable across organisations: discounts, credits and subscriptions make identical workloads bill differently. Merged work is not comparable by count: a one-line typo fix and a multi-week subsystem are both "one pull request" (PR). And a number tied to a person's incentives can be gamed. Sections 2 to 5 address the first three; Sections 6.3 and 11 address the fourth.
2. Definition: the formula, units and a worked example
2.1 The formula
TIER = Σ(outcome_weight × quality_multiplier) / (total_AI_cost_USD / 1000)
With Event 3 scoring disabled, the numerator is the sum, over accepted outcomes inside the measurement window, of each outcome's weight (its size) multiplied by its quality multiplier (1.0 for a clean merge, reduced by a continuous integration (CI) failure or a revert). The denominator is eligible captured cost in the same window, in US dollars, divided by 1,000. Spend that could not be linked to an issue is included (Section 5.3). Group reports cover identified developers' captured spend and exclude pseudo-developer spend, including poller remainders; repository scopes also exclude unqualified spend. This is merged work per $1,000 of captured AI usage, priced at list rates: an observational ratio, not a causal estimate.
An accepted outcome is one of three things: a merged pull request (Section 4.1), a direct commit to the default branch when the administrator turns on push capture (Section 4.5), or an outcome posted through the outcomes API, where the caller asserts the merge (Section 4.6). Nothing else adds to the numerator.
Unit: weighted outcome points per $1,000 of list-price AI cost. A TIER of 100 means 100 weighted points for each $1,000 of AI usage. The scale constant 1,000 is fixed in the scoring engine.
The report also carries two related figures:
cost_per_point=total_cost_usd / weighted_points, in US dollars per weighted point;1000 / TIERwhenever both are non-zero. With zero points it isnull, so "no accepted outcome" never reads as the most efficient score. With points at zero recorded cost, TIER is 0 andcost_per_pointis 0; the dashboard shows that case as "No recorded AI cost" (Section 7.1), because atierof 0 alone cannot tell it from nothing accepted. Zero recorded cost is often a capture gap rather than free work; neither zero is an efficiency reading.- Spend Leverage =
total_cost_usd / actual_paid_usd, the list-price value of the usage divided by what finance actually paid (Section 8).
Which figure a decision uses. TIER reports pooled points over captured cost for a closed month. Compare the same population across closed months and inspect the published work-category panel, frozen capture disclosures and work mix alongside the pooled ratio. Synthetic/offline raw windows additionally expose per-type outcome-linked segments and segment_reconciliation; those segments exclude no-outcome and unattributed spend and can overlap. They are not the real monthly response contract.
When two figures are comparable.
- No absolute scale. There is no good/ok/poor band and no reference cohort. A bare figure is neither good nor bad.
- The main comparison is with your own past. Compare the same organisation or branch across closed months, accounting for changes in work mix. Two teams in one organisation compare weakly, after a review of their size labels and task mix; treat a difference as a question to ask. Two organisations do not compare (Section 6.3).
- Same prices, rubric and release. For a single-origin monthly projection, compare
disclosures.pricesentries (versionandhash),disclosures.used_price_versions,disclosures.unregistered_price_versionsandclose.policy(includingrubric_version). Multi-origin projections carry these disclosures and policy per entry inorigin_evidence[]. Unknown price identities do not establish comparable pricing; matching stamps cannot establish matching task mix, labelling habits or capture completeness. Use the same TIER release (GET /api/v1/livezreports the running release; named manifests retainclose.build). Repricing raw inputs cannot revise a closed month. The rawprice_table.table_hash,rubric.versionanddata_quality.mixed_price_versionsfields belong to synthetic/offline reports. Real servers denyGET /api/v1/eventsandGET /api/v1/outcomes; use authorized compliance exports for retained subject evidence.
2.2 The rules inside the formula
- With Event 3 scoring disabled, each stored outcome contributes its full
weight × qualityonce. Enabled scoring uses allocated corrective points under rubric 2 (see Event 3 capability). One accepted change can be stored twice: a PR merged into an integration branch whose merge is then merged again (Section 4.1), and a squash merge that push capture stored twice before v0.5.2 (Section 4.5). Work that is never accepted (a pull request that is closed unmerged, abandoned or still open) contributes nothing to the numerator, however much it cost; that cost stays in the pooled denominator. - Team and organisation figures are summed points over summed cost, never an average of individual ratios. Example: A has 10 points on $10 (TIER 1,000) and B has 1 point on $100 (TIER 10). The average of the two ratios is 505; the pooled figure is 11 / $110 × 1,000 = 100, which reflects where the money went.
- When the window's cost is zero the engine reports TIER 0 rather than dividing by zero. So uncaptured usage makes TIER read high only when some cost is recorded; with none recorded, TIER is 0.
- The window is half-open:
[since, until). A row timestamped exactly atsinceis inside; one atuntilis outside.
2.3 Worked example (illustration, with the arithmetic shown)
Suppose a developer merges three pull requests in a window, and $35.00 of list-price AI spend is recorded under that developer in the same window (all of it, whether or not it was linked to an issue).
| PR | Size label | Weight | What happened after merge | Quality | weight × quality |
|---|---|---|---|---|---|
| A | size/m |
3 | clean | 1.0 | 3.0 |
| B | size/s |
1 | CI failed on the merge commit within 48 h | 0.7 | 0.7 |
| C | size/l |
5 | reverted 20 days later by a commit whose first line is Revert "Add export" and whose body says the feature was "no longer needed" (strategic) |
0.8 | 4.0 |
Weighted points = 3.0 + 0.7 + 4.0 = 7.7.
TIER = 7.7 / (35.00 / 1000) = 7.7 / 0.035 = 220 weighted points per $1,000.
cost_per_point = 35.00 / 7.7 = $4.55 per weighted point (and 1000 / 220 = 4.55, the same figure from the other direction).
This row clears the evidence floor (Section 7.1): it has 3 outcomes (the floor is 3), $35.00 of cost (the floor is $5.00), and, in this illustration, no outcome flagged as zero-token. So ranked: true. With the v0.6.1 interval rule (#1233), it carries unvalidated descriptive bootstrap bounds only if its own attributed-cost share is at least 50%; when shown, they are labelled "excludes uncertainty from unattributed spend". Team rows carry none.
A second illustration, on the cost side, shows how one API call becomes dollars. Take claude-sonnet-5, which the version-14 price table shipped in v0.6.0 lists at $2.00 per million input tokens and $10.00 per million output tokens, with Anthropic's default cache multipliers (reads 0.10×, 5-minute cache writes 1.25×, 1-hour cache writes 2.00× of the input rate). A call with 200,000 input tokens, 1,500,000 cache-read tokens, 50,000 5-minute cache-write tokens and 5,000 output tokens prices as:
input 200,000 / 1e6 × $2.00 = $0.400
cache read 1,500,000 / 1e6 × $2.00 × 0.10 = $0.300
cache w5m 50,000 / 1e6 × $2.00 × 1.25 = $0.125
output 5,000 / 1e6 × $10.00 = $0.050
total $0.875 (stored as 875,000 micro-dollars)
The pricing function sums the per-class amounts for the call and rounds once, to the nearest micro-dollar (1 USD = 1,000,000; an exact half rounds to the even neighbour), and stores that integer.
3. Where the cost comes from: capture paths, pricing and cache, price-table versions
3.1 Capture paths
TIER's default path reads the session logs Claude Code already writes, so a Claude Code user installs nothing and sets nothing; the other paths need a flag or, for the proxy, a base-URL change. Every cost event lands in one table, token_events. Eight producers write to it: four log readers (Claude Code, Codex CLI, Opencode, Muse Code), the reverse proxy, POST /api/v1/costs, and two org-level pollers. tierd ship is a carrier, not a ninth producer: it runs the log readers on a laptop and sends their events to a server.
Cost is recorded under a developer identifier. The log readers use the operating-system (OS) username ($USER, then $LOGNAME, then the system account name). tierd score and tierd ship accept --developer <id> to override it; tierd serve --watch-as <id> binds its local watcher to a developer and requires exclusive ownership of the complete watched trees. Outcomes carry the GitHub login, so the two often need joining (Section 5.2).
Claude Code session files (on by default). Claude Code writes one JSONL (JSON Lines) file per session under ~/.claude/projects/. TIER's parser reads only type, timestamp, gitBranch, cwd, sessionId and message.{id, model, role, usage.*}, so prompt and completion text never become a stored value. With worktree attribution on (Section 5.1; off by default), a second decoder also reads each tool call's name and the file paths it names, never file contents or shell commands; docs/privacy.md lists exactly what it reads and keeps. In Claude Code's logs as TIER's parser meets them, one call appears as several entries with the same message.id: one per streaming chunk plus a final entry. Within one read, TIER keeps the entry with the largest token total (input, output and all cache classes summed), which is the final one. A session counts only when its cwd is the target repository or a directory under it, or a git worktree of that repository; other sessions are dropped. Each message becomes one event keyed on ("anthropic", message_id), the same key the proxy derives for the same call. tierd score scans once and exits; tierd serve --watch-repo <path> follows the directory with a 1-second debounce.
When a second event arrives with a key already stored, from a later read or from the proxy, the store selects one coherent growing observation and keeps its larger token counts, atomically recomputing token-derived cost at the row's original rates (#1231). Incompatible observations are retained as conflicts rather than merged field by field. Caller-authoritative costs stay unchanged; unavailable original rates, frozen monthly and legacy raw-verification input bands and completed poller rows prevent promotion. Historical repair is explicit (Section 3.3). How often a partial first read happens in practice has not been measured.
Capture validation remains open. Claude costs are derived from deduplicated Claude Code usage logs. Their completeness against independent provider accounting remains unvalidated, including for the subscription routes used here. Missing usage would understate those costs. Codex costs use logged cumulative counters; those counters do not independently establish complete capture. If the logs undercount, costs are understated, TIER scores read high, and Claude looks relatively cheaper in cross-vendor comparisons. Capture completeness is unknown unless independently reconciled for the stated engines, routes, versions and reporting window. A healthy collector or a 100% Fidelity reading does not establish complete capture. Issue #837 tracks independent validation. Deduplication establishes one captured call per key, not complete provider accounting. No measured error factor is established and no historical multiplier is applied.
Codex CLI rollout logs (off by default, --codex-rollout). Codex writes per-session logs at ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. TIER reads the line type and timestamp and, under payload, the session id, cwd, git.branch, model and the cumulative total_token_usage counters of each token_count event. Prompts, reasoning traces, tool output and patches in the same file are never decoded. Per-call usage is the difference between consecutive cumulative snapshots; the first snapshot counts in full, and a repeated snapshot adds nothing. Summing the per-call field instead can double-count, because Codex was seen to re-emit a token_count event: on one session captured for the collector's tests in July 2026, summing gave 145,165 tokens where the cumulative counters gave 124,386. Four checks are fatal for a file: total == input + output when total_tokens is non-zero, cached tokens within input, reasoning tokens within output (never added on top), and no counter going down. A file that fails emits no events on that pass, and the error names it; events stored from it on an earlier pass stay. This reference names no supported tool versions: a change in a tool's log format can stop its capture. The key is ("codex-rollout", "openai", session_id, ordinal). Under serve the collector rescans every scan_interval (config collectors.codex_rollout.scan_interval; default 5 minutes, floor 30 seconds). Under ship it makes one pass.
Opencode session store (off by default, --opencode). Opencode keeps sessions in a SQLite database at ~/.local/share/opencode/opencode.db (ship --opencode-db, or collectors.opencode.db_path in the serve config, moves it). TIER opens it read-only, reads the message table and, from each message's JSON, the role, model id, provider id, working directory, completion time and token counts. It skips messages that have not completed. The key is ("opencode", provider id, message id). The spend is recorded against the watched repository when the message's working directory is inside it, and dropped otherwise.
Opencode reports reasoning tokens beside output, so TIER prices output + reasoning at the output rate. Z.ai's pricing page, as the project read it on 2026-08-28, does not say whether reasoning bills at that rate: the token arithmetic is checked, the rate convention is not. On the maintainer's own Opencode GLM-5.3 sessions, measured on 2026-08-28, reasoning exceeded output on 77% of 3,943 messages. Opencode's own cost field is not read.
The collector captures one provider route: the Z.ai coding plan (provider id zai-coding-plan). It skips every other provider and logs how many messages it skipped. Ollama's cloud tier is named in that log, because it publishes no per-token rate to price against; a provider TIER does not know is skipped with a warning. The embedded table prices glm-5.3 ($1.40 / $4.40 per million, cache read $0.26) and glm-5.3-flash ($0.15 / $0.50, cache read $0.03) at Z.ai's published per-token API rates, which the operator ruled on 2026-09-25 are the value of coding-plan tokens even though the plan itself is a flat subscription (Section 8.1).
Any other GLM model on that route (for example glm-5.2) has no embedded row, so its events price at the $0.50 per million unknown-model guess, with a warning (Section 3.3). To give one a rate, build a --prices table from the embedded internal/store/prices.yaml with a version this database has never recorded, such as 1000, because --prices replaces the whole table (Section 3.4). Do not add a glm-5.3@zai-coding-plan row: a key with @<route> is looked up before the bare model name, so that row would replace the embedded glm-5.3 row for coding-plan traffic, and one marked billing_mode: subscription relabels that spend. Every command that loads an override changing a built-in per-token model's billing_mode logs a WARN naming both modes, and the override still loads (#921). The label never changes a stored cost or TIER: it appears in the /events export and the data-protection export, tierd reprice rewrites it, and a subscriptions: block (Section 8.1) accepts a route only if the price table marks that route's row billing_mode: subscription. serve warns at startup if a --prices table drops the embedded glm-5.3 rows.
Opencode records no git branch, so its spend lands in the unattributed:detached-head bucket and never joins an issue. The spend still counts in the denominator, and the outcome it produced still counts in the numerator, so the pooled figure is not biased by it; only the pairing of that cost with its issue is lost. The zero-token check (Section 7.1) looks for tokens per (developer, repository, issue), so an outcome whose AI work was done only in Opencode records no tokens against its issue, is flagged, and takes its developer (and its pooled group and organisation headline) below the evidence floor while keeping its points. No branch-naming convention can fix this, because the branch is never recorded. Like Codex, Opencode capture is off by default on serve and ship, and serve requires --watch-repo with it.
Muse Code session logs (off by default, --muse). Meta's Muse Code CLI writes one session log per session at ~/.local/share/muse/sessions/YYYY/MM/DD/<session-id>/session.jsonl (ship --muse-home, or home in the collectors.muse config block, moves it). TIER reads only token counts, model names, the session id, each run's recorded branch and the workspace root, never prompt, reply or tool text. Its token mapping was measured on one real Muse Code 1.4.0 session (docs/how-it-works.md, section 3d). Two record kinds are model calls Meta bills, and each becomes one event: an agent model call, and an automatic model review of a pending tool call. Muse writes a run's branch after the run's last model call, so a call is recorded only once its run has settled: the run's branch record is present, or the run has ended and Muse wrote past it, or the session log has gone 6 hours without a write. The 6-hour rule is a guess: a live run that waits longer at a tool-approval prompt is recorded as unattributed:detached-head, and that stays. Otherwise attribution follows the same branch and ±30-minute commit rule as Claude Code (Section 5.1), and a session counts only when its recorded workspace root is in a watched repository. A subagent's calls take the repository and branch of the parent run that spawned it; subagent spend that cannot be tied to a single parent run is excluded with a warning, so a Muse Code user's recorded spend can be lower than their real spend (docs/how-it-works.md, section 3d, lists the cases). The key is ("muse", "meta", record id). Calls are priced at the muse-spark-* per-token list rates even when the developer pays for Muse by subscription (an operator ruling on #895, 2026-09-27), and Meta's per-query web-search charge is not priced, so Muse Code cost is a floor for any session that searched. The proxy has no Meta route. Under serve the collector rescans every scan_interval (config collectors.muse.scan_interval; default 5 minutes, floor 30 seconds), its first pass reads every log on disk, and serve requires --watch-repo with it.
Reverse proxy (mounted by default). tierd serve mounts /anthropic/, /openai/ and /gemini/, forwarding to --anthropic-target, --openai-target and --gemini-target (defaults: the three providers' public API hosts). Setting a target to the empty string unmounts that route; --read-only unmounts all three. A client points its base URL at the proxy (for example ANTHROPIC_BASE_URL=http://<tier-host>:8080/anthropic) and sends an upload or personal key in an X-Tier-Token header (Section 9.5). It is a separate header because Authorization carries the client's own provider credentials, which the proxy passes through. Forwarding a call on the client's provider credentials spends money, so the read-only token gets 403; a missing or wrong token gets 401 and counts toward the per-IP lockout. Every X-Tier-* header, this one included, is removed before the request goes upstream, on every request (#865). The proxy passes the response through (it may decompress a gzip response in transit), and records id, model and usage, including from server-sent-event streams. The /openai/ route reads both the Chat Completions and the Responses API shapes. The Gemini route is tested against synthetic bodies and has not been exercised by live Gemini traffic.
The client may name the spend with three headers: X-Tier-Developer, X-Tier-Issue and X-Tier-Repo. Whether a coding tool can add custom headers is a property of that tool, not of TIER. X-Tier-Developer must match the authenticated holder or the request is refused; omission uses the holder. X-Tier-Issue is stored verbatim and joins an outcome only if it is written the way TIER stores issue ids (issue-42, TIER-99; Section 5.1). The one value the proxy rejects is the reserved sentinel unattributed (or any unattributed: value), which counts as forged. A missing or forged issue header stores the spend under the issue unattributed on the authenticated holder. A counter records each case.
Double counting, in one list.
- Claude Code JSONL and the proxy for the same call: one row, because both use the Anthropic message id. The row selects a coherent growing observation and updates token-derived cost atomically under the original basis; incompatible observations are retained as conflicts. Caller-authoritative costs stay unchanged.
- Re-running
tierd score,tierd shipor a watcher re-read: one row per key. - Codex through
/openai/and--codex-rollout: counted twice. The proxy keys on the response id, the collector on session and ordinal. - Opencode through the proxy and
--opencode: counted twice, for the same reason. POST /api/v1/costswithout anidempotency_key: every re-post is a new row.POST /api/v1/costsfor usage a provider poller also reports: refused on a server running that provider's poller, unless the row declares"billed_to": "other"(Manual REST, below; #854). A wrong declaration, and a row stored before v0.5.2, are counted twice.
tierd serve warns at startup in the two double-counting cases: Codex capture with /openai/ mounted, and Opencode capture with any proxy route mounted.
Manual REST. POST /api/v1/costs stores one event, for scripts and imports. Its source must be "api" or omitted (omitted means api); any other value is a 400. Every cost event carries a fidelity: realtime (captured per call, by the log readers and the proxy), daily (a daily total) or estimated. On /costs it may be daily or estimated (default estimated); realtime is refused. Unlike every other path, /costs stores the caller's dollar figure (cost_usd) as given, so an import must apply list pricing itself. With an idempotency_key, a repeat of the same cost is ignored and a different cost is a 409 unless the request sets override (an audited correction); a keyed post whose developer, issue, model, source or fidelity differs from the stored row is also a 409 and writes nothing (#871). On a server running the Anthropic Admin or OpenAI Usage poller, a row for that provider's models is refused with 400 unless it declares "billed_to": "other", for spend billed where the poller cannot see it (Claude Max seats, Bedrock or Vertex, another organisation); the declaration is stored and exported, and serve warns at startup with the count and dollar sum of undeclared manual rows on days a poller covered (#854). model, developer and issue_id are required; issue_id is stored as sent, so it joins only if written the way TIER stores issue ids. A negative cost is a 400. The body has no timestamp field: the server stamps the time it arrives, so an import of past spend lands in the current window. The row is stamped with the loaded table's price_version although that table did not price it, and tierd reprice never rewrites it.
Recorded cost can combine token-derived table rates, unknown-model guesses, approximate provider remainders, Copilot gross dollars, Cursor vendor-cent fallbacks and caller-supplied /costs dollars (stored as given, never repriced). Token-derived reference cost may use published rates or an explicit operator estimate. Provider- or caller-supplied dollars and a price-version stamp do not establish derivation from TIER's rate table. /events shows each row's source, fidelity and billing_mode; /scores does not yet break the total down.
Org-level pollers (off by default). Two pollers read an organisation's usage and cost from the Anthropic Admin API and the OpenAI Usage API, one day at a time, once that day ended at least 24 hours ago. They write two things. First, a remainder event per model and day: for each token class, the provider's total minus what the Claude Code, Codex, Opencode and proxy paths recorded for that model and day, never below zero, priced with the table. It is stored under developer unattributed, issue unattributed and fidelity daily, with no repository. These reach the denominator and cannot be split per person. Each poll re-reads every day from the first of the previous month. Completed day/model rows retain their counts, costs and basis on re-poll, including when later capture or provider totals differ. Newly started poll days after installation cutover aggregate-approx-v1-2026-10-14 price the remainder with request thresholds disabled and store pricing_basis=aggregate_approx; missing models on an existing day inherit its original policy. Request pricing stays unchanged. Aggregate views disclose the approximation, its possible long-context under-statement and historical uncertainty; pricing provenance and daily approximations describes original-rate ceilings and explicit repair. Days older than that window are never revisited. Second, delta rows in org_actual_spend under the source anthropic-admin or openai-usage, which feed Spend Leverage (Section 8). They are enabled by the collectors.anthropic_admin and collectors.openai_usage config blocks, each taking api_key (prefer the @/path/to/file form), org and an optional poll_interval (default 1h, minimum 5m). Do not also post the same provider's invoice to org_actual_spend by hand for that organisation.
What each source needs
The four local readers use usage records already on the machine, with no collector credential and no billing-plan check. They need access to those files and a watched repository. A remote or hosted session is outside these readers unless its supported local records are available to them. The organisation and team pollers are enabled only when their config block is present; see config.example.yaml for the settings.
| Source | Plan or account type needed | Credential | What is recorded | Per-person attribution | What is NOT covered |
|---|---|---|---|---|---|
Claude Code JSONL (jsonl) |
Claude Code account producing local session logs; plan not checked | None for the reader | Model, token counts, priced cost, time and repository; issue when derived | Configured developer id, otherwise OS username; aliases can join it to a GitHub login | Usage outside the selected repository; browser-only Claude usage |
codex (collectors.codex_rollout) |
Codex CLI account producing local rollout logs, including subscription or API-key use; plan not checked | None for the reader | Model, token counts derived from cumulative snapshots, priced cost, time and repository; issue when derived | Configured developer id, otherwise OS username; aliases can join it | Hosted tasks without local rollout logs; sessions outside watched repositories |
opencode (collectors.opencode) |
Opencode using the Z.ai coding plan route (zai-coding-plan) |
None for the reader | Completed messages' model, token counts, priced cost, time and repository | Configured developer id, otherwise OS username; aliases can join it | Other provider routes; in-flight messages; issue attribution (no branch in the store) |
muse (collectors.muse) |
Muse Code account producing local session logs; plan not checked | None for the reader | Billed model-call token counts, model, priced cost, session id, time and repository; issue when derived | Configured developer id, otherwise OS username; aliases can join it | Web-search fees; subagent calls without supported parent linkage; sessions outside watched repositories |
anthropic_admin (collectors.anthropic_admin) |
Anthropic organisation with access to usage and cost reports | Org Admin API api_key (supports @/path/to/file) and configured org |
Daily per-model token remainders, priced cost and monthly actual-spend deltas | None: remainder is unattributed |
Personal Claude subscriptions; per-person, repository or issue linkage from the aggregate reports |
openai_usage (collectors.openai_usage) |
OpenAI API organisation with access to usage and cost reports | Org admin/owner API api_key (supports @/path/to/file) and configured org |
Daily per-model token remainders, priced cost and monthly actual-spend deltas | None: remainder is unattributed |
ChatGPT subscriptions; per-person, repository or issue linkage from the aggregate reports |
copilot_billing (collectors.copilot_billing) |
GitHub Copilot organisation AI-credit billing | GitHub App installed on the org with Administration: Read-only for spend (preferred); organization GitHub Copilot Business: read only for counting through root mappings: app_id, installation_id, private_key; or org-admin token; secrets private_key and token support @/path/to/file; configured org |
Daily spend using GitHub's gross amount (before included credits and discounts), by mapped developer, model and product, plus an org-level unattributed remainder | Root mappings use numeric GitHub user IDs resolved through the current billing seats list. Login-shaped roster IDs and aliases still capture spend; only mappings count toward privacy k. Unmapped spend remains in the org remainder | Individual Copilot Pro (no org billing API); token counts, repository or issue attribution; net invoice spend |
cursor_admin (collectors.cursor_admin), supported for team costs |
A Cursor team plan with Admin API access (Teams or Enterprise; Cursor decides which plans include it) | Team Admin API api_key (supports @/path/to/file) |
Daily developer/model cost and available token counts, priced per event before aggregation | Normalized email lookup through root mappings for counting; alias lookup still assigns spend. Service/cloud agents stay unattributed. Vendor email is never stored on events | Individual accounts; BYO-key events and Cursor's fee on them; conversation id, repository or issue attribution |
Copilot organisation billing is supported. Cursor Admin is supported for team costs on a team plan with Admin API access (Teams or Enterprise; Cursor decides which plans include it). Requests using a person's own API key are left out, including the small amount Cursor itself charges on them. The operator's 2026-11-02 live check at 6528b5fb on real Cursor data matched Cursor's own figures: 18 events, $19.6002 and 15,195,306 tokens, with equal TIER, for one team member over two days. This is the scope of that check; a working credential probe alone does not establish complete capture. tierd doctor --config <path> checks configured credentials: copilot_billing credential reports HTTP status, item count and auth mode; cursor_admin credential reports HTTP status and event count. Doctor also probes Copilot seat access and warns when mapping polling is disabled. Doctor probes do not ingest spend. Both collectors use daily fidelity and reconcile changes with signed delta rows; neither feeds org_actual_spend or imports seat fees.
Copilot live accounting check (2026-11-05). For one seat over two days, organisation and per-person gross spend matched GitHub's figures at TIER's microdollar precision: $0.001655 on 2026-11-02 and $0.125241 on 2026-11-03. Both days were fully discounted, with net $0; TIER records gross, before included credits and discounts, rather than the net bill. Unmapped spend stayed in the organisation remainder, and voiding a mapping replayed correctly, returning its spend to that remainder. This one-seat, two-day check is the scope of the evidence; it does not cover individual Copilot plans.
Copilot App install and upgrade. In the GitHub App's organization permissions,
keep Administration: Read-only for billing usage and add
GitHub Copilot Business: read for the paginated
GET /orgs/{org}/copilot/billing/seats
read. After updating the App, accept the App's updated permissions on its
organization installation. TIER checks the installation token's
organization_copilot_seat_management permission; token (PAT) mode probes the
seats endpoint instead. Missing permission reports
“Copilot seat permission not granted — accept the App's updated permissions”.
In token mode, doctor instead names the required manage_billing:copilot
(or admin:org) scope. Mapping polling fails closed, while alias spend capture
continues. Individually malformed seats are skipped with a count-only warning;
list shape or pagination errors disable mapping polling for that pass.
Copilot billing. The poller reads GitHub's organisation AI-credit billing usage, not a personal Copilot Pro account. Every pass reads all root mapping partitions; it fetches the current paginated billing seats list when mappings exist. Numeric IDs resolve to current logins, so a rename follows the ID and a reused login needs its own mapping. A mapped ID absent from a successful seats read is skipped with a count-only warning; its active or ended, non-voided partition keeps its stored rows unchanged, and the organization remainder subtracts that preserved spend so the day can settle without double-counting. An absent mapping with no stored rows contributes nothing. A failed seats read leaves days with stored non-voided mapping partitions incomplete and unsettled for retry. It requests usage once per login in the union of mapped seats and alias candidates, and requests the organisation total. The alias candidates send lowercased user= query values for every rostered developer id and every alias key whose canonical target is rostered, when the text matches ^[A-Za-z0-9][A-Za-z0-9-]{0,38}$ with no trailing or doubled hyphen; the reserved unattributed developer is excluded. These identifiers can be local OS usernames or configured developer ids, not just GitHub logins. An alias key that is a local username but happens to be a different org member's GitHub login is queried as that login, so its spend is attributed to the alias's developer unless an effective root mapping for that seat overrides it for that day; keep alias keys that are not GitHub logins unambiguous. It records the nonnegative organisation remainder as unattributed spend per model and product. Organisation-level and per-person gross spend matched GitHub for one seat over two days in the 2026-11-05 live check; an unusable organisation response leaves that day incomplete. Keep Opencode's Copilot provider excluded, and do not also import the same spend manually. Defaults are poll_interval: 6h (minimum 15m), settle_days: 2 and lookback_days: 7, with 1 <= settle_days <= lookback_days <= 31.
Cursor pricing and identity. 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. The reported-cents fields are tokenUsage.totalCents for token calls and chargedCents for non-token calls. Usage emails are trimmed and lowercased by the same normalizer used for root mappings. Each effective, non-voided mapping aggregates into its own row under the mapping developer; other human usage keeps alias-based spend attribution. The literal "null" and service/cloud/automation identities never carry mapping evidence. Vendor emails are transient insert evidence, never event columns or reconciliation keys; conversation ids are not decoded or stored. A colliding case-folded alias goes to unattributed.
Mapping reconciliation. Both pollers read all vendor mapping rows once per
pass, including ended and voided partitions. Mapping-specific keys extend the
existing delta keys with the opaque mapping ID; legacy keys remain unchanged.
Before effective_from, alias spend keeps its legacy path; a mapping-only Copilot
login contributes nothing, leaving its spend in the organization remainder.
Ended mappings retain their partition for days inside their interval, including
late usage. A voided partition targets zero on replay inside the lookback, with
spend reconciled to the legacy path and no counting evidence. Copilot clamps and
rounds at the original developer/model/product boundary to conserve spend.
Nonnegative partitions consume that budget deterministically, with legacy spend
first, then mapping IDs; negative vendor amounts never create a counted identity.
No vendor ID is stored on events or in delta keys. End and Void counting remains
governed by the insert binding and the shared census/coaching predicate.
If erasure hard-deleted a mapping row, its stored partition leaves that day
incomplete: no deltas or settlement, and later days still ingest. Cursor records
unresolved spend through its existing loss path.
Cursor BYO keys and uncosted days. Requests whose kind contains api key (case-insensitive; observed BYO kind: User API Key) are skipped, even when Cursor calls them chargeable. 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 requests 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 disclosed in the monthly close evidence. For a token call without an audited rate, adding the model's price and completing a clean re-read can resolve the day before it leaves lookback_days and before a loss is recorded. Late discovery records a loss immediately; a fresh process treats prior certification as possibly complete. Defaults are poll_interval: 1h (also the minimum), settle_days: 1 and lookback_days: 7, with 1 <= settle_days <= lookback_days. The Admin key can write team settings at Cursor; TIER uses it only to read usage.
Event issue rules. The optional POST /api/v1/events wire field issue_rule
is client-asserted extractor provenance. Only the closed set in
POST /api/v1/events is retained;
absent or unknown values store NULL, with one warning for unknown values.
It is never copied into outcome provenance and does not establish strict
attribution. Upgrade the server first. Against an older server's exact
unknown-field HTTP 400, the shipper uses its existing single resend with the
rejected optional field omitted; no capabilities endpoint is involved.
The laptop shipper, tierd ship. When developers work on laptops and the server runs centrally, tierd ship --server <url> --repo <path> sends locally captured events to POST /api/v1/events. The server prices each event again with its own loaded table. ship keeps no state (the server deduplicates on keys), reads the last 90 days by default, and exits 1 when it finds nothing unless --allow-empty is passed. Every run resends everything in its window, in batches of 500 events, and the server keeps one row per key; a scheduled job can pass a shorter --since to send less. A batch that fails after three retries ends the run with exit 1; batches already sent stay stored. The project has not measured the server load of frequent full resends. --codex-rollout, --opencode and --muse are off by default on ship too. Without them, a central server records a laptop's Codex, Opencode or Muse Code outcomes and none of their cost, so that work reads as free and TIER reads high for exactly the developers who moved to those tools.
3.2 Pricing: list prices, and why
Token-derived costs use a reference price table embedded in the binary (internal/store/prices.yaml), rather than the organisation's invoice. Copilot billing records GitHub's gross AI-credit dollars; Cursor falls back to vendor cents when a token call has no audited TIER rate or a call is not token-based (Section 3.1). The aim: price what the provider would charge at its published list rates, including its charging mechanics. Other pricing limits are stated where they apply: an unknown model is priced by a guess (Section 3.3); Opencode reasoning tokens are priced at the output rate by assumption (Section 3.1); /costs stores the caller's figure (Section 3.1); and fast mode, below.
- Mechanics count in the denominator: cache-read and cache-write multipliers, long-context re-pricing above a model's threshold (Section 3.3), and thinking or reasoning tokens where a provider reports them (priced at the output rate).
- Procurement does not: enterprise discounts, batch discounts and subscription fees leave the denominator unchanged. They are recorded as paid spend and show up in Spend Leverage (Section 8). TIER prices recorded token classes using its reference table. Coverage depends on the capture route and request evidence. The following billing features are not fully supported; affected totals must not be described as complete provider bills.
| Feature | Status | Direction or limit |
|---|---|---|
| Standard token rates | Reference-table rates; exact entries are not independent capture validation | See the dated qualification below for gpt-5.6 cells |
| Anthropic read / 5m-write / 1h-write classes | Priced when preserved in the observation | Missing classes understate cost |
| Fast / service-tier / geo modifiers | Not detected or priced | Premium usage can understate cost and raise TIER |
| Fee-bearing server tools | Not priced; web fetch has no extra fee | Omitted fees understate cost and raise TIER |
| OpenAI separate cache-write class | Not correctly priced; billing meaning and containment unvalidated | Error direction remains unverified for the route |
| GPT long-context tiers | Not encoded in product rows | Applicable premiums can understate cost and raise TIER |
| Aggregate request-length effects | New poll days use base-rate aggregate_approx; historical completed rows stay frozen |
New approximations may understate long-context cost; historical error can go either way |
| Unknown-model guess | Priced by guess | Error can be in either direction and has no validated bound; exact current lookup does not establish historical pricing basis |
| Reasoning rules by route | Independent capture validation requires a reconciliation receipt | Omitted or overlapping classes can move cost either way |
| Declined server-side fallback attempts | Not counted | Missing billable attempts understate cost and raise TIER |
A 2026-10-14 check of provider pricing pages found gpt-5.6 rates differing from table v14 and a promotional window through 2026-11-21 whose model applicability remains unconfirmed. These are unresolved source discrepancies, not verified replacement rates or verified expiry applicability. A human-confirmed primary-source read is required before changing those cells. Until resolved, identify the affected cells separately from verified standard-rate cells.
The table describes v0.7.0 limitations. The aggregate approximation is prospective; v0.5.2 and completed historical rows keep their original pricing. The encoded rates are in internal/store/prices.yaml and reference-price-table.md. No assessment exposure percentage is presented as a fresh measurement here.
| Capture route | Recorded classes / reasoning convention | Independent live accounting status |
|---|---|---|
| Anthropic JSONL | Input, output, cache reads and 5m/1h writes; thinking inclusion unvalidated | Unverified (#837), including subscription routes |
| Anthropic proxy | Input, output and reported cache classes | Unverified; no live-dogfood proxy receipt |
| OpenAI proxy: Chat Completions / Responses | Input, output and reported cached input; separate writes unvalidated | Unverified (#459); checked Responses fixtures are synthetic |
| Codex rollout | Cumulative input, cached input and output; reasoning is logged separately, not added to output cost | Independent counter and billing reconciliation unverified |
| Gemini proxy | Input, cached input, output and reported thoughts | Unverified (#459) |
| xAI / DeepSeek / hosted open-weights via OpenAI route | OpenAI-compatible usage fields; reasoning semantics depend on route | Unverified (#459) |
| Opencode / Z.ai | Input, output, cache and separately reported reasoning; coding-plan valuation uses reference rates | Independent reasoning containment and billing reconciliation unverified |
| Muse | Recorded token classes with documented local mapping | Independent accounting unverified; unsupported nested calls and tool fees can be omitted |
| Anthropic / OpenAI daily pollers | Daily remainder by reported token class | Aggregate approximation, not request-level reconciliation |
| Copilot billing | Organisation AI-credit gross spend | Supported; 2026-11-05 live accounting check, one seat over 2026-11-02 and 2026-11-03 (two days); gross recorded, net $0 on both days |
| Cursor Admin | Team usage costs and available token counts | Supported for team costs with a team plan and Admin API access; 2026-11-02 check at 6528b5fb matched Cursor's 18 events, $19.6002, 15,195,306 tokens and TIER for one member over two days; own-API-key requests and Cursor's small fee on them left out |
A usage-schema source or a parser fixture establishes less than independently reconciled billing. The Copilot and Cursor checks have their stated scopes; the other routes above have no dated independent reconciliation receipt supplied with this document.
List pricing puts every installation's cost on the same price basis: the same tokens on the same model cost the same dollars everywhere. That does not make TIER comparable across organisations (Section 6.3). Cache reuse stays in the cost because it depends partly on how the work is run, for example how much context is reused between calls. Two teams with identical work but different cache reuse get different scores: the team with less reuse pays more for the same tokens and scores lower. Read cache_read_share (the share of tokens that were cache reads) beside a score before concluding that a team is less efficient.
3.3 The cost arithmetic
For a model with separate input and output rates, cost is:
input × input_rate
+ cache_read × input_rate × cache_read_mult
+ cache_w5m × input_rate × cache_write_5m_mult
+ cache_w1h × input_rate × cache_write_1h_mult
+ output × output_rate
with token counts divided by one million and rates in dollars per million tokens. Long-context tier: if the model carries one and the call's input plus all cache classes (cache reads and both cache-write classes; output is not counted) is strictly greater than context_threshold, the over-tier input and output rates replace the base rates for the whole call, and the cache multipliers scale off the over-tier input rate. In the v14 table shipped in v0.6.0 four rows carry such a tier: claude-haiku-5-5 has a 100,000-token threshold ($0.10 / $0.50 base, $0.50 / $2.50 over); the other three have a 200,000-token threshold: claude-sonnet-4-5 ($3.00 / $15.00 base, $6.00 / $22.50 over), gemini-2.5-pro ($1.25 / $10.00 base, $2.50 / $15.00 over) and gemini-3.1-pro ($2.00 / $12.00 base, $4.00 / $18.00 over). No other row has one, claude-opus-4-8 included, so a large context on those models prices at the base rate however big it is. Self-hosted reference entries are combined: one rate for all token classes and no cache discount.
Cache multipliers are per model, as fractions of the input rate. A row that sets none takes its provider's default:
| Provider or rows | Cache read | 5-minute write | 1-hour write |
|---|---|---|---|
| Anthropic (default) | 0.10× | 1.25× | 2.00× |
claude-opus-5-5, claude-sonnet-5-5 |
0.05× | 1.25× | 2.00× |
claude-fable-5-1, claude-mythos-5-1 |
0.025× | 1.25× | 2.00× |
| OpenAI 4-era (default) | 0.50× | 1.0× | 1.0× |
OpenAI gpt-5.x |
0.10× | 1.0× | 1.0× |
gpt-6.1-sol |
0.05× | 1.25× | 1.25× |
OpenAI gpt-6-* |
0.10× | 1.25× | 1.25× |
gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro |
0.25× | 1.0× | 1.0× |
deepseek-r1 |
0.0509× ($0.028 ÷ $0.55 input) | 1.0× | 1.0× |
deepseek-v3 |
0.1× ($0.028 ÷ $0.28 input) | 1.0× | 1.0× |
glm-5.3 |
$0.26 ÷ $1.40, stored at full precision | 1.0× | 1.0× |
glm-5.3-flash |
0.2× ($0.03 ÷ $0.15) | 1.0× | 1.0× |
muse-spark-1.1, -1.2, -1.3 |
0.12× ($0.15 ÷ $1.25) | 1.0× | 1.0× |
muse-spark-1.2-contributor, -1.3-contributor |
0.02× ($0.002 ÷ $0.10) | 1.0× | 1.0× |
| Every other Google, xAI, DeepSeek, Z.ai and self-hosted row | 1.0× | 1.0× | 1.0× |
Multipliers are used as written, unrounded: 0.0509 × $0.55 is a cache-read rate of $0.027995 per million, not exactly the published $0.028; only each call's total is rounded to the micro-dollar. Representative version-14 rows shipped in v0.6.0, in $ per million input / output tokens: claude-opus-4-8 5.00 / 25.00; claude-opus-5 5.00 / 25.00; claude-opus-5-5 4.00 / 20.00; claude-sonnet-5 2.00 / 10.00; claude-fable-5-1 10.00 / 50.00; claude-haiku-4-5 1.00 / 5.00; claude-haiku-5-5 0.10 / 0.50 through 100K; gpt-6-astra 10.00 / 50.00; gpt-6-sol 2.00 / 10.00; gpt-6.1-sol 2.00 / 10.00; gpt-5.6-sol 5.00 / 30.00; glm-5.3 1.40 / 4.40; glm-5.3-flash 0.15 / 0.50; muse-spark-1.3 1.25 / 4.25; self-hosted-medium 0.50 for all tokens. (Fable and Mythos are Anthropic model names; Muse Spark is Meta's.)
Model names are normalised before lookup: lower-cased, the one recognised provider prefix (zai-coding-plan/) stripped, date and -preview/-latest suffixes removed (claude-sonnet-4-20250514 becomes claude-sonnet-4). Minor versions have their own rows because each has its own price; normalisation does not collapse opus-4-8 to opus-4. When a call carries a route or host (the proxy's upstream host, Opencode's provider id), the key <model>@<host> is tried first and the bare model name second.
An unknown model is priced by a guess: round rates the project chose, not measured ones. selfHostedClass first tests the lower-cased name for substrings: nemotron-ultra or deepseek-r1-full selects large; otherwise phi-4-mini, embeddings or reranker selects small. Otherwise, the first parameter count in billions matched by paramCountRE selects large at ≥70B, medium at 7–<70B or small below 7B. The combined rates, one rate for all tokens, are $2.00 per million for self-hosted-large, $0.50 for self-hosted-medium and $0.10 for self-hosted-small. A 13b, 31b or 65b model is therefore medium. Only when nothing matches does the guess default to self-hosted-medium. Both guess paths log a one-time warning per normalised 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. Every guess made while serving increments tier_unknown_model_events_total on the Prometheus metrics endpoint (/metrics), so a new model that arrives before the table is updated is visible.
A guessed cost has no validated error bound or error direction. The share of tokens with an unknown pricing basis or without an exact entry in the current price table is not a measured share of dollars originally guessed. Current table exactness does not establish the original basis of historical rows.
tierd reprice --from-version N --commit recomputes the cost of every stored row whose price_version is numerically N or higher (so an override numbered 1000 is included in any run below 1000) from its stored token counts, under the current table, and re-stamps price_version. It skips POST /api/v1/costs rows and rows that carry a cost but no token counts. It refuses to commit if any changed row would be priced by a guess and has a nonzero cost before or after, unless --allow-guessed is passed. Rows costing zero both before and after, including zero-token <synthetic> rows, do not trigger this refusal; the test is on cost, not the model name. Without --commit it is a dry run.
3.4 Price-table versions
The table carries version (an integer) and effective_date (YYYY-MM-DD) as one unit. TIER v0.6.0 embeds version 14, effective 2026-10-12, 93 models, replacing v0.5.2’s version 12, effective 2026-10-02, 91 models; v14 changed only claude-sonnet-5-5 cache reads from $0.20/M (0.10×) to $0.10/M (0.05×), per Anthropic's pricing page read 2026-10-12; v13 added claude-haiku-5-5 at $0.10/$0.50 through 100K prompt tokens ($0.50/$2.50 above) and gpt-6.1-sol at $2/$10 with $0.10 cached input; v12 added claude-sonnet-5-5 at $2.00 per million input tokens and $10.00 per million output tokens, v11 added the five Meta Muse Spark rows and the meta provider, and v10 had added the Fable 5.1, Mythos 5.1, Opus 5.5, gpt-6 and Z.ai GLM rows and corrected claude-sonnet-5 from $3/$15 to $2/$10. The effective date marks the table revision, not the day each row was captured. The rows are the maintainers' reading of each provider's published prices, with inferred cache-write rates identified in the table's comments; this reference does not reproduce the provider pages, and the table file's comments record what each revision changed. No update schedule is published; a new release carries a new table, and until then an unpriced model is guessed and warned about (Section 3.3).
Each cost row is stamped with a price_version: the table that priced it, except for POST /api/v1/costs rows, which carry the loaded table's version although the caller supplied the dollars (Section 3.1). Future token-derived partial-to-final promotions atomically update counts and cost under retained original rates and rules; explicit doctor cost-consistency --dry-run/--apply, tierd reprice and audited /costs corrections are the historical correction paths. Completed poller rows and frozen monthly and legacy raw-verification input bands remain protected, so a window that spans a price change reports data_quality.mixed_price_versions. A /scores response carries a price_table block describing the table the server has loaded now: version, effective_date, table_hash (a digest of the resolved rates, prefixed tierpt1:, the name of TIER's digest scheme, defined in internal/store/prices.go) and file_hash (a SHA-256 digest of the YAML bytes, prefixed sha256:). The table hash answers "same prices": a change to a compiled-in default multiplier moves it with the file unchanged, and a comment-only edit moves file_hash alone. Section 2.1 gives the comparison rule built on it.
An administrator may override the table with --prices <file.yaml> (or prices_file: in config, or TIER_PRICES). The override replaces the whole table; it does not merge. It must contain the self-hosted-large, self-hosted-medium and self-hosted-small rows or it is refused at startup, and any model it omits is priced by the unknown-model guess. The version number is binding, and two guards enforce it:
- At load, before any database opens: an override that declares this binary's embedded version (14 in v0.6.0) with different resolved rates is refused. No flag accepts the collision, so changed version-14 rates cannot be loaded by v0.6.0 on any database, fresh or not.
- At database open: a version this database has already recorded under a different
table_hashis refused. This binding alone can be retired:tierd prices forget-version --db <path> --version <N> --commit(a dry run without--commit) marks the recorded identity as forgotten, so a replacement table may reuse a non-embedded number.
So two installations can record different tables under the same non-embedded number, which is why the comparison rule uses the hash. Editing rates and bumping version are one action.
GET /api/v1/fidelity adds a 30-day unknown_pricing_token_share: share of tokens with an unknown pricing basis or without an exact entry in the current price table (#1228; R-2026-10-14-17). It sums input, output and all three cache classes, counting a row as unknown when its price_version is different from the active version, its host-aware model lookup has no exact current entry, or its stored pricing_basis is missing, unrecognized, guessed, unavailable, or aggregate (including historical aggregates). Known bases (token_derived and caller_authoritative) still require the active version and an exact current entry. Rows at other versions, including higher override versions after a rollback, remain basis-unknown even if their model is listed today. The share is computed on reads, without a migration or cost rewrite, and is zero with no recorded tokens. cost_label is "estimated cost" iff the share is nonzero. The existing unknown_model_cost_share remains a separate dollar-weighted current-table lookup. See the API contract for the additive JSON fields and unchanged anonymised-mode refusal.
Local tierd score prints the same token-share label for its Claude Code JSONL window and marks affected developer costs, issue costs and the total as estimated cost. It freshly prices the logs under the active table on every run. An exact configured estimate is an exact table entry; neither a zero share nor a low share establishes provider list rates, a bound on dollar error or complete capture.
4. Where the outcomes come from: PRs, weights, quality floors, direct commits, outcomes API
Issue extraction provenance. Schema version 12 adds nullable issue_rule TEXT
columns to outcomes and token_events; outcome_exclusions preserves the
same column when an existing outcome is archived. The singleton
issue_rule_cutover records recorded_at (UTC), outcome_id (the maximum
outcomes.id) and token_event_id (the maximum token_events.id) when the
migration first applies; empty tables have a boundary of 0. It is written once
and survives reopening and erasure. Existing rows remain NULL, and historical
issue assignments are unchanged. NULL rows above these boundaries were written
after cutover; legacy is an export rendering, not proof of a pre-upgrade row.
This is a one-way upgrade from schema 11 to 12, after the quality-trigger
provenance migration from 10 to 11: older binaries refuse the database.
Outcome rules are server-set by webhook or PR backfill from the extractor that
produced issue_id; explicit API issue IDs have no rule. POST /api/v1/outcomes
rejects a posted issue_rule through its closed decoder. New rows carry strict
rule names: branch_first_slot, branch_configured_key,
body_close_directive, body_configured_key, body_relation_ref,
commit_close_directive and commit_configured_key. The legacy names
branch_first_numeric, branch_tracker_shape, body_tracker_shape,
body_bare_hash and commit_tracker_shape identify rows written before the
strict-grammar change; see #1301's cutover marker. The close-directive names
remain shared across the cutover.
4.1 What counts as an outcome
The default outcome is a merged pull request. Merge is the acceptance event; a PR that is opened, reviewed or closed without merging records nothing. Four producers can record an outcome. The webhook, the outcomes API and backfill resolve the weight the same way: a supplied weight (from a size label, or sent through the API) is stored with weight_source: "label", and with none the diff heuristic gives "git-heuristic". Push capture (Section 4.5) uses a fixed 0.5 ("push"). Quality starts at 1.0, except that the outcomes API accepts a caller-supplied quality in [0, 1] (Section 4.6).
- GitHub webhook (
POST /webhook/github): apull_requestevent withclosed+mergedinserts one outcome,source = "github-webhook". - Provider-neutral API (
POST /api/v1/outcomes): for GitLab, Bitbucket, Gitea or any CI that can call an endpoint on merge,source = "api-outcome". - Backfill (
tierd backfill): walks merged-PR history through the GitHub REST API, reconstructing one outcome per merged PR,source = "backfill". - Push capture (opt-in): direct commits to the default branch,
source = "push"(Section 4.5).
A merged PR whose branch and body carry no recognisable issue reference records no outcome at all. The webhook logs "PR merged but no issue ID found" at debug level; backfill counts such PRs in its closing line (unattributed=N), which is the one place the number is reported. Section 5.3 says what this does to the figure.
An otherwise eligible merged PR can earn points regardless of its file paths, including documentation, hand-off notes and agent-memory changes. Opening, closing or reopening the referenced issue does not change its points; issue state is not a scoring input.
Each PR or API outcome is keyed on its merge_commit_sha, across all repositories (push outcomes have no SHA and are keyed on repository, issue and day; Section 4.5), and the first producer to record a merge commit hash (SHA) wins: a later webhook delivery, backfill run or API post for the same SHA changes nothing (backfill counts it as skipped; the API answers 200 with status: "duplicate"). There is no correction path short of erasure (Section 6.4). Labels come from the producer that recorded the outcome: the webhook reads them from the merge event, backfill reads them as they are when it runs. Editing a label after that changes nothing, and two producers could have weighed the same merge differently; the first one wins. TIER creates no labels; the team adds them in GitHub. A PR merged into any branch counts, not only the default branch, so a feature PR merged into an integration branch, and that branch merged later, records two outcomes (two merge SHAs). CI is read only on the default branch, so a PR merged into another branch never receives the CI floor (Section 4.4). A PR that closes several issues is credited to one primary issue (the branch-derived id if there is one, else the leftmost closes #N in the body); the others are logged, not credited.
4.1.1 Corrective declarations
This project's merge procedure requires every development PR to declare either Corrects: none or same-repository parent PR numbers, for example Corrects: #7, #8. Other installs enforce this requirement only with their own CI check; a reusable Action is planned after launch. Numbers are positive ASCII decimals without leading zeros; repository-qualified references, mixed none/references, malformed values and contradictory lines are invalid. Duplicate parents are deduplicated (maximum 32). The key is case-insensitive; none is lowercase. Surrounding whitespace, emphasis, one list bullet and a fullwidth colon are accepted; fenced examples and blockquotes do not declare a correction. A bare issue reference is not a declaration. The shared internal/corrects parser enforces this grammar and a 256 KiB body limit.
4.2 Size weights
The weight is the PR's size, read from a GitHub label, case-insensitive, with or without the size/ prefix:
| Label | Weight | Canonical meaning |
|---|---|---|
size/xs, xs |
0.5 | trivial: a one-line fix, a typo, a config flag, a dependency bump |
size/s, s |
1 | small: a localised change to one function or file |
size/m, m |
3 | medium: a self-contained feature or fix across a few files |
size/l, l |
5 | large: a feature touching several components, or a refactor with migration |
size/xl, xl |
8 | extra-large: a subsystem or multi-day effort landed as one PR |
The fixed scale applies to label-derived and heuristic weights: neither can produce any value but 0.5, 1, 3, 5 or 8. A weight sent through POST /api/v1/outcomes is taken as sent, anywhere in (0, 8] (Section 4.6). When a PR carries several recognised size labels, the first in the order the webhook payload (or, for backfill, the REST response) lists them wins. Label names can be remapped with the outcomes.size_labels config key. A custom table replaces the built-in one entirely, need not list all five sizes, and a label missing from it falls through to the heuristic; each value must be one of 0.5, 1, 3, 5, 8, or startup fails. tierd backfill --config <file> reads the same key, so backfilled and live outcomes weigh the same.
With no recognised label, TIER falls back to a bucketed diff-size heuristic on the PR's aggregate line and file counts:
effort = (additions + deletions) + changed_files × 10
effort ≤ 15 → 0.5
effort ≤ 60 → 1
effort ≤ 200 → 3
effort ≤ 1000 → 5
otherwise → 8
A 50-line PR touching 3 files has effort 50 + 30 = 80, weight 3. The provenance is recorded as weight_source = "label" or "git-heuristic". The heuristic is a fallback, not a measure of value. The webhook payload carries aggregate counts and no per-file data, and the webhook path makes no outbound GitHub call and keeps no clone, so generated or vendored churn inflates an unlabelled PR's weight. A size label replaces the diff entirely, which is the mitigation.
The rubric is versioned and reported on every /scores response and report manifest: the stored scoring policy selects rubric 1 while Event 3 is off and rubric 2 while it is enabled and fully resolved. Monthly closes pin that provenance; the version is not stored on raw outcomes. The project's tests tie it to the weight scale, the heuristic's output values, the work-type list and the quality floors, so changing any of those fails the tests until the number is bumped. The heuristic's thresholds, the work-type precedence, the default label names and the revert keyword lists are not tied to it: they can change with no new rubric number. The weights, floors and windows in this section are the project's rubric choices; it publishes no study behind them. The meaning column above is the rubric's definition of each size; docs/rubric.md adds worked examples and says nothing the table does not.
4.3 Work types
Each outcome carries a work_type from a closed taxonomy: feature, bug, security, incident, tech-debt, research, compliance. It is read from a PR label equal to a type name or prefixed type: / kind:; when several apply the precedence is security > incident > compliance > bug > tech-debt > research > feature; with no type label the default is feature. Monthly /scores may publish a census-gated work_types panel (Section 6.2). Synthetic/offline raw windows return a work_types[] array scoring each type separately; the following segment arithmetic applies to those raw windows. A segment divides by the developer's cost on the issues that carry an outcome of that type (Section 2.1 says why that reads higher than the pooled figure). An issue with outcomes of two types has its whole cost counted in both segments, so the segments do not add up to the whole. Comparing a security TIER to a feature TIER is a category error the taxonomy exists to prevent. The pooled rows mix all types, so a shift in the mix of work moves them with no change in efficiency. Section 2.1 says which figure a decision uses.
4.4 Quality floors: what is enforced, and what is specified but not
After an outcome is recorded, its quality is derived from events, never edited by hand. Each signal is appended to an append-only quality_events log and the outcome's quality is recomputed as the minimum of the applicable floors, clamped to [0.1, 1.0]. The floors the shipped code enforces:
| Signal | Floor | Window | Source event |
|---|---|---|---|
clean merge (ci_pass, or no signal) |
1.0 | — | — |
CI failure on the merge commit (ci_fail): a completed push-triggered failure run on the default branch whose head SHA is a recorded merge commit |
0.7 | up to 48 hours after the outcome; no lower bound | workflow_run webhook |
strategic revert, business decision (revert_strategic) |
0.8 | footer: up to 60 days after the outcome, inclusive, no lower bound; issue fallback: from the outcome through 60 days after it, inclusive | push webhook |
quality revert, code problem (revert_quality) |
0.1 | footer: up to 60 days after the outcome, inclusive, no lower bound; issue fallback: from the outcome through 60 days after it, inclusive | push webhook |
The floor values (0.7, 0.8, 0.1) are scoring conventions chosen by the project, not measured fractions of correctness, business value or value lost; no calibration study is published. CI runs on unmerged PR-head commits are not scored. In v0.6.1 (#1230), only push-triggered runs on the default branch change quality; matched scheduled and dispatched runs are audit-only, and merge_group runs are dropped. Workflow scope is not checked, so a matching push-triggered failure can still reflect runner or deployment problems; that does not establish that the merge caused it.
A strategic revert keeps most of the credit because the rubric reads it as a decision about work that was done, not a fault in it. The outcome's timestamp for a webhook-recorded PR is the PR's merged_at, falling back to receipt time only when it is missing, malformed, zero or future (Section 5.4); a revert is timed by its commit timestamp (receipt time if the commit timestamp is missing or zero), a CI run by its updated_at from GitHub (receipt time if updated_at is missing or zero). The 48-hour window is an upper cutoff only: a failure whose updated_at is before the outcome's timestamp still counts, but the outcome must already be recorded when the delivery arrives, or the delivery is ignored. A CI failure is cleared (recorded as ci_fail_flaky, quality stays 1.0) by a success that is a re-run of the failing run: same merge commit, same workflow_id, a strictly later run_attempt, finishing within 30 minutes of the failure, only if the matching failure is already stored when the success arrives. A green run of a different workflow on the same commit does not clear a red one. Once one flaky re-run is recorded for a (commit, workflow), every failure of that workflow on that commit is cleared, including later failures with no re-run of their own. So a genuine failure that follows a flaky one on the same commit and workflow is not penalised.
A pushed commit counts as a revert only when its commit subject (git's %s: leading blank lines skipped, first paragraph joined) matches (?i)^revert\s+["']?(.+?)["']?\s*$: the word Revert (any case) followed by whitespace, as in git's own Revert "feat: add login" or a hand-written Revert broke login. Revert: broke login, Reverted login change and revert(auth): … do not match, receive no penalty, and, with --push-capture on, are not excluded as reverts, so such a commit can itself be captured as a 0.5 push outcome. TIER v0.6.0 links a matching revert first by the This reverts commit <sha> footer git adds (a full 40-character lowercase hex SHA only). An unresolved footer never falls through to an issue match. Without a footer, a close directive (closes #N) or tracker key in the commit subject can resolve only when exactly one outcome exists for that issue in the pushed repository, with a timestamp in the inclusive interval from 60 days before the revert through the revert's timestamp. A bare #N, body reference, repository-less legacy row or another repository's same-numbered issue cannot select a target. In a push containing a Merge pull request #N commit, other revert commits use the footer only; their quoted subjects can refer to work that never shipped separately. The exact-footer path applies only the inclusive 60-day upper cutoff after the outcome; it has no lower bound, so an outcome timestamped after the revert can still receive a penalty.
When several qualified outcomes are eligible, TIER applies no new penalty and appends revert_ambiguous to each candidate. The revert commit SHA is source_ref, so replaying the same check does not duplicate those rows. This records “checked, unresolved”: numeric quality is unchanged (a clean outcome stays 1.0), quality_status in the JSON outcomes export reads unknown, and the quality-event JSON/CSV show status: unknown, evidence: false, with reason: ambiguous. The developer data export preserves the raw event_type: revert_ambiguous and reason: ambiguous. Existing penalties are neither repaired nor backfilled. Unknown event types also default to unknown status and no evidence; no quality-event row is evidence merely because it exists. These markers never count toward quality-evidence share. Cohort diagnostics count distinct revert source_ref, not candidate rows. The dashboard uses stored numeric quality and does not display this per-outcome status; inspect the exports for unresolved checks.
Unresolved checks emit a WARN with sanitised identifiers and increment tier_revert_ambiguous_total{reason}. Reasons are ambiguous, legacy_only, footer_unresolved, merge_push (footerless sibling in a PR merge push), and no_candidate. A check without a qualified candidate, including an unresolved footer, stores no quality event and cannot mark a particular outcome unknown. The counter counts delivery processing, including replays; durable ambiguous-revert counts use distinct source references. These rules do not establish complete quality coverage or justify a claim of complete quality adjustment.
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.
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.
A reland is assessed separately under the outcome and corrective-work rules; it does not erase the earlier revert signal.
The revert is then classified by keyword over the revert commit's message (the PR body is not consulted). In v0.6.0, the case-insensitive patterns use word boundaries and explicit inflections: bug does not match "debug" and OOM does not match "room". The (?s) phrase patterns permit intervening text across lines. The strategic patterns are exactly: \bproduct decisions?\b, \bPM requested\b, (?s)\bfeature flags?\b.*\bdisabl(?:e|es|ed|ing)\b, (?s)\bbusiness requirements?\b.*\bchanged?\b, \bpivot(?:s|ed|ing)?\b, \bdeprecat(?:e[ds]?|ing|ions?)\b, \bsunset(?:s|ted|ting)?\b, \bremoving features?\b, \bno longer needed\b, \breplaced by\b. The quality patterns are: \bbroke(?:n)?\b, \bbreak(?:s|ing|ages?)?\b, \bcrash(?:es|ed|ing)?\b, \bOOM(?:s|ed|ing)?\b, \bout of memory\b, \bregressions?\b, \bdegradations?\b, \bincidents?\b, \boutages?\b, \bbug(?:s|gy|fix(?:es)?)?\b, \bdefect(?:s|ive)?\b, (?s)\bperformance\b.*\bdegrad(?:e[ds]?|ing|ations?)\b, \bmemory leaks?\b, \bdata loss(?:es)?\b, \bcorrupt(?:s|ed|ing|ions?)?\b, \btimeouts?\b, \bdeadlock(?:s|ed|ing)?\b, (?s)\bsecurity\b.*\bvuln(?:s|erab(?:le|ility|ilities))?\b. A revert is strategic (floor 0.8) only when it hits at least one strategic pattern and no quality pattern; every other case, including no keyword at all, selects floor 0.1. The penalty lands on the matched original outcome, not on the reverter merely for reverting. Strategic versus defect classification is a commit-message heuristic, not verified cause or blame. The v0.6.0 release source contains these rules.
The project's quality-degradation specification describes a fuller eight-event model. These historical design rules are not product commitments and are not enforced by v0.6.0: proportional partial reverts (1.0 − 0.9 × fraction), production-incident correlation, hotfix-branch detection (0.4), downstream-CI failures (−0.20 per service), the "no CI signal" 0.95 penalty, the cross-PR flaky registry, and the persisted provisional → observing → final phase state machine. The 48-hour and 60-day bounds are applied as event windows today; the lifecycle state is not materialised. On this event-derived path there is no route to a quality of 0.0, because the clamp floor is 0.1; the outcomes API (Section 4.6) is the exception, since it accepts an explicit quality: 0 from the caller, though the next scoring-path CI or resolved-revert event on that merge commit replaces it with the event-derived value. Except for the explicit trigger-provenance recovery command below, there is no endpoint to correct or delete a single quality event; a wrongly classified revert or CI failure stays in the log. The only deletion is the erasure of a whole developer (Section 6.4). Corrective-work accounting (Event 3) is governed by its separately adopted design; scoring is built, off by default, and affects live scores when the operator enables it on their installation (see enabled operation). Supported CI and revert rules derive from recorded events.
Quality evidence and CI trigger cutover (schema 11, #1230)
The nullable quality_events.trigger_event TEXT column records workflow_run.event.
It stays outside idx_quality_events_uq: replay identity remains
(outcome_id, event_type, source_ref). The fixed trigger vocabulary is push,
pull_request, schedule, workflow_dispatch, merge_group,
pull_request_target, release, workflow_run, and repository_dispatch;
other supplied values become other.
The installation's first schema-11 writable open stamps
quality-trigger-recording-v1 in tier_migrations.applied_at. From that cutover,
only push-triggered runs on the default branch qualify as CI evidence or
change quality. Other matched trigger types are retained for audit and leave
quality unchanged. This includes scheduled/nightly and manually dispatched runs.
It also drops merge_group runs, which are the gating CI for merge-queue
repositories; this rule does not claim to observe their gating checks. The
existing 48-hour CI matcher and 60-day revert matcher remain in force.
Legacy CI rows with trigger_event IS NULL keep their existing scoring effect;
they are excluded from qualifying evidence and shown as CI trigger unknown.
Recovery is explicit. A legacy row resolved to push qualifies, and a row resolved
to a non-push trigger stops affecting an unsealed outcome. Only still-NULL rows
remain trigger unknown. The promise about scheduled runs starts at the cutover;
it does not silently re-score old rows. Closed monthly contributions are never rewritten by trigger repair. Schema 30 removes legacy seal bodies.
GET /api/v1/scores exposes quality_evidence for the window and each developer
row. quality_evidence_share is the sum of pre-quality size weights of eligible
current outcomes with at least one qualifying matched CI or revert event, divided
by the sum of pre-quality size weights of all eligible current outcomes. A
failure counts as evidence, not verified success. An outcome with both CI and
revert evidence contributes its weight once. A zero denominator produces null.
Legacy scoring treats every current outcome as eligible, including zero-token
outcomes; when Event 3 is enabled its existing ranking eligibility applies. The
weight is the stored size weight before quality, not final allocated points.
The read joins the existing quality_events log to current outcomes;
erased, excluded and orphaned outcomes cannot contribute. No second evidence
record or new table is built. The log is append-only in normal operation, not
immutable: erasure deletes it. workflows breaks CI evidence down by workflow_id
(unknown if absent), each using the same eligible-weight denominator; workflows
can overlap and their shares must not be summed. Revert evidence belongs to no
workflow. trigger_unknown counts still-NULL CI rows separately, with flagged
outcome IDs. recorded_numerator_rows and recovered_numerator_rows count
qualifying event rows, so they need not equal matched outcome counts. Recovery
is identified by each explicit resolve-triggers quality-history audit row.
For each kind, ci and revert, matched counts eligible outcomes with
qualifying evidence of that kind; window_open counts eligible outcomes whose
48-hour CI / 60-day revert acceptance window is still open at request time.
These are independent counts: matched outcomes can still have open windows.
Every share is shown with the installation's cutover timestamp. Evidence
completeness is unknown; there are no durable delivery-coverage records.
The score label reads "size-weighted points, adjusted for observed quality
signals; evidence: N%", or "evidence incomplete" for an undefined share.
Synthetic/offline anonymised raw reports return only {"status":"not_shown"} for this quality evidence. Monthly reports use frozen quality disclosures and the publication decision.
To recover provenance, first generate and review a plan for this installation:
tierd quality resolve-triggers --db ~/.tier/tier.db --dry-run --out triggers.json --token @/path/to/token
shasum -a 256 triggers.json
The command reads tierd's retained webhook_payloads.body_gz first and uses the
GitHub Actions API only when retained evidence has no match. Duplicate deliveries
of the same run agree; independent or conflicting matches stay unknown rather
than taking the first match. Rows predating #687 (head_sha:attempt, no workflow
ID), rows with no repository, and unavailable provenance remain unknown. Output
includes resolved and unresolved counts. Dry-run opens the database read-only
and writes no database rows. A token is required; @file indirection and the
post-flag-parse TIER_GITHUB_TOKEN fallback are supported. HTTP API URLs and
HTTPS-to-HTTP redirects are refused.
Only after an operator approves that exact dry-run plan, record the sign-off:
tierd quality resolve-triggers --db ~/.tier/tier.db --apply triggers.json --actor OPERATOR --approved-digest SHA256
There is no unattended apply. Apply verifies the approved plan digest and current
row identity, fills trigger provenance on unsealed outcomes, and writes an audit
row for every fill, including the actor and plan digest. Apply runs offline:
payload-sourced evidence is re-checked against retained payloads; API-sourced
rows are bound by the reviewed plan's digest and identity fields and are not
re-fetched. Changed local row identity or trigger provenance refuses an API row.
Apply prints the count of API-sourced rows applied "as reviewed, not re-fetched".
Non-push resolutions recompute quality from the remaining applicable events,
except when quality history records the current quality as a non-event write
(such as UpdateQuality); apply reports those preserved values. A value differing
from the pre-fill event resolution alone does not establish a non-event write.
Outcomes in closed monthly reports or protected legacy raw-verification bands, their provenance
and stored report outputs are untouched. Apply reports how many rows it actually
filled; skipped frozen rows remain unknown. The raw
payload retention limit (90 days / 50,000 rows) limits recovery, not the durable
evidence read from quality_events.
4.5 Direct commits (opt-in push capture)
Trunk-based teams that commit straight to the default branch never produce a merged-PR event, so their spend would score near zero. With --push-capture (env TIER_PUSH_CAPTURE, config outcomes.push_capture; off by default), a direct commit to the default branch that carries a resolvable issue reference becomes an outcome with a fixed weight of 0.5, weight_source = "push", source = "push". Commits are grouped to one outcome per (repository, issue, day in Coordinated Universal Time, UTC), credited to the GitHub login of its earliest commit, ordered by GitHub's push time (repository.pushed_at, which GitHub sets), then commit time, then SHA (#938), so splitting work into many commits earns nothing extra. A commit with no GitHub author login, or whose login is the reserved unattributed sentinel or resembles it (#619), is skipped but still logged and counted in tier_push_unattributed_total.
Skipped as well: reverts (the same commit-subject test as above); commits whose subject (git's %s: leading blank lines skipped, first paragraph joined) looks like git's merge subject (Merge pull request #N, Merge branch …, Merge remote-tracking branch … and no other form, so Merge tag … is not skipped; push payloads carry no parent list, so this is a subject match); and commits whose changed files are all generated or vendored. "Generated or vendored" means every file matches the outcomes.generated_paths list: a trailing / is a directory at any depth, a leading * is a filename suffix, anything else is an exact filename. The default list is vendor/, node_modules/, *.pb.go, *_generated.go, *.gen.go, go.sum and the lock files of npm, Yarn, pnpm, Cargo, Poetry, Bundler and Composer; [] turns the exclusion off.
A squash merge arrives as both a PR event and a push, in no fixed order. When the PR event is processed first, the push commit is skipped because its SHA is already recorded. Each captured commit is also recorded in a per-commit ledger, and a PR outcome removes its merge commit from it in the same transaction: a push outcome that held only that commit is deleted, and one holding other commits stays, re-owned to its earliest remaining commit. So the squash merge counts once whatever the order (#849). Push outcomes stored before v0.5.2 are never deleted, so a double count already stored stays. Push outcomes carry no merge_commit_sha, so CI-failure floors do not reach them; revert degradation still applies through the issue id. Push-grain outcomes are not directly comparable with PR-grain outcomes. /scores cannot filter by producer; authorized subject exports carry source on each retained row. The raw GET /api/v1/outcomes endpoint is denied on real servers.
4.6 The outcomes API
POST /api/v1/outcomes (root key) records an outcome from any forge:
| Field | Required | Notes |
|---|---|---|
developer |
yes | canonical developer identifier |
issue_id |
yes | stored as sent, so it joins cost only when written the way TIER stores issue ids (issue-42, TIER-99); the unattributed sentinel is rejected |
pr_number |
yes | integer ≥ 1 |
merge_commit_sha |
yes | dedup key, any non-empty string up to 256 characters (not checked as hex); a replay returns 200 with status: "duplicate" |
merged_at |
yes | timestamp in the RFC 3339 format, year 2020–2050; becomes the outcome timestamp used for windowing |
weight |
no | taken as sent if 0 < weight ≤ 8, not checked against the scale: 2.7 is stored as 2.7 and stamped weight_source: "label"; 0 or above 8 is a 400 |
quality |
no | in [0, 1]; default 1.0. The API accepts an explicit 0; the event-derived path (Section 4.4) never goes below 0.1; the value holds only until the first quality event on that merge_commit_sha (a CI run, flaky re-run or revert), which recomputes quality from 1.0 over the event floors and overwrites it; a green CI run resets a posted 0.3 to 1.0 |
additions, deletions, changed_files |
no | used for the size heuristic when weight is omitted; with none of them the heuristic gives 0.5 |
work_type |
no | one of the seven types, default feature; an invalid value is 400 |
repo |
no | canonical owner/repo so the outcome joins cost from the same repository; omitted, it is stored as unqualified (Section 5.1) |
Outside GitHub no CI or revert signal ever arrives, because both come only from GitHub's webhooks, so an API-posted outcome keeps the quality the caller sent.
A fresh insert returns 201 with {"status":"created","weight_source":...,"weight":...}. The bearer token is the single authenticator; any holder can post an outcome as any developer, so it is an org-level secret, and the api-outcome source stamp keeps such rows distinguishable in audit.
4.7 Backfill
tierd backfill --repo owner/name reconstructs outcomes from merged-PR history over the last 90 days by default (--since reaches further back). It requires a GitHub token with read access (TIER_GITHUB_TOKEN, preferably as @/path/to/file). It calls two GitHub REST endpoints (Section 9.4), derives the issue id, weight and work type with the same code as the webhook, starts each outcome at quality 1.0, and does not reconstruct degradation signals: a revert or CI failure that already happened in history is not applied. A PR already in the store is skipped, not updated. Run it before serve, using the current-month safeguard in Section 9.4 for a new installation; restarting with elapsed-month outcomes can freeze them before matching spend and census arrive. Both write one SQLite file, and SQLite lets one writer in at a time; a writer that waits more than 5 seconds for the other fails with a "database is busy" error. TIER takes no lock of its own to prevent this.
5. Attribution and windows: issue linkage, unattributed spend, the cost horizon, window matching
Shared effective attribution (#1306, #489)
Every live issue-cost reader uses the same token-event projection: event id, timestamp, developer, repository, integer micro-dollar cost, stored issue, effective issue and deciding layer. In precedence order:
- A stored assigned issue wins, even if it is wrong (
stored_assignment, U93 A). - A server-owned #1074 fill is already assigned on the row and wins over recovery
(
existing_fill;pr-number-at-insertorbranch-at-merge). - Still-unattributed events can use
read_time_recovery, enabled by default for retroactive live recomputation. The store'sWithReadTimeRecovery(ctx, false)switch disables this layer for a read. - The correction-overlay interface is reserved, has no implementation and is inactive. It cannot correct a stored assignment under U93 A.
Recovery reads only GitHub merged-PR outcomes (github-webhook or backfill). A
recorded event branch hash must equal the PR head hash on the same qualified
repository. The inclusive window is
[max(previous strictly earlier merge on that repository/branch hash, PR.created_at − 7 days), merged_at].
Equal-time PRs share the preceding bound. Exactly one claimant is required, even
when several claimants name the same issue. The PR's own outcome supplies the
issue. A recorded PR number constrains branch evidence; when it is the only
recorded evidence it requires a unique merged PR with a permitted head hash in
that same bounded window. Missing creation/merge times cannot supply recovery.
Backfill-sourced outcomes are covered without running a branch-window fill.
Default-branch sessions and revert heads are excluded: intake omits their usable
head hashes, and the main/foreign-repository sentinels are never recovered.
Opaque hashes cannot retrospectively establish an unrecorded branch name.
The accepted reused-branch/late-webhook residual of existing #1074 fills remains;
read-time recovery never overrides those fills.
Codex and Muse use recorded branch evidence when present. Opencode records none, so its unresolved events stay unresolved. Recovery never uses cwd as history, a deleted worktree's current state, or today's checkout branch. Events without recorded evidence (including historical rows predating #1074) remain unresolved. Missing logs and ambiguous matches may prevent historical recovery.
A read-time recovery changes what a read reports but stores nothing; it does not correct an already-assigned issue or change a closed monthly report. Already-assigned wrong issues remain wrong in stored data; replaying logs or enabling recovery does not correct them. Protected bands include each closed calendar month and its 14-day token-evidence lead-in. Raw event exports and input digests describe stored evidence. Already-exported artifacts and frozen monthly rows do not change. Pooled captured cost and outcome points are unchanged; issue buckets, attribution shares, bootstrap allocations, segments and eligibility evidence can change.
Live read responses carry X-TIER-Attribution: “scores are as of <ts> and may be
revised as branches merge”. Synthetic/offline raw /scores and issue detail mark
recovered support with attribution_layer: "read_time_recovery"; per-issue rows
also expose the recovered cost portion. Mixed rows mark that they include
recovery, not that all their spend was recovered. Group reports do not gain a
population-dependent recovery indicator or exact recovery share.
The read-only receipt uses the existing database without migration or writes:
tierd attribution recovery-receipt --since 2026-06-01 --until 2026-07-01 --repo acme/app --json --db /path/to/tier.db
--since is required, dates are UTC YYYY-MM-DD, --until is exclusive, and
omitting --repo covers the fleet. Omit --json for human output. The candidate
population is current unattributed events plus server-marked #1074 fills (the
marker proves their earlier unattribution; the original sentinel was not kept).
Other assigned events are a separate excluded/reference population. Each
candidate has one terminal disposition in order: protected,
permitted_existing_fill, unique_read_time_recovery, ambiguous,
unresolved. Event counts and integer micro-dollars must reconcile exactly or
the command fails. Recovered share uses candidate cost, including protected cost.
Unique evidence alone does not establish precision: production wrong-booking
counts are unmeasured, while synthetic fixture expectations test correctness.
The eleven overlapping reason tags are separate from terminal accounting:
already_assigned, unique_recorded_pr, unique_repo_branch_time,
missing_recorded_evidence, backfill_without_branch_fill,
protected_sealed_band, default_branch_without_worktree_evidence,
ambiguous_multi_claimant, no_matching_recorded_outcome,
claimant_without_issue, and sessions_jsonl_gone. Missing evidence does not establish an insertion date.
JSONL availability is not retained in the database, so the last tag is explicitly
unmeasured, rather than inferred from current files or reported as an observed
zero. The other tags count observations in the receipt snapshot and may overlap.
5.1 The shared key is the issue id
Cost and outcomes are joined by developer and by issue id, extracted by one package from the branch name or the PR body so all paths agree:
The strict per-surface grammar and examples are in conventions. Branches use only the first slot, with year/date guards and excluded release, bot and revert prefixes. PR bodies accept close directives, structured configured tracker keys, or explicit relation references; commit subjects accept close directives or configured keys. Bare PR-body #N and unconfigured tracker-shaped tokens are rejected. Jira-style keys (e.g. ABC-123) are recognised once their prefix is configured with --tracker-keys / attribution.tracker_keys; OP is built in. TIER_TRACKER_KEYS also sets the prefixes (CLI > env > config > empty). Comma-separated tracker keys trim surrounding whitespace and reject empty entries. Configured keys and OP preserve zero-padded positive numbers (ACME-006, OP-0042); bare hash references and numeric branch slots still reject leading zeros. PR-body half of #N is a relation reference (including qualifiers before half), never commit-subject evidence; behalf of #5 does not match. Balanced */_ emphasis wrappers immediately around reference tokens are accepted, while **closes** #42 remains rejected. A branch wins over the body. Startup and doctor suggest unconfigured stored prefixes without enabling them. The change is prospective; stored attribution is not rewritten.
Issue-id misfiling (v0.6.0; fixed in v0.6.1, #1297). The v0.6.0 extractor accepted tracker-shaped text such as SHA-256 or UTF-8 and the first eligible bare number segment in a branch (docs/notes-2026-09-26 → issue-26), so PRs and their spend can be filed under wrong IDs, including real issue numbers. A rerun at 2026-10-17T01:56:35Z found 30/336 merged PRs (8.9%) misfiled in TIER's own development repository, merged 2026-09-23T15:42:42Z through 2026-10-17T01:00:03Z (inclusive cut-off): one repository with this maintainer's branch naming, not a general error rate. Pull-request points and captured spend do not depend on the issue id, but a misfiled id can merge or split direct-push credit (one 0.5 outcome per repository, issue and UTC day) and change which outcome a footerless revert penalises, so headline TIER can move; per-issue rows can be wrong. Use fix/123-short-desc with no tracker-shaped token in the branch, or a closes #123 PR-body line when the branch yields no ID; the strict grammar above fixes this extraction behaviour prospectively. The measured figure remains a historical observation.
For Claude Code sessions the branch comes from the gitBranch field in the log. Every Claude Code path (tierd score, tierd ship and the live watcher) also reads the repository's git log. When the message was recorded on a feature branch (not main, master or a detached head), a branch-tip commit on a branch of the same name within ±30 minutes of the message supplies the issue id, and that commit's issue id wins over the one in the branch name; if several qualify, the first in git log order wins. Otherwise the branch name decides. The watcher reads the last 30 days of commits through a cache refreshed at most every 30 seconds. Attribution is fixed when a message is first stored: a commit that lands later does not re-attribute it, and a later tierd score, which re-reads everything, can differ. tierd ship sends events to a server that already holds the same keys, and the server keeps its first attribution.
Worktree attribution (#823; opt-in, off by default). 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. Claude Code writes on every line of a session file the folder the session started in (cwd) and that folder's branch (gitBranch). A session started in the main checkout that then works in a git worktree (a second checkout of the same repository, on its own branch, made with git worktree add) still says main on every line, so its spend is booked to unattributed:main; subagents inherit their parent's cwd and branch and are booked the same way. The switch --worktree-attribution on tierd serve (its live watcher), tierd ship and tierd score (environment variable TIER_WORKTREE_ATTRIBUTION; on serve also the config key watch.worktree_attribution) attributes each message instead by the worktree its tool calls named, with the branch that worktree had checked out at that moment, read from git's own files; it never runs git for this. With it on, each Claude Code event records the rule that chose its issue, branch, worktree-cwd, worktree-toolpath or carry, exported as attribution_rule by GET /api/v1/events. The server can instead assign pr-number-at-insert (#1074) at insert; this rule is never client-sent. Rows stored with the switch off and no server resolution export as legacy. Spend in a worktree of a repository that is not configured goes to unattributed:foreign-repo (Section 5.3). It was released in v0.5.2 as a preview, off by default, because its measured accuracy fell short of the project's release gates; the measurement and its limits are in docs/how-it-works.md, section "Measured accuracy: read this before turning it on", which also gives the rules in order. Two consequences to weigh before turning it on: there is no supported correction of an already assigned issue, so turning the switch off later moves no wrongly assigned row back. TIER v0.6.0 can fill previously unlinked rows by recorded PR or branch evidence, without overwriting an assigned issue; and a tierd server older than v0.5.2 rejects with 400 every batch a ship with the switch on sends, so upgrade the server first. tierd score --repo <path> --worktree-attribution previews the effect and stores nothing: it scans the same session files with the switch off and on and prints what would change, with separate tool-path and carry counts and USD costs.
Because a GitHub issue number is unique within one repository, each row also stores a canonical owner/repo (from the webhook's repository.full_name, from remote.origin.url for local capture, or from the X-Tier-Repo header on the proxy). Rows whose repository cannot be determined store the sentinel unqualified, which joins tolerantly: an unqualified row matches the same issue id in any repository. So issue-42 from two repositories can be joined to each other when one side is unqualified; naming the repository (repo on the API, X-Tier-Repo on the proxy) avoids that. tierd repair-repo moves one developer's cost rows off unqualified using a session-to-repository map the administrator supplies (dry run unless --commit). A contributor on a fork must name the upstream so their cost joins its outcomes: tierd score --repo-slug owner/upstream; tierd ship --repo <path> --repo-slug <path>=owner/upstream (repeatable, one per --repo); and for serve --watch-repo, the config map watch.repo_slugs (there is no serve flag).
Spend links to outcomes at developer/repository/issue grain, not at individual-PR grain. The documented unqualified repository exception can associate the same issue id across repositories; it does not establish a PR's expenditure.
5.2 Two identities that must be mapped
Cost is attributed to the shipping identity (the OS username, or --developer <id>); outcomes are attributed to the PR author's GitHub login. When these differ, POST /api/v1/developer_alias {"alias":"alice-laptop","canonical":"alice"} joins them. There is one alias per request and no bulk endpoint, so a team's aliases are posted by a script. Aliases are applied when a score is read, so an alias covers rows stored before it was created, and deleting it splits them again. Push-captured outcomes use the commit author's GitHub login and go through the same map. Without an alias, cost and outcomes land in separate rows: the cost row has no points and the outcome row has no cost, so each reads TIER 0 unless it has other matched activity. /scores reports the split as data_quality.unjoined_developers.
An alias joins identities; it does not verify the issue or repository match. Collaborators' captured spend remains under their own identities, even when another person merges the work.
5.3 Unattributed spend
Spend that resolves to no issue stays in the denominator under a labelled bucket:
unattributed:main: spend recorded onmainormaster. Its share of window cost is reported asexploratory_cost_share. The field has been computed this way since it was introduced; its name comes from an older description and does not mean the work was exploratory, overhead or never merged.unattributed:branch-without-issue: a named branch with no recognisable issue reference.unattributed:detached-head: no branch recorded, which is where Opencode spend lands, and Muse Code spend whose run recorded no branch.- bare
unattributed: producers that cannot see a branch, such as the org pollers and a proxy call without an issue header. unattributed:foreign-repo: with worktree attribution on (Section 5.1), Claude Code spend in a worktree of a repository that is not configured; its repository is stored asunqualified, so the other repository is never named.
data_quality.attributed_cost_share is the fraction of window cost that joined an issue; it and the buckets sum to 1.0. A large unattributed share means TIER could not see which issue the money went to, usually because branch names carried no issue number. It does not mean the money was wasted.
When the headline reads low. Linking spend to an issue does not move the pooled figure or a developer's figure by itself, because both divide all points by all cost. Among issue-linking gaps, the figure reads low in exactly one case: accepted work that recorded no outcome because its PR named no issue in the branch or the body (Section 4.1). (A separate, identity gap splits one person's cost and outcomes across two unjoined rows; Section 5.2.) Its cost is in the denominator with no points. That case and a low attribution share often share a cause, branch names without issue numbers: a PR from such a branch records an outcome only if its body names the issue (closes #N and the other forms in Section 5.1). So a low share is a reason to check, not a verdict. Backfill's closing line reports how many merged PRs recorded nothing (unattributed=N beside inserted=N). Spend recorded without an issue while its PR did record an outcome (for example sessions logged on main while the PR, on its own branch, named the issue; or Opencode) does not bias the figure. It loses the pairing instead: the segments leave that cost out, and the outcome may be flagged zero-token, which takes its row below the evidence floor (Section 7.1).
Run tierd doctor --repo <path> to check local issue attribution. Its issue-attribution check divides issue-attributed dollars by feature-branch dollars (attributed plus unattributed:branch-without-issue). Below the --min-attribution floor (default 0.5) the check fails with a non-zero exit; from 0.5 to below 0.9 it warns; 0.9 and above is healthy.
Spend on main or master, detached-head spend and bare-unattributed spend are left out of that ratio. A separate, informational check named spend not on a feature branch reports them. So the doctor's ratio is not the dashboard's attributed_cost_share, which divides by all window cost.
In synthetic/offline raw reports, the API distinguishes the following cases:
- Unattributed spend — cost the collector could not tie to any issue (no branch reference, mainline work, detached head). It has no issue id.
- No-outcome spend (
no_outcome_cost_usdin synthetic/offline rawsegment_reconciliation) — cost that did resolve to an issue id, but for which that developer has no recorded outcome on that repository and issue inside the window: abandoned work, work still in flight, a PR that never merged, or an issue a colleague merged. When two people work one issue and one merges it, the other's spend is always no-outcome spend; TIER has no notion of pairing or shared credit. It counts as a cost with nothing to show for it yet. - A merged PR with no issue reference — the outcome side of the same gap: the merge happened, but it records no outcome at all (Section 4.1). Its cost, if the branch had no reference either, is in the unattributed bucket.
In synthetic/offline raw reports, segment_reconciliation accounts for the window with the identity outcome_linked + no_outcome + unattributed == window_cost, exact on the integer micro-dollar fields. The per-work-type segments divide by outcome-linked cost alone, so when there is any no-outcome or unattributed cost they can read higher than the pooled figure (an individual segment can still read lower, because its outcome mix differs). Section 2.1 says which figure a decision uses.
When is one pull request's cost known? TIER reports captured issue-associated spend, not observed expenditure for an individual pull request. Even when exactly one outcome matches a developer/repository/issue in the window, that issue total can include other work and exclude collaborators' spend. A sole-outcome match does not establish complete capture or correct attribution. The conditions below are limitations on any such match, not proof of a PR's bill: its sessions ran on a branch naming its issue, or a #1074 fill credited it; it is the only outcome that developer has on that (repository, issue) in the window; cost and outcome identities are aliased; repositories match; all its spend fell inside the window; and every tool used was captured. TIER cannot check the first or the last. A single matched PR does not establish complete capture or correct attribution.
Captured-dollar diagnostics
In synthetic/offline raw reports, /scores adds data_quality.join_readiness, computed from the same read snapshot and canonical identities as the scores. It lists repositories with outcomes but no matching cost (outcome count and weighted points), repositories with captured cost but no matching outcomes, the share of captured dollars without a qualified repository, incompatible real repositories for the same developer and issue, repo-blind joins with several plausible repositories, and issue-linked cost with no matching same-developer outcome. Repository presence compares only qualified real repository ids, exactly; an unqualified row cannot fill a repository gap. Issue joins use the existing tolerant RepoMatch rule; a repo-blind row can still join while its repository remains uncertain. The unqualified share divides repository-unqualified captured micro-dollars by all captured micro-dollars in the window; it is zero under strict ?repo= and in zero-cost windows. The three per-(developer, issue) lists are sorted and capped at 50 rows, with uncapped total counts alongside them. These are coverage and evidence diagnostics, not proof of missing real expenditure. They create no aliases and change no score. Historical branch-evidence recovery, including absent versus ambiguous evidence, belongs to the separate #489 recovery receipt.
On real servers, tierd doctor --server <url> --api-token @<root-key-file> reads frozen monthly identity, outcome-join and horizon disclosures through /api/v1/report_manifest?audience=diagnostic. It cannot diagnose present-day captured-dollar worktree patterns: the real route policy denies GET /api/v1/diagnostics/captured-dollars, and doctor omits that check on HTTP 403.
The synthetic/offline captured-dollar diagnostic uses stored checkpoint cwd and session evidence plus read-only current git worktree metadata. It reports shares of captured dollars, never complete real spending. At least 50% in unattributed:main with a verified main checkout and linked worktrees triggers the advice to start sessions in the worktree. Present-day metadata cannot prove historical work locations; missing metadata can prevent detection. This receipt is excluded from raw score manifests because filesystem evidence can change independently of scored inputs.
5.4 Two clocks, and why recent windows read low
The free-window arithmetic below describes the synthetic demo and archived raw reports. Real monthly /scores reads select closed calendar months with period=YYYY-MM; since and until return HTTP 400 (Section 6.2). Named branch reads can combine live open-month inputs with frozen closed months under issued-key scope. The denominator is windowed on the cost event's timestamp, which lands continuously as tokens are spent. The numerator is windowed on the outcome's timestamp, which lands at merge time (the webhook uses merged_at, falling back to receipt time only when it is missing, malformed, zero or future; the API and backfill use merged_at; push capture uses the commit timestamp (server time if the commit timestamp is missing or zero)). The two halves are read independently over the same [since, until) and then joined by identity, not by time. So:
- Trailing edge (near
until): cost has landed for work that has not merged yet. The ratio reads low. The default/scoreswindow opens at the start of the UTC day 90 days ago with an open upper bound, so the newest weeks of a live deployment are always in this zone. - Leading edge (near
since): an outcome merges inside the window but its cost was spent beforesince. The ratio reads high.
Spend and points use different clocks, so boundary-crossing work can move a ratio either way even across two complete months. TIER has no established complete-capture or per-PR lead-time measure; a long window alone does not establish comparable observations. While a free window's upper bound is still ahead of the clock (the synthetic demo's default /scores window is open-ended), its number keeps moving as work merges and new cost arrives; it does not settle. Once until has passed, the window is closed to new PR merges, but a delayed webhook delivery can still add a previously unrecorded outcome whose merged_at falls inside it. A past window can also move: a backfill re-run or a POST /api/v1/outcomes (both stamp merged_at) and push capture (commit timestamp, or server time if the commit timestamp is missing or zero) can add outcomes; a revert up to 60 days after merge lowers an existing outcome's quality; and a late ship adds cost stamped at the original session time. The code defines no "settled" state and reports none. By the rules above, a window has mostly stopped moving when until is in the past, 60 days have passed since until, and no revert committed within 60 days of an outcome in the window is still waiting to be pushed (a revert is timed by its commit timestamp, so a late push can still lower a quality; Section 4.4), every laptop has run ship since until, and no backfill is pending. Administrative actions can still change it afterwards: an API post, a /costs correction, a reprice, an alias or hierarchy edit, or an erasure. To fix a figure for later comparison, record its manifest and check it with tierd verify-report (Section 9.6). Compare two such periods, never a mature quarter against the current open one.
5.5 The cost horizon
Every installation has a cost horizon: the timestamp of the earliest cost event in its store (SELECT ts FROM token_events ORDER BY ts LIMIT 1, scoped to the repository when ?repo= is set). Outcomes backfill freely from tracker history; cost exists only from that earliest event forward.
The horizon is one timestamp for the whole installation, across every developer and every capture path (or one repository with ?repo=). A developer or a tool whose capture started later is not reflected in it; source_coverage_start gives each capture path's own earliest event when there is more than one.
Loading history moves the horizon back. tierd ship writes events stamped with the session's original timestamps, so after a run the horizon moves back to the oldest session still on disk, at most 90 days by default (further with --since). Coding tools delete old session logs on their own schedule; the project's reading is that Claude Code keeps about 30 days by default, so for a Claude Code user a first ship usually reaches back about 30 days, not 90. On serve, the Codex, Opencode and Muse Code collectors' first pass loads every log on disk, and the Anthropic Admin and OpenAI Usage pollers write remainder events back to the first of the previous month. (tierd score only prints a report; it writes nothing and does not move the horizon.) A fresh install with nothing loaded has no horizon at all. One that has only tailed Claude Code live for a week has a horizon about a week old, or older if an older session file the watcher had not tracked before was appended to, because a file's first tracked write re-reads it whole (tracked files resume from their checkpointed offset). Before the first developer retention prune, extracted events outlive the logs they came from, so only an erasure (DELETE /api/v1/developer/{id}) that removes the earliest rows moves the horizon forward; it is a property of what was captured. The first developer retention prune persists the horizon, including repo/source horizons, so deleting old cost rows does not move the coverage clamp forward.
A window reaching back past the horizon divides outcomes by cost that was never captured, and the number reads high. On one multi-repo installation, measured on 2026-07-26, the API's default 90-day window read about twice what the same installation reported for the last 30 days, roughly the period its cost capture covered. That is a single measurement, reported here as an illustration of size, not a rule.
A first trial is the common case. tierd backfill loads 90 days of merged PRs by default, while the session logs on disk may cover about 30. The older outcomes then have no recorded tokens against their issues, so they are flagged zero-token and take their developers below the evidence floor (Section 7.1), and a 90-day window reads high. For a monthly read, choose a closed month beginning at or after disclosures.horizon.cost_coverage_safe_since to clear this leading bound; it cannot show a developer or a tool whose capture started later, which source_coverage_start and the zero-token flags hint at.
GET /api/v1/scores?period=YYYY-MM reports the frozen horizon in disclosures.horizon: cost_coverage_start (the horizon timestamp), window_predates_cost_capture (an explicit true or false when checked), cost_coverage_safe_since (the horizon's UTC date, rounded to the next day when it is not exactly midnight), and source_coverage_start per capture path. If the flag is true, choose a later closed month whose first day is on or after cost_coverage_safe_since. Unknown, unrecorded or no_cost_observations status does not establish complete capture. Monthly reads reject since and until with HTTP 400; later shipping cannot revise a close's horizon, costs or census. The synthetic demo retains rolling-window data_quality fields and From/To controls. Legacy seal storage is removed (Section 6.2).
5.6 Window matching in practice
tierd backfill and tierd ship both default to the last 90 days, but retained session logs may cover less. The new-install walkthrough in Section 9.4 bounds both to the current UTC month so startup cannot close older outcomes before their matching spend and roster arrive. Monthly /scores reads use a closed period=YYYY-MM, not a rolling window. Read disclosures.horizon and choose a closed month beginning at or after cost_coverage_safe_since; importing older cost later cannot repair a frozen report. A month that predates capture divides outcomes by cost that was never captured and can read high.
Historical self-measurement
Historical self-measurement; original inputs and run settings are not publicly reproducible. The maintainer reported 221 points per $1,000 across 152 merged PRs, trailing 30 days to 2026-07-21, with 72.1% of spend unlinked. It was computed from backfilled history; no quality events were recorded for it, so it is size-weighted only. It is not independent validation of the metric or rubric, and its relationship to a preserved snapshot has not been established. The original price identity and settings are unavailable publicly, so it does not establish comparability with v0.5.2.
The maintainer has exercised TIER's CI and revert rules in manual test runs; the project's automated webhook tests exercise the same rules. The inspected historical 221 figure has no recorded quality-event receipt.
Claude costs are derived from deduplicated Claude Code usage logs. Their completeness against independent provider accounting remains unvalidated, including for the subscription routes used here. Missing usage would understate those costs. Codex costs use logged cumulative counters; those counters do not independently establish complete capture. If the logs undercount, costs are understated, TIER scores read high, and Claude looks relatively cheaper in cross-vendor comparisons.
We run TIER on TIER's own development every day, and we use what it shows us to decide how we work with AI tools and where the money goes. This is the maintainer's reported historical reading from TIER's own development repository. It illustrates the dashboard; it is not independent validation of the metric or its rubric. Read it as size-weighted history, not demonstrated quality-adjusted output.
The 30 days through 21 July 2026; published 22 July UTC. Historical 221: computed from backfilled history; no quality events were recorded for it, so it is size-weighted only. Original inputs and run settings are not publicly reproducible. This is an observational ratio, not a causal estimate or independent validation. Claude costs came from deduplicated Claude Code usage logs; completeness against independent provider accounting remains unvalidated, including for the subscription routes used here. If those logs undercount, costs are understated and TIER scores read high. If Claude's logs undercount more than Codex's, Claude looks relatively cheaper in cross-vendor comparisons.
- 221 Historical size-weighted points per $1,000 of captured spend
- 152 merged PRs, worth 723 weighted points
- $4.52 cost per outcome point
The published rounded totals decompose to about 4.76 points per recorded PR and 46.5 recorded PRs per $1,000. The underlying historical inputs remain unverified. This predates v0.5.2; the private inputs, exact totals and run settings were not preserved in a public dataset, so this reference cannot independently establish them.
72.1% of recorded spend lacked an issue match; 28% matched is rounded. All captured spend counts here. Unattributed usage can be useful; the gap measures linkage, not waste. Starting a session outside the worktree is one possible cause, but this run does not establish its cause or the amount of uncaptured usage.
5.7 Developer-mode per-issue report (#1273)
v0.7.0 availability: The separate per-issue report remains unavailable on real data; real developer-mode named reads return the coaching card instead. The following descriptive report is available only in the synthetic tierd demo, which creates its own invented data in a private temporary database and cannot load yours. Team/division requests return 403.
The following synthetic/offline raw contract reads cost, points and TIER per
(repo, issue_id):
tierd issues --help # real servers deny the raw issue endpoint
Rows whose effective issue includes recovered spend carry
attribution_layer: "read_time_recovery" and read_time_recovery_cost_usd.
The report and CLI disclose “scores are as of <ts> and may be revised as branches
merge”; as_of and attribution_notice accompany recovered report rows.
Stored assigned issues and frozen monthly outputs stay unchanged.
This is a server-backed command, distinct from the local session-cost tierd score. Synthetic/offline raw reports can show per-issue reconciliation.
Real servers deny GET /api/v1/issues;
the raw contract below applies only to synthetic/offline reports.
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.
The command labels issue figures attributed (lower bound) and repeats the
window's unattributed share beside each row. Its disjoint reconciliation rows
(issues plus unattributed reasons) sum to total captured spend. Spend-only issues
remain visible with their cost; an outcome-only issue has zero measured cost and
cannot pass the floor. Missing historical issue IDs are unattributed, and stored
unqualified repositories remain separate. No tracker parents or features are
inferred. The reported total covers captured list-price costs, including manual
rows, rather than claiming complete capture or matching an invoice.
The report reuses /scores' window loader, scoring engine, zero-token checks and
stored Event 3 policy in one database snapshot, and stamps the rubric version
(legacy 1 or Event 3 2) and correction rule. Each outcome counts once. The issue's
points and all its cost are summed across developers before dividing by cost /
1000, including cost from developers who produced no accepted outcome. Event 3
debits change the corrected original's issue; a follow-up keeps its own projected
points on its own issue. No correction debit is reassigned to the repair issue.
The floor judges the summed issue: at least 3 eligible outcomes and $5, and no zero-token flags, or no numeric TIER. Zero eligible accepted outcomes says no score; other insufficient evidence says below evidence floor. Rows stay in identifier order and are never sorted by TIER. An issue's partial cost denominator can yield a larger TIER than the developer's all-spend score; do not compare them as equivalent figures.
Bounds accept YYYY-MM-DD, YYYY-MM or YYYY in UTC, with an exclusive until;
omitting until keeps the window open-ended. Omitting since starts at UTC
midnight 90 days ago. --json retains full precision and raw/eligible outcome
counts; printed reconciliation costs retain six decimal places (micro-dollars).
See the API contract for
fields, refusal semantics and strict scope disclosure.
6. Aggregation and privacy: modes, k-anonymity, suppression, what it must never be used for
6.1 Holder-scoped reporting
Server reporting modes are retired. serve takes no aggregation setting;
--aggregation, nonempty TIER_AGGREGATION, and the YAML aggregation key
are rejected. Root keys read everyone, node keys read their dated branch, and
personal keys read their holder. Organization, Team, Person and Models views
remain available, including group-to-person expansion. Name-free monthly
comparisons retain the k floor (default 5, minimum 3), complementary
suppression. Archived raw-manifest verification retains its original pinned
arithmetic; that pin cannot select a server access policy.
6.2 k-anonymity, suppression and closed monthly reports
k-anonymity gates name-free monthly group publication. The configurable floor is --k-anonymity (env TIER_K_ANONYMITY, config k_anonymity), default 5, hard minimum 3. A counted person needs positive eligible captured cost and a qualifying merged outcome on the dated path, with counted-identity evidence, roster membership and bot exclusions. A merged PR alone, manual cost, paid-only activity or an alias alone never supplies census membership. See Who counts toward k. Named holder-scoped views do not apply k.
Monthly publication depends on the frozen census, the reading server's k floor (at least the close's k), two members and complementary suppression. Paid and realtime amounts stay outside that decision; paid outside activity nodes belongs on the name-free company line. Score headlines additionally require the frozen ranked verdict. A group can remain withheld despite reaching k when publishing it would permit subtraction of a smaller group.
Published panels. Monthly manifests can publish work categories, corrective work, quality, cache levers and financial panels under their census and complement rules. Paid, realtime and work amounts do not decide which group rows are shown. Work categories use a frozen safe Other work union. See Monthly publication residuals (#1508) for the accepted subtraction limits.
Group reports do not disclose exact cost-attribution or quality-evidence shares. Capture-fidelity and cost-horizon warnings are different signals; they do not establish complete capture or observed quality coverage. 'Not shown' is neither a clean bill of health nor evidence that coverage is low. Ask the operator which diagnostics were run, what they found, and which capture or attribution gaps remain.
Suppression. TIER applies the frozen counted-person census, k and complementary folds of at least two members across the full tree before selecting published groups. A group can remain withheld after reaching k if its publication would expose a smaller complement. Withholding is per the tree decision; it does not promise that any small residual withholds the entire response. Consumers must preserve withheld status and must not reconstruct hidden rows. See Monthly publication residuals (#1508).
A writable tierd serve closes due UTC months at startup and checks again every
minute. A month becomes due 24 hours after its end. Reports, CSV, trends,
comparisons and month pickers read frozen month_person rows; Team and group
views expand to the people allowed by the reader's key. Live branch views remain
available before close. Late raw input does not rewrite a closed month.
Read-only serving starts no close worker. Source coverage and known capture
loss are frozen as evidence and disclosures; they do not require arming or skips.
The seal command, preview, status, --seal-from, --report-grace, seal_from
and report_grace settings have been removed. Nonempty TIER_SEAL_FROM and
TIER_REPORT_GRACE settings fail startup explicitly.
6.3 What the numbers are for, and their limits
TIER is built for coaching, not for evaluating developers. Per-person figures are not accurate enough to judge an individual. TIER can set captured spending beside weighted merged work and support conversations about model use, cache reuse and issue links. It does not establish which model caused an outcome or enforce an organisation's policy. The positioning remains grade the work, not the worker, by policy rather than by mechanism.
Two reasons support the coaching stance. The first is the project's reading of the law, stated here so a reader can check it with counsel; it is not legal advice, and the project's longer notes are in docs/legal-and-privacy.md. In Germany, per-developer measurement is subject to works-council co-determination (§87 of the Works Constitution Act, Betriebsverfassungsgesetz, BetrVG). In France it requires consultation of the works council (comité social et économique, CSE); in the Netherlands, the works council's consent (Article 27 of the Works Councils Act, Wet op de ondernemingsraden, WOR). The project reads most of the EU/EEA as similar. Using the output alone to drive a human-resources (HR) decision would likely engage Article 22 of the General Data Protection Regulation (GDPR), on decisions based solely on automated processing, and the project expects a data protection impact assessment (DPIA) for any per-developer deployment. For measured employees in the EU/EEA the project recommends team-only aggregation plus advance consultation. The second reason is method: a per-developer figure moves with task mix, seniority, pairing and review load, and a number tied to a person's evaluation invites gaming (Section 11).
Current access and retention: Real servers always require an issued key. Root reads everyone, node keys read their dated branch and personal keys read their holder. Named cards audit before construction and need no declaration or positive verified spend. Shared/static credentials and server reporting modes are retired. Team, division and company reports expand to their people; name-free monthly comparisons retain the k floor. Writable servers run raw retention at startup and daily (90 days by default, 60–365 days); pruning is permanent. Read-only servers and the synthetic demo do not initiate pruning. See identity access for current setup and trust limits.
Immutable spend provenance governs named reads. Named bars on a shared scale are removed on real data, and every named administrator read/export is audited.
Comparisons that are unsupported. Three, by construction:
- Raw TIER across organisations. List prices put both organisations' cost on one price basis, but the numbers still do not compare. Size labelling differs (a generous labeller scores the same work higher), task mix differs, and capture coverage differs (an uncaptured tool reads as free).
- Any two figures that fail the comparison rule of Section 2.1 (different frozen price identities, policy/rubric or release, or unknown pricing provenance).
- One work type against another (Section 4.3).
6.4 What is stored, and what is never read
TIER's log parsers decode only an allowlist of metadata and usage fields, so prompt and completion text never reach a stored row from those paths. Two paths differ. The reverse proxy forwards complete request and response bodies to and from the provider (it records only usage). The webhook stores each raw GitHub delivery body, which can contain arbitrary PR and commit text, pasted code included.
Everything is stored in one local SQLite file. TIER sends no telemetry to the project or to anyone else. Beyond the tierd server the administrator names (which ship, doctor --server, hierarchy import, issues --server and healthcheck contact), it makes outbound calls when used that way: the proxy (mounted by default on writable servers) forwards a client's own calls to the provider; backfill, outcomes, quality and score --outcomes read GitHub; configured Anthropic, OpenAI, Copilot and Cursor pollers read their vendor APIs; and doctor --config probes each configured provider. Configured federation pushes v2 frozen monthly people/model rows, paid cells and frozen evidence to the host-pinned central server. Relay preserves each owning origin's contribution; central applies the reader's scope, names setting and census-only publication decision. A root import preview fetches a consented bundle from the source server over HTTPS with a one-time key. These connections are detailed in Where it goes. The file holds developer identifiers, issue ids, repository slugs, model names, token counts, list cost in micro-dollars, price versions, an opaque session UUID, PR metadata (number, author login, weight, quality, merge SHA), quality events and finance ledgers. Schema 30 removes the legacy seal tables. Closed monthly reports instead keep frozen person/model rows, captured contribution evidence and anonymous group census counts; erasure and retention pool removed figures into nameless remainders. Prefold person-key sets and seal-body assembly are removed; archived raw-report verification retains its original per-measure census. Two conditional stores hold personal data an auditor should note: the watcher's checkpoints of how far it has read each session file (live --watch-repo mode; they hold the working-directory path, branch and session id, and with worktree attribution on, the path of a worktree) and raw GitHub webhook bodies (webhook path enabled; commit author names and emails, PR titles and commit messages).
Developer retention (#1371). Schema version 14 follows the identity batch’s schema 13. The transactional engine and startup/daily
scheduler run in real writable servers on writable servers.
Pruning advances a forward-only watermark;
the synthetic demo does not initiate age-pruning. The
--developer-retention-days flag and config developer_retention_days accept
60–365 days, default 90; CLI wins. The minimum comes from
store.RevertObservationWindow, the longest finite quality-change window.
Real servers require issued holder keys.
The prune uses activity time: event timestamps, outcome merge timestamps,
quality rows' outcome age, invoice-period end, and closed membership end. Open
memberships and active grouping stay. One transaction preserves watcher
session tombstones, cost horizons, name-free already-priced costs and allocated
points with their policy digest, freezes connected correction chains, scrubs
old endpoints, removes aged identities and advances the forward-only watermark.
Sub-k labels fold into other. Old fleet-wide org score totals read the roll-up;
individual and repo history cannot be reconstructed. A rollback leaves the old
state. A committed prune is followed by checked WAL truncation with a bounded retry deadline.
If truncation stays busy, a warning recommends tierd backup; the prune remains committed.
Widening works forward only and never restores pruned data. Before the first
prune, backfill works; afterwards all historical named writers refuse data
before the permanent watermark in any mode. /events returns
rejected_before_watermark; the shipper treats those rows as final for the current run and prints
the count; a later run re-sends them while they remain inside --since. Startup and /report_manifest report cumulative counts without
names. Event 3 confirmations remain unbounded on unpruned chains; on a frozen
chain a later review is recorded with no point changes (ErrCorrectionFrozen).
Watcher checkpoint paths are HMAC-keyed with the install secret, atomically migrated with offsets intact. A name-free checkpoint kind preserves parent vs subagent provenance after the path is hashed. Last-activity timestamps also prevent a copied old file from restoring named checkpoint metadata. Raw webhook retention is capped by the developer window and its existing 90-day/50,000-row limits. Monthly retention removes named contributions and their evidence into a nameless remainder while conserving group/model totals. Schema 30 removes the obsolete seal stores and their canonical-history watermarks; monthly reports use the independent lifecycle described above. Before upgrading the developer-mode deployment, back up its database and set its window to 365 days. The privacy retention section explains the backup and retention settings.
Erasure and export. DELETE /api/v1/developer/{id} erases, in one transaction, the developer's (and their aliases') rows in cost events, outcomes, per-developer paid spend, hierarchy and dated team membership, seat membership, quality events, quality history, the repository-repair log and the push-capture commit ledger and audit, then their alias entries. Frozen monthly rows are pooled into a nameless remainder in the same transaction. It erases the watcher's tail-state for the person's sessions with a tombstone the live watcher obeys, deleting the row instead when the session file is gone (#919); docs/privacy.md lists its limits, among them that a session that stored no events cannot be found. It does not touch raw webhook bodies, organisation-level paid spend, or the reprice and correction audit logs, and ordinary ingestion through a later ship or backfill can store the same rows again. Company imports differ: when erasure finds an import trace (including a colliding source mapping it removes), TIER keeps unlinked keyed records of the relevant identifiers so later imports leave matching people behind without naming them. TIER keeps this record so a completed erasure stays effective. Your organisation decides the legal basis with its counsel. It is not anonymous: a database holder can test a guessed identifier. It matches identifiers across sources, including a different person with the same identifier; renaming evades it. Entries remain until root clears them or the database is deleted. Root can clear one identifier with a reason of new consent or a different person with that identifier, or clear all because counsel refused the record; each operation is audited without identifiers or hashes and reveals no entry membership. See the root clear commands and privacy limits. Restoring an older backup loses newer records and undoes those erasures: re-run erasures after a restore. No endpoint removes a person's raw webhook bodies; they go with the 90-day cut, and the repository documents no other procedure. Backups taken with tierd backup are separate files that erasure does not reach. A closed month conserves group/model totals while erasure removes the person's named contribution evidence. Captured alias links select the subject's historical monthly rows without erasing another owner's reused identifier. GET /api/v1/developer/{id}/export produces the access-request file. TIER is single-tenant: developer identifiers are global, there is no tenant_id, two organisations' alice rows would collide, and it must not run as a shared multi-organisation service.
7. Reading the number: the evidence floor, unvalidated bounds and descriptive comparisons
7.1 The evidence floor (ranked)
A row clears the evidence floor when all three hold: at least 3 outcomes in the window; at least $5.00 of list-price cost (all of the row's cost, including spend not linked to an issue); and zero flagged outcomes. The wire field for this is ranked: true, a name kept for compatibility; it marks the floor and nothing else. It is not a position in any list, and nothing in TIER sorts by it or by TIER. The floor never changes a number; it changes what the number may claim. Every row below the floor in synthetic/offline raw /api/v1/scores comes back with its tier, weighted_points and total_cost_usd exactly as computed and ranked: false. A developer row that clears the evidence floor and the separate 50% row attributed-cost-share floor gets unvalidated descriptive bootstrap bounds (Section 7.2); one below either has ci_low = ci_high = 0, the wire spelling of "no interval". Team and division rows never carry an interval; for them the floor decides only what the dashboard draws and whether the headline shows a number. The floor exists because one 0.5-weight PR against $0.0004 of cost yields a TIER around 1,250,000, a figure that says nothing about the work. The thresholds are fixed in the code; no flag changes them. They are the project's judgement; it publishes no study showing they are sufficient, or how often they flag work that did use captured AI. AI work done more than 14 days before an issue's latest merge does not count toward the zero-token check, so long-running work can be flagged.
The evidence floor can reject some suspicious readings, but passing it does not verify the inputs or establish statistical adequacy.
A team or division row applies the same three conditions to its summed inputs. Five developers with one outcome and $2 each are each below the floor; their team, with 5 outcomes and $10, clears it, and at the default k of 5 it is also named (Section 6.2).
Order. The API returns developer rows in identifier order and team rows in name order (the other row last). The dashboard keeps that order: "Rows are listed alphabetically, not by TIER." A developer row below the floor shows its TIER muted, tagged "below evidence floor", with no bar drawn. In the dashboard's per-work-type panels the floor is tested against that segment's own cost and outcomes.
The dashboard headline is the rollup over every row (the total block). It shows a word instead of a number in four cases and keeps the measured inputs on screen in each: "NO SCORE" when the window has no weighted points (nothing accepted is the absence of a score, not a score of zero); "No recorded AI cost" when it has points at zero recorded cost (often a capture gap rather than free work); and, when the rollup is below the floor, "NOT ENOUGH SPEND TO SCORE" under $5.00 or "NOT ENOUGH EVIDENCE TO SCORE" otherwise. "NO SCORE" and "No recorded AI cost" also appear on individual rows.
Separately from the floor, the dashboard qualifies the headline by attribution coverage: when data_quality.attributed_cost_share is below 0.5, the value is dimmed and labelled "provisional — N% attribution coverage", and the attribution banner turns to a warning. It says most of the cost joined no issue; Section 5.3 says what that does and does not imply. Team and division reports do not publish attributed_cost_share (Section 6.2), so their headline is labelled "provisional — coverage not shown", without the dimming. A row can clear the floor and be provisional, or the reverse.
The zero-token check is the third condition. An outcome is flagged when its developer recorded fewer than 1,000 tokens (input, output and every cache class summed) on its (repository, issue) in the attributable window: the 14 days up to and including the timestamp of the latest outcome on that (repository, issue) in the scored window, by any developer. That window may start before since; the check reads those tokens even though they are outside the scored window. Several outcomes on one issue (for example daily push-capture outcomes) share one window. The flagged outcome keeps its full points; the developer's row falls below the floor. The check sees only recorded tokens, so it cannot tell its causes apart: AI work TIER never captured (off-books usage, G-02 in Section 11), broken identity mapping, spend that cannot join an issue (Opencode, Section 3.1), session logs that are gone (Section 5.5), or a PR written without AI. The project accepts flagging the last case, because missing uncaptured AI use would be worse. In a large group, where almost any window holds one such outcome, this keeps the group row and headline below the floor; the project has no remedy for that beyond linking branches to issues and capturing every tool. The flag propagates: a team or division row is below the floor unless its members' flagged count is zero, so one flagged outcome from any member takes the whole group row below the floor, and the headline with it. Monthly name-free quality disclosures publish only under the group decision; named cards follow holder scope.
Synthetic/offline raw data_quality.partition_diagnostics (#1233, v0.6.1)
reports the outcomes-per-(repository, issue) distribution, weight-bucket counts
and effective points, and the share of PR outcomes at weight 8 by count and
points. Quality-discounted weight-8 PRs remain in that cap bucket. It shows
points per PR outcome beside points per $1,000 of captured spend. The former
excludes push outcomes by stored provenance and retains legacy PRs without a number; the latter uses all scored outcomes and cost. Empty
share denominators are null. PR partitioning can change points even when
work and captured spend stay fixed, on the same issue or across issues. These
diagnostics impose no automatic penalties; existing opt-in Event 3 rules are
unchanged. Synthetic/offline anonymised raw reports return partition_diagnostics: "not_shown".
7.2 Unvalidated descriptive bounds
The bootstrap fields below describe synthetic/offline raw reports. Real monthly comparisons use frozen manifests and do not establish statistical inference.
With the v0.6.1 interval rule (#1233), for developer rows that clear the evidence floor and whose own attributed-cost share is at least 50%, /scores returns ci_low and ci_high, unvalidated nominal 95% descriptive bounds from a percentile bootstrap over the developer's own outcomes. Each of 1,000 replicates (a fixed number, not configurable) draws the developer's outcomes with replacement. Each draw carries its weight × quality and its share of cost: the developer's own spend on that (repository, issue), split evenly across the developer's outcomes on it. The developer's cost that joins none of their outcomes (spend not linked to an issue, and spend on issues where they have no outcome) is added back as a fixed term. TIER is recomputed per replicate, and a replicate whose cost comes to zero or less is dropped. The bounds are the 2.5th and 97.5th percentiles of the replicates kept, by nearest rank. The random seed is fixed, so the same stored rows give the same interval on every read. cost_per_point_ci_low is 1,000 ÷ ci_high and cost_per_point_ci_high is 1,000 ÷ ci_low (the bounds swap); if either TIER bound is 0, both read 0. The interval is self-relative: it says how much this developer's own number could move under resampling, not where it stands against anyone. In the rare case that no replicate is kept, the interval reads 0 to 0, as for a row below the floor. The method assumes the developer's outcomes are independent draws, which work on one issue may not be; the project has not studied how often the interval covers the true value. Team, division and total rows carry no interval. Developer rows inside each work-type segment carry their own interval, computed the same way over that segment's outcomes and cost.
Nominal 95% bounds. Coverage is unvalidated: in constructed tests where several outcomes share one issue, they covered the true value less often than 95%. They are descriptive resampling bounds. When shown, the bounds are the 2.5th and 97.5th percentiles of the stated bootstrap resampling procedure. Their 95% repeated-sampling coverage has not been validated. They exclude uncertainty from uncaptured usage, incorrect attribution and unattributed spend. For bootstrap calculations TIER divides shared developer/repository/issue cost equally among that issue's outcomes. This is an allocation convention, not observed spending per pull request. The v0.6.1 rule (#1233) withholds both TIER and cost-per-point bounds when that row's own attributed-cost share is below 50%, using numeric zero bounds and ci_note: "withheld: row attributed-cost share below 50%". The share is the fraction of that row's cost paired with resampled outcomes, after the bootstrap allocation; the fixed-cost remainder includes spend on issues without outcomes and non-resampled repair cost. Exactly 50% passes. When shown, ci_note is "excludes uncertainty from unattributed spend". ranked and the point estimate are unchanged. Synthetic/offline anonymised raw reports report interval_status: "not_shown", without revealing either side of the floor. v0.6.0 itself did not apply this withholding rule.
7.3 significant
TIER serves closed UTC months on real servers: GET /api/v1/scores?period=YYYY-MM and GET /api/v1/scores/compare?period_a=YYYY-MM&period_b=YYYY-MM. Omit selectors for the latest close and, for comparison, its preceding calendar month. Other query parameters, including windows and work-type filters, return HTTP 400. For a single-origin monthly projection, compare disclosures.prices entries (version and hash), disclosures.used_price_versions, disclosures.unregistered_price_versions and close.policy (including rubric_version). Multi-origin projections carry these disclosures and policy per entry in origin_evidence[]. Unknown price identities do not establish comparable pricing; matching stamps cannot establish matching task mix, labelling habits or capture completeness.
The legacy significant field and rolling-window bounds apply only to synthetic/offline raw reports. They are unvalidated descriptive non-overlap results, not an inference or noise threshold. Real monthly comparisons carry frozen manifests and publication decisions.
Before interpreting a change, document differences in population, work mix, capture and measurement rules. If these are unknown or substantial, report two observations without calling the difference an improvement or deterioration. The synthetic demo alone accepts raw /scores?work_type=<type>&since=&until= segments; real monthly reads reject those filters.
7.4 When to read it
For monthly reads, check disclosures.horizon.window_predates_cost_capture first; if it is true, select a later closed period=YYYY-MM beginning at or after disclosures.horizon.cost_coverage_safe_since. Multi-origin reports carry the corresponding disclosures per origin_evidence[] entry. Unknown coverage needs investigation. Raw-window significant and work-type filter examples apply only to synthetic/offline reports. Document population, work mix, capture and measurement changes before interpreting a difference.
coverage_pct (labelled "Fidelity" in the dashboard) measures capture fidelity, not completeness: the share of captured spend that arrived per request rather than as a daily or estimated total. It says nothing about spend TIER never saw, and it reads near 100% under JSONL capture even when most spend is linked to no issue. Attribution is attributed_cost_share.
Capture completeness is unknown unless independently reconciled for the stated engines, routes, versions and reporting window. A healthy collector or a 100% Fidelity reading does not establish complete capture.
8. Spend Leverage, and what the number supports in a spending decision
8.1 Spend Leverage
Spend Leverage = total_cost_usd / actual_paid_usd: the list-price value of the usage TIER recorded, divided by what finance recorded as paid. It is a separate figure and never changes TIER, which always divides by list-price cost whatever was paid. It is server-only, because it needs a finance figure.
Which months count. Paid spend is posted per calendar month (YYYY-MM) and never pro-rated. A window includes every month from the month containing since up to, but not including, the month containing until. So a mid-month since brings in its whole month and a mid-month until drops its whole month. With no until, every month from since onward counts, including months not yet over. Use month-aligned windows (since=2026-04, until=2026-07) whenever leverage matters. The dashboard's default window (30 days back, no end) does not line up: it sets the full paid amount of every month from the one 30 days back through the current month (usually two months, sometimes one or three) against 30 days of list cost.
Reading it. Above 1: the organisation paid less than list for the recorded usage (a discount, a commitment, a subscription). Near 1: what metered billing at list gives, though discounts, capture gaps and seat allocation can each move it. Below 1: it paid more than list, for example a flat fee that was under-used that month. Synthetic example for the same developer and one complete calendar month: $600 of captured list-price usage divided by $300 paid for that usage gives $600 / $300 = 2×. Both amounts are invented to show the method; they use no private input and describe no observed deployment. On the wire, a row with no finance entry has actual_paid_usd 0 and spend_leverage 0. A row whose net paid is zero or negative has spend_leverage 0 and carries the net in actual_paid_usd, so a negative net is visible but a net of exactly zero looks like no entry. The dashboard renders 0 as "—", and when the window's total paid is negative its leverage tile reads "(credit)" with the balance beneath.
Posting what was paid. Finance posts US-dollar amounts at one of two grains; the per-developer grain wins where both exist. Every post adds a row: there is no idempotency key, so posting the same invoice twice doubles it. TIER records whatever dollar figure is posted for a month; the accounting basis (cash, invoice or accrual) and the treatment of tax, currency and prepaid credit are finance's choice, and TIER does not check them. Credit memos and refunds are negative rows, corrections are deltas, and the net is summed when read; a multi-month window sums each month's net.
- Per developer:
POST /api/v1/actual_spend {"developer","period":"YYYY-MM","actual_paid_usd"}, for tools billed per seat. - Org level:
POST /api/v1/org_actual_spend {"org","period","actual_paid_usd"}, for one contract covering many seats. Each seat's share isorg_total / seats, where the seats are the developers whoseperiod_membershipis open in that month. Membership is kept by month: joining mid-month counts as a full seat for that month. A developer's first hierarchy entry with an organisation opens a membership reaching back to every earlier month, so they count as a seat on all past invoices; re-enrolling a developer whose organisation was cleared, or whose membership was ended, opens the new seat in the current month instead (#867). Moving to another organisation makes them a seat in both for that month.POST /api/v1/period_membership/{developer}/endcloses a membership so leavers stop diluting the share. With no hierarchy, or no organisation named in it, there are no seats, and an org-level invoice is allocated to no one. When both grains exist in a month, the org-level seats split what is left after the per-developer rows; that remainder is clamped at zero, and if every seat has its own row it goes to no one.
Tier-1 home org and conservation. A developer's own per-seat invoice (tier 1) is returned to them once and subtracted only from their home org's pool. Home is the org of their earliest active period_membership seat in that month: smallest period_start, including the 0000-01 first-enrollment sentinel, with ties broken by org ascending. The choice uses all active seats, even when a seat's org has no invoice. A later move in the same month cannot displace an earlier start; if there was no seat on the first, the earliest active start still determines home. A tier-1 developer remains excluded from the tier-2 remainder split in every org where they hold a seat that month. The whole-month overlap rule above is unchanged.
For each org and month, its home-scoped tier-1 allocations plus its tier-2 slices conserve its invoice to micro-dollar precision, provided tier 1 does not exceed the invoice and a non-tier-1 seat can receive any remainder. Synthetic example: two orgs each invoice $1000, a mover has a $300 tier-1 row, and each org has one other person. If the origin is home, the mover receives $300, the origin peer $700, and the destination peer $1000: each org's $1000 is fully allocated. The overlap cannot subtract the same $300 twice. A per-developer invoice with no active seat remains independent spend; the zero clamp and unallocated remainders described above still apply.
Team leverage is Σ list cost / Σ paid, never an average of ratios. Under a ?repo= scope, spend_leverage and actual_paid_usd are reported as 0 with data_quality.spend_leverage_suppressed: true, because an invoice carries no repository.
Subscription plans. Token value and subscription fees are recorded separately. The price table says what a subscription route's tokens are worth at a comparable per-token rate (a row marked billing_mode: subscription); the subscriptions: config block says what the flat fee was (route_prefix, org, monthly_fee_usd, optional active_since), and serve posts that fee to org_actual_spend under the source subscription:<route_prefix>: at startup and hourly, the full fee for each month (never pro-rated), back to active_since at startup, posting only the difference from what is already recorded for the current month, so restarts do not duplicate it and a fee change corrects the current month only. The fee never enters the denominator. A "subscription row" here means a row marked billing_mode: subscription; the embedded table ships none (its GLM rows are ordinary per-token rows), because for most plans a comparable rate is a judgement with no first-party source, and serve refuses to start when a route_prefix matches no subscription row in the active table. So on the embedded table, record a flat fee by posting it yourself with one of the two endpoints above. That covers a Claude subscription too. The Anthropic Admin API poller reads the organisation's API usage and cost, and by the project's reading a subscription plan's fee does not appear there, so post it by hand.
The Z.ai coding plan is one of two flat-fee routes the embedded table has per-token rates for; the other is a Muse subscription, whose calls are priced at Meta's per-token list rates (Section 3.1). Z.ai publishes per-token API rates for the same GLM models, and the operator ruled on 2026-09-25 that those published rates are the value of coding-plan tokens. That is a valuation choice, not a price the plan charges, and it is why the plan's glm-5.3 spend needs no subscription row. Ollama's cloud tier publishes no per-token rate for its models at all, so there is nothing comparable to price it with, and it is not captured (Section 3.1).
Leverage is not a saving. On a flat or subsidised plan leverage can run above 1, as in the synthetic 2× example. That does not mean the organisation saved that multiple. The ratio says what the plan bought at list prices; it does not say what metered billing would have cost, because usage might have been different under it. The dashboard tile is labelled "metered cost / paid spend" for that reason. Leverage also inherits every gap in capture: usage TIER did not record lowers the numerator, and an invoice that covers tools TIER does not capture raises the denominator.
8.2 What the number supports in a spending decision
How to use TIER for the decisions the white paper says it may inform:
TIER does not set or enforce AI spending budgets or spending limits.
What a change over time tells you. Within one organisation, under the comparison rule (Section 2.1), for two windows that have stopped moving (Section 5.4): a falling cost_per_point in one work type means recorded AI cost per weighted point of that type fell. It does not say the work was the same, or why. Four ordinary causes move it with no change in efficiency: a shift in the mix of work, a change in how generously size labels are applied, a change in capture coverage (a newly used tool that is not captured makes work look cheaper), and a window edge. Rule each out before reading a change as an efficiency gain. TIER offers no validated inference test; the legacy significant field is only an unvalidated non-overlap result (Section 7.3). A team-level or per-work-type difference is an observation to investigate, not a measured effect; /scores/compare cannot filter by type. Before interpreting a change, document differences in repository scope, membership, work mix, capture and measurement rules. If those differences are unknown or substantial, report the two observations without calling their difference an improvement or deterioration.
How complete the cost side must be. TIER cannot see spend on a tool it does not capture, and no field reports that spend. So first confirm by hand that every AI tool the team uses is captured by some path (Section 3.1). Then:
- For monthly reads,
disclosures.horizon.window_predates_cost_capturemust befalse; if true, choose a later closed month starting at or afterdisclosures.horizon.cost_coverage_safe_since. Unknown status is not coverage. - Inspect the frozen
disclosures(ororigin_evidence[].disclosures) and publishedqualitysummaries for input status, outcome joins and capture horizons. Exact rawattributed_cost_shareand provisional-headline behavior belong to synthetic/offline reports; real name-free reads omit those raw shares. - Frozen realtime capture shares are not completeness measures. In synthetic/offline raw reports,
coverage_pct(0 to 100) is the share of captured cost that arrived per call. A low value means more cost came from the org pollers or from imports. The code sets no threshold for it. - For the open doubt on Claude Code's logs (Section 3.1, issue #837), TIER applies no correction. For usage billed through a provider's API console, the org pollers add the gap between the provider's daily totals and what capture recorded, as unattributed spend; they cannot see a subscription plan's usage. Before relying on an absolute dollar figure, compare one month of TIER's Claude Code cost with what the provider billed for the same usage. That check is possible only for API-billed usage: a flat subscription's bill has no token counts to compare. Prefer decisions that rest on a change within one capture path, which the doubt affects less if the error is stable; nothing in TIER tests that it is.
Decisions it can inform:
- Model or routing choice. Treat a move to a cheaper model as a hypothesis to investigate. Compare similar work over finished periods, record other changes (task mix, acceptance criteria, pricing and capture), and inspect acceptance and repair evidence beside cost. A before/after change in TIER cannot show that the model change caused the difference. TIER associates merged outcome points with models by cost, with an equal-share allowance for zero-cost attributable models; this is association, not causal credit, so it cannot say whether the cheaper model did the work as well. Monthly model views follow holder scope; name-free cache levers follow the census publication rule (Section 6.2).
- A tool trial. Give a group a new AI tool for a window and compare its
cost_per_pointwith its own previous window. This works only if the new tool is captured; if it is not, its spend reads as zero and the trial looks free. - A team's trend. Whether one team's cost per point moves after a change in practice, such as linking branches to issues or reusing context.
- Whether a flat plan is used. Spend Leverage below 1 for several months means the fee was more than the list-price value of the usage TIER recorded. Whether metered billing would have cost less depends on usage under it, which TIER cannot know (Section 8.1).
9. How to use it: install, demo, see spend, full score
9.1 Install
The project offers documentation, issues and releases in the public repository; no support SLA is promised.
Solo users can read their own local Claude Code, Codex CLI, Opencode (Z.ai coding plan) and Muse Code cost table and issue-attributed spend share with tierd score. tierd score --outcomes joins that spend to only the GitHub token owner's merged PRs for a personal TIER; it persists no report data and shows no team band. tierd demo provides the dashboard on invented data.
Build v0.7.0 from source (Go 1.27.3; this revision or later requires Go 1.27.3 or newer, raised for GO-2026-6809; make, git):
git clone --branch v0.7.0 https://github.com/tiermetric/tier.git
cd tier
make build # produces ./bin/tierd
Or install without cloning:
go install github.com/tiermetric/tier/cmd/tierd@v0.7.0
tierd version
Both commands pin the tag. @latest or a clone of main can give you a version whose flags and defaults differ from the ones documented here.
Each release on the tiermetric/tier releases page publishes per-platform tarballs (darwin and linux on amd64 and arm64, and windows on amd64), each with a .sha256 checksum and a SLSA (Supply-chain Levels for Software Artifacts) build-provenance attestation you can check with gh attestation verify <file> --repo tiermetric/tier. It also publishes a container image to the GitHub Container Registry (GHCR) as ghcr.io/tiermetric/tierd, built for linux/amd64 only (on arm64, use the arm64 binaries).
Homebrew (macOS): brew install tiermetric/tap/tierd builds tierd from source.
The macOS binaries are not yet Developer-ID signed or notarized. A binary downloaded with a web browser is marked as downloaded, and macOS's Gatekeeper may refuse to run it. Remove the mark once with sudo xattr -d com.apple.quarantine /usr/local/bin/tierd (use the path where you put it). A binary built from source or with go install carries no such mark. The download, checksum and extraction commands are in docs/quickstart.md. The Windows archive passed a command-line smoke test of tierd v0.6.2 on Windows 11 (2026-10-22, synthetic data only). Scoring populated data and the dashboard against a real server are not yet tested there, there is no Windows CI, and the database file-permission protection does not apply; the full list is in docs/quickstart.md. Treat Windows as experimental. The default log locations are built from the home directory, so they resolve on Windows too.
The commands in this section write tierd, meaning the binary on your PATH. go install puts it in Go's binary directory ($(go env GOPATH)/bin unless GOBIN is set); add that directory to PATH if tierd version is not found. From a source checkout, use the full path to bin/tierd instead, for example ~/tier/bin/tierd; the relative ./bin/tierd works only while your current directory is the checkout.
9.2 See it in one command (synthetic data)
tierd demo # then open http://127.0.0.1:8080
From v0.6.1, tierd demo seeds fresh SQLite in a random private temporary directory (mode 0700, removed on exit including SIGINT/SIGTERM); it refuses --db, and flags, environment and config cannot select the database. It seeds that database with demo-* developers, DEMO-* issues and an ACME (DEMO) organisation and prints a synthetic-data banner. It runs serve in --read-only mode: only the read, health and open routes (the dashboard and /docs/) are mounted, and the write, ingest, admin, webhook and proxy routes and every collector are structurally absent rather than token-gated. Because capture is disabled in that mode, --read-only also lifts the --watch-repo requirement: --codex-rollout or --opencode beside it produces a startup warning that the spend will not be recorded, rather than a refusal to start. demo is the one exception to the issued-key startup rule of Section 9.4: because it serves invented data in that mode, it may bind a non-loopback address with no token (tierd demo --addr 0.0.0.0:8124). The exemption is an in-code signal set by the demo command alone; no serve flag or environment variable can reach it.
9.3 Check capture, then see where the money went (no server, no token)
tierd doctor --repo ~/src/your-repo # local checks: git repository, git remote, claude projects dir, recent sessions, issue attribution, spend not on a feature branch
tierd score --repo ~/src/your-repo # last 90 days of Claude Code spend for that repo
tierd score --repo ~/src/your-repo --since 2026-05-01
With no --server, doctor runs the local checks named above (plus collector-credential probes when --config names an Admin/Usage poller). Add --server <url> --api-token @<root-key-file> for server reachability/auth, clock offset, price-table parity, frozen monthly identity joins, outcome coverage, cost horizon and monthly close status. These read the diagnostic /report_manifest, not a live /scores window; there is no sealing check. Use --month YYYY-MM for a specific closed month:
tierd doctor --repo ~/src/your-repo --server http://127.0.0.1:8080 --api-token @<root-key-file>
score reads the invoking user's own local Claude Code, Codex CLI, Opencode (Z.ai coding plan) and Muse Code logs by default, prints the price-table identity, source subtotals, combined costs and cost by issue. Absent sources are skipped with a note; Opencode is opened read-only, though SQLite may leave -shm/-wal files beside it. Add --outcomes to join only the GitHub token owner's merged PRs and compute a local personal TIER, with no persisted report or team band. Useful flags include --since, --prices, --repo-slug on a fork, and source-path overrides --claude-dir, --codex-sessions-dir, --opencode-db and --muse-home. The cost-only form accepts --developer; --outcomes refuses identity overrides. See the quickstart.
9.4 The full score
First create a GitHub token for tierd backfill, which calls two GitHub REST endpoints and nothing else: GET /repos/{owner}/{repo}/pulls and GET /repos/{owner}/{repo}/pulls/{number}. A fine-grained personal access token (PAT) with Pull requests: Read-only on that one repository is enough:
- Create it at GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token (
https://github.com/settings/personal-access-tokens/new). - Repository access: only that repository. Permissions: Pull requests = Read-only (Metadata is added automatically).
- Copy the
github_pat_…value; GitHub shows it once. - Save it to a private file: run the line below, paste the token (nothing is shown) and press Enter:
mkdir -m 700 -p ~/.tier && chmod 700 ~/.tier && read -rs T && (umask 077; printf '%s' "$T" > ~/.tier/github-token) && chmod 600 ~/.tier/github-token; unset T.
First complete issued-key bootstrap
and holder upload setup,
saving the issued root secret in ~/.tier/api-token and the YOUR_LOGIN upload
secret in ~/.tier/upload-key. Stop that server before the backfill.
Start this walkthrough in the current UTC month with a fresh database. The first serve closes every elapsed due month at once; ship's back catalog cannot enter those closes.
Both backfill and ship start at that month so startup cannot freeze older
outcomes before their matching spend and roster arrive. Writable serve closes
due months at startup and every minute, 24 hours after month end. Import the
roster and finish shipping and aliases before then; later uploads cannot revise
frozen costs or census. This sequence cannot bootstrap complete historical
reports: use offline score --outcomes for past work. For branch reports, use
the first full month after the roster takes effect.
# 1. Reconstruct this UTC month's merged-PR outcomes. Run this BEFORE serve:
# the store is single-writer SQLite.
export TIER_GITHUB_TOKEN=@$HOME/.tier/github-token # or pass --token @$HOME/.tier/github-token
tierd backfill --repo your-org/your-repo --since "$(date -u +%Y-%m-01)" # GitHub "owner/name" slug, not a path
# 2. Start the server. Real report reads require holder keys.
mkdir -p ~/.tier && chmod 700 ~/.tier
tierd serve
# tierd listening addr=127.0.0.1:8080
# 3. Import the roster using root, then in a second terminal ship this UTC
# month of local cost with the issued holder key.
tierd ship --server http://127.0.0.1:8080 --repo ~/src/your-repo \
--developer YOUR_LOGIN --bound-token @"$HOME/.tier/upload-key" \
--since "$(date -u +%Y-%m-01)"
A literal token on the command line leaks through ps and shell history; the @file form avoids that. A repository owned by an organisation may require an organisation owner to approve the token before it works. gh auth token from the GitHub CLI also works, but it hands backfill a token with far wider access than it needs. For GitHub Enterprise, add --github-api-url <base URL>.
Then open http://127.0.0.1:8080. The dashboard (a static HTML page with client-side JavaScript, served at /) now computes the score from your captured cost and merged outcomes. The page asks for an issued holder key when a read returns 401; it keeps the token for the browser session only and sends it as Authorization: Bearer. The tierd in these blocks is the binary on your PATH; from a checkout, substitute its full path as in Section 9.1.
This walkthrough starts no watcher: repeat ship with the same current-month
bound and collector flags for new activity before the month closes. Add
--codex-rollout, --opencode or --muse for tools you use. Their server-side
watchers are configured separately and can load older logs on their first pass;
they are not needed for this walkthrough. See the
bound watcher setup for live
Claude Code capture and Section 3.1 for each capture path.
New merges after this point reach the server in one of two ways. A GitHub webhook (Section 9.5) needs the server reachable from GitHub, which a loopback bind is not; the alternative for a local trial is to stop serve, re-run tierd backfill, and start serve again, since the store is single-writer. Keep the backfill bound at the current UTC month; later shipping cannot repair an already closed month. With no webhook, no CI or revert signal arrives, so every outcome in this layout keeps quality 1.0. A brand-new trial with fewer than 3 merged PRs or under $5 of captured spend (unattributed spend included) shows a row below the evidence floor until more data accrues. So does one whose backfilled PRs are older than the session logs on disk (Section 5.5): choose a later closed month beginning at or after disclosures.horizon.cost_coverage_safe_since. If your OS username differs from your GitHub login, map them:
printf 'Authorization: Bearer %s\n' "$(cat ~/.tier/api-token)" |
curl -f -X POST http://127.0.0.1:8080/api/v1/developer_alias \
-H @- -H "Content-Type: application/json" \
-d '{"alias":"alice-laptop","canonical":"alice"}'
Send the header through curl's input so the token never appears in ps: start the command with printf 'Authorization: Bearer %s\n' "$(cat /path/to/token)" | and add -H @- to curl (printf is built into the shell, so it starts no process that would show the token), but only when nothing else in the command reads standard input (-d @-, --data-binary @-, -T -, -K -).
9.5 Run it for a team
export TIER_WEBHOOK_SECRET=@/etc/tier/webhook-secret
tierd serve --addr 0.0.0.0:8080 --db /var/lib/tier/tier.db
tierd itself serves plain HTTP. The https:// addresses in the webhook and ship examples assume TLS (Transport Layer Security) is terminated in front of it by a reverse proxy, load balancer or tunnel that forwards to tierd's listener; TIER ships no certificate handling of its own. How to keep the per-IP lockout working behind such a proxy is in docs/security.md.
--db defaults to ~/.tier/tier.db on every command that opens the store (tier.db in the current directory if the home directory cannot be resolved). Real servers always require issued holder keys, on loopback too. Static
admin/read/metrics flags are removed. Bootstrap root on the host with
tierd tokens issue --root, then issue holder keys; rotation and revocation use
opaque credential ids. Only the synthetic demo bypasses real authentication.
Configure the GitHub webhook at Settings → Webhooks: payload URL https://<tierd-host>/webhook/github, content type application/json, the secret from TIER_WEBHOOK_SECRET, and the individual events Pull requests, Pushes and Workflow runs. Without a secret the webhook route is not mounted at all. Populate the dated organisation roster before relying on monthly group reports, for example with tierd hierarchy import (Section 6.1). Monthly reports close automatically 24 hours after each UTC month ends; use Team for live branch figures before then. Laptops feed the server on a schedule using each developer's bound upload credential:
*/15 * * * * /absolute/path/to/tierd ship --server https://<tier-host> --repo ~/src/app --bound-token @/path/to/upload-token --developer maya --codex-rollout --opencode --since 2026-09-01
Replace /absolute/path/to/tierd with the installed binary's absolute path; git must be on cron's PATH (default /usr/bin:/bin), or set PATH= in the crontab. Provision a bound upload credential for maya using the identity runbook; the server refuses a different developer ID. Shared ingest credentials are retired. Admin uploads remain team-only and do not supply counted identity. Admin credentials allow invented spend under other IDs and pre-claimed keys; cross-developer key collisions are rejected and reported in rejected_cross_developer. Before v0.6.0, those token counts were merged. Replay promotion remains constrained by stored identity and original rates; see upload trust limits. Keep only the collector flags for tools the developer uses. A fixed --since must be within the last 90 days and moved forward from time to time to bound how much each run resends (Section 3.1); a rolling date needs a wrapper script, because % must be escaped in a crontab line and date differs between Linux and macOS. The server does not know which laptops should report. Use the organization People page's last-authenticated-upload and last-upload-that-added-data stamps to check arrival, not as proof of complete capture. Real-data raw bulk reporting routes, including /fidelity, are denied; separately audited compliance exports remain available. tierd ship -h describes its output on each kind of failure.
In this layout the central server holds no developer's session logs, so it runs without --watch-repo, --codex-rollout, --opencode or --muse, and each laptop's ship carries those collector flags instead. The flags are not forbidden on a server: any serve that does have session logs and repositories on its own disk (for example a shared build host) may take --watch-repo, repeated per repository, with the collectors; serve refuses the collector flags only when no --watch-repo is given. Likewise point backfill at the server's database, or its outcomes land in ~/.tier/tier.db where the central serve never reads them. For a fresh installation, bound backfill and each laptop's ship to the current UTC month as in Section 9.4; finish roster import, shipping and aliases before its 24-hour grace ends. In the cron example above, replace the fixed --since with that month's first day. Starting serve with elapsed-month outcomes can freeze missing costs and census before shipping; later uploads cannot repair the close:
tierd backfill --repo your-org/your-repo --db /var/lib/tier/tier.db \
--since "$(date -u +%Y-%m-01)" # before serve starts
A config file (tierd serve --config config.yaml) mirrors the flags; precedence is CLI flag > environment variable > config file > built-in default. This reference does not reproduce the full schema: the repository's config.example.yaml lists every key, including the hierarchy, pollers, repository slugs and size-label overrides. For Docker, the image (linux/amd64 only, Section 9.1) runs tierd as non-root uid 65532 on a static base with no shell; it binds 0.0.0.0; bootstrap an issued root key on the host and use HTTPS for remote sign-in. The image sets HOME=/data and declares /data a volume, so the default database is /data/.tier/tier.db:
docker run -d -p 8080:8080 -v tier-data:/data \
ghcr.io/tiermetric/tierd:0.7.0 serve --addr 0.0.0.0:8080
It ships only tierd (no git, no host logs), so feed a stock container with tierd ship or the proxy; watching inside a container needs the session-log directory and the repository mounted, and commit-based attribution is unavailable without git.
9.6 The other commands
tierd backup --db <path> --out <new-file> takes a consistent snapshot with SQLite's VACUUM INTO, safe while serve runs. The snapshot is a complete database file that --db can point at; follow the backup replacement procedure to restore it with serve stopped. tierd reprice --from-version N [--commit] recomputes historical costs under the current table (dry run by default). tierd score-log --format claude|codex --log <file> prices one session log as JSON with no store or network. tierd verify-report checks a report against its manifest: the record, served by GET /api/v1/report_manifest, of frozen monthly scope, policy, contribution evidence and projection. Archived synthetic/offline raw manifests instead pin windows, repository scope, raw arithmetic, price/rubric/tool identities and input digests. It exits 0 when every pinned input matches and the recomputed report equals the manifest, 1 when an input or a number has moved (and names which), and 2 when it could not check at all; 2 is never a pass. Monthly tiermonthmanifest1 receipts refold frozen contributions, model allocation and levers, and compare the complete attached projection. Later raw input or policy changes cannot revise them. The older tiersealedmanifest1 receipt is retired and returns 2 (COULD NOT CHECK); exit codes 3 and 4 no longer apply. Archived live tiermanifest1 raw-input auditing remains supported (docs/reproducibility.md). tierd healthcheck probes /api/v1/livez. tierd repair-repo moves one developer's cost rows off the unqualified repository sentinel (Section 5.1). tierd prices forget-version --db <path> --version <N> --commit retires a registered price-table identity in that database (dry run without --commit; tierd prices list --db <path> and tierd prices audit --db <path> show the registry and its ledger); it cannot free the embedded version, which the load-time guard protects regardless. Run any command with -h for its full flag list (exit 0); grouped commands also accept top-level help. tokens list opens an existing database read-only without migrations. corrections list|show refuse missing paths without creating files; existing databases retain their upgrade and notice behavior. An explicit unopenable doctor --db path prints a warning.
Upgrades. A newer binary migrates the database when it opens it, and an older binary refuses to open a database a newer one has migrated, so take a tierd backup before upgrading; that backup is the only way back. Running serve as a system service is left to the administrator's own service manager; the repository ships a container image and an example systemd unit, deploy/tierd.service.
Scale. The project publishes no tested deployment size (developers, events or database size) for the single-writer SQLite store, and no figure for what running TIER itself costs. A warning fires when SQLite's write-ahead log (WAL) grows past 64 MiB (Section 10); it only warns, and the project documents no remedy.
10. API and interfaces
Real routes use issued keys and always-on default-deny. Root administers the
server, node keys read their dated branch, personal keys read self, and upload
keys ingest self. Root/metrics keys scrape /metrics. The monthly table serves
scores, comparisons, manifests and CSV. Raw diagnostic/export descriptions
below are retained for the local synthetic/archive projection; the real route
policy denies those diagnostics. Named cards and compliance reads audit before
construction. See identity access for the current route map.
| Method and path | Purpose | Scope |
|---|---|---|
POST /api/v1/costs |
ingest one token-cost event (source must be api or omitted; Section 3.1) |
root |
POST /api/v1/events |
bulk ingest from tierd ship (sources jsonl, codex-rollout, opencode, muse); server re-prices |
personal or upload key only |
POST /api/v1/outcomes |
record a merged outcome (Section 4.6) | root |
POST /api/v1/actual_spend |
per-developer invoice total for a YYYY-MM period |
root |
POST /api/v1/org_actual_spend, GET |
org-level invoice total; read back the ledger | root |
GET /api/v1/scores |
real servers: monthly name-free projection under issued-key scope, ?period=YYYY-MM or latest close; synthetic month selectors use the same reader; synthetic/offline raw windows retain their window/filter contract |
root, node/Read-node or personal key |
GET /api/v1/scores/{developer} |
holder-scoped coaching card; audit before construction; no positive-spend prerequisite | root, authorized node, or self personal key |
GET /api/v1/scores/compare |
real servers: two monthly projections, period_a and period_b, or latest close and preceding UTC month; synthetic month selectors use the same reader; raw-window deltas remain for synthetic/offline reports |
root, node/Read-node or personal key |
GET /api/v1/issues |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/diagnostics/captured-dollars |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/report_manifest |
monthly comparison proof via period or latest close; holder-scoped proof via month; synthetic/offline raw manifests retain their separate window contract |
root, node/Read-node or personal key |
GET /api/v1/events, GET /api/v1/outcomes |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/quality_events, GET /api/v1/quality_history |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/correction_diagnostics |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/repair_spend |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/fidelity |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
POST, GET, DELETE /api/v1/developer_alias[/{alias}] |
identity map | admin |
PUT /api/v1/org_hierarchy/{developer}, POST, GET /api/v1/org_hierarchy |
developer → team/division/org map; PUT body {"team","division","org"} with team required, POST body an array of {"developer","team","division","org"} in one all-or-nothing transaction |
root |
POST /api/v1/period_membership/{developer}/end |
close a departed developer's seat | root |
GET /api/v1/developer/{id}/export, DELETE /api/v1/developer/{id} |
GDPR access and erasure | root |
GET /api/v1/corrections/{kind} |
Synthetic/offline raw contract only; denied on real servers | none on real servers |
GET /api/v1/health, /healthz, /livez |
smoke, readiness (503 while the watcher restarts), liveness (version, uptime) | open |
GET /api/v1/version |
build identity (version, commit, platform, price-table version, no digests); first released in v0.4.1, so v0.4.0 and earlier answer 404 | open |
GET /, GET /docs/ |
the dashboard page, and the documentation as static HTML; both mounted in every mode, --read-only included |
open |
GET /metrics |
Prometheus exposition | root or metrics key only |
POST /webhook/github |
GitHub deliveries, HMAC-SHA256 (a keyed hash of the body) in X-Hub-Signature-256; mounted only with a secret |
signature |
Windows on the read endpoints accept YYYY-MM-DD, YYYY-MM or YYYY, each meaning the first instant of that day, month or year in UTC; windows are half-open, so until=2026 ends immediately before 2026-01-01T00:00Z, and until must be after since. since defaults to the start of the UTC day 90 days ago (the dashboard defaults its "From" date to 30 days ago). Real servers take no window parameters on /scores, /report_manifest and /scores/compare, only monthly selectors. Synthetic period selectors also read frozen monthly rows; synthetic live windows and archived offline auditing retain the raw contract. The scores endpoints reject any query parameter outside their own list with 400: in synthetic/offline raw windows, for /scores that list is since, until, before (a legacy alias of until), team, work_type and repo; for /scores/compare it is the four window parameters alone. The project's compatibility rule for responses is additive-only: a field is not renamed, removed or given a new computation in place, and CSV columns are append-only. That is a rule the project holds itself to, not something the code can guarantee for future releases. (exploratory_cost_share is computed as it was when introduced; only the words describing it changed; Section 5.3. One exception in v0.5.2 (#786): the premium cutoff behind premium_model_share moved from $5 to $4, which changes that share for models priced between the two.) Feature detection reads livez.version. This reference does not reproduce request and response schemas, pagination parameters or error bodies; they are in the repository's docs/api-compatibility.md and docs/outcomes-api.md. Exports paginate by an opaque next_cursor (also in the X-Next-Cursor header), default page 1,000 rows, hard cap 10,000, and cost_micro is integer micro-dollars.
issue_rule is the last field/column on event and outcome JSON/CSV exports,
after all existing columns (including conditional Event 3 outcome columns).
NULL renders as legacy; recorded labels render verbatim. The
issue_rule_cutover marker in Section 4 distinguishes the pre-upgrade ID band
from later NULL rows. The developer access export includes the stored nullable
value, rendering SQL NULL as JSON null. Both columns are erased with their
existing table rows; the identifier-free cutover marker remains.
A developer row in synthetic/offline raw /scores (developers[]) carries (with ci_note added in v0.6.1 by #1233): developer, tier, weighted_points, total_cost_usd, actual_paid_usd, spend_leverage, coverage_pct, exploratory_cost_share, cost_per_point (null on zero points), sample_n (the developer's outcomes in the window, flagged ones included), ci_low, ci_high, cost_per_point_ci_low, cost_per_point_ci_high, ranked, ci_note, flagged_outcomes (outcomes that failed the zero-token check). A team or division row (teams[]) carries team, tier, weighted_points, total_cost_usd, actual_paid_usd, spend_leverage, coverage_pct, cost_per_point, ranked, and no interval. Monthly tiermonthmanifest1 projections expose cache shares through group_levers, pooling frozen token inputs with distinct counted-person partition floors. Synthetic and archived raw windows carry no group levers. Rows come in identifier or name order (in team and division mode, the other row last), and the dashboard keeps that order (Section 7.1). In synthetic/offline raw windows the response also carries team_rollups (Section 6.1).
The synthetic/offline raw /scores response carries, beside the rows: price_table {version, effective_date, table_hash, file_hash}; rubric {version}; total (a pooled rollup); work_types[]; cost_composition (name-free: cache_read_share, premium_model_share for models whose base input rate is at or above $4.00 per million tokens (a constant in the code, lowered from $5 to $4 in v0.5.2 (#786) so that claude-opus-5-5 at $4.00 counts as premium; while Sonnet, Haiku and the flash/mini tiers do not; the over-threshold long-context rates play no part), by_model, and by_class, which holds token counts, not dollars, for input, output, cache read and cache write); segment_reconciliation (outcome_linked + no_outcome + unattributed == window, exact on the integer fields); and data_quality (attribution shares, unattributed buckets, exploratory_cost_share, unjoined_developers, zero-token flags, mixed_price_versions, the cost-horizon fields, kanon_suppressed, and repo_scope, which echoes the repository when ?repo= is set). A team or division report leaves out work_types[], cost_composition, segment_reconciliation and the data_quality fields Section 6.2 lists, and carries attribution_coverage and excludes_unattributed_spend, plus uncounted_active_ids when an identifier did not count toward k.
TIER emits two operational warnings. A zero-outcome tripwire checks hourly and warns (log plus tier_zero_outcome_tripwire gauge) when cost accrued but no outcome landed in the last --zero-outcome-window-days (default 7): a broken webhook or a trunk-based team without push capture. A WAL-size tripwire checks every 5 minutes and warns once when the SQLite write-ahead log (WAL) grows past 64 MiB (gauge tier_sqlite_wal_bytes).
11. Limits and known ways it can mislead or be gamed
The project publishes its own adversarial analysis (docs/adversarial-analysis.md), dated 26 March 2026: 28 scenarios, each with a code used below: G for individual gaming, U for unfair comparison, O for organisational gaming, M for mathematical edge cases and P for perverse incentives. It rated 7 critical, 14 high and 7 medium. It analysed an earlier formula that divided by tokens rather than dollars, on an older weight scale, and the ratings have not been redone for the current formula; read them as the project's judgement at that date. TIER is built for coaching, not for evaluating developers (Section 6.3); the software cannot enforce that. Known ways the number misleads or can be gamed:
Structural, present in normal use
- Task mix moves the number. Greenfield features and production debugging will not score alike; a senior on a legacy module can show a lower number than a junior on greenfield. The project has not measured the relative effects of task mix and developer skill on TIER. Compare a team with its own history (Section 2.1).
- Windowing. Recent, open-ended windows read low (the trailing edge, Section 5.4). Windows that reach back past the cost horizon read high (Section 5.5); widening a window shrinks the edge effects but makes this one worse once it crosses the horizon.
- Attribution coverage. A merged PR with no derivable issue records no outcome at all (logged at debug level, so easy to miss), and TIER reads low. Spend recorded without an issue weakens the segments and trips the zero-token check (Section 5.3). Branch names that carry the issue number reduce both. They cannot help Opencode spend, because Opencode records no branch, and they do not stop a PR merged into a non-default branch from escaping the CI floor, which is read only on the default branch.
- Identity mismatch between OS username and GitHub login splits into zero-scoring rows until aliased (flagged in
unjoined_developers). - Outcome timestamp fallback. The webhook, backfill and outcomes API use the PR's
merged_at; only when it is missing, malformed, zero or future does the webhook fall back to receipt time, which can place an outcome in a later window than its actual merge. Push capture uses the commit time (server time if the commit timestamp is missing or zero). - Cache-hit rates. Identical workflows with different cache reuse get different scores by design; read
cache_read_sharebeside the number. - The diff heuristic has no semantics. A five-line concurrency fix and a five-line typo fix weigh the same unless labelled, and generated churn inflates unlabelled PRs.
- Quality is partial. 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. Incidents, hotfix detection and downstream failures are not enforced in v0.6.0; backfill does not reconstruct historical quality events. CI floors do not reach push-captured outcomes, and without the Workflow runs subscription CI failures are invisible. Corrective-work accounting (Event 3) ships in v0.6.0 and is off by default; the operator chooses whether to enable it.
- Coverage of tools. Capture completeness is unknown. Unsupported populations and deployment gaps differ:
- Individual Cursor and Copilot Pro accounts: not covered by the team/organisation pollers. ChatGPT Team: no collector. Cursor Admin is supported for team costs with Admin API access; own-API-key requests and Cursor's fee on them are left out. Copilot organisation billing has a supported opt-in poller (Section 3.1).
- Codex cloud / Desktop cloud tasks, Claude Code hosted web tasks and Managed Agents: no collector for the hosted services.
- Gemini CLI without the proxy: no collector.
- Opencode non-Z.ai providers: excluded by design from this collector.
- Out-of-repository Codex sessions: tracked in #1067.
- Muse root-less, nested or unlinked subagent calls: excluded by the current collector; unsupported cases remain uncaptured.
- Runner, remote-review and other-machine logs in a supported local format: uncaptured when no collector or shipper ingests them; another machine is a deployment gap, not inherently an unsupported format.
The reverse proxy's OpenAI-compatible route (OpenAI Chat Completions and Responses, xAI, DeepSeek and hosted open-weights) and its Gemini route are experimental until a dated live accounting reconciliation is published for the stated protocol, model and version. The checked OpenAI Responses fixtures are synthetic and establish handling of those fixtures, not complete live capture. Record fixture provenance separately for the other routes; their live-accounting status remains unvalidated without a reconciliation receipt. The Anthropic proxy route also has no live-dogfood accounting receipt. Uncaptured usage can understate cost and make TIER read high when some cost is recorded.
Gaming vectors
- Off-books usage (G-02, critical). Work done on a personal AI subscription that TIER never sees shrinks the denominator. The zero-token check takes a developer below the evidence floor when any outcome has under 1,000 tokens against its issue in the 14-day attributable window (Section 7.1); beyond that, the defence is policy.
- Label inflation (the labelling side of O-01 manager weight manipulation, and the relabelling in G-03). Calling a medium change
size/lraises the numerator, and no code defends against it. The fixed scale constrains only labels and the heuristic;POST /api/v1/outcomestakes any weight in (0, 8] (Section 4.6). Nothing detects a mislabelled PR, and the rubric version stamp cannot see a generous labelling culture. The mitigation is human: review labels against the meanings in Section 4.2. - PR splitting (G-03). The scale is concave at the top, so splitting large work pays. One
size/xlPR is 8 points; the same work as twosize/lPRs is 10, or as threesize/mPRs is 9. The heuristic has the same shape: a 1,200-line, 10-file PR has effort 1,300 and weight 8, while the same diff as two 600-line, 5-file PRs has effort 650 each and weight 5 + 5 = 10. (The adversarial analysis states G-03 as issue splitting on an older weight scale: a 13-point epic relabelled into sub-issues totalling 21; the incentive is the same.) The one-outcome-per-merge rule does not stop this; push capture's one-outcome-per-(repository, issue, UTC day) rule closes only the commit-splitting form. - Cheap-work farming (P-02, P-03). Avoiding hard problems, or avoiding AI where it would help, can both raise the ratio. Work-type segmentation and the "no absolute good band" rule make it easier to notice; nothing prevents it.
- Forged rows (O-03). Root can post manual
/costsand outcomes under another name. Those manual costs supply no counted identity./eventsand proxy admission accept only personal/upload keys and enforce their subject; legacy shared/admin credentials are retired. Historical shared rows remain unverified. Root and host/database owners remain trusted administrators. See identity access. - Quality-window exploitation (G-04). Reverts after 60 days do not degrade the original outcome.
- Double outcomes. One change can be stored twice: Sections 4.1 (integration branches) and 4.5 (push capture).
- Goodhart (P-06, critical). The risk: if TIER becomes a target, people will work to raise the number rather than improve the work, by the routes above.
Operational
- Double counting and original-basis promotion: Section 3.1. A
--pricesoverride replaces the whole table: Section 3.4. - The store is single-writer SQLite; run
backfillbeforeserve, not beside it (Section 4.7). - Everything is single-tenant; run one instance per organisation.
12. Glossary
Short definitions; the section named holds the rule.
- TIER: weighted outcome points per $1,000 of list-price AI cost (§2.1).
- Accepted outcome: a merged pull request, an opt-in direct commit, or an API-posted merge (§2.1, §4).
- Weighted point: one outcome's
weight × quality(§2.1). - Weight: an outcome's size; 0.5 / 1 / 3 / 5 / 8 from a label or the diff heuristic, or any value in (0, 8] sent through the API (§4.2, §4.6).
- Quality multiplier: 1.0 for a clean merge, lowered by a CI failure or a revert (§4.4).
- List-price cost: token counts priced by the reference price table, stored as integer micro-dollars (§3.2, §3.3).
- Reference price table: the embedded, versioned
prices.yaml;--pricesreplaces it (§3.4). - price_version / table_hash / file_hash: the table version stamped on each cost row; digests of the resolved rates and of the file (§3.4).
- Rubric version: the stamp on the weight scale, work-type taxonomy and quality floors (§4.2).
- cost_per_point: dollars per weighted point (§2.1).
- Spend Leverage: list cost ÷ paid (§8.1).
- Operator / administrator: the project maintainer who rules on design questions / whoever runs an installation (front matter).
- Evidence floor (
ranked): 3 outcomes, $5.00 of cost, no zero-token outcome (§7.1). - Zero-token outcome: an outcome whose developer recorded under 1,000 tokens on its issue in the 14-day attributable window (§7.1).
- Provisional: the dashboard's label on the headline when
attributed_cost_shareis below 0.5 (§7.1). - Unattributed / no-outcome spend: cost with no issue / cost on an issue with no outcome for that developer (§5.3).
- attributed_cost_share / coverage_pct: share of window cost joined to an issue / share of captured cost that arrived per call (§5.3, §7.4).
- Issue id: the join key (
issue-42,TIER-99) read from a branch name, commit or PR body (§5.1). - Window: the half-open interval
[since, until)(§2.2, §10). - Cost horizon: the timestamp of the earliest cost event in the store (§5.5).
- Bootstrap interval: unvalidated nominal 95% descriptive bounds from 1,000 resamples of a developer's outcomes (§7.2).
- significant: legacy raw, unvalidated non-overlap result; inference not established (§7.3).
- Reporting scope: issued root, node and personal keys determine whose data a real server serves (§6.1); startup reporting modes are retired.
- k-anonymity / Other: name-free groups use the frozen census, k and complementary folds (§6.2).
- Closed month: frozen monthly contributions captured after the UTC month and its 24-hour grace (§6.2).
- Worktree attribution: the opt-in, off-by-default rule that attributes Claude Code spend by the git worktree a session's tool calls worked in (§5.1).
- Work type: one of seven categories; scores compare within a type (§4.3).
- Push capture: opt-in scoring of direct commits to the default branch (§4.5).
- Laptop shipper:
tierd ship(§3.1). - Idempotency key: the per-event key the store deduplicates on (§3.1).
- developer_alias / org_hierarchy / period_membership: identity map; developer → team, division, organisation map; paid-spend seats by month (§5.2, §6.1, §8.1).
- Acronyms: API (application programming interface), CI (continuous integration), CSV (comma-separated values), DPIA (data protection impact assessment), GDPR (General Data Protection Regulation), GHCR (GitHub Container Registry), HMAC (hash-based message authentication code), JSONL (JSON Lines), PAT (personal access token), PIP (performance improvement plan), PR (pull request), SLSA (Supply-chain Levels for Software Artifacts), TLS (Transport Layer Security), WAL (write-ahead log); BetrVG, CSE and WOR are expanded in §6.3.
Appendix A: machine-readable summary
Constants and rules from the body, for tools. Where a value here and the body differ, the body is right and this appendix is a defect.
document:
title: "TIER — technical reference"
version: "1.10"
companion: "whitepaper.md (version 2.9)"
revision_note: "v0.7.0 release"
date: "UNRELEASED-DATE"
describes: "tiermetric/tier v0.7.0; Event 3 off by default, operator opt-in"
licence: "Apache-2.0"
formula: # §2.1
tier: "sum(weight * quality) / (total_cost_usd / 1000)"
unit: "weighted outcome points per 1000 USD of list-price AI cost"
cost_per_point: "total_cost_usd / weighted_points; null when points are 0; 0 when cost is 0 and points > 0"
accepted_outcome: [merged_pull_request, push_capture_commit_opt_in, api_posted_outcome]
denominator: "all recorded cost in the window, linked to an issue or not"
team_rollup: "sum of points / (sum of cost_usd / 1000)"
comparable_when: "matching disclosures.prices version/hash entries and close.policy (multi-origin: origin_evidence[].disclosures and policy), known frozen price provenance and same release; own closed-month history first; never two organisations"
decision_figure: "pooled closed-month figure, frozen disclosures and published work-category context"
segments: "synthetic/offline raw windows only: outcome-linked cost; categories may overlap"
never_compare: [across_organisations, across_work_types] # §6.3
weights: # §4.2, §4.6
labels: {xs: 0.5, s: 1, m: 3, l: 5, xl: 8}
heuristic: {effort: "additions + deletions + changed_files * 10", buckets: {"<=15": 0.5, "<=60": 1, "<=200": 3, "<=1000": 5, "else": 8}}
several_size_labels: "first in the webhook payload or REST response order wins"
api_weight: "taken as sent, 0 < w <= 8"
push_capture: {weight: 0.5, grain: "one outcome per (repository, issue, UTC day)", squash_merge: "counts once in any event order since v0.5.2 (#849); double counts stored before the upgrade stay"}
rubric_version: {default: 1, event3_enabled: 2, set_by_hand: true, tied_by_tests: [scale, heuristic_outputs, work_types, quality_floors], not_tied: [heuristic_thresholds, work_type_precedence, default_label_names, revert_keywords]}
quality: # §4.4
floors: {clean: 1.0, ci_fail: 0.7, revert_strategic: 0.8, revert_quality: 0.1}
windows: {ci_fail: "48h upper cutoff, run updated_at, push-triggered default-branch runs only; matched schedule/dispatched runs audit-only; merge_group dropped (#1230, v0.6.1)", revert: "60d"}
combination: "minimum of floors, clamped to [0.1, 1.0]"
flaky: "same commit, same workflow_id, later run_attempt, success within 30 min; then every failure of that (commit, workflow) is cleared"
keyword_match: "case-insensitive unanchored regular expressions over the revert commit message"
corrective_work: "Event 3 available in v0.6.0; off by default, operator opt-in, rubric 2"
specified_not_enforced: [partial_revert, incident_correlation, hotfix_branch, downstream_ci, no_ci_signal, flaky_registry, lifecycle_state_machine]
pricing: # §3.2–§3.4
price_table: {version: 14, effective_date: "2026-10-12", models: 93} # v0.6.0
cost_storage: "integer micro-dollars, one round-half-to-even per call"
long_context_trigger: "input + cache_read + cache_write_5m + cache_write_1h > context_threshold"
long_context_rows: [claude-haiku-5-5, claude-sonnet-4-5, gemini-2.5-pro, gemini-3.1-pro] # v0.6.0
unknown_model: {large: 2.00, medium: 0.50, small: 0.10, unit: "USD per million, all tokens", note: "nemotron-ultra/deepseek-r1-full substrings: large; phi-4-mini/embeddings/reranker substrings: small; otherwise first paramCountRE match: >=70B large, 7–<70B medium, <7B small; no match: medium"}
store_conflict: "same key: coherent larger counts kept, token-derived cost recomputed at original basis; incompatible observations retained as conflicts"
billing_mode_changes_cost: false
capture: # §3.1
producers: [claude_code_jsonl, codex_rollout, opencode, muse_code, proxy, post_costs, anthropic_admin_poller, openai_usage_poller, copilot_billing_poller, cursor_admin_poller]
defaults: {claude_code: on, codex_rollout: off, opencode: off, muse_code: off, proxy: mounted, pollers: off, copilot_billing: off, cursor_admin: off}
copilot_billing: {introduced: v0.6.2, status: supported, poll_interval: "6h", settle_days: 2, lookback_days: 7}
cursor_admin: {introduced: v0.6.2, status: supported, poll_interval: "1h", settle_days: 1, lookback_days: 7}
worktree_attribution: {default: off, flag: "--worktree-attribution", env: TIER_WORKTREE_ATTRIBUTION, config: "watch.worktree_attribution (serve only)", status: "opt-in preview; parents never carry; activation needs recorded gates, operator rulings and explicit decision; no new accuracy measured; see docs/how-it-works.md", stored_attribution: permanent} # §5.1
opencode_routes: [zai-coding-plan]
counted_twice: ["codex via /openai/ + --codex-rollout", "opencode via proxy + --opencode", "POST /costs re-posted without idempotency_key", "POST /costs rows declared billed_to=other for usage a running poller also reports, and manual rows for a poller-covered provider stored before v0.5.2"]
open_doubt: "Claude Code JSONL may undercount tokens (issue #837, unsettled); if so TIER reads high"
evidence_floor: # §7.1
wire_field: ranked
min_outcomes: 3
min_cost_usd: 5.00
zero_token: {min_tokens: 1000, window_days: 14}
configurable: false
row_order: "identifier order; nothing sorts by TIER"
intervals: # §7.2, §7.3
status: "unvalidated descriptive bounds; inference not established"
bootstrap: {replicates: 1000, configurable: false, seed: fixed, bounds: "2.5th and 97.5th percentile, nearest rank"}
cost_per_point_ci: "low = 1000 / ci_high; high = 1000 / ci_low"
significant: "legacy raw unvalidated non-overlap result; inference not established; always false for groups or withheld bounds"
aggregation: # §6
source: month_person
server_mode_selection: retired
k_anonymity: {default: 5, minimum: 3, counts: "distinct non-bot roster people with positive counted-evidence cost and qualifying merged outcomes on the dated path", publication: "census-only k and complementary folds; paid/realtime/work amounts do not gate group rows"}
anonymised_reports: {period: "closed UTC month", select: "?period=YYYY-MM", close_after: "24h", automatic: true, source: month_person, recomputed: never}
anonymised_breakdowns: "census-gated groups, work panels, quality, levers and financials"
retention: "every real writable server initiates retention; read-only and synthetic demo do not"
known_limits: ["root and branch administrators remain trusted; qualifying invented people can pad census"]
spend_leverage: # §8.1
formula: "total_cost_usd / actual_paid_usd"
months: "month(since) <= period < month(until); open until includes every later month"
org_invoice_without_hierarchy: "allocated to no one"
finance_posts_idempotent: false
use: # §6.3
permitted: "coaching conversations using holder-scoped cards with audited reads and raw-data retention (90-day default, 60–365 days); the separate real-data per-issue report remains future work; synthetic demo uses its own invented data in a private temporary database and cannot load yours"
stance: "TIER is built for coaching, not for evaluating developers. Per-person figures are not accurate enough to judge an individual."
built_in_1370: [issued_holder_keys, view_log, bound_uploads, immutable_named_provenance]
built_in_1372: [coaching_card]
built_in_1371: [retention_90d_default_365d_ceiling, name_free_rollup]
retention_today: "developer retention and startup/daily scheduler are built; retention runs on every real writable server; the synthetic demo does not initiate age-prunes; the watermark is permanent"
windows: # §5, §10
interval: "[since, until), UTC"
defaults_days: {api: 90, dashboard: 30, backfill: 90, ship: 90, score: 90} # api and dashboard: synthetic demo
monthly_scores: "period=YYYY-MM; since/until return 400"
read_first: [disclosures.horizon.window_predates_cost_capture, disclosures.horizon.cost_coverage_safe_since]
install: # §9.1
source: "git clone --branch v0.7.0 https://github.com/tiermetric/tier.git && cd tier && make build"
go: "go install github.com/tiermetric/tier/cmd/tierd@v0.7.0"
image: "ghcr.io/tiermetric/tierd:0.7.0 (linux/amd64)"
go_version: "1.27.3 or newer (v0.7.0; raised for GO-2026-6809)"
Monthly close metadata. Closed reports show their actual UTC closed_at,
frozen policy and capture-loss disclosure. Late evidence does not change closed
figures. Scores, comparisons and manifests no longer serve legacy seal timing
headers or outcome-dependent revert-window observations. See the
monthly metadata contract.
Exclude recognised revert PRs from existing outcomes (#1277)
tierd outcomes exclude-reverts defaults to --dry-run. It reads the local
--db PATH (default ~/.tier/tier.db) without migrations or database writes.
Optional --repo owner/name and inclusive --since YYYY-MM-DD (or RFC3339) filter
stored PR outcomes by repository and outcome timestamp. Already excluded and
erased outcomes are absent. Evidence comes first from retained pull_request payloads for the same
repository and PR: prefer usable action=closed, merged=true deliveries
matching the stored merge SHA over later edited deliveries, using the newest
usable delivery within each preference. Then try the newest usable payload
without a SHA, then fall back to GitHub's pulls/{n} API, which uses the current
title rather than the title at merge time. As with backfill, use TIER_GITHUB_TOKEN or
--token @/path/to/file; --github-api-url supports GitHub Enterprise and refuses
plaintext non-loopback endpoints. The token is required for a dry run.
tierd outcomes exclude-reverts --db PATH --dry-run --out revert-plan.json
# The operator reviews every candidate and records approval of these exact bytes.
shasum -a 256 revert-plan.json
tierd outcomes exclude-reverts --db PATH --apply revert-plan.json \
--actor OPERATOR --approved-digest APPROVED_SHA256
Without --out, the JSON plan goes to stdout. --out creates a new file with
mode 0600 and refuses to overwrite an existing file. Each candidate includes
repo, pr, outcome_id, merge_sha, evidence_source (payload or api), head_ref, title,
verdict and reason. api also covers an unavailable fallback when the
stored repository or merge identity is missing.
Only the shared recognised-revert rule qualifies: head ref ^revert-[0-9]+- or
title beginning Revert ". An ordinary Revert to v2 API PR stays. Missing or
incomplete evidence, API 404s and mismatched merge identities yield unknown;
issue numbers and branch hashes never substitute for evidence. Both head ref
and title are required to establish keep; either recognised form establishes
exclude. Authentication/permission failures, exhausted API retries and
interruption abort with a non-zero exit and no plan written. The version-1 plan
includes total/exclude/keep/unknown counts. Approval is the SHA-256 of the raw
plan file bytes, as computed by shasum -a 256 revert-plan.json; the dry run
prints this digest to stderr. No digest is embedded in the JSON plan. Candidates
are ordered by stored repository, PR number and outcome id. since preserves
fractional seconds.
Operator sign-off is required for each install. Apply only the exact dry-run
plan the operator approved. The operator must review and approve each install's
plan and must not run apply unattended. --actor and
--approved-digest record that approval; the actor is self-asserted local input,
not authentication, and the digest detects changes rather than proving who
approved them. Filesystem/database access remains the operator boundary. Apply
makes no GitHub requests and refuses filters, output and API flags. It verifies
the approved raw-bytes digest and requires the exact canonical dry-run encoding;
case-variant/duplicate keys and whitespace reformatting are refused. Each row's
evidence and reason must be consistent with its verdict under the shared
classifier. Payload evidence is re-verified against retained deliveries before
any exclusions are applied; missing or changed evidence requires a fresh plan
and approval. Apply then checks each exclude candidate's
stored id, repository, PR and merge SHA under the same write lock as the move.
keep and unknown candidates are never applied.
Apply is atomic per row. The existing exclusion transaction moves the whole
row, keeps its original id, reconciles push credit and records actor, time and
the approved plan digest/evidence in outcome_exclusions. It bumps the erase
epoch so an in-flight monthly close retries its snapshot. A stale,
erased or differently excluded candidate stops apply with a clear error and a
count of rows already committed; those earlier rows remain applied. Replaying
the same approved plan skips matching exclusion audit rows without rewriting
them. Regenerate and obtain approval of a new dry run to handle changed rows;
erased data is never recreated. Exclusions follow the existing developer
erasure/export rules.
Current scoring, evidence floors, bootstrap inputs and exports omit excluded
outcomes through the same mechanism as insertion-time revert exclusions.
Earlier unsealed manifests covering the changed window can consequently
fail verify-report; frozen monthly contributions remain unchanged. A recognised revert PR
earns no credit; the original's penalty still depends on a separately recognised
revert push. Revert-of-revert restoration is unsupported.
Corrective-relation operator commands (Event 3, off by default)
tierd corrections list --db PATH lists recorded relations; show --db PATH --relation ID includes their audit, sizing assessments and allocations. List
pages use --limit (1–1000), followed by the last row's recorded_at and id as
--cursor (RFC3339Nano) and --cursor-id. A show exceeding 1000 rows per evidence
kind refuses and directs the operator to the paginated exports.
Confirmation and settlements that confirm require a different actor from the
declarer, including aliases and case variants, and --role operator. This
identity check is advisory because --actor is self-asserted. Actor and role are self-asserted local CLI input, not
an authentication or authorization boundary. Access to the database is the
operator boundary on this install. The reviewer's identity must equal the actor.
For example, after reviewing an actual relation and its current revision:
tierd corrections confirm --db PATH --relation ID --revision REV \
--actor OPERATOR --role operator --reviewer OPERATOR \
--reviewed-at 2026-10-15T12:00:00Z \
--reviewed-original 5 --reviewed-own 1 --pure-repair \
--scope 'Reviewed original and cumulative delivered scope' \
--rationale 'Follow-up repairs that scope without delivered growth'
Reviewed sizes use the existing 0.5/1/3/5/8 scale. Supply both
--reviewed-before and --reviewed-after for cumulative scope, or explicitly
choose --pure-repair; a raw PR size label is not reviewed growth evidence.
Optional --comparison-base, --comparison-before and --comparison-after
retain comparison commit identities. Review time, rationale and scope are
required. Evidence, audit and whole-chain projection commit atomically. Exact
replays are no-ops; changed evidence or stale revisions refuse.
Confirmation and re-assessment require the fix PR's captured child outcome.
If only its merged-PR declaration exists, the command refuses with "child outcome
not captured yet; confirm after the fix PR's outcome is recorded" and leaves the
relation and evidence unchanged.
dispute and withdraw take --relation, --revision, --actor, --role and
--reason. Before confirmation, withdrawal removes the active relation and
recomputes its chain. A dispute retains its previous treatment and requires
explicit operator settlement; another confirm cannot bypass it. After
confirmation, both disputes and withdrawals retain the allocation until the
operator uses settle with --role operator, --reason and
--settlement uphold, re-assess or remove. Re-assessment requires the same
reviewed-sizing arguments as confirmation. Disputed status remains visible in
developer/operator evidence. Real servers deny raw correction exports; synthetic/offline anonymised reports suppress them. Erased-party allocations stay frozen and cannot be reallocated.
None of these commands enables Event 3 scoring. Scoring is built and off by
default; the operator chooses whether to enable it on their installation.
Single-operator installations review and confirm their own pairs. The distinct
actor label requirement does not establish a second human or independent review.
Corrective-work diagnostics and scoring policy (Event 3)
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.
Enable Event 3 scoring
outcomes.event3_scoring defaults to false; set it to true in the YAML
configuration to opt in:
outcomes:
event3_scoring: true
Alternatively use tierd serve --event3-scoring or
TIER_EVENT3_SCORING=true; precedence is explicit CLI (including --event3-scoring=false),
TIER_EVENT3_SCORING, configuration, then false. Confirmation, migration, restart
and evidence replay never opt in. Restart without an enabling setting selects
legacy scoring on a writable server. A read-only server preserves stored policy
and refuses startup if the requested setting disagrees or the stored policy is
enabled but unresolved. Policy changes and complete-chain re-resolution commit
in one transaction. Disabled policy uses the legacy arithmetic and rubric 1; enabled
policy reads persisted allocations and rubric 2. Scores, details, comparisons,
live manifests and monthly closes refuse unresolved enabled state. Turning the flag
off selects legacy values and rubric 1 atomically. Raw outcomes are preserved.
The schema-v10 upgrade is one-way whether or not scoring is enabled. Back up
before upgrading. Turning scoring off restores legacy arithmetic, not the older
schema. To roll back to an older binary, stop TIER and restore the pre-upgrade
backup; later data is lost. Older binaries refuse the new configuration key:
remove it as warned in config.example.yaml, but do not run older binaries on
the upgraded database.
Enabled allocations and diagnostics
Reviewed cumulative before/after scope supplies marginal growth separately from
a follow-up's own reviewed size and individual allocated share. Pure repair adds
zero growth but can receive points charged first against the original, subject
to its floor and the available budget. A raw size label is not reviewed growth.
Growth uses the 0.5–8 scale, so a follow-up whose chain's cumulative scope
already sizes at 8 adds zero growth whatever it delivers; new scope merged as
its own PR that declares no parent (Corrects: none) is credited on its own.
Eligible unconfirmed same-author pairs use a provisional churn/file-count
approximation; missing inputs leave sizing pending. Unconfirmed foreign roots
never debit their authors. Reviewed per-original shares for multiple originals
are planned, not shipped. See the
rubric for allocation details.
A dispute or withdrawal after confirmation retains the previous treatment until
operator settlement. Disputed state appears in developer/operator views,
exports, the corrective digest and monthly closes. Real servers deny raw correction diagnostics; synthetic/offline anonymised reports use not_shown. These disputes do not rewrite frozen monthly contributions.
Synthetic/offline raw reads at GET /api/v1/correction_diagnostics?since=...&until=...
report pending, provisional, confirmed-with-provisional-sizing and disputed
relations. When scoring is disabled, declarations are still captured but do not
affect points; when the operator enables scoring, resolved allocations apply.
Active declared relations unadjudicated for 30 days escalate. Original and child merge periods
are disclosed separately, including when evidence arrives after a month closed.
The dashboard shows repairs performed/received and raw/credited status.
Rubric-2 /scores embeds only a time-independent correction_diagnostics dispute
summary (status, disputed_relations, and relations containing relation_id,
state, disputed). The same per-window summary is attached to work-type
segments and comparison windows. Archived grouped raw replays use {"status":"not_shown"}.
Clock escalation and live/closed status appear only on the synthetic/offline diagnostics route. Real servers deny that route.
Live anonymous manifests omit correction_revision; developer manifests carry
it as install-wide information, and verification checks window effects through
scores and digests rather than diverging on unrelated revision movement.
Declaration coverage covers captured outcomes only: pending relations prevent a
complete declaration claim. Historical coverage is not_established; captured
spend fidelity remains the separate /api/v1/fidelity measurement. Neither a
relation nor an unresolved revert check supplies CI/revert-only evidence: that
predicate admits only ci_pass, ci_fail, ci_fail_flaky, revert_quality, and
revert_strategic from the quality-event ledger.
GET /api/v1/repair_spend accepts the same window and repository scope. Its
attribution grain is the token event identity, counted once across supporting
repair relations. A matching recorded PR reference supports that PR; issue-only
attribution remains a pooled issue amount shared by all work on that issue.
The response reports both portions and retains partial attribution. It never
splits an issue pool equally across PRs or claims a repair's causal cost, complete
capture, complete corrective history, or independently verified PR attribution.
Token timestamps bound spend; relation merge timestamps bound repair diagnostics.
Allocation figures are per outcome: multi-parent relation rows repeat the child
projection and must not be summed.
Team/division diagnostics and repair spend return only {"status":"not_shown"},
before reading any evidence, with no identifying links or exact counts. These
surfaces accept read credentials and are also available on read-only servers.
Monthly closes pin policy, correction rule, rubric and captured contributions. Late evidence affects live figures and future closes. Stored correction triggers invalidate in-flight monthly folds and unresolved policy allocations; the close refuses unresolved or mismatched policy before publishing. Erasure and retention can pool named rows with a recorded proof revision, conserving group/model totals. The old sealed-report tables, insertion guard and receipt replay are removed. Old sealed receipts return COULD NOT CHECK; monthly verification refolds captured contributions and checks the attached projection.
Event 3 coverage
Available
Coverage is merged PRs whose Corrects: declaration TIER has recorded, live
since this installation upgraded to a release that records Corrects: declarations,
or via tierd backfill. Backfill reads the PR body as it is today, with provenance
backfill:fetch-time, not as it was at merge. With scoring enabled, backfilled
pairs can change live points. Declarations can link to older originals; that does
not establish complete historical coverage. Diagnostics report historical
coverage as not_established.
Not shipped
Automatic nomination of undeclared repairs is planned, not shipped. TIER does not detect undeclared repairs; they remain outside this accounting.
Time to merge (v0.6.2)
Time to merge describes PR created → merged (includes draft time) from
captured timestamps. Every real-server read uses a closed month from
month_person and its frozen outcome evidence, with the shared full-tree
k/complement decision and a separate distinct eligible PR-author floor.
Model allocation rows do not multiply PRs or seats. All additive counts are
withheld. Median calculation retains nanoseconds through the middle-pair mean;
p90 uses nearest rank and is absent below ten distinct PRs. The dashboard shows
median hours and available p90 without a sample denominator. Older closes with
no captured timestamp evidence and erased evidence are explicitly unavailable;
there is no raw-data fallback. Holder keys and read-audit admission are required
in every mode, including read-only serving. The separate descriptive response
is not an attached verify-report result. Synthetic demo and offline live-window
statistics retain their raw reader. See the
API contract.
Smaller or later-created PRs merge faster; time to merge is not a quality measure.