TIER /api/v1 compatibility contract
v0.7.0 identity, coaching and retention: Developers and their declared viewers can open verified coaching cards, with every view logged before construction. Real-data developer startup requires an active permitted viewer credential, a nonempty admin token and a named audited operator. Shared reads are name-free. Writable developer mode starts raw-data retention at startup and daily; once armed, it continues in team/division mode. See developer access and upload identity for current commands and restrictions.
This document is the stability contract for TIER's HTTP API. It pins, for
every /api/v1 endpoint: its stability guarantee, its response schema, and the
project's rules for what may change without a version bump and what may not.
It exists because a JSON field's meaning can change while it stays
parseable, and nothing in the wire bytes announces it. #187 demoted the
top-level /scores developers/teams/total fields to a pooled back-compat
summary that is explicitly not a cross-type ranking, with no version signal and no
changelog a consumer could detect. External orgs scripting against /scores
kept computing a comparison the project itself now calls a category error. This
contract makes that class of change announced and detectable instead of
silent.
The Go response structs in internal/api/ are the authoritative field
definitions; this document must be kept in step with them (see
Changing the API).
- Base path:
/api/v1(the Prometheus scrape at/metricsis the one documented exception, mounted at the root). - Scope: single-process, single-tenant. There is no
/v2today. - Out-of-scope HTTP surfaces: the inbound
POST /webhook/githubseam (mounted at the root whenTIER_WEBHOOK_SECRETis set) and the dashboard UI at/are not part of this/api/v1contract — the webhook is a GitHub-governed wire seam (see the JSONL ingestion contract) and the dashboard is UI, both governed separately. - Encoding: UTF-8 JSON unless noted. The four bulk exports also emit CSV via
Accept: text/csv.
#1476 — root-authored vendor mappings, Part 1
These additive routes require root authority in both handler and store. Root browser sessions use the existing same-origin and mutation CSRF checks; root bearer scripts remain supported. Other credential kinds and static tokens get 403. In read-only serving, POST on the collection path returns 405 because GET shares that path; End and Void return 404 because their routes are unregistered.
| Route | Request | Success |
|---|---|---|
GET /api/v1/org/vendor-mappings |
No query parameters | 200, array of all mappings, including ended/voided rows |
POST /api/v1/org/vendor-mappings |
{"pairs":[{"vendor":"copilot","vendor_user_id":"12345","developer":"alice"}]} |
201, array of created mappings |
POST /api/v1/org/vendor-mappings/{id}/end |
Empty body or {} |
200, {"ok":true} |
POST /api/v1/org/vendor-mappings/{id}/void |
Empty body or {} |
200, {"ok":true} |
Mapping fields are tenant_id, id, vendor, vendor_user_id, developer,
effective_from, created_at, created_by, nullable ended_at and voided_at.
The effective date is server-owned: the next UTC midnight. Mapping interval
strings use the event timestamp's UTC storage format; creation uses fixed-width
RFC3339 UTC. Bulk creation accepts 1–200 pairs, atomically, with one audit row
per pair, with a dedicated request cap that accommodates 200 maximum-length IDs.
Normalized IDs are limited to 320 bytes and cannot contain control characters.
Targets must be canonical roster people who have not left. Invalid IDs,
unknown people, unknown JSON fields and query parameters
get 400; conflicting active mappings or repeated closure get 409; missing IDs
get 404. Void may follow End and permanently removes past counting eligibility.
Reconciliation reads ended and voided mapping partitions too:
ended intervals accept late usage; voided partitions reverse into legacy spend
inside the lookback. A hard-deleted mapping partition leaves its day incomplete
without deltas or settlement, while later days continue ingesting.
DSAR exports add vendor_mappings when nonempty and vendor_mapping_id on
mapped events; a NULL vendor_mapping_id is omitted to preserve pre-mapping
export shapes. Erase receipts add vendor_mappings when rows were removed.
Empty additions are omitted to preserve legacy response shapes. Existing sealed
report bytes and the legacy route golden remain unchanged. Poller wiring and
the Unmapped panel follow in Parts 2 and 3.
#1431 — real-person census and upload freshness
D4 changes the k census: only rostered non-bot fixed named_subject identities
with bound_upload from their own local credential or server_watch evidence
in the window count. Root-mapped vendor evidence also counts when its insert-time
binding remains valid; roster-login matches and legacy aliases alone do not. Census and
coaching share the same evidence predicate, including the own-credential check.
Shared/legacy uploads, manual costs, roster-only people,
imported-unbound people and federated people cannot fill k. Device count does not
increase person count. This breaks formerly visible shared-token group reports;
amounts retain existing aggregation rules. Seal schema 21 adds nullable
sealed_report.census_rule_version (d4-c2-v2 for new seals, d4-c2-v1 for earlier D4 seals, unknown for older
ones). Census rule d4-c2-v1 is introduced by seal fold rule 3 (the upgrade
from fold rule 2); the fold-rule number versions grouping, census and folding
together, while the census name identifies the D4 evidence rule. Schema 21
prevents older binaries opening the database. Rule 2 seals retain their stored
config digest and frozen inputs and still read and verify successfully.
The A′ legacy-golden ruling changes exactly two rows: GET /api/v1/scores,
api-token admin, evaluationMode=true, fixture=evaluation-window, with
aggregation team and division (admin token configured, writable routes).
Those fixtures label uploads bound_upload without a credential, a state no
release writes. D4 counts them zero, so the evaluation responses lose their
previous counted-person shapes. The seal golden changes only config_digest
for fold rule 2 → 3; its body stays unchanged. Evidence written by dev builds
between 2026-10-24 02:42Z and 22:16Z with this missing credential no longer counts
toward k. The fixture remains unbound so the legacy freeze exposes D4.
The A′ extension to coaching changes the 16 affected legacy coaching-card rows
from 200 to 404 under the synthetic no-credential fixture: 11 on
GET /api/v1/developer/{id}/coaching and 5 on its
GET /api/v1/scores/{developer} alias. Other legacy route
schemas and export contents retain their previous contract. Coaching events,
named spend and excluded-spend share use the census's exact evidence predicate.
D4 exclusion causes are confined
to the root-only reporting message; legacy diagnostic categories remain unchanged.
| Read | Authorized audience | Freshness fields |
|---|---|---|
GET /api/v1/org/people?node= |
Root or managing node in subtree | last_upload_date (UTC date or null), freshness |
GET /api/v1/org/keys?node=&developer= |
Root or managing node in subtree | last_data_date (UTC date or null), freshness |
GET /api/v1/me |
Personal key, self only | last_upload_at (exact receipt or null), freshness; absent for root/node |
GET /api/v1/me/devices |
Personal key, own devices | last_data_at (exact receipt or null), last_data_date, freshness |
GET /api/v1/org/nodes/{id}/reporting |
Root or node in subtree | reporting_7d, people_total, withheld; counts omitted below k, message gives root the cause and others “Withheld — ask your admin” |
Fresh is age <24h; Stale is 24h through exactly 7d; Silent is >7d; Never is no
bound receipt. Legacy NULL credential_id receipts are Never. People, device and self views
preserve Silent and the last upload date or time after retention using persisted
upload-credential receipt metadata older than the retention watermark. Summary reporting
includes the exact 7d boundary and excludes departed people. Its k gate uses
qualifying evidence in the same seven-day window: server receipt time for uploads,
event time for watch evidence; bots cannot fill k. Eligibility and reporting use
the same precise receipt-time comparison, including fractional seconds. The
existing tree complementary suppression gates the full forest before selecting
a subtree, preventing parent-minus-children disclosure of a sub-k sibling.
At evaluation k=2, tree suppression conservatively retains its shared floor of 3;
k=1 has no positive sub-k residual. Root evaluation
reads use the configured evaluation floor and carry evaluation_mode: true;
node keys always retain the shared floor of at least 3. All reads are
no-store; invisible subtree targets return 404. Shared/read tokens are refused
on these routes and receive neither date nor chip fields. /org/people and device
lists retain their array shapes. The node summary is the separate route above.
Admin web identity and sessions (#1417)
These routes are separate from the legacy admin/read scopes and mounted in
normal and read-only serve, in every aggregation mode. Synthetic demo and
trusted offline replay do not mount them. All responses, including refusals,
use JSON (except 204), Cache-Control: no-store, CSP and nosniff.
| Method and path | Request | Response |
|---|---|---|
GET /api/v1/me |
Root/node/personal bearer or session cookie | 200 {credential_id,kind,capability,developer,node:{id,name,path:[],level_name},pages:[]} |
POST /api/v1/session |
Bearer, JSON {return_to?:"/…"}, exact Origin or same-origin fetch metadata |
200 {csrf_token,return_to} plus session cookie |
DELETE /api/v1/session |
Cookie, X-CSRF-Token, JSON content type, same Origin check |
204 plus expired cookie |
node.path is a root-first array of node names. A root key with no node uses
empty node id/name/level name and an empty path. capability is empty for root
and personal keys. pages describes role navigation; it does not grant access
to APIs that are not built. The JSON body never includes credential secrets, verifiers,
labels, prefixes or token lengths. Node keys resolve their own node binding;
personal keys use current holder membership. Revoked credentials and holders
with left_at are refused on every request.
Missing/unknown credentials return 401; wrong kind, legacy keys, break-glass,
CSRF/Origin violations and insecure off-loopback sign-in return 403. Cookie
and bearer together, malformed JSON, unknown fields, trailing JSON and unsafe
return paths return 400; wrong content type returns 415. Rate/lockout returns
429 with Retry-After for lockout. Store or session-capacity failure returns 503.
An explicit cross-origin Origin is refused even with same-origin fetch metadata.
Sign-in bootstraps CSRF; every later cookie mutation requires the session-held
token. Org administration writes sent with a bearer and no cookie need neither
CSRF nor Origin (below).
Cookie-authenticated GET /api/v1/me also returns that session's CSRF token in
the X-CSRF-Token response header, allowing a newly loaded page to sign out
without persisting the token in browser storage. Bearer reads and refused
requests do not return that header. This does not change the JSON body.
The static dashboard shell (#1437, plan P20) serves /signin, /org, /people,
/keys, /devices, /card, /federation, /coverage, /settings, /import,
/import-source, /upstream, /first-run and /admin.js. All dashboard pages
and scripts, including legacy / and /dashboard.js, carry CSP,
X-Content-Type-Options: nosniff and Cache-Control: no-store. The new pages
use cookie-authenticated /api/v1/me for identity and allowed navigation;
pages awaiting their APIs have only a labelled, empty body. Static HTML is
public and confers no API access. Shell pages and /admin.js return 404 when
admin web is disabled (synthetic demo or unsealed recompute). Every shell page,
including auxiliary pages, requires inclusion in /me.pages; pages missing
that grant keep their URL and show the access-denied page, “This page is outside
this sign-in”, with a link to the first allowed navigation page. Keys and My
devices also require their role navigation grant, even if an incorrect allowlist
includes the page. Import pages additionally check the role: a personal or node
key opening Company import, or a node key opening Source export, returns to its
first allowed navigation page before any import loader runs. Only a 401 identity response
opens sign-in; other failures keep the shell with a session-check retry state.
Sign-in distinguishes rejected keys, 429 lockout (with Retry-After seconds),
unavailable service, network failure and conflicting session requests. Sign-out
clears local shell state on success or 401/403; other failures retain it and
show that sign-out did not complete. The legacy dashboard assets and read-token
flow remain unchanged.
The People and Settings pages (#1449, plan P22/P24) use the existing org APIs.
People lists the authorized subtree, invites managers only into native strict
descendants, and confirms move, leave, root viewer declarations and typed root
erasure. Add-person and recovery responses show the claim code once; manager
invitations show the node key once. Hide, same-tab navigation, pagehide and
session loss clear the transient result. Neither value is stored or put in a URL.
Root sees reuse-refusal flags from the existing org audit beside recovery actions.
A non-root self-granting move remains available and displays the API's 409 refusal.
Actions never render server error bodies: 403/404 use the approved refusal copy,
and 429 uses Retry-After seconds when present, disabling actions during the wait.
Move choices exclude the person's current node. Other than viewer-declaration
refusals, 400 responses ask for request correction and retry. Open card links
resolve to /card for the holder and /api/v1/scores/{dev} for a declared viewer.
The leave-date default, maximum and submit guard use the UTC calendar day.
Settings shows resolved values and sources; explicit pins disable controls and
saves for that setting. Saves carry the session CSRF token. Retention lowering
shows the server's exact preview and confirms the identical request with its
confirmation token. /first-run is included in root's /me.pages; its evaluation
control uses the same settings API and honors pins. The setup prompt appears
only when the People inventory has at most one person. /card shows the signed-in
subject's declaration and effective-access notices from /api/v1/me/notices,
including both granted and removed viewers; reading notices adds no write. It
also shows the viewer, label, route and time from GET /api/v1/me/views. This
personal-key bearer/session route returns only the resolved holder's
coaching_view_audit rows as [{viewer,label,route,ts}], or [].
It accepts no query parameters (400) or caller-supplied subject. Root, node and
legacy tokens receive 403. It is mounted in normal and read-only serve in team,
division and developer modes, writes nothing and needs no verified spend. The
card routes still return 404 without verified spend in the window. Responses
and refusals are no-store; identity/session errors follow the rules below.
The cookie is HttpOnly/SameSite=Strict/Path=/, with Secure and the __Host-
name prefix whenever the effective origin is HTTPS. On permitted plain-HTTP
loopback it uses the plain tier-session name without Secure. Both names have
a serving-port suffix on nonstandard ports. A cookie-bearing admin-web request
with an explicit foreign Origin is refused before session lookup or rate
accounting, including GET; absent-Origin GET fetch-metadata rules are unchanged.
Sessions expire after 60 minutes idle or 12 hours total,
are re-resolved on each request, and disappear on restart. Sign-out uses a cookie
session; scripts retain bearer reads. See identity access
for the trusted-proxy and sticky-startup contract. Legacy route status, access
and cache behavior are unchanged: legacy routes never read the admin session
cookie, so a live or stale cookie neither authorizes nor refuses there. A root,
node or personal key sent as a bearer to a legacy route is refused 403. Legacy
export carries node_id on org_hierarchy and hierarchy_membership rows, and
the subject's org records in org_people and org_audit. Legacy erasure
reports every erased table in deleted, org_people and org_audit included,
and total_deleted is their sum; a subject with only org records is erased
with 200, not 404.
Federation wire contract (#1430)
internal/federation defines the pure tier-federation/v1 push payload in
docs/contracts/tier-federation-push.schema.json. This package
adds no HTTP route, authentication, persistence or outbound traffic. Every field
is required and non-null; empty lists use []. Decode refuses unknown fields,
duplicate keys, case-altered property names and trailing JSON. Bodies above
1 MiB return typed code too_large; a supplied contract other than exactly
tier-federation/v1 returns bad_contract; other invalid input returns
malformed. These are package codes, with HTTP mapping left to the transport.
Each period requires k ≥ 3, an end after its start and a matching digest; the
payload requires a canonical lowercase UUID server id and seq ≥ 1. All org-node
ids use the same exact 8-4-4-4-12 lowercase hexadecimal UUID spelling; parent
ids may also be empty for the root. Duplicate period bounds, duplicate group ids
within a period and groups that name themselves as parent are malformed.
sent_at, period_start and period_end must be byte-equal to their UTC
time.RFC3339Nano encoding (Go layout 2006-01-02T15:04:05.999999999Z07:00),
ending in literal Z. Fractions are optional, use 1–9 digits and have no trailing
zero; offsets including +00:00 and -00:00 are refused.
Integers use whole-number syntax without a fraction or exponent (5.0 is
refused). The schema bounds k to the 64-bit Go int maximum, sequence and
micro-costs to int64, and points to finite float64 values. The digest is sha256: plus
lowercase hexadecimal SHA-256 over the complete period with only digest
omitted. Object keys are sorted recursively, with compact Go JSON encoding
and HTML escaping disabled (&, < and > remain literal),
shortest round-trip floating-point numbers and exact integer micro-costs.
Array order and group names are significant; accepted timestamp strings survive
re-encoding unchanged. The golden
fixture pins the marshalled shape; regeneration is explicit through
go test ./internal/federation -run '^TestPayloadGoldenV1$' -update.
Closed periods are frozen increments under the append-only federation design. Sequence replay checks, exact retries and period-conflict handling belong to the receiver; this package validates each payload independently.
Org administration routes (#1421, #1422, #1423)
These routes are available in every aggregation mode outside synthetic/offline
replay. GETs accept root/node/personal credentials as specified below, via bearer
or session; unknown, repeated or undecodable query parameters give 400. Per D2,
a mutation sent with a bearer and no session cookie (a script) needs no CSRF
token and no Origin; a cookie-session mutation requires the server-held CSRF
token and exact Origin (or same-origin fetch metadata when Origin is absent).
Both require JSON content type; cookie plus bearer is 400. Every response,
including errors, is no-store. Read-only serve mounts the GETs and an explicit
403 refusal for claim redemption; other mutations are absent. Legacy
credentials and break-glass are denied. Every successful write arms the D1
admin-web flag in its own transaction.
| Method and path | Principal and request | Response |
|---|---|---|
GET /api/v1/org/tree?node= |
Root or node; omitted node defaults to the principal's binding | 200 {levels:[{level,name}],nodes:[{id,name,level,level_name,parent_id,kind}]}; subtree only |
PUT /api/v1/org/levels |
Root; {levels:[{level,name}]}; 1–6 distinct levels in 0–5; updates supplied level names |
200 {levels:[{level,name}]} |
POST /api/v1/org/nodes |
Root or managing node; {parent_id,name,level} |
201 {node:{id,name,level,level_name,parent_id,kind}} |
PATCH /api/v1/org/nodes/{id} |
Root or managing ancestor; {name} |
200 {node:{id,name,level,level_name,parent_id,kind}}; level-0 rename refused 409 |
POST /api/v1/org/nodes/{id}/move |
Ancestor of both parents; {parent_id} |
200 {node:{id,name,level,level_name,parent_id,kind}}; populated cross-org move refused 409 |
DELETE /api/v1/org/nodes/{id} |
Root or managing ancestor; no body (or {}) |
204; 409 unless empty |
GET /api/v1/org/people?node= |
Root or managing node; omitted node defaults to binding | 200 [{developer,node_id,path:[],status,left_on,created_by_node,created_at,last_upload_date,freshness,key_used,node_manager,claim_state,claim_expires_at,claimed_at,stated_leave_date,declared_viewers:[],manager,skip_level,works_viewers:[]}]; created_at/left_on are UTC dates; claim_expires_at is also returned for an expired code; stated_leave_date is the date entered on leave; declared_viewers and separate manager (string), skip_level (string), works_viewers (array) are root only; empty roles are returned as ""/[], preserving the declaration for an unchanged viewers PUT; non-root responses omit all four fields |
POST /api/v1/org/people |
Root or managing node; {developer,node_id} |
201 {developer,claim:{code,expires_at}}; code shown once, valid 72 hours; 409 exists anywhere in the organisation; 429 after --people-cap-per-day (default 25) creations per UTC day across the credential's branch |
POST /api/v1/org/people/{dev}/claim |
Managing node while subject has no active personal key; root for recovery; no body or {} |
201 {claim:{code,expires_at}}; cancels prior unused codes; recovery revokes old personal keys only on redemption |
POST /api/v1/claim |
Code alone, no bearer or admin-session cookie (other cookies ignored); {code}; JSON, HTTPS off loopback and exact Origin (or same-origin fetch metadata) required |
201 {credential_id,secret} once; unknown/cancelled 401; expired 410; consumed 409 “already claimed at — ask your admin”; failed codes count toward IP lockout (429); read-only 403 |
POST /api/v1/me/key/rotate |
Personal holder only; no body or {} |
201 {credential_id,secret}; replaces own personal keys atomically, cancels outstanding onboarding codes (a pending recovery code survives), preserves device keys; old sessions fail on their next request |
POST /api/v1/org/people/{dev}/move |
Root or node(manage) ancestor of both current and target nodes; {node_id:"..."} |
200 {}; server click closes and opens dated memberships for the person and aliases; same-org moves leave billing unchanged; cross-org moves follow the hierarchy import (UpsertHierarchy) rule: non-destination open seats close in the click's UTC month, and a destination seat opens unless the person has an unsuperseded deliberate end there (overlap in the click month when it opens); one person_moved audit with actor credential_id; keys and viewer declarations unchanged; history stays at its dated node; 404 unknown/hidden source or invalid/federated target, 403 visible source with destination outside scope, 409 left person or a non-root manager's move granting that manager new viewer access, 400 same node or invalid body; no date parameter |
POST /api/v1/org/people/{dev}/leave |
Root or managing node; {leave_date:"YYYY-MM-DD"} (today or past) |
200 {revoked:[credential_id],membership_closed_at}; both membership ledgers close, every key the person holds and every viewer key on their card revoke, and unused claims cancel at server click time; 403 (nothing written) if a managing node may not revoke one of those keys; 409 if it would revoke the last live root key; stated date stored as stated_leave_date and audited, never moves a figure |
GET /api/v1/org/audit?node= |
Root or managing node | 200 audit array with D10 named/free fields; non-root sees claim events in its subtree only; reuse refusals and all other actions root only |
GET /api/v1/me/audit |
Personal or node | 200 claim-event audit rows about own holder |
GET /api/v1/me/notices |
Personal or node | 200 [{id,tenant_id,developer,ts,kind,viewers_json,read_at}] about own holder |
POST /api/v1/org/managers |
Root or managing ancestor; {developer,node_id,capability} |
201 {key:{credential_id,secret}}; existing active holder, strict-descendant binding; manage or root-issued read |
GET /api/v1/org/keys?node=&developer= |
Root or managing node; node defaults to binding; developer filter must be visible | 200 public credential array described below |
POST /api/v1/org/keys |
Root or managing node; {holder,kind,label,capability?} |
201 {credential_id,secret}; kind upload or node (personal refused 400); node binding comes from holder membership |
POST /api/v1/org/keys/{cid}/reissue |
Root or managing node with mint and revoke authority; no body (or {}) |
201 {credential_id,secret}; upload/node only (personal refused 403); old revoked + replacement minted atomically; 409 if the holder is not an active member |
POST /api/v1/org/keys/{cid}/revoke |
Root or managing node with revoke authority; no body (or {}) |
200 {}; personal keys root only, managers 403 (use Mark-as-left); other leavers' keys included, and legacy keys with no placement for root |
GET /api/v1/me/devices |
Personal only | 200 own upload-key inventory; additionally exact last_data_at or null and manager-added devices carry added_by (opaque credential id) and added_by_label (the minter's developer id) |
POST /api/v1/me/devices |
Personal only; {label} |
201 {credential_id,secret}; own upload key only |
POST /api/v1/me/devices/{cid}/revoke |
Personal, own device; no body (or {}) |
200 {}; another holder's key, a non-device key and an unknown id give the same 404 |
POST /api/v1/me/devices/{cid}/reissue |
Personal, own device; no body (or {}) |
201 {credential_id,secret}; atomic rotation |
The key inventory is [{credential_id,holder,label,kind,capability,node_path:[], issued_at,revoked_at,used,last_data_date,freshness}]. Capability, revocation and
last-data date are nullable; paths are root-first names. Inventory never selects
or returns credential secrets, hashes, prefixes or lengths. Personal device
responses add exact last_data_at, and manager/root-added devices include
added_by and added_by_label; ancestor inventories contain dates only.
Freshness is Fresh (<24 hours), Stale (1–7 days), Silent (>7 days), or
Never. used includes persisted last-seen or data receipt, excluding sign-in.
People claim state is invited (with claim_expires_at), claimed (with
claimed_at), expired, or left; pre-claim legacy people can be uninvited,
and a cancelled code can show cancelled. Codes never appear in lists. Lists
derive expiry without writing; it is audited and notified once by the next
redemption, reissue or leave.
People status is active, not_counted, imported_unbound, or left;
creation starts not_counted. left_on and last_upload_date may be null.
Malformed bodies, unknown fields or trailing JSON give 400; wrong JSON content type gives 415. Missing/unknown keys give 401; wrong kind/capability and CSRF or Origin failure on a cookie mutation give 403. Hidden or missing targets give 404; a visible source move without authority over both parents, or a change to a key listed in the caller's inventory that it may not change, gives 403. Duplicate active labels (per holder and kind, and per binding for node keys), existing people, cycles, invalid tree level/depth, nonempty deletion, level-0 renaming, operations on already-revoked keys and reissue for an inactive holder give 409. Node removal also returns 409 while a leaver placed there holds any unrevoked key. Root's full key inventory retains revoked keys after their placement node is removed. A move to the current parent is a no-op with no audit row. A subtree with people cannot move to another level-0 org (409); its placement, dated membership and billing membership stay unchanged. Empty subtrees can move between orgs. Credential rate limits (60/minute, shared with sessions) and the persisted people cap give 429. Write lock contention gives 503 with Retry-After. Reads resolve one snapshot; writes re-resolve the verifier and authorize against the tree inside the same write transaction as mutation, audit and sticky activation. All successful writes are audited with opaque actors and D10 named/free detail separation.
Time to merge (#1342, v0.6.2)
GET /api/v1/time-to-merge is read-scoped, including on the read-only
server. It is a descriptive measure, not covered by verify. Calling it
never writes or changes a seal or the /scores response. For anonymized sealed
months, median and p90 are stored at seal time and never recomputed; counts,
including exclusion and contributor counts, are not published. Live windows
retain their existing statistics and counts. Its definition is pr_created_to_merged, labelled
PR created → merged (includes draft time): merged_at − created_at, using
both captured timestamps, including timestamps stored by backfill. It never
substitutes outcome ts for merge evidence. Quality, points, cost, labels and
correction eligibility do not weight or filter a duration.
In team/division modes, parameters and the sealed-read gate match /scores:
only ?period=YYYY-MM is accepted, with omission selecting the latest sealed
month. Free since, until and before bounds are refused with 400; no
sealer returns 503. Sealed figures use the score seal snapshot, with median
and p90 computed once inside the seal transaction. Every count is null with
null_reason: "sealed_counts_not_published"; exclusion reason counts are null
as well. Months sealed before these statistics were stored return null median
and p90 with null_reason: "sealed_before_ttm_stored", and are never backfilled.
The dashboard passes the same period as its /scores request.
The synthetic demo keeps the existing /scores window helpers: since,
until, and before (the legacy alias for until), with UTC whole-day bounds,
half-open [since, until) selection by outcome ts, default since 90 days
ago at UTC midnight, and omitted until meaning open-ended. Invalid/unknown
parameters return 400; retention violations return 422. This is an
org-wide read: repo, team and work_type are not accepted. Aggregation mode
is the server's existing setting, never a client mode parameter. Real-data
developer mode returns 403 before store reads. The synthetic demo uses the
team fold and echoes window.aggregation: "team"; its four synthetic authors
are below k=5, so its tile is withheld rather than publishing group figures.
The frozen wire shape is:
The following example describes a live window; sealed months use the null-count rules above.
{
"definition": "pr_created_to_merged",
"window": {"since": "2026-10-01", "until": "2026-11-01", "aggregation": "team"},
"groups": [{"team": "Engineering", "count": 10, "median_seconds": 14400, "p90_seconds": 86400, "null_reason": ""}],
"residual": {"team": "other", "count": null, "median_seconds": null, "p90_seconds": null, "null_reason": "authors<k"},
"total": {"count": null, "median_seconds": null, "p90_seconds": null, "null_reason": "withheld_with_group"},
"excluded": {"count": 0, "reasons": {"no_created_at_before_capture": 0, "no_created_at": 0, "no_merged_at": 0, "bot": 0}, "null_reason": ""}
}
The row key is team, matching /scores (also in division mode);
window.aggregation identifies its level. groups is always an array in label
order; residual is an object with key other only when the shared fold
actually emits that cohort, and is otherwise omitted. Omitted until is not
echoed. A group or residual with no included PRs has null figures with
null_reason: "no_prs", which does not withhold the total. For nonempty figures,
counts and both statistics are null with null_reason: "authors<k"
unless at least k distinct census-counted developers each authored an included
PR in that figure. k has the same MinKAnonymity clamp as /scores.
The endpoint shares the score window load, canonical aliases, dated membership,
census and PreFold/AggregateFolded rules. A group folded in /scores folds
into the same residual here; if /scores withholds all rows because its
residual is unsafe, this endpoint withholds them too. It may withhold more:
a named score group failing the PR-author floor keeps its row with null
figures. Its PRs never move into the residual. The residual independently
requires k counted PR authors. No org-wide duration total is implied by a sum
of group medians.
Strict protected org total. total has count, median_seconds,
p90_seconds and null_reason, with no team key. It is computed over every
included PR in the window, using the same statistic rules and k distinct
counted PR authors at org level. Below org-k, all three figures are null
with null_reason: "authors<k". An empty total instead has null figures
with null_reason: "no_prs". Otherwise, whenever a group or residual with
included PRs fails its floor, all three total figures are null with
null_reason: "withheld_with_group". An absent residual or a cohort with
zero included PRs does not withhold the total. No group failing the PR-author
floor is added to that residual. This
strict complementary suppression prevents subtracting a published total to
recover a hidden cohort, mirroring /scores (#593). A group's short-sample
p90 (n<10) is not a withheld cohort: its count and median are published, and
it does not alone suppress total. The total's own p90 is null with reason
"n<10" when its included n < 10.
The dashboard tile beside the headline says Time to merge · PR created →
merged (includes draft time). A published total shows median X h · N of M merged PRs measured, with N = total.count and M = total.count + excluded.count, both org-wide. M is this defined reporting denominator;
the exclusions can include push-only/API rows, so it is not a separate census
of GitHub PRs. A withheld total or excluded block explicitly says
Time to merge: withheld to
protect small groups, without numbers or a denominator. An empty window
with no exclusions says Time to merge: no merged PRs measured; malformed
or missing figures say Time to merge: unavailable. p90 appears on hover
and in off-screen screen-reader text for n ≥ 10. The tile is neutral, wraps at
390px, and states:
"smaller or later-created PRs merge faster; not a quality measure".
Statistics are unweighted whole seconds. The median requires n ≥ k and is the
middle value for odd n, or the arithmetic mean of the two middle durations
for even n. Durations retain subsecond precision through that calculation;
the result is truncated to integer seconds. p90 is nearest rank
sorted[ceil(0.9*n)-1], truncated to integer seconds. For n < 10, count and
median can be present but p90 is null with null_reason: "n<10" applying
only to p90. No mean duration is published.
excluded.count and its four reason counts cover the whole org only;
they never appear per row. The author set covers every outcome in the
population, including excluded evidence, using the canonical census counting
rule and excluding bots and unattributed identities. Below k counted authors,
or whenever /scores suppresses its residual, excluded.count and
excluded.reasons are null with excluded.null_reason: "authors<k". An empty
population with no included PRs or exclusions uses "no_prs" with the same
null fields; a published block has null_reason: "". Each excluded outcome
is counted once, in this precedence order:
bot: the same canonical bot classification as the census (captured GitHub type, fixed login list or[bot]suffix), including alias-carried marks.no_created_at_before_capture: a webhook/backfill PR with NULLcreated_atand outcometsbefore 2026-10-11, when commit840fa8daintroduced capture. This is the column's release date, not this installation's upgrade date; later upgrades classify missing evidence on or after that date asno_created_at. It does not invent a creation date.no_created_at: other NULL creation evidence, including push-only/API rows, and a PR whosecreated_atfell after its merge. Both the webhook and backfill store NULL in that case; the read also rejects inverted evidence.no_merged_at: creation evidence exists but merge evidence is NULL.
Pseudo-developer rows are dropped as on /scores. Webhook and backfill sources
are both included when both timestamps are present, including old merges
captured by a later backfill. Smaller or later-created PRs merge faster; not a
quality measure.
Pricing provenance additions (#1231, #1232)
POST /events accepts optional cache_write_evidence (nested or legacy; omitted means unknown). legacy with a nonzero 1h write count is refused. The receiver derives costs and basis; clients cannot declare a pricing snapshot or rule. Shippers preserve the evidence on current receivers and retry once without additive provenance/PR fields only for an exact unknown-field 400 from an older receiver. Other 400s retain their usual failure behavior.
GET /events appends pricing_basis, rate_snapshot and cache_write_evidence to JSON and CSV; the previous columns retain their positions. Bases include token_derived, guessed_estimate, caller_authoritative, aggregate_approx, legacy_aggregate and basis_unavailable; historical nulls export empty strings. Snapshot identities resolve inside the operator's database to immutable effective rates and a retained rule implementation.
/scores and each /scores/compare window add data_quality.aggregate_pricing when provider remainders exist in developer mode. It carries cutover, approximation/historical notices, row counts, ceiling_status and, where positive, possible_increase_micro. This is an original-rate highest-rate-per-class diagnostic, not proven overbilling. It bounds the retained rate effect on aggregate counts, with one aggregate rounding step; per-request rounding and unrecorded fees are outside that calculation, so it is not a provider-invoice bound. A full ceiling is unavailable for missing, unsupported or estimated original bases (or sum overflow); a partial known-rate amount does not bound the whole window. Anonymous modes always emit the same conditional notice with ceiling_status=not_shown and no amounts or populations. All read responses also carry constant X-TIER-Aggregate-Pricing policy text, including CSV/array views and older sealed bodies without pricing metadata. New sealed responses retain the emitted notice; old sealed bytes are not rewritten or overlaid with current metadata. The dashboard shows conditional policy text when metadata is absent. Request-level tier arithmetic, raw numeric columns, actual-paid reconciliation and completed-poller retry semantics are otherwise unchanged. See the policy and repair commands.
Per-issue report addition (#1273)
GET /api/v1/issues is a read-scoped, live developer-mode-only
report, also mounted by the read-only server. Both team and division modes return
403 before reading data, including on sealed installations. No issue-level
k-anonymity or tracker hierarchy is implied. Existing /scores routes and
response shapes are unchanged.
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.
Parameters are since, until, before (the existing alias for until), and
repo. Bounds use the /scores grammar (YYYY-MM-DD, YYYY-MM, YYYY), UTC
midnight and half-open [since, until) semantics. Omitted since defaults to
90 days ago at UTC midnight; omitted/empty until is open-ended, and its echo
is omitted. Invalid bounds, non-increasing windows, invalid repository slugs,
and unknown parameters (including sort=tier) return 400. Retention violations
return 422. repo is strict equality; repo_scope_excluded, when present,
discloses excluded unqualified records exactly as on /scores. All totals and
shares use the selected scope and window.
The report consumes the same loadWindow path and scoring engine as /scores,
with stored Event 3 policy and outcome projections read in one snapshot. Unresolved
policy/projections refuse the report (500, db error). The additive wire shape is:
| Field | Meaning |
|---|---|
since, optional until, optional repo |
Echoed UTC bounds and repository scope |
scoring_policy, correction_rule_version, rubric_version |
Shared policy stamp: legacy / legacy-v1 / 1, or event3 / event3-v2 / 2 |
label |
Literal attributed (lower bound) |
total_cost_usd |
All captured list-price spend in the window/scope, including manual cost rows; not invoice spend or a claim of complete capture |
attributed_cost_usd, unattributed_cost_usd |
Disjoint spend totals, summing to total_cost_usd |
unattributed_share |
Unattributed / total captured spend, a fraction 0–1; zero when total spend is zero |
optional data_quality |
The shared /scores zero-token and mixed-price signals, plus cost horizon (cost_coverage_start, cost_coverage_safe_since, window_predates_cost_capture, and optional source_coverage_start); same window and repo scope |
reconciliation |
Always an array, empty for an empty window; disjoint rows covering all spend |
Each reconciliation row has kind (issue or unattributed), repo, issue_id,
label, unattributed_share, cost_usd, weighted_points, outcome_n, sample_n,
flagged_outcomes, tier and score_status. Issue rows carry the literal
attributed (lower bound) label and the window's unattributed share beside their
figures. Unattributed rows carry label unattributed and retain the sentinel
issue_id as the reason (including unattributed:<reason>); an empty historical
issue ID is also unattributed. Such rows have no TIER. unqualified repository
identity (including historical empty repos) is retained as stored; it is never
assigned to another repo by a tolerant join for this partition. Cost is accumulated in integer micro-dollars before
conversion to USD; floating point JSON sums should be compared at that precision.
Rows are ordered by repository then issue ID, with unattributed rows last,
never by TIER. The union includes spend-only and outcome-only issues. All
spend tied to the stored (repo, issue_id) stays in that row's denominator, even
from developers with no outcome. Each outcome contributes once; Event 3's debit stays on the corrected
original's issue while the follow-up's own projected points stay on its own issue.
outcome_n counts raw outcomes and sample_n counts the shared engine's eligible
observations. Points are summed, then divided by summed issue cost / 1000.
The evidence floor is applied after summing, never per developer: at least
3 eligible outcomes, at least $5, and no zero-token flags. Only then is tier a
number with score_status: "scored" if repository identity is also unambiguous.
A row with a repo-blind identity (the same definition used by store.RepoMatch),
or a real-repo row whose issue ID has repo-blind spend or outcomes in the
selected window/scope, always has tier: null and
score_status: "repo-blind spend on this issue id". This status also covers
outcome-only ambiguity. The disjoint stored rows are unchanged: no blind spend
or outcomes are reassigned to a real repository. For example, $5 on acme/app#1
and $45 on unqualified#1 remain separate rows, and neither publishes a TIER.
Absent that ambiguity, unattributed rows have no score; issue rows with
zero-token flags have zero-token outcome; zero eligible accepted outcomes have
no score; other insufficient evidence has below evidence floor. Each withholds
TIER as JSON null, never a low numeric score. These partial denominators make issue TIER unsuitable for direct
comparison with an all-spend developer score. No feature/parent rollup or seal is
created by this endpoint.
tierd issues --server <url> [--since <date>] [--until <date>] [--repo owner/repo]
prints this report, cost-horizon and mixed-price disclosures, and its
reconciliation; --json prints the validated server response bytes, preserving
additive wire fields. A covered window explicitly reports
window_predates_cost_capture: false; a store with no captured cost has no horizon
to report. A single-price window omits mixed_price_versions.
It accepts --api-token (including @file) or TIER_API_TOKEN, allows the read
token, refuses redirects and returns nonzero on mode/auth/server/decode failure.
The dashboard's developer-mode per-issue table uses the same endpoint; it never
fetches this breakdown in team/division mode.
Live issue reports may add as_of and attribution_notice when recovery is used:
“scores are as of <ts> and may be revised as branches merge”. A reconciliation
row with recovered support adds attribution_layer: "read_time_recovery" and
read_time_recovery_cost_usd (omitted for a zero-dollar recovered portion).
This marks mixed rows as including recovery. Developer-mode /scores and its
per-issue developer detail add the same layer marker where applicable. All read
routes except GET /api/v1/report_manifest and sealed reads carry the
population-independent X-TIER-Attribution disclosure; stored
sealed bodies stay byte-identical. Raw event exports continue to report stored
attribution. See effective attribution and the read-only recovery receipt.
Feature detection
The supported feature-detection mechanism is the version field of
GET /api/v1/livez. It is the build version the binary was compiled with
(injected via -ldflags; falls back to "dev"). A client that needs to know
whether a server supports a given field or endpoint should read livez.version
rather than probing behaviour, except for the narrow events fallback below. livez is unauthenticated (a liveness probe must
not need credentials), so feature detection never requires a token.
Identifying a deployment: GET /api/v1/version
Feature detection asks "what can this server do?" — a different question from
"is this the build I published?" For the second, use GET /api/v1/version,
also unauthenticated and also mounted in read-only mode.
🔴 First released in v0.4.1. A server running v0.4.0 or earlier answers
404here — verified against the published0.4.0container image. The published v0.4.1 release binary answers200with its commit (measured 2026-09-25); the response below is the exact body it returned.⚠️
404on this path does not mean the server is unhealthy or the wrong build — it most likely means the build predates the route. On a v0.4.0 or older target, identify a deployment withGET /api/v1/livez, which has always carriedversion. Note that reports the release, not the build: two binaries from a moved tag share a version string, which is exactly why this route exists.
{
"version": "v0.4.1",
"commit": "ec6ff60fd72c6824c22d2c01e58cdc444d49fcce",
"modified": false,
"go_version": "go1.26.6",
"platform": "darwin/arm64",
"price_table": { "version": 9, "effective_date": "2026-07-26" }
}
commit is the revision injected at build time (make build, the release
binaries and the published container image set it); without that, it falls back to the vcs.revision stamp the Go toolchain
embeds. modified comes only from the stamp. On a binary built with
-buildvcs=false, or from a tree with no .git like the shipped container,
there is no stamp and modified is omitted, never false. Absent does NOT mean
clean (see GET /api/v1/version). commit is also carried on livez — additively,
so an existing liveness probe is unaffected.
🔴 Why version alone is not enough, and why price_table.version is a trap.
A tagged release reports the same version string however it was built, so two
binaries from a moved tag are indistinguishable by it; commit is what makes
"this deployment is the build I published" an assertion rather than a hope. And
price_table.version bumps only when prices change: measured 2026-08-06, a
deployment reported price table 9 while the newest source was also 9 — while
the running binary was a full release behind. It is useful next to the build
(it answers "which rates priced these numbers"), never as identity.
⚠️ A version check is only meaningful if it can say NO. Assert the expected
commit, and make sure your check fails when pointed at a different build —
otherwise "the version matches" is indistinguishable from "the check never ran".
Do not rely on the presence or absence of an individual response field for
feature detection at runtime: additive fields (below) can appear at any release,
and omitempty fields are absent whenever their value is empty even on a server
that fully supports them.
Price-table content identity (#713)
On GET /api/v1/scores and GET /api/v1/scores/compare — and in tierd score-log's JSON — the price_table block carries two digests alongside
version and effective_date:
| field | covers | shape |
|---|---|---|
table_hash |
the RESOLVED in-memory table | tierpt1:<64 lowercase hex> |
file_hash |
the raw source bytes of the price YAML | sha256:<64 lowercase hex> |
🔑 Why the resolved table, not the file. The server bakes provider-default
cache multipliers into every model at parse time, and those defaults come from
code, not from the YAML. Change the compiled-in Anthropic cache-read
multiplier and every cached-token cost in the system changes while the YAML is
byte-identical — a hash over the file would certify "same rates" across a real
rate change, which is worse than no hash. table_hash moves; file_hash does
not.
The pair runs the other way too: a comment-only edit to the price YAML moves
file_hash and leaves table_hash alone, so source-URL comments stay auditable
without re-identifying the prices.
What each answers.
versionanswers "which published revision of the reference table is this". It is a human-assigned label; two installs can both report9while one runs a--pricesoverride that moved a rate.table_hashanswers "do these two installs hold the same resolved price inputs". It deliberately excludesversionandeffective_date, so "content unchanged, version bumped" and "version unchanged, content changed" stay distinguishable.file_hashanswers "is this the same source document".
⚠️ The scheme tag is load-bearing — compare the whole string, never the hex
alone. tierpt1 names the CANONICALIZATION, and it will be bumped if the
serialization ever changes. A consumer that strips the tag cannot tell "computed
by a different scheme version" from "these two disagree about prices", and a
guard built that way would reject every install on the older binary the first
time the scheme is legitimately fixed.
🔴 These are UNKEYED digests, self-reported by the server. They are not a signature. Against an operator who controls the deployment they prove nothing — such an operator can change the table and report any hash they like. What they detect is what they are for: accidental drift between installs, an edit to the price YAML on a host whose binary you already trust, and a serializer that cannot tell two different tables apart. Do not build an attestation on them.
✅ Since #714 table_hash is also load-bearing at STARTUP, not only for
comparison. A deployment records the identity of every price table it serves and
refuses to open a database in which one version: integer would come to denote two
different tables. See
reference-price-table.md §9.
That does not strengthen any claim below — the digest is still unkeyed and
self-reported — it just means a mismatch now stops a boot instead of only informing
a comparison.
🔴 table_hash equality is NECESSARY, not sufficient, for comparing costs
across installs — and the omission that matters most is the binary. Equal
digests mean equal price inputs. The pricing logic is outside the digest
and cannot be inside it: which row a given model resolves to is decided by code
(model-name normalization, the host-qualified key, and the self-hosted size-class
patterns — that pattern list has already changed once, in #267, and altered
pricing). So two installs on different builds can report the same
table_hash and still price the same event differently.
Before comparing figures across installs, match all of:
| stamp | where | what it holds constant |
|---|---|---|
price_table.table_hash |
/scores, /scores/compare, /report_manifest |
the resolved price inputs |
commit |
GET /api/v1/version |
the pricing logic |
rubric.version |
/scores |
the outcome weights |
| aggregation mode + window | /scores |
what is being summed |
⚠️ Do not treat a published digest as a trust anchor. The value changes on every price-table edit, and no gate keeps a copy pasted into a document (this one included, which is why the sample above shows the shape rather than a value). Read it from the deployment you are asking about.
🔴 The digests are NOT on GET /api/v1/version, deliberately. That endpoint
is unauthenticated — identifying a build is a probe concern — and it returns
price_table: {version, effective_date} only.
An unkeyed digest over a low-entropy price table is a confirmation oracle:
someone who can guess a private --prices file confirms the guess in one hash,
and an install running the shipped table with one negotiated rate edited is
grid-searchable on that rate. Publishing that from an unauthenticated endpoint
would have pushed the mitigation onto ingress configuration that many
self-hosted installs do not have.
The convenience it would have bought — answering "same build and same prices" from one probe — is not needed: a third party verifying a published report reads the digests from that report's own manifest, never from a live endpoint. So this costs nothing and closes the oracle outright.
⇒ Read table_hash from /scores, /scores/compare, or
GET /api/v1/report_manifest (#715 -- the report manifest this paragraph
anticipated, now a real endpoint). Read commit from /api/v1/version. The two
questions have two sets of endpoints, and that is the intended shape.
Query parameters are STRICTLY validated on the scores endpoints (#590)
GET /api/v1/scores, GET /api/v1/scores/{developer} and
GET /api/v1/scores/compare reject any query parameter they do not implement
with 400, naming the offending parameter and listing what is accepted.
Matching is exact and case-sensitive: ?Repo= is not a synonym for ?repo=, it
is an error.
⚠️ This is a deliberate behavioural break, and it is recorded as one rather than filed under "additive". Before #590 these endpoints silently ignored unrecognized parameters, as
net/httpdoes by default. A client that sends an extraneous parameter and previously got200now gets400.
It was taken because the alternative is worse than a break. /api/v1/scores
accepted ?repo= and ignored it, returning a whole-installation aggregate
byte-for-byte identical to a correctly scoped one. A caller who believed they had
scoped a query received a figure spanning every repository, with no way to detect
it from the response. Adding the filter without this strictness would have left
the same trap one keystroke away -- ?repos=, ?Repo=, ?repo_id= would each
silently widen a query back to installation-wide while the caller's own assertion
passed, asserting nothing.
The invariant, stated once: "could not scope" must never share a response shape with "scoped, and this is the result." A silent wrong answer is worse than an error.
This extends to query strings the strictness the write endpoints have always applied
to request bodies (DisallowUnknownFields(), see the compatibility rule).
It is a request-side break, which is why it ships on /v1 rather than forcing a
/v2: rules 2 and 3 above govern the RESPONSE schema, where a consumer's parsing
breaks silently. A rejected request fails loudly at the caller, in the one place the
mistake can still be corrected.
What a client should do: send only documented parameters, and detect scoping
from the response (data_quality.repo_scope), never from the request it
sent. Feature-detect via livez.version as always.
The compatibility rule
JSON responses are additive-only. A field's semantics are frozen once shipped.
- Adding a field is additive. A new response field may be added at any release. Clients MUST ignore unknown fields.
- Renaming or removing a field is BREAKING. It is not a refactor. It
requires a new field name (additive) or a
/v2(breaking), never a rename-in-place. - Changing a field's SEMANTICS is BREAKING even when the type and name are
unchanged. If the number a field carries comes to mean something different
-- a different population, unit, scope, or definition -- you MUST either
introduce a new field name for the new meaning or bump to
/v2. You may not repurpose an existing field in place. This is the #187 rule: the demotion should have shipped the new meaning under a new key (it did --work_types) and the change to the OLD keys' meaning should have been announced (it was not). The narrowly scoped #1095 security exception below permits replacing raw healthz error text with classes in place. - Closed-set (enum) values are additive too. A new allowed value for a
string enum (
source,fidelity,weight_source,work_type,work_type_source,billing_mode, watcherstatus) may be appended; an existing value may not be renamed or removed. - CSV column order is append-only. The four exports
(
GET /events,GET /outcomes,GET /quality_events,GET /quality_history) carry a positional CSV contract. Columns are only ever appended at the end, never reordered or removed, and an existing column's VALUES never change shape (issue_idstayed"issue-42"whenrepoarrived in its own new column, #231). This restates the contract already enforced ininternal/api/export.go(eventsCSVHeader,outcomesCSVHeader,qualityEventsCSVHeader,qualityHistoryCSVHeader) and documented indocs/how-it-works.md. With Event 3 enabled, its conditional outcome columns precedeissue_rule; positional consumers must read by header name.
Request bodies are strict in the other direction: the write endpoints decode
with DisallowUnknownFields(), so an unknown request field is rejected 400.
Adding a new optional request field is additive (old clients omit it); making
a previously-optional field required, or adding a required field, is breaking.
Additive vs breaking at a glance
| Change | Classification |
|---|---|
| Add a new response field | Additive |
| Add a new endpoint | Additive |
| Add a new optional request field | Additive |
| Append a new CSV column at the end | Additive |
| Append a new enum value | Additive |
| Rename a response field | Breaking |
| Remove a response field | Breaking |
| Change a field's meaning/unit/scope in place | Breaking |
| Reorder or remove a CSV column | Breaking |
| Change an existing CSV column's value shape | Breaking |
| Add a required request field / make an optional one required | Breaking |
| Change a success status code | Breaking |
| Reject a previously-ignored query parameter | Breaking (see #590) |
| Change the DEFAULT VALUE of an optional query parameter | Announced -- behavioural, not a field break (see #746) |
Announcing a deprecation or a semantic change
When a field or endpoint is being retired, or a field's meaning is changing (which ships as a new field plus a deprecation of the old one), the change is announced two ways:
- The
Deprecation(RFC 9745) andSunset(RFC 8594) response headers on the affected endpoint.Deprecationcarries the date the field/endpoint became deprecated;Sunsetcarries the date after which it may be removed (a/v2or a removal). Together they replace the single RFC 7234Warning: 299header precedent onPOST /costs(deprecatingcache_write_tokens):Warningwas deprecated by RFC 9111 and is dropped by some intermediaries, so it is not a reliable deprecation signal.Warning: 299remains only for the one already-shippedcache_write_tokenscase for back-compat; new deprecations use theDeprecation/Sunsetpair. - An entry in the API changelog below, naming the field, the change, the release, and the issue.
A semantic demotion (a field's meaning narrowing or changing, like #187) MUST additionally:
- ship the new meaning under a new field name (never repurpose the old field), and
- document, in the old field's changelog entry and its Go doc comment, exactly what it now means and what a consumer should read instead.
Endpoint catalog
Auth scopes (see internal/api/handler.go):
- write -- requires the write/admin bearer token (
Authorization: Bearer). The read-only viewer token is rejected 403. - ingest --
POST /api/v1/eventsonly: the ingest token or the write token (#1144). The ingest token is rejected 403 on every other protected route. - read -- satisfied by EITHER the read-only viewer token or the write token.
- metrics --
GET /metricsonly: the metrics token or the write token, and indevelopermode also the read-only viewer token (#944). The metrics token is rejected 403 on every other route. - open -- no token (probes only; never exposes spend data).
When the server is started with no API token (loopback-only, fail-closed per
cmd/tierd), auth is disabled and all scopes are transparent.
Error bodies are uniformly {"error": "<message>"} with a 4xx/5xx status.
Write endpoints
Every POST, PUT and PATCH below requires Content-Type: application/json
(parameters such as charset are allowed) and answers 415 otherwise, in token mode
too (#893). Authentication runs first, so an unauthenticated request still gets 401.
A DELETE carries no body and needs no Content-Type.
POST /api/v1/costs
-
Scope: write. Success:
201 Created(fresh key, or an identical/ matching re-post), empty body -- or200 OK(a sanctioned override actually corrected a row, #346), body{"corrected": true, "old_cost_usd": <float>, "new_cost_usd": <float>}. -
Manual single-row cost import. Request:
costRequest--developer,issue_id,model(required);input_tokens,output_tokens,cache_read_tokens,cache_write_5m_tokens,cache_write_1h_tokens,cost_usd,source,fidelity,idempotency_key(optional);override,override_actor,override_reason(optional -- #346, see below);billed_to(optional -- #854, see below). -
Per row,
cost_usdmust be finite and in0..10000($10,000), including audited overrides. Every token field must be in0..1000000000000(1e12), after legacycache_write_tokensnormalization. An over-limit request returns400naming the limit and asking callers to split a large import across rows (#1082)./actual_spendand organization invoice limits are unchanged. -
cache_write_tokensis the deprecated legacy single-bucket field (superseded by the 5m/1h split, #55); when present and non-zero it is routed to the 5m bucket and the response carries the legacyWarning: 299header. -
sourceaccepts only"api"or omitted;fidelityaccepts"daily","estimated", or omitted ("realtime"is rejected -- reserved for automated capture). -
⚠️
issue_idmay not be the reserved unattributed sentinel (#466). The sentinel family -- the bareunattributedplus anyunattributed:<reason>sub-bucket -- is server-assigned: the collector and the proxy write it when they genuinely could not resolve an issue. A client supplying it now gets400; it was previously written silently. The check is case-insensitive and trims surrounding whitespace, soUNATTRIBUTED:mainis refused too -- ingest deliberately rejects more than the read side matches, because a case variant in the table would classify one way in SQL and another in Go. Ordinary ids that merely contain the word (unattributed-work,not-unattributed) are unaffected. Same rule onPOST /outcomes;POST /eventsis the documented exception. -
⚠️ A row for a provider whose org usage poller this server runs is refused
400unless it declaresbilled_to: "other"(#854). The Anthropic Admin and OpenAI Usage pollers bring the org's recorded usage for their provider up to the provider's own total, and they never subtract a/costsrow, so a manual row for that org's usage would be counted twice. Manual posting and the poller are alternatives for one org: use one or the other. The refusal applies to every row whosemodelis priced under that provider (claude-*with the Anthropic poller,gpt-*and the other OpenAI models with the OpenAI poller), with tokens or cost only, after the same case, whitespace and date-suffix normalisation the poller applies (claude-sonnet-4-20250514is refused too). Amodelthe price table does not recognise is admitted and is not counted by the startup warning, even when it names a polled model another way, such asanthropic/claude-sonnet-4orClaude Sonnet 4: the poller counted that usage under the canonical id, so if the row is the polled org's usage it is counted twice unnoticed. Post canonical model ids, the names in the price table.-
The remedy. The
400body names the provider and carries this text, which is also what the startup warning below prints. It isPolledProviderOverlapRemedyininternal/api/handler.go; that constant is the source, and this copy follows it:This server runs the org usage poller for this provider, which already counts the polled org's usage, so a manual row for that usage is counted twice. To post spend billed where the poller cannot see it (Claude Max seats, Bedrock or Vertex, another org), send "billed_to": "other"; do not post the polled org's own usage. For a row already stored without billed_to: if it is Claude Max, Bedrock, Vertex or another org's usage, no action is needed; if it has an idempotency_key and repeats the polled org's usage, correct it to cost_usd 0 with the audited override, sending the stored row's idempotency_key, developer, issue_id, model and fidelity with override=true, override_actor and override_reason. The override corrects the cost only: the row's token counts stay counted. A row with no idempotency_key, or whose stored fidelity is neither daily nor estimated, cannot be corrected through the product.
-
override: truewithoutbilled_tois admitted when a row already owns theidempotency_key: it corrects that row (the remedy above). When no row owns the key, the override would insert a new undeclared row, so it gets the same400and nothing is written. Whether a row owns the key is decided in the same write-locked transaction as the correction. -
billed_toaccepts only"other", or omitted; any other value is400, with or without a poller. A server with no poller for the row's provider accepts the field, stores it and otherwise behaves as before. -
The declaration is stored on the row (
billed_to, NULL when not declared) and appears in theGET /eventsexport and the DSAR export. It is not part of the keyed re-post comparison: a re-post that differs from the stored row only inbilled_tois the idempotent201, and the stored value is left as first recorded. So a row stored withoutbilled_tocan never gain one. -
⚠️ A keyed retry of a row stored before the upgrade now gets
400on a server running that provider's poller, if the retry omitsbilled_to. The row already exists and nothing is written either way; add"billed_to": "other"to the retry only if that spend really is billed elsewhere. -
Startup warning. For each running poller,
tierd servelogs oneWARNat startup, "undeclared manual rows that may be counted twice", with the number ofsource='api'rows that have nobilled_to, are for that provider's models, and fall on a UTC day that poller covered, their summed cost (cost_usd), and the remedy. A poller covers a day when it has stored a row for it. The day match is approximate, because a manual row's time is when it was posted, not the day the usage happened. Nothing is logged when the count is 0. The count never falls to 0 for legitimate Claude Max, Bedrock or other-org rows stored before the upgrade, because those can never gain abilled_to. -
Listing rows stored before the upgrade. Use either route. Both only read.
-
Over the API (developer aggregation mode only:
GET /api/v1/eventsreturns403inteamordivisionmode). The endpoint filters only bysince,until,cursorandlimit, so filter the rows yourself.TIER_URLis where your tierd is served (for examplehttp://127.0.0.1:8080) andTIER_TOKENis your read or write token (--read-tokenor--api-token):cursor= while :; do page=$(curl -fsS -H "Authorization: Bearer $TIER_TOKEN" \ "$TIER_URL/api/v1/events?since=2020-01-01&limit=10000&cursor=$cursor") || { echo "request failed; the listing is incomplete" >&2; break; } printf '%s\n' "$page" | jq -r '.events[] | select(.source == "api" and .billed_to == "") | [.ts[0:7], .model, .developer, .issue_id, .fidelity, .idempotency_key, .cost_micro] | @tsv' cursor=$(printf '%s\n' "$page" | jq -r .next_cursor) [ -n "$cursor" ] || break doneIt follows
next_cursorpage by page until the export is exhausted; the rows it lists can be on any page, so do not stop at the first. Each line is month, model, developer, issue id, fidelity, key (empty for an unkeyed row) and cost in millionths of a dollar: the fields the override needs. For the sum per provider per month, add upcost_microfor that provider's models in each month and divide by 1,000,000. -
In SQL (any aggregation mode), on the machine running
tierd, withDBset to the path you gave--db(default~/.tier/tier.db):Prerequisite: the separate
sqlite3CLI (sudo apt install sqlite3on Ubuntu/Debian; preinstalled on macOS).sqlite3 -cmd '.timeout 5000' "$DB" <<'SQL' SELECT substr(ts, 1, 7) AS month, model, COUNT(*) AS rows, SUM(idempotency_key IS NULL) AS unkeyed, SUM(cost_micro) / 1e6 AS usd FROM token_events WHERE source = 'api' AND billed_to IS NULL GROUP BY month, model ORDER BY month, model; SQLAdd up the rows for the provider's models (
claude-*for Anthropic; the OpenAI models for OpenAI) to get its sum for each month. This lists every undeclared manual row, not only those on days a poller covered.
-
-
-
⚠️
developermay not be the reserved unattributed sentinel either (#619). The same reserved string is the sentinel for two columns, and this is now the same rule on both -- one predicate backs both checks, so they cannot drift. A client supplying"developer": "unattributed"(orUNATTRIBUTED, orunattributed:main, or any of them with surrounding whitespace) now gets400; it was previously written silently.This half mattered more than the
issue_idhalf. TIER ispoints / (cost/1000). Forgingissue_idmoves a dollar between buckets inside your own denominator and leaves your headline score unchanged. Forgingdevelopermoves it out of your denominator entirely -- onto theunattributedpseudo-developer -- so your cost falls and your score rises.⚠️ Scope, stated plainly: this removes the deniable forgery, not the ability to raise your own score. TIER is single-tenant with one shared write token and a free-form
developercolumn, so posting"developer": "mallory-2"still moves your spend out of your own row. What the sentinel added was cover — it is indistinguishable from honest server-assigned spend and lands in a bucket nobody audits. Bindingdeveloperto the authenticating credential is the control that would close the general case, and it is separate work.Ordinary identities that merely contain the word (
unattributed-bot,not-unattributed) are unaffected, as isunknown, the real no-identity fallback the collector emits. Same rule onPOST /outcomes,POST /events,POST /actual_spend, both columns ofPOST /developer_alias, andPUT/POST /org_hierarchy. Unlikeissue_id,developerhas no allowlist anywhere, including/events: the producers that legitimately assign the developer sentinel (the org-level Anthropic-Admin and OpenAI-Usage pollers, and the proxy's own missing-header fallback) write in-process and never cross an HTTP boundary, so there is nothing on the wire to allowlist. -
Keyed re-post (
idempotency_keypresent): an identical re-post (same key, same cost, same(developer, issue_id, model, source, fidelity)) is idempotent --201, no new row, and the stored row is left exactly as first recorded, token counts included. A re-post with the same key but a differentcost_usdis rejected with409 Conflict(#295): the stored cost is immutable (#233), so the correction is refused rather than applied. Divergence is judged on the stored integercost_micro, so an honest retry whose float/FX/rounding jitter rounds to the same micro value is not a conflict. To change a recorded figure, use the sanctioned override below (#346): it replaces the stored cost on the same row and writes an audit row. ⚠️ Do not re-post under a newidempotency_keyto correct a figure. A new key is a new row, and every spend read adds both, so a correction from $100 to $80 is recorded as $180. A distinct key is right only for spend that is genuinely additional, such as when the earlier row is not yours. For the one row class the override cannot reach, see the pre-#82realtimenote below. -
A re-post whose cost matches but whose
(developer, issue_id, model, source, fidelity)differs from the stored row's is rejected409(#871) -- the same identity-mismatch response the override path below returns, and it likewise does not say whose row the key belongs to. Nothing is written for the caller and the stored row is unchanged. The cost is compared first, so a re-post whose cost AND identity both differ gets the divergent-cost409above. A re-post that changes only token counts is a silent no-op on those columns (they are immutable on conflict, #233, #871) --201, NOT a409. One consequence: a keyed re-post of a pre-#82 row whose stored fidelity is anything outsidedaily/estimated(realtimeis the live example; see below) now409s even at the same cost, because no request can state that fidelity; nothing is written either way. Reuse a key only for a genuine retry of the same event --idempotency_keyis a GLOBAL namespace across every producer (client-generated/costskeys and automatedMessageIdempotencyKey-derived keys from/events/JSONL/proxy alike), so reusing someone else's key is a real, not hypothetical, way to collide with a row that isn't yours. -
Sanctioned cost-correction override (#346, ruling C -- the follow-up to #295's ruling A above): set
override: trueplus requiredoverride_actorandoverride_reason(both non-empty,override_actor<= 256 chars,override_reason<= 1024 chars) to let a legitimate finance correction land on a DIVERGENT keyed re-post instead of 409ing.-
overridewith a missing/emptyoverride_actororoverride_reason, or with an emptyidempotency_key, is rejected400-- an override must be attributed and explained, never silent, and there is nothing to override without a key. -
override_actor/override_reasonset withoutoverride: trueis rejected400rather than silently ignored. -
The stored row's
(developer, issue_id, model, source, fidelity)must match the request's -- a mismatch is rejected409(a DIFFERENT status detail than the plain divergent-cost 409, but the same status code; the plain path returns this same response on a same-cost mismatch, #871). The 409 body deliberately does not echo which identity the key actually belongs to -- defense in depth against a BLIND collision, not a secrecy guarantee (see the stated trust model below). That tuple is the set of stored columns a/costsrequest can vary, exceptbilled_to(#854), a declaration that is deliberately not compared;repo,host,billing_mode,session_id, andtsare forced by the endpoint and are therefore NOT compared.idempotency_keyis a global namespace (see above), so this is a real collision case, not a theoretical one: without this check, reusing a copy-pasted key under a different identity could rewrite the WRONG row's cost. -
⚠️ One row class cannot be corrected through this endpoint at all.
fidelityis INSERT-only, and #82 narrowed/coststodaily/estimated/omitted -- so a pre-#82source='api'row that still carriesrealtimecan never be matched: stating it is a400, omitting it defaults toestimated, and both mismatch. Every possible request409s. No API route ortierdcommand corrects or deletes a single row (DELETE /api/v1/developer/{id}erases everything that developer owns, which is not a correction), so the fix is made directly in the database. Do not delete the row and re-post it:/coststakes no timestamp and stamps the new row with the time it arrives, so the spend would move from its original date to today. Re-posting under a new key without removing the old row would add the new figure to the old one.Instead, change the cost on the row itself, the same one-column change the override makes. It keeps the row's date and every other column. Run it on the machine running
tierd; it is safe whiletierd serveis running. SetDBto the path you gave--db(default~/.tier/tier.db), then back up and look the row up by its key:DB=~/.tier/tier.db tierd backup --db "$DB" --out ~/tier-before-cost-fix.db sqlite3 -cmd '.timeout 5000' "$DB" <<'SQL' SELECT id, developer, issue_id, cost_micro, fidelity, ts FROM token_events WHERE idempotency_key = '<old key>' AND source = 'api' AND fidelity = 'realtime'; SQLReplace
<old key>with the key before you run it. TheSELECTmust show exactly the one row you mean to change. Note itsid(a whole number).cost_microis the cost in millionths of a dollar, so $80 is80000000. Then change that row by itsid, never by pasting the key again, with theidand the new figure in place of<id>and<new cost_micro>:sqlite3 -cmd '.timeout 5000' "$DB" <<'SQL' UPDATE token_events SET cost_micro = <new cost_micro> WHERE id = <id> AND source = 'api' AND fidelity = 'realtime'; SELECT changes(); SQLIt must print
1. A0means theidwas not arealtimeapirow, and nothing changed. Thesourceandfidelityterms are there so a mistypedidcannot touch a captured row. Every spend read sumscost_microdirectly, so the new figure shows at once with nothing to rebuild. Unlike the override, this writes nocost_correction_auditrow, so record who made the change and why somewhere you keep. The backup is your copy of the old figure. Changing the row also changes the digest of every published report whose window covered it, so atierd verify-reportmanifest saved before the change will no longer match. -
When it actually corrects a divergent row: the UPDATE touches only
cost_microon that one row (token counts, model, source, fidelity, price_version, billing_mode are all left exactly as first recorded -- never a last-writer-wins upsert of the whole row), and an append-only audit row (old -> new, actor, reason) is written to the (internal, unexposed via any GET route today)cost_correction_audittable.200. -
When there is nothing to correct (fresh key, or the cost already matches): behaves exactly like the non-override path --
201, no audit row. -
Reprice-safe.
tierd reprice --commitrecomputescost_microfrom token counts, and it never reprices asource='api'row -- a manual import's cost is the caller's authoritative figure and re-deriving it from token counts (which may not exist) yields$0.00. Corrections therefore survive a reprice sweep, and the sweep reports how many rows it protected rather than omitting them silently.
-
-
Stated trust model for the override. Read this before treating the identity check as an access control:
- The write scope is a single global bearer token with no subject.
Nothing binds the authenticating principal to the
developerfield, and notenant_idcolumn exists anywhere to put such a binding in -- TIER is single-tenant by design today. - The identity check therefore prevents an accidental key collision
from landing a correction on the wrong row. It does not prevent a
holder of the write token from deliberately correcting a row that is not
theirs, because the tuple it compares is not secret: the read-scoped
GET /api/v1/eventsexport publishes everyidempotency_keyalongside its full identity tuple. - Deliberate misattribution by a write-token holder is out of scope until an identity layer exists (#65).
- What the endpoint does structurally guarantee: it forces
source="api"on every request and the identity check comparessource, so the override can only ever touch a row whose storedsourceisapi-- the manual-import lane. Automatically captured spend (every non-apisource:jsonl,proxy,codex-rollout,opencode,muse, legacy storedcopilot-apirows, and the org pollersanthropic-admin,openai-usage,copilot-billing, andcursor-admin) is unreachable from this endpoint. override_actoris a self-asserted claim, not a verified identity. It is unvalidated free text the caller chooses, written verbatim into the audit ledger and the operator log. The audit trail records who the caller says they are. Nothing checks it against the credential that made the request, and nothing can while the write scope has no subject.cost_correction_auditrefusesUPDATEat the schema level (#604), so no code path can silently rewrite a recorded correction -- a mutation would have to drop the trigger first.DELETEis deliberately not refused -- erasure and retention are lawful deletes. This is not tamper-proofing against someone holding the database file.
- The write scope is a single global bearer token with no subject.
Nothing binds the authenticating principal to the
-
503+Retry-After: 1when another writer holds the database write lock. Retryable, and nothing landed -- the request runs in one all-or-nothing transaction that takes the write lock before it writes, so on503no row was inserted, no cost was corrected and no audit row was recorded; retry to complete the request. Distinguish it from the permanent statuses: a409means the key genuinely conflicts (divergent cost, or an identity mismatch) and retrying is pointless, and a500means an unexpected store failure -- including a read-only or full database, which is deliberately NOT reported as503because it will never clear on its own. Only the503should be retried automatically. ⚠️ Since #610 this applies to the WHOLE endpoint, and that is a behavioural change on the plain path. Both halves --override: trueand the plain insert, keyed or unkeyed -- now take the write lock before they write, bounded at the same 250ms. Before #610 only the override half did: a plain post that lost the race blocked for the DSN's full 5000ms and then answered500with noRetry-After, so one URL called the same transient condition retryable or permanent depending on theoverridefield. Two things move for a plain post, not one. A500after ~5s becomes a503after ~250ms -- and, because the endpoint now gives up at 250ms instead of waiting out 5000ms, contention that used to resolve into a201can now return503as well. The endpoint no longer waits on the client's behalf; the retry is the client's. That is the deliberate trade: with a single write connection, a request that waits 5s does not stall itself, it stalls every other in-flight request behind it.
POST /api/v1/events
- Scope: ingest or write (#1144). Success:
201 Created, body{"accepted": <int>, "rejected_cross_developer": <int>}. - Cross-developer key collisions leave the original row unchanged and increment
rejected_cross_developer; the server logs a WARN and the shipper warns without failing or retrying. No migration; global idempotency stays unchanged. - Bulk repo-aware ingest (array body).
acceptedcounts processed events excluding cross-developer rejections, not rows newly created (coherent original-basis promotion absorbs replays). Per-event fields mirrorcostRequestplusrepo,session_id, and a required RFC3339timestamp. Since #233 the server reprices token counts with its own price table; the clientcost_usdis retained only as a cross-check. - Each token field has the same 1e12 cap as
/costs; both clientcost_usdand server-resolved cost must be <= $10,000 per event. Over-limit events return400naming the limit and the split-across-rows remedy; the whole batch is refused. Capture and reprice clamp oversized costs, preserve token counts, and retaincost_clamped=1clamp history (also in DSAR); the counter counts capture attempts and committed reprice changes. Existing data migrates without this ceiling. Exact integerSUM(cost_micro)is unchanged (#1082). - ⚠️
issue_idfollows an ALLOWLIST here, not the/costsrule (#466). This endpoint is the JSONL collector's own transport, and the collector legitimately assigns the unattributed sentinel when a message resolves to no issue. It therefore accepts the five exact canonical spellings --unattributed,unattributed:main,unattributed:detached-head,unattributed:branch-without-issue,unattributed:foreign-repo(#823) -- and400s every other member of the family, every case variant, and every near-miss. Anunattributed:foreign-reporow must omitrepo(it is stored asunqualified, so no foreign repository name is kept); any otherrepovalue is a400that does not echo it. Why the split rather than one rule: the endpoint validates all-or-nothing, so one rejected event fails the whole batch; the shipper treats4xxas terminal with no retry; and it is stateless, so the next run rebuilds the identical batch and fails identically. Applying the strict/costsrule here would be permanent 100% capture loss for anyone who has ever committed onmainwithout an issue, including their well-formed attributed events. hostis OPTIONAL and is a PRICING INPUT (#719). Omitted, an event prices at the model-only rate -- exactly how every pre-#719 client behaves, so adding the field breaks no existing integration. Supplied, the server prices at the host-qualified rate<model>@<host>when the active table has one. It exists because a source can have a rate that lives ONLY under a host key: theopencodecollector stamps its provider id (zai-coding-plan) as the host because #712's GLM override rates are keyed on it (the embedded table has carried model-onlyglm-5.3rows only since v10, #786). Before this field those events re-priced at the guessedself-hosted-mediumfallback ($0.50/M) with no signal at all -- the pricing-divergence detector compares the server's figure against the shipper's, and the shipper's was the same guess. ⚠️ It is client-controlled input selecting a price-table key, so a caller can choose which audited rate its tokens bill at. That is the same latitudemodelalready gives, and it sits inside this endpoint's existing trust model (the bearer token is attribution-grade; deliberate misattribution is out of scope until an identity layer exists, #65). The endpoint enforces the SHAPE: the 256-char identifier cap, andComputeCostHost's rejection of amodelstring containing the host separator, so a forged"model@host"cannot bypass the host lookup. ⚠️ Version skew: the endpoint usesDisallowUnknownFields, so a shipper sendinghostto a pre-#719 server 400s the whole batch (all-or-nothing, and a 4xx is terminal with no retry). The field isomitempty, so a jsonl/codex batch is byte-identical to what a pre-#719 shipper sent and is unaffected; only a batch actually carrying a host is at risk, and it fails loudly.pr_numberandbranch_hashare OPTIONAL (#1074).pr_numberis a positive integer; zero/negative values are a400.branch_hashis exactly 64 lower-case hex characters; other shapes are a400. Omitted/null fields store NULL. The hash is SHA-256 of canonicalrepo + "\x00" + branch, using the full name withrefs/heads/removed. Default/unknown branches andunqualifiedrepositories do not produce a hash. The shipper sends both fields whenever set. Only an HTTP400whose error body containsjson: unknown field "pr_number"orjson: unknown field "branch_hash"resends that batch once with both fields stripped and omits them for that server for the rest of the process. Other400s,5xx, timeouts and network errors follow the existing error/retry path and cache nothing; a2xxnever strips fields. There is no capabilities route; ingest-token scope remainsPOST /api/v1/eventsonly. Old servers accept the stripped batch but retain neither field; old shippers omit both fields even against new servers. At insert, only an unattributed event in a real repo and an unsealed month resolves through exactly one real-issue outcome on(repo, pr_number). Itspr-number-at-insertrule is server-owned and rejected as client input. Recording a merged PR fills eligible unattributed, unsealed(repo, pr_number)rows with the same rule. Branch matches on the same repo and pseudonymised head hash credit only inside[max(previous merge, PR opened − 7 days), merge], with server-ownedbranch-at-merge; overlapping recorded windows are skipped. Both rules export verbatim in event JSON/CSV and are rejected as client input. Branch reuse bounds later credit at the previous merge. Guessable branch names can be recovered from the hash. API outcomes have no head-ref field and leave the hash NULL; webhook/backfill set it only with head-ref evidence.issue_ruleis OPTIONAL (#1296): client-asserted extractor provenance. The closed set isbranch_first_numeric,branch_tracker_shape,body_close_directive,body_tracker_shape,body_bare_hash,commit_close_directive,commit_tracker_shape,branch_first_slot,branch_configured_key,body_configured_key,body_relation_refandcommit_configured_key. In v0.6.1 (#1297), new rows carry strict rule names from the strict issue-reference grammar; stored attribution is not rewritten. An absent or unknown value is stored as NULL; unknown values cause one warning without logging the supplied value. This assertion is never copied into outcome provenance or used to upgrade a row to strict. Upgrade the server first: the shipper reuses its single-resend fallback on an exact unknown-field HTTP 400, omitting the rejected field for that server.attribution_ruleis OPTIONAL (#823): the rule that assignedissue_id, empty or one of the closed setstore.AttributionRules()(branch,worktree-cwd,worktree-toolpath,carry). Omitted, the row stores no rule -- exactly how every pre-#823 shipper behaves.pr-number-at-insertandbranch-at-merge(#1074) are server-assigned, never client-sent. Any other input is a400naming the allowed set; the refused value is not echoed. Stored on insert only; a replay never rewrites it. Same version skew ashost: a pre-#823 server400s a batch that carries the field, so upgrade tierd before the shippers. Claude Code capture usescarryonly forisSidechain:truegroups (#1016); parents never carry. Worktree attribution stays opt-in and off by default. Activation requires recorded gates, operator security rulings and an explicit operator decision. No new accuracy figures have been measured. Historical rows retain the rule recorded by their original collector.- ⚠️
developergets the STRICT rule here, with NO allowlist (#619). The asymmetry withissue_iddirectly above is deliberate, not an oversight: the local collectors this wire admits (sourcemust bejsonl,codex-rollout,opencodeormuse) label every event with--developeror the OS username, whose own no-identity fallback isunknown-- neverunattributed. The producers that do assign the developer sentinel are the org pollers, which are excluded from this endpoint twice over (their sources are not shippable, and they write in-process rather than over HTTP). There is therefore nothing legitimate to allowlist, and an allowlist would only make forging as effective as honesty.
POST /api/v1/outcomes
- Scope: write. Success:
201 Createdon insert,200 OKon duplicate. - Response
outcomeResponse:status("created"or"duplicate"; always present) plusweight_source,weight,work_type(omitempty). Dedup is onmerge_commit_sha; a replay returns200withstatus: "duplicate"and echoes the stored row's weight/source/work_type. - ⚠️
issue_idmay not be the reserved unattributed sentinel (#466) -- the same case-insensitive rule asPOST /costs,400on any member of the family. This is the path where forging actually pays: an outcome on the sentinel earns weighted points whose cost no work-type segment denominator ever sees, inflating the numerator rather than merely shifting a denominator. The GitHub webhook derives ids viaissueref, which can only emit#\d+/ABC-123shapes, so no legitimate producer is affected. - ⚠️
developermay not be the sentinel either (#619) -- same rule,400. An outcome filed against theunattributedpseudo-developer credits weighted points to a pool of spend that by construction has no owner. The webhook takes its developer from the signed GitHub payload's PR author login, so no legitimate producer is affected here either.
POST /api/v1/actual_spend
- Scope: write. Success:
201 Created, empty body. - Finance per-developer invoice total. Request:
developer,period(YYYY-MM),actual_paid_usd. Rows accumulate; negatives are accepted as credit memos. - ⚠️
developermay not be the reserved unattributed sentinel (#619) --400, same case-insensitive rule asPOST /costs. This ledger is the tier-1 invoice input to Spend Leverage, so it is a denominator by another name: posting your own invoice under the sentinel drops your actual-paid out of your row exactly as forging/costsdrops your metered cost.
POST /api/v1/org_actual_spend
- Scope: write. Success:
201 Created, empty body. - Org-level invoice total (one contract covering N developers). Request:
org,period(YYYY-MM),actual_paid_usd. Same accumulate/credit-memo semantics. orgmust be the exact string you set asorgonPOST /api/v1/org_hierarchyorPUT /api/v1/org_hierarchy/{developer}. The allocation joinsorg_actual_spend.orgtoperiod_membership.org(which the hierarchy write populates) byte-for-byte — no trimming, no case folding, on either write path (internal/store/store.go,actualSpendCTE;upsertHierarchyTx). An invoice posted underAcmewhen the hierarchy saysacmeallocates to nobody: it is accepted with201, but no developer receives a slice and Spend Leverage keeps reading—.
GET /api/v1/org_actual_spend
- Scope: write (finance audit read; NOT granted to the viewer token).
- Success:
200 OK,orgActualSpendResponse:since(string, echoed window lower bound)orgs(array of{org, period, actual_paid_usd, entries})
?org=filters to one org;?since=sets the window.actual_paid_usdis the net (source-scoped sum) roll-up per (org, period);entriesis the row count behind that net.
POST /api/v1/developer_alias
-
Scope: write. Success:
201 Created, empty body. -
Maps a raw identifier to a canonical developer. Request:
alias,canonical(both required). Chain/self-map violations are400. -
⚠️ Neither
aliasnorcanonicalmay be the reserved unattributed sentinel (#619) --400on either, same case-insensitive rule. An alias is a retroactive rename of the identity space: the score join resolves every stored developer through this map before aggregating, so without this guard the checks on/costs,/events,/outcomesand/actual_spendcould all be bypassed in one hop. Both directions are refused because they are two different attacks:{"alias": "alice", "canonical": "unattributed"}folds alice's whole history into the pseudo-developer (self-dealing), while{"alias": "unattributed", "canonical": "bob"}dumps every org-poller aggregate and every proxy-unresolved dollar into bob's denominator (sabotage). -
503+Retry-After: 1when another writer holds the database write lock. Retryable — it is a lost race for SQLite's single writer, not a bad request and not a broken database. Distinguish it from the400s above: those are permanent and retrying cannot help. A500here still means an unexpected store failure, including a database that cannot be written at all (read-only file, full disk) — deliberately NOT reported as retryable. -
The alias is joined to its canonical developer for every window, but its team placement changes only from the write on (#914): the write dates the change by the server clock, and events recorded under the alias before it stay in the team the alias was in then.
409when the server clock reads earlier than a membership row the write would close.
DELETE /api/v1/developer_alias/{alias}
- Scope: write. Success:
204 No Content;404if not mapped. - Team placement changes from the delete on (#914), as for
POST.409as forPOST;503+Retry-After: 1when another writer holds the write lock.
GET /api/v1/developer_alias
- Scope: write (admin; discloses the identity map).
- Success:
200 OK,{"aliases": {<alias>: <canonical>, ...}}.
PUT /api/v1/org_hierarchy/{developer}
- Scope: write. Success:
200 OK, the storedHierarchyRow:{developer, team, division, org}. Request body:{team, division, org}. - A changed team or division is dated by the server clock (#886); a
valid_from/valid_tofield is refused400, like any unknown field.409when the server clock reads earlier than the developer's current membership began (the host clock stepped back): nothing was written, and the request succeeds once the clock is right. 503+Retry-After: 1when another writer holds the database write lock. Retryable — it is a lost race for SQLite's single writer, not a bad request and not a broken database. A500here still means an unexpected store failure.
POST /api/v1/org_hierarchy
- Scope: write. Success:
201 Created,{"accepted": <int>}. - All-or-nothing bulk import (array of
{developer, team, division, org}). Every row is dated by the same server instant (#886);409exactly as on thePUTabove, with nothing written. 503+Retry-After: 1when another writer holds the database write lock. Retryable, and NOTHING was written — the batch is one transaction, so the whole request is safe to replay verbatim. A500here still means an unexpected store failure.
GET /api/v1/org_hierarchy
- Scope: write (discloses the full developer->team map).
- Success:
200 OK,{"hierarchy": [{developer, team, division, org}, ...]}.
POST /api/v1/period_membership/{developer}/end
- Scope: write. Success:
200 OK,{developer, org, period_end}(the applied end record). Request:{org, period_end}. 503+Retry-After: 1when another writer holds the database write lock. Retryable. Distinguish it from the400this route also returns whenperiod_endprecedes the membership's start: that one is permanent and retrying cannot help. The503is classified FIRST precisely so a transient lock conflict is never reported as bad input.
DELETE /api/v1/developer/{id} (GDPR Art. 17 erasure)
- Scope: write. Success:
200 OK,{"deleted": {<table>: <count>, ...}, "total_deleted": <int>};404when nothing matched (which makes a repeated erasure idempotent). Each count is the rows this erasure changed in that table. Reported tables includeorg_claimsandviewer_notices.deleted.watcher_checkpoint, and sototal_deleted, counts rows tombstoned plus rows deleted (#919).deleted.sealed_personis always present and counts sealed person keys tombstoned, never deleted (#913, #914), including keys of a canonical id the person's alias was linked to when the period was sealed, which another person may hold now (#975);total_deletedincludes it, so an erasure that only tombstones sealed keys returns200, not404.deleted.canonical_id_historyis always present and counts history rows deleted, including a row recording that another person's alias once pointed at the erased id (#975). Available in team-aggregation mode (compliance tooling, not a reporting surface). 503+Retry-After: 1when another writer holds the database write lock. Retryable, and the erasure did NOT happen — the transaction is all-or-nothing, so nothing was partially deleted; retry to complete the request. A404means there was nothing to erase; a500means an unexpected store failure. Only the503should be retried automatically.
GET /api/v1/developer/{id}/export (GDPR Art. 15 access)
- Scope: write. Success:
200 OK,store.DeveloperExport:{developer, identifiers, token_events, outcomes, actual_spend, org_hierarchy, period_membership, hierarchy_membership, quality_events, quality_history, repo_repair_audit, push_outcome_commits, push_outcome_audit, developer_alias, watcher_checkpoint, correction_declarations, corrective_relations, correction_audit, correction_assessments, correction_allocations}(each a row array grouped by table).push_outcome_commitsandpush_outcome_audit(#849) are the push-capture commit ledger and its audit; acommit_shastartingpre-ledger:is a marker, not a commit. Apush_outcome_commitsrow'spush_order(#938) is its ownership sort key. The audit's storedcommit_developer(another developer, the author of that commit) is left out.hierarchy_membership(#886) rows are{id, developer, team, division, valid_from, valid_to}; the storedwritten_bycredential fingerprint is left out.outcomesrows carryauthor_type(#856): the PR author's GitHub account type (User,Bot,Organization,Mannequin) as the webhook recorded it, ornullwhen not captured.404when the developer has no data. Available in team-aggregation mode.
Read endpoints
GET /api/v1/admin/shared-token
- Scope: admin credential. Absent from read-only serving. Success:
200 OK;Cache-Control: no-store. - JSON keys:
accepted_7_days,refused_7_days,accepted_30_days,refused_30_days(integer request counts), andlast_seen_day(UTC YYYY-MM-DD or empty string). - Windows include today and the preceding 6/29 UTC days;
last_seen_dayis lifetime last use. Refusals flush every 60 seconds and at graceful shutdown.
GET /api/v1/scores
- Scope: read. Success:
200 OK,scoresResponse. - Query in developer mode:
?since=(YYYY-MM-DD / YYYY-MM / YYYY; default: the start of the UTC day 90 days back, #746),?until=(or?before=) exclusive upper bound,?team=,?work_type=,?repo=. Any other parameter is a400-- see strict query-parameter validation. - In team/division mode, only
?period=YYYY-MMis accepted (omit it for the newest sealed month). Any other parameter is a400, including the developer-mode filters above. ?repo=(#590) narrows the response to ONE repository. The value must already be slug-shaped: two or more/-separated segments, no scheme and no host (acme/alpha, orgroup/sub/projfor a nested GitLab path). It is then canonicalized exactly as storedrepovalues are -- lowercased, a trailing.gitand surrounding/trimmed -- soAcme/Alpha.gitmatchesacme/alpha.- A value that cannot canonicalize at all is a
400: fewer than two segments, an embedded URL, illegal characters, over-length, or the reservedunqualified. - ⚠️ A well-formed but non-existent slug is NOT a
400.acme/alpahscopes normally and returns an empty window, and a host-qualifiedgithub.com/acme/alphais accepted as a legal three-segment slug rather than stripped toacme/alpha(scheme/host stripping belongs to the collector's write path, not this filter). So the400buys "that is not a slug", not "you mistyped a repository name". Distinguish an empty result from a typo by reading the echoeddata_quality.repo_scope, which reports the canonical slug actually queried. ?repo=with an empty value is treated as unscoped and returns an installation-wide read; fleet-wide is spelled by OMITTING the parameter. It stays distinguishable because norepo_scopekey is emitted -- assert on that key.- Scoping is STRICT. Only rows naming that exact repository are counted. Rows
carrying the reserved
unqualifiedsentinel -- recorded by producers that structurally cannot know a repository, such as the reverse proxy -- are excluded, never folded in. Including them would attribute every repo-blind row in the installation to whichever single repository was named, which is the same over-counting defect?repo=exists to prevent, inverted. - Strictness can therefore under-count, so it is disclosed rather than left
silent: see
data_quality.repo_scope_excluded. A scoped figure over a window containing repo-blind rows is a lower bound, not a total. unqualifieditself cannot be selected as a scope. Those rows are surfaced as a disclosure, not offered as a queryable population.spend_leverageandactual_paid_usdare suppressed to0under a scope, and the suppression is declared (data_quality.spend_leverage_suppressed). Actual spend is what the organization PAID a vendor over a period; it carries no repository and cannot be divided by one without inventing an allocation. Dividing it by one repository's list-price cost would inflate leverage by roughly the installation-to-repository ratio. The keys remain PRESENT and read0; read thespend_leverage_suppressedflag, not the zero.- 🔴
?repo=is REFUSED with a400in any anonymized (team/division) aggregation mode. Scoping narrows the cohort before the k-anonymity floor is applied, so a repository only one person works in can shrink a group belowkand expose an individual's figures through the residual bucket -- the exact disclosure those modes exist to prevent, and the same reason?team=is not honored there. It is rejected rather than ignored: silently dropping it would return an installation-wide aggregate that looks scoped. - 🔴
?work_type=is REFUSED with a400in any anonymized (team/division) aggregation mode (#864). An anonymized install publishes ONE breakdown per window: its group rows and the grand total. A second breakdown differences against the first to a group belowkeven when every published row haskpeople (measured: pooled team rows minus work-type totals recovered a 3-person group's $9.21). A value that is not a work type is still the ordinaryinvalid work_type400. Developer mode honors the filter as before. - Query parameters are parsed STRICTLY, not via a lenient decoder: a malformed pair
(bad percent-encoding, or a
;-- which Go does not accept as a separator) is a400, and so is a REPEATED parameter. Both would otherwise be silently dropped or silently resolved to the first value, widening the result while looking filtered.
- A value that cannot canonicalize at all is a
- Fields:
-
since(string) -- echoed window lower bound (UTC calendar day). Since #746 this is exact rather than approximate: the default bound is snapped back to the start of its UTC day, so the day printed here is the day the window actually opens on. Before that it read2026-05-31for a window that began at2026-05-31T05:00:58Z. -
price_table({version, effective_date, table_hash, file_hash}) -- always present price provenance stamp (#233, extended by #713).table_hash("tierpt1:<64 hex>") is a digest of the RESOLVED in-memory table;file_hash("sha256:<64 hex>") is a digest of the raw source bytes. See content identity below. -
rubric({version}) -- always present canonical weight-rubric stamp (#239,scoring.RubricVersion), the weight-side analogue ofprice_table. Aweighted_pointscount (hencetierandcost_per_point) is comparable across responses ONLY whenrubric.versionANDprice_table.versionboth match. See docs/rubric.md for the versioned rubric and its "what you may / may not compare" rules. There is deliberately no absolute good/ok/poor band. -
total(teamScoreJSONor absent) -- rollup across all developers in the response. POOLED back-compat summary (#187): NOT a cross-type ranking. Seework_typesfor the authoritative within-category comparison. -
developers(array ofdeveloperScoreJSON) -- per-developer rows in developer mode; an explicit empty array in any anonymized mode (team/division). Same pooled caveat astotal. -
aggregation(string,omitempty, #270) -- discriminator naming the ANONYMIZED grouping level whose rows populateteams:"team"(#185) or"division". Omitted in developer mode (which shipsdevelopers, noteams). It is the seam that lets a consumer tell what eachteamslabel means, since a division-mode response is otherwise structurally identical to a team-mode one. A future org/department level would set its own name here with the SAMEteamsarray -- no new response field. -
teams(array,omitempty) -- populated only in an anonymized mode: k-anonymized GROUP aggregates, no individual names. In--aggregation team(#185) each row is a team; in--aggregation division(#270) each row is a division (one level up inorg_hierarchy), labelled in the sameteamfield. Theaggregationdiscriminator above says which. Groups are dated (#886): each cost event, outcome and invoice counts toward the group its developer held at that event's own time (an invoice at the start of its month), read from the append-onlyhierarchy_membershipledger, so a later move never changes a past window. A group with fewer thankcontributing developers folds into the residual"other"row; a developer whose division is empty/unset folds into"other"too. A developer counts toward a group's floor once, however many rows they carry, and only with activity: a developer whose only figure there is paid spend fills no seat. 🔴 If the"other"row does not reachk, the WHOLE response is withheld (#864):teamsis absent, named rows included, as istotal, anddata_quality.kanon_suppressedsays so. A named row published beside a withheld residual differences against any other view that folds that group into a residual of its own. -
team(teamScoreJSON,omitempty) -- populated only when?team=filters to one team (developer mode only). -
team_rollups(array,omitempty, #821) -- developer mode only. Partitionstotalby dated team membership (#886): each event counts toward the team its developer held at that event's own time. One row per team that has at least one event in this response, in byte-wise ascending team-name order, then at most one unassigned row for events whose developer had no team at that time (or one mapped to""). An event is placed by the dated membership of the identifier it was recorded under, as the anonymized modes place it (#914): an alias carries its person's team from the moment it was added, re-pointed or deleted, and events recorded under it before then stay where they were. Each row isteamScoreJSONplusunassigned(bool, always present); the unassigned row hasunassigned: trueand noteamkey, so it can never collide with a team a customer happens to name. Every event is in exactly one row (a developer who moved inside the window can appear in two), so the rows'weighted_pointsandtotal_cost_usdsum tototal's (to float64 rounding: the grouped sum re-associates the same additions).tier,cost_per_point,coverage_pct,spend_leverageandrankedare per-row ratios and do not sum. A team with anorg_hierarchyrow but no developer in the window has no row.- The mode rule alone decides presence: developer mode, whenever
totalis present, over the same population: the window and?repo=narrow it;?team=and?work_type=do not (?team=fillsteam,?work_type=narrows onlywork_types). Under?team=XtheXrow equals theteamblock. - Top level only.
work_typessegments carry noteam_rollups. - 🔴 ABSENT in any anonymized mode (
team/division). A per-team rollup has no k-floor; in those modes it would re-expose every sub-k team the k-anonymizedteamsarray folds into"other", andtotal - Σ named teamsis the differencing channel #593 closes. It is therefore also absent wheneverdata_quality.kanon_suppressedis set: k-anonymity suppression only ever fires in an anonymized mode, and the #593 strip pass withholdsteam_rollupstogether withtotalregardless. - ⚠️ Read scope sees team structure.
/scoresis read-scoped (#190), so in developer mode the read-only viewer token now sees every team name and each team's totals, from which membership can be inferred. This is operator ruling #821 = B: the dashboard's team panel needs it.GET /org_hierarchy, the developer->team map itself, stays write-scoped. - Not named
teams: that key is frozen as the k-anonymized array whose rows are labelled by theaggregationdiscriminator.
- The mode rule alone decides presence: developer mode, whenever
-
Additive diagnostics (#972 / U97), developer/operator mode only:
data_quality.join_readinesscontains arraysoutcome_only_repos({repo, outcome_count, points}),cost_only_repos({repo, captured_cost_usd}),incompatible_repos({developer, issue_id, cost_repo, outcome_repos}),ambiguous_repo_blind_matches({developer, issue_id, plausible_repos}), andissue_spend_without_same_developer_outcome({developer, issue_id, cost_repo}), plusunqualified_cost_share. The three per-(developer, issue) lists are sorted and capped at 50 rows each;incompatible_repos_total,ambiguous_repo_blind_matches_totalandissue_spend_without_same_developer_outcome_totalcarry their uncapped counts. Arrays are empty when clean. Repository gaps compare qualified real repo ids exactly; unqualified rows cannot satisfy repository presence. Issue joins require the same canonical developer. The repository-only fold does not establish that any particular developer's issue joined. Points use the same policy-projectedOutcome.Points()as scores, including corrections. Dollar figures use captured non-API costs.unqualified_cost_sharedivides captured micro-dollars on non-real repositories by all captured micro-dollars in the window; it is zero when the denominator is zero or under strict?repo=. Cost presence for join checks includes manual cost rows, matching the scoring join. All inputs are loaded in the score's read snapshot; scoring and attribution are unchanged. Join readiness is entirely absent in team/division modes. Live checkpoint evidence, current engine enablement and on-disk worktree checks are absent from/scores, its comparison responses and report manifests. The separate captured-dollar diagnostics route serves that live evidence without changing reproducible scores. -
data_quality(omitempty) -- presence contract (#351): the block is now present whenever the window has spend or outcomes (it always carries the always-on attribution shares below); it is omitted ONLY for a truly empty window. So a consumer must key off the SPECIFIC field it cares about, NOT off block presence as a boolean "a warning fired" (the pre-#351 reading -- still true for the tripwire fields, which stay omit-when-clean). Fields: the zero-token-outcome tripwire (#136):{zero_token_outcomes: [{developer, issue_id, tokens}]}in developer mode,{zero_token_outcome_count: <int>}(name-free) in team mode. May additionally carrymixed_price_versions([<int>],omitempty, #293): the ascending distinctprice_tableversions that priced the window'stoken_events, present ONLY when that set has more than one element -- the mixcost_micro's immutability (#233) creates, which the single top-levelprice_table.versionstamp would otherwise mask. Developer mode only (#864): it reads every row of the window, pseudo-developer spend included, so inteamanddivisionmode it is omitted, and thereprice_table.versionnames only the active table, not every table that priced the window. A floored warning is to return through a follow-up issue. It also carries the true attribution-coverage fields (#351):attributed_cost_share(<float>in[0,1],omitempty) -- the fraction of windowcost_microthat joins to a REAL issue rather than theunattributedsentinel (attributed / total, exact from the same window's cost composition). This is the honest coverage headline an adopter must see up front -- but it measures issue-attribution, which is NECESSARY BUT NOT SUFFICIENT for a score: spend on a real issue with no OUTCOME counts here yet drives no TIER. Read it WITHattributed_outcome_share-- the two measure DIFFERENT joins (cost->issue here, outcome->cost there) and are NOT expected to reconcile. Present whenever the window has spend;omitemptyfires ONLY when there is no spend (a real0.0-- all spend unattributed -- IS emitted, not dropped). Do NOT confuse it withcoverage_pct(see caveat below). Developer mode only (#864):teamanddivisionmode never publish it, because beside the group rows andtotalit bounds one person's spend even when k counted people carry the linked and the unlinked cost.attribution_coverage(string,omitempty, #864) --"not_shown"on every anonymized window that has spend, here and in each/scores/comparewindow'sdata_quality; absent in developer mode and on a window with no spend. It has that one value only. In these modes treat an absent share as "not shown", never as full coverage, whether or not this field is present.excludes_unattributed_spend(<bool>,omitempty, #864) --trueon every anonymized response, here and in each/scores/comparewindow'sdata_quality; absent in developer mode. Spend recorded under theunattributedpseudo-developer (an org-usage poller's remainder, proxy requests with no developer header) is dropped before any row,total, count or share is computed, so no difference of published figures is that spend. It is constant: it does not say whether a window held such spend. The amount is visible in developer mode, andGET /api/v1/org_actual_spend(write token) carries the invoices. The pseudo-developer never holds paid spend, sospend_leveragehere is captured list-price spend divided by paid spend over the same people.attributed_outcome_share(<float>in[0,1],omitempty) -- the fraction of the window's outcomes whose canonical(developer, repo, issue)has ANY matching token spend (tokens > 0, a looser bar than the zero-token tripwire). Falls to ~0 under the identity-mismatch failure mode below. Present whenever the window has outcomes;omitemptyfires only when it has none. Name-free. A highattributed_cost_sharewith a lowattributed_outcome_shareand a non-emptyunjoined_developersis the silent-TIER=0 signature the three fields exist to make loud.unjoined_developers(omitempty) -- developers present on only ONE side of the cost/outcome join (#351/#125): cost keyed to an OS username, outcomes to a GitHub login, with nodeveloper_aliasmapping them, which otherwise reads as a silent TIER=0. Shape{cost_only?: [<name>], outcome_only?: [<name>], cost_only_count, outcome_only_count}. Present only when at least one side is non-empty. In developer mode the name lists are populated so the operator can map the aliases; in team-aggregation mode (#185) the names are suppressed and only the two counts carry, through the same k-anon guard aszero_token_outcomes.
Caveat --
coverage_pctis NOT attribution coverage. The per-developer /total/teamcoverage_pctis capture fidelity: the share of the spend tierd DID record that arrived per-request (realtime proxy/JSONL) rather than as a coarse estimate. It reads ~100% even when most spend never attributes to an issue, which is why it was mistaken for completeness. Its computation is unchanged (#136 keeps the wire name stable; the dashboard labels it "Fidelity"). Attribution completeness isdata_quality.attributed_cost_share, added here.unattributed_buckets(array,omitempty) -- the labeled split of the single unattributed mass (attribution refocus, Option B): one row per reason the join could not tie spend to an issue. Shape[{bucket, cost_usd, share}], sorted by descending cost.bucketis the stable labelunattributed:main(spend recorded onmainormaster, so TIER could not link it to an issue),unattributed:detached-head,unattributed:branch-without-issue,unattributed:foreign-repo(#823: a worktree of a repository this install does not configure; not counted inexploratory_cost_share; its rows always carryrepo=unqualified, and/eventsrefuses any other value), or the baseunattributed(host-blind producers).shareis of TOTAL window cost, so the buckets +attributed_cost_sharesum to 1.0. Present only when the window has unattributed spend. Developer mode only (#864): a bucket one person's spend fills publishes that spend.exploratory_cost_share(<float>in[0,1],omitempty) -- theunattributed:mainbucket's share of total window cost: spend recorded onmainormaster, so TIER could not link it to an issue. This spend stays in the denominator. The field reports how large it is; it does not remove it. The field name is historical and is kept because it is the wire contract: the value does not say whether the work was exploration, planning or anything else. A common cause: the session was started in the main checkout, and the work ran in a separate worktree. Claude Code usually records the branch checked out in the directory where the session started (measured 2026-09-26 across one install's session files); a latercdinto another worktree is not recorded, and for harness worktree agents TIER substitutes the session's last named branch. Start the session inside the worktree, or on a branch named with the issue number, and the spend is attributed to the issue. That helps only if the worktree's branch names the issue; otherwise the spend lands inunattributed:branch-without-issue. Pointer-emitted so a real0.0carries; present alongsideunattributed_buckets, so developer mode only (#864).- Cost horizon (#512) -- four fields that say whether this window starts
before TIER began recording cost. They describe the requested scope: the whole
installation without
?repo=, and only that repository with it. All four are absent when the scope holds no cost at all (on a?repo=read, when that repository has no cost rows, even if other repositories do), and also when the server could not look the horizon up (it logs the error and still returns the scores), so absence never means "covered". Otherwise the first three are always present. Name-free; they carry in both modes. See interpreting-the-number.md for how to read them.cost_coverage_start(RFC 3339 string) -- the time of the earliest cost event in the scope: the installation's cost horizon without?repo=, or that repository's with it.window_predates_cost_capture(<bool>) --truewhensinceis earlier thancost_coverage_start, which means the window counts outcomes against cost that was never recorded and the TIER reads too high. An explicitfalseis sent when the window is covered, so silence never stands in for "checked and clean".cost_coverage_safe_since(YYYY-MM-DD) -- the earliestsincevalue that clears the horizon. When the horizon falls partway through a day, this is the following day, becausesincealways starts at midnight.source_coverage_start({<source>: <RFC 3339 string>},omitempty) -- each capture source's own earliest cost event in the scope. Sent only when more than one source has recorded cost in the scope: a window can clear the overall horizon and still start before one source began.
uncounted_active_ids(omitempty, #856) -- anonymized (team/division) modes only: legacy activity diagnostics for identifiers with spend or merged work in the window that fail the roster, bot or legacy capture checks. These categories do not enumerate D4-only exclusions: an identifier can be absent from these counts and still fail the current k-anonymity rule below. Shape{manual_only, push_only, not_on_roster, bot}, all integers; each identifier is counted once, under the first reason that applies in the orderbot,not_on_roster, thenmanual_only(it has a hand-enteredPOST /costsrow and no captured activity) orpush_only(its only activity is push-captured merged work). Stated once per window, for the whole org: it does not move with?work_type=or?team=, and it never appears on ateamsrow, awork_types[]segment or/scores/compare. Absent when the legacy checks find no exclusions, and in developer mode. Theunattributedpseudo-developer (#853) never counts and is never reported here: it is not a person anyone could put on the roster.- Current counting rule (D4). A fixed
named_subjectcounts toward k only when it has outcomes or cost in the window, one of the identifiers its person's cost or outcomes in the window were recorded under (aliases join identifiers into one person) holds its own roster membership overlapping the window, so an identifier idle in the window never rosters another's activity (#914), it is not a bot (GitHub account typeBoton an outcome the webhook recorded in the window, a[bot]login suffix, or a fixed list of bot logins for older rows), and it has qualifying local token-event evidence in the window:bound_uploadtied to that subject's own personal/device credential,server_watch, orvendor_attributedwith a root-authored insert-time mapping that still matches the subject and covers the event time without being voided. The fixed subject must remain its own canonical identity; aliases cannot lend another subject's evidence. Imported rows, shared or legacy uploads, manual costs and outcomes alone never establish a seat. An imported person needs an own-credential upload here to become locally bound before other qualifying local evidence can count for them. An uncounted identifier's figures stay summed in its row and intotal, except a pseudo-developer's, which an anonymized mode drops (#864, seeexcludes_unattributed_spend). - Per-measure floor. A group is named, and a residual published, only when
at least k counted people carry each figure it publishes: k with
captured cost for
total_cost_usd(a hand-enteredPOST /costsrow fills no cost seat), k with outcomes forweighted_points, k with paid spend foractual_paid_usd, and both populations for each ratio:tierandcost_per_point(cost and outcomes),spend_leverage(cost and paid).coverage_pctsplits cost into a realtime and a non-realtime share, and each non-zero share needs k counted carriers with captured cost in that share (a hand-entered fee is no carrier of the non-realtime share). A figure that no row carries (it is exactly0) needs none. - Remedy: put the people on the roster and capture local evidence with their own personal/device credentials, server-watch, or valid root-mapped vendor attribution. A collector or proxy using a shared credential does not establish a counted person. The floor applies to shared-viewer reports.
- Current counting rule (D4). A fixed
kanon_suppressed(omitempty, #593) -- present when a sub-k residual cohort was WITHHELD from an anonymized (team/division) response. Shape{developers, k_anonymity, withheld_total, withheld_cost_composition,withheld_teams, withheld_segment_reconciliation}.withheld_teams(#864) istruewhenever the block is present: the whole response is withheld, named rows included.withheld_cost_compositionandwithheld_segment_reconciliationarefalse: an anonymized mode never builds either block (#864). Name-free: counts only, never an identity.developerscounts people under the counting rule above (#853, #856) and may be0, when the withheld group holds only uncounted identifiers. It may also bek_anonymityor more, when the residual was withheld because one of its figures is carried by fewer than k counted people (the per-measure floor). Widening the window clears a suppression only if it brings enough counted people into the residual (people with no team, or in a team too small to have its own row).- When it is present,
teamsandtotalare ABSENT (#864; before it the named rows stayed and onlytotal,cost_compositionandsegment_reconciliationwent, and the visible rows no longer summed to the window). That is deliberate. Before #593 the residual bucket was emitted with no floor, publishing a sub-k cohort's exact figures; and merely removing that row would not have closed it, becausetotalminus the named rows reconstructs the hidden cohort by subtraction. Both the row and every unfloored aggregate over the same population are withheld together. - ⚠️ This retires the previous guarantee that k-anonymized rows always sum to
the grand total. That property is what makes the disclosure recoverable, so
it and k-anonymity cannot both hold. Consumers that reconciled rows against
totalmust treat a suppressed response as a distinct case rather than a reconciliation failure. k_anonymityis the floor actually in force, after the library'sMinKAnonymityclamp -- not the value a caller requested. Notetierd serverefuses to start belowMinKAnonymity(3), so on a served deployment the configured and enforced values always agree; the clamp is defence in depth for direct library callers. The field reports the enforced value regardless, so a consumer never has to know which.- A consumer must key off this field to distinguish "withheld for anonymity" from "this window has no data". Those demand opposite reactions: the first is fixed by widening the window or querying a level with more people in it, the second by fixing capture.
- Developer-aggregation mode never emits this: it names everyone by design, so there is nothing to reconstruct.
- One further placement carries the same shape (#593):
/scores/comparecarries a top-levelkanon_suppressedrather than one inside a window'sdata_quality, because the two-window fold suppresses a cohort from BOTH sides together or from neither -- attaching it to one window would imply the other was unaffected. (work_types[].kanon_suppressedwas a second placement until #864 removed work-type segments from anonymized responses.)
repo_scope(<string>,omitempty, #590) -- the canonical repository this response was narrowed to, echoed back canonicalized (what was queried, not what was typed). Present on every?repo=read; absent on an unscoped one. This is the field that makes "scoped" and "not scoped" distinguishable on the wire, which is the whole point of #590 -- the original defect was not that?repo=did nothing, it was that a caller could not TELL it did nothing. A consumer that requires a scoped figure must assert on this key, never on having sent the parameter. Name-free; carries in both modes.repo_scope_excluded(omitempty, #590) -- what the strict scope DROPPED from the window as repo-blind. Shape{token_events, cost_usd, outcomes}. Present only on a scoped read that actually excluded something; a scoped read over a fully-qualified window omits it, and that absence is the clean signal -- the scoped figures are a true total rather than a lower bound. Read it as: this much of the window could not be placed in ANY repository. The excluded rows are not claimed to belong to the scoped repository; they are unattributable by construction, which is what the sentinel means.cost_usdis the size of the hole in the scoped denominator andoutcomesthe hole in the numerator -- they move a TIER score in opposite directions. Name-free. ⚠️ The two sides do not share a lower bound.token_events/cost_usdcount the ATTRIBUTION BAND[since - 14d, until)andoutcomescounts[since, until), because a scope can suppress a repo-blind row inside an outcome's 14-day look-back (store.UnqualifiedExclusionWindow). 🔑 The manifest pins this same quantity asreport_manifest.repo_scope_excluded(#751) -- but in micro-dollars (cost_micro), and it is emitted on every scoped manifest including an all-zero one, where this block is omitted when clean. Two surfaces, two jobs; do not diff them field-for-field.spend_leverage_suppressed(<bool>,omitempty, #590) --truewhen a repo scope suppressedspend_leverage/actual_paid_usd, which are installation-wide by construction and cannot be scoped (see?repo=above). Absent entirely on an unscoped read: suppression is a property of scoping, not a standing caveat. Without this field a scoped response'sactual_paid_usd: 0would be indistinguishable from "no actual spend has been recorded", which is a materially different statement. Name-free.
-
work_types(array ofworkTypeSegmentJSON,omitempty) -- the authoritative type-segmented view (#187): one entry perwork_type, each{work_type, developers?, total?}scored over ONLY that category. This is the surface for comparing developers; the pooleddevelopers/totalabove are retained for back-compat and are not a cross-type ranking. 🔴 Developer mode only (#864): absent in any anonymized mode, where the group rows are the one breakdown; the per-segmentaggregation,teamsandkanon_suppressedkeys existed only there and are gone. #937 (an audit that solves the published sums) is the route to bring a work-type view back to anonymized modes. -
segment_reconciliation(omitempty, #466) -- accounts for the window's WHOLE spend against thework_typessegments above, so a reader can see how much the segmented view leaves out and why. Omitted when the window has no cost rows. It is ALSO dropped — silently, with a server-side ERROR log — when the block is not fit to publish: an accumulator saturated at the int64 ceiling, or any figure came out negative. That third case is the one absence this API does NOT declare on the wire, because it signals a server fault rather than a policy withhold; its only reachable trigger is a window whose summed spend approaches ~9.2e12 USD. Shape:{developers: [row], total: row}whererowis{developer, window_cost_usd, window_cost_micro, outcome_linked_cost_usd,outcome_linked_cost_micro, no_outcome_cost_usd, no_outcome_cost_micro,unattributed_cost_usd, unattributed_cost_micro}. 🔴 Developer mode only (#864): the whole block is absent in any anonymized mode, which publishes no segments to reconcile, and whose no-outcome and unattributed figures would be a second breakdown of the window's cost.totalis the always-present name-free rollup and carries nodeveloper. ⚠️ A deployment running an org-usage poller will see a row whosedeveloperis literally"unattributed"— org-level invoice aggregates that cannot honestly be split per person — with its cost wholly inunattributed_cost_micro. That is the developer sentinel, a different field from the issue sentinel discussed below.- Why it exists. A segment's TIER denominator is outcome-linked cost only:
work_typeis a property of the OUTCOME, so spend on an issue that produced none has no category to be filed under and appears in no segment. The pooled headline score keeps that spend in its denominator, so every per-type TIER read systematically better than the headline, invisibly. - The invariant, and exactly where it holds:
outcome_linked_cost_micro + no_outcome_cost_micro + unattributed_cost_micro== window_cost_micro, EXACTLY, on the integer fields and only those. The_usdcompanions are independent float conversions, soa + b + c === don them is false for roughly one triple in ten — 22601 of 216000 in a deterministic SYNTHETIC sweep spanning $0.008 to $78 per component (TestSegmentReconciliation_DollarsAreNotExact); the same sweep fails 0 of 216000 on the integers. That rate is a property of the sweep's magnitudes, not a measurement of any production window. Assert on the micros, display the dollars. - The invariant is INTERNAL to this block. All four figures fold from ONE
DeveloperIssueCostsWindowsnapshot, which makes it an arithmetic identity no concurrent writer can break. It is not a cross-block promise:window_cost_microand the pooledtotalcome from separate non-transactional reads over a window whose upper bound is usually open, so on a live store they can differ by rows written between them. Do not assert across blocks. - ⚠️ It does NOT reconcile against the segments' totals, and no invariant claims it does. The segments can legitimately double-count a cost row -- an issue carrying two work types is charged to both segments, and under the tolerant repo join (#231) a repo-blind cost row is charged to every qualified outcome sharing its issue id. Both over-counts are deliberate (they lower TIER, so ambiguity never flatters a developer), which makes "segments + gap == window" false on ordinary data. This block partitions the underlying cost ROWS instead, each counted exactly once, under the identical repo rule the segments use.
- ⚠️
no_outcomeandunattributedare DIFFERENT and are never merged.no_outcomeis cost tied to a REAL issue id that produced no outcome in the window (abandoned work, work in flight, a PR that never merged) -- the gap this block exists to surface.unattributedis cost the collector could not tie to any issue at all (theunattributedsentinel family), which is the established meaning of the word elsewhere in this API and matchescost_composition's split. It is reported here only so the three parts sum to the window. - Not narrowed by
?work_type: the gap is a property of the developer's window, not of the segment the caller asked for.
- Why it exists. A segment's TIER denominator is outcome-linked cost only:
-
cost_composition(omitempty) -- cost-composition sidecar (#234): a whole-window, name-free breakdown of WHERE spend went, for optimization. Omitted when the window has no token spend. Shape:{total_cost_usd, attributed_cost_usd, unattributed_cost_usd, unattributed_share,cache_read_share, premium_model_share, by_model: [{model, host, cost_usd, share,premium}], by_class: {input_tok, output_tok, cache_read, cache_write}}. Dollar figures are USD; shares are fractions in[0,1]. Reconciliation (exact in the underlying integer micro-dollars; the USD floats reconcile to micro-dollar precision):attributed_cost_usd + unattributed_cost_usd == total_cost_usdandsum(by_model[].cost_usd) == total_cost_usd(no residual bucket).cache_read_shareiscache_read / (input + cache_read + cache_write)(input-side hit share, docs/ pricing-philosophy.md §4);premium_model_shareis the SPEND share on models pricing >= $4/M base input (the frontier/reasoning tier;PremiumInputRateThresholdPerM, lowered from $5/M with price table v10; releases up to v0.4.1 still use $5/M).by_classis TOKEN counts, not allocated dollars (a stored blendedcost_microis not exactly splittable per class);by_model.premiumand the model breakdown are host-aware (#300), so an open-weights model split across hosts stays multiple rows. 🔴 Developer mode only (#864): absent in any anonymized mode. Naming no individual is not enough: every field buttotal_cost_usdsplits the window's cost a second way, and a model one developer used published that developer's spend inby_model(measured: $4.44).total_cost_usdrestatestotal, which stays. #937 is the route to bring a per-model view back to anonymized modes.
-
developerScoreJSON:developer, tier, weighted_points, total_cost_usd, actual_paid_usd, spend_leverage, coverage_pct, exploratory_cost_share, cost_per_point, sample_n, ci_low, ci_high, cost_per_point_ci_low, cost_per_point_ci_high, ranked, flagged_outcomes.exploratory_cost_share(attribution refocus, Option B) is this developer'sunattributed:maincost / their total window cost -- the per-developer companion todata_quality.exploratory_cost_share(the name is historical; it does not say the work was exploration), naturally k-anon-safe because developer rows are not emitted in team-aggregation mode. The array is sorted by developer identifier, never by TIER; therankedflag (#133) tells a client which rows clear the evidence floor. The dashboard keeps that order and never sorts the rows by TIER (#830); the v0.4.1 dashboard put ranked rows first, highest TIER first.cost_per_point(#239) istotal_cost_usd / weighted_points-- the inverse-unit dual oftier(numerically1000/tier); it isnullon a zero-point row (#472) so "no accepted outcome" is not encoded as the most-efficient0, while a zero-recorded-cost row (shown as "No recorded AI cost", often a capture gap rather than free work) keeps0, meaning no recorded cost, not an efficiency reading.cost_per_point_ci_low/highare its nominal 95% self-relative bootstrap bounds (coverage unvalidated), the reciprocal (ends swapped) of thetierCI --0for unranked rows. See docs/rubric.md.- #1233 interval withholding (v0.6.1 wire-contract amendment). On
/scoresdeveloper rows, developer detail, work-type developer rows and each developer compare side, the row's OWN attributed-cost share must be at least 0.5 for an interval. This uses the cost paired with resampled outcomes, divided by this row's total cost, after the same allocation/scaling as the bootstrap; its complement isfixedCostUSD, including cost on issues without outcomes and non-resampled repair cost. It is not the window-leveldata_quality.attributed_cost_share. Exactly 0.5 passes; a nonpositive total has no interval. The evidence floor is checked first: an unranked row always sayswithheld: below evidence floor, even when its attributed-cost share is also below 0.5. Withheldci_low,ci_high,cost_per_point_ci_low/highremain present numeric0, following the existing below-evidence-floor convention; they are neithernullnor omitted. Compare sides have no cost-per-point bounds. Additiveci_notesayswithheld: row attributed-cost share below 50%, orwithheld: below evidence floor; shown intervals carry the exact labelexcludes uncertainty from unattributed spend.ranked, the score and cost-per-point point estimate do not change.significantis false if either side's interval is withheld, even when both rows remain ranked. For months sealed after this change, team/division/scoreswindows always returndata_quality.interval_status: "not_shown", without row shares, reasons or floor-dependent status; live/scoreswindows do too. These two diagnostics are/scores-only, not compare-window or/issuesfields. Older sealed months omit bothinterval_statusandpartition_diagnostics; stored seal bytes never change. - #1233 partition diagnostics (additive, v0.6.1).
/scorescarriesdata_quality.partition_diagnostics: developer mode gets{outcomes_per_issue: [{outcomes, issues}], weight_buckets: [{weight, outcomes, points}], outcome_n, capped_pr_count, capped_pr_count_share, capped_pr_points, capped_pr_points_share, points_per_pr, points_per_1000_usd, notice}. Issues are keyed by repository and issue ID across the window; distributions are sorted ascending. Buckets use raw size weight and effective scored points. The cap is weight 8, including quality-discounted outcomes. Cap shares use non-push PR outcomes (weight_source != "push"), including legacy PRs without a number, by count and by points;points_per_pruses those PRs too. All scored outcomes and whole-window cost enter points per $1,000. Zero share/mean denominators arenull; zero-cost TIER remains0. The notice discloses same-issue and cross-issue partition sensitivity and that diagnostics apply no automatic penalties. Team/division/scoresmodes return the constant stringnot_shownfor live windows and months sealed after this change, including suppressed or empty windows. Older sealed months omit the field; stored seal bytes never change. teamScoreJSON:team (omitempty), tier, weighted_points, total_cost_usd, actual_paid_usd, spend_leverage, coverage_pct, cost_per_point, ranked. No CI at team grain (the bootstrap is a per-developer signal, #133).ranked(#502) is the #133/#136 evidence floor on the aggregate's SUMMED inputs -- outcomes >=MinRankedOutcomes, spend >=MinRankedCostUSD, no zero-token outcome among the members.tieris unaffected by it: an unranked aggregate still carries its true quotient, so a consumer must gate the headline onrankedrather than expect a scrubbed number (#136). There is deliberately no team-levelsample_n-- see the #502 changelog entry.
GET /api/v1/diagnostics/captured-dollars
- New ADDITIVE public v1 read endpoint (#972 / #1279). It carries the same stability promise as the other public v1 read routes: existing response fields retain their names, meanings, units and scopes; incompatible changes require the versioning/deprecation process above.
- Auth and modes: read scope (
requireRead); developer mode only. Team and division modes return403, without diagnostic counts or shares. - Bounds: the same
since,until(orbefore) and strictrepoquery bounds as/scores. Malformed dates, an upper bound at or beforesince, disallowed repository scopes (includingunqualified), and unknown query parameters return400. A diagnostic query failure returns500here alone. - Release history:
/scoresnever carriedcaptured_dollar_diagnosticsin any release; that field existed only on this unmerged branch. Adding this endpoint changes no released/scoreswire contract. - Response: receipt with
share_label: "share of captured dollars",since(RFC3339), optionaluntil(RFC3339; absent means open-ended),scope(fleetor the strict requested repo),sample_size(captured token-event count),captured_cost_usd,engine_enablement(engine →enabled,disabledorunknown),enablement_scope,bucketsandmain_checkout_worktree_share,stored_cwd_sample_size(events with stored cwd) andstored_cwd_cost_share(their share of captured dollars). Metadata availability does not establish historical working-directory evidence. The worktree share is captured dollars inunattributed:mainwhose stored cwd resolves to a main checkout with verified linked worktrees present now, divided by all captured dollars in scope.buckets[]has{engine, bucket, subagent_origin, sample_size, captured_cost_usd, share}; each share uses that same whole-window captured denominator, not the engine subtotal. Only unattributed buckets appear; attributed dollars remain in the denominator. Empty/zero-cost windows have zero shares. Engine uses capture source (jsonl→claude-code).subagent_originis session-level:main_session(parent paths only),subagent(subagent paths only),mixed(both), orunknown(no stored evidence). Parent and subagent events sharing a session id cannot be split per event. The stored cwd is retained when all checkpoints agree; otherwise a single parent path supplies cwd, or cwd remains unknown. Current local engine enablement cannot establish historical or remote-shipper enablement. Paths/session ids are never emitted. Manualsource=apicosts are excluded. These are never shares of real spending.
Live checkpoint evidence, current engine enablement and on-disk worktree checks
are excluded from /scores, its comparisons and report manifests, so they do
not change verify-report's reproducible canonical body. Doctor reads this
separate endpoint. Strict ?repo= reads use only the selected repository's
inputs, including metadata joins. See the explanation and
limitations.
GET /api/v1/scores/{developer}
- Scope: read. Success:
200 OK,developerDetailResponse. - Blanket
404in any anonymized (team/division) mode (#185, #270): it names one individual by construction; the 404 is returned for every path value, before any lookup, so it is not an existence oracle. - Fields:
developer, tier, weighted_points, total_cost_usd, actual_paid_usd, spend_leverage, coverage_pct, cost_per_point, sample_n, ci_low, ci_high, cost_per_point_ci_low, cost_per_point_ci_high, ranked, flagged_outcomes, issues.cost_per_pointand its CI mirrordeveloperScoreJSON(#239).issuesis an array of{issue_id, weight, quality, pr_number (omitempty), zero_token}. Note: this endpoint's top-level object has norubric/price_tablestamp of its own -- read those fromGET /api/v1/scoresfor the same window. - Query:
?since=,?until=(or?before=),?repo=. Any other parameter is a400-- see strict query-parameter validation. ?repo=(#590) applies the SAME strict scoping as/scores, including theunqualifiedexclusion, and narrows theissuesarray too -- a scoped detail must not list work done in another repository. Adds three top-level fields mirroring the/scoresdata_qualityones:repo_scope(omitempty),repo_scope_excluded(omitempty),spend_leverage_suppressed(omitempty). Under a scopeactual_paid_usdandspend_leverageare suppressed to0and the suppression is declared -- read the flag, not the zero.
GET /api/v1/scores/compare
-
Scope: read. Success:
200 OK,compareResponse(#277). -
Purpose: a before/after period comparison -- two half-open windows in, per-row deltas plus a CI-overlap significance flag out. In developer mode it reuses the SAME windowed scores computation as
GET /scores, so a compared score matches/scoresfor the same window. It is an aggregate view like/scores; in anonymized (team/division) mode it compares sealed calendar months, refolding rows with the two-window intersection, so group rows can differ from each month's standalone/scoresreport. -
Developer-mode only: free-window queries (each window mirrors
/scores'since/untilgrammar and validation, #276):?since_a=,?until_a=(window A = "before");?since_b=,?until_b=(window B = "after"). Eachsince_defaults to the start of the UTC day 90 days ago (#746, the same default/scoresuses); eachuntil_is an optional exclusive upper bound that must be strictly after itssince_(400otherwise). Eachsince_is retention-checked (422if it predates the retention horizon), notuntil_. Every delta is B - A. -
Team/division mode: use
?period_a=2026-05&period_b=2026-06for two closed, sealed months. Give both parameters, with A earlier than B, or omit both for the two latest sealed months. An unsealed month returns404; incompatible sealed configurations return409. Reads never seal a month. Free bounds are refused with400, even when they describe whole months or are empty. For the developer-mode example?since_a=2026-05-01&until_a=2026-06-01&since_b=2026-06-01&until_b=2026-07-01, the exacterroris:?since_a=, ?until_a=, ?since_b=, ?until_b= not accepted for anonymised reports (#913): they are served per sealed calendar month, so two overlapping windows cannot be differenced to isolate one person's figures. Remove ?since_a=, ?until_a=, ?since_b=, ?until_b= and select two months with ?period_a=YYYY-MM and ?period_b=YYYY-MM (neither: the two latest sealed months) -
Any parameter outside the mode's allowlist is a
400(#590) -- including?repo=, which this endpoint does not implement. It is rejected rather than ignored precisely because it returns the same class of cost figure from the same shared windowed computation as/scores, so silently accepting-and-dropping it would reproduce the #590 defect one endpoint over. Scoped comparison is a legitimate future feature; being quietly unscoped is not a feature. -
Fields:
window_a,window_b({since, until?, data_quality?}) -- each window's echoed bounds and its OWNdata_qualityblock (#277): the zero-token /mixed_price_versionssignal is per window (a mixed-version WARN can apply to one window and not the other), same shape and mode-dependent name-suppression as/scores'data_quality. Each block also carries the cost-horizon fields (cost_coverage_start,cost_coverage_safe_since,window_predates_cost_capture, andsource_coverage_startwhen more than one source has recorded cost), checked against that window's ownsince; they are omitted when the store holds no cost. Sealed team/division comparisons omitcost_coverage_safe_since, as do the stored monthly bodies. The #351 coverage shares are a/scores-only surface; in an anonymized mode each block carriesattribution_coverageas on/scores.untilis omitted when the window is open-ended.price_table({version, effective_date, table_hash, file_hash}) -- always present active-table stamp (#233, extended by #713); per-window pricing mixes surface via each window'sdata_quality.mixed_price_versions(developer mode only).mode(string) --"developer"in developer mode; in team/division mode, echoes the level both months were sealed under ("team"or"division"). It tells a consumer whetherdevelopersorteamscarries the rows (the same discriminator as/scores'aggregation).developers(array ofdeveloperDeltaJSON) -- per-developer deltas in developer mode; an explicit empty array in any anonymized mode (#185, #270), never a named row. Each row:{developer, present_a, present_b, a, b, delta_tier, delta_weighted_points, delta_total_cost_usd, significant}.a/barescoreSideJSON({tier, weighted_points, total_cost_usd, actual_paid_usd, spend_leverage, coverage_pct, cost_per_point, sample_n, ci_low, ci_high, ranked}).cost_per_point(#239) is the same points-guardedtotal_cost_usd / weighted_points(nullon a zero-point side, #472)/scores' developer rows and the team compare sides carry, added for contract parity; no self-relativecost_per_pointCI on a side. Rows are the union of developers across both windows; a delta andsignificantare computed only when the developer is present in BOTH windows (0/falseotherwise, never fabricated against an absent side).teams(array ofteamDeltaJSON,omitempty) -- populated only in an anonymized mode (team #185 / division #270): each row{team (omitempty), a, b, delta_tier, delta_weighted_points, delta_total_cost_usd, significant, ranked}wherea/bareteamScoreJSON. k-anonymity is a two-window INTERSECTION (#277): a group is a named row only if it independently clears the k-floor in BOTH windows; a group sub-k in EITHER window (including one present in only one window) folds into the singleotherresidual on BOTH sides, so a group's PRESENCE never differs across windows and no sub-k aggregate can be recovered from a delta.significantis alwaysfalsehere: group aggregates carry no bootstrap CI (an interval is a per-developer signal, #133). 🔴 Absent, withkanon_suppresseddeclared, when the comparison'sotherresidual or EITHER window's ownotherbucket (the one/scoresfolds for that window alone) does not reachk(#864). The intersection folds intoothera group that is sub-k in the other window, so without the per-window check a comparison'sotherortotalminus that group's row from/scoresor a second comparison was the window's own sub-k residual.total(teamDeltaJSON,omitempty) -- name-free grand-rollup delta across every developer in each window.significantis alwaysfalse. ⚠️ ABSENT in two distinct cases: both windows are empty, OR a sub-k residual was suppressed (#593) -- there the delta minus the named group deltas would reconstruct the hidden cohort.kanon_suppressedis what tells the two apart. Developer mode never suppresses, so there it is absent only for two empty windows.kanon_suppressed(omitempty, #593) -- top-level, present when a sub-k residual was withheld from the comparison. Same shape as/scores'data_quality.kanon_suppressed:{developers, k_anonymity, withheld_total, withheld_cost_composition,withheld_teams, withheld_segment_reconciliation}. It sits at the top level because the two-window fold withholds a residual from BOTH sides or from neither; the residual is withheld when EITHER side is unsafe, and since #864 the whole comparison goes with it (withheld_teamsistrue).developerscounts people under #856's rule and may be0, when the withheld group holds only uncounted identifiers. It is the largest count among the withheld residuals (the comparison's two sides and each window's own), a magnitude hint and not a union.withheld_totalistrue.withheld_cost_compositionandwithheld_segment_reconciliationare alwaysfalsehere: compare ships neither sidecar, so none was withheld.- Never emitted in developer mode.
ranked(bool, on everyteamDeltaJSON-- bothteams[]rows andtotal) -- the derived ranking verdict of the comparison itself (#605):a.ranked && b.ranked, a boolean AND over the two sides' own #133/#136 verdicts and never a third floor. The rule it encodes is one sentence: anything derived from an unranked input is itself unranked. It is an AND rather than an OR because a ranked baseline beside an unranked selected window lets a reader reconstruct the withheld ratio exactly (selected = baseline + delta), and% change = delta/baselineis a pure function of the withheld baseline ratio. Always present, neveromitempty:falseand "an older server that never said" must stay distinguishable on the wire (the ambiguity behind #603). Consumers read this field rather than re-deriving the conjunction -- one producer, so the floor reaches every consumer instead of being re-implemented at each.
-
Unvalidated descriptive bounds: 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 and exclude uncertainty from unattributed spend, incorrect attribution and uncaptured usage.
/scores, its work-type segments, developer detail and/scores/comparecarryTier-Bounds-Status: unvalidated_descriptive,Tier-Inference: not_establishedand aTier-Bounds-Noteheader. Legacyci_*field names remain for compatibility. -
Legacy non-overlap result:
significantis retained, not a validated inference verdict. It is true only when a developer is present, above the evidence floor and (in v0.6.1, #1233) at or above the row's own 50% attributed-cost-share floor in both windows and the bounds do not overlap; touching ends overlap. Withheld bounds, an absent side or a below-floor side make it false; it is always false for groups. Neither value establishes an effect or a noise threshold. The dashboard shows “inference not established” and does not colour changes from this flag. -
Unblocks the dashboard dumbbell comparison (#278).
Seal disclosure (#1233)
Sealed report bytes and body_digest never change. /report_manifest adds
seal_disclosure, and sealed /scores/compare includes that object on each
window_a / window_b. It contains sealed_at (actual insertion time in UTC),
configured_grace_seconds (nullable), grace_status (recorded or not_recorded),
days_after_period_end, revert_windows_at_seal (withheld, open, closed, or not_recorded)
and note. Future seals record their grace and whether any recorded outcome's
60-day revert window was still open, atomically at the actual insertion time.
The note states that some windows were still open only for open.
Every non-developer seal level returns withheld, because outcome-dependent
observations can identify a person in an anonymized cohort. This includes all
production seals in team and division modes; developer mode seals the shared
team view for org history while its developer reports remain live.
Only developer-level records can return open (recorded as true at sealing),
closed (recorded as false, or inferred for legacy records as below), or
not_recorded (legacy status unknown).
Legacy grace is null / not_recorded, never the current configuration. Legacy
window status for developer-level records is not_recorded unless the actual
seal occurred at least 60 days after period end, when all nominal windows were
closed; non-developer levels still return withheld. A short delay alone does
not prove any particular outcome still had an open window. No historical report
body, digest or config_digest is rewritten.
Sealed /scores exposes the same disclosure beside the stored bytes:
Tier-Sealed-At, Tier-Report-Grace-Seconds (not_recorded for legacy seals),
Tier-Sealed-Days-After-End, Tier-Revert-Windows-At-Seal and Tier-Seal-Note.
The dashboard displays the selected month's own disclosure, and both months in
a comparison. The default grace remains 14 days; it can leave 60-day revert
windows open. Longer grace does not establish delivery completeness.
GET /api/v1/report_manifest
- Scope: read (satisfied by EITHER the viewer token or the write token).
Success:
200 OK,reportManifestJSON. - The
(predicate, as-of)identity of the reportGET /scoresreturns for the same window and scope (#715). A window predicate ALONE is an unstable identity: late JSONL ingestion adds rows carrying yesterday'ststhat legitimately join "the same window", so two runs of the same query over the same[since, until)can return different numbers and both be correct. - Query parameters:
since(optional, defaults as on/scores),until(with the legacybeforealias),repo. The allowlist is narrower than/scores':work_typeandteamare rejected with400, not ignored, because neither changes any field below -- accepting them would let a caller believe they held a manifest for a filtered report. ?repo=is refused with400in any anonymized (team/division) mode (#185, #270), by the same shared guard/scoresuses: this endpoint publishes per-repo ROW COUNTS over a caller-chosen window, and a cohort count is exactly the quantity a k-anonymity floor exists to protect.- Top-level fields:
-
manifest_schema(string) -- the scheme tag, currently"tiermanifest1". It is the rollback seam, not decoration: it lets a consumer tell "produced by a DIFFERENT scheme" apart from "describes a different database state". Bumped when an existing field's MEANING changes; adding a field does not bump it. -
since,until(omitempty),token_since(strings) andrepo(string,omitempty) -- the predicate. The three timestamps are RFC3339 UTC instants, not calendar dates. An absentuntilmeans open-ended, which is a different report from one bounded at today's date, so it is never backfilled with a synthetic "now". ⚠️ The reason the instant is load-bearing MOVED with #746, it did not go away. It used to be that an omitted?since=resolved tonow - 90dcarrying a time of day, so stamping a bare date would give two windows eight hours apart one identity. That case no longer arises from this server: the default is snapped to the start of its UTC day and every explicit bound already parses to midnight, so every bound this emitter publishes is midnight UTC. The field stays an instant because its type is shipped, and because the consumer side must keep handling instants a different producer -- a hand-written or pre-#746 manifest -- can carry. -
✅ Since #746 the default shape is re-runnable. A manifest requested with no window at all now carries a midnight-UTC
since, which/scorescan be re-queried with, sotierd verify-reportre-runs it normally. Before #746 the default was the one shape that could not be fed to the verifier at all. ⚠️ Re-runnable is not "returns 0" — an openuntilmeans the re-run covers[since, ∞)as the database stands now, so later ingestion is a legitimate1(DIVERGED). Take the manifest and the/scoresbody on the same UTC day: each request resolves the default separately, and the two bounds differ by a full 24h across a midnight. 🔴 The refusal is still live and still correct for any OTHER producer./scoresaccepts only whole-day bounds, so a manifest whosesinceis not midnight UTC -- hand-written, or emitted by a pre-#746 build -- has no query that reproduces its window, andverify-reportexits 2 (could not check) rather than truncating the bound, because truncating would re-run a different window and compare its numbers against this manifest's. -
⚠️
token_sinceissince - 14dand is NOT decoration. The scoring path does not readtoken_eventsonly inside[since, until): the #136 zero-token tripwire builds a per-outcome window[merge - AttributableWindow, merge], so an outcome near the lower edge is funded by events up to 14 days beforesince.watermarks.windowtherefore coverstoken_eventsover[token_since, until)andoutcomesover[since, until)-- the two sides do not share a lower bound, andtoken_event_countis a count over the attribution band, legitimately larger than the report window alone suggests. A watermark bounded atsincewould hold still while a late-ingested row in that band cleared the tripwire and changed/scores. -
aggregation(string) --developer/team/division. Always present, unlike/scores, which omits it in developer mode: a manifest exists to be diffed, and a key that is absent in one mode makes "developer" and "the key was dropped" the same reading. -
k(integer,omitempty) -- the k-anonymity cohort floor, emitted only in an anonymized mode, because that is the only mode in which any floor is applied. Publishing the configured integer in developer mode would assert a protection that is not in force. -
price_table(object) --version,effective_date,table_hash,file_hash(#713). ⚠️sourceis deliberately absent. It is a local filesystem path; the 2026-08-28 ruling on the #713 review confines it totierd score-log(a local CLI printing to the invoking operator's own terminal), because a served surface must not disclose the server's directory layout.table_hashalready answers what reproducibility asks -- "are these the same PRICES" -- and answers it better than a path. -
rubric(object) --version, the weight rubric (#239). -
tool_version(string),commit(string,omitempty) -- the binary. Not redundant with the two blocks above:Open()-time migrations rewritetoken_eventsin place with no ledger row at all, so a binary upgrade is a report input that no watermark can see, and this is what makes it visible. -
watermarks(object) -- the as-of half, split into two sub-objects because they are read over different predicates and carry different disclosure risk:watermarks.window--max_token_event_id,token_event_count,max_outcome_id,outcome_count. Windowed and repo-scoped. 🔴 OMITTED ENTIRELY in any anonymized (team/division) aggregation mode, with a top-levelkanon_suppressedobject declaring the withhold. These are unfloored counts of work over a caller-chosen window, and #593 established that narrowing?since=alone -- with no?repo=involved -- shrinks a cohort below the k-anonymity floor./scoreswithholdsteamsandtotalfor exactly those windows; publishing row counts there would hand back the activity volume of the one contributor/scoresdeclined to name, sweepable day by day. The withhold is deliberately blunt (every anonymized request, not only sub-k windows): the precise alternative needs the realKAnonSuppressionsignal, which costs a full window load, and a cheaper approximation would be a second, divergent floor that errs toward disclosure.watermarks.ledgers--max_<ledger>_idand<ledger>_countforquality_history,reprice_row_audit,cost_correction_audit,repo_repair_row_audit,push_outcome_audit(#849) andcorrection_audit(#1235). Unwindowed and unscoped (see below), and present in every mode: install-wide and window-independent, so it carries no count of the caller's chosen population. Every key within a present sub-object is always present; a0is a real reading ("nothing here yet") and is never dropped.
-
events_digest,outcomes_digest(objects,omitempty) --valueandrows(#716, wired by #740; tierdig2 in #1235).valueis"tierdig2:"plus 64 hex characters: a content identity over the ROWS the report was computed over. 🔴 This is the only pin that can see an in-placeUPDATE, which aMAX(id)/COUNT(*)watermark pair structurally cannot -- a reprice, a fidelity flip or an editedmerge_commit_shainside an already-published window moves no id and no count.rowsis the denominator that proves the digest was earned: an empty window yields a perfectly well-formedvalue, and onlyrowsseparates "this window genuinely holds nothing" from "this measured nothing", so the two are always published together. -
⚠️ The two digests do not cover the same window, for the same reason
token_sinceexists.events_digestcoverstoken_eventsover[token_since, until)-- the attribution band -- andoutcomes_digestcoversoutcomesover[since, until).events_digest.rowstherefore equalswatermarks.window.token_event_countandoutcomes_digest.rowsequalsoutcome_count. Digesting both over[since, until)would leave a late-ingested band row outside the identity; digesting both over the band would make an outcome the report never read a false alarm. -
🔑 Both digests honour
?repo=(#747), and the scope is part of the hashed identity. A scoped manifest publishes digests covering only the rows that report read -- so another repository's ingestion cannot move them, and the tworowsequalities above hold under a scope exactly as they do fleet-wide. Three consequences:- A scoped digest and a fleet-wide digest are different values even over identical rows; the predicate is hashed in so that a scope change reads as a change rather than as silent agreement. ⛔ Never compare one against the other.
- Scoping is strict, matching a scoped
/scores: rows carrying the reservedunqualifiedrepository sentinel are excluded, and the under-count that causes is disclosed by/scores'data_qualityblock asrepo_scope_excluded. ✅ That disclosure figure now has a pin of its own — the top-levelrepo_scope_excludedobject below (#751). It is not covered by these digests and never will be: widening the predicate to reach the sentinel would over-attribute every repo-blind row in the fleet to whichever repository the caller named, which is exactly what #590 closed. ?repo=is canonicalized (lowercased,.gitand stray slashes stripped) before it reaches the digest, so?repo=Acme/Tier.gitand?repo=acme/tierreturn the samevalue.manifest.repoechoes the canonical slug, never the caller's spelling.
-
digests_omitted(string,omitempty) -- present exactly when the two digests are absent, naming the reason. An undeclared absence would read as "this server publishes no digests", which is a different and false statement. One reason remains:- anonymized mode --
rowsis an unfloored count of work over a caller-chosen window, the same quantitywatermarks.windowis withheld for (#593).kanon_suppressed.withheld_digestsistruein this case.
⚠️ A second reason -- a repo-scoped manifest -- applied until #747, when the digest gained a repo predicate. The field is not removed: it is what separates "withheld, here is why" from "this server publishes none", and that distinction is independent of how many reasons exist.
- anonymized mode --
-
repo_scope_excluded(object,omitempty) --token_events,cost_micro,outcomes(#751). The repo-blind rows a strict?repo=dropped: the pinned form of what/scoresdiscloses under the same key indata_quality. 🔴 It exists because it is the one published figure the digests above structurally cannot cover. A scoped/scoresstill reads theunqualifiedsentinel rows in order to publish that disclosure, while a scoped digest excludes them by design and a scoped manifest carries no fleet-wide digest. Measured before this field: an in-place reprice of a repo-blind row movedrepo_scope_excluded.cost_usdfrom9to14while the scoped digests and the scoped watermarks stayed byte-identical, andtierd verify-reportprintedREPRODUCEDover changed served bytes.- Present on every scoped manifest, absent on every fleet-wide one.
repopresent ⟺ this object present. A fleet-wide report excluded nothing -- the sentinel rows sit inside its own digests -- so there is no such published figure to pin, and emitting an install-wide repo-blind cost for a request that named no scope would add disclosure rather than remove a gap. - ⚠️ It is emitted even when every count is zero, which deliberately
differs from
/scores, where the block is omitted on a clean window because there the absence is the signal to a human reader. Here it is a pin: an omit-when-clean pin is vacuous exactly when it matters most, because a window clean at publish time into which a sentinel row later lands would verify asNOT PINNED, and an absent check reading as a passing one is the defect the digests were added to close. - ⚠️
cost_micro, notcost_usd./scorespublishes dollars; the manifest pins the stored integer micro-dollars, because a pin is compared for equality and float equality over a JSON-round-tripped dollar figure can manufacture a divergence.cost_usd == cost_micro / 1_000_000. - ⚠️
token_events/cost_microcount the ATTRIBUTION BAND[token_since, until), whileoutcomescounts[since, until)-- the same asymmetrytoken_sinceexists for, because a scope can suppress a repo-blind row inside an outcome's 14-day look-back. A consumer recomputing over the narrower window compares two different populations. - It cannot appear in an anonymized mode:
?repo=is refused there, so a scoped manifest is always a developer-aggregation one. - What
tierd verify-reportprints -- four outcomes and a silent fifth: no line at all (fleet-wide, absent -- there is no pin to miss);NOT PINNED(scoped, absent -- a pre-#751 emitter or a hand-written file);NOT CHECKED(fleet-wide but present -- reported, never counted as agreement, because the value corresponds to no served number);UNCHANGED; andCHANGED, whose detail names which of the three moved. Whentoken_sincedisagrees with the verifier's band, the token leg is not compared:NOT CHECKED, orCHANGEDonly if the outcome count moved. A run with such a withheld pin and nothingCHANGEDexits2, not0. ⚠️ The presence rule above is an emitter rule -- the verifier tolerates a file that breaks it rather than refusing.
- Present on every scoped manifest, absent on every fleet-wide one.
-
⚠️ Cost, because this is a request path. The digests are two full-window scans. Measured (Apple M5 Max, 20,000 in-window
token_events+ 2,000 outcomes, 2026-08-29): the watermark read is 1.42--1.49 ms/op, the digests add 28.5--34.6 ms/op -- so this endpoint went from ~1.4 ms to ~30--36 ms, a ~20x increase scaling linearly with the window. That is affordable because a manifest is fetched once per published report. It is why the digests are on this endpoint and not on/scores, which already pays four full-window scans and is polled. ⚠️ #751 added a third read, and it was measured rather than assumed. The exclusion counts are two window aggregates whoserepo = ?filter no index can serve here, so both touch the table for every in-window row. ⛔ Not because there is no repo index --outcomescarries one withrepoleading (idx_outcomes_push_daily_repo), unusable only because it is partial onsource = 'push', which this query does not imply. The plans are pinned by a test rather than argued in prose. 🔑 Measured in ONE interleaved run (watermark, exclusion, digests per round), not appended to the two figures above -- which is why its watermark and digest legs read slightly differently from the #740 numbers, and why the ratios below are computed within that run rather than across the seam: watermark 1.44--1.78 ms/op, exclusion 2.98--4.56 ms/op, digests 28.7--36.5 ms/op (37--48% CPU idle across the rounds -- read as ceilings). That is two to three times the whole watermark read and +9.9% to +11.9% on the endpoint, and it is the same query a scoped/scoresalready runs for the same window. -
kanon_suppressed(object,omitempty) --withheld_window,withheld_digests,k_anonymity,aggregation,reason. Present only when a withhold happened. A manifest that went silently quiet would make "absent" and "this install has no rows" the same reading, which is the failure the/scoreskanon_suppressedblock exists to prevent. Bothwithheld_*flags are written explicitly rather than inferred from a missing key, so this object is a complete statement of what k-anonymity removed.
-
- Why five mutation ledgers and not one.
outcomes.qualityis mutated in place, so an as-of stamp keyed ontoken_events.idalone CANNOT bound a report -- an outcome's quality, a direct multiplier on every weighted point published, can change with no new row in either obvious sequence. The five append-only mutation ledgers are what make such a revision visible. Note also thattoken_events.idandoutcomes.idare separateAUTOINCREMENTcounters; neither bounds the other. - The two windowed sequences are scoped to
[since, until)and?repo=; the five mutation ledgers are NOT. Theirtsis the instant of the MUTATION, not of the row mutated, so a revision made today against a June outcome would be invisible to a June-windowed ledger read. Unwindowed is also the conservative direction: it can report a change that did not touch this window, never miss one that did. - ⚠️ What equal manifests do and do not prove. They mean no input this
structure watches has moved. They are not an attestation of byte-identical
output. FIVE report inputs are uncovered, and the count is stated because
an earlier draft said "two":
period_membershipanddeveloper_aliasare mutated in place with no transition log -- and aMAX(id)/COUNT(*)pair cannot see an in-placeUPDATEat all, so covering them needs ledgers those tables do not have.actual_spendis not mutated in place: its rows are only inserted (InsertActualSpend), never updated, and removed only by erasure (EraseDeveloper); it is uncovered because nothing watches it.hierarchy_membership(#886), which selects team and division rows, is dated and append-only, so a write cannot move a window that ended before it, but it is not watermarked, so a window still open at the write can move unseen. An alias edit is dated the same way (#914). (developer_aliasis the highest-leverage of them: it re-keys the entire cost-to-outcome join.) The fifth isOpen()-time migrations, which rewritetoken_eventswith no ledger row and are covered by thetool_versionstamp rather than by a watermark. Read the manifest as "no row-level evidence I watch has changed", not as "this report is reproducible". - Additive per the matrix above: a new endpoint is additive, not a compat break.
GET /api/v1/events
- Scope: read. Success:
200 OK. 403 in any anonymized (team/division) mode (#185, #270: raw per-developer rows are suppressed). Keyset-paginated bulk export oftoken_events. - Query:
?since=,?until=,?limit=(rejected loudly above the store max),?cursor=(opaque, echo the previous page's cursor). - JSON (default):
eventsExportResponse--{next_cursor, events: [...]}. Each event:id, ts, developer, issue_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cost_micro, source, fidelity, idempotency_key, repo, session_id, price_version, host, billing_mode, billed_to, attribution_rule, pricing_basis, rate_snapshot, cache_write_evidence, issue_rule. - CSV (
Accept: text/csv): same fields in the append-only column order ofeventsCSVHeader. Emptynext_cursor(body and theX-Next-Cursorheader) means the window is exhausted. - CSV formula protection (#1095): all four bulk exports prefix a single
quote (
') to text cells beginning with=,+,-,@, tab or carriage return, following OWASP CSV-injection guidance. Numeric columns remain numeric (including negative values such as-1.5); JSON values are unchanged. Use JSON when the original text is required without the CSV safety prefix. issue_rule(#1296) is appended last in JSON and CSV, after all existing fields. NULL exports aslegacy; a recorded extraction rule exports verbatim.legacycan include post-upgrade NULL rows: the singletonissue_rule_cutoverrecords the UTC migration time and maximum outcome/event IDs at cutover (cutover details). The developer access export preserves the nullable stored value (JSONnullfor SQL NULL), including archived rows inoutcome_exclusions.attribution_rule(#823) is an additive field appended afterbilled_to: the rule that assigned the row'sissue_id. The values arebranch,worktree-cwd,worktree-toolpath,carry,pr-number-at-insertorbranch-at-merge(#1074: server-assigned, never client-sent;branch-at-mergeis the merged PR's head-branch fill). A row with no rule recorded (every row stored before #823, and any row whose producer records none) exports aslegacy, and any other stored value exports asunknown(exportAttributionRule). The DSAR export shows the stored value as stored:nullwhere none was recorded, otherwise the stored text.
GET /api/v1/outcomes
- Scope: read. Success:
200 OK. 403 in any anonymized (team/division) mode (#185, #270). Same pagination/CSV contract asGET /events. - JSON:
outcomesExportResponse--{next_cursor, outcomes: [...]}. Each outcome:id, ts, developer, issue_id, pr_number, weight, weight_source, quality, merge_commit_sha, additions, deletions, changed_files, source, work_type, work_type_source, repo, push_day, quality_status, issue_rule(Event 3 raw/projection fields, when enabled, precedeissue_rule). - CSV: the append-only
outcomesCSVHeaderorder. issue_rule(#1296) is server-set from webhook/backfill extraction, appended last after every existing column, including conditional Event 3 fields. Explicit outcomes API issue IDs and pre-migration rows store NULL and exportlegacy; recorded rules export verbatim. Theissue_rule_cutoverID boundaries above distinguish the pre-upgrade band from later NULL rows. Postingissue_ruletoPOST /api/v1/outcomesis rejected as an unknown field and stores nothing.quality_status(#1228) is appended afterpush_dayin CSV; JSON omits it when empty.unknownmeans the outcome has an unresolved or unrecognized quality check. Empty does not establish observed quality. This diagnostic field does not change the numericqualitymultiplier.push_day(#242) is the UTC calendar day asource='push'outcome aggregates to (part of the(repo, issue_id, push_day)dedup key), and""for a PR outcome (NULL column), matchingmerge_commit_sha.
GET /api/v1/quality_events
- Scope: read. Success:
200 OK. 403 in any anonymized (team/division) mode (#185, #270: rows carry a per-developerdevelopercolumn). Keyset-paginated bulk export of the append-onlyquality_eventssignal log (#242). Same?since/?until/?limit/?cursorquery, pagination, and content-negotiation contract asGET /events. - JSON (default):
qualityEventsExportResponse--{next_cursor, quality_events: [...]}. Each row:id, outcome_id, developer, issue_id, event_type, source_ref, event_ts, recorded_at, reason, status, evidence, trigger_event. - ⚠️
source_ref's value shape for CI rows changed in #687 (BREAKING) --head_sha:run_attempt:workflow_id, washead_sha:run_attempt. Same column, different bytes; see the changelog entry. The identical change applies tosource_refonGET /api/v1/quality_history, which carries the CI ref throughUpdateQualityForOutcome. - CSV (
Accept: text/csv): the append-onlyqualityEventsCSVHeaderorder;reason,status, andevidence(#1228) follow the original eight columns;trigger_event(#1230) is appended afterevidence. revert_ambiguousrecords a checked but unresolved revert against each qualified candidate, withreason="ambiguous",status="unknown", andevidence=false. It neither changes numeric quality nor adds quality history.source_refis the revert SHA; distinct SHAs, rather than candidate rows or delivery counts, count unresolved reverts. Resolved revert event types havestatus="observed"andevidence=true; unrecognized types fail closed asunknown/false.reasonis empty on earlier rows.trigger_event(#1230) is the normalized CI workflow trigger:push,schedule,workflow_dispatch,merge_group,pull_request,pull_request_target,release,workflow_run,repository_dispatch, orother. Missing or unsupported new triggers areother. Legacy CI rows with unknown provenance and non-CI rows have JSONnulland an empty CSV cell. Only CI rows withtrigger_event="push"havestatus="observed"andevidence=true. Legacy CI rows with NULL provenance havestatus="CI trigger unknown"andevidence=false; known non-push CI rows havestatus="unknown"andevidence=false. These diagnostics do not themselves change the stored numeric quality.
GET /api/v1/quality_history
- Scope: read. Success:
200 OK. 403 in any anonymized (team/division) mode (#185, #270). Keyset-paginated bulk export of the append-onlyquality_historytransition log (#242). Same pagination/CSV contract asGET /events. - JSON:
qualityHistoryExportResponse--{next_cursor, quality_history: [...]}. Each row:id, outcome_id, developer, issue_id, old_quality, new_quality, reason, source_ref, ts. - CSV: the append-only
qualityHistoryCSVHeaderorder. - Together the two quality exports make an outcome's multiplier re-derivable
(
quality == last new_quality) from a BI export, not just the erasure-scoped DSAR export. - 🔴
source_ref's VALUE SHAPE changed for CI rows (#687) — BREAKING. The column set is unchanged, but "Change an existing CSV column's value shape" is Breaking on its own, and this is that. CI events are nowhead_sha:run_attempt:workflow_id; they werehead_sha:run_attempt. Reverts are still the bare revert commit SHA. A consumer that splits and readsparts[0]/parts[1]is unaffected; one that assertslen(parts) == 2, or that treats a third component as impossible, breaks. See the changelog entry for the full rule, including what happens to rows written before #687.
GET /api/v1/fidelity
- Scope: read (satisfied by EITHER the viewer token or the write token).
Success:
200 OK,fidelityResponse. 403 in any anonymized (team/division) mode (#185, #270, same posture asGET /events/GET /outcomes): the body names individual developers, which the anonymized modes suppress, and it cannot be k-anonymized while staying a per-developer capture report -- the 403 is returned before the store is touched. - Per-canonical-developer capture-fidelity signals (#236): the rollout view for
"which developers are (not) capturing, and at what quality." Raw
token_eventsdeveloper keys are canonicalized throughdeveloper_alias(#125) and merged, so a developer mid-rename is one row, not two. Takes no query parameters; the windows are fixed at 7d and 30d. - Top-level fields (
fidelityResponse):now,since_7d,since_30d(strings) -- RFC3339 UTC stamps of the exact window bounds the counts below are measured over, so a reader never has to assume a server-local clock for what "7d"/"30d" meant for this response.developers(array ofdeveloperFidelityJSON) -- one row per canonical developer, sorted bydeveloper. An empty array[]on an empty DB (never null).
developerFidelityJSON:developer(string) -- canonical developer identifier.event_count_7d,event_count_30d(integers) --token_eventscounts over the 7d and 30d windows.last_event_by_source(object,{source: RFC3339-UTC-string}) -- the most recent event timestamp per capture source over all history (not just the 30d window): the "is this source still delivering" signal. Always present (an empty object{}when the developer has no events), so a client can index it without a nil check; a source absent from the map has never delivered.fidelity_levels(object,{level: 30d-count}) -- the 30d event count per fidelity level (realtime/daily/estimated). Always present ({}when empty); a developer with norealtimecount is on a degraded capture path. Keys are the samefidelityclosed set as elsewhere in this contract.unknown_model_cost_share(number,0..1) -- the fraction of the developer's 30d stored spend whose(host, model)has no exact entry in the current table (#267).0when 30d spend is zero. This existing read-time lookup does not establish whether the original dollars were guessed.unknown_pricing_token_share(number,0..1, always present; #1228) -- share of tokens with an unknown pricing basis or without an exact entry in the current price table. Computed at read time over the same 30d window: sum input, output, cache read, 5m cache write and 1h cache write tokens on qualifying rows, divided by those five classes on all rows. Zero-cost rows with tokens count; zero-token rows do not. Returns0with no 30d tokens. A row qualifies when itsprice_versionis different from the active table version, its host-aware model lookup has no exact current entry, or its storedpricing_basisis missing, unrecognized, guessed, unavailable, or an aggregate approximation (including historical aggregates).token_derivedandcaller_authoritativeare known bases; both still need the active version and an exact current table entry to avoid qualifying. Other versions (including the legacy0sentinel and higher override versions after a rollback) count as basis-unknown even when the model is listed exactly today; the registry retains identity, not historical rates. Alias merging sums token counts before dividing.unknown_pricing_token_share_label(string, always present; #1228) -- exactly"share of tokens with an unknown pricing basis or without an exact entry in the current price table"(R-2026-10-14-17, D-32 C″).cost_label(string, optional; #1228) --"estimated cost"iffunknown_pricing_token_share > 0; omitted otherwise. On/fidelitythis label refers only to the token-share test; the response has no dollar field for it to qualify. A guessed-cost row with zero tokens does not contribute to the share and therefore does not trigger this label.
- #1228 is an additive read-only diagnostic with the same mode/auth gating, no
migration and no automatic repricing. An explicit audited
tierd repriceupdates the price basis, so an exact row can clear on the next read. Exact configured self-hosted estimates remain exact table entries; this share does not certify provider list rates, bound dollar error or establish capture completeness. It adds no headline-withholding threshold or/scoresfield. Localtierd scoreprints the same label and a token-weighted percentage for its Claude Code JSONL window, qualifying each affected developer/issue cost and total asestimated cost. It freshly prices the logs under the active table on every run, so it has no stored legacy price-version basis. - Additive per the matrix above: a new endpoint is additive, not a compat break. It was added in #320 (behind #236), after this contract doc (#241) was written, hence the catalog backfill (#322).
GET /metrics
- Scope: metrics (#944). In
teamanddivisionmode the read-only viewer token is rejected403with a body naming--metrics-token; with no metrics token configured, only the write token scrapes. Operator-only: security.md § The metrics token. Mounted at the root (/metrics, not/api/v1), and only when a metrics registry is wired (cmd/tierd); absent otherwise. - Success:
200 OK, Prometheus text exposition (text/plain; version=0.0.4). Not a JSON contract; the additive-only rule applies at the level of metric/label names.
Open endpoints (probes)
GET /api/v1/version
Build identity of the running process (#638). Open (no token) and mounted in read-only mode, because the deployment hardest to identify by other means is the public demo, and the demo runs read-only.
{version: <string>, commit: <string, omitempty>, modified: <bool, omitempty>, go_version: <string, omitempty>, platform: <string>, price_table: {...}}
commitis the build revision: ldflags-injected where available, otherwise the Go toolchain'svcs.revisionstamp. Absent when neither exists.modifiedis a tri-state: absent means the binary carries no VCS stamps (the shipped container is built with.gitexcluded, so this is its normal state);falsemeans stamped and clean. Absent does NOT mean clean.price_tablehere is{version, effective_date}and is provenance for the figures, not build identity — itsversionbumps only when prices change, so it can agree across a full release gap. It deliberately carries no content digests on this unauthenticated endpoint; read those from/scoresor a report manifest instead. See content identity.
GET /api/v1/health
- Scope: open. Success:
200 OK,{"status": "ok"}.
GET /api/v1/healthz
- Scope: open. Readiness probe (#49).
200when every subsystem is healthy,503when any subsystem is restarting/stopped; same JSON body in either case. As of #48 the body is extensible:{"watcher": <health.WatcherSnapshot>, "subsystems": {"<name>": {"healthy": <bool>, "detail": <object, omitempty>}}, "healthy": <bool>}.subsystemsis a map keyed by subsystem name (watcher, and future collectors). Each value is{healthy, detail}wheredetailis that subsystem's payload (forwatcher, aWatcherSnapshot). New collectors append a key here — no consumer needs new per-subsystem code.healthy(top level) is the aggregate:trueiff every subsystem is healthy. It mirrors the200/503status code.watcher(top level) is retained for backward compatibility and duplicatessubsystems.watcher.detail; it is deprecated in favour of thesubsystemsmap. Public watcher fields:status, last_error (omitempty), last_event_ts (omitempty), started_at (omitempty), next_retry_at (omitempty), restart_count, watch_add_failures, last_watch_add_error (omitempty). Nonempty errors are classes:last_erroriswatch_failedandlast_watch_add_erroriswatch_add_failed. Full errors remain in logs. This route is outside auth, so the same redaction applies even when a valid viewer or admin token is supplied.- Do NOT wire a k8s liveness probe here.
GET /api/v1/livez
- Scope: open. Liveness probe (#49); always
200. BodylivezResponse:{status: "alive", uptime_s: <int>, version: <string>, commit: <string, omitempty>}.commitwas added additively by #638; it is absent on a binary carrying no build identity.versionis the documented feature-detection signal.
Event 3 corrective scoring contract (#1235)
Event 3 is off by default. The API's WithScoringPolicy option accepts the
scoring policy contract; its zero value selects legacy scoring. An enabled policy
requires resolved state and event3-v2. These interfaces do not enable scoring;
see Enable Event 3 scoring for
operator configuration. Existing flag-off score bodies and values retain their
legacy meaning.
Scores, detail, segments and comparison
Enabled /scores, developer detail and /scores/compare responses disclose
correction_policy: {scoring_policy: "event3", correction_rule_version: "event3-v2", rubric_version: 2}. Legacy uses rubric 1. This stamp comes from
scoring.ScoringPolicy.Stamp; readers must not infer policy from a size or
quality value. Flag-off score responses retain their previous representation.
Unknown or unresolved enabled policy refuses computation.
When enabled, developer score rows gain outcome_n, the raw outcome count used
for activity and coverage. sample_n becomes the number of eligible growth
observations used for ranking floors. A pure repair can receive allocated points
while adding zero samples. Cross-author declarations awaiting confirmation use
growth eligibility without moving either participant's contribution. Unknown
growth remains explicitly unknown. Counts follow the same projection through
rollups and folding; team/division responses continue to withhold unfloored
counts and identifying relation evidence.
Headline, developer detail, work-type segments and comparison consume persisted
whole-chain projections before partitioning by repository, window, work type,
developer or historical team. Costs stay with the original cost records. Detail
issues expose an enabled-policy projection object with the fields described
below. Bootstrap resamples only eligible growth observations; excluded repairs'
points and their cost shares are fixed terms. Pooled issue cost is split across
raw outcomes before partitioning and counted once. tierd score remains the
local cost-attribution command.
Raw evidence and subject access
POST /api/v1/outcomes preserves omitted/null diff stats separately from genuine
zero stats; ordinary outcome fallback weight is unchanged. Corrects: references
never invent issue attribution. The existing numeric outcome response/export
columns remain compatible; nullable evidence fields disclose availability.
GET /api/v1/developer/{id}/export adds correction_declarations,
corrective_relations, correction_audit, correction_assessments and
correction_allocations arrays. Endpoint matching includes either party, aliases
and reviewer evidence across the connected chain. DELETE /api/v1/developer/{id}
adds corresponding keys under deleted, counting rows changed. Assessment and
allocation counts measure rows scrubbed/frozen; subject declarations are deleted
after connected text and links are scrubbed. Erasure removes endpoint identities, comparison SHAs and
subject-bearing text before deleting outcomes, severs identifying indirect links,
and retains numeric allocations. Any erased party freezes the allocation; the
survivor's numbers are never redistributed. The reviewed-allocation schema upgrade
ensures eraser compatibility; corrective evidence pins add no schema version.
Existing-outcome revert exclusion CLI (#1277)
tierd outcomes exclude-reverts [--dry-run] [--db PATH] [--repo owner/name] [--since DATE] [--out FILE] produces a deterministic version-1 JSON plan;
--dry-run is the default. It opens the existing database read-only and refuses
a schema mismatch. Candidates have repo, pr, outcome_id, merge_sha,
evidence_source, head_ref, title, verdict (exclude|keep|unknown) and
reason. Counts include total, exclude, keep, unknown. Approval uses
lowercase SHA-256 hex of the raw plan file bytes (shasum -a 256 plan.json);
the dry run prints this digest to stderr, outside the JSON plan. The plan has no
embedded digest. Top-level fields in order are version, repo, since,
candidates, counts; candidates sort by stored repository, PR, outcome id.
The source is payload or api (including unavailable API fallback for missing
stored identity). A supplied evidence merge SHA must match the stored SHA;
API evidence must supply it. For a matching SHA, usable action=closed,
merged=true payloads take precedence over later edits, then the newest usable
payload without a SHA, then API fallback. API fallback uses the current title.
Only API 404 becomes unknown; authentication/permission errors, exhausted
retries and interruption abort without writing a plan. Incomplete evidence or
a mismatched API merge identity remains unknown.
Raw branch hashes and issue attribution never establish a revert.
tierd outcomes exclude-reverts --apply PLANFILE --actor OPERATOR --approved-digest SHA256 [--db PATH] requires recorded operator sign-off on
that exact dry-run plan. Operators must review and approve each install's plan
and must not run apply unattended. Actor/approval are self-asserted, not
authentication. Apply requires the file to be byte-identical to the dry-run
encoder's canonical re-encoding of the decoded plan (two-space indentation and
a final newline); duplicate/case-variant keys, reformatted whitespace and unknown
JSON fields are refused. It checks the raw-bytes approval digest, version and
counts, and each row's evidence for self-consistency against the shared
classifier, including consistency between reason and verdict. payload
rows are re-verified against retained payloads before any exclusions are applied;
missing or changed retained evidence requires a fresh dry run and approval.
Apply accepts no filters or API/output flags and makes no API requests. Each
exclude row is verified and moved through the existing exclusion mechanism
atomically under its write lock. Earlier rows remain committed if a later row
is stale or erased. Matching replay is a no-op. The existing audit row holds
actor, time, approved raw plan digest and candidate evidence; no new schema or
unique key is introduced. Exit status is 0 on success, 1 on invalid input or
apply failure; success JSON contains plan_digest, applied, replayed,
skipped and actor_identity. Sealed report bytes are unchanged. Historical
unsealed manifests can mismatch after current outcomes are excluded. See the
operator procedure.
Operator lifecycle
tierd corrections list|show|confirm|dispute|withdraw|settle --db PATH operates
on local recorded evidence. Mutations use --relation ID --revision N --actor LOGIN --role ROLE; actor/role are self-asserted local input, not authentication.
This installation's confirmer and settler is the operator. Confirmation requires
an actor different from the declarer and reviewed sizing: --reviewed-original,
--reviewed-own, --reviewed-before, --reviewed-after (or explicit
--pure-repair), --scope, --rationale, --reviewer, --reviewed-at.
Comparison identities use --comparison-base, --comparison-before,
--comparison-after. Review time is RFC3339; weights are exact rational values.
Disputes and withdrawals require --reason. A dispute or withdrawal after
confirmation retains the last allocation until operator settlement with
--settlement uphold|re-assess|remove; re-assessment requires a reviewed sizing
record. Revision checks reject stale decisions; replay of an identical audited
action is idempotent. Confirmation never enables scoring. Distinct-root
relations remain pending under D2; launch scoring makes no multi-root claim.
Outcome and evidence exports
GET /api/v1/outcomes retains the meaning and types of existing weight,
quality, additions, deletions, changed_files and all existing CSV positions.
JSON adds nullable raw_additions, raw_deletions, raw_changed_files and
diff_stats_available. SQL NULL differs from a genuine numeric zero; pre-cutover
API all-zero stats remain unavailable evidence. A persisted projection adds
raw_weight, reviewed_own_weight, marginal_credit, credited_weight,
projected_quality, allocated_points, ranking_eligibility, status,
pending_reason, rule_version, provisional, disputed, frozen.
Sizing/share/marginal fields retain exact rational strings. Quality and allocated
points are numeric. allocated_points is final participant credit, consumed
once; never reconstruct it by dividing and multiplying by quality. D1 preserves
each follow-up's own quality. Frozen allocations survive endpoint erasure without
redistribution.
The CSV prefix through push_day is unchanged. Append this tail in order:
raw_additions,raw_deletions,raw_changed_files,diff_stats_available,reviewed_own_weight,marginal_credit,credited_weight,projected_quality,allocated_points,ranking_eligibility,correction_status,correction_pending_reason,correction_rule_version,correction_provisional,correction_disputed,correction_frozen
GET /api/v1/corrections/{kind} adds read-scoped exports for relations,
audit, assessments and allocations. Each supports the bulk
export pagination, since/until, opaque cursor and Accept: text/csv contract,
and returns {next_cursor, <kind>: [...]} in JSON. These exports window by
recording time, while the manifest digest windows by endpoint dependency.
Nullable endpoints and text reflect erasure; a missing endpoint is never a new
identity. Free text passes through CSV formula protection. Team/division modes
return 403 before any evidence query; identifying evidence is not_shown.
Digest and manifest verification
New digests use tierdig2: plus 64 hex characters. This is an explicit
scheme change, including new domain frames for token events. Token frames retain
17 fields. Outcome frames increase from 11 to 16: the original 11, then nullable
additions, deletions, changed files, merge occurrence, and the legacy stats
availability bit. Nullable fields use n for NULL or v plus stored text;
timestamps use raw stored bytes. No receipt-time fallback substitutes for merge
occurrence evidence. Framing is an eight-byte big-endian byte length followed by
UTF-8 payload, with fixed arity per row. Floats retain the exact canonical
hexadecimal representation with negative zero normalized.
events_digest.rows still counts token rows in [token_since, until);
outcomes_digest.rows still counts outcome rows in [since, until), under the
same repository scope. Neither population changes. The additive
corrections_digest is separately domain-separated and has {value, counts};
counts contains declarations, relations, audit, assessments,
allocations, dependency_outcomes. Its graph starts with window outcomes and
endpoint merge occurrence evidence, then closes over connected declarations,
relations, audit, assessments, allocation chain versions and outcome evidence.
It includes ancestors/children outside the window and dispositions recorded
later. Independent repositories remain outside the selected dependency graph.
Each table has its own domain and explicit ordered column list; NULL, integer,
float and text have distinct frame tags. Erasure removes identifiers from the
stored inputs; surviving frozen numeric allocations remain digestible.
tiermanifest1 is retained because all manifest changes are additive:
scoring_policy,correction_rule_version,rubric_versionpin the same scoring rule stamp.rubric.versionagrees: legacy/legacy-v1/1 or event3/event3-v2/2.outcome_counts: {outcome_n, sample_n}pins raw and eligible counts separately.corrections_digestpins corrective evidence even while scoring is disabled.watermarks.ledgers.max_correction_audit_id,correction_audit_countandcorrection_projection_revisionpin install-wide audit and projection state. They can move without a change in this window; the scoped digest identifies whether this window's evidence changed.
Watermarks, counts, raw outcome inputs and all digests use one read snapshot.
TIER resolves the stored scoring policy in that same snapshot for each request;
the policy stamp and eligible counts cannot use stale startup policy options.
Manifest policy provenance uses only the top-level evidence fields above, and the
projection revision uses only watermarks.ledgers.correction_projection_revision.
The nested correction_policy receipt remains part of enabled score responses.
Team/division manifests withhold outcome_counts and all three digests along
with watermarks.window, declaring the withhold in kanon_suppressed and
digests_omitted. TIER also withholds correction_projection_revision in
these modes. Policy stamps and the remaining install-wide ledgers stay visible.
tierd verify-report recognizes rubrics 1 and 2 under their own pinned policies
and passes that policy to the scoring handler for replay. A manifest without
Event 3 fields uses legacy-v1; a rubric-2 manifest requires explicit policy pins.
Unknown policies/rules, contradictory policy/rubric pins, unknown digest schemes
and equal hashes with contradictory row counts are uncheckable (exit 2), never
agreement. Other rubric versions report CHANGED under R-2026-10-14-20; the two
rubric pins must still agree. An actual changed input gives exit 1. Old tierdig1 pins are
recomputed with their original domains, 17/11 frames, column projections and
scope, so an unchanged archived report verifies cleanly; values from different
schemes are never compared as tampering. No scoring flag is enabled by verification.
The current stored correction policy and install-wide corrective counters are
informational; a live policy change cannot override the pinned replay policy or
refute an unchanged report. The verifier checks the replayed score body's nested
correction_policy receipt independently of the requested manifest policy.
Sealed reports likewise recompute with their recorded policy, rule and rubric.
API changelog
-
#1425 — personal-key custody (operator ruling E, 6a). Add-person now returns a claim code instead of a personal key. Direct personal issue and reissue through
/org/keysare refused; personal revoke is root only. Adds claim redemption, claim issue/recovery, holder rotation, leave cancellation, audit/notices and device creator attribution. Claim codes are hash-only, single-use and expire after 72 hours. DSAR addsorg_claimsandviewer_noticesarrays when present, excluding code hashes; legacy route access and existing empty shapes stay fixed. -
#1368 / #1375 -- sealed team/division levers (ADDITIVE fields, v0.6.3).
GET /api/v1/scores?period=YYYY-MMadds the optional numeric fraction in[0,1]teams[].cache_read_share(cache reads / all input-side tokens). Every published group, including foldedother, first needs k counted seats with both captured spend and merged work; failure withholds all groups' levers. The cache partition needs k contributors with cache reads and k without them, counting distinct cost contributors with positive input-side tokens (input + cache read + cache write), including bots and spend-only people. Zero-input contributors count on neither side; mixed cached and uncached input puts a contributor on the cache-read side. k is clamped to at least 3. Optionaldata_quality.team_levers_withheldistruewhen levers are withheld;data_quality.team_levers_withheld_reasonis one ofgroup_seats_below_korcategory_seats_below_k. Withheld share keys are absent, never zero or null. Live windows, anonymized/scores/compareand org totals carry no levers. Previously sealed bodies remain immutable; pre-lever bodies are verified/recomputed without adding these fields or their notice. -
#1230 / #1302 -- CI trigger provenance (ADDITIVE field; BEHAVIOURAL evidence/status change).
GET /api/v1/quality_eventsadds nullable JSONtrigger_eventand appends the CSV column afterevidence. CI evidence is now push-only: legacy NULL CI rows return the new statusCI trigger unknownwithevidence=false; known non-push CI rows returnunknown/false. Push CI and resolved revert rows remainobserved/true. Numeric quality is a separate stored value. -
#972 / #1279 --
GET /api/v1/diagnostics/captured-dollars(ADDITIVE). New public v1 read endpoint with the same stability promise as the other v1 reads. Read scope, developer mode only (403in team/division modes), with the samesince/until/before/repobounds as/scores. Live diagnostic evidence is separate from reproducible scores; no released/scoresfield is removed or changed.
Newest first. Every entry names the change, its classification, and the issue.
Backfilled entries (#185, #187, #191, and the additive-column history)
predate this discipline and are recorded here so the record is complete.
-
#1235 -- Event 3 scoring contract (ADDITIVE fields/option/CLI/routes; POLICY-GATED semantic change). The contract adds
WithScoringPolicy,outcome_nand the policy/rule/rubric stamp, plus assessment/allocation DSAR arrays and endpoint-aware scrub counts. It also adds audited corrective commands, enabled-policy SampleN and shared projected readers/bootstrap, detail projections, append-only outcome CSV fields and read-scoped corrective evidence exports. Flag-off scores keep legacy bytes. See the Event 3 contract for field names, actor limitations, count populations and anonymized suppression. Production enablement remains off by default and requires operator configuration. -
#1235 -- Event 3 evidence pins (DIGEST SCHEME CHANGE; ADDITIVE manifest).
tierdig2adds nullable stats, merge occurrence and availability to outcome frames (11 → 16), and a domain-separated corrective digest with typed counts over full connected dependencies. Existing digest row populations stay intact.tiermanifest1adds policy/rule/rubric, raw/eligible counts and corrective audit/revision pins in one snapshot. The verifier replays the pinned policy, verifiestierdig1with its old definition, and refuses unknown schemes rather than calling a scheme transition tampering. The digest and verifier changes ship together; no schema bump or production flag enablement. -
#1228 -- unresolved revert diagnostics (ADDITIVE export fields/type).
GET /api/v1/quality_eventsaddsreason,status, andevidenceto JSON and appends them to CSV.revert_ambiguousis an unresolved check, never penalty or quality evidence.GET /api/v1/outcomesadds optional JSONquality_statusand appends the CSV column;unknownis diagnostic and leaves numeric quality unchanged. See the endpoint schemas above. -
#1228 -- unknown-pricing token share on
/fidelity(ADDITIVE response fields).GET /api/v1/fidelityadds per-developerunknown_pricing_token_shareandunknown_pricing_token_share_label, both always present, plus optionalcost_label("estimated cost"iffunknown_pricing_token_share > 0). The share measures tokens with an unknown pricing basis or without an exact entry in the current price table over the 30d window; it is0with no 30d tokens. -
#1074 -- PR attribution at insert (ADDITIVE request fields/rule value).
POST /api/v1/eventsaccepts optionalpr_numberandbranch_hashfields. At insert, an unattributed event in a real repository and an unsealed month resolves through exactly one real-issue outcome on(repo, pr_number)and recordspr-number-at-insert, a server-assigned rule never client-sent. -
#1144 -- scoped ingestion (ADDITIVE auth/response; BREAKING collision behaviour).
POST /api/v1/eventsalso accepts the new ingest scope alongside write/admin. Its receipt gains the additiverejected_cross_developercount. Cross-developer idempotency-key collisions no longer merge token counts: they are rejected and counted inrejected_cross_developer. -
#1095 -- S03-3 requires an explicit paid amount (BREAKING request behaviour).
POST /api/v1/actual_spendandPOST /api/v1/org_actual_spendnow return400whenactual_paid_usdis omitted ornull, instead of returning201and storing a $0 row. Clients must supplyactual_paid_usd; an explicit0remains valid and returns201. -
#1082 -- token-event cost ceiling (BREAKING request behaviour; ADDITIVE schema/metric).
POST /api/v1/costsimports withcost_usd > $10,000or any token field> 1e12now return400instead of2xx; split the import across rows. An identical keyed replay of a legacy over-ceiling cost remains an idempotent201.POST /api/v1/eventsremains2xx: over-ceiling events are stored with cost clamped to $10,000 and marked. New nullabletoken_events.cost_clampedrecords the mark, andtier_token_event_cost_clamps_totalcounts clamp attempts. -
#1095 -- healthz watcher errors are classes (BREAKING value change). Explicit exception to rule 3: raw error text exposed filesystem paths to unauthenticated callers. Keeping that text under the old fields while adding new fields, or waiting for
/v2, would preserve the security exposure. Thereforelast_errorandlast_watch_add_errorkeep their field names for compatibility, but their values become error classes in place; no new fields are added. This exception is limited to these two fields on/healthz. In bothwatcherandsubsystems.watcher.detail, nonemptylast_erroris nowwatch_failedand nonemptylast_watch_add_erroris nowwatch_add_failed, replacing raw error text. Empty errors remain omitted. Full errors remain in logs; consumers parsing error text must use the classes instead. This applies to every caller, including authenticated callers.last_event_ts,started_at, andnext_retry_atretain their existing values and omission rules. -
#1095 -- CSV formula-leading text is neutralised (BREAKING CSV value shape; security fix). The four bulk exports now prefix a single quote to text cells starting with
=,+,-,@, tab or carriage return. Numeric columns, JSON output and CSV column order are unchanged. -
#1042 -- write bodies must end after exactly one JSON value (BREAKING request-side behaviour; no field changes). Every write endpoint that decodes a JSON body now refuses anything after its one value except whitespace: a second value, trailing garbage, or a stray
]or}is a400with the existing "request must contain exactly one JSON object" (or "array") message. Before, a stray]or}({...}]garbage,{...}}) was accepted and stored with a2xx. A body whose value fits the size cap but whose trailing bytes do not is now a400; on/events,/outcomesand the bulk/org_hierarchyimport it carries the same "request body exceeds N bytes" message as an oversized value. Breaking: a client that sent such a body goes from2xxto400. Well-formed clients (the shipper,tierd hierarchy import, curl with one JSON value) are unaffected. -
#913 -- sealing waits for every configured source (ADDITIVE; schema version 6). A new
stall_reasoncode,source_behind: the first owed month is held because a sourcetierd servepulls from has not settled past its end. Serve records each source it starts insource_watermarkand retires the rest; a source's successful pass advances its settled-through time only over coverage with no gap. A source certifies only data it recorded: a pass that skipped in-scope spend a later pass re-reads settles nothing, and spend it can never re-read (a damaged or failing log its cursor moved past, a skipped row, an excluded subagent call, a deleted log holding a call) is stored as lost, so every month the lost span touches stays held untiltierd seal --skip. The 404 for a held month names each source and its settled-through time, the gap it cannot fetch, or the span it lost.sealable_atandnext_seal_atkeep their meaning: a held month reads as awaiting its seal, never as not sealable. Until serve has registered its sources on the database, every month is held. A month before a source's first recorded coverage never seals automatically; its 404 says so and names the remedy (arm a later month, or setactive_sincefor subscription fees).tierd seal --armrefuses such a month;tierd seal --skiprecords it as a gap with categorysource_behind, once confirmed. The session watcher, webhooks,tierd shipand/costspushes are covered by--report-graceonly. The database moves to schema version 6, one-way: a version-5 tierd refuses it. -
#913 --
tierd serveseals and serves monthly reports (BREAKING inteam/divisionmode). The sealed reads described below are now served. Inteamanddivisionmode,/scores,/report_manifestand/scores/compareanswer only from sealed calendar months. A request that was a live window (since/until/before,?team=,?work_type=,?repo=, or the_a/_bbounds) is now a400. Until the operator arms sealing (tierd seal --arm YYYY-MM|earliest, orseal_from), every read is a404beginningsealing not armed. A writable serve runs a seal pass at startup and then every hour; each pass seals every owed month whose grace lag (--report-grace, default 14 days) has ended, oldest first. A--read-onlyserve never seals: it serves the months a writable server sealed in the same database, and names a month that is overdue. Everyteam/divisiontierd servebuilds its sealer; an anonymised server without one would answer the three reads with a503whoseerrorsays it has no sealer, never a live body. At startup serve logsreport_grace, and it WARNs when the current aggregation, k or fold rule differs from the config the newest sealed month was sealed under, naming the first month the current config applies from.tierd verify-reportstill replays a live-window manifest over its window. Developer mode is unchanged. One-way: a sealed month is never recomputed after its seal, and the first seal pins the earliest month for good. The database is already at schema version 5, so an older tierd refuses it. -
#913 -- a sealed read names a stalled month (ADDITIVE to the sealed reads below). While sealing is stalled, the sealed manifest and every sealed
404(including/scores/compare's) carrystalled_period(YYYY-MM),stall_reasonandowed_since(the month'ssealable_at), and the manifest'snext_seal_atand theTier-Next-Seal-Atheader are absent. Sealing is stalled at the first month owed after the newest sealed or gapped month when the last seal pass failed there, or, while no seal pass is running, when it is more than two seal passes past itssealable_at, so a restarted or read-only server still names it.stall_reasonis a failure category code, never an error's text. The codes aresealFailures,sealFailureStorage,sealFailureInternalandsealUnreportedininternal/api/seal.go:write_lock_busy,erase_raced,clock_behind,refold_mismatch,floor_moved,not_next,overlap,gapped,not_sealable,cancelled,storage,internal, andunreported, which means no seal pass has reported on the month while none is running, so check thattierd serveis running and not read-only. A code is never reassigned, and a later release may add codes, so treat an unknown code as an unnamed failure. All three fields are absent while nothing is stalled, and none appears on/healthz,/livezor/health.tierd doctor --serverreads them from/report_manifestand reports a stalled month as a WARN, with thetierd seal --skipcommand only for astalled_periodthat is exactlyYYYY-MM.tierd seal --status --aggregation team --db ~/.tier/tier.db --server http://127.0.0.1:8080prints the sealing state from the database, opened read-only, and asks--serverfor the reason; use the same flags, config file and environment asserve.tierd seal --helplists its exit codes. -
#913 -- permanent seal gaps (ADDITIVE; schema version 5). No endpoint changes yet. The database gains
sealed_gap, an append-only record of a month the operator marks as never to be sealed, so the months after it seal. The database moves to schema version 5, one-way: a version-4 tierd refuses it. -
#913 -- sealed monthly reads (BREAKING in
team/divisionmode; served from thetierd serveentry above). Inteam/divisionmode/scoresand/report_manifestanswer one sealed calendar month:?period=YYYY-MM, or none for the latest sealed month (a grace raised later never hides a month already sealed)./scoresreturns the stored body byte for byte, so its sha256 equals the manifest'sbody_digest; the month and its markers ride in response headers, not a wrapper:Tier-Period,Tier-Sealed-At,Tier-Next-Seal-At,Tier-Earliest-PeriodandTier-Latest-Period(the last three equal the manifest'snext_seal_at,earliest_periodandlatest_period). The manifest (manifest_schema: tiersealedmanifest1) carriesperiod,period_start,period_end,sealed_at,next_seal_at,earliest_period,latest_period,config(aggregation,period_size,k,fold_rule,digest),body_digest,tool_version,commit, andconfig_gapwhen the current config differs. A GET never writes: once the operator arms sealing, a background pass seals each month after its grace lag, permanently, oldest first, and a month it fails to seal holds back every later one. A sealed body always carriesdata_quality.attribution_coverage: "not_shown"and nevercost_coverage_safe_since. A sealable month with no data is a200with the same top-level anddata_qualitykeys as a k-suppressed month, exceptkanon_suppressedandattributed_outcome_share, which an empty month never carries, and the conditional quality signals (such asunjoined_developersorzero_token_outcome_count), which appear only when their condition holds, so a k-suppressed month can carry ones an empty month lacks. A month not sealed is a404withperiod,aggregationanderror: while sealing is not armed,errorbeginssealing not armed; for a month sealable but not yet sealed it beginsmonth is sealable but not yet sealedand, while the background pass is failing at that month or an earlier one, names that month and why; otherwise it says why the month is not sealable, andsealable_atis present when the month will become sealable. With nothing sealed yet, the default read's404names the latest sealable month, or the earliest when that is later.since/until/before,team,work_typeandrepoare400s, and any other parameter is an unknown-parameter400listingperiodalone. Sealing is armed by the operator settingseal_from, a month; until then nothing is sealed. The earliest sealable month is the later ofseal_fromand the first full month after the cost horizon (a month starting exactly at the horizon counts as full), pinned by the first seal: older data imported after the first seal never appears in anonymised modes./scores/comparecompares two sealed months:?period_a=YYYY-MM&period_b=YYYY-MMwithperiod_aearlier, or neither for the two latest sealed months: the month the default/scoresread serves and the month before it or, while that month is inside a raised grace lag, the latest sealed month before it. The rows are refolded at the months' sealed k from their stored fold inputs, never from a live window or from the two stored bodies, so a team is named only if it clears k in both months, and each month's own residual must reach k or the whole comparison is withheld. The body keeps the live shape;modeis the level both months were sealed under, each window'ssince/untilare the month's bounds and itsdata_qualitycarries the live compare window's fields from the stored body, exceptcost_coverage_safe_since, which a sealed body omits; the two sides oftotalare the stored bodies'totals (a zero side for a month with no rows), so they equal/scores?period=; andprice_tableis the active table, as on the live read. The months ride in headers:Tier-Period-A,Tier-Period-B,Tier-Sealed-At-A,Tier-Sealed-At-B, plusTier-Next-Seal-At,Tier-Earliest-PeriodandTier-Latest-Period. A reversed or equal pair, one period alone, and anysince/until/before/_a/_bbound are400s naming?period_a=and?period_b=. A month not sealed is a404naming it, with theerrorandsealable_atof/scores?period=for that month; with neither period given, fewer than two sealed months is such a404. Two months sealed under different configs, or under a fold rule this binary does not refold, are a409carryingperiod_a,period_b,config_aandconfig_b. Developer mode is unchanged. -
#823 -- the
/eventsexport names the rule that assigned each row's issue (ADDITIVE; one export column).GET /api/v1/eventsappendsattribution_ruleas the last JSON field and the last CSV column:branch,worktree-cwd,worktree-toolpath,carry,pr-number-at-insertorbranch-at-merge(#1074: server-assigned, never client-sent);legacywhere no rule was recorded (every row stored before #823);unknownfor a stored value outside that set. The DSAR export (GET /developer/{id}/export) shows the stored value as stored:nullwhere none was recorded. No field is retired; seeGET /api/v1/events. -
#975 -- erasure follows retired canonical ids (ADDITIVE; schema version 4).
DELETE /api/v1/developer/{id}addsdeleted.canonical_id_history(always present), anddeleted.sealed_personnow also counts keys computed from a canonical id an alias edit retired, in periods sealed while the link was active. The database moves to schema version 4, one-way: a version-3 tierd refuses it. -
#823 --
POST /eventsacceptsattribution_ruleand theunattributed:foreign-repobucket (ADDITIVE on the server). Both are new accepted inputs; every pre-#823 request is accepted unchanged. The new bucket can appear indata_quality.unattributed_buckets, and is not exploratory spend. An older tierd400s either, so the server ships first. -
#913 -- erasure tombstones sealed person keys (ADDITIVE; schema version 3).
DELETE /api/v1/developer/{id}addsdeleted.sealed_person(always present: sealed person keys tombstoned, never deleted), andtotal_deletedincludes it, so an erasure whose only effect is tombstoning returns200rather than404. The database moves to schema version 3, one-way: a version-2 tierd refuses it. -
#864 -- anonymized modes publish one breakdown per install and window (BREAKING behaviour in anonymized modes; three additive fields). Before, a
team/divisionresponse published the group rows beside a second and third breakdown of the same window -- the work-type segments and the cost composition -- and they differenced to a group below k even when every published row had k people: pooled team rows minus work-type totals recovered a 3-person group's $9.21, and a model one developer used published that developer's $4.44 incost_composition.by_model. Now, inteamanddivisionmode only:?work_type=on/scoresis a400;work_types(with its per-segmentteams,aggregationandkanon_suppressed),segment_reconciliation,cost_composition,data_quality.unattributed_buckets,data_quality.exploratory_cost_shareanddata_quality.mixed_price_versions(on/scoresand both/comparewindows;price_table.versionthen names only the active table, and a floored warning is to return through a follow-up issue) are absent, and so isdata_quality.attributed_cost_share; spend under theunattributedpseudo-developer is dropped from every figure, which narrows #856's "an uncounted identifier's figures stay summed" for pseudo-developers; and when theotherbucket does not reach k under #856's counting rule and per-measure floor, the WHOLE response is withheld --teams(named rows included) andtotal-- and declared inkanon_suppressed, whosewithheld_cost_compositionandwithheld_segment_reconciliationare nowfalse./scores/comparewithholds its whole comparison when its own residual or either window's ownotherbucket does not reach k. Fields (additive):kanon_suppressed.withheld_teams(always present in the object):truewhen the rows were withheld with the residual;data_quality.excludes_unattributed_spendanddata_quality.attribution_coverage, both described underGET /api/v1/scores. Breaking: anonymized responses lose the keys above, and a response that used to carry named rows beside a withheld residual now carries none. No key leaves the schema developer mode reads, and developer mode is unchanged. NoDeprecation/Sunsetheader is sent: the keys are withdrawn to stop a disclosure, which a sunset period would prolong. #937 (an audit that solves every published sum) is the route to bring a work-type or per-model view back to anonymized modes. Ships in the first tagged release after this merge. -
#944 -- the read token no longer scrapes
/metricsin anonymized modes (BREAKING auth behaviour; one new credential; no schema bump). Two scrapes of/metricsa short interval apart difference into one person's spend and activity, below the k floor. Now: inteamanddivisionmodeGET /metricsanswers the read-only viewer token with403(body names--metrics-token) and accepts the new metrics token (--metrics-token,TIER_METRICS_TOKEN,@/path/to/file, ormetrics_tokenin YAML) or the write token; with no metrics token configured, only the write token scrapes. The metrics token is refused403on every other route and on the proxies, andserverefuses to start when any two of the write, read and metrics tokens are equal.developermode is unchanged, and with no token configured/metricsstays open in every mode. Breaking: a Prometheus job scraping an anonymized install with the read token gets403; point it at the metrics token (security.md § The metrics token). The flag, env var and config key exist only from this release; an older binary refuses--metrics-tokenand a config withmetrics_tokenat startup, so upgrade tierd with the metrics token set, then repoint Prometheus. #908 (splitting the token scopes) may later subsume the metrics token. -
#854 --
POST /costsrefuses rows a running org poller already counts (BREAKING behaviour; one additive request field; one additive export column; no schema version bump). Before, a manual row for the provider of a running Anthropic Admin or OpenAI Usage poller was accepted201, and when it was the polled org's usage it was counted twice: the poller brings the org's recorded usage up to the provider's total and never subtracts/costsrows. Now: such a row is refused400unless it declares"billed_to": "other"; seePOST /api/v1/costs. The201-to-400change happens only on a server that runs that provider's poller; every other install behaves as before. A keyed retry of a row stored before the upgrade, sent withoutbilled_to, now gets400on such a server. Field:billed_to(optional request field, additive; only"other"). ⚠️ Version skew: upgrade the server first. An older server rejects the unknown field with400(DisallowUnknownFields), so a client that sendsbilled_toto it fails on every row. Anoverride: truerequest withoutbilled_tostill corrects a row that already owns its key, which is how a stored double count is fixed; one whose key owns no row is refused like any other. At startuptierd servewarns, per running poller, about undeclared manual rows on days that poller covered. How to list rows stored before the upgrade, by API or SQL, is underPOST /api/v1/costs. Export:billed_tois appended to theGET /eventsJSON and CSV (empty when not declared) and added totoken_eventsrows inGET /developer/{id}/export(null when not declared). No field is retired. Schema: the schema version is unchanged, becausebilled_tois an added column, so a binary older than #854 can still open the database. Running one against it admits undeclared manual rows again (it has no refusal), and the #854 startup warning will count them. -
#938 -- a push outcome's owner is ordered by GitHub's push time (behaviour fix; one additive export column; no schema version bump). A push outcome was re-owned to its earliest commit by commit time, which the committer sets, so a later push of a commit dated earlier the same UTC day took another developer's row. Now: the order is
repository.pushed_atfrom the push payload, then commit time, then SHA. Entries stored before the upgrade and pre-ledger markers sort first, so no stored outcome changes owner. A push with no usablepushed_atis recorded but sorts after every push time and, among such pushes, by arrival. This holds only while no binary older than #938 writes to the database: the schema version did not change, so such a binary can still open a database a #938 binary has opened, and every entry it writes getspush_order0 and keeps precedence permanently. Do not run a pre-#938 binary against that database. Export: eachpush_outcome_commitsrow inGET /developer/{id}/exportgainspush_order(integer, additive): the commit's earliest push time in Unix seconds,9223372036854775807for a push without one, and0for a pre-ledger marker or an entry stored before #938. Erasure is unchanged. Metrics:tier_push_missing_pushed_at_totalcounts commits recorded without a usablepushed_at. No field is retired. -
#856 -- the k-anonymity floor counts people, not identifiers (BREAKING behaviour in anonymized modes; one additive response field; one additive export key). Before, any identifier with spend or merged work filled a seat, so hand-entered
/costsrows, push-only commit authors, identifiers nobody put on the roster, and bots could lift a group to k, and a five-person row whose cost was carried by one person published that person's exact cost. Now: the rule underdata_quality.uncounted_active_idsapplies toteams, everywork_types[].teams, and each window of/scores/compare, with the per-measure floor on top. Field:data_quality.uncounted_active_ids(additive,omitempty,/scoresonly).kanon_suppressedgains no field;developersnow counts people under the new rule. Export:GET /developer/{id}/exportoutcomesrows gainauthor_type(additive). Breaking: in anonymized modes a group or residual that was published may now be withheld, withtotal,cost_compositionandsegment_reconciliation(on/scores/compare,total) withheld alongside it as in #593. No field is retired, so noDeprecation/Sunsetheader is sent. Ships in the first tagged release after this merge. -
#914 -- alias edits are dated (BREAKING behaviour; no schema bump). Team and division rows now place each event by the dated membership of the identifier it was recorded under, not its canonical developer's.
POSTandDELETE /developer_aliasand everyorg_hierarchywrite append membership rows, dated by the server clock, for each identifier whose placement they change, so no alias edit moves an identifier's past between teams. Events under an alias from before it was added now stay in the team the identifier was in then (usuallyother). Both alias routes can now answer409(clock behind) andDELETEcan answer503. The upgrade gives each existing alias its person's dated history, so no past window changes. Both guarantees hold only while no binary older than #914 writes to the database: because the schema version did not change, a binary built frommainbetween #912 and #914 can still open a database a #914 binary has opened, and its alias edits append no membership rows, which the one-time upgrade does not repair, so events under those aliases then place as unassigned. Do not run such a binary against that database; no released version is affected, since v0.5.0 and v0.5.1 were never tagged. Until #913's sealed periods ship, an alias merge or unmerge can still change whether a past team clears the k floor; #913 closes it. -
#849 -- a squash merge counts once whatever order its events arrive in (behaviour fix; additive export and manifest keys; no schema version bump). With push capture on, a squash merge whose
pusharrived before itspull_requestwas stored twice: a push outcome and the PR outcome. Now: every captured push commit is recorded in a per-commit ledger, and the PR outcome — from the webhook,tierd backfillorPOST /api/v1/outcomes— removes its merge commit's contribution in the same transaction. A push outcome that held only that commit is deleted; one that holds other commits stays, and itsdeveloperandtsare re-derived from its earliest remaining commit (commit time, then SHA) — so a push outcome's owner can change after it was first written. Push outcomes written before the upgrade are never deleted. The push outcome keeps its(repo, issue, UTC day)grain,0.5weight and every export column. Export:GET /developer/{id}/exportgainspush_outcome_commitsandpush_outcome_auditarrays (additive); erasure clears the subject's rows in both and re-owns a push outcome that also holds another developer's commit. Manifest:watermarks.ledgersgainsmax_push_outcome_audit_idandpush_outcome_audit_count(additive,tiermanifest1unchanged);tierd verify-reportreports them aspush reconciliations. Metrics:tier_push_merge_commit_captures_totalcounts pushes that carry aMerge pull request #Ncommit beside captured commits (the #934 leak). No field is retired. Ships in the first tagged release after this merge. -
#886 -- team and division rows are dated (BREAKING behaviour; one additive export key; schema version 2). Before, every window was grouped by today's team map, so reading
/scores, moving one developer and reading again showed that developer's figures in the difference of two team rows. Now:teams(team and division modes, includingwork_types[].teams),/scores/compare,team_rollupsand?team=group each event by the membership its developer held at the event's own time, from an append-only ledger dated by the server clock.team_rollupsrows are per event, not per person: a developer who moved inside the window appears in two rows. Alias edits are dated too (#914, above). A developer whose only figure in a group is paid spend no longer counts toward its k floor. Requests:PUT/POST /org_hierarchyanswer409when the server clock reads earlier than the developer's current membership began;valid_fromandvalid_toare refused400like any unknown field. Export:GET /developer/{id}/exportgains ahierarchy_membershiparray (additive), without the storedwritten_byfingerprint. Breaking: past team rows change once on upgrade only for people first assigned after it (their earlier history stays inother), and a group padded by paid-only seats may now be withheld. The database moves to schema version 2, which an older tierd refuses to open; binaries older than #141 do not check the schema version, so the bump cannot stop them — back up before upgrading and never run them against an upgraded DB. No field is retired, so noDeprecation/Sunsetheader is sent. Ships in the first tagged release after this merge. -
#871 -- plain
POST /costsrefuses another identity's key (BREAKING status; no field changes). A keyed post whosecost_usdmatched the stored row's was accepted with201whatever its identity, and MAX-merged its token counts into that row -- so a post reusing another developer's key raised their captured row's counts and wrote nothing for the caller. It now returns the override path's identity-mismatch409when the stored(developer, issue_id, model, source, fidelity)differs, and a same-identity re-post no longer changes the stored token counts on either path. Breaking: a client re-posting such a key goes from201to409; its201never recorded its spend. The owner of a row can now get that409on a same-cost retry too: when the stored fidelity is a pre-#82 value outsidedaily/estimated(pre-#82/costsaccepted any fidelity string), or when the first post sentfidelity: "daily"and the retry omits it (omitted defaults toestimated). Nothing is written either way; the remedy is to resend with the stored fidelity, which for a pre-#82 value is not possible (see the pre-#82 note underPOST /costsabove). -
#919 -- erasure and export cover watcher checkpoints (ADDITIVE).
DELETE /api/v1/developer/{id}addsdeleted.watcher_checkpoint(rows tombstoned plus rows deleted), andtotal_deletednow includes it;GET /api/v1/developer/{id}/exportadds awatcher_checkpointarray. -
#893 -- browser-shaped requests refused (BREAKING request-side behaviour; no field changes). Three refusals close cross-site and DNS-rebinding writes to a tierd the operator's own browser can reach. Content-Type: every
POST,PUTandPATCHunder/api/v1answers415unless its media type isapplication/json, in token mode too.curl -dwithout-H 'Content-Type: application/json'sends a form type and now gets415. Cross-origin: a request whoseSec-Fetch-Siteis notsame-originornone(or, without that header, whoseOriginhost differs fromHost) gets403on/api/for every method, including a clicked link, and on every other path for any method butGET,HEADandOPTIONS, so the provider proxies too (#903). Host: a tokenless tierd on loopback answers403to any request whoseHostis not a loopback name or IP, except on/webhook/github. Breaking: a client that wrote with anotherContent-Typegoes from2xxto415. The shipper,tierd hierarchy import, curl and SDK clients send noOriginorSec-Fetch-Siteand address the server by the name they dialled, so the two403s do not reach them. Ships in the first tagged release after this merge. -
#853 -- the
unattributedpseudo-developer no longer counts toward the k-floor (BREAKING behaviour in anonymized modes; no new field). A residual cohort of k-1 real developers plus theunattributedpseudo-developer (the org billing pollers' remainder) counted as k and was published, so a sub-k group of real people cleared the floor on the strength of spend no person is behind. Fix: only real people count toward the floor. That residual is now withheld, and withholding it takestotal,cost_compositionandsegment_reconciliationwith it (on/scores/compare,totalalone -- it ships no sidecars), exactly as any #593 suppression does. (A residual of the pseudo-developer alone was already withheld; it now reportsdevelopers: 0rather than1.) Field:kanon_suppressed.developersnow counts real people only and can be0-- the case where the only group withheld is the pseudo-developer's spend. The field keeps its name: it was always documented as a count of developers withheld, and the pseudo-developer is not one, so counting it was the defect, not the meaning. Applies to/scores(data_quality.kanon_suppressed), eachwork_types[]segment (work_types[].kanon_suppressed), and/scores/compare(top-levelkanon_suppressed). Developer mode is unaffected. Breaking: in anonymized modes a window that previously returnedtotaland its sidecars may now return them absent withkanon_suppressedset, and a consumer that assumeddevelopers >= 1must handle0. No field is retired, so noDeprecation/Sunsetheader is sent. Ships in the first tagged release after this merge. -
#821 --
scores.team_rollups(additive; developer mode only). A top-level array that partitionstotalbyorg_hierarchyteam, plus oneunassigned: truerow for developers with no team, so the rows'weighted_pointsandtotal_cost_usdsum tototal's. It answers "where did the org's points and spend go, by team" without a?team=request per team. Classification: additive -- a newomitemptyresponse field; no existing field changes meaning. Why notteams:teamsis frozen as the k-anonymized array of the anonymized modes, labelled byaggregation. Filling it in developer mode with unfloored rows would repurpose it in place, which rule 3 forbids, and would makeaggregationabsent beside a populatedteamsfor the first time. Shape rules that are easy to get wrong: the mode rule alone decides presence -- developer mode, besidetotal; absent inteamanddivisionmode (an unfloored per-team rollup is the #593 differencing channel), and so under any k-anonymity suppression, which only those modes raise; rows in byte-wise ascending team-name order; not narrowed by?team=or?work_type=; not onwork_typessegments. Scope: the read-only viewer token now sees every team name and its totals in developer mode (ruling #821 = B);GET /org_hierarchystays write-scoped. See the endpoint section. -
#824 --
exploratory_cost_sharedescription corrected (documentation only). The value is unchanged, and so is the name, which is historical. The field description in this document, and the docs changed with it, no longer call this spend exploratory or planning overhead: it is spend recorded onmainormaster, which TIER could not link to an issue. Other places may still use the old wording until they are updated. Bothdata_quality.exploratory_cost_shareand the per-developerexploratory_cost_shareare affected. See theGET /scoresfield list. -
#751 --
report_manifest.repo_scope_excluded(additive). A scoped manifest now pins the repo-blind rows a strict?repo=dropped --token_events,cost_micro,outcomes-- which is the pinned form of what/scoresdiscloses under the same key indata_quality. Why: that figure was the one published number a scoped manifest's own identities structurally could not cover. A scoped/scoresreads theunqualifiedsentinel rows to publish it, while the #747 scoped digests exclude those rows by design (widening them would reintroduce the #590 over-attribution) and a scoped manifest carries no fleet-wide digest. Measured: an in-place reprice of a repo-blind row movedrepo_scope_excluded.cost_usdfrom9to14while the scoped digests and watermarks were byte-identical, andtierd verify-reportprintedREPRODUCEDover changed served bytes. Classification: additive -- a newomitemptyresponse field. Nomanifest_schemabump: no existing field changed meaning, and an old consumer ignoring the key still reads every other field exactly as before. A scoped manifest without it verifies asNOT PINNED, never an error; a fleet-wide one prints no line at all, because a fleet-wide report excluded nothing and there is no pin to miss. Shape rules that are easy to get wrong: present on every scoped manifest including an all-zero one (an omit-when-clean pin is vacuous exactly when it matters), absent on every fleet-wide one, and denominated in micro-dollars rather than the dollars/scorespublishes. See the endpoint section. ⚠️ This endpoint's earlier additions (#715, #740, #747) have no changelog entry of their own; they are recorded in the endpoint section above. Do not read their absence here as "nothing changed". -
#746 -- the DEFAULT
?since=window now opens at the start of its UTC day (BEHAVIOURAL, not opt-in; a request-parameter DEFAULT moved, no response field was renamed, removed or repurposed). When?since=is omitted the lower bound wasnow - 90dcarrying the current time of day; it is now that instant snapped BACKWARD to00:00:00Z. One definition (api.defaultSince, called fromparseSince's empty branch) feeds every windowed read: six call sites, NINE endpoints, becauseparseExportParamsis a single call site serving four routes./scores,/scores/{developer},/scores/compare,/report_manifest,/org_actual_spend, and the bulk exports/events,/outcomes,/quality_eventsand/quality_history. Explicit bounds are untouched --YYYY-MM-DD/YYYY-MM/YYYYalready parse to midnight UTC. What moves: a default window is up to 24h WIDER, so it can include events timestamped between the UTC midnight that opens the window and thenow - 90dinstant the old bound started at. On a populated install that is a real change in the numbers. Measured in-process on the fixture incmd/tierd/verifyreport_defaultwindow_test.go-- three $4 events (and their outcomes) at the window-opening midnight, alongside three $3 events deep inside the window:total.total_cost_usd9 -> 21, plus a developer row the old bound excluded. Both figures are asserted by that test, so this entry cannot drift away from the fixture it cites. Classification, stated plainly because the obvious label is wrong: this is NOT rule 3. Rule 3 governs a RESPONSE FIELD whose number comes to mean something different, and its remedy is a new field name or a/v2. No response field changed meaning here --sincewas always specified as the "echoed window lower bound (UTC calendar day)", and the snap is what makes that description literally true. What changed is the DEFAULT VALUE of a request parameter, which the rules did not previously cover; the table above now has a row for it, and this entry is its precedent. The obligation that does apply is announcement, which is what this entry is. Direction is not arbitrary: widening can only ever add spend that really occurred, where snapping forward would silently drop a partial day of cost out of a cost metric. Also worth knowing: an install whose FIRST captured event falls in that widened day will newly reportwindow_predates_cost_captureon the default view -- honest (the window really does now predate capture), but new. AndcheckWindowRetentioncomparessinceagainst the retention horizon, so once #252 pruning is armed the no-parameters request becomes the shape most likely to trip its422; it is inert today (SetRetentionHorizonhas no non-test caller). One new sharp edge: the two-request workflow that builds a verifiable report (GET /report_manifestthenGET /scores, merged) resolves the default SEPARATELY in each request. Before the snap the two bounds differed by the milliseconds between the calls -- always slightly, harmlessly. They are now byte-identical all day, but differ by a full 24h if a UTC midnight falls between the two calls. Take both requests on the same UTC day, or pass explicit bounds. Why it was worth a population change:/scoresechoessinceas a bare calendar day while/report_manifestpublishes the honest instant, so a default report claimed"2026-05-31"for a window that opened at2026-05-31T05:00:58Z-- andtierd verify-reportrefused that manifest with exit 2, because/scoreshas no query that reproduces a mid-day bound. The DEFAULT report shape was the one shape that could not be verified. The verifier's refusal is deliberately UNCHANGED and still fires for hand-written and pre-#746 manifests. Not affected:tierd score/ship/backfillkeep their own unsnappedparseSince-- those feed JSONL scans, not a windowed store read, where a wider scan is merely redundant work. -
#687 --
source_ref's value shape for CI rows (BREAKING onGET /api/v1/quality_eventsandGET /api/v1/quality_history). CI quality events now encodehead_sha:run_attempt:workflow_id; they encodedhead_sha:run_attempt. No column was added, renamed or reordered, so the JSON and CSV shapes are untouched -- but the VALUE shape of an existing column changed, which the table above classifies as Breaking on its own. Rationale: flaky-rerun matching carried no workflow identity at all, so any successful workflow run on a merge commit within 30 minutes of any failing one neutralised the failure and a0.7outcome silently read1.0. In a repo with CI + lint + docs + CodeQL on the default branch those finish seconds apart, and anyone with push rights could make it deterministic by adding one trivially-green workflow -- no webhook secret required. Breaking: a consumer re-deriving the multiplier must now pair aci_fail_flakywith aci_failon the same head SHA AND the sameworkflow_id, not the same head SHA alone. A consumer that readsparts[0]/parts[1]positionally is unaffected; one that asserts exactly two components, or treats a third as impossible, breaks. Rows written before #687 keep the two-component form and are treated as carrying NO workflow identity: they pair only with each other, never with an identified row. That is what makes a pre-#687 outcome re-derive to the value it always had rather than being silently re-judged. Reverts are unchanged (the bare revert commit SHA). -
#638 --
GET /api/v1/versionadded, andcommitadded to/livez(ADDITIVE). A new open probe endpoint reporting build identity, plus one newomitemptyfield on an existing body. No existing field changed name, type or semantics, so a client parsing/liveztoday is unaffected. Rationale: theversionstring alone cannot identify a build -- a tagged release reports the same string however it was built -- so a deployment could not be asserted to be the build it was believed to be. -
#619 -- the reserved-sentinel ingest guard now covers
developer(BREAKING onPOST /costs,/events,/outcomes,/actual_spend,/developer_alias,PUT/POST /org_hierarchy; silent header drop on the proxy). #466 closed forgery of theunattributedsentinel onissue_idand deliberately left thedeveloperhalf open. This closes it, with the same predicate rather than a lookalike, so the two columns cannot drift. Breaking:developermay no longer carry the sentinel family — the bareunattributedplus anyunattributed:<reason>sub-bucket — on any of the six endpoints above.400on all of them, case-insensitively and after trimming whitespace. It was previously written silently. OnPOST /developer_aliasthe rule applies to bothaliasandcanonical. Why the break was taken: this is the worse half of the #466 vector, and the only half that pays. TIER ispoints / (cost/1000). Forgingissue_idmoves a dollar between buckets inside the forger's own denominator and leaves the headline score unchanged; forgingdevelopermoves it out of that denominator entirely, onto theunattributedpseudo-developer, so the forger's cost falls and their score rises. It was also user-visible:segment_reconciliation.developers[]emitted a row whosedeveloperwas literally"unattributed".POST /developer_aliasis included because an alias is a retroactive rename of the identity space — the score join resolves stored developers through it before aggregating — so without that guard the spend endpoints are bypassable in one hop. ⚠️org_hierarchywas missed by the first pass of this work and added after review, which is worth recording because of why it was missed: it reads as org-structure admin rather than a spend write. It is not.upsertHierarchyTxalso opens aperiod_membershipseat backdated to0000-01, and nothing downstream excludes the sentinel from a per-developer aggregate — so one authenticated write enrollingunattributedinto a team drags the whole unattributed pool into that team's denominator. Every other surface here is self-dealing; that one is aimed outward. No allowlist anywhere, unlikeissue_id: the/eventsallowlist exists because the JSONL collector legitimately ships the sentinel family as anissue_idon every session message it could not link to an issue. Nothing legitimately ships it as adeveloper: the two producers that assign it — theanthropicadmin/openaiusageorg pollers, for aggregates that cannot honestly be split per person — write in-process viacollector.Ingesterand never cross an HTTP boundary, and the sources they carry are not shippable over/eventsin the first place. The proxy's own missing-header fallback is likewise server-side. So the strict rule costs no capture. Blast radius: a client that was forging.unknown— the real no-identity fallbackcollector.OSUsername()emits — is unaffected, as are ordinary identities that merely contain the word (unattributed-bot,not-unattributed).tierd ship --developer unattributedwill now fail its batch; that invocation was always a forgery. Proxy (not an error, by design): a forgedX-Tier-Developeris treated as a missing header rather than rejected — the proxy sits on the request path and must never fail a provider call over attribution metadata. The stored row is identical to the missing-header case; what changes is the counter. Also additive:tier_proxy_unattributed_totalgains adeveloper-forgedvalue on itsheaderlabel, mirroringissue-forged. No existing series changes meaning — a forged header previously incremented no counter at all. This is the label to alert on: it is the only one of the five that indicates a client raising its own score. -
#466 --
scores.segment_reconciliation(additive) + the reserved-sentinel ingest guard (BREAKING onPOST /costs,/outcomes,/events). Additive: a top-levelsegment_reconciliationblock onGET /scorespartitions the window's cost intooutcome_linked/no_outcome/unattributed, each as both_usdand_cost_micro, with the exact partition invariant holding on the integers.kanon_suppressedgainswithheld_segment_reconciliation. Purely additive: no existing field changes meaning, and the block isomitempty. Breaking:issue_idmay no longer carry the reserved unattributed sentinel.POST /costsandPOST /outcomes400on the whole family, case-insensitively and after trimming whitespace;POST /eventsallows exactly the collector's four canonical spellings and400s everything else. It was previously written silently. Why the break was taken: the sentinel is server-assigned, and a client forging it moved its own dollars out of theno_outcomethrash signal and out of the attributed side of the #234 coverage split. Worse, a case variant classified DIFFERENTLY in SQL (LIKE, case-insensitive) and in Go (HasPrefix, case-sensitive), so one dollar was reported simultaneously as unattributed spend bycost_compositionand as abandoned real-issue work bysegment_reconciliation, in one response, against a named developer. The read side is nowGLOB(case-sensitive) so the two engines agree on stored rows; ingest is deliberately WIDER than the read side so a variant never becomes a row at all. Blast radius: no legitimate producer sets the sentinel as anissue_idon these HTTP surfaces. The GitHub webhook derives ids viaissueref, which can only emit#[1-9]\d*/ABC-123shapes. The org pollers (anthropicadmin,openaiusage) DO assign the bare sentinel for aggregates they cannot split per developer — they are unaffected because they write in-process viacollector.Ingester, never over/costs. The/eventsallowlist exists precisely because the JSONL collector assigns it too, and a4xxthere is terminal for the stateless shipper. ⚠️ Scope, stated precisely: this guard coveredissue_idonly. The same string is also the sentinel for thedeveloperfield; that half was closed separately by #619, below. Also additive:tier_proxy_unattributed_totalgains anissue-forgedvalue on itsheaderlabel. No existing series changes meaning — a forged header previously incremented no counter at all, since the guard did not exist. Today's readers: none. This ships the DATA only;internal/dashboarddoes not rendersegment_reconciliation, so the segmented panel a reader actually looks at still shows outcome-linked cost alone. The UI pass is separate work. -
#668 -- three org-hierarchy routes answer write-lock contention as
503instead of500(BREAKING status change).PUT /api/v1/org_hierarchy/{developer},POST /api/v1/org_hierarchyandPOST /api/v1/period_membership/{developer}/endnow take the SQLite write lock before they write, bounded at the same 250ms the other request-path writers have used since #346/#598/#610. Contention on any of the three is therefore503+Retry-After: 1instead of500. Breaking, and recorded as such rather than filed under "a better error": a status code changes for an input a client can actually hit, and a client with a500-is-fatal rule now sees503. Retry the503; do not retry the400or the500. ⚠️ But the shape of this break differs from #610's, and the difference is worth stating because the obvious reading is wrong. #610 also shortened a wait, so contention it had previously waited out and completed began failing instead. That does NOT apply here. Measured: the DEFERRED read-then-write these three used to run did not wait at all -- SQLite runs no busy handler for a deadlock-prone upgrade, so it returnedSQLITE_BUSYin ~65us and the handler answered500. So these routes never completed under contention; they failed immediately with an unretryable error and said500. No client loses a success it used to get. The change is500->503on a request that failed either way, plus aRetry-Aftertelling the client what to do about it. One route deserves its own note:POST /api/v1/period_membership/{developer}/endalso returns400whenperiod_endprecedes the membership's start. Contention is classified BEFORE that check, so a transient lock conflict is never reported as incoherent input -- which would tell a client to fix aperiod_endthat was perfectly valid. Why the change was made at all: it is the precondition for raisingSetMaxOpenConns(#669). These sites' in-process atomicity came from the single connection, so raising the pool without converting them would have turned a latency defect into a correctness one -- an unretriedSQLITE_BUSY_SNAPSHOT(517) race -- with no test failing. -
#610 --
POST /costsanswers write-lock contention one way (BREAKING status change on the plain path). The plain, non-override half of the endpoint now takes the SQLite write lock before it writes, bounded at the same 250ms theoverride: truehalf has used since #346. Contention there is therefore503+Retry-After: 1after ~250ms, where it was previously a500after the DSN's full 5000ms block with noRetry-After. Applies to keyed and unkeyed posts alike. Breaking, and recorded as such rather than filed under "a better error": a status code changes for an input that a client can actually hit, and a client with a500-is-fatal rule now sees503. The correct handling is the one the override path already documents -- retry the503, do not retry the409or the500. ⚠️ And the break is wider than the500->503swap, so do not read it as error-text polish: the wait itself shrank from 5000ms to 250ms, so contention the endpoint previously waited out and completed as a201now returns503instead. Any client posting into a contended store sees more failures than before -- each of them fast, retryable, and carryingRetry-After. A client with no retry on/costsat all is the one that regresses, and it is the one that must change. Why the break was taken: one route answered the same transient condition two different ways depending on one request field, and no caller can reasonably retry one and not the other. The500was also the worse of the two answers -- it reads as a broken database, so an operator goes looking for corruption when another writer simply held the lock. And the 5000ms wait was never free: with a single write connection, a request that blocks that long stalls every other in-flight request behind it, so the endpoint was buying one client's201with every other client's latency. It is the same defect class #598 fixed across the store, here on the busiest write path. What does NOT change: the201/200status codes and bodies themselves, the #295 divergent-cost409, the #346 override behaviour, and the500for a genuinely permanent store failure (read-only or full database -- deliberately NOT reported as503). On503nothing was written: the insert runs in one all-or-nothing transaction that takes the lock before it writes. -
#605 --
rankedon every compare DELTA row (additive field).teamDeltaJSON-- the shape behind/scores/compare'steams[]rows and its name-freetotal-- now carries a DERIVEDranked:a.ranked && b.ranked, computed once server-side. The rule it encodes is one sentence: anything derived from an unranked input is itself unranked. Why an AND and not an OR: a ranked baseline beside an unranked selected window lets a reader reconstruct the withheld ratio exactly (selected = baseline + delta), and% change = delta/baselineis a pure function of the withheld baseline ratio -- it leaks directly rather than additively. The one-ranked-one-unranked case is precisely the one worth withholding, so it is the case the AND catches. Notomitempty: afalseis the load-bearing value here, so omitting it would drop the verdict entirely rather than encode it. (Do not read this as a licence to feature-detect on field presence -- see the compatibility rules above;livez'sversionremains the supported mechanism.) Derived, with a single producer: consumers READ this field instead of re-deriving the conjunction. #502 addedrankedto the aggregate and it reached only one of the org headline's three consumers, because the other two re-derived the quotient from raw sums rather than reading the struct (#605 fixed the compare view, #606 the CLI report). A rule re-implemented at each consumer drifts at each consumer. Today's readers: the dashboard's compare card gates its delta and % change ontotal.ranked. Theteams[]rows carry the same derived field for contract uniformity -- one struct, one producer -- but the dashboard's team dumbbell reads the two sides' owna.ranked/b.rankeddirectly, so nothing consumesrankedon ateams[]row yet. -
#502 --
rankedon every group aggregate (additive field).teamScoreJSON-- the shape behindtotal, the?team=filter, the k-anonymizedteamsarray, eachwork_typessegment, and both sides of/scores/compare-- now carriesranked, the #133/#136 evidence floor applied to the aggregate's SUMMED inputs (outcomes >= 3, spend >= $5.00, no zero-token outcome). It previously existed only ondeveloperScoreJSON, so a group aggregate reached every consumer with no ranking verdict at all and was rendered as evidence by default. Notomitempty: afalseis the load-bearing value, and omitting it makes "unranked" indistinguishable from "a server that never said". No field's meaning changes, andtieris NOT one of them: an unranked aggregate still carries its exact quotient (the #502 case shipstier: 2.8e8), per #136 -- the number is never altered, only its ranking authority revoked. Consumers must gate the headline onranked; do not expect a scrubbed or floored number. Deliberately NOT accompanied by a team-levelsample_n: that count is the missing denominator that keepsdata_quality.attributed_outcome_sharenon-invertible in the anonymized modes. A boolean discloses a threshold crossing, not a count. -
#593 -- k-anonymity residual floor (BREAKING behaviour in anonymized modes; additive field). The residual
othercohort was emitted with no k-floor, so an anonymized deployment published a sub-k cohort's exact figures -- reproduced at k=5 as one developer's TIER, cost, points and cost-per-point. Reachable by narrowing any axis; the?repo=guard added in #590 closed only one of them. Fix: a sub-k residual is withheld, andtotalpluscost_compositionare withheld with it -- necessary becausetotalminus the named rows reconstructs the hidden cohort exactly. Applies to/scores, eachwork_typessegment, and/scores/compare. Declared via the additivedata_quality.kanon_suppressed. Breaking: in anonymized modes a response may now omittotalandcost_composition, and visible rows no longer sum to the window. The previously documented "totals are preserved exactly" property is retired -- it is the property that made the disclosure recoverable. Developer mode is unaffected. -
#590 --
?repo=scoping on the scores endpoints (additive fields) + strict query-parameter validation (BREAKING behaviour, deliberately). Two halves that must ship together. Additive:?repo=onGET /scoresandGET /scores/{developer}, plus thedata_qualityfieldsrepo_scope,repo_scope_excludedandspend_leverage_suppressed(the last three mirrored as top-level fields on the developer detail). No existing field changes meaning. Breaking: all three scores endpoints now reject unrecognized query parameters with400instead of ignoring them. Classified as breaking and recorded as such rather than quietly filed under "additive" -- a client sending an extraneous parameter goes from200to400. Why the break was taken:/scorespreviously accepted?repo=and ignored it, returning a whole-installation aggregate indistinguishable from a scoped one. The filter alone would have left the trap one keystroke away (?repos=,?Repo=), where a caller's assertion passes while asserting nothing. The governing invariant is that "could not scope" must never share a response shape with "scoped, and this is the result"; a silent wrong answer is worse than an error. Scoping semantics: strict equality. Rows carrying theunqualifiedsentinel are excluded, never folded in -- tolerant matching would attribute every repo-blind row in the installation to whichever single repository was named. Strictness can under-count, so it is disclosed (repo_scope_excluded) rather than left silent: a scoped figure over a window containing repo-blind rows is a lower bound.spend_leverage/actual_paid_usdare suppressed under a scope (actual spend has no repository and cannot be divided by one) and the suppression is declared. 🔴 What #593 does NOT close, stated plainly so nobody reads it as more than it is: it is a per-RESPONSE rule, and cross-request differencing remains open. Two separately k-safe windows -- neither suppressed, neither declaring anything -- still subtract to the cohort active only in the wider one, recovering points as well as cost. No per-response rule can close that; it is the query-composition problem and needs a different control (rate limiting, query logging, or a privacy budget). Treat anonymized aggregation as raising the cost of identifying an individual, not as a guarantee against a determined caller who can issue arbitrary windows. -
#277 --
GET /api/v1/scores/compare(additive: new endpoint). A before/after period comparison: two half-open windows (since_a/until_a,since_b/until_b) in, per-developer or per-group deltas plus a legacy raw, unvalidated non-overlapsignificantflag out. Read scope. Reuses the/scoreswindowed computation (extracted into a sharedloadWindow), so a compared score matches/scoresfor the same window. Three invariants are enforced server-side (the reason it is an endpoint, not a client two-fetch): (1) anonymized-mode k-anonymity is a two-window INTERSECTION -- a group is named only if it clears the k-floor in BOTH windows, else it folds tootherin both, so presence never differs across windows and no sub-k aggregate leaks through a delta; (2) the same anonymized guard as/scores-- never a named per-developer delta in team/division mode; (3) per-windowdata_quality. A new endpoint breaks no existing consumer. Unblocks the dashboard dumbbell (#278). -
#239 --
scores.cost_per_point+scores.rubricversion stamp (additive). Added the inverse-unitcost_per_point(total_cost_usd / weighted_points,0on a zero-point row at #239;nullsince #472) and its self-relative bootstrap CI (cost_per_point_ci_low/high, the reciprocal of thetierCI, no second resample) todeveloperScoreJSONanddeveloperDetailResponse;cost_per_point(no CI) toteamScoreJSON; and an always-present top-levelrubric({version},scoring.RubricVersion) stamping which canonical weight-rubric produced theweighted_points, the weight-side analogue ofprice_table. Purpose: give the TIER number a MEANING without an absolute band.cost_per_pointis the constant-dollar benchmarking/trend unit. What closes the generous-vs-strict labeling exploit is NORMATIVITY -- the single canonical rubric compiled into the binary, so everyone weights against the same calibration instead of house habits; therubricversion stamp is PROVENANCE, not the closer: it does not set the weights, it records WHICH rubric produced aweighted_pointsso two numbers can be trusted to share a matched rubric (and a mismatch surfaces as non-comparable AND visible instead of a silent category error). No absolute good/ok/poor band is defined anywhere -- comparison is self-relative and valid only within a matchedrubric.version+price_table.version(see docs/rubric.md). Existing fields and the TIER formula are untouched; pinned consumers unbroken. (No cross-org comparison is planned.) -
#295 --
POST /costsdivergent keyed re-post now409(bug-fix; narrowly breaking). Fixes silent financial data loss: after #233 a keyed re-post with the SAMEidempotency_keybut a CHANGEDcost_usdwas silently dropped (first-writer-wins) yet still returned201, so a finance correction vanished with a success code. Such a divergent re-post now returns409 Conflictwith a JSON error body; the storedcost_microremains immutable (#233) -- the409REJECTS the write, it never overwrites. Strictly scoped to the divergent case: an IDENTICAL re-post (same key, same cost) is unchanged -- still201, still idempotent -- and an unkeyed post is unchanged. Divergence is judged on the stored INTEGERcost_micro, so an honest retry whose float/FX/rounding jitter rounds to the same micro value does NOT409. Classified breaking only for a client that depended on the silent-drop-returns-201behavior -- i.e. on losing its own correction. The correction path is the sanctioned audited override below (ruling C); a NEWidempotency_keyis a second row that every spend read adds to the first (#860). -
#346 --
POST /costssanctioned cost-correction override, ruling C (additive). The follow-up #295's changelog entry above named as a separate piece of work:override: true+ requiredoverride_actor+override_reasonlets a legitimate finance correction land on a divergent keyed re-post instead of 409ing, as a narrow, audited, single-column (cost_micro-only) UPDATE with an append-onlycost_correction_auditrow (old -> new, actor, reason) -- never a last-writer-wins upsert. Purely additive: every pre-#346 request (nooverridefield) behaves identically to before, including the #295 409. See the full contract above, including the endpoint's stated trust model: the identity check closes the accidental key collision, not deliberate misattribution by a holder of the write token, andoverride_actoris a self-asserted claim. -
#351 --
scores.data_qualitytrue-attribution-coverage + unjoined-developer flag (additive; presence-contract clarified). Added threedata_qualityfields so the headline/scoresnumber is honest about how much of the window it actually covers:attributed_cost_share(fraction of windowcost_microjoined to a real issue, not theunattributedsentinel),attributed_outcome_share(fraction of outcomes with matching token spend), andunjoined_developers(developers with cost but no outcomes, or outcomes but no cost -- the silent-TIER=0 identity mismatch; named in developer mode, name-free counts only in team-aggregation mode, #185). No existing field changed meaning or computation.coverage_pctis NOT touched and was NOT measuring attribution coverage: it is per-developer capture FIDELITY (realtime vs. estimated share of recorded spend) and correctly reads ~100% even when most spend is unattributed -- the mismatch the PM surfaced was a reading error, now documented, not a bug incoverage_pct. Presence-contract note: the two coverage shares are ALWAYS present when the window has the relevant data (spend / outcomes), so a non-empty window now ships adata_qualityblock even when nothing is flagged (previously the block was omitted unless a tripwire fired). A truly empty window still ships no key; a consumer that ignores unknown keys is unaffected. The dashboard trust strip keys only off the zero-token fields, so it is visually unchanged. -
#293 --
scores.data_quality.mixed_price_versions(additive). Added anomitempty[<int>]field to thedata_qualityblock: the ascending distinctprice_tableversions that priced the window'stoken_events, present ONLY when more than one version is spanned.scores.price_table.versionstamps a single active table, but promotions retain the original row basis (#1231), so a window legitimately mixes historical and active pricing; this WARN keeps the stamp from reading as false uniformity. Name-free (version integers only), so it carries identically in developer and team-aggregation mode. No existing field changed meaning; a consumer that ignores the key is unaffected. -
#242 --
push_dayon the outcomes export + quality audit bulk exports (additive). Appendedpush_dayas the trailing column of theGET /outcomesJSON and CSV (the UTC per-issue-per-day dedup key asource='push'row aggregates to;""for a PR row) -- a pinned positional consumer is unbroken. Added two new read endpoints,GET /api/v1/quality_eventsandGET /api/v1/quality_history, reusing the #191 keyset-pagination / limit /Acceptmachinery and inheriting the #185 team-mode403. Together they make an outcome's multiplier re-derivable (quality == last new_quality) from a BI export, not just the erasure-scoped DSAR export. -
#234 --
scores.cost_compositionsidecar (additive). A whole-window, name-free breakdown of where spend went -- cost by normalized model, per-class token composition, attributed vs unattributed spend, and the two optimization levers (cache_read_share,premium_model_share).omitempty, so a window with no token spend ships no key. Pure sidecar: the TIER formula and every existing/scoresfield are untouched. Pinned consumers unbroken. -
#48 --
GET /api/v1/healthzextensible subsystem body (additive). Added a top-levelsubsystemsmap ({"<name>": {healthy, detail}}) and an aggregatehealthybool. The legacy top-levelwatcherblock is retained (duplicatessubsystems.watcher.detail) so pre-#48 consumers are unaffected; it is now deprecated in favour of the map.200/503semantics unchanged. Subsystems now register into ahealth.Registryinstead of the handler hard-coding one key, so v1.5 collectors extend the body without a schema break. -
#236 --
GET /api/v1/fidelityendpoint added (additive). Per-canonical- developer capture-fidelity signals (now/since_7d/since_30dwindow stamps; per developerevent_count_7d,event_count_30d,last_event_by_source,fidelity_levels,unknown_model_cost_share). Read-scoped;403in team-aggregation mode like the bulk exports. Shipped in #320 on a branch that predated this contract doc (#241); the catalog entry was backfilled in #322. A new endpoint changes no existing surface. -
#304 --
host+billing_modecolumns (additive). Appended to thetoken_eventsJSON/CSV export.billing_mode(per_token|subscription|self_hosted_amortized) discriminates whethercost_microis a canonical per-token figure or a derived/approximate one. Pinned consumers unbroken. -
#238 --
session_idcolumn (additive). Appended to thetoken_eventsexport (and accepted onPOST /events). Opaque Claude Code session UUID; NULL for session-blind producers. -
#713 --
price_table.table_hash+price_table.file_hash(additive). Both are always present on/scoresand/scores/compare(and intierd score-log's JSON). They are deliberately absent from/api/v1/version, which is unauthenticated -- see content identity. A client that decodesprice_tableinto a closed struct is unaffected (both are new keys); one that rejects unknown keys must be updated. -
#233 --
price_tablestamp +price_versioncolumn (additive).scores.price_table({version, effective_date}) is now always present;price_versionwas appended to thetoken_eventsexport. The server became the single pricing authority (POST /eventsreprices; clientcost_usdis a cross-check). -
#231 --
repocolumn (additive). Appended to BOTH exports and accepted as an optional field onPOST /costsandPOST /events.issue_idvalues were deliberately left unchanged so a consumer pinned toissue_idkeeps reading the same value; the repository arrived in its own new column rather than changing an existing column's shape in place. -
#191 -- bulk exports added (additive).
GET /api/v1/eventsandGET /api/v1/outcomesintroduced (keyset-paginated, JSON default + CSV). Both return403in team-aggregation mode. New endpoints, no existing surface changed. -
#187 -- work-type segmentation;
/scorestop-level fields SEMANTICALLY DEMOTED. Addedscores.work_types(the authoritative within-category comparison) andwork_type/work_type_sourcecolumns on the outcomes export (additive). In the same change, the top-leveldevelopers/teams/totalbecame a POOLED population summary that is explicitly NOT a cross-type ranking. The numbers stayed parseable while their meaning narrowed; that semantic change shipped without a version signal or deprecation header, and is the motivating defect for this contract (#241). Under the rule above it should have been announced via a changelog entry and the field doc comments (it now is); the new meaning correctly shipped under a new key (work_types) rather than repurposing the old fields. -
#270 -- division-aggregation mode +
aggregationdiscriminator (additive field; new opt-in mode). Adds--aggregation division, a second ANONYMIZED level that rollsorg_hierarchyup one step past team to division, reusing the identical k-anonymity floor and every suppression guard team mode has (the per-developer/scores/{developer}404, the bulk-export/fidelity403s). Its rows ride the SAMEteamsarray as team mode; the new top-levelaggregationstring field (omitempty, absent in developer mode) says which level the rows are. Adding the field is additive and back-compatible; the mode itself is deploy-time and opt-in like #185. k-anonymity holds independently at each level because every level is a flat partition of the same developer set, so a sub-k team suppressed at team level is absorbed into a>= kdivision at division level, never re-exposed. -
#185 -- team-aggregation mode (behavioural, opt-in). When the server runs in
AggregationTeammode,GET /scoresreplaces nameddeveloperswith k-anonymizedteamsand emitsdevelopersas an empty array, andGET /scores/{developer}blanket-404s. This is a deploy-time mode, not a wire-shape change to a given response, but it changes which fields are populated -- consumers must handle the empty-developers/teamsshape. (The bulk exports landed later in #191 and inherit this policy, returning403in team mode; see that entry. Division mode #270 rides the same policy.) -
#136 --
data_qualityblock +zero_token/flagged_outcomesfields (additive). Zero-token-outcome tripwire surfaced on/scoresand/scores/{developer}. -
#133 -- ranking fields (additive).
sample_n,ci_low,ci_high,rankedadded to per-developer rows; the array is not pre-sorted (at #133; the array has since been sorted by developer identifier -- see the/scorescatalog). -
#55 --
cache_write_5m_tokens/cache_write_1h_tokens(additive);cache_write_tokensdeprecated. The legacy single-bucket request field is accepted with aWarning: 299header (the last use of the RFC 7234 precedent; new deprecations use the RFC 9745Deprecation/ RFC 8594Sunsetpair).
Changing the API
When you add a field, add it in the same PR to (1) the Go response struct in
internal/api/, (2) this document's endpoint catalog, (3) the
API changelog, and -- for the CSV exports -- (4) the relevant
CSV header slice (eventsCSVHeader, outcomesCSVHeader, qualityEventsCSVHeader,
qualityHistoryCSVHeader) and docs/how-it-works.md. When you
deprecate or change a field's meaning, follow
Announcing a deprecation:
new field name (never repurpose), the RFC 9745 Deprecation / RFC 8594 Sunset
headers, and a changelog entry.
Relationship to the ingestion seam contract
TIER's cross-service ingestion seam, tier-jsonl-ingestion (Claude Code session
JSONL -> tier collector), is pinned by the JSON schema at
docs/contracts/tier-jsonl-ingestion.schema.json. That seam is the external
Claude Code producer format tier consumes; tier owns and publishes the seam
contract even though the data flows inbound, because tier, not the producer,
defines which fields it requires. This document, by contrast, governs the HTTP
API tier itself PRODUCES.
The /api/v1 REST surface is not yet pinned as its own formally versioned seam
contract; that is a possible follow-up once an external consumer of the REST
contract needs a stability guarantee.
Merged-PR attribution evidence (#1074)
Developer data exports include nullable outcome head_branch_hash, created_at and
merged_at. Older rows retain NULL. These fields are additive in that export; the
GET /api/v1/outcomes JSON/CSV contract and POST /api/v1/outcomes request are unchanged.
Head refs are hashed using canonical repo + "\x00" + branch after stripping
refs/heads/; default/unknown branches produce NULL, and raw head refs are not stored
on outcomes. Missing PR-opened or merge evidence disables branch credit.
Root viewer declarations and settings (#1440, plan P13/P14)
| Method and path | Authorization | Behavior |
|---|---|---|
PUT /api/v1/org/people/{dev}/viewers |
Root only | manager, skip_level, works_viewers[]; atomic declaration, subject notice and named-column audit; 409 with nothing changed if the same team in the same org would have two distinct nonempty managers |
DELETE /api/v1/org/people/{dev} |
Root only | Exact confirm developer; atomic erasure and name-free audit |
GET /api/v1/me/notices, /api/v1/me/audit |
Personal or node | Caller subject only; audit includes viewer declaration actions |
GET /api/v1/org/settings |
Root only | settings value/source/pinned map and daily-tally shared_counts{last7,last30,last_seen} |
PUT /api/v1/org/settings |
Root only | Partial setting update, audited and effective immediately; pinned keys return 409 |
Declared viewers can use their node or personal sign-in bearer/session on the
existing coaching and single-developer score card routes in developer mode.
Hierarchy placement alone grants no access. These authenticated cards add
declared_viewers; legacy pair-credential card bytes retain their shape. The
legacy DELETE /api/v1/developer/{id} contract and frozen legacy golden remain
unchanged. New routes refuse legacy tokens and use the existing admin session,
Origin/CSRF, rate limit, strict JSON and no-store boundary.
Settings use explicit flag > environment > config pins, then saved DB values, then defaults. Evaluation defaults off, shared ingest defaults warn, retention defaults 90 days (60–365). A whole-set invalid update is 400 and saves nothing. Retention lowering returns a preview and confirmation token before any save; the identical confirmed request applies the window for the next prune. Tokens are bound to the root credential, requested changes and previous settings, expire after ten minutes and are single-use. Read-only serve has no settings PUT. See identity access for request and response details.
The settings migration follows per-node sealed decisions (v22) at schema step
v23, reuses subject notices and adds server_settings and nullable
sealed_report.settings_json. In team/division mode and the developer-mode org
sealer's team view, new seals record live settings
values and sources through the existing immutable metadata path. These seals
and body replay use that resolved snapshot for evaluation labels and contributor
counts, including offline verify-report; current settings do not override a
stamped seal. Old seals remain NULL and keep their existing replay
rules. Binaries predating settings refuse v23; binaries predating graft refuse
v25. Import apply advances the schema to v26; mapping-audit source redaction
advances it to v27. This binary refuses any schema above v28 before migration.
Graft source, preview and apply (P34–P36)
Schema v25 adds consented graft staging and mapping erasure after per-node sealed decisions (v22), persisted settings (v23), and vendor mappings (v24).
These routes use Cache-Control: no-store and the identity rate limit. Legacy
admin/read tokens cannot open them. Admin mutations require JSON and the existing
Origin/session CSRF checks; scripts may use their scoped bearer credential.
| Method and route | Credential | Behavior |
|---|---|---|
GET /api/v1/me/consent |
Personal | Read the holder's own consent. |
PUT /api/v1/me/consent |
Personal | Set move_named for the holder only; destination_label is required only when true. Withdrawal can send {"move_named":false}. |
POST /api/v1/org/export-keys |
Root | Return credential_id, shown-once secret, and expires_at (one hour). No request fields. |
GET /api/v1/export/bundle |
Export bearer, HTTPS | Consume the key and return an audited tier-import-bundle/v1 snapshot atomically. HEAD and other methods return 405 without consuming it; plain HTTP burns a recognized export key before returning 403. Revoking the issuer refuses subsequent pulls. |
POST /api/v1/org/imports |
Root | Pull once using source_url, export_key, destination_node_id; return batch_id and preview. |
PUT /api/v1/org/imports/{batch}/mappings |
Root | Save {source_developer: {decision: "map" or "new", developer: target}}. |
POST /api/v1/org/imports/{batch}/apply |
Root | Apply a ready preview atomically; no request fields. Return the stored apply summary on retries. |
The bundle contract is tier-import-bundle.schema.json.
The source URL must be an HTTPS origin or its exact /api/v1/export/bundle URL,
without user information, query, fragment, or another path. The company server
fetches from whatever host root names and sends the export key to that host.
A wrong host receives a live key that may be able to pull the named bundle from
the real source before consumption or expiry. The fetch honours proxy environment
variables via http.ProxyFromEnvironment (code: internal/api/graft.go,
fetchImportBundle; internal/store/graft_source.go, graftExportIdentity).
Choosing the host is a root-only trust decision: internal, loopback and link-local
addresses are allowed; the URL checks do not limit network reachability.
Redirects fail; the response is capped at 64 MiB (67,108,864 bytes) with a
30-second fetch timeout.
A fetch timeout returns 400 with {"error":"import source timed out"}.
The source export and destination fetch, decode, preview and apply share this
fixed byte bound; it is not an event-count limit. The standard server write
timeout remains 60 seconds. Oversized bundles return 413 with error,
size_bytes and limit_bytes before any destination staging or imported rows.
size_bytes is a lower bound when reading stops at the limit plus one byte;
a declared Content-Length or source refusal is source-claimed. A destination
also checks streamed bytes when Content-Length is absent. On a real HTTP
transport, an understated Content-Length truncates the visible body; malformed
or incomplete JSON returns 400 rather than a size refusal.
Source 413 refusals relay only validated numeric size and limit fields, never
remote error text. Bundles are never truncated to fit. A preview contains
people, consented, name_free (aggregate row count), duplicate_events,
left_behind, left_behind_rows, estimated_lock_seconds,
estimated_lock_seconds_min, estimated_lock_seconds_max, memory_gb_for_64_mib, and
clashes[{source_developer,existing}]. Event duplicates are checked by idempotency
key, then by the shared content fingerprint, against local and incoming rows.
A preview writes no usage rows and creates no people. A large import can use
about 1.4 GB of memory for a 64 MiB bundle, holds the database writer during staging
and apply, and grows the write-ahead log. The byte cap does not bound those
costs to 64 MiB.
EXTREME — concurrency and data loss during company imports (#1488, ruling A‴). While an import applies, live events wait and aren't dropped. They can be lost if the process dies, including being killed for memory on a small server, if the 30-minute cap passes, or if the write fails after the wait; TIER flags a report when that may have happened. Lost proxy events can't be recovered.
The legacy estimated_lock_seconds midpoint remains for compatibility; use
estimated_lock_seconds_min and estimated_lock_seconds_max for the range, and
memory_gb_for_64_mib for the memory warning. The lock estimate is a range: about 20 seconds per 100,000 events on a fast
machine, about 60 on one core, and slower servers can take many times longer.
Allow about 1.4 GB of memory for a 64 MiB bundle; a 1 GB server may be killed
for memory, losing live events in flight. The byte cap does not bound memory
to 64 MiB.
During the source pull and apply, proxy inserts wait on an in-process signal without a database connection. JSON and SSE responses complete while capture continues afterward, retaining only the event and tracked by the same shutdown drain. Outside imports, the existing 90-second budget and roughly 250 ms lock tries are unchanged. After the signal clears, that normal budget restarts. Preview and apply clear the 60-second HTTP write deadline after root auth. On SIGTERM, closing active HTTP connections cancels an unfinished import; capture drains before the store closes.
Each source pull or apply durably records its own unique attempt before its
lock. A failed record write refuses the operation. Every exit removes only its
own record with a detached context, retrying for up to five seconds. Cleanup
failures log only a sanitized error class. Both servers cover each source pull;
the destination also covers preview and apply. A 503 before the pull starts
does not consume the source export key. Startup treats any leftover record as a
possible loss and sets the durable data_quality.live_capture_may_be_lost flag;
read-only serving re-reads that warning for each report without writing it.
Startup skips a flag write when its dates are already covered and warns with a
sanitized error class if the writer is busy; serving continues. Exhaustion and final
write failures also set the flag once a write is possible, increment
tier_proxy_writes_total{provider,outcome="error"}, and log an ERROR without
names, credentials or underlying error text. Failed flag writes retry within the
same bounded budget; writable close flushes a pending warning.
The settings row stores the earliest and latest UTC dates when events may have
been lost. A legacy boolean has an unknown first date, so it covers the past
through its stored write date; a record without a usable date is treated
conservatively through the date it is read. Only reports whose windows overlap those dates carry the boolean
data_quality.live_capture_may_be_lost. Sealed months keep the warning stored
with their body, including org reports, totals, compare and their CSV exports
(the CSV column is live_capture_may_be_lost). Later live losses do not rewrite
sealed months. The dashboard says: “TIER may have missed some AI costs in this
period, because a server restart or an import interrupted recording.” There is
no clear command: later periods outside the date range are unaffected.
At most 10,000 captures continue after responses at once. Above that limit a response waits for its capture, providing back-pressure without dropping events.
Webhooks keep today's 503 on write-lock contention and must be manually
redelivered; GitHub does not automatically redeliver failures. Other routes,
including POST /api/v1/costs, retain their documented 503 and Retry-After
responses. The 64 MiB limit and all-or-nothing apply are unchanged.
The stored apply receipt includes lock_started_at (sampled after lock
acquisition, before authorization) and lock_ended_at (the final receipt-write
marker immediately before commit, rather than a post-commit observation).
The dashboard displays these stored markers; older receipts may lack them.
Receipt replays preserve these times. The 64 MiB limit and all-or-nothing apply
are unchanged.
The mappings PUT has a dedicated 1 MiB (1,048,576-byte) request cap, enough
for 1,000 clash mappings with ordinary identity labels. An oversized request
returns 413 with {"error":"import mappings exceed 1048576-byte limit"};
malformed JSON still returns 400.
Matching IDs always require an explicit decision. map names an existing local
identity; new must name an unused local identity. Mappings never imply a merge.
The store's readiness prerequisite rechecks every clash and target against the
current database; it refuses missing or conflicting mappings, including clashes
introduced after preview. Apply calls that prerequisite inside its write
transaction. An unmapped clash returns 409 and writes nothing.
Data-subject exports, erasure, and retention resolve held staged people and
import_batch_people rows through the explicit mapping, or the source ID when
unmapped. A colliding source name cannot export the held mapping or invalidate the
mapped person's stage. Once staging is absent, source-named subjects can export
source metadata with the mapping's target developer omitted, and erase that
metadata. Erasing a source-only name in an id_mapped audit row nulls only
source_developer; the target's audit row remains available in their export.
The dashboard's /import-source page exposes only the signed-in personal
holder's consent, or root-only export issuance. Root also has /import for
pulling a preview, saving explicit clash mappings and applying it. /api/v1/me
includes import-source for personal and root principals and import for root
only; node principals receive neither. These pages use the shared CSP, nosniff,
no-store and session CSRF transport. The shown-once export key includes its
expiry and clears on Hide, same-tab navigation and pagehide. The pasted key is
cleared as soon as the preview request starts, including failed requests.
Apply remains disabled until every clash has an explicit saved choice and
renders the stored apply receipt returned by the API. No import state or key is
saved in browser storage, URLs or history.
The dashboard's map choice sends the preview's matched existing identity;
only new accepts a typed target, initially <source_developer>-imported.
A 409 after preview discards the batch and clashes and reopens the pull form;
another preview requires a newly issued one-time source key.
Staged bundles expire after 24 hours; expired or erased stages cannot be
applied. Applied batches retain a name-free receipt for idempotent retries. PUT
mappings for an unknown batch returns the same 409 import preview expired or unavailable as an expired batch. The clock-driven cleanup runs at startup and
every minute while serving.
The consuming bundle GET is omitted from read-only serving. Consent GET remains
a read without database writes. Graft table deletion counts appear only when rows were deleted. Erasure receipts
always include the fixed erased_import_record notice; they never count record entries.
Apply returns batch_id, people, events, duplicate_events,
conflicting_events, outcomes, skipped_events, skipped_outcomes,
left_behind, left_behind_rows, estimated_lock_seconds,
estimated_lock_seconds_min, estimated_lock_seconds_max, memory_gb_for_64_mib, and other. people is the number of named
subjects processed; events and outcomes count inserted rows. Duplicate events
are skipped by idempotency key, event UID, then content fingerprint; a reused key
or UID increments conflicting_events only when a stored fingerprint exists and differs.
A native row without a stored fingerprint cannot be compared. The existing
row wins. Identity aliases discovered through fingerprints persist, so later
bundles cannot evade dedup by changing an alias event's content. Outcomes deduplicate by (repo, issue_id, pr_number, ts, source) across all local developers and all people in the bundle; existing figures win even when the incoming weight or quality differs. Only source="push" uses the push path; any other source requires a PR number.
A fresh pull of the same source content, with its current clashes explicitly
mapped, at the same destination preserves the original receipt's batch ID and
lock times without recreating erased named history. For that fresh batch,
name-free totals are rechecked against the current destination k and sticky
withholding, and its receipt records the resulting suppression. An exact retry
of an already applied batch still returns its frozen receipt. Replay
matching uses a content identity that excludes issued_at; the wire digest
still covers issuance time and every other bundle field. Source identity,
named rows, name-free totals, and left_behind remain part of the match.
The same content at a different destination returns 409.
This protection covers content identities recorded by the corrected preview path. Previously applied batches whose staging was cleared retain only their old issuance-dependent digest; their original content identity cannot be backfilled from that digest. Retrying their original batch ID still returns its stored receipt; presenting the exact original bundle also preserves that receipt and records a stable identity for later fresh pulls. Pending previews created before this fix acquire their stable identity when applied. Changed content is a new import, subject to the erased-person record below. Erase at the source too when that source must stop supplying the person's history.
Apply refuses the entire bundle with 400 if an event or name-free total has a negative list cost or exceeds MaxTokenEventCostMicro (10,000,000,000 micro-dollars). Named events must use one of the contract-pinned v1 capture sources (jsonl, codex-rollout, opencode, muse) at realtime fidelity; poller, proxy, manual, unknown source and incompatible fidelity claims return 400. An unmapped source name that cannot be a local identity label returns 400 and must be explicitly mapped.
Rows before the permanent retention watermark are skipped as final, counted in skipped_events and skipped_outcomes, and never written. Remaining rows apply successfully; retries return the same receipt.
Named events retain source costs, price versions (including zero), pricing bases,
and provenance. origin_server and import_batch_id distinguish them from local
evidence; local repricing and token promotion cannot rewrite their source figures.
New people have origin imported, receive no credentials or viewer declarations,
and remain imported_unbound until an own-credential upload here. Explicitly mapped
existing people retain their local placement and declarations. A mapped person with local history but no org-person row is registered as native at the destination, preserving native census eligibility. Imported spend remains Unknown until the subject has qualifying local evidence, then joins that subject in live identity totals and future seal populations. Imported rows never supply census evidence; existing sealed populations and apply receipts remain frozen. Local capture horizons and price-version coverage exclude imported rows.
Name-free totals are stored under the destination in external_totals, always
as Other. Each other entry contains period_start, period_end, withheld,
points, and list_cost_micro; both figures are null below the destination's
k (at least three). Counts and identities are absent. Unrelated totals cannot
supply cover to each other. Identical source-period totals are stored once;
re-imports persist withholding and the highest threshold seen, so lowering k cannot release a previously withheld total.
Changed or overlapping source periods, or redirecting them to another node,
return 409 and roll back the entire apply. The stored receipt freezes the
apply-time decision. These totals do not add local census contributors.
Bundle v1 exports only events accepted by the same predicate used by preview
and apply: jsonl, codex-rollout, opencode, and muse at realtime, with
nonnegative tokens and list cost no greater than 10,000,000,000 micro-dollars.
Other events stay on the source. Non-push outcomes without a PR number also
stay; push outcomes may have a null PR. Invalid transport figures stay on the
source too. Negative or over-ceiling name-free cost totals are omitted, alongside
other unsafe totals; they never disclose non-consenters in the retained-row report.
Negative finite outcome points and name-free points retain their existing semantics.
A new collector cannot widen v1's pinned sources without a contract bump.
Preview refuses unacceptable claims before staging; apply retains the atomic
refusal for forged or hand-built stages, including negative tokens.
The required digest-covered left_behind report has basis: "source-claimed"
and a fixed ordered classes array. Its source classes are jsonl,
codex-rollout, opencode, muse, proxy, anthropic-admin, openai-usage,
cursor-admin, copilot-billing, api, other (unknown event sources), and
outcomes (retained outcome rows, zero cost). Every class carries source_class,
count, and signed net_cost_micro, including explicit zeros. It covers only
active, non-departed consenters, with no per-person split. Missing, null, malformed,
or overflowing reports are refused; absence never means zero. All counts and net
cost additions are overflow-checked. Preview and the stored apply receipt carry
the same report and left_behind_rows counter, separate from retention's
skipped_events and skipped_outcomes. These are source claims, not destination
usage or verified totals, and are never added to destination spend.
The dashboard labels the retained-row total and class table as what the source
reports in preview and completion, hides empty reports, and allows import.
The source ledger is unchanged by pull: for active, non-departed consenters,
event count and net cost reconcile to exported events plus retained events. Name-free SQL excludes
active, non-departed consenters, including retained rows; the report accounts
for those rows in the bundle while the source's ledger still includes them.
Departed consenters remain excluded from named rows and the left_behind
report, but their historical spend and outcomes join the name-free closed-month
cohort under the existing k, contributor, dominance, and cost gates. Unsafe
name-free cohorts remain omitted without a person-level refusal report.
Consent responses, consent notices, and bundle_pulled records carry the plain
sentence: "Some rows stay on the source because bundle v1 exports only accepted
events and outcomes."
Imports are explicitly exempt from --people-cap-per-day because apply is
root-only and audited; this does not change the cap for ordinary people creation.
The existing staging and receipt JSON columns hold the report without an
additional migration. The current database schema is v28.
The source records bundle_pulled when the one-shot snapshot is delivered.
The company records import_applied; a new preview of identical content records an apply audit with replay_of_batch_id naming the original batch. Retrying an already applied batch returns its receipt without another audit. Explicit mapping saves record id_mapped.
An audit failure rolls back apply, including people, usage, totals, and staging
consumption. Apply is omitted from read-only serving.
Federation administration and transport (P26 + P27, EXTREME)
These routes work in every aggregation mode on a real serving instance. Legacy
shared tokens grant no access. Administration uses the existing root/node bearer
or cookie session boundary: cookie mutations retain exact Origin and CSRF
checks; scripts may use bearer alone. All responses, including refusals, use
Cache-Control: no-store. Credentials are limited to 60 requests/minute.
| Method and route | Principal / input | Result |
|---|---|---|
GET /api/v1/org/federation |
Root or node reader | {servers:[{server_id,label,host,node_id,path,status,token:{credential_id,issued_at,revoked_at},last_push_at,last_period_end,schedule:{interval,hour_utc},next_expected_at,contract_version}]}; own branch only; status is current, late, silent, never or revoked |
POST /api/v1/org/federation/servers |
Root or managing node; {label,host,parent_node_id,interval,hour_utc} |
201 {server_id,node_id,token:{credential_id,secret}}; native parent in own branch; server takes one child place; interval daily/weekly, hour 0–23 |
POST /api/v1/org/federation/servers/{id}/revoke |
Same branch manager; no body or {} |
200 {}; accepted history retained; sibling-branch manager gets 403 |
POST /api/v1/org/federation/servers/{id}/reissue |
Same branch manager; no body or {} |
201 {server_id,node_id,token:{credential_id,secret}}; atomic old-token revocation and new-token issuance, including recovery after revoke |
GET /api/v1/org/federation/refusals?since= |
Root or top-level node reader; optional RFC3339 timestamp | Refusal array {ts,reason,server_id?,server_label?,source_class?,count?}; root alone sees source class; identified servers scoped to the reader's branch; overflow rows have hour, reason and count |
POST /api/v1/federation/push |
Federation bearer over HTTPS; tier-federation/v1 payload |
200 {accepted_seq,accepted:[{period_start,period_end}]}; own place only |
GET /api/v1/federation/status |
Federation bearer over HTTPS | {server_id,last_seq,last_period_end,next_expected_at}; no writes |
The v1 schema and the merged Go
validator define the wire shape. At most 1 MiB and 31 closed periods may be
pushed. Each accepted (server_id,period_start,period_end) is immutable. Under a
later sequence, an identical period with the same digest is skipped; only new
periods appear in accepted. Any other overlap with an accepted period returns
409 period_conflict, including shifted or spanning bounds. Adjacent periods
are allowed. There is no provisional-revision arm. Exactly the last accepted
sequence and the identical complete request bytes return the same 200; other old sequences return 409
replayed_seq. A batch either commits every new period and its replay state or
commits nothing.
Missing/unknown tokens return 401 no_token/unknown_token. Revoked tokens,
wrong credential kinds or another body's server id return 403
revoked_token/wrong_server; HTTP returns 403 not_https before credential
lookup, including loopback and requests with missing or unknown tokens.
Only TLS or a single X-Forwarded-Proto: https from a configured trusted peer
satisfies HTTPS. Unknown contract major returns 400 bad_contract, invalid
payloads 400 malformed, oversized bodies 413 too_large, and lockout or
credential rate limit 429 rate_limited with Retry-After. Unknown tokens count
toward IP lockout; known refused credentials are neutral. Logs retain no raw
address, presented credential, credential-derived value or claimed server id.
Refusals flush from bounded memory every minute and after HTTP drain, with a
normal one-minute crash-loss window (longer during flush failures). Failed flushes are retried; detail is capped
at 10,000 rows/90 days and overflow aggregates by hour/reason for 90 days.
The worker prunes durable detail and hourly aggregates even when no new refusals
are buffered.
Inventory status precedence is revoked, never, current, silent, then late. A server is current when its last period end reaches the UTC month boundary containing now minus the 14-day report grace. Otherwise, a server that has pushed is silent after more than seven days since its last push, or late.
Read-only serving omits push and management mutations and performs no refusal writes. GET inventory, refusals and own status remain reads. Federation adds allowed servers, immutable periods and bounded refusal detail/aggregates without a schema-version bump. The existing chain remains per-node sealed decisions at v22, persisted settings at v23 and vendor mappings at v24; federation adds no numbered step. Federation lifecycle actions are available to managing ancestors through the existing org audit route, alongside its claim rows. The frozen legacy route fixture is unchanged.
Sealed subtree report API (#1441, P18 + P19)
The additive GET /api/v1/org/report?node=&period=YYYY-MM route serves a stored
per-node month. Root/node credentials are authorized to their current subtree;
node defaults to the credential binding. The shared read token is explicitly
accepted without contributor counts. Other legacy principals remain denied on
these new routes. All existing legacy route shapes and sealed bodies remain
unchanged. New schema step 22 adds per-node seals, fold decisions, read tallies
and the nullable stored TTM body. Older seals retain null TTM bodies.
The JSON response contains node, period{start,end,label}, k,
evaluation_mode: false, groups, other and caveat. Each group contains
cell_id (dated slice), node_id (structural node), parent_id, level, level_name, name
and withheld; a folded member can also have fold_id. Shown rows carry
points, list_cost_usd, derived tier when cost is positive, and, for
root/node credentials, bound_contributors.
Withheld rows omit all figures and counts. other rows carry member_ids and
the same withholding/figure rules. Historical cells whose parent is outside
the authorized subtree expose no parent or external fold identifier.
GET /api/v1/org/report/compare and the compare=YYYY-MM parameter on report
and CSV routes put months side by side, returning {compare:[...]} in JSON.
There is exactly one period and up to twelve repeated compare values.
Shared read-token reports retain mode granularity: team/developer allow the
leaf level; division stops at each branch’s own legacy division node (level 1
for legacy depths, the leaf’s parent below level 2), using the same mapping as
org_node_labels. Requests below that branch boundary return 404. Root/node authorization remains independent of mode.
Only canonical calendar months are accepted; arbitrary bounds and aggregates
return 400, and absent per-node seals return 404. A comparison never refolds,
unwithholds or computes a multi-month total. Node keys are sealed-only in v0.7.0;
they receive no month-to-date figures.
GET /api/v1/org/report.csv exports the same ordered rows with columns
period,cell_id,node_id,parent_id,withheld,points,list_cost_usd,bound_contributors,fold_id,member_ids,name,level,tier,level_name,unplaced.
Absent JSON figures/counts are blank CSV cells. Member IDs use semicolons.
These routes are absent under --read-only. Responses are no-store and
subtree audit failures return 503 after two failed minute flushes. Reads that
cannot fit the bounded audit tally also return 503, without recording a partial
comparison.
Root-level Unplaced cells carry unplaced: true and a reserved sentinel node ID,
with no team name. Default root and shared reads without node= include them.
The CSV unplaced column carries the same flag. These cells
are omitted below the root and are never upstream named nodes. Their figures
follow the same withholding rules as other cells.
A deterministic node failure permanently records an unavailable month with a
CHECK-constrained cause: legacy, g_prime_refused, data_defect, no_tree or
panic. The node savepoint rolls back, leaving zero org_seal_node and
org_seal_fold rows. Database and I/O errors roll back the whole transaction
so the month retries.
Report, compare and CSV reads return the existing unavailable-month 404; callers
must preserve a missing month rather than substitute zero. The legacy sealed
body and legacy routes remain byte-identical.
Root credentials can read GET /api/v1/org/report/status for stored month status
and cause. Node and shared read tokens cannot read causes. Federation receives
a typed MISSING month and cause. Ordinary report, compare and CSV responses
use one identical 404 for unavailable and never-sealed months. Re-sealing an
unavailable month is not supported; adding it would require a separate decision
and a table rebuild.
Federated nodes are opaque in this server’s seal: their shown rows omit figures
and counts, never enter a fold, and contribute nothing to local ancestor totals.
Ancestor totals remain equal to the local legacy Total; P30 adds federated totals
separately. Empty local rows can be shown with zero local people and figures.
Shared-token reads use the same IP lockout and 60-per-minute credential budget
as root/node reads. Developer-mode tierd seal --arm, --status and --skip
control the org sealer’s team view with max(k, 3).
The Organization page at /org and its /org.js script use the existing admin
shell registration and return CSP, nosniff and no-store. The page selects one
sealed calendar month and optionally a second month via the existing compare
route. “Load month” submits the month form; setting a compare month opens
“Compare groups”. Comparison labels must match the requested months. Branch
rollup titles show the selected path. Currency uses en-US formatting and points
and TIER use one decimal place. New groups default to the level directly below
their parent. Its CSV link uses the same node and month parameters. It adds no
API route or schema step. Shared-token access remains on the existing dashboard/API
path; the Organization shell continues to require a root/node cookie session.
Vendor poller identity transport (#1476 part 2) adds no event wire fields or schema migration. Copilot seat IDs and Cursor emails are transient ingestion input; only root mappings can bind counted evidence. Mapping-specific delta keys add the opaque mapping ID, while unmapped and alias-only keys retain their existing format. Copilot credential probes also report seat permission failures; accept the App's updated organization GitHub Copilot Business: read permission. See identity access and collector installation and upgrade.
Live unmapped vendor users (#1476)
GET /api/v1/org/vendor-mappings/unmapped is a root-only admin route, classified
with its vendor-mapping neighbours and using their authentication/read rate
limits. Node, personal, other credential kinds and static tokens receive 403.
Cookie/bearer ambiguity receives 400; foreign Origin receives 403. Unknown query
parameters receive 400; invalid sessions receive 401; exhausted read budget
receives 429; store contention receives 503. An in-memory per-vendor guard refuses
overlapping fetches and enforces at least 60 seconds between fetch starts,
including failed fetches, with 429 and Retry-After before upstream I/O.
The GET remains mounted in read-only serving and never writes database state.
Responses are private and no-store.
200 returns {users: [{vendor, vendor_user_id, login, suggestion}], messages: [], effective_from: "YYYY-MM-DD"}. effective_from is the next UTC day; suggestion
is empty unless exactly one current roster person matches a normalized login or
email through their ID or alias. Copilot IDs are numeric GitHub user IDs; Cursor
IDs are normalized emails. Only active mappings suppress a vendor identity from
the list. Ended or voided mappings may be mapped anew from the next UTC day.
A missing configuration, transport failure or unusable response yields that vendor's fixed unavailable message and no users from that vendor, still with status 200. Missing Copilot seat permission uses its fixed permission message. No upstream body or error text is exposed. Successful data from the other vendor can still be returned. Each vendor gets its own 20-second deadline, for at most 40 seconds of upstream fetching within the 60-second write timeout. CSV import uses the existing POST bulk route regardless of live-fetch availability, preserving its 201 success, 400 validation and 409 atomic-conflict semantics. CSV IDs are sent as raw trimmed text; the bulk endpoint alone normalizes and validates identities and normalized duplicates. Existing vendor-mapping route response bodies are unchanged.
Central totals and reporting coverage (#1458, P30 + P32)
The root/node sealed subtree JSON report also contains rollup{native,federated,whole,servers,caveat}.
native ("Teams on this server") uses the selected node's stored local rollup,
including its dated slices, without summing descendants. federated ("From
federated servers") uses accepted periods in the reader's subtree. Each server
entry identifies its opaque place and returns its accepted, count-free periods.
These totals are reported by them, not re-checked here: central never
reconstructs a server total from its groups or subtracts inside its subtree.
Shared read-token reports carry no rollup key, retaining the base sealed report
shape and mode granularity. They disclose neither federated totals nor server counts.
Federated ancestor totals follow the dated tree at month end. Backfilled periods
before a server's admission use its first place; later moves preserve the old
ancestor total. Only current-scope servers expose details, so a new ancestor can
read the moved server's accepted history without rewriting its own old rollup.
GET /api/v1/org/totals?node=&period=YYYY-MM returns this same rollup to root/node
credentials. GET /api/v1/org/totals.csv uses the same query and writes three
rows (native, federated, whole) with columns
source,period,missing,withheld,points,list_cost_usd,through,servers_current,servers_total,label,tier.
TIER is the ratio of total points to total list-price cost, never an average of scores.
Missing or withheld figures are absent in JSON and blank in CSV, never zero.
These routes accept exactly one month, no comparison or free window, and use
the existing in-memory subtree read tally. They are absent under --read-only.
whole selects the latest common closed calendar month at or before the
requested month; its period states which month its figures cover, and
through is that month's final UTC date. It does not mix months or carry a
late server's previous value into a newer month. Each included server must
cover the entire identical month with accepted, contiguous, non-overlapping
periods. A gap, overlap or period crossing the month boundary is missing.
An unavailable local node month is missing as well. The whole rollup remains
withheld if any component is withheld. servers_current and servers_total
describe complete coverage of the requested month, including this server;
revoked servers contribute accepted history but are excluded from both counts.
A missing server month is skipped when revocation is at or before that month's
start, in both federated and whole totals; accepted history is retained.
The backward search stops when a required, placed, unrevoked server has no
accepted period in or before the candidate month. Admitting a new federated
server therefore blanks the whole-company figure until its accepted periods
cover a complete calendar month that is also available on the other servers.
Node readers see their selected subtree path as the combined total label;
root readers retain "Whole company".
Unavailable and never-sealed requested local months return the identical 404
per-node sealed month unavailable on report and totals routes.
GET /api/v1/org/coverage?node=[&month=YYYY-MM][&format=csv] accepts root/node
credentials in every aggregation mode. Coverage uses the same in-memory D9
subtree tally and fail-closed behavior as report/totals reads, and is absent
under --read-only. Other legacy credentials and personal/federation keys
cannot open it. All responses are
no-store; reads perform no database writes. Unknown, repeated or malformed
query parameters return 400; an invisible target returns 404.
Without month, coverage reports the last seven days of local bound-upload
receipts and live server status. Server status (current, late, silent,
never, revoked) has precedence Revoked → Never → Current → Silent → Late.
The expected horizon is the end boundary of the latest month whose end plus
the shared 14-day default report grace has passed. Current means the last accepted period
end reaches that horizon, regardless of push age. Otherwise, a push more than
seven days ago is Silent; a more recent push is Late. Daily/weekly interval and
UTC hour determine the reporter's next expected push, independently of other servers.
servers includes schedule, last report, last period end, next expected time
and contract version. With month, native group status describes receipts during
that UTC calendar month; server status describes accepted periods overlapping
that month, with reasons distinguishing never set up, no reports and stopped. Local
receipt placement follows the dated membership/tree; a month that has not
ended is refused with 400. Receipt queries bind their time window in SQL.
groups contains native node IDs and paths, status (reported, late or
not_reported) and withheld. Shown rows include a reason when absent:
never_set_up means no own personal/upload key was issued before the window's
end for a person placed in the group at that horizon; no_reports means setup
exists but there is no earlier qualifying receipt; stopped means an earlier
receipt exists and none is in the window (late). This definition is shared by
live and monthly modes; servers use their admission and accepted periods as
setup and reporting evidence. Sub-k and complementary-withheld rows retain
Reported / Not reported only: no reasons, contributor counts or upload timestamps. A shown group
may include last_report_at as a UTC date, never an exact timestamp.
last_period_end is the next UTC day boundary after that receipt date, and
next_expected is that date's next daily receipt boundary. Both are dates and
are absent when there is no qualifying receipt history. The full dated forest uses distinct own-credential contributors and complementary
suppression at max(k,3); movers count on their first active slice. Monthly details
require an available, shown stored node decision and use at least its sealed k.
When there are no receipts in the selected month, that stored decision gates
setup/stopped details; a higher configured k keeps those rows status-only.
Unavailable, unsealed and withheld months remain status-only.
No native group publishes a contributor count, including in sealed months.
One bound upload makes a native group reporting; roster-only, shared, unbound,
aliased-away and bot evidence do not count. Internal nodes report when their
dated subtree reports. summary{groups_reporting,groups_total} counts native
group statuses, including the selected native node, never people or servers.
Servers appear separately, with reason (never_set_up, no_reports,
stopped) when applicable. Monthly server metadata comes from accepted history
for that month and earlier periods; revocation does not erase it.
Coverage's reporting-detail gate uses distinct reporting people per dated cell as its additive figures. Repeated receipts do not pad those figures. The same k, complementary suppression, equality, and dominance checks still apply; monthly detail also requires a released sealed group. A small group and its necessary complement retain status only, while safe siblings retain dates and reasons. Coverage releases no usage figures or contributor counts.
Coverage CSV contains group rows, server rows, and one summary row, with columns
kind,node_id,path,status,reason,withheld,last_report_at,last_period_end,next_expected,contract_version,month,groups_reporting,groups_total.
Withheld group detail cells remain blank; shown groups can carry reasons,
report dates, period ends and next expected dates. Exact timestamps and
configured schedules appear only on server rows.
User-controlled labels and paths are formula-neutralized in CSV.
Federation and coverage dashboard pages (#1464, P31 + P33, MODERATE)
/federation, /upstream (Child server settings), and /coverage use the
existing admin session shell, CSP, nosniff, and no-store headers. Root and node
readers can view federation and coverage within their authorized subtree.
Managing nodes can allow, revoke, or reissue a server token inside their branch;
root can choose any native parent. Refused attempts are displayed only for root
and top-level node readers; other readers see an empty table. Detail and hourly
roll-up rows each have a separate display limit of 100, and roll-ups show their
counts. Unknown-token refusals retain the federation reason label. The audit
section uses the authorized audit inventory for root and managing nodes.
Allow/reissue tokens appear only in the transient shown-once result, with Copy and Hide. Same-tab navigation, pagehide, and session clearing erase the result; late responses cannot restore it after navigation. Mutations carry session CSRF and JSON, leaving exact-Origin enforcement to the existing API boundary. The 403/404 action message is “Your key cannot make this change.” Rate-limited changes use the approved Retry-After countdown and disable actions while waiting. Read failures use read copy and never display API error bodies. An unconfigured upstream read (503) shows the host-setup state with Save and Test connection disabled. Company totals reads returning 404 show None for each rollup, without a CSV link or a zero amount.
Root's page allowlist now includes /upstream, linked from Federation. The
central HTTPS address is display-only. Saving sends only the schedule and an
optional replacement token; leaving the paste empty retains the file. A paste
is cleared immediately after submission and uses the existing atomic 0600 host
file replacement. Host setup instructions remain linked even when saving is
unavailable. The paste field uses autocomplete="one-time-code" outside the
schedule form. Test connection uses the saved setup and reads central status
only, without sending a report or changing push history.
Its result distinguishes success, credential refusal, connection failure and an
invalid upstream response, without treating these as session failures.
Both CSV anchors use download; downloading keeps the session shell visible.
The Company totals panel and its reads are limited to root and top-level nodes.
Coverage separates live server status from closed-month coverage and links to
the API's CSV for the selected month and reader's branch. Server reasons retain
never-set-up, no-reports, and stopped distinctions. Native group rows show only
the status and the API-authorized date; withheld groups show status with a
Withheld placeholder for the date, ignoring all other details. Federation's
closed-month rollup uses the three approved source labels, its common horizon,
and N of M current. Missing/unavailable totals display None and withheld totals
Withheld; neither becomes a zero-dollar amount.
Child federation upstream (#1457, P28 + P29, EXTREME)
The child uses the existing tier-federation/v1 contract without a revision arm.
Only sealed calendar node months are eligible, in every aggregation mode and in
evaluation mode. Month-to-date and evaluation rows are never built for transport.
Unavailable node months, for any cause, are MISSING: they have no period in the
payload. The resulting gap is never represented as zero or an empty present
period. Unplaced contributes only through its sealed root total, never as a named
node or fold member. Federated nodes remain opaque. Dated and synthetic cell IDs
become stable opaque UUIDs; all payloads pass the central's shared Go decoder.
Host configuration requires all three settings, with CLI > environment > YAML:
federation:
central_url: https://central.example
token_file: /var/lib/tier/upstream/token
server_id: 11111111-1111-4111-8111-111111111111
The corresponding flags are --federation-central-url,
--federation-token-file, --federation-server-id; environment variables are
TIER_FEDERATION_CENTRAL_URL, TIER_FEDERATION_TOKEN_FILE,
TIER_FEDERATION_SERVER_ID. There is no token-value flag, environment setting or
YAML key. The central URL must be an HTTPS origin without credentials, query,
fragment or path. The durable destination and server ID must match the host
pins on restart; mismatches refuse startup to preserve pending retry identity.
A refusal means this database already belongs to a different central URL or
server ID: restore those host settings to resume the exact pending request.
Moving the token file is allowed; only the destination and server ID are durable
pins. To intentionally change either pin, keep the original database and its
pending history with the original destination; provision a separate child
database and server registration for the new destination. There is no in-place
repin command.
All three routes require a root credential (bearer or the existing CSRF-protected
browser session), return Cache-Control: no-store, and deny legacy tokens:
| Route | Request | Response |
|---|---|---|
GET /api/v1/org/upstream |
None | Host central_url, server_id, token_stored, interval, hour_utc, next_seq, last_success_at, last_period_end, last_attempt_at, last_error, next_attempt_at |
PUT /api/v1/org/upstream |
{interval:"daily"|"weekly",hour_utc:0..23,token?:"…"} |
Same safe view; token is write-only; central_url and token file path are not editable |
POST /api/v1/org/upstream/test |
None or {} |
{ok,http_status,error?,last_success_at}; performs only central GET /api/v1/federation/status, without reserving a sequence or writing push state |
Without host configuration these routes return 503 after authorization. Read-only
serving mounts only GET and starts no pusher. Synthetic demo never configures a
pusher. The root-only Child server settings page is served at /upstream.
A paste atomically replaces the host token file with mode 0600. The file and its
directory must be owned by tierd's user, the directory must not be group/other
writable, and symlinks, hard links and nonregular token files are refused. The
file is read again for each push, retry and connection test. Neither the token
nor any credential-derived value enters responses, the database, audit or logs.
A successful settings save records upstream_configured with interval,
hour_utc and a boolean token_replaced, never any credential-derived value.
Invalid pastes (empty after trimming, internal whitespace or more than 4096 input
bytes) return 400 with the fixed message invalid upstream token; reads and
writes both trim surrounding whitespace.
The replacement is staged in a same-directory 0600 temporary file. Settings and
audit commit before atomic rename; a database failure removes the temporary file
and leaves the old token active. A rename failure returns an error after the
settings and audit have committed; the temporary file is removed.
The minute worker honors the UTC hour and daily/weekly interval. Startup catches
up when the configured hour has passed. Each tick sends at most 31 oldest closed
months in a body capped at 1 MiB. A sealed federated node with NULL local figures
remains opaque: its wire group is withheld with zero figures and no population
counts, and the month can still be pushed. Sequence reservation and the exact pending body
(including sent_at) commit in the additive federation_upstream table before
network I/O. Upstream adds no schema-version bump: older schema-24 binaries
ignore this additive table. Graft advances the schema to 25, followed by import apply at 26
and mapping-audit source redaction at 27; this binary refuses schema versions above 28. Every retry,
including after cancellation or restart, uses those same bytes and sequence.
Acknowledgements must match both the sequence and period list before advancing
the cursor. A separate local scan watermark skips permanently missing node
months without claiming that central accepted them. An available month with an
unrepresentable tree stops the scan with last_error=unsupported_tree and the
normal backoff; it cannot be skipped. Build failures, including invalid stored
pending bytes, record last_error=build, back off and log a fixed ERROR message.
Catch-up continues on subsequent ticks; once caught up, the next
scheduled daily/weekly time applies. Failures retain the pending request and use
exponential backoff from 30 seconds to one hour, checked each minute. A refused
sequence or period is never rewritten automatically. HTTPS certificate validation
is mandatory, redirects are refused, each request has a 15-second timeout, and
response bodies are capped at 64 KiB. Shutdown cancels and joins the worker using
serve's bounded writer drain.
Erased people and later imports (#1491, ruling A⁗)
TIER keeps this record so a completed erasure stays effective. Your organisation decides the legal basis with its counsel.
On an erasure request, if any identifier has an import trace, TIER stores one unlinked keyed hash for each canonical ID, alias and mapped source name. A batch row mapped to someone else is not a trace. If erasure deletes a mapping row whose target is someone else, it also records that source name; a wrong exclusion is reported and reversible. Applied-batch plaintext source-name rows for the erased mapped person are deleted. Retention ageing makes no entry and never deletes entries. The record holds no figures, timestamp or link between identifiers and uses its own HMAC domain. Hashes never appear in API output, logs, audit rows, receipts, previews or data-subject exports. Those compliance artifacts state the record's existence in fixed wording, without checking membership or counting entries.
The record is not anonymous: anyone holding the database could test a guessed identifier. It matches on the identifier alone, so the same identifier from any source is excluded, including a different person. Preview and apply leave a match behind, rechecking inside the apply transaction, with this report line naming nobody: “One or more people were left behind so a completed erasure stays effective.” The bundle is not refused; lookup failure refuses the import. A wrongly excluded person has to come forward because the report names nobody; root can release them. A different (renamed) identifier evades the record. A person erased here with no import trace is not recorded, except this clash case, so a later first import can recreate them. A staged copy can remain for 24 hours.
Entries are kept until root clears them or the database is deleted, with no timer.
A clear removes only the named identifier; a returning person with several
identifiers needs one clear each. Root states either “new consent” or “a different
person with that identifier” (the API value is different person with that identifier).
The response is identical whether or not an entry existed. Each clear is audited
without the identifier, hash or entry count. An audited clear-all states
“our counsel refused this record”. There is no dashboard control. Nothing in the
release waits for counsel, and no Article is cited as the record's legal basis.
The secret and entries share the database file. Entries do not survive a restore
from an older backup that predates them: restoring it loses newer entries and
undoes those erasures. Re-run erasures after a restore.
Schema 28 adds one table, erased_import_identifiers, after schema 27.
Older binaries refuse it. erasure_notice is present in preview and completion
only when someone is left behind by this record; it contains the fixed report
line above. The excluded people require no mapping, and mapping saves skip them.
erased_import_record in DSAR exports and both erase receipts is fixed text,
independent of whether the subject is recorded. No count of entries is returned.
| Route | Credential | Request and response |
|---|---|---|
POST /api/v1/org/erased-imports/clear |
Root only | {identifier, reason}; reason is new consent or different person with that identifier; 200 {"cleared":true} even when absent |
POST /api/v1/org/erased-imports/clear-all |
Root only | {reason:"our counsel refused this record"}; 200 {"cleared":true} |
Both routes use the admin API's bearer or cookie/CSRF rules, are unavailable in read-only serving, refuse unknown fields and query parameters, and audit the fixed reason without the subject. Invalid reasons return 400; non-root callers are refused. Commands using a root bearer (replace the example identifier):
curl --fail-with-body -H "Authorization: Bearer $TIER_ROOT_KEY" \
-H 'Content-Type: application/json' \
-d '{"identifier":"returning-person","reason":"new consent"}' \
"$TIER_URL/api/v1/org/erased-imports/clear"
curl --fail-with-body -H "Authorization: Bearer $TIER_ROOT_KEY" \
-H 'Content-Type: application/json' \
-d '{"reason":"our counsel refused this record"}' \
"$TIER_URL/api/v1/org/erased-imports/clear-all"
Clearing allows new events to import. Identical source content still returns its old stored receipt and does not recreate anyone, including after a clear.