TIER docs

TIER /api/v1 compatibility contract

Earlier release sections below are historical where they describe shared tokens, viewer declarations or server modes. The retirement contract here supersedes those permissions and configuration recipes. Raw-window response fields below (price_table.table_hash, rubric.version, segment_reconciliation, window data_quality and work-type filters) apply only to synthetic/offline reports. Real /scores and comparisons use the monthly contract below; real raw event, outcome, quality, issue, correction and fidelity GET routes are denied even to root.

Credential and declaration retirement (#1499)

All protected real-server routes require current issued keys from one table. Viewer/shared/legacy-admin tokens have no authority. Root/node/personal reads retain holder and dated-branch scope; card and root compliance reads audit before construction. Upload and proxy admission accept only personal/upload keys, metrics accepts root/metrics keys, and read keys cannot write. Unknown credentials receive 401 and valid keys with the wrong authority receive 403, subject to IP lockout.

serve --api-token, --read-token, --metrics-token, --admin-viewer and their server YAML/environment settings are retired. Nonempty retired environment settings fail explicitly. Client bearer flags still transport issued keys. viewer-tokens, declaration and bulk-token selectors, PUT /api/v1/org/people/{dev}/viewers, and hierarchy declaration fields are removed. Schema 30 drops viewer keys and hierarchy declaration columns and removes declaration notices. Required claim/key and effective-access notices, DSAR, erasure, rate/lockout, CSRF and fail-closed read auditing remain.

Monthly person reports (#1497)

tierd verify-report now refuses the retired tiersealedmanifest1 format with exit 2 (COULD NOT CHECK), including when old seal rows remain in the source database. The old codes 3 and 4 and informational seal recomputation are removed. Monthly tiermonthmanifest1 proofs and archived live tiermanifest1 raw-input audits remain distinct supported contracts. A legacy seal receipt is never relabelled as a monthly proof.

Monthly subject export and erasure also resolve aliases captured with each close. A retired canonical is included only where that close captured a link from an identifier the subject holds at request time; a later owner of the same ID keeps their unrelated live and frozen rows. The frozen alias links are local contribution evidence, removed by erasure or retention with that contribution.

GET /api/v1/org/report/status is root-only and now returns monthly close metadata, newest month first: month, closed_at, rule, sources_registered and nullable live_capture_may_be_lost (the close's import/restart loss flag; null means not recorded). It accepts no query parameters, contains no person payloads or row digests, and requires audit admission before responding. A failed audit returns 503; revoked roots and every other key kind are refused. The route remains absent under --read-only.

Correction diagnostics now label a known period monthly_closed_preserved when that UTC month has a close, live otherwise, and unknown when its merge time is missing. Legacy seal presence no longer supplies this label. Cost-consistency class frozen_cost_mismatch replaces sealed_cost_mismatch for protected raw inputs. A closed month and its half-open 14-day attribution lookback prevent PR/branch fills, read-time recovery, token promotion, repair and repricing. CI trigger repair protects the closed outcome month. Old raw-verification receipt bands remain protected while that adapter exists.

GET /api/v1/org/live keeps its response shape. Open months use live inputs; closed months use frozen person/model rows, including dated group paths. Mixed windows combine both. Full UTC month bounds are required if a selected closed month contains a nameless erasure or retention remainder visible in the authorized projection; otherwise the remainder is excluded before checking bounds. A visible remainder with partial bounds returns HTTP 400 with affected_months and whole_month_alternatives (month, inclusive since, exclusive until). Only authorized remainders contribute to those alternatives; selecting one is explicit. Named branch and card defaults start at midnight UTC today minus 29 days. Named cards use their subject projection. A repository-scoped Person card spanning a closed month returns HTTP 400: that projection is not supported by the monthly card reader. Fleet-wide cards remain supported. Closed rows apply the live alias and subjectless-spend rules and preserve dated group totals, including Unplaced. Both live and closed Models credit recorded zero-cost models; missing model evidence uses "No model recorded" with a null cost per point. Last upload remains independent of the figures window.

GET /api/v1/report_manifest?month=YYYY-MM returns tiermonthmanifest1 with the authorized root, node or personal projection and attached Team/Models results. Optional node and model filters pin that projection. Node keys are restricted to their own branch; personal keys to their own person. The audience=comparison form and GET /api/v1/org/report/compare?month=YYYY-MM are available to root and node keys; personal keys receive HTTP 403 on both forms. They return only name-free k-safe group totals through SuppressTree. Monthly comparison reads use the existing subtree-read audit and fail closed with HTTP 503 when it is unavailable. Missing closes return HTTP 404; unknown audiences and unsupported comparison filters return HTTP 400. They omit hidden row identities, census counts and change history. Tree dominance is removed for this monthly output; complementary suppression remain.

GET /api/v1/org/report/compare?period=YYYY-MM&compare=YYYY-MM now reads only monthly rows. Repeat compare for up to twelve additional months. The response is {"compare":[...]}, with one tiermonthmanifest1 name-free projection per requested month, in request order. The single month=YYYY-MM form returns one manifest directly. Root, node (including read-only node) and personal keys receive the same full-tree decision at each close's k. Anonymous, shared, upload and federation credentials cannot read it. Arbitrary windows, node/model/person filters and duplicate primary months are refused. Missing closes return 404; a failed read-audit buffer returns 503 without figures. All months and the credential are resolved in one snapshot. No comparison read creates a close. A frozen live_capture_may_be_lost flag discloses capture loss without releasing source details or internal sample counts. Published whole populations also carry work_types from frozen outcome evidence and conserved category nano amounts, with per-category eligibility and explicit unavailable states. corrective_work supplies the frozen Event 3 population summary when published; relation history, declarations and repair-spend details are explicitly not recorded at close. Category scores and spend are unavailable because the close has no work-type cost partition. New closes pin import/restart loss as close.capture_loss; older close metadata without that field explicitly reports that this loss status was not recorded.

GET /api/v1/org/report?period=YYYY-MM[&node=...] returns the same named tiermonthmanifest1 projection as the monthly manifest. Root sees all local people, node keys (including read-only keys) see their dated branch, and personal keys see themselves. node narrows a branch; a moved child's figures remain clipped to the key's branch at the time of the figures. The scope pins both the key's branch and selected node when they differ. JSON contains results.people, spend_usd, merged_points and model association. People sum to branch totals, including a pooled nameless remainder after erasure or retention. Small samples show score_state: "no score" and a null cost per point. These named reads have no comparison k gate. Shared, legacy-admin, upload and federation keys are refused. The report and its CSV require exactly one period; repeated compare parameters belong only on /org/report/compare. Monthly report, CSV and manifest reads admit their in-memory audit tally before responding and return 503 when that tally is unavailable. Missing closes return 404; reads never create a close. /org/report no longer returns a legacy rollup; central totals remain available separately at /org/totals during the reader transition. On real servers, GET /api/v1/scores?period=YYYY-MM and GET /api/v1/report_manifest?period=YYYY-MM return the same name-free tiermonthmanifest1 projection. Omit period to select the latest monthly close. GET /api/v1/scores/compare?period_a=YYYY-MM&period_b=YYYY-MM returns {"compare":[...]} in A/B order; omit both to compare the latest close with the preceding UTC calendar month. Both closes must exist. These reads accept root, node (including Read) and personal keys under issued-key scope. Shared, legacy-admin and viewer credentials are refused. They reject arbitrary windows, repository/team/work-type filters, duplicate values and incomplete pairs. There are no legacy TIER deltas, seal headers, seal markers, arming or freshness estimates in these responses. Each close supplies its actual closed_at, frozen k and capture-loss disclosure. Unknown or missing closes fail closed; reads never close a month. The dashboard uses a month picker and renders the same suppression decision for each requested month, clearing figures before a new request or after a failure. It has no legacy sealed-mode picker, seal-header parsing, stall/skip state or seal disclosure. A failed manifest starts no report request, including when its error body contains a month. Team, My card and Models remain available for live figures. tierd doctor --server requires a root key and reads /api/v1/report_manifest?audience=diagnostic, selecting the latest close or --month YYYY-MM. It reports frozen identity joins, cost horizons, pricing provenance, partial-input status and actual close time. Current prices do not revise this evidence; aliases and uploads affect future closes. It fetches no seal status or live raw diagnostics.

The explicit report_manifest?month=YYYY-MM form retains its authorized named projection and filters. Synthetic demo period selectors also use these frozen monthly projections. Demo live windows and offline recomputation keep their raw-input reports and manifests; enabling a legacy sealer cannot redirect them to stored seal bodies. The sealed HTTP adapters and informational sealed recomputation are removed. Federation uses v2 monthly snapshots and exact page receipts; monthly digests never stand in for old receipt schemes. Unknown monthly receipt schemas, rules or fields cannot verify successfully.

v0.7.0 identity, coaching and retention: Server aggregation selection and the legacy viewer-credential startup prerequisite are retired. People, root and dated branch holders open coaching cards with every view logged before construction. Every real writable server starts raw-data retention at startup and daily, including before the first watermark; read-only serving and the synthetic demo do not initiate age-prunes. Raw-receipt replay keeps its original pinned aggregation arithmetic. See developer access and upload identity.

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).

#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. Monthly census evidence is captured with the closed rows. Schema 30 drops the historical seal and canonical seal-history tables without converting their aggregate-only history into named monthly rows. The old sealed receipt format refuses verification with exit 2.

The legacy route golden now pins always-on default-deny. Anonymous requests receive 401 outside health and the private synthetic demo. Shared credentials cannot open named cards; root, dated branch and personal keys use the holder boundary. The monthly verifier reads frozen contribution evidence; the seal verifier is retired.

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 evidence: "live_7d_bound_receipts_and_watch", 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. All reporting-summary keys 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 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. HTTP 400 responses ask for request correction and retry. Open card links resolve to /card for the holder and /person?developer={dev} for branch readers. 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; it links to Team for live figures. The setup prompt appears only when the People inventory has at most one person. /card renders the signed-in subject's coaching card and the fixed ancestor-visibility notice. 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 under issued-key access, writes nothing and needs no verified spend. The card routes render an empty card 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 (#1500)

internal/federation/v2 defines the sole production monthly push contract in docs/contracts/tier-federation-push.schema.json: tier-federation/v2 with tiermonthperson1 rows. Every envelope and tagged-record property is required; previous paid amounts may explicitly be null. Unknown, duplicate and incorrectly cased keys, trailing documents, invalid UTF-8 and noncanonical timestamps are refused. Integer amounts retain all 44 signed int64 scoring units and the separate monthly paid partition without floating-point conversion. The schema lists close, cell, census, person, remainder, paid, paid-cell, frozen-evidence, owning tombstone and checkpoint variants. Capture evidence, aliases and signing fields are excluded.

Pages are bounded to 1 MiB, 500 records and 64 KiB per canonical record; snapshots have at most 4,096 pages and 256 MiB of staging. Complete snapshots validate digest chains, frozen membership, erasure watermarks and conservation before atomic publication. Exact retries return retained page receipts. The authenticated direct-TLS admission and pinned route establish authority.

Production Open has one schema-32 federation migration and no selectable legacy mode. Fresh installation creates v2 federation tables directly. Upgrades preserve admissions, credentials, schedules, local monthly rows and evidence; pending v1 bytes are discarded with obsolete_pending_v1. Accepted v1 history refuses migration with a specific diagnostic and leaves schema, rows and version unchanged. The aggregate bridge, v1 wire and legacy storage installers are removed.

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}; parent_id is "" for a top-level node 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}]; 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; viewer declaration fields are removed
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 unchanged; viewer declarations removed; 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 revokes, 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[?period=YYYY-MM] accepts a root, node or personal holder key or session on real servers, including read-only servers, in every aggregation mode. Omission selects the latest monthly close. Unknown, repeated or arbitrary-window parameters return 400; a missing close returns 404. Shared, legacy-admin, viewer, upload and federation credentials cannot open this report. Read-audit admission fails closed before any figures leave.

The definition remains pr_created_to_merged: captured PR creation to merge, including draft time. New closes freeze both timestamps alongside outcome evidence in the same monthly snapshot. This descriptive endpoint reads that evidence, never today's raw outcomes or old seal bodies. Earlier closes lacking timestamps return null statistics with no_timestamp_evidence; evidence removed by erasure or retention returns evidence_removed. Neither is backfilled. The separate response is not an attached verify-report result.

window.aggregation is monthly, and its UTC bounds cover the selected month. groups uses the same full-tree decision as /scores, including synthetic folds; its team field contains the published tree ID. Hidden groups have no metric row. The total additionally needs k distinct eligible counted PR authors, as does every group. Model allocations cannot add authors or PR samples. The median averages the two middle nanosecond durations before conversion to whole seconds; p90 uses nearest rank and is null below ten distinct PRs. Every sample, contributor and exclusion count is null, with no exclusion reason counts. Published statistics carry monthly_counts_not_published; a small author sample carries authors<k. Group medians are never summed or averaged into the total. A nonempty below-k child PR sample also withholds its ancestors (withheld_with_group). The dashboard uses the selected month and shows median hours and available p90, without a denominator. Missing evidence and withheld figures have explicit text.

The private synthetic demo and offline recomputation retain the live window helpers (since, until, before), legacy group fold and raw timestamp reader. Their counts and exclusion diagnostics describe only that live window. This exception never opens a real server to anonymous reads.

Monthly score, comparison and manifest responses also contain optional group_levers, keyed only by published tree IDs. Cache-read shares pool frozen cache tokens over all input-side tokens, including cache writes; they never average person or model ratios. Every published population needs k eligible people with captured spend and merged work. Both cache and non-cache input partitions need k distinct eligible people; model rows and zero-input people cannot add seats. No other group practice ratio is released on the name-free surface. Named monthly reports and CSV retain each authorized person's practice ratios. Erased evidence cannot supply category seats. The front-page CSV exports only the already-displayed group figures and allowed cache shares; it makes no extra read.

The front page keeps monthly group rows, comparisons and its month picker. “Open branch” opens /org at that month and group, where the holder boundary selects the people and CSV. A synthetic fold opens its parent branch rather than claiming that the parent is the anonymous fold's exact membership.

Pricing provenance additions (#1231, #1232)

The window data_quality envelope below is synthetic/offline only; real monthly reads carry frozen disclosures (or origin_evidence[].disclosures).

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. Monthly responses retain their frozen pricing provenance. 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)

The following raw-window report is synthetic/offline only. Real servers deny GET /api/v1/issues, so the CLI server path cannot read it there.

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 raw routes except GET /api/v1/report_manifest carry the population-independent X-TIER-Attribution disclosure; stored monthly reports retain their frozen inputs. 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 404 here — verified against the published 0.4.0 container image. The published v0.4.1 release binary answers 200 with its commit (measured 2026-09-25); the response below is the exact body it returned.

⚠️ 404 on 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 with GET /api/v1/livez, which has always carried version. 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 synthetic/offline raw 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.

⚠️ 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/http does by default. A client that sends an extraneous parameter and previously got 200 now gets 400.

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 verify the returned scope. Real monthly scores accept only period; comparisons accept only period_a and period_b. Synthetic/offline raw windows echo repository scope in data_quality.repo_scope. Feature-detect via livez.version as always.

The compatibility rule

JSON responses are additive-only. A field's semantics are frozen once shipped.

  1. Adding a field is additive. A new response field may be added at any release. Clients MUST ignore unknown fields.
  2. 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.
  3. 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.
  4. 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, watcher status) may be appended; an existing value may not be renamed or removed.
  5. 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_id stayed "issue-42" when repo arrived in its own new column, #231). This restates the contract already enforced in internal/api/export.go (eventsCSVHeader, outcomesCSVHeader, qualityEventsCSVHeader, qualityHistoryCSVHeader) and documented in docs/how-it-works.md. With Event 3 enabled, its conditional outcome columns precede issue_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:

  1. The Deprecation (RFC 9745) and Sunset (RFC 8594) response headers on the affected endpoint. Deprecation carries the date the field/endpoint became deprecated; Sunset carries the date after which it may be removed (a /v2 or a removal). Together they replace the single RFC 7234 Warning: 299 header precedent on POST /costs (deprecating cache_write_tokens): Warning was deprecated by RFC 9111 and is dropped by some intermediaries, so it is not a reliable deprecation signal. Warning: 299 remains only for the one already-shipped cache_write_tokens case for back-compat; new deprecations use the Deprecation/Sunset pair.
  2. 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:

Endpoint catalog

Real-server authority is defined by internal/api/identity.go and holder scope:

Only tierd demo serves anonymous synthetic dashboard data. The raw response schemas and query examples in the read catalog below are synthetic/offline contracts; real mounted monthly reads follow Monthly person reports. Client --api-token transports an issued key and grants no authority by itself.

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

POST /api/v1/events

POST /api/v1/outcomes

POST /api/v1/actual_spend

POST /api/v1/org_actual_spend

GET /api/v1/org_actual_spend

For a table-backed root key, the organization's names-off choice returns 403 before any view audit or raw finance figures. Use the monthly group report for k-safe totals.

POST /api/v1/developer_alias

DELETE /api/v1/developer_alias/{alias}

GET /api/v1/developer_alias

PUT /api/v1/org_hierarchy/{developer}

POST /api/v1/org_hierarchy

GET /api/v1/org_hierarchy

POST /api/v1/period_membership/{developer}/end

DELETE /api/v1/developer/{id} (GDPR Art. 17 erasure)

GET /api/v1/developer/{id}/export (GDPR Art. 15 access)

Table-backed root data-subject exports remain available under every names_visibility choice, with audit admission before person payload construction and fail-closed audit refusal. Personal keys retain their own card and report figures under every setting.

Read endpoints

Raw-window schemas in this catalog describe synthetic/offline reports. Real scores, comparisons and manifests use the monthly contract above; real bulk event/outcome/quality, issue, correction and fidelity reads are denied. Real named score detail is a holder-scoped card.

The shared-token diagnostic route is retired.

GET /api/v1/scores

GET /api/v1/diagnostics/captured-dollars

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}

GET /api/v1/scores/compare

Monthly close metadata

Monthly scores, comparisons and manifests carry actual closed_at, frozen policy and capture-loss disclosure from the close. They do not expose legacy seal headers, stall/arming markers, stored body digests or outcome-dependent revert-window observations. The sealed HTTP disclosure format is retired.

GET /api/v1/report_manifest

GET /api/v1/events

GET /api/v1/outcomes

GET /api/v1/quality_events

GET /api/v1/quality_history

GET /api/v1/fidelity

GET /metrics

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: {...}}

GET /api/v1/health

GET /api/v1/healthz

GET /api/v1/livez

Event 3 corrective scoring contract (#1235)

Raw-window score, segment, evidence-GET and digest envelopes in this section are synthetic/offline only. Real servers deny correction diagnostic/evidence GET routes; local CLI evidence and root-authorized subject exports remain available. Monthly policy stamps and panels use frozen contributions.

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:

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

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.

The following #913 seal entries describe a retired contract. Current monthly close behavior is described under plan R′; the old commands and producer no longer exist.

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 erasure and settings (#1440, plan P13/P14)

Root erasure and settings routes remain. Holder card reads use dated branch checks and commit their view audit before construction. Personal view-history reads expose only their own rows. Declaration routes and fields are absent.

Settings use explicit flag > environment > config pins, then saved DB values, then defaults. 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 adds server_settings. Schema 30 removes the retired seal stores and their metadata installers, retaining saved settings, hourly read audit, the installation secret and correction-policy invalidation guards. It does not backfill named monthly history from aggregate-only seals. Old sealed receipts refuse verification with exit 2; raw-input manifests remain supported. This binary refuses schemas newer than 30 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. Other non-200 source refusals return 400 with the source refused: HTTP N, where N is only the numeric HTTP status; the source body and headers are never relayed. 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, monthly_reports (the frozen residual financial/disclosure projections), 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, event UID, then content fingerprint within the same destination person, against local and incoming rows. Equal content from different people is kept and does not increment duplicate_events. Before mappings are saved, preview uses each source person's identifier; mapping to another person can change the advisory duplicate count. Apply uses the saved destination mapping. 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.

Import event identity resolves aliases to the canonical person. Within one person, equal content fingerprints collapse even when the source counted rows with distinct message IDs and keys. Preview duplicate counts are before mapping; a saved mapping can change the count at apply. When a transport identity belongs to a previous destination person, save that explicit mapping through PUT /api/v1/org/imports/{batch}/mappings before applying again.

EXTREME — concurrency and data loss during company imports (#1488, ruling A‴). While an import applies, live proxy captures wait. Uploads to /api/v1/events can receive 503 on write-lock contention. tierd ship resends the unchanged batch with exponential backoff (one second initially, capped at 30 seconds) for up to three minutes after the first 503. It honours Retry-After seconds or HTTP dates, prints a plain waiting line, and fails clearly if the window expires or the command is cancelled. Unsent events remain safe to re-ship because uploads deduplicate. Other upload clients must retry a 503 themselves. Proxy captures 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 the parsed event and server-admitted credential accessor through the shutdown drain; the write transaction rechecks binding, revocation and departure. 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 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, lock_started_at, lock_ended_at, and other. The lock estimates and memory_gb_for_64_mib belong to preview only. 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 only for the same mapped destination person; equal events from other people retain their rows and spend. When no same-person duplicate exists, a transport key or UID already owned by another person refuses the transaction rather than reporting a duplicate. 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. Bundle v1 outcomes carry digest-covered work_type, work_type_source, and weight_source through export and apply. Export follows outcome aliases and counts rejected aliased outcomes in left_behind. Mapping uniqueness uses canonical people. 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. If the original receipt has an erasure notice and the current pull has no excluded people, a fresh batch applies normally; identity deduplication keeps existing rows from doubling. Changed overlapping name-free totals keep the first stored total and record other[].kept_first: true; named rows continue. The receipt shows the stored figures and current sticky withholding. Browser identity conflicts retain the batch ID and allow an explicit source-to-previous-destination mapping, including when the preview had no clashes. Expired batches still require a fresh pull.

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 totals use validated frozen monthly rows and exclude 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 k, contributor, complement and cost gates, without tree dominance. Unsafe name-free cohorts remain omitted without a person-level refusal report. The required digest-covered name_free_omissions array records each omitted closed month's period_start, period_end and reason: eligibility_not_established or transport_figures_not_supported. It contains no identities, figures or counts. Preview and stored apply receipts carry the same array; the import screen shows it separately from retained named rows. An omitted period cannot overlap an exported total or another omission. Totals use the stricter close/requested k, frozen counted evidence and dated paths, with per-person points/cost gates and full-tree checks across all frozen additive measures. Pooled or unrecorded population evidence cannot supply cover; no raw-history fallback is used. A later alias that could duplicate residual evidence in the named export makes that monthly total unavailable. Each exported total also carries financials and name-free evidence from its close. Preview, the first stored total and replay receipts preserve signed paid amounts, realtime evidence, horizons, partial inputs, capture-loss status and frozen provenance; a changed overlapping total retains the first evidence.

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 (v2, #1500, EXTREME)

Production Open installs schema 32 and production routes use tier-federation/v2 with row_schema: "tiermonthperson1". Read-only Open requires this schema and never migrates it. An upgrade refuses nonempty accepted v1 history atomically: aggregate periods cannot be converted into named monthly rows. Obsolete v1 pending bytes are discarded with obsolete_pending_v1, preserving the reserved sequence and host binding. The retained v1 library/schema is historical; it is not the production transport.

Administration uses root/node bearer keys or the normal cookie Origin/CSRF boundary. Federation keys cannot administer or read reports. Managing node keys operate only in their branch; out-of-scope targets return 404. Only root may erase a received origin/person centrally. All responses use no-store caching.

The application enforces federation and upstream routes before mux path cleaning: unknown or noncanonical paths return 404 without redirecting, and unsupported methods return 405 with the exact Allow methods. Both use {error,retryable}. Read-only serving keeps mutation routes absent (404). Revocation discards incomplete page bodies and internal staging metadata atomically with the audit; reissue resumes through the retained nameless snapshot receipts. Startup converges the schema-32 revocation guard under the writer lock.

Method and route Request / result
GET /api/v1/org/federation Branch-scoped servers with origin IDs, approved origins/routes, token metadata, schedule, last_received_at and committed reported_through
POST /api/v1/org/federation/servers {origin_id,label,host,parent_node_id,interval,hour_utc}; 201 admission/origin/node IDs and a shown-once token
POST /api/v1/org/federation/servers/{id}/revoke {}; 200 {}; scrubs incomplete staging and preserves committed history
POST /api/v1/org/federation/servers/{id}/reissue {}; 201 IDs and replacement token, preserving namespace and receipts
POST /api/v1/org/federation/servers/{id}/origins {origin_id,route,host,label}; approves a descendant on its pinned route
PATCH /api/v1/org/federation/origins/{origin_id} {host}; audited approved-host update
POST /api/v1/org/federation/origins/{origin_id}/people/{person_id}/erase Root only, {}; pools names and installs a permanent local restore block
GET /api/v1/org/federation/refusals?since= Canonical timestamp filter; bounded detail/hour records; root alone sees source classes
POST /api/v1/federation/push One strict v2 page; 202 staged, 200 committed or exact receipt retry
GET /api/v1/federation/status Optional single snapshot_id; pinned identities, sequence, committed freshness, snapshot state and protocol limits
POST /api/v1/federation/abort {contract,server_id,seq,snapshot_id,snapshot_digest}; idempotent aborted/committed receipt

The Go contract in internal/federation/v2 defines exact fields and tagged records. JSON rejects duplicate, unknown, incorrectly cased, missing and null fields, trailing documents, and imprecise integer forms. Pushes require HTTPS by default: direct TLS or an HTTPS assertion from a configured trusted proxy. Untrusted forwarded headers never establish HTTPS. Root may allow plain HTTP through federation_require_https=false in organization settings; names, figures and the federation key then travel unencrypted. Pushes require exactly one federation bearer, JSON media and no content encoding. The production client follows no redirects and discovers no environment proxy. Error bodies contain exactly {error,retryable}. Only 429/503 carry Retry-After. Other permanent refusals require configuration repair; origin_not_allowed backs off only the refused origin for one hour, without claiming delivery or stopping other origins.

Pages are bounded to 1 MiB and 500 records; snapshots to 4,096 pages and 256 MiB. The 256 MiB limit counts incoming wire bytes; receipt metadata is charged only to the separate 1 GiB staging budget. Authenticated pushes do not consume the 60-per-minute status/read quota. Administration inventories and refusals are not subject to the 64 KiB transport-response limit. Root's federation inventory includes this server's own origin_id; server rows retain their issued server_id. Months must precede the current UTC month, sent_at may be at most five minutes ahead, and close k is bounded to 3–1,000,000. Census prefixes must cover the sum of their direct children; without a nameless remainder they must equal counted people. Organization and level writes reject Unicode format characters. An unsendable frozen month returns the stable local error federation month has an unsendable name. Moving an admitted route's anchor returns 409 with a reparented explanation.

Durable exact bytes, digest chains and receipts survive restarts. Partial pages never publish rows or freshness. Final publication checks conserved cells, identity membership and owning erasure watermarks atomically. Receipts recognize old exact retries after later commits. Admission/route bindings cannot move; credential rotation preserves origin namespaces. Tombstone authority comes from the authenticated, pinned route; there are no signatures or signing keys.

Monthly person/model rows retain signed integer amounts. An empty model retains the local row's existing absence of model association. Missing local level names use a stable Level N label when controls are first frozen. Remote records carry no local capture evidence, aliases or child credentials. Reader scope, k and name-expansion policy apply at read time, with audit admission before bytes.

Blocked incoming person records are consumed and counted in blocked_records, with the other records counted in accepted_records. Names are removed before staging; restart-safe digest progress checks the original snapshot stream before publication. Exact retries retain the original counts and receipts. Conservation controls supply nameless remainders, and permanent blocks prevent name restoration. Missing owning erasure watermarks still return tombstone_gap. The v1 wire, storage, pending paths and aggregate bridge are retired; only the coordinated upgrade reads their old shape to refuse accepted history or discard pending bytes.

Refusals retain no raw address, presented credential, credential-derived value or body-claimed server identity. They buffer in bounded memory and flush every minute and after HTTP drain; details are capped at 10,000 rows/90 days and hourly overflow is retained for 90 days. Read-only serving omits mutations and performs no refusal writes. Inventory/status remain read-only operations.

Sealed subtree report API (#1441, P18 + P19)

The report and CSV routes now use the named monthly projection described in Monthly person reports. The former seal-cell groups, other, withheld and rollup response has been retired on these routes. The organization's names_visibility choice also governs these reads: on (default), top_admin_only or off. Names off includes root; top admin only permits root alone to expand groups. Personal keys always retain their own figures and card. Root data-subject exports remain available and audited under every choice; finance read-back refuses when names are off. Root saves the choice through the existing settings PUT and old/new audit, without a schema change. Comparison remains name-free and uses the complete monthly k decision. These routes are absent under --read-only; all responses are no-store.

GET /api/v1/org/report.csv?period=YYYY-MM[&node=...] exports the same ordered people as monthly JSON, with columns period,developer,spend_usd,merged_points,cost_per_point,score_state,last_upload,spent_nothing_merged,premium_model_share,cache_read_share,context_per_output_token,no_issue_share,live_capture_may_be_lost, followed by scoped diagnostic status/counts and the shared monthly financial/disclosure columns. Null ratios and unknown upload times are blank. Person names are formula-safe CSV text; no person or hidden row evidence outside the authorized branch enters the export. Capture-loss flags come from the close, not current raw state. An empty closed month has zero totals and an empty people array; it is distinct from a missing close. Monthly manifests and reports share the same projection and audit boundary. A manifest's optional model filter does not authorize a wider branch. Comparison manifests reject node and model filters. When names are hidden, reports, named manifests and /org/totals return this same full-tree comparison manifest; branch/model filters never create another suppressed projection. Their CSV has period,group,disposition,spend_usd,merged_points, with withheld figures blank. No person rows, model figures, row hashes, origin inventory or hidden census accompany the decision. The Organization page renders group totals for that response.

A writable tierd serve closes due UTC months at startup and checks again every minute. A month becomes due 24 hours after its end. Reports, CSV, trends, comparisons and month pickers read frozen month_person rows; named monthly reports/manifests and people CSV also project authorized month_paid cells, including paid-only people and nameless remainders, with absent billing distinct from recorded zero or credits. Comparison JSON exposes only published rows, without hidden IDs, fold membership or census counts. Its dashboard KPIs use the published whole row. Monthly report/manifest and CSV disclosures retain frozen horizons, partial-input status and price/rubric/build provenance; model filters receive no monthly bill allocation. Team and group views expand to the people allowed by the reader's key. Live branch views remain available before close. Late raw input does not rewrite a closed month. Read-only serving starts no close worker. Source coverage and known capture loss are frozen as evidence and disclosures; they do not require arming or skips. The seal command, preview, status, --seal-from, --report-grace, seal_from and report_grace settings have been removed. Nonempty TIER_SEAL_FROM and TIER_REPORT_GRACE settings fail startup explicitly.

Schema 30 drops legacy seal lifecycle tables and report bodies. Closed months and frozen contribution rows are the only stored reports; tiersealedmanifest1 receipts refuse with exit 2. The #913 entries below describe the retired contract.

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 closed calendar month and optionally a second month via the compare route. “Load month” submits the month form; setting a compare month opens “Compare groups”. Comparison labels must match the requested months; this view renders monthly name-free trees for the whole company and sends no node filter. It shows no person counts and hides the single-month CSV link. Branch rollup titles for single-month reads show the selected path. The page renders named people, no-score states and lever ratios from the monthly projection, and uses its totals directly. Currency uses en-US formatting and points use one decimal place. New groups default to the level directly below their parent. The single-month CSV link uses the same node and month parameters. It adds no API route or schema step. The Organization shell requires a root/node cookie session; shared tokens cannot open its monthly report or CSV.

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 /api/v1/org/totals and .csv readers sum the same authorized local and committed received month_person rows as /api/v1/org/report. They accept exactly one period=YYYY-MM and an optional dated node selection. The response retains native, federated, whole, servers and caveat, and adds per-origin origins and missing_origins with available months and the last fully committed closed-month date. whole.period always names the selected month. An incomplete figure is labelled reported subtotal; missing origins are excluded explicitly. No period tiling, common-month search or older-month substitution remains. Revoked admissions retain their committed history and availability. Named branch totals have no comparison k floor. Each total carries financials (paid and realtime amounts, recording/revision state and derived ratios), ranked (the frozen score evidence verdict), provisional (partial retained inputs), and local evidence when applicable. Multi-origin origin_evidence lists each close's date, capture-loss state, policy and frozen disclosures. Named responses also include origin identity and build; name-free comparisons omit those inventory values. CSV serializes the same evidence in origin_evidence. Server TIER is derived after the ranked verdict; an incomplete numerator withholds federated and whole figures.

Totals CSV writes three rows (native, federated, whole) with columns source,period,missing,withheld,points,list_cost_usd,through,servers_current,servers_total,label,tier,live_capture_may_be_lost,ranked,provisional,origin_reports, followed by paid_status,paid_micro,realtime_status,realtime_micro,capture_fidelity,spend_leverage,disclosure_status,partial_input,retention_watermark,cost_horizon_status,cost_coverage_start,cost_coverage_safe_since,source_coverage_start,closed_at,rubric,prices,pricing_policy,pricing_notice,billing_notice,partial_coverage,origin_evidence. The origin_reports column carries each remote origin's exact reported through YYYY-MM-DD stamp, missing status and available months. Points and costs pool integer units before ratios; TIER is never an average of scores. A month with no committed close returns 404. Reads use the existing in-memory subtree tally, fail closed with 503 on audit admission failure and are absent under --read-only.

Multi-origin reports and report_manifest?month=YYYY-MM use tiermonthmanifest2. Named people carry an origin-qualified opaque person_id, branch, monthly numbers, model associations, score/no-score state and remote freshness. Remote links are built from the receiver-approved normalized DNS host as https://HOST/person?developer=LOGIN&month=YYYY-MM. No credential travels in the link. Team, Person and Models accept a closed-month selector; remote full coaching cards open on their owning server using that reader's sign-in. Offline origins remain readable and their available months remain selectable.

Monthly comparisons and /scores use one full-tree k/complement decision over this committed universe. Model rows cannot add census seats; remainders conserve amounts and frozen census, but cannot qualify for peer or lever populations. Defaults choose the latest committed approved origin month. tiermonthmanifest1 retains genuine local contribution refolding. tiermonthmanifest2 distinguishes locally refolded evidence, rows received from each origin and locally deidentified projections. Verification reproduces received sums, allocation, scope and ratios; it does not claim to refold child captures, coaching evidence or timestamp distributions. Those remain on the owning server. /api/v1/org/live remains local.

The v2 receiver, sender workers and coordinated schema-32 upgrade are active in production. These readers use committed monthly rows; they do not consume the superseded v1 accepted-period storage.

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 24-hour monthly-close grace has passed. Current means the latest committed closed month 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 origin, schedule, last commit receipt time, reported-through date/stamp, available months, next expected time and v2 contract version. With month, native group status describes receipts during that UTC calendar month; server status describes a committed close for exactly 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 committed monthly closes 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. Receipt detail uses the same full-tree k/complement decision as monthly comparisons, with distinct own-credential reporters and no tree dominance. Each reporter supplies at most one census seat across dated paths; repeated uploads never add seats. Monthly details also require a shown month_person group decision using the greater of the current and frozen k. With no receipts in the selected month, that monthly decision gates setup/stopped details. Missing closes and withheld groups remain status-only; legacy seals cannot supply that gate. Changed monthly rows fail closed.

local_evidence: "live_bound_upload_receipts" labels the native diagnostic in JSON and the CSV's local_evidence column (group and summary rows). Receipt dates/status and setup reasons read retained raw evidence in the same snapshot as the monthly gate. Late receipts or retention can change this diagnostic; frozen monthly costs and points do not change. Server evidence is committed v2 origin/month history; staged pages and v1 periods cannot supply coverage. No native group publishes a contributor count, including in closed 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 and complementary suppression checks apply without tree dominance; monthly detail also requires a shown frozen month_person comparison 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,local_evidence. 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 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)

Production upstream administration requires root through the bearer/session/CSRF boundary. GET /api/v1/org/upstream returns only pinned destination identities, schedule, token-stored state, delivery/retry metadata and nullable pending:{snapshot_id,seq,next_page_index,page_count}. PUT requires {token,interval,hour_utc}; token:null retains the host file. POST /api/v1/org/upstream/test requires {} and reads only the pinned status endpoint, validating its complete v2 response without reserving a snapshot or advancing delivery. Errors contain exactly {error,retryable}. Cancellation interrupts attempts, settings saves and status probes waiting behind an active push without changing reserved bytes, configuration or delivery state.

Host configuration requires all four 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
  receiver_origin_id: 22222222-2222-4222-8222-222222222222

Flags are --federation-central-url, --federation-token-file, --federation-server-id and --federation-receiver-origin-id; corresponding environment names are TIER_FEDERATION_CENTRAL_URL, TIER_FEDERATION_TOKEN_FILE, TIER_FEDERATION_SERVER_ID and TIER_FEDERATION_RECEIVER_ORIGIN_ID. The receiver identity must be explicitly approved on the host; a network response cannot repin it. No token value belongs in CLI, environment or YAML. Destination URL, admission and receiving installation are immutable after pinning. The URL must be an HTTPS origin, or an HTTP origin when this server’s organization allows plain HTTP for federation. Moving the token file preserves the pending reservation and destination.

The token stays in an owned 0600 host file, re-read on each request. Invalid pastes return 400 malformed; no secret or credential-derived value enters responses, database errors, audit or logs. Replacement is staged until settings and audit commit, then atomically renamed. Database failure preserves the old file; rename failure is reported after commit and discards the temporary file.

The minute worker respects UTC schedules and durable retry deadlines, delivering at most one complete origin snapshot per attempt. Owning tombstones precede monthly projections. Local and active received origins are delivered separately; a relay preserves their original identities, controls and erasure authority. The per-origin/month inventory discovers late closes independently of the newest month. Pages and sequence are reserved before network I/O. Status reconciliation resumes staging or selects the exact final retry after acknowledgement loss; only its validated receipt advances delivery. Privacy-invalidated bytes are scrubbed, reconciled/aborted and rebuilt. Interrupted attempts retain their exact reservation. Shutdown cancels and joins the worker. Synthetic demo starts no pusher; read-only startup verifies existing pins and starts no writer.

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

(
set -eu
umask 077
H_TIER=$(mktemp)
trap 'rm -f "$H_TIER"' EXIT
printf 'Authorization: Bearer %s\n' "$TIER_ROOT_KEY" > "$H_TIER"
curl --fail-with-body -H @"$H_TIER" \
  -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 @"$H_TIER" \
  -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.

Live named branch read (#1495, plan R′ slice 1)

GET /api/v1/org/live accepts root, node (Manage or Read) and personal bearer/session credentials in every aggregation mode. Parameters are since, until (exclusive UTC day) and optional node; default is the last 30 days. Unknown parameters are 400. Personal reads include only the holder. Root sees all people when the organization's names choice permits it; permitted node reads include only their dated branch. Off-branch selected nodes are denied. Each event and outcome uses its own dated placement.

The JSON object contains since, until, people, spend_usd, merged_points, models, association and model_report. Windows containing closed months also carry financials, closed_months and a provisional flag for partial retained inputs. Each closed_months entry records the exact selected bounds, whether the month is complete, its authorized financial projection, frozen close provenance and disclosures, and explicit capture-loss evidence status. Paid amounts apply only to complete UTC months. Partial or mixed open/closed windows leave combined billing unavailable; separate complete-month amounts remain available. Missing monthly billing remains absent, distinct from recorded zero and signed credits. People contain developer, spend_usd, merged_points, nullable cost_per_point, score_state, nullable last_upload, spent_nothing_merged, the four practice ratios and models, plus applicable financials. Models receive frozen realtime numerator and capture fidelity without monthly bill allocation. Holder cards carry the same applicable financials and per-month evidence. Monthly responses declare Tier-Inference: not_established and aggregate pricing without unsupplied bounds. Too little evidence has score_state:"no score" and a null person ratio. Model rows contain model, spend_usd, merged_points, nullable cost_per_point, and an optional bundled report with edition, task, url, accepted, runs, optional spend_usd and nullable cost_per_accepted. The bundled orgsync figures use Edition 2 or, for earlier models, Edition 1; zero accepted runs retain a null cost per accepted run. Each outcome's points are apportioned by attributable model cost; missing evidence is retained as "No model recorded". The association label and separate report units travel with the numbers. Spend stays within the chosen period, while a merged outcome can use earlier attributable costs. People sum to the group figures.

When names are hidden, /org/live returns the shared monthly comparison manifest instead. An exact whole-month window requests that close (404 if absent); other windows select the latest closed month (404 if none). Node/model filtering cannot derive a second anonymous decision. Team, Person and Models label the whole company and month and show only the safe groups. Personal keys keep their own live figures. Monthly records contain people under every choice, and a setting change never rewrites a close. The reading server applies its organization choice to every origin; a full card request on another server checks that server's choice.

The route uses one authorized snapshot and writes nothing. /team, /person and /models render these figures in the existing responsive light/dark shell. Organization links group names to Team; the Person screen renders the coaching card and levers. tierd demo seeds all screens on its private synthetic database. Real card reads commit their view log first and require a writable server.

The private synthetic demo also answers GET /api/v1/me/notices with [], as it does for /api/v1/me/views, so My card stays open. This exemption is absent on real servers. Live cards include summary.no_issue_share for advice about recorded spend without an issue, including when there is no merged work.

Live figures: identity, placement and limits (#1496)

Live Team, Person and Models reads join aliases to one canonical person after checking each raw identifier's dated placement. Spend uses the stored named subject when present and otherwise the producer's developer identifier, so shared uploads and upgraded history remain visible without gaining census eligibility. Personal keys open only their own card and figures. Last upload uses receipt history independently of the figures window, with the existing credential receipt fallback after retention; its visibility follows the holder's branch at receipt time.

Root totals include activity before placement. The live response's groups array contains visible node totals (node_id, parent_id, name, spend_usd, merged_points), plus unplaced:true and direct:true rows where needed. Pass a direct:true row's node_id as ?node=direct:<placement> to read only that placement's direct people, under the same dated branch authorization. Unplaced belongs only under the root. With independent company roots and no placement evidence, it stays in the forest remainder visible only to the global root credential; company node keys cannot infer its owner. Children plus direct and unplaced figures reconcile with each parent; the overlapping ancestor rows must not be added together. Organization uses one live response and loads sealed reports independently, so large trees do not spend one read per group.

Live figures and holder cards default to 30 days and refuse windows longer than 90 days with HTTP 400: choose a shorter period or use monthly reports for older months. This window cap is the simpler bound on live read cost. Recorded zero-cost models receive an equal model share of associated points; remaining points are divided among paid models by attributable cost. With only zero-cost models the split is equal. "No model recorded" has a null cost per point rather than implying free work. Card opens on read-only servers explain that the view log needs a writable server. Live branch reports admit one bounded audit count per covered UTC month and requested scope before building figures; unavailable admission or read-only serving returns 503 with Retry-After.

Retired evaluation_mode YAML and saved settings are ignored. They cannot relax live access or k; historical report replay retains its recorded labels.

Monthly federation receipts project origin availability only through their selected month, so later-month delivery does not change verification. The manifest records the reading server's k floor and compares using that same floor. Full-card URLs include the owning origin and person ID, including on middle servers. Known remote groups remain authorized from their retained route paths when the selected month has not arrived. Monthly billing revisions use a separate frozen-basis ledger-ID chain. Readers and CSV distinguish awaiting billing, recorded date, revised date and the previous paid amount; still expected sources prevent recorded from meaning final. A close without a basis says billing not recorded at close and refuses late invoices. Name-free group rows exclude paid outside shown groups at every ancestor and carry the same amount-free note when the company line is nonzero. Named readers keep each person on their frozen path. Personal keys receive the same name-free /scores and /scores/compare decisions; /org/report/compare remains denied.

Federation v2 carries the frozen paid partition, recording/allocation status, realtime numerator, billing reconciliation and its independent revision chain per origin and month. It retains quality, horizon, retention, capture-loss, price/rubric/build evidence, dated labels and selectors. Relay forwards each origin once; its own invoice never reallocates onto child contributions. Readers sum integer numerators and denominators before deriving ratios; unknown billing remains unknown. Local and received rows use one census-only publication decision, with no fold-equality check. Paid stays outside that decision, with nodeless paid on the name-free company line. Erasure, retention and replay preserve amounts without restoring names. Multi-origin JSON and CSV retain each origin's disclosures. The organization owns its data; unreleased databases have no compatibility adapter. Paid is schema step 31 and federation is step 32.

Name-free live requests require one exact closed UTC month; an explicit partial or multi-month window returns HTTP 400. The dashboard's whole-month selector uses /org/report, including received origins. Submitting From/Before dates clears monthly selection.