TIER docs

TIER /api/v1 compatibility contract

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

This document is the stability contract for TIER's HTTP API. It pins, for every /api/v1 endpoint: its stability guarantee, its response schema, and the project's rules for what may change without a version bump and what may not.

It exists because a JSON field's meaning can change while it stays parseable, and nothing in the wire bytes announces it. #187 demoted the top-level /scores developers/teams/total fields to a pooled back-compat summary that is explicitly not a cross-type ranking, with no version signal and no changelog a consumer could detect. External orgs scripting against /scores kept computing a comparison the project itself now calls a category error. This contract makes that class of change announced and detectable instead of silent.

The Go response structs in internal/api/ are the authoritative field definitions; this document must be kept in step with them (see Changing the API).

#1476 — root-authored vendor mappings, Part 1

These additive routes require root authority in both handler and store. Root browser sessions use the existing same-origin and mutation CSRF checks; root bearer scripts remain supported. Other credential kinds and static tokens get 403. In read-only serving, POST on the collection path returns 405 because GET shares that path; End and Void return 404 because their routes are unregistered.

Route Request Success
GET /api/v1/org/vendor-mappings No query parameters 200, array of all mappings, including ended/voided rows
POST /api/v1/org/vendor-mappings {"pairs":[{"vendor":"copilot","vendor_user_id":"12345","developer":"alice"}]} 201, array of created mappings
POST /api/v1/org/vendor-mappings/{id}/end Empty body or {} 200, {"ok":true}
POST /api/v1/org/vendor-mappings/{id}/void Empty body or {} 200, {"ok":true}

Mapping fields are tenant_id, id, vendor, vendor_user_id, developer, effective_from, created_at, created_by, nullable ended_at and voided_at. The effective date is server-owned: the next UTC midnight. Mapping interval strings use the event timestamp's UTC storage format; creation uses fixed-width RFC3339 UTC. Bulk creation accepts 1–200 pairs, atomically, with one audit row per pair, with a dedicated request cap that accommodates 200 maximum-length IDs. Normalized IDs are limited to 320 bytes and cannot contain control characters. Targets must be canonical roster people who have not left. Invalid IDs, unknown people, unknown JSON fields and query parameters get 400; conflicting active mappings or repeated closure get 409; missing IDs get 404. Void may follow End and permanently removes past counting eligibility. Reconciliation reads ended and voided mapping partitions too: ended intervals accept late usage; voided partitions reverse into legacy spend inside the lookback. A hard-deleted mapping partition leaves its day incomplete without deltas or settlement, while later days continue ingesting.

DSAR exports add vendor_mappings when nonempty and vendor_mapping_id on mapped events; a NULL vendor_mapping_id is omitted to preserve pre-mapping export shapes. Erase receipts add vendor_mappings when rows were removed. Empty additions are omitted to preserve legacy response shapes. Existing sealed report bytes and the legacy route golden remain unchanged. Poller wiring and the Unmapped panel follow in Parts 2 and 3.

#1431 — real-person census and upload freshness

D4 changes the k census: only rostered non-bot fixed named_subject identities with bound_upload from their own local credential or server_watch evidence in the window count. Root-mapped vendor evidence also counts when its insert-time binding remains valid; roster-login matches and legacy aliases alone do not. Census and coaching share the same evidence predicate, including the own-credential check. Shared/legacy uploads, manual costs, roster-only people, imported-unbound people and federated people cannot fill k. Device count does not increase person count. This breaks formerly visible shared-token group reports; amounts retain existing aggregation rules. Seal schema 21 adds nullable sealed_report.census_rule_version (d4-c2-v2 for new seals, d4-c2-v1 for earlier D4 seals, unknown for older ones). Census rule d4-c2-v1 is introduced by seal fold rule 3 (the upgrade from fold rule 2); the fold-rule number versions grouping, census and folding together, while the census name identifies the D4 evidence rule. Schema 21 prevents older binaries opening the database. Rule 2 seals retain their stored config digest and frozen inputs and still read and verify successfully.

The A′ legacy-golden ruling changes exactly two rows: GET /api/v1/scores, api-token admin, evaluationMode=true, fixture=evaluation-window, with aggregation team and division (admin token configured, writable routes). Those fixtures label uploads bound_upload without a credential, a state no release writes. D4 counts them zero, so the evaluation responses lose their previous counted-person shapes. The seal golden changes only config_digest for fold rule 2 → 3; its body stays unchanged. Evidence written by dev builds between 2026-10-24 02:42Z and 22:16Z with this missing credential no longer counts toward k. The fixture remains unbound so the legacy freeze exposes D4.

The A′ extension to coaching changes the 16 affected legacy coaching-card rows from 200 to 404 under the synthetic no-credential fixture: 11 on GET /api/v1/developer/{id}/coaching and 5 on its GET /api/v1/scores/{developer} alias. Other legacy route schemas and export contents retain their previous contract. Coaching events, named spend and excluded-spend share use the census's exact evidence predicate. D4 exclusion causes are confined to the root-only reporting message; legacy diagnostic categories remain unchanged.

Read Authorized audience Freshness fields
GET /api/v1/org/people?node= Root or managing node in subtree last_upload_date (UTC date or null), freshness
GET /api/v1/org/keys?node=&developer= Root or managing node in subtree last_data_date (UTC date or null), freshness
GET /api/v1/me Personal key, self only last_upload_at (exact receipt or null), freshness; absent for root/node
GET /api/v1/me/devices Personal key, own devices last_data_at (exact receipt or null), last_data_date, freshness
GET /api/v1/org/nodes/{id}/reporting Root or node in subtree reporting_7d, people_total, withheld; counts omitted below k, message gives root the cause and others “Withheld — ask your admin”

Fresh is age <24h; Stale is 24h through exactly 7d; Silent is >7d; Never is no bound receipt. Legacy NULL credential_id receipts are Never. People, device and self views preserve Silent and the last upload date or time after retention using persisted upload-credential receipt metadata older than the retention watermark. Summary reporting includes the exact 7d boundary and excludes departed people. Its k gate uses qualifying evidence in the same seven-day window: server receipt time for uploads, event time for watch evidence; bots cannot fill k. Eligibility and reporting use the same precise receipt-time comparison, including fractional seconds. The existing tree complementary suppression gates the full forest before selecting a subtree, preventing parent-minus-children disclosure of a sub-k sibling. At evaluation k=2, tree suppression conservatively retains its shared floor of 3; k=1 has no positive sub-k residual. Root evaluation reads use the configured evaluation floor and carry evaluation_mode: true; node keys always retain the shared floor of at least 3. All reads are no-store; invisible subtree targets return 404. Shared/read tokens are refused on these routes and receive neither date nor chip fields. /org/people and device lists retain their array shapes. The node summary is the separate route above.

Admin web identity and sessions (#1417)

These routes are separate from the legacy admin/read scopes and mounted in normal and read-only serve, in every aggregation mode. Synthetic demo and trusted offline replay do not mount them. All responses, including refusals, use JSON (except 204), Cache-Control: no-store, CSP and nosniff.

Method and path Request Response
GET /api/v1/me Root/node/personal bearer or session cookie 200 {credential_id,kind,capability,developer,node:{id,name,path:[],level_name},pages:[]}
POST /api/v1/session Bearer, JSON {return_to?:"/…"}, exact Origin or same-origin fetch metadata 200 {csrf_token,return_to} plus session cookie
DELETE /api/v1/session Cookie, X-CSRF-Token, JSON content type, same Origin check 204 plus expired cookie

node.path is a root-first array of node names. A root key with no node uses empty node id/name/level name and an empty path. capability is empty for root and personal keys. pages describes role navigation; it does not grant access to APIs that are not built. The JSON body never includes credential secrets, verifiers, labels, prefixes or token lengths. Node keys resolve their own node binding; personal keys use current holder membership. Revoked credentials and holders with left_at are refused on every request.

Missing/unknown credentials return 401; wrong kind, legacy keys, break-glass, CSRF/Origin violations and insecure off-loopback sign-in return 403. Cookie and bearer together, malformed JSON, unknown fields, trailing JSON and unsafe return paths return 400; wrong content type returns 415. Rate/lockout returns 429 with Retry-After for lockout. Store or session-capacity failure returns 503. An explicit cross-origin Origin is refused even with same-origin fetch metadata. Sign-in bootstraps CSRF; every later cookie mutation requires the session-held token. Org administration writes sent with a bearer and no cookie need neither CSRF nor Origin (below). Cookie-authenticated GET /api/v1/me also returns that session's CSRF token in the X-CSRF-Token response header, allowing a newly loaded page to sign out without persisting the token in browser storage. Bearer reads and refused requests do not return that header. This does not change the JSON body.

The static dashboard shell (#1437, plan P20) serves /signin, /org, /people, /keys, /devices, /card, /federation, /coverage, /settings, /import, /import-source, /upstream, /first-run and /admin.js. All dashboard pages and scripts, including legacy / and /dashboard.js, carry CSP, X-Content-Type-Options: nosniff and Cache-Control: no-store. The new pages use cookie-authenticated /api/v1/me for identity and allowed navigation; pages awaiting their APIs have only a labelled, empty body. Static HTML is public and confers no API access. Shell pages and /admin.js return 404 when admin web is disabled (synthetic demo or unsealed recompute). Every shell page, including auxiliary pages, requires inclusion in /me.pages; pages missing that grant keep their URL and show the access-denied page, “This page is outside this sign-in”, with a link to the first allowed navigation page. Keys and My devices also require their role navigation grant, even if an incorrect allowlist includes the page. Import pages additionally check the role: a personal or node key opening Company import, or a node key opening Source export, returns to its first allowed navigation page before any import loader runs. Only a 401 identity response opens sign-in; other failures keep the shell with a session-check retry state. Sign-in distinguishes rejected keys, 429 lockout (with Retry-After seconds), unavailable service, network failure and conflicting session requests. Sign-out clears local shell state on success or 401/403; other failures retain it and show that sign-out did not complete. The legacy dashboard assets and read-token flow remain unchanged.

The People and Settings pages (#1449, plan P22/P24) use the existing org APIs. People lists the authorized subtree, invites managers only into native strict descendants, and confirms move, leave, root viewer declarations and typed root erasure. Add-person and recovery responses show the claim code once; manager invitations show the node key once. Hide, same-tab navigation, pagehide and session loss clear the transient result. Neither value is stored or put in a URL. Root sees reuse-refusal flags from the existing org audit beside recovery actions. A non-root self-granting move remains available and displays the API's 409 refusal. Actions never render server error bodies: 403/404 use the approved refusal copy, and 429 uses Retry-After seconds when present, disabling actions during the wait. Move choices exclude the person's current node. Other than viewer-declaration refusals, 400 responses ask for request correction and retry. Open card links resolve to /card for the holder and /api/v1/scores/{dev} for a declared viewer. The leave-date default, maximum and submit guard use the UTC calendar day.

Settings shows resolved values and sources; explicit pins disable controls and saves for that setting. Saves carry the session CSRF token. Retention lowering shows the server's exact preview and confirms the identical request with its confirmation token. /first-run is included in root's /me.pages; its evaluation control uses the same settings API and honors pins. The setup prompt appears only when the People inventory has at most one person. /card shows the signed-in subject's declaration and effective-access notices from /api/v1/me/notices, including both granted and removed viewers; reading notices adds no write. It also shows the viewer, label, route and time from GET /api/v1/me/views. This personal-key bearer/session route returns only the resolved holder's coaching_view_audit rows as [{viewer,label,route,ts}], or []. It accepts no query parameters (400) or caller-supplied subject. Root, node and legacy tokens receive 403. It is mounted in normal and read-only serve in team, division and developer modes, writes nothing and needs no verified spend. The card routes still return 404 without verified spend in the window. Responses and refusals are no-store; identity/session errors follow the rules below.

The cookie is HttpOnly/SameSite=Strict/Path=/, with Secure and the __Host- name prefix whenever the effective origin is HTTPS. On permitted plain-HTTP loopback it uses the plain tier-session name without Secure. Both names have a serving-port suffix on nonstandard ports. A cookie-bearing admin-web request with an explicit foreign Origin is refused before session lookup or rate accounting, including GET; absent-Origin GET fetch-metadata rules are unchanged. Sessions expire after 60 minutes idle or 12 hours total, are re-resolved on each request, and disappear on restart. Sign-out uses a cookie session; scripts retain bearer reads. See identity access for the trusted-proxy and sticky-startup contract. Legacy route status, access and cache behavior are unchanged: legacy routes never read the admin session cookie, so a live or stale cookie neither authorizes nor refuses there. A root, node or personal key sent as a bearer to a legacy route is refused 403. Legacy export carries node_id on org_hierarchy and hierarchy_membership rows, and the subject's org records in org_people and org_audit. Legacy erasure reports every erased table in deleted, org_people and org_audit included, and total_deleted is their sum; a subject with only org records is erased with 200, not 404.

Federation wire contract (#1430)

internal/federation defines the pure tier-federation/v1 push payload in docs/contracts/tier-federation-push.schema.json. This package adds no HTTP route, authentication, persistence or outbound traffic. Every field is required and non-null; empty lists use []. Decode refuses unknown fields, duplicate keys, case-altered property names and trailing JSON. Bodies above 1 MiB return typed code too_large; a supplied contract other than exactly tier-federation/v1 returns bad_contract; other invalid input returns malformed. These are package codes, with HTTP mapping left to the transport.

Each period requires k ≥ 3, an end after its start and a matching digest; the payload requires a canonical lowercase UUID server id and seq ≥ 1. All org-node ids use the same exact 8-4-4-4-12 lowercase hexadecimal UUID spelling; parent ids may also be empty for the root. Duplicate period bounds, duplicate group ids within a period and groups that name themselves as parent are malformed. sent_at, period_start and period_end must be byte-equal to their UTC time.RFC3339Nano encoding (Go layout 2006-01-02T15:04:05.999999999Z07:00), ending in literal Z. Fractions are optional, use 1–9 digits and have no trailing zero; offsets including +00:00 and -00:00 are refused. Integers use whole-number syntax without a fraction or exponent (5.0 is refused). The schema bounds k to the 64-bit Go int maximum, sequence and micro-costs to int64, and points to finite float64 values. The digest is sha256: plus lowercase hexadecimal SHA-256 over the complete period with only digest omitted. Object keys are sorted recursively, with compact Go JSON encoding and HTML escaping disabled (&, < and > remain literal), shortest round-trip floating-point numbers and exact integer micro-costs. Array order and group names are significant; accepted timestamp strings survive re-encoding unchanged. The golden fixture pins the marshalled shape; regeneration is explicit through go test ./internal/federation -run '^TestPayloadGoldenV1$' -update.

Closed periods are frozen increments under the append-only federation design. Sequence replay checks, exact retries and period-conflict handling belong to the receiver; this package validates each payload independently.

Org administration routes (#1421, #1422, #1423)

These routes are available in every aggregation mode outside synthetic/offline replay. GETs accept root/node/personal credentials as specified below, via bearer or session; unknown, repeated or undecodable query parameters give 400. Per D2, a mutation sent with a bearer and no session cookie (a script) needs no CSRF token and no Origin; a cookie-session mutation requires the server-held CSRF token and exact Origin (or same-origin fetch metadata when Origin is absent). Both require JSON content type; cookie plus bearer is 400. Every response, including errors, is no-store. Read-only serve mounts the GETs and an explicit 403 refusal for claim redemption; other mutations are absent. Legacy credentials and break-glass are denied. Every successful write arms the D1 admin-web flag in its own transaction.

Method and path Principal and request Response
GET /api/v1/org/tree?node= Root or node; omitted node defaults to the principal's binding 200 {levels:[{level,name}],nodes:[{id,name,level,level_name,parent_id,kind}]}; subtree only
PUT /api/v1/org/levels Root; {levels:[{level,name}]}; 1–6 distinct levels in 0–5; updates supplied level names 200 {levels:[{level,name}]}
POST /api/v1/org/nodes Root or managing node; {parent_id,name,level} 201 {node:{id,name,level,level_name,parent_id,kind}}
PATCH /api/v1/org/nodes/{id} Root or managing ancestor; {name} 200 {node:{id,name,level,level_name,parent_id,kind}}; level-0 rename refused 409
POST /api/v1/org/nodes/{id}/move Ancestor of both parents; {parent_id} 200 {node:{id,name,level,level_name,parent_id,kind}}; populated cross-org move refused 409
DELETE /api/v1/org/nodes/{id} Root or managing ancestor; no body (or {}) 204; 409 unless empty
GET /api/v1/org/people?node= Root or managing node; omitted node defaults to binding 200 [{developer,node_id,path:[],status,left_on,created_by_node,created_at,last_upload_date,freshness,key_used,node_manager,claim_state,claim_expires_at,claimed_at,stated_leave_date,declared_viewers:[],manager,skip_level,works_viewers:[]}]; created_at/left_on are UTC dates; claim_expires_at is also returned for an expired code; stated_leave_date is the date entered on leave; declared_viewers and separate manager (string), skip_level (string), works_viewers (array) are root only; empty roles are returned as ""/[], preserving the declaration for an unchanged viewers PUT; non-root responses omit all four fields
POST /api/v1/org/people Root or managing node; {developer,node_id} 201 {developer,claim:{code,expires_at}}; code shown once, valid 72 hours; 409 exists anywhere in the organisation; 429 after --people-cap-per-day (default 25) creations per UTC day across the credential's branch
POST /api/v1/org/people/{dev}/claim Managing node while subject has no active personal key; root for recovery; no body or {} 201 {claim:{code,expires_at}}; cancels prior unused codes; recovery revokes old personal keys only on redemption
POST /api/v1/claim Code alone, no bearer or admin-session cookie (other cookies ignored); {code}; JSON, HTTPS off loopback and exact Origin (or same-origin fetch metadata) required 201 {credential_id,secret} once; unknown/cancelled 401; expired 410; consumed 409 “already claimed at — ask your admin”; failed codes count toward IP lockout (429); read-only 403
POST /api/v1/me/key/rotate Personal holder only; no body or {} 201 {credential_id,secret}; replaces own personal keys atomically, cancels outstanding onboarding codes (a pending recovery code survives), preserves device keys; old sessions fail on their next request
POST /api/v1/org/people/{dev}/move Root or node(manage) ancestor of both current and target nodes; {node_id:"..."} 200 {}; server click closes and opens dated memberships for the person and aliases; same-org moves leave billing unchanged; cross-org moves follow the hierarchy import (UpsertHierarchy) rule: non-destination open seats close in the click's UTC month, and a destination seat opens unless the person has an unsuperseded deliberate end there (overlap in the click month when it opens); one person_moved audit with actor credential_id; keys and viewer declarations unchanged; history stays at its dated node; 404 unknown/hidden source or invalid/federated target, 403 visible source with destination outside scope, 409 left person or a non-root manager's move granting that manager new viewer access, 400 same node or invalid body; no date parameter
POST /api/v1/org/people/{dev}/leave Root or managing node; {leave_date:"YYYY-MM-DD"} (today or past) 200 {revoked:[credential_id],membership_closed_at}; both membership ledgers close, every key the person holds and every viewer key on their card revoke, and unused claims cancel at server click time; 403 (nothing written) if a managing node may not revoke one of those keys; 409 if it would revoke the last live root key; stated date stored as stated_leave_date and audited, never moves a figure
GET /api/v1/org/audit?node= Root or managing node 200 audit array with D10 named/free fields; non-root sees claim events in its subtree only; reuse refusals and all other actions root only
GET /api/v1/me/audit Personal or node 200 claim-event audit rows about own holder
GET /api/v1/me/notices Personal or node 200 [{id,tenant_id,developer,ts,kind,viewers_json,read_at}] about own holder
POST /api/v1/org/managers Root or managing ancestor; {developer,node_id,capability} 201 {key:{credential_id,secret}}; existing active holder, strict-descendant binding; manage or root-issued read
GET /api/v1/org/keys?node=&developer= Root or managing node; node defaults to binding; developer filter must be visible 200 public credential array described below
POST /api/v1/org/keys Root or managing node; {holder,kind,label,capability?} 201 {credential_id,secret}; kind upload or node (personal refused 400); node binding comes from holder membership
POST /api/v1/org/keys/{cid}/reissue Root or managing node with mint and revoke authority; no body (or {}) 201 {credential_id,secret}; upload/node only (personal refused 403); old revoked + replacement minted atomically; 409 if the holder is not an active member
POST /api/v1/org/keys/{cid}/revoke Root or managing node with revoke authority; no body (or {}) 200 {}; personal keys root only, managers 403 (use Mark-as-left); other leavers' keys included, and legacy keys with no placement for root
GET /api/v1/me/devices Personal only 200 own upload-key inventory; additionally exact last_data_at or null and manager-added devices carry added_by (opaque credential id) and added_by_label (the minter's developer id)
POST /api/v1/me/devices Personal only; {label} 201 {credential_id,secret}; own upload key only
POST /api/v1/me/devices/{cid}/revoke Personal, own device; no body (or {}) 200 {}; another holder's key, a non-device key and an unknown id give the same 404
POST /api/v1/me/devices/{cid}/reissue Personal, own device; no body (or {}) 201 {credential_id,secret}; atomic rotation

The key inventory is [{credential_id,holder,label,kind,capability,node_path:[], issued_at,revoked_at,used,last_data_date,freshness}]. Capability, revocation and last-data date are nullable; paths are root-first names. Inventory never selects or returns credential secrets, hashes, prefixes or lengths. Personal device responses add exact last_data_at, and manager/root-added devices include added_by and added_by_label; ancestor inventories contain dates only. Freshness is Fresh (<24 hours), Stale (1–7 days), Silent (>7 days), or Never. used includes persisted last-seen or data receipt, excluding sign-in. People claim state is invited (with claim_expires_at), claimed (with claimed_at), expired, or left; pre-claim legacy people can be uninvited, and a cancelled code can show cancelled. Codes never appear in lists. Lists derive expiry without writing; it is audited and notified once by the next redemption, reissue or leave. People status is active, not_counted, imported_unbound, or left; creation starts not_counted. left_on and last_upload_date may be null.

Malformed bodies, unknown fields or trailing JSON give 400; wrong JSON content type gives 415. Missing/unknown keys give 401; wrong kind/capability and CSRF or Origin failure on a cookie mutation give 403. Hidden or missing targets give 404; a visible source move without authority over both parents, or a change to a key listed in the caller's inventory that it may not change, gives 403. Duplicate active labels (per holder and kind, and per binding for node keys), existing people, cycles, invalid tree level/depth, nonempty deletion, level-0 renaming, operations on already-revoked keys and reissue for an inactive holder give 409. Node removal also returns 409 while a leaver placed there holds any unrevoked key. Root's full key inventory retains revoked keys after their placement node is removed. A move to the current parent is a no-op with no audit row. A subtree with people cannot move to another level-0 org (409); its placement, dated membership and billing membership stay unchanged. Empty subtrees can move between orgs. Credential rate limits (60/minute, shared with sessions) and the persisted people cap give 429. Write lock contention gives 503 with Retry-After. Reads resolve one snapshot; writes re-resolve the verifier and authorize against the tree inside the same write transaction as mutation, audit and sticky activation. All successful writes are audited with opaque actors and D10 named/free detail separation.

Time to merge (#1342, v0.6.2)

GET /api/v1/time-to-merge is read-scoped, including on the read-only server. It is a descriptive measure, not covered by verify. Calling it never writes or changes a seal or the /scores response. For anonymized sealed months, median and p90 are stored at seal time and never recomputed; counts, including exclusion and contributor counts, are not published. Live windows retain their existing statistics and counts. Its definition is pr_created_to_merged, labelled PR created → merged (includes draft time): merged_at − created_at, using both captured timestamps, including timestamps stored by backfill. It never substitutes outcome ts for merge evidence. Quality, points, cost, labels and correction eligibility do not weight or filter a duration.

In team/division modes, parameters and the sealed-read gate match /scores: only ?period=YYYY-MM is accepted, with omission selecting the latest sealed month. Free since, until and before bounds are refused with 400; no sealer returns 503. Sealed figures use the score seal snapshot, with median and p90 computed once inside the seal transaction. Every count is null with null_reason: "sealed_counts_not_published"; exclusion reason counts are null as well. Months sealed before these statistics were stored return null median and p90 with null_reason: "sealed_before_ttm_stored", and are never backfilled. The dashboard passes the same period as its /scores request.

The synthetic demo keeps the existing /scores window helpers: since, until, and before (the legacy alias for until), with UTC whole-day bounds, half-open [since, until) selection by outcome ts, default since 90 days ago at UTC midnight, and omitted until meaning open-ended. Invalid/unknown parameters return 400; retention violations return 422. This is an org-wide read: repo, team and work_type are not accepted. Aggregation mode is the server's existing setting, never a client mode parameter. Real-data developer mode returns 403 before store reads. The synthetic demo uses the team fold and echoes window.aggregation: "team"; its four synthetic authors are below k=5, so its tile is withheld rather than publishing group figures.

The frozen wire shape is:

The following example describes a live window; sealed months use the null-count rules above.

{
  "definition": "pr_created_to_merged",
  "window": {"since": "2026-10-01", "until": "2026-11-01", "aggregation": "team"},
  "groups": [{"team": "Engineering", "count": 10, "median_seconds": 14400, "p90_seconds": 86400, "null_reason": ""}],
  "residual": {"team": "other", "count": null, "median_seconds": null, "p90_seconds": null, "null_reason": "authors<k"},
  "total": {"count": null, "median_seconds": null, "p90_seconds": null, "null_reason": "withheld_with_group"},
  "excluded": {"count": 0, "reasons": {"no_created_at_before_capture": 0, "no_created_at": 0, "no_merged_at": 0, "bot": 0}, "null_reason": ""}
}

The row key is team, matching /scores (also in division mode); window.aggregation identifies its level. groups is always an array in label order; residual is an object with key other only when the shared fold actually emits that cohort, and is otherwise omitted. Omitted until is not echoed. A group or residual with no included PRs has null figures with null_reason: "no_prs", which does not withhold the total. For nonempty figures, counts and both statistics are null with null_reason: "authors<k" unless at least k distinct census-counted developers each authored an included PR in that figure. k has the same MinKAnonymity clamp as /scores.

The endpoint shares the score window load, canonical aliases, dated membership, census and PreFold/AggregateFolded rules. A group folded in /scores folds into the same residual here; if /scores withholds all rows because its residual is unsafe, this endpoint withholds them too. It may withhold more: a named score group failing the PR-author floor keeps its row with null figures. Its PRs never move into the residual. The residual independently requires k counted PR authors. No org-wide duration total is implied by a sum of group medians.

Strict protected org total. total has count, median_seconds, p90_seconds and null_reason, with no team key. It is computed over every included PR in the window, using the same statistic rules and k distinct counted PR authors at org level. Below org-k, all three figures are null with null_reason: "authors<k". An empty total instead has null figures with null_reason: "no_prs". Otherwise, whenever a group or residual with included PRs fails its floor, all three total figures are null with null_reason: "withheld_with_group". An absent residual or a cohort with zero included PRs does not withhold the total. No group failing the PR-author floor is added to that residual. This strict complementary suppression prevents subtracting a published total to recover a hidden cohort, mirroring /scores (#593). A group's short-sample p90 (n<10) is not a withheld cohort: its count and median are published, and it does not alone suppress total. The total's own p90 is null with reason "n<10" when its included n < 10.

The dashboard tile beside the headline says Time to merge · PR created → merged (includes draft time). A published total shows median X h · N of M merged PRs measured, with N = total.count and M = total.count + excluded.count, both org-wide. M is this defined reporting denominator; the exclusions can include push-only/API rows, so it is not a separate census of GitHub PRs. A withheld total or excluded block explicitly says Time to merge: withheld to protect small groups, without numbers or a denominator. An empty window with no exclusions says Time to merge: no merged PRs measured; malformed or missing figures say Time to merge: unavailable. p90 appears on hover and in off-screen screen-reader text for n ≥ 10. The tile is neutral, wraps at 390px, and states: "smaller or later-created PRs merge faster; not a quality measure".

Statistics are unweighted whole seconds. The median requires n ≥ k and is the middle value for odd n, or the arithmetic mean of the two middle durations for even n. Durations retain subsecond precision through that calculation; the result is truncated to integer seconds. p90 is nearest rank sorted[ceil(0.9*n)-1], truncated to integer seconds. For n < 10, count and median can be present but p90 is null with null_reason: "n<10" applying only to p90. No mean duration is published.

excluded.count and its four reason counts cover the whole org only; they never appear per row. The author set covers every outcome in the population, including excluded evidence, using the canonical census counting rule and excluding bots and unattributed identities. Below k counted authors, or whenever /scores suppresses its residual, excluded.count and excluded.reasons are null with excluded.null_reason: "authors<k". An empty population with no included PRs or exclusions uses "no_prs" with the same null fields; a published block has null_reason: "". Each excluded outcome is counted once, in this precedence order:

  1. bot: the same canonical bot classification as the census (captured GitHub type, fixed login list or [bot] suffix), including alias-carried marks.
  2. no_created_at_before_capture: a webhook/backfill PR with NULL created_at and outcome ts before 2026-10-11, when commit 840fa8da introduced capture. This is the column's release date, not this installation's upgrade date; later upgrades classify missing evidence on or after that date as no_created_at. It does not invent a creation date.
  3. no_created_at: other NULL creation evidence, including push-only/API rows, and a PR whose created_at fell after its merge. Both the webhook and backfill store NULL in that case; the read also rejects inverted evidence.
  4. no_merged_at: creation evidence exists but merge evidence is NULL.

Pseudo-developer rows are dropped as on /scores. Webhook and backfill sources are both included when both timestamps are present, including old merges captured by a later backfill. Smaller or later-created PRs merge faster; not a quality measure.

Pricing provenance additions (#1231, #1232)

POST /events accepts optional cache_write_evidence (nested or legacy; omitted means unknown). legacy with a nonzero 1h write count is refused. The receiver derives costs and basis; clients cannot declare a pricing snapshot or rule. Shippers preserve the evidence on current receivers and retry once without additive provenance/PR fields only for an exact unknown-field 400 from an older receiver. Other 400s retain their usual failure behavior.

GET /events appends pricing_basis, rate_snapshot and cache_write_evidence to JSON and CSV; the previous columns retain their positions. Bases include token_derived, guessed_estimate, caller_authoritative, aggregate_approx, legacy_aggregate and basis_unavailable; historical nulls export empty strings. Snapshot identities resolve inside the operator's database to immutable effective rates and a retained rule implementation.

/scores and each /scores/compare window add data_quality.aggregate_pricing when provider remainders exist in developer mode. It carries cutover, approximation/historical notices, row counts, ceiling_status and, where positive, possible_increase_micro. This is an original-rate highest-rate-per-class diagnostic, not proven overbilling. It bounds the retained rate effect on aggregate counts, with one aggregate rounding step; per-request rounding and unrecorded fees are outside that calculation, so it is not a provider-invoice bound. A full ceiling is unavailable for missing, unsupported or estimated original bases (or sum overflow); a partial known-rate amount does not bound the whole window. Anonymous modes always emit the same conditional notice with ceiling_status=not_shown and no amounts or populations. All read responses also carry constant X-TIER-Aggregate-Pricing policy text, including CSV/array views and older sealed bodies without pricing metadata. New sealed responses retain the emitted notice; old sealed bytes are not rewritten or overlaid with current metadata. The dashboard shows conditional policy text when metadata is absent. Request-level tier arithmetic, raw numeric columns, actual-paid reconciliation and completed-poller retry semantics are otherwise unchanged. See the policy and repair commands.

Per-issue report addition (#1273)

GET /api/v1/issues is a read-scoped, live developer-mode-only report, also mounted by the read-only server. Both team and division modes return 403 before reading data, including on sealed installations. No issue-level k-anonymity or tracker hierarchy is implied. Existing /scores routes and response shapes are unchanged.

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

Parameters are since, until, before (the existing alias for until), and repo. Bounds use the /scores grammar (YYYY-MM-DD, YYYY-MM, YYYY), UTC midnight and half-open [since, until) semantics. Omitted since defaults to 90 days ago at UTC midnight; omitted/empty until is open-ended, and its echo is omitted. Invalid bounds, non-increasing windows, invalid repository slugs, and unknown parameters (including sort=tier) return 400. Retention violations return 422. repo is strict equality; repo_scope_excluded, when present, discloses excluded unqualified records exactly as on /scores. All totals and shares use the selected scope and window.

The report consumes the same loadWindow path and scoring engine as /scores, with stored Event 3 policy and outcome projections read in one snapshot. Unresolved policy/projections refuse the report (500, db error). The additive wire shape is:

Field Meaning
since, optional until, optional repo Echoed UTC bounds and repository scope
scoring_policy, correction_rule_version, rubric_version Shared policy stamp: legacy / legacy-v1 / 1, or event3 / event3-v2 / 2
label Literal attributed (lower bound)
total_cost_usd All captured list-price spend in the window/scope, including manual cost rows; not invoice spend or a claim of complete capture
attributed_cost_usd, unattributed_cost_usd Disjoint spend totals, summing to total_cost_usd
unattributed_share Unattributed / total captured spend, a fraction 0–1; zero when total spend is zero
optional data_quality The shared /scores zero-token and mixed-price signals, plus cost horizon (cost_coverage_start, cost_coverage_safe_since, window_predates_cost_capture, and optional source_coverage_start); same window and repo scope
reconciliation Always an array, empty for an empty window; disjoint rows covering all spend

Each reconciliation row has kind (issue or unattributed), repo, issue_id, label, unattributed_share, cost_usd, weighted_points, outcome_n, sample_n, flagged_outcomes, tier and score_status. Issue rows carry the literal attributed (lower bound) label and the window's unattributed share beside their figures. Unattributed rows carry label unattributed and retain the sentinel issue_id as the reason (including unattributed:<reason>); an empty historical issue ID is also unattributed. Such rows have no TIER. unqualified repository identity (including historical empty repos) is retained as stored; it is never assigned to another repo by a tolerant join for this partition. Cost is accumulated in integer micro-dollars before conversion to USD; floating point JSON sums should be compared at that precision.

Rows are ordered by repository then issue ID, with unattributed rows last, never by TIER. The union includes spend-only and outcome-only issues. All spend tied to the stored (repo, issue_id) stays in that row's denominator, even from developers with no outcome. Each outcome contributes once; Event 3's debit stays on the corrected original's issue while the follow-up's own projected points stay on its own issue. outcome_n counts raw outcomes and sample_n counts the shared engine's eligible observations. Points are summed, then divided by summed issue cost / 1000. The evidence floor is applied after summing, never per developer: at least 3 eligible outcomes, at least $5, and no zero-token flags. Only then is tier a number with score_status: "scored" if repository identity is also unambiguous. A row with a repo-blind identity (the same definition used by store.RepoMatch), or a real-repo row whose issue ID has repo-blind spend or outcomes in the selected window/scope, always has tier: null and score_status: "repo-blind spend on this issue id". This status also covers outcome-only ambiguity. The disjoint stored rows are unchanged: no blind spend or outcomes are reassigned to a real repository. For example, $5 on acme/app#1 and $45 on unqualified#1 remain separate rows, and neither publishes a TIER. Absent that ambiguity, unattributed rows have no score; issue rows with zero-token flags have zero-token outcome; zero eligible accepted outcomes have no score; other insufficient evidence has below evidence floor. Each withholds TIER as JSON null, never a low numeric score. These partial denominators make issue TIER unsuitable for direct comparison with an all-spend developer score. No feature/parent rollup or seal is created by this endpoint.

tierd issues --server <url> [--since <date>] [--until <date>] [--repo owner/repo] prints this report, cost-horizon and mixed-price disclosures, and its reconciliation; --json prints the validated server response bytes, preserving additive wire fields. A covered window explicitly reports window_predates_cost_capture: false; a store with no captured cost has no horizon to report. A single-price window omits mixed_price_versions. It accepts --api-token (including @file) or TIER_API_TOKEN, allows the read token, refuses redirects and returns nonzero on mode/auth/server/decode failure. The dashboard's developer-mode per-issue table uses the same endpoint; it never fetches this breakdown in team/division mode.

Live issue reports may add as_of and attribution_notice when recovery is used: “scores are as of <ts> and may be revised as branches merge”. A reconciliation row with recovered support adds attribution_layer: "read_time_recovery" and read_time_recovery_cost_usd (omitted for a zero-dollar recovered portion). This marks mixed rows as including recovery. Developer-mode /scores and its per-issue developer detail add the same layer marker where applicable. All read routes except GET /api/v1/report_manifest and sealed reads carry the population-independent X-TIER-Attribution disclosure; stored sealed bodies stay byte-identical. Raw event exports continue to report stored attribution. See effective attribution and the read-only recovery receipt.

Feature detection

The supported feature-detection mechanism is the version field of GET /api/v1/livez. It is the build version the binary was compiled with (injected via -ldflags; falls back to "dev"). A client that needs to know whether a server supports a given field or endpoint should read livez.version rather than probing behaviour, except for the narrow events fallback below. livez is unauthenticated (a liveness probe must not need credentials), so feature detection never requires a token.

Identifying a deployment: GET /api/v1/version

Feature detection asks "what can this server do?" — a different question from "is this the build I published?" For the second, use GET /api/v1/version, also unauthenticated and also mounted in read-only mode.

🔴 First released in v0.4.1. A server running v0.4.0 or earlier answers 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 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 detect scoping from the response (data_quality.repo_scope), never from the request it sent. Feature-detect via livez.version as always.

The compatibility rule

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

  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

Auth scopes (see internal/api/handler.go):

When the server is started with no API token (loopback-only, fail-closed per cmd/tierd), auth is disabled and all scopes are transparent.

Error bodies are uniformly {"error": "<message>"} with a 4xx/5xx status.

Write endpoints

Every POST, PUT and PATCH below requires Content-Type: application/json (parameters such as charset are allowed) and answers 415 otherwise, in token mode too (#893). Authentication runs first, so an unauthenticated request still gets 401. A DELETE carries no body and needs no Content-Type.

POST /api/v1/costs

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

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)

Read endpoints

GET /api/v1/admin/shared-token

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

Seal disclosure (#1233)

Sealed report bytes and body_digest never change. /report_manifest adds seal_disclosure, and sealed /scores/compare includes that object on each window_a / window_b. It contains sealed_at (actual insertion time in UTC), configured_grace_seconds (nullable), grace_status (recorded or not_recorded), days_after_period_end, revert_windows_at_seal (withheld, open, closed, or not_recorded) and note. Future seals record their grace and whether any recorded outcome's 60-day revert window was still open, atomically at the actual insertion time. The note states that some windows were still open only for open.

Every non-developer seal level returns withheld, because outcome-dependent observations can identify a person in an anonymized cohort. This includes all production seals in team and division modes; developer mode seals the shared team view for org history while its developer reports remain live. Only developer-level records can return open (recorded as true at sealing), closed (recorded as false, or inferred for legacy records as below), or not_recorded (legacy status unknown).

Legacy grace is null / not_recorded, never the current configuration. Legacy window status for developer-level records is not_recorded unless the actual seal occurred at least 60 days after period end, when all nominal windows were closed; non-developer levels still return withheld. A short delay alone does not prove any particular outcome still had an open window. No historical report body, digest or config_digest is rewritten.

Sealed /scores exposes the same disclosure beside the stored bytes: Tier-Sealed-At, Tier-Report-Grace-Seconds (not_recorded for legacy seals), Tier-Sealed-Days-After-End, Tier-Revert-Windows-At-Seal and Tier-Seal-Note. The dashboard displays the selected month's own disclosure, and both months in a comparison. The default grace remains 14 days; it can leave 60-day revert windows open. Longer grace does not establish delivery completeness.

GET /api/v1/report_manifest

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)

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.

Changing the API

When you add a field, add it in the same PR to (1) the Go response struct in internal/api/, (2) this document's endpoint catalog, (3) the API changelog, and -- for the CSV exports -- (4) the relevant CSV header slice (eventsCSVHeader, outcomesCSVHeader, qualityEventsCSVHeader, qualityHistoryCSVHeader) and docs/how-it-works.md. When you deprecate or change a field's meaning, follow Announcing a deprecation: new field name (never repurpose), the RFC 9745 Deprecation / RFC 8594 Sunset headers, and a changelog entry.

Relationship to the ingestion seam contract

TIER's cross-service ingestion seam, tier-jsonl-ingestion (Claude Code session JSONL -> tier collector), is pinned by the JSON schema at docs/contracts/tier-jsonl-ingestion.schema.json. That seam is the external Claude Code producer format tier consumes; tier owns and publishes the seam contract even though the data flows inbound, because tier, not the producer, defines which fields it requires. This document, by contrast, governs the HTTP API tier itself PRODUCES. The /api/v1 REST surface is not yet pinned as its own formally versioned seam contract; that is a possible follow-up once an external consumer of the REST contract needs a stability guarantee.

Merged-PR attribution evidence (#1074)

Developer data exports include nullable outcome head_branch_hash, created_at and merged_at. Older rows retain NULL. These fields are additive in that export; the GET /api/v1/outcomes JSON/CSV contract and POST /api/v1/outcomes request are unchanged. Head refs are hashed using canonical repo + "\x00" + branch after stripping refs/heads/; default/unknown branches produce NULL, and raw head refs are not stored on outcomes. Missing PR-opened or merge evidence disables branch credit.

Root viewer declarations and settings (#1440, plan P13/P14)

Method and path Authorization Behavior
PUT /api/v1/org/people/{dev}/viewers Root only manager, skip_level, works_viewers[]; atomic declaration, subject notice and named-column audit; 409 with nothing changed if the same team in the same org would have two distinct nonempty managers
DELETE /api/v1/org/people/{dev} Root only Exact confirm developer; atomic erasure and name-free audit
GET /api/v1/me/notices, /api/v1/me/audit Personal or node Caller subject only; audit includes viewer declaration actions
GET /api/v1/org/settings Root only settings value/source/pinned map and daily-tally shared_counts{last7,last30,last_seen}
PUT /api/v1/org/settings Root only Partial setting update, audited and effective immediately; pinned keys return 409

Declared viewers can use their node or personal sign-in bearer/session on the existing coaching and single-developer score card routes in developer mode. Hierarchy placement alone grants no access. These authenticated cards add declared_viewers; legacy pair-credential card bytes retain their shape. The legacy DELETE /api/v1/developer/{id} contract and frozen legacy golden remain unchanged. New routes refuse legacy tokens and use the existing admin session, Origin/CSRF, rate limit, strict JSON and no-store boundary.

Settings use explicit flag > environment > config pins, then saved DB values, then defaults. Evaluation defaults off, shared ingest defaults warn, retention defaults 90 days (60–365). A whole-set invalid update is 400 and saves nothing. Retention lowering returns a preview and confirmation token before any save; the identical confirmed request applies the window for the next prune. Tokens are bound to the root credential, requested changes and previous settings, expire after ten minutes and are single-use. Read-only serve has no settings PUT. See identity access for request and response details.

The settings migration follows per-node sealed decisions (v22) at schema step v23, reuses subject notices and adds server_settings and nullable sealed_report.settings_json. In team/division mode and the developer-mode org sealer's team view, new seals record live settings values and sources through the existing immutable metadata path. These seals and body replay use that resolved snapshot for evaluation labels and contributor counts, including offline verify-report; current settings do not override a stamped seal. Old seals remain NULL and keep their existing replay rules. Binaries predating settings refuse v23; binaries predating graft refuse v25. Import apply advances the schema to v26; mapping-audit source redaction advances it to v27. This binary refuses any schema above v28 before migration.

Graft source, preview and apply (P34–P36)

Schema v25 adds consented graft staging and mapping erasure after per-node sealed decisions (v22), persisted settings (v23), and vendor mappings (v24).

These routes use Cache-Control: no-store and the identity rate limit. Legacy admin/read tokens cannot open them. Admin mutations require JSON and the existing Origin/session CSRF checks; scripts may use their scoped bearer credential.

Method and route Credential Behavior
GET /api/v1/me/consent Personal Read the holder's own consent.
PUT /api/v1/me/consent Personal Set move_named for the holder only; destination_label is required only when true. Withdrawal can send {"move_named":false}.
POST /api/v1/org/export-keys Root Return credential_id, shown-once secret, and expires_at (one hour). No request fields.
GET /api/v1/export/bundle Export bearer, HTTPS Consume the key and return an audited tier-import-bundle/v1 snapshot atomically. HEAD and other methods return 405 without consuming it; plain HTTP burns a recognized export key before returning 403. Revoking the issuer refuses subsequent pulls.
POST /api/v1/org/imports Root Pull once using source_url, export_key, destination_node_id; return batch_id and preview.
PUT /api/v1/org/imports/{batch}/mappings Root Save {source_developer: {decision: "map" or "new", developer: target}}.
POST /api/v1/org/imports/{batch}/apply Root Apply a ready preview atomically; no request fields. Return the stored apply summary on retries.

The bundle contract is tier-import-bundle.schema.json. The source URL must be an HTTPS origin or its exact /api/v1/export/bundle URL, without user information, query, fragment, or another path. The company server fetches from whatever host root names and sends the export key to that host. A wrong host receives a live key that may be able to pull the named bundle from the real source before consumption or expiry. The fetch honours proxy environment variables via http.ProxyFromEnvironment (code: internal/api/graft.go, fetchImportBundle; internal/store/graft_source.go, graftExportIdentity). Choosing the host is a root-only trust decision: internal, loopback and link-local addresses are allowed; the URL checks do not limit network reachability. Redirects fail; the response is capped at 64 MiB (67,108,864 bytes) with a 30-second fetch timeout. A fetch timeout returns 400 with {"error":"import source timed out"}. The source export and destination fetch, decode, preview and apply share this fixed byte bound; it is not an event-count limit. The standard server write timeout remains 60 seconds. Oversized bundles return 413 with error, size_bytes and limit_bytes before any destination staging or imported rows. size_bytes is a lower bound when reading stops at the limit plus one byte; a declared Content-Length or source refusal is source-claimed. A destination also checks streamed bytes when Content-Length is absent. On a real HTTP transport, an understated Content-Length truncates the visible body; malformed or incomplete JSON returns 400 rather than a size refusal. Source 413 refusals relay only validated numeric size and limit fields, never remote error text. Bundles are never truncated to fit. A preview contains people, consented, name_free (aggregate row count), duplicate_events, left_behind, left_behind_rows, estimated_lock_seconds, estimated_lock_seconds_min, estimated_lock_seconds_max, memory_gb_for_64_mib, and clashes[{source_developer,existing}]. Event duplicates are checked by idempotency key, then by the shared content fingerprint, against local and incoming rows. A preview writes no usage rows and creates no people. A large import can use about 1.4 GB of memory for a 64 MiB bundle, holds the database writer during staging and apply, and grows the write-ahead log. The byte cap does not bound those costs to 64 MiB.

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

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

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

Each source pull or apply durably records its own unique attempt before its lock. A failed record write refuses the operation. Every exit removes only its own record with a detached context, retrying for up to five seconds. Cleanup failures log only a sanitized error class. Both servers cover each source pull; the destination also covers preview and apply. A 503 before the pull starts does not consume the source export key. Startup treats any leftover record as a possible loss and sets the durable data_quality.live_capture_may_be_lost flag; read-only serving re-reads that warning for each report without writing it. Startup skips a flag write when its dates are already covered and warns with a sanitized error class if the writer is busy; serving continues. Exhaustion and final write failures also set the flag once a write is possible, increment tier_proxy_writes_total{provider,outcome="error"}, and log an ERROR without names, credentials or underlying error text. Failed flag writes retry within the same bounded budget; writable close flushes a pending warning.

The settings row stores the earliest and latest UTC dates when events may have been lost. A legacy boolean has an unknown first date, so it covers the past through its stored write date; a record without a usable date is treated conservatively through the date it is read. Only reports whose windows overlap those dates carry the boolean data_quality.live_capture_may_be_lost. Sealed months keep the warning stored with their body, including org reports, totals, compare and their CSV exports (the CSV column is live_capture_may_be_lost). Later live losses do not rewrite sealed months. The dashboard says: “TIER may have missed some AI costs in this period, because a server restart or an import interrupted recording.” There is no clear command: later periods outside the date range are unaffected.

At most 10,000 captures continue after responses at once. Above that limit a response waits for its capture, providing back-pressure without dropping events.

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

The stored apply receipt includes lock_started_at (sampled after lock acquisition, before authorization) and lock_ended_at (the final receipt-write marker immediately before commit, rather than a post-commit observation). The dashboard displays these stored markers; older receipts may lack them. Receipt replays preserve these times. The 64 MiB limit and all-or-nothing apply are unchanged.

The mappings PUT has a dedicated 1 MiB (1,048,576-byte) request cap, enough for 1,000 clash mappings with ordinary identity labels. An oversized request returns 413 with {"error":"import mappings exceed 1048576-byte limit"}; malformed JSON still returns 400.

Matching IDs always require an explicit decision. map names an existing local identity; new must name an unused local identity. Mappings never imply a merge. The store's readiness prerequisite rechecks every clash and target against the current database; it refuses missing or conflicting mappings, including clashes introduced after preview. Apply calls that prerequisite inside its write transaction. An unmapped clash returns 409 and writes nothing.

Data-subject exports, erasure, and retention resolve held staged people and import_batch_people rows through the explicit mapping, or the source ID when unmapped. A colliding source name cannot export the held mapping or invalidate the mapped person's stage. Once staging is absent, source-named subjects can export source metadata with the mapping's target developer omitted, and erase that metadata. Erasing a source-only name in an id_mapped audit row nulls only source_developer; the target's audit row remains available in their export.

The dashboard's /import-source page exposes only the signed-in personal holder's consent, or root-only export issuance. Root also has /import for pulling a preview, saving explicit clash mappings and applying it. /api/v1/me includes import-source for personal and root principals and import for root only; node principals receive neither. These pages use the shared CSP, nosniff, no-store and session CSRF transport. The shown-once export key includes its expiry and clears on Hide, same-tab navigation and pagehide. The pasted key is cleared as soon as the preview request starts, including failed requests. Apply remains disabled until every clash has an explicit saved choice and renders the stored apply receipt returned by the API. No import state or key is saved in browser storage, URLs or history. The dashboard's map choice sends the preview's matched existing identity; only new accepts a typed target, initially <source_developer>-imported. A 409 after preview discards the batch and clashes and reopens the pull form; another preview requires a newly issued one-time source key.

Staged bundles expire after 24 hours; expired or erased stages cannot be applied. Applied batches retain a name-free receipt for idempotent retries. PUT mappings for an unknown batch returns the same 409 import preview expired or unavailable as an expired batch. The clock-driven cleanup runs at startup and every minute while serving. The consuming bundle GET is omitted from read-only serving. Consent GET remains a read without database writes. Graft table deletion counts appear only when rows were deleted. Erasure receipts always include the fixed erased_import_record notice; they never count record entries.

Apply returns batch_id, people, events, duplicate_events, conflicting_events, outcomes, skipped_events, skipped_outcomes, left_behind, left_behind_rows, estimated_lock_seconds, estimated_lock_seconds_min, estimated_lock_seconds_max, memory_gb_for_64_mib, and other. people is the number of named subjects processed; events and outcomes count inserted rows. Duplicate events are skipped by idempotency key, event UID, then content fingerprint; a reused key or UID increments conflicting_events only when a stored fingerprint exists and differs. A native row without a stored fingerprint cannot be compared. The existing row wins. Identity aliases discovered through fingerprints persist, so later bundles cannot evade dedup by changing an alias event's content. Outcomes deduplicate by (repo, issue_id, pr_number, ts, source) across all local developers and all people in the bundle; existing figures win even when the incoming weight or quality differs. Only source="push" uses the push path; any other source requires a PR number. A fresh pull of the same source content, with its current clashes explicitly mapped, at the same destination preserves the original receipt's batch ID and lock times without recreating erased named history. For that fresh batch, name-free totals are rechecked against the current destination k and sticky withholding, and its receipt records the resulting suppression. An exact retry of an already applied batch still returns its frozen receipt. Replay matching uses a content identity that excludes issued_at; the wire digest still covers issuance time and every other bundle field. Source identity, named rows, name-free totals, and left_behind remain part of the match. The same content at a different destination returns 409.

This protection covers content identities recorded by the corrected preview path. Previously applied batches whose staging was cleared retain only their old issuance-dependent digest; their original content identity cannot be backfilled from that digest. Retrying their original batch ID still returns its stored receipt; presenting the exact original bundle also preserves that receipt and records a stable identity for later fresh pulls. Pending previews created before this fix acquire their stable identity when applied. Changed content is a new import, subject to the erased-person record below. Erase at the source too when that source must stop supplying the person's history.

Apply refuses the entire bundle with 400 if an event or name-free total has a negative list cost or exceeds MaxTokenEventCostMicro (10,000,000,000 micro-dollars). Named events must use one of the contract-pinned v1 capture sources (jsonl, codex-rollout, opencode, muse) at realtime fidelity; poller, proxy, manual, unknown source and incompatible fidelity claims return 400. An unmapped source name that cannot be a local identity label returns 400 and must be explicitly mapped.

Rows before the permanent retention watermark are skipped as final, counted in skipped_events and skipped_outcomes, and never written. Remaining rows apply successfully; retries return the same receipt.

Named events retain source costs, price versions (including zero), pricing bases, and provenance. origin_server and import_batch_id distinguish them from local evidence; local repricing and token promotion cannot rewrite their source figures. New people have origin imported, receive no credentials or viewer declarations, and remain imported_unbound until an own-credential upload here. Explicitly mapped existing people retain their local placement and declarations. A mapped person with local history but no org-person row is registered as native at the destination, preserving native census eligibility. Imported spend remains Unknown until the subject has qualifying local evidence, then joins that subject in live identity totals and future seal populations. Imported rows never supply census evidence; existing sealed populations and apply receipts remain frozen. Local capture horizons and price-version coverage exclude imported rows.

Name-free totals are stored under the destination in external_totals, always as Other. Each other entry contains period_start, period_end, withheld, points, and list_cost_micro; both figures are null below the destination's k (at least three). Counts and identities are absent. Unrelated totals cannot supply cover to each other. Identical source-period totals are stored once; re-imports persist withholding and the highest threshold seen, so lowering k cannot release a previously withheld total. Changed or overlapping source periods, or redirecting them to another node, return 409 and roll back the entire apply. The stored receipt freezes the apply-time decision. These totals do not add local census contributors.

Bundle v1 exports only events accepted by the same predicate used by preview and apply: jsonl, codex-rollout, opencode, and muse at realtime, with nonnegative tokens and list cost no greater than 10,000,000,000 micro-dollars. Other events stay on the source. Non-push outcomes without a PR number also stay; push outcomes may have a null PR. Invalid transport figures stay on the source too. Negative or over-ceiling name-free cost totals are omitted, alongside other unsafe totals; they never disclose non-consenters in the retained-row report. Negative finite outcome points and name-free points retain their existing semantics. A new collector cannot widen v1's pinned sources without a contract bump. Preview refuses unacceptable claims before staging; apply retains the atomic refusal for forged or hand-built stages, including negative tokens.

The required digest-covered left_behind report has basis: "source-claimed" and a fixed ordered classes array. Its source classes are jsonl, codex-rollout, opencode, muse, proxy, anthropic-admin, openai-usage, cursor-admin, copilot-billing, api, other (unknown event sources), and outcomes (retained outcome rows, zero cost). Every class carries source_class, count, and signed net_cost_micro, including explicit zeros. It covers only active, non-departed consenters, with no per-person split. Missing, null, malformed, or overflowing reports are refused; absence never means zero. All counts and net cost additions are overflow-checked. Preview and the stored apply receipt carry the same report and left_behind_rows counter, separate from retention's skipped_events and skipped_outcomes. These are source claims, not destination usage or verified totals, and are never added to destination spend. The dashboard labels the retained-row total and class table as what the source reports in preview and completion, hides empty reports, and allows import. The source ledger is unchanged by pull: for active, non-departed consenters, event count and net cost reconcile to exported events plus retained events. Name-free SQL excludes active, non-departed consenters, including retained rows; the report accounts for those rows in the bundle while the source's ledger still includes them. Departed consenters remain excluded from named rows and the left_behind report, but their historical spend and outcomes join the name-free closed-month cohort under the existing k, contributor, dominance, and cost gates. Unsafe name-free cohorts remain omitted without a person-level refusal report.

Consent responses, consent notices, and bundle_pulled records carry the plain sentence: "Some rows stay on the source because bundle v1 exports only accepted events and outcomes." Imports are explicitly exempt from --people-cap-per-day because apply is root-only and audited; this does not change the cap for ordinary people creation. The existing staging and receipt JSON columns hold the report without an additional migration. The current database schema is v28.

The source records bundle_pulled when the one-shot snapshot is delivered. The company records import_applied; a new preview of identical content records an apply audit with replay_of_batch_id naming the original batch. Retrying an already applied batch returns its receipt without another audit. Explicit mapping saves record id_mapped. An audit failure rolls back apply, including people, usage, totals, and staging consumption. Apply is omitted from read-only serving.

Federation administration and transport (P26 + P27, EXTREME)

These routes work in every aggregation mode on a real serving instance. Legacy shared tokens grant no access. Administration uses the existing root/node bearer or cookie session boundary: cookie mutations retain exact Origin and CSRF checks; scripts may use bearer alone. All responses, including refusals, use Cache-Control: no-store. Credentials are limited to 60 requests/minute.

Method and route Principal / input Result
GET /api/v1/org/federation Root or node reader {servers:[{server_id,label,host,node_id,path,status,token:{credential_id,issued_at,revoked_at},last_push_at,last_period_end,schedule:{interval,hour_utc},next_expected_at,contract_version}]}; own branch only; status is current, late, silent, never or revoked
POST /api/v1/org/federation/servers Root or managing node; {label,host,parent_node_id,interval,hour_utc} 201 {server_id,node_id,token:{credential_id,secret}}; native parent in own branch; server takes one child place; interval daily/weekly, hour 0–23
POST /api/v1/org/federation/servers/{id}/revoke Same branch manager; no body or {} 200 {}; accepted history retained; sibling-branch manager gets 403
POST /api/v1/org/federation/servers/{id}/reissue Same branch manager; no body or {} 201 {server_id,node_id,token:{credential_id,secret}}; atomic old-token revocation and new-token issuance, including recovery after revoke
GET /api/v1/org/federation/refusals?since= Root or top-level node reader; optional RFC3339 timestamp Refusal array {ts,reason,server_id?,server_label?,source_class?,count?}; root alone sees source class; identified servers scoped to the reader's branch; overflow rows have hour, reason and count
POST /api/v1/federation/push Federation bearer over HTTPS; tier-federation/v1 payload 200 {accepted_seq,accepted:[{period_start,period_end}]}; own place only
GET /api/v1/federation/status Federation bearer over HTTPS {server_id,last_seq,last_period_end,next_expected_at}; no writes

The v1 schema and the merged Go validator define the wire shape. At most 1 MiB and 31 closed periods may be pushed. Each accepted (server_id,period_start,period_end) is immutable. Under a later sequence, an identical period with the same digest is skipped; only new periods appear in accepted. Any other overlap with an accepted period returns 409 period_conflict, including shifted or spanning bounds. Adjacent periods are allowed. There is no provisional-revision arm. Exactly the last accepted sequence and the identical complete request bytes return the same 200; other old sequences return 409 replayed_seq. A batch either commits every new period and its replay state or commits nothing.

Missing/unknown tokens return 401 no_token/unknown_token. Revoked tokens, wrong credential kinds or another body's server id return 403 revoked_token/wrong_server; HTTP returns 403 not_https before credential lookup, including loopback and requests with missing or unknown tokens. Only TLS or a single X-Forwarded-Proto: https from a configured trusted peer satisfies HTTPS. Unknown contract major returns 400 bad_contract, invalid payloads 400 malformed, oversized bodies 413 too_large, and lockout or credential rate limit 429 rate_limited with Retry-After. Unknown tokens count toward IP lockout; known refused credentials are neutral. Logs retain no raw address, presented credential, credential-derived value or claimed server id. Refusals flush from bounded memory every minute and after HTTP drain, with a normal one-minute crash-loss window (longer during flush failures). Failed flushes are retried; detail is capped at 10,000 rows/90 days and overflow aggregates by hour/reason for 90 days. The worker prunes durable detail and hourly aggregates even when no new refusals are buffered.

Inventory status precedence is revoked, never, current, silent, then late. A server is current when its last period end reaches the UTC month boundary containing now minus the 14-day report grace. Otherwise, a server that has pushed is silent after more than seven days since its last push, or late.

Read-only serving omits push and management mutations and performs no refusal writes. GET inventory, refusals and own status remain reads. Federation adds allowed servers, immutable periods and bounded refusal detail/aggregates without a schema-version bump. The existing chain remains per-node sealed decisions at v22, persisted settings at v23 and vendor mappings at v24; federation adds no numbered step. Federation lifecycle actions are available to managing ancestors through the existing org audit route, alongside its claim rows. The frozen legacy route fixture is unchanged.

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

The additive GET /api/v1/org/report?node=&period=YYYY-MM route serves a stored per-node month. Root/node credentials are authorized to their current subtree; node defaults to the credential binding. The shared read token is explicitly accepted without contributor counts. Other legacy principals remain denied on these new routes. All existing legacy route shapes and sealed bodies remain unchanged. New schema step 22 adds per-node seals, fold decisions, read tallies and the nullable stored TTM body. Older seals retain null TTM bodies.

The JSON response contains node, period{start,end,label}, k, evaluation_mode: false, groups, other and caveat. Each group contains cell_id (dated slice), node_id (structural node), parent_id, level, level_name, name and withheld; a folded member can also have fold_id. Shown rows carry points, list_cost_usd, derived tier when cost is positive, and, for root/node credentials, bound_contributors. Withheld rows omit all figures and counts. other rows carry member_ids and the same withholding/figure rules. Historical cells whose parent is outside the authorized subtree expose no parent or external fold identifier.

GET /api/v1/org/report/compare and the compare=YYYY-MM parameter on report and CSV routes put months side by side, returning {compare:[...]} in JSON. There is exactly one period and up to twelve repeated compare values. Shared read-token reports retain mode granularity: team/developer allow the leaf level; division stops at each branch’s own legacy division node (level 1 for legacy depths, the leaf’s parent below level 2), using the same mapping as org_node_labels. Requests below that branch boundary return 404. Root/node authorization remains independent of mode. Only canonical calendar months are accepted; arbitrary bounds and aggregates return 400, and absent per-node seals return 404. A comparison never refolds, unwithholds or computes a multi-month total. Node keys are sealed-only in v0.7.0; they receive no month-to-date figures.

GET /api/v1/org/report.csv exports the same ordered rows with columns period,cell_id,node_id,parent_id,withheld,points,list_cost_usd,bound_contributors,fold_id,member_ids,name,level,tier,level_name,unplaced. Absent JSON figures/counts are blank CSV cells. Member IDs use semicolons. These routes are absent under --read-only. Responses are no-store and subtree audit failures return 503 after two failed minute flushes. Reads that cannot fit the bounded audit tally also return 503, without recording a partial comparison.

Root-level Unplaced cells carry unplaced: true and a reserved sentinel node ID, with no team name. Default root and shared reads without node= include them. The CSV unplaced column carries the same flag. These cells are omitted below the root and are never upstream named nodes. Their figures follow the same withholding rules as other cells.

A deterministic node failure permanently records an unavailable month with a CHECK-constrained cause: legacy, g_prime_refused, data_defect, no_tree or panic. The node savepoint rolls back, leaving zero org_seal_node and org_seal_fold rows. Database and I/O errors roll back the whole transaction so the month retries. Report, compare and CSV reads return the existing unavailable-month 404; callers must preserve a missing month rather than substitute zero. The legacy sealed body and legacy routes remain byte-identical.

Root credentials can read GET /api/v1/org/report/status for stored month status and cause. Node and shared read tokens cannot read causes. Federation receives a typed MISSING month and cause. Ordinary report, compare and CSV responses use one identical 404 for unavailable and never-sealed months. Re-sealing an unavailable month is not supported; adding it would require a separate decision and a table rebuild.

Federated nodes are opaque in this server’s seal: their shown rows omit figures and counts, never enter a fold, and contribute nothing to local ancestor totals. Ancestor totals remain equal to the local legacy Total; P30 adds federated totals separately. Empty local rows can be shown with zero local people and figures. Shared-token reads use the same IP lockout and 60-per-minute credential budget as root/node reads. Developer-mode tierd seal --arm, --status and --skip control the org sealer’s team view with max(k, 3).

The Organization page at /org and its /org.js script use the existing admin shell registration and return CSP, nosniff and no-store. The page selects one sealed calendar month and optionally a second month via the existing compare route. “Load month” submits the month form; setting a compare month opens “Compare groups”. Comparison labels must match the requested months. Branch rollup titles show the selected path. Currency uses en-US formatting and points and TIER use one decimal place. New groups default to the level directly below their parent. Its CSV link uses the same node and month parameters. It adds no API route or schema step. Shared-token access remains on the existing dashboard/API path; the Organization shell continues to require a root/node cookie session.

Vendor poller identity transport (#1476 part 2) adds no event wire fields or schema migration. Copilot seat IDs and Cursor emails are transient ingestion input; only root mappings can bind counted evidence. Mapping-specific delta keys add the opaque mapping ID, while unmapped and alias-only keys retain their existing format. Copilot credential probes also report seat permission failures; accept the App's updated organization GitHub Copilot Business: read permission. See identity access and collector installation and upgrade.

Live unmapped vendor users (#1476)

GET /api/v1/org/vendor-mappings/unmapped is a root-only admin route, classified with its vendor-mapping neighbours and using their authentication/read rate limits. Node, personal, other credential kinds and static tokens receive 403. Cookie/bearer ambiguity receives 400; foreign Origin receives 403. Unknown query parameters receive 400; invalid sessions receive 401; exhausted read budget receives 429; store contention receives 503. An in-memory per-vendor guard refuses overlapping fetches and enforces at least 60 seconds between fetch starts, including failed fetches, with 429 and Retry-After before upstream I/O. The GET remains mounted in read-only serving and never writes database state. Responses are private and no-store.

200 returns {users: [{vendor, vendor_user_id, login, suggestion}], messages: [], effective_from: "YYYY-MM-DD"}. effective_from is the next UTC day; suggestion is empty unless exactly one current roster person matches a normalized login or email through their ID or alias. Copilot IDs are numeric GitHub user IDs; Cursor IDs are normalized emails. Only active mappings suppress a vendor identity from the list. Ended or voided mappings may be mapped anew from the next UTC day.

A missing configuration, transport failure or unusable response yields that vendor's fixed unavailable message and no users from that vendor, still with status 200. Missing Copilot seat permission uses its fixed permission message. No upstream body or error text is exposed. Successful data from the other vendor can still be returned. Each vendor gets its own 20-second deadline, for at most 40 seconds of upstream fetching within the 60-second write timeout. CSV import uses the existing POST bulk route regardless of live-fetch availability, preserving its 201 success, 400 validation and 409 atomic-conflict semantics. CSV IDs are sent as raw trimmed text; the bulk endpoint alone normalizes and validates identities and normalized duplicates. Existing vendor-mapping route response bodies are unchanged.

Central totals and reporting coverage (#1458, P30 + P32)

The root/node sealed subtree JSON report also contains rollup{native,federated,whole,servers,caveat}. native ("Teams on this server") uses the selected node's stored local rollup, including its dated slices, without summing descendants. federated ("From federated servers") uses accepted periods in the reader's subtree. Each server entry identifies its opaque place and returns its accepted, count-free periods. These totals are reported by them, not re-checked here: central never reconstructs a server total from its groups or subtracts inside its subtree. Shared read-token reports carry no rollup key, retaining the base sealed report shape and mode granularity. They disclose neither federated totals nor server counts. Federated ancestor totals follow the dated tree at month end. Backfilled periods before a server's admission use its first place; later moves preserve the old ancestor total. Only current-scope servers expose details, so a new ancestor can read the moved server's accepted history without rewriting its own old rollup.

GET /api/v1/org/totals?node=&period=YYYY-MM returns this same rollup to root/node credentials. GET /api/v1/org/totals.csv uses the same query and writes three rows (native, federated, whole) with columns source,period,missing,withheld,points,list_cost_usd,through,servers_current,servers_total,label,tier. TIER is the ratio of total points to total list-price cost, never an average of scores. Missing or withheld figures are absent in JSON and blank in CSV, never zero. These routes accept exactly one month, no comparison or free window, and use the existing in-memory subtree read tally. They are absent under --read-only.

whole selects the latest common closed calendar month at or before the requested month; its period states which month its figures cover, and through is that month's final UTC date. It does not mix months or carry a late server's previous value into a newer month. Each included server must cover the entire identical month with accepted, contiguous, non-overlapping periods. A gap, overlap or period crossing the month boundary is missing. An unavailable local node month is missing as well. The whole rollup remains withheld if any component is withheld. servers_current and servers_total describe complete coverage of the requested month, including this server; revoked servers contribute accepted history but are excluded from both counts. A missing server month is skipped when revocation is at or before that month's start, in both federated and whole totals; accepted history is retained. The backward search stops when a required, placed, unrevoked server has no accepted period in or before the candidate month. Admitting a new federated server therefore blanks the whole-company figure until its accepted periods cover a complete calendar month that is also available on the other servers. Node readers see their selected subtree path as the combined total label; root readers retain "Whole company". Unavailable and never-sealed requested local months return the identical 404 per-node sealed month unavailable on report and totals routes.

GET /api/v1/org/coverage?node=[&month=YYYY-MM][&format=csv] accepts root/node credentials in every aggregation mode. Coverage uses the same in-memory D9 subtree tally and fail-closed behavior as report/totals reads, and is absent under --read-only. Other legacy credentials and personal/federation keys cannot open it. All responses are no-store; reads perform no database writes. Unknown, repeated or malformed query parameters return 400; an invisible target returns 404.

Without month, coverage reports the last seven days of local bound-upload receipts and live server status. Server status (current, late, silent, never, revoked) has precedence Revoked → Never → Current → Silent → Late. The expected horizon is the end boundary of the latest month whose end plus the shared 14-day default report grace has passed. Current means the last accepted period end reaches that horizon, regardless of push age. Otherwise, a push more than seven days ago is Silent; a more recent push is Late. Daily/weekly interval and UTC hour determine the reporter's next expected push, independently of other servers. servers includes schedule, last report, last period end, next expected time and contract version. With month, native group status describes receipts during that UTC calendar month; server status describes accepted periods overlapping that month, with reasons distinguishing never set up, no reports and stopped. Local receipt placement follows the dated membership/tree; a month that has not ended is refused with 400. Receipt queries bind their time window in SQL.

groups contains native node IDs and paths, status (reported, late or not_reported) and withheld. Shown rows include a reason when absent: never_set_up means no own personal/upload key was issued before the window's end for a person placed in the group at that horizon; no_reports means setup exists but there is no earlier qualifying receipt; stopped means an earlier receipt exists and none is in the window (late). This definition is shared by live and monthly modes; servers use their admission and accepted periods as setup and reporting evidence. Sub-k and complementary-withheld rows retain Reported / Not reported only: no reasons, contributor counts or upload timestamps. A shown group may include last_report_at as a UTC date, never an exact timestamp. last_period_end is the next UTC day boundary after that receipt date, and next_expected is that date's next daily receipt boundary. Both are dates and are absent when there is no qualifying receipt history. The full dated forest uses distinct own-credential contributors and complementary suppression at max(k,3); movers count on their first active slice. Monthly details require an available, shown stored node decision and use at least its sealed k. When there are no receipts in the selected month, that stored decision gates setup/stopped details; a higher configured k keeps those rows status-only. Unavailable, unsealed and withheld months remain status-only. No native group publishes a contributor count, including in sealed months. One bound upload makes a native group reporting; roster-only, shared, unbound, aliased-away and bot evidence do not count. Internal nodes report when their dated subtree reports. summary{groups_reporting,groups_total} counts native group statuses, including the selected native node, never people or servers. Servers appear separately, with reason (never_set_up, no_reports, stopped) when applicable. Monthly server metadata comes from accepted history for that month and earlier periods; revocation does not erase it.

Coverage's reporting-detail gate uses distinct reporting people per dated cell as its additive figures. Repeated receipts do not pad those figures. The same k, complementary suppression, equality, and dominance checks still apply; monthly detail also requires a released sealed group. A small group and its necessary complement retain status only, while safe siblings retain dates and reasons. Coverage releases no usage figures or contributor counts.

Coverage CSV contains group rows, server rows, and one summary row, with columns kind,node_id,path,status,reason,withheld,last_report_at,last_period_end,next_expected,contract_version,month,groups_reporting,groups_total. Withheld group detail cells remain blank; shown groups can carry reasons, report dates, period ends and next expected dates. Exact timestamps and configured schedules appear only on server rows. User-controlled labels and paths are formula-neutralized in CSV.

Federation and coverage dashboard pages (#1464, P31 + P33, MODERATE)

/federation, /upstream (Child server settings), and /coverage use the existing admin session shell, CSP, nosniff, and no-store headers. Root and node readers can view federation and coverage within their authorized subtree. Managing nodes can allow, revoke, or reissue a server token inside their branch; root can choose any native parent. Refused attempts are displayed only for root and top-level node readers; other readers see an empty table. Detail and hourly roll-up rows each have a separate display limit of 100, and roll-ups show their counts. Unknown-token refusals retain the federation reason label. The audit section uses the authorized audit inventory for root and managing nodes.

Allow/reissue tokens appear only in the transient shown-once result, with Copy and Hide. Same-tab navigation, pagehide, and session clearing erase the result; late responses cannot restore it after navigation. Mutations carry session CSRF and JSON, leaving exact-Origin enforcement to the existing API boundary. The 403/404 action message is “Your key cannot make this change.” Rate-limited changes use the approved Retry-After countdown and disable actions while waiting. Read failures use read copy and never display API error bodies. An unconfigured upstream read (503) shows the host-setup state with Save and Test connection disabled. Company totals reads returning 404 show None for each rollup, without a CSV link or a zero amount.

Root's page allowlist now includes /upstream, linked from Federation. The central HTTPS address is display-only. Saving sends only the schedule and an optional replacement token; leaving the paste empty retains the file. A paste is cleared immediately after submission and uses the existing atomic 0600 host file replacement. Host setup instructions remain linked even when saving is unavailable. The paste field uses autocomplete="one-time-code" outside the schedule form. Test connection uses the saved setup and reads central status only, without sending a report or changing push history. Its result distinguishes success, credential refusal, connection failure and an invalid upstream response, without treating these as session failures.

Both CSV anchors use download; downloading keeps the session shell visible. The Company totals panel and its reads are limited to root and top-level nodes. Coverage separates live server status from closed-month coverage and links to the API's CSV for the selected month and reader's branch. Server reasons retain never-set-up, no-reports, and stopped distinctions. Native group rows show only the status and the API-authorized date; withheld groups show status with a Withheld placeholder for the date, ignoring all other details. Federation's closed-month rollup uses the three approved source labels, its common horizon, and N of M current. Missing/unavailable totals display None and withheld totals Withheld; neither becomes a zero-dollar amount.

Child federation upstream (#1457, P28 + P29, EXTREME)

The child uses the existing tier-federation/v1 contract without a revision arm. Only sealed calendar node months are eligible, in every aggregation mode and in evaluation mode. Month-to-date and evaluation rows are never built for transport. Unavailable node months, for any cause, are MISSING: they have no period in the payload. The resulting gap is never represented as zero or an empty present period. Unplaced contributes only through its sealed root total, never as a named node or fold member. Federated nodes remain opaque. Dated and synthetic cell IDs become stable opaque UUIDs; all payloads pass the central's shared Go decoder.

Host configuration requires all three settings, with CLI > environment > YAML:

federation:
  central_url: https://central.example
  token_file: /var/lib/tier/upstream/token
  server_id: 11111111-1111-4111-8111-111111111111

The corresponding flags are --federation-central-url, --federation-token-file, --federation-server-id; environment variables are TIER_FEDERATION_CENTRAL_URL, TIER_FEDERATION_TOKEN_FILE, TIER_FEDERATION_SERVER_ID. There is no token-value flag, environment setting or YAML key. The central URL must be an HTTPS origin without credentials, query, fragment or path. The durable destination and server ID must match the host pins on restart; mismatches refuse startup to preserve pending retry identity. A refusal means this database already belongs to a different central URL or server ID: restore those host settings to resume the exact pending request. Moving the token file is allowed; only the destination and server ID are durable pins. To intentionally change either pin, keep the original database and its pending history with the original destination; provision a separate child database and server registration for the new destination. There is no in-place repin command.

All three routes require a root credential (bearer or the existing CSRF-protected browser session), return Cache-Control: no-store, and deny legacy tokens:

Route Request Response
GET /api/v1/org/upstream None Host central_url, server_id, token_stored, interval, hour_utc, next_seq, last_success_at, last_period_end, last_attempt_at, last_error, next_attempt_at
PUT /api/v1/org/upstream {interval:"daily"|"weekly",hour_utc:0..23,token?:"…"} Same safe view; token is write-only; central_url and token file path are not editable
POST /api/v1/org/upstream/test None or {} {ok,http_status,error?,last_success_at}; performs only central GET /api/v1/federation/status, without reserving a sequence or writing push state

Without host configuration these routes return 503 after authorization. Read-only serving mounts only GET and starts no pusher. Synthetic demo never configures a pusher. The root-only Child server settings page is served at /upstream.

A paste atomically replaces the host token file with mode 0600. The file and its directory must be owned by tierd's user, the directory must not be group/other writable, and symlinks, hard links and nonregular token files are refused. The file is read again for each push, retry and connection test. Neither the token nor any credential-derived value enters responses, the database, audit or logs. A successful settings save records upstream_configured with interval, hour_utc and a boolean token_replaced, never any credential-derived value. Invalid pastes (empty after trimming, internal whitespace or more than 4096 input bytes) return 400 with the fixed message invalid upstream token; reads and writes both trim surrounding whitespace. The replacement is staged in a same-directory 0600 temporary file. Settings and audit commit before atomic rename; a database failure removes the temporary file and leaves the old token active. A rename failure returns an error after the settings and audit have committed; the temporary file is removed.

The minute worker honors the UTC hour and daily/weekly interval. Startup catches up when the configured hour has passed. Each tick sends at most 31 oldest closed months in a body capped at 1 MiB. A sealed federated node with NULL local figures remains opaque: its wire group is withheld with zero figures and no population counts, and the month can still be pushed. Sequence reservation and the exact pending body (including sent_at) commit in the additive federation_upstream table before network I/O. Upstream adds no schema-version bump: older schema-24 binaries ignore this additive table. Graft advances the schema to 25, followed by import apply at 26 and mapping-audit source redaction at 27; this binary refuses schema versions above 28. Every retry, including after cancellation or restart, uses those same bytes and sequence. Acknowledgements must match both the sequence and period list before advancing the cursor. A separate local scan watermark skips permanently missing node months without claiming that central accepted them. An available month with an unrepresentable tree stops the scan with last_error=unsupported_tree and the normal backoff; it cannot be skipped. Build failures, including invalid stored pending bytes, record last_error=build, back off and log a fixed ERROR message. Catch-up continues on subsequent ticks; once caught up, the next scheduled daily/weekly time applies. Failures retain the pending request and use exponential backoff from 30 seconds to one hour, checked each minute. A refused sequence or period is never rewritten automatically. HTTPS certificate validation is mandatory, redirects are refused, each request has a 15-second timeout, and response bodies are capped at 64 KiB. Shutdown cancels and joins the worker using serve's bounded writer drain.

Erased people and later imports (#1491, ruling A⁗)

TIER keeps this record so a completed erasure stays effective. Your organisation decides the legal basis with its counsel.

On an erasure request, if any identifier has an import trace, TIER stores one unlinked keyed hash for each canonical ID, alias and mapped source name. A batch row mapped to someone else is not a trace. If erasure deletes a mapping row whose target is someone else, it also records that source name; a wrong exclusion is reported and reversible. Applied-batch plaintext source-name rows for the erased mapped person are deleted. Retention ageing makes no entry and never deletes entries. The record holds no figures, timestamp or link between identifiers and uses its own HMAC domain. Hashes never appear in API output, logs, audit rows, receipts, previews or data-subject exports. Those compliance artifacts state the record's existence in fixed wording, without checking membership or counting entries.

The record is not anonymous: anyone holding the database could test a guessed identifier. It matches on the identifier alone, so the same identifier from any source is excluded, including a different person. Preview and apply leave a match behind, rechecking inside the apply transaction, with this report line naming nobody: “One or more people were left behind so a completed erasure stays effective.” The bundle is not refused; lookup failure refuses the import. A wrongly excluded person has to come forward because the report names nobody; root can release them. A different (renamed) identifier evades the record. A person erased here with no import trace is not recorded, except this clash case, so a later first import can recreate them. A staged copy can remain for 24 hours.

Entries are kept until root clears them or the database is deleted, with no timer. A clear removes only the named identifier; a returning person with several identifiers needs one clear each. Root states either “new consent” or “a different person with that identifier” (the API value is different person with that identifier). The response is identical whether or not an entry existed. Each clear is audited without the identifier, hash or entry count. An audited clear-all states “our counsel refused this record”. There is no dashboard control. Nothing in the release waits for counsel, and no Article is cited as the record's legal basis. The secret and entries share the database file. Entries do not survive a restore from an older backup that predates them: restoring it loses newer entries and undoes those erasures. Re-run erasures after a restore.

Schema 28 adds one table, erased_import_identifiers, after schema 27. Older binaries refuse it. erasure_notice is present in preview and completion only when someone is left behind by this record; it contains the fixed report line above. The excluded people require no mapping, and mapping saves skip them. erased_import_record in DSAR exports and both erase receipts is fixed text, independent of whether the subject is recorded. No count of entries is returned.

Route Credential Request and response
POST /api/v1/org/erased-imports/clear Root only {identifier, reason}; reason is new consent or different person with that identifier; 200 {"cleared":true} even when absent
POST /api/v1/org/erased-imports/clear-all Root only {reason:"our counsel refused this record"}; 200 {"cleared":true}

Both routes use the admin API's bearer or cookie/CSRF rules, are unavailable in read-only serving, refuse unknown fields and query parameters, and audit the fixed reason without the subject. Invalid reasons return 400; non-root callers are refused. Commands using a root bearer (replace the example identifier):

curl --fail-with-body -H "Authorization: Bearer $TIER_ROOT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"identifier":"returning-person","reason":"new consent"}' \
  "$TIER_URL/api/v1/org/erased-imports/clear"
curl --fail-with-body -H "Authorization: Bearer $TIER_ROOT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"reason":"our counsel refused this record"}' \
  "$TIER_URL/api/v1/org/erased-imports/clear-all"

Clearing allows new events to import. Identical source content still returns its old stored receipt and does not recreate anyone, including after a clear.