Privacy
Admin sign-in sessions (#1417)
Admin-web sessions live only in process memory. Sign-in creates no database
row, file or durable audit entry. A random 256-bit session id is sent only in an
HttpOnly, SameSite=Strict cookie; it is never logged or written to disk.
Cookies use Secure and the __Host- prefix whenever the effective origin is
HTTPS; on permitted plain-HTTP loopback they use neither Secure nor __Host-.
Memory holds a hashed session id, the credential verifier, a separate CSRF token,
the serving origin and creation/activity times. It never retains the bearer
secret. Session data disappears on sign-out, expiry or restart; expired entries
are reclaimed at sign-in. Limits are 60 minutes idle, 12 hours absolute,
16 sessions per credential (its oldest is evicted) and 4096 simultaneous
sessions per process. Each authenticated request checks the
current credential and holder, so revocation or a recorded departure ends the
session on its next request. Read-only servers support sessions without database
writes.
GET /api/v1/me exposes only the credential id, kind, capability, holder,
node id/name/path/level name and available page names. It exposes no key secret,
verifier, prefix or length. Every admin-web response is no-store. The full
admin token cannot sign in; shared read-token dashboard behavior stays unchanged.
See identity access
for Origin, CSRF, cookie and HTTPS requirements.
The separate dashboard shell (/signin, #1437, plan P20) sends a sign-in key
only in the POST's Authorization header and clears the password field as soon
as the request starts. Its JavaScript never reads or writes cookies, localStorage
or IndexedDB, and never places keys in URLs or history. Admin keys and CSRF tokens
stay out of browser storage in this shell; the separate legacy dashboard keeps
its pasted API token in sessionStorage under tier_token (code:
internal/dashboard/assets/dashboard.js, load, fetchJSON).
On a 401 identity response, the shell saves the
requested path and query (without a fragment) in the tab's sessionStorage under
tier_admin_return_to. The client accepts at most 2048 characters, starting with
one /, with no backslashes or control characters. Sign-in sends the path for
server validation; a successful identity reload clears it before returning.
Sign-out clears it even if the request fails. This return path is the shell's
only use of sessionStorage; import state stays out of it. See
Identity and access for retry and fallback behavior. It learns
identity and navigation only through /api/v1/me; the acting banner displays
the returned holder, key kind and node path using text nodes. Identity and
navigation clear when leaving the page. Pages awaiting their APIs show only
their title, without sample people, keys or reports. Every page, including an
auxiliary page, is gated by the identity response's page allowlist. The shell
pages and /admin.js are absent (404) in synthetic demo and unsealed recompute
modes. A failed identity read opens sign-in only on 401; other failures keep
the form hidden and offer a session-check retry without sending a bearer key.
Sign-out success or 401/403 clears local identity and navigation. Other
sign-out failures retain the shell and its in-memory CSRF token for retry and
show that sign-out did not complete.
The existing server-held session CSRF token is returned in an X-CSRF-Token
header only on a successful cookie-authenticated /api/v1/me response. The
shell holds it in page memory for sign-out; it is absent from bearer identity
responses and refusals. All dashboard HTML and scripts, including the legacy
dashboard, serve CSP, nosniff and no-store. The legacy read-token form and its
storage behavior are unchanged.
The legacy DSAR export (GET /api/v1/developer/{id}/export) carries the opaque
node_id on its org_hierarchy and hierarchy_membership rows. It also
discloses the subject's org, division and team names in org_hierarchy, which
the tree keeps current on every rename or move, and the node the person was
first placed in as org_people.created_node_id.
Who counts toward k (D4)
A person counts once in a reporting window only with roster membership, no bot
classification, and frozen named evidence from this server: bound_upload from
their own personal/device credential, server_watch (--watch-as), or
vendor_attributed carrying a valid root-authored mapping ID. Roster-login
matches and legacy aliases alone never count. Census and coaching use the
same evidence predicate, including the own-credential check. Many devices still
count as one named person. Later alias edits cannot
lend somebody else's evidence to a person.
Vendor evidence counts only when the mapping names the event's frozen subject, the event timestamp is at or after its effective date, the mapping is not voided, and the event precedes any End. Root creates mappings prospectively: the first eligible date is the next UTC midnight. Earlier events and old alias-stamped vendor rows never gain counting eligibility, even on replay. End keeps past evidence, including lagged events received after End with timestamps inside the mapping interval. Invalid vendor identities leave spend stored unbound. Void withdraws past counting and cannot be undone. Stored sealed reports remain byte-identical after either action. Census and coaching use this same predicate. Both pollers now pass transient identity for root-mapped usage. Reconciliation keeps ended partitions inside their interval; voided partitions target zero on replay within the lookback and spend returns to the legacy path. Erased mapping partitions leave their day incomplete without deltas or settlement, while later days still ingest.
Vendor IDs live in vendor_mappings and nullable named org audit detail, never
on token events. Erasure hard-deletes the person's mappings and ended or voided
mappings with the same vendor and vendor ID, including rows assigned to other
people. Another person's active mapping survives.
It scrubs named audit detail matching both vendor and vendor ID to NULL.
Mappings follow roster retention: aged leavers lose their mappings, and mappings
never keep a person live by themselves. DSAR exports include every mapping with
the subject's vendor and vendor ID, including rows assigned to other people,
with vendor, vendor user ID, developer, effective/creation dates, creator, End
and Void dates. Raw vendor identity is transient during ingestion; only the
opaque mapping ID is stored on an event.
Exports omit a NULL vendor_mapping_id to preserve pre-mapping export shapes.
Created people who never upload, imported people without local bound evidence,
federated people, shared/legacy uploads, manual /costs rows, and outcomes alone
count zero. Their eligible amounts can remain in totals; they cannot fill k.
This is a breaking change for shared-token teams. Issue claim codes so people
can claim personal keys and create device keys. Startup and tierd doctor --db
explain when missing bound evidence withholds every group. Sealed months record
census_rule_version as d4-c2-v2 for mapping-aware evidence under seal fold
rule 4 (up from 3). The earlier
d4-c2-v1 rule was introduced by seal fold rule 3 (up from 2).
The fold-rule number versions grouping, census and folding together; the census
name identifies the D4 evidence rule. Old seals retain their bytes and config
digests with their original or unknown census version; rule 2 seals still read and verify.
Credential-free bound_upload evidence from dev builds between 2026-10-24
02:42Z and 22:16Z counts zero; no release writes that state.
For a withheld node reporting summary, root sees the cause; every other principal sees “Withheld — ask your admin”, with counts omitted. A managing node can invent people and upload through their keys; k does not protect against that node.
Upload freshness
Authorized people and key inventories show dates and Fresh / Stale / Silent /
Never chips. Fresh means less than 24 hours, Stale means 24 hours through exactly
7 days, Silent means more than 7 days, and Never means no bound receipt, including
legacy rows with no credential ID. Server receipt time determines freshness.
Personal keys see exact times on /api/v1/me and /api/v1/me/devices. Ancestors
see UTC dates only. 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. Shared upload and read tokens receive no freshness fields.
The subtree reporting summary counts distinct active people reporting within the
inclusive last 7 days; groups without k qualifying non-bot people in that
window omit counts. Upload evidence uses one precise server receipt-time
comparison for both eligibility and reporting, including fractional seconds.
The existing tree complementary suppression gates the full forest before any
subtree is selected, so parent and child counts cannot expose a sub-k sibling.
At evaluation k=2, tree suppression conservatively retains its shared floor of 3;
at k=1 there is no positive sub-k residual.
Root evaluation summaries may use the configured evaluation floor and are labelled evaluation_mode: true;
node keys retain the shared floor. All reads use org authorization.
Evaluation mode (operator choice)
Team reports are name-free and need at least k people (default 5, minimum 3).
An operator can turn on evaluation mode to see small rows themselves while
setting up; shared viewers never see them. This operator choice amends #185.
Enable evaluation_mode: true (or --evaluation-mode) and choose
k_anonymity: 1 or 2 for setup. Only the administrator and a developer whose
credential identifies the row's sole contributor can see rows below 3 or member
breakdowns. Small rows are live only and are never sealed; responses and exports
carry the mode, k, contributor count and evaluation notice. Turn evaluation mode off and set k_anonymity to 3 or more (default 5) to share team reports.
Each row's contributor count covers only its own inputs; an empty label means
unassigned people. On shared/folded paths, other counts the union of its inputs.
The admin/self evaluation view withholds unassigned and literal other rows
while any label is withheld below its applicable floor.
A response-wide contributor count spans published inputs and is published only when
no cohort is suppressed.
Sealing uses the resolved evaluation setting for both labels and contributor
counts. Body replay, including offline verify-report, uses the setting recorded
with the seal; seals without a settings stamp keep their existing replay rules.
The legacy shared --api-token acts as break-glass admin while evaluation_mode is on. This is a configurable choice, not a product property. Reads are audited as "shared admin token". This access will be removed in the first release after v0.7.0.
While k is below 3, shared viewers see team rows of 3 or more people. Coaching ranges still need at least 3 other people. k guards against accidental disclosure to viewers; it does not stop an admin inventing names.
Coach the practice, not the person. Grade the work, not the worker.
TIER helps engineers choose the right model, give it the right context, and spend where it pays. In developer mode, a developer's page is visible to that developer and to the viewers the operator configures: the team's declared manager, an optional skip-level viewer, or whoever a works agreement names. Team membership alone grants no access. A declared manager or skip-level viewer may be on the same team; a works-agreement viewer on the same team is refused. Every viewer sees the same page, and the developer can read the log of who viewed it, including administrators, on My card with their own personal key. The log shows the viewer label, what they read and when, even without a card. A card needs verified spend in the requested window; without it, the card returns 404. Managers may coach from it; no one may ask for figures beyond it. TIER is not suitable for pay, promotion, appraisal, reviews, improvement plans, discipline or dismissal. The product cannot enforce that; only your policy or works agreement can. Anyone with host access can read the database; these limits apply at the API.
TIER is built so that adopting it does not mean handing over your source code or your prompts. This document states exactly what TIER reads, what it stores, and what it never touches — each claim grounded in the code so a security reviewer can audit it.
JSONL token events store a branch only as nullable branch_hash, a pseudonymised SHA-256 of
canonical repo + "\x00" + full branch name with refs/heads/ removed. Raw branch names are
never stored in token_events. Local watcher checkpoints keep the raw branch name in the
plaintext JSON fields GitBranch and LastRealBranch. Default/unknown branches and unknown
repositories produce NULL hashes. This is not
anonymisation: guessable branch names can be reversed by hashing candidates with the known
repo. Merged-PR outcomes store the same pseudonymised hash as head_branch_hash, never
raw head.ref, plus nullable PR created_at and merged_at. Webhooks and backfill use
head refs when available; API outcomes lack head refs and leave the hash NULL. Main,
master, HEAD and a payload-supplied repository default branch have NULL hashes.
Recognised revert exclusions retain the matching raw head ref or quoted title as
audit evidence in outcome_exclusions, under the PR author's access and erasure scope.
Branch credit fires only for still-unattributed events in the same repo and head-branch
hash inside [max(previous merge on that repo and head branch, PR opened − 7 days), merge].
It never changes existing attribution or sealed months; overlapping recorded claims are
skipped. Reusing a branch bounds the next PR's credit at the previous merge; unrecorded
merges cannot be considered. Missing head/open/merge evidence disables branch credit.
The hash does not conceal guessable names. Existing webhook audit payloads retain their
original body under the separate audit retention policy below.
The core guarantee
TIER never stores prompt or completion content, and never sends it anywhere you have not already sent it. Every capture path parses with an allowlist. The Claude Code JSONL parser deserializes only
type,timestamp,gitBranch,cwd,sessionId, andmessage.{id,model,role,usage.*}— token counts, not text; the reverse proxy passes your prompts on to the provider you chose and reads each response as it passes back to extractid/model/usage, which is all it keeps; the Codex rollout-log parser reads six fields, none of which is content; the Opencode reader touches one table (message) and six fields inside its JSON blob, never the tables holding prompts, tool output, or credentials; and the Muse Code reader decodes ids, timestamps, token counts, model names, the workspace root and branch records, never prompt, reply or tool text. One opt-in switch widens the Claude Code read: with worktree attribution on (--worktree-attribution, off by default), the parser also reads the file or folder paths in tool calls and uses the one that seven tools name, never the file's contents, the tool's other arguments, or a command line. See Worktree attribution.
Proxy routes are mounted by default on a writable server. They forward traffic only when a client points at them; --read-only or an empty provider target removes the corresponding route.
Why the guarantee holds
TIER parses inputs with allowlist structs. Only the fields named in the struct are deserialized; every other JSON field — including any prompt or completion text — is silently dropped by the JSON decoder and never enters memory as a named value, let alone the database.
-
Read-time recovery can attribute one person’s spend to the issue of a merged PR (theirs or a colleague’s) on the same branch, without storing anything.
-
JSONL capture (
internal/collector/jsonl.go, thejsonlEntrystruct): readstype,timestamp,gitBranch,cwd,sessionId, and undermessage:id,model,role, and theusagetoken counters (input_tokens,output_tokens,cache_creation_input_tokens,cache_read_input_tokens, and the 5m/1h cache-creation split). There is no content or text field in the struct. With worktree attribution on, a second, separate allowlist struct reads tool-call paths; it is described in Worktree attribution. -
Codex rollout-log capture (
internal/collector/codexrollout/parse.go, therolloutLinestruct, #464): reads exactly six fields — the linetypeandtimestamp, and underpayload:id(the session UUID),cwd,git.branch,model, plus thetoken_countevent'sinfo.total_token_usagecounters. Codex rollout logs contain a great deal more than that — the agent's full system prompt (base_instructions), your prompts, the model's messages and reasoning traces, tool output, and patch bodies — and none of it is deserialized: those fields have no counterpart in the struct, so the JSON decoder discards them. Thecwdis read to decide which repository a session belongs to (a session outside every watched repo is dropped) and is not stored by this path — and, since it is never stored, it is never forwarded either, including bytierd ship --codex-rollout(#492). What that flag sends is the same usage-event shape as every other collector, listed under "Where it goes" below; enabling Codex capture adds no new category of data to what leaves the machine.tierd scorereads this by default on the machine it runs on; nothing is stored or sent. -
Opencode capture (
internal/collector/opencode, opt-in with--opencode, #719): Opencode keeps its sessions in a SQLite database rather than JSONL files, and TIER opens it read-only (mode=ro; neverimmutable=1, which would ignore Opencode's live write-ahead log). It reads one table,message, and from each row's JSON blob only therole,modelID,providerID, thetokenscounts,time.completed, and thepath.cwdused to decide which repository the message belongs to. That database also contains OAuth access and refresh tokens (account), API credentials (credential), and prompt and tool-output text (part) — none of which TIER reads, stores, or forwards. Thecwdis used for the repo decision and is not stored, exactly as in the two paths above, sotierd ship --opencodeadds no new category of data to what leaves the machine either.tierd scorereads this by default on the machine it runs on; nothing is stored or sent. -
Muse Code capture (
internal/collector/muse/parse.go, opt-in with--muse, #895): Muse Code'ssession.jsonllogs hold your prompts, the model's replies, tool arguments and tool output. The reader decodes each line in two stages: first only the fields every record carries (the recordid,recorded_at, the session idstream.id, the run id, and the record and event kinds), and then, only for the five record kinds it uses, a second narrow struct for that kind: the model name and token counts of a billed call (model_completedorautomated_review_completed, plus the review'scompleted_at_ms), theworkspace_rootof ametadatarecord, the run id,workspace_root, VCS and branch reference of aworkspace_branchrecord, and the two ids of amemory_reminder_child_session_linkedrecord (parent_run_idandchild_session_id, which tie a subagent session to the run that started it, #901). No prompt, reply or tool text is ever decoded, andTestDecoderAllowlistfails if a field is added to any of those structs. It opens only files namedsession.jsonl, not the other files Muse keeps beside them. Theworkspace_rootdecides which repository a session belongs to and is not stored; the branch becomes an issue id, and the branch name itself is not stored. The Muse session id is stored (see "Session id" below). The collector keeps its scan position in memory and writes no checkpoint row, sotierd ship --museadds no new category of data to what leaves the machine.tierd scorereads this by default on the machine it runs on; nothing is stored or sent. -
Copilot billing (
collectors.copilot_billing, new in v0.6.2, experimental): reads GitHub's organisation AI-credit billing reports and current billing seats. It stores the mapped developer identity (the user login when that is the canonical developer id), model, product, day and gross amount, plus an unattributed organisation remainder. Product is preserved in the reconciliation key; queried GitHub logins are not stored separately from the mapped developer. Root mappings use numeric seat IDs to follow renames; only mapped identity counts toward privacy k. Numeric vendor IDs and fetched logins are transient, never event columns, delta-key inputs, log values, error details or metric labels. Events retain only the opaque mapping ID and frozen named subject. It stores no token counts, repository or issue linkage from this API. -
Cursor Admin (
collectors.cursor_admin, introduced v0.6.2, supported for team costs): requires a team plan with Admin API access; reads team usage events and stores the mapped developer, model, day, cost and available token counts. Normalized email joins root mappings for counted identity and still joins aliases for spend. Email is transient insert input, never stored on events, in delta keys, logs, errors or metric labels; it persists only in the root-authored mapping and its existing audit/erasure/export surfaces. Events retain only the opaque mapping ID and frozen named subject. The literal"null"and service/cloud/automation accounts never carry mapping evidence. A colliding alias or otherwise unmapped user stays unattributed unless an effective root mapping supplies identity. Conversation id is neither decoded nor stored. BYO-key events labelledUser API Keyare skipped, including Cursor's fee on those requests. -
Reverse-proxy capture (
internal/proxy/proxy.go): the provider response parsers read only the messageid, themodel, and theusagetoken counts from the response body. The client gets the same response content the provider sent; TIER extracts usage and forwards the rest untouched. (Not always the same bytes: the proxy drops the client'sAccept-Encoding, so a response the client asked to have compressed arrives uncompressed.)
Because the parsers are allowlists, adding no new fields is the default — capturing content would require deliberately adding a content field, which does not exist.
Captured-dollar diagnostics
In developer mode, GET /api/v1/diagnostics/captured-dollars reads stored cwd and
session id from watcher_checkpoint to group session-level origin and
working-directory evidence. The session-file key is an install-secret HMAC of
the path, not a stored absolute path. The server also reads on-disk git worktree
metadata at those stored cwd paths: .git directories, worktree admin gitdir
files and linked checkout .git backlinks. It does not reopen session transcripts
or run git maintenance commands. It emits counts and captured-dollar shares,
never stored paths or session ids. Parent and subagent checkpoints sharing a
session id produce session-level origin, not per-event subagent attribution.
These live checks are separate from /scores and report manifests. Doctor reads
and formats the server's receipt; the endpoint returns no receipt in team or
division modes. See the diagnostic limitations.
Worktree attribution (off by default)
Why it exists. Claude Code writes, on every line of a session file, the
folder the session started in (cwd) and that folder's git branch
(gitBranch). Many developers, and most coding agents, start Claude Code in the
main checkout of a repository and then do the work in a git worktree: a
second checkout of the same repository, on its own branch, in its own folder
(made with git worktree add). The session file still says "main", so TIER
books that spend to unattributed:main instead of to the issue the work was
for. Worktree attribution (#823) finds the worktree the session actually worked
in, from the paths its tool calls named. That needs a wider read of the session
file than the token parser makes, so it is off unless you turn it on. It is
an opt-in preview: its measured accuracy, and why a wrong attribution is
permanent, are in
how-it-works.md.
How to turn it on, and the rules it applies, are in
how-it-works.md. This
section states what it reads and what it keeps.
Parents no longer carry (#1016); only message groups labelled isSidechain:true can use carry. Worktree attribution stays opt-in and off by default. Activation requires recorded gates, the operator's security rulings and an explicit operator decision. No new accuracy figures have been measured.
What it reads from the session file. A second allowlist struct
(toolPathLine in internal/collector/toolpath.go), separate from the token
parser's, decodes from each assistant line only:
- each content block's
type, and its toolname; - from each block's tool input, whatever the tool (MCP tools included), the
string values of three keys,
file_path,notebook_pathandpath, spelled exactly so. The decoder copies those three values into memory and skips every other member of the input unread.
It then keeps one path per tool call, for seven tools only: file_path for
Read, Edit, MultiEdit and Write; notebook_path for NotebookEdit;
path for Glob and Grep. Every other decoded value, including a path
that an MCP tool or Bash carries, is discarded there, before any disk read,
and never leaves memory. What the decoder skips is never copied: the text
Edit removes and inserts, the content Write writes, the pattern Grep
searches for, and Bash's command. A relative path, a network path
(starting // or \\), a path over 4,096 bytes and a path containing a NUL byte
are dropped; at most 16 distinct paths are kept from one line
(maxToolPathsPerLine), from at most 64 content blocks (maxToolBlocksPerLine).
It also reads two markers each line already carries, isSidechain and
agentId, which tell a subagent's lines apart from its parent's
(messageLineHead in internal/collector/messagemerge.go). Only isSidechain:true permits carry; an absent, false or wrongly typed label does not.
Which session files. tierd ship and tierd score decode the tool paths
of every Claude Code session file whose session ended on or after --since,
and look up the git files below for every path they keep, before they drop
the sessions whose cwd is outside the repositories you named with --repo
(parseSessionFileAttributed in internal/collector/jsonl.go). So the .git
pointer files and worktree admin folders of other projects on the machine are
read too, to recognise them as another repository's; nothing from a dropped
session is stored. The live watcher also decodes the tool paths of every
session file it reads, but looks up git files only for sessions whose cwd is
in a watched repository.
Command lines are not read. Reading Bash commands (for cd <path> or
git -C <path>) was considered and is not built. The operator ruled
(via #826, recorded on #823) that it comes later, as its own change, behind its
own switch, off by default.
What it reads from disk. For each path it keeps, and for the session's own
cwd (rule 1 in how-it-works.md), it reads git's own bookkeeping files, never
the file the path names, and it never runs git to do it (worktreeIndex in
internal/collector/worktreeindex.go, branchAt in
internal/collector/worktreereflog.go). TIER still runs git log on the
repositories you configured, as it does with the switch off.
- the
.gitentry found by walking up from the path. In a linked worktree this is a small file pointing at the worktree's admin folder,<main checkout>/.git/worktrees/<name>/; - in that admin folder,
commondirandgitdir, which must point back at the same worktree (a moved, pruned or forged worktree fails this check and is not used), andHEAD; - the worktree's reflog,
logs/HEADin the same folder, at most 1 MiB. It lists every checkout with a time (checkout: moving from A to B), which is how TIER learns which branch the worktree had checked out when each message was written. The whole file is read into memory; each line also carries the committer's name and email and, for a commit, its subject line, and none of them is kept; - owners, permissions and modification times of those files. What is checked
differs by file. The
.gitpointer file and the admin folder must be owned by the OS user runningtierd.HEAD, thelogsfolder andlogs/HEADmust be owned by that user and not writable by everyone.commondirandgitdirget neither check of their own; they are trusted only because they sit in the checked admin folder and must point back at the same worktree. A file that fails its check is not trusted, and the path falls back to today's rule.
Paths under /tmp and ~/.claude are ignored.
What it stores.
-
For every Claude Code event it records, one fixed label naming the rule that chose the issue:
branch,worktree-cwd,worktree-toolpath,carryorpr-number-at-insert(#1074: server-assigned, never client-sent) (token_events.attribution_rule, see What IS stored). It is never path text: TIER refuses or drops any other value. -
Spend in a worktree of a repository you did not configure (not one of the command's
--repoor watched repositories) goes to the fixed issue labelunattributed:foreign-repo, with the repository stored asunqualified. No event stores that repository's name or path, andtierd shipnever sends them. The watcher's local checkpoint is the exception: see item (c) below and Watcher tail-state under What IS stored. Items (a) and (b) below are the known cases where spend tied to another repository is booked to your configured repository instead. -
Known gaps, with this switch on or off (tier#1018; operator ruling 2026-10-18: disclosed now, fixed after launch). Items (a) and (b) book spend to the wrong issue in a repository you configured. A booking is permanent: TIER keeps the first one it writes and does not rewrite it.
- (a) When an isolated-agent message inherits the most recent human-named
branch seen in its session file, that branch can come from another
repository. The message can then be booked to that branch's
issue-Nin your configured repository: with rulebranchwhen this switch is on, and with no rule (shown aslegacy) when it is off. - (b) A session that starts in a configured repository and later moves into
another repository's main checkout (not a linked worktree) records that
repository's branch on its later messages, and they can be booked to that
branch's
issue-Nin the configured repository. - (c) The watcher's checkpoint keeps the raw branch names and the absolute
working-directory path of each session file it tails, including session
files in repositories you did not configure. So another repository's path
and branch names can sit in that local row. They stay in the local database
and are never shipped. A data-subject export
(
GET /api/v1/developer/{id}/export) returns the rows it can link to that person through their events, so in case (a) it can return another repository's branch name; rows for sessions with no events stay local. For sessions in configured repositories this is by design, because a restart needs them to resume. The rows for other repositories are to be cut down to the session id after launch.
For (a) and (b), the event's
branch_hashis a hash of your configured repository plus the other repository's branch name. It is not the name, but for a common branch name the hash can be guessed.The #823 gate run (one machine, about seven days) recorded that "the latch passed only
main(0 of 229 cases)" and found no row with another repository's working directory. That is one machine, not a guarantee. - (a) When an isolated-agent message inherits the most recent human-named
branch seen in its session file, that branch can come from another
repository. The message can then be booked to that branch's
-
In live watch mode only, the watcher's checkpoint row for a session file also holds the absolute path of the worktree it is carrying from one message to the next, so a restart resumes correctly (see Watcher tail-state under What IS stored). That checkpoint stays in the local database and is never sent.
What it never stores or sends. No tool-call path is written to an event or
sent by tierd ship, and the code that reads and classifies the paths logs
nothing. The dry-run audit
(tierd score --worktree-attribution) prints separate tool-path and carry counts and USD costs, repository names, issue
labels, session ids and times, never a path or any message content, and stores
nothing. With the switch off, none of this is read and events carry no rule.
Messages held back until they finish (live watch mode only). One reply can be
written as several lines of the session file, spread over minutes. With worktree
attribution on, the live watcher (tierd serve --watch-repo) does not store a
message until it has all of its lines, so each message is judged once, from
every path it named. A message still open at the end of the file is held back:
no event is stored for it meanwhile, though the file's checkpoint row (its
path, and the session id) is saved. When the file has had no new line for
600 seconds (idleRelease in internal/collector/watcherattr.go), the next
read releases it and stores it. The longest pause between two lines of one
message was 417 seconds when this was measured (2026-09-30, over 156,834
multi-line messages in 8,532 session files on one machine), so the wait is
longer than any measured pause. Idleness is judged by the file's modification
time, and after a restart the watcher reads at once any file whose checkpoint
stops short of its end. A known gap: if a line of a message arrives after
that message was released, it is read as a fragment and stored again under the
same message key; the store keeps the larger token counts and the first
row's issue, rule, cost and price version, so a late fragment cannot move a
message to a different issue, and that row's cost stays priced on the counts it
was first stored with while its token counts rise. A message held back when an
erasure runs can be stored after it: see the erasure limits under
Data-subject rights.
tierd ship reads each file whole in one pass and holds nothing back.
Shipper credential and identity limits (#1144)
The recommended credential for tierd ship is the server's --ingest-token
(TIER_INGEST_TOKEN, config http.ingest_token, or @/path/to/file). Supply it
on the laptop through ship --api-token @/path/to/ingest-token or
TIER_API_TOKEN=@/path/to/ingest-token. The admin token still works. The ingest
token opens only POST /api/v1/events; it cannot read scores or exports, post
outcomes, erase data, or use the provider proxies.
A shared-token holder can post events under any developer id, rostered or not.
Those events remain in team totals but never count a person toward k (D4).
A holder can also pre-claim an idempotency key a teammate would later use,
suppressing that row. The token does not identify a developer.
A holder who knows a key can replay it under the same developer with larger
counts, raising that row's token counts and cost (recomputed at the row's original
rates, #1231). Replaying a teammate's key this way raises that teammate's cost
(the TIER denominator); real-data developer mode denies GET /api/v1/events,
so read-token holders cannot obtain keys through that route (code:
internal/api/identity.go, identityRoutePolicies, identityBoundary).
A later cross-developer collision is rejected and reported as
rejected_cross_developer in the response, with the original row unchanged.
The server logs a WARN and the shipper prints the rejected count, without
failing the ship or retrying. Same-developer replays still deduplicate, with a
zero rejection count. Global idempotency stays unchanged; the guard cannot
reverse a pre-claim that was inserted first.
What IS stored
TIER stores the following in a single local SQLite file. Configured network paths can transmit data; see Where it goes.
- Developer identifier — an OS username (JSONL) or GitHub login (PR outcomes), and any alias→canonical mapping you configure.
- Issue id — derived from the branch name or PR/commit body (e.g.
issue-42,TIER-99), or a fixedunattributed:…label naming why none was found. With worktree attribution on, spend in a worktree of a repository you did not configure gets the fixed labelunattributed:foreign-repoand the repositoryunqualified, so that repository is not named. - Repository (
token_events.repo,outcomes.repo, #231) — the canonicalowner/reposlug a cost was spent in or an outcome was earned in, taken from a per-reporepo:override, the repository'sremote.origin.url, a GitHub webhook'srepository.full_name, or the optionalrepofield ofPOST /api/v1/eventsorPOST /api/v1/outcomes. It is the reserved valueunqualifiedwhen the producer could not know the repository. A slug can identify a person: a personal repository such asalice/side-projectnames its owner. - Model names — e.g.
claude-opus-4-8. - Token counts — input, output, and the cache read/write counters.
- Cost — list-price cost in integer micro-dollars, computed from the token counts via a versioned reference price table.
- Pricing provenance (
token_events.price_version, #233;token_events.hostandtoken_events.billing_mode, #300) — the version of the price table that computed the row's cost (0for a row no version was stamped on); the serving host the cost was priced against (for example the reverse proxy's upstream host name or an Opencode provider id), orunknownwhen the producer does not know it; and how that host bills:per_token,subscriptionorself_hosted_amortized. A caller ofPOST /api/v1/eventsmay send its ownhost(up to 256 bytes), and TIER stores that string trimmed and lowercased but otherwise as sent, so it holds whatever the caller put in it — and a self-hosted host name such asalice-laptop.localcan name a person. - Session id (
token_events.session_id, #238) — the opaque agent session UUID an event belongs to (Claude Code'ssessionId, the Codex rollout log'ssession_meta.id, thesession_idcolumn of Opencode'smessagetable, or the Muse Code log'sstream.id), so context-bloat and rework-loop token-waste can be diagnosed at session grain. It is a random identifier only; it carries no prompt content, no completion text, and no file contents, and is NULL for rows a session-blind producer (the reverse proxy or an Admin-API poller) captured. - Idempotency key (
token_events.idempotency_key, #21) — the dedup key that makes a replayed event update one row instead of adding a second. TIER's own collectors and the reverse proxy store a SHA-256 hex digest. A caller ofPOST /api/v1/costsorPOST /api/v1/eventschooses its own key, and TIER stores that string as sent, so it holds whatever the caller put in it — which can include personal data such as an email address. It is NULL for rows that were stored without a key. - Billing declaration (
token_events.billed_to, #854) —otherwhen aPOST /api/v1/costscaller declared the row's spend is not billed to an org whose usage poller the server runs; NULL on every other row. - Attribution rule (
token_events.attribution_rule, #823) — which rule assigned the row's issue id: one of the fixed labelsbranch,worktree-cwd,worktree-toolpath,carry,branch-at-mergeorpr-number-at-insert(#1074: server-assigned, never client-sent), never path text. It is NULL on older rows and on rows no rule was recorded for, is written only when the row is first inserted, is included in the data-subject export, and is erased with the row. - Issue extraction rule (
outcomes.issue_rule,token_events.issue_rule, and the existingoutcome_exclusions.issue_rulesnapshot column, schema 12) — a fixed closed-set label, never branch, path or message text. Outcomes retain server-derived webhook/backfill evidence; events retain client assertions. Existing rows and unknown/absent event rules are NULL, rendered aslegacyin event/outcome exports; the data-subject access export preserves JSONnull. The field is included last and erased with the existing outcome/event row. The identifier-freeissue_rule_cutoversingleton records the UTC migration time and maximum outcome/event IDs once and survives erasure; these boundaries distinguish the pre-upgrade band from later NULL rows (cutover details). - PR metadata — PR number, author login, size-derived weight, quality, and the merge commit SHA.
- Watcher tail-state (live watch mode only —
--watch-repoor thewatch.reposconfig key) — when live ingestion is enabled, TIER persists one operational checkpoint row per JSONL file it tails (watcher_checkpoint), including session files whose repository you did not configure, so a restart resumes tailing from the last parsed byte instead of re-reading each file from the start. Each row'smetadatacolumn holds a small JSON blob describing the session being tailed: the absolute working-directory path (cwd), the git branch (and, when an isolated-agent session inherits one, the most recent human-named git branch seen so far in that file), and the session id (a UUID), plus the session start time, the last activity time (used to age out metadata), the first-observed model name, and an internal parse-sequence counter. For a session file in a repository you did not configure, that path and those branch names are that other repository's (tier#1018 (c)). With worktree attribution on (#823), it also holds the absolute path of the linked git worktree currently carried, if any. Nothing here is captured unless you run the watcher; a one-shottierd scorewrites no checkpoint. Each row is keyed by an install-secret HMAC of the session file's absolute path, not the path itself. The key does not store the OS username, working directory or session filename. The metadata'scwdremains a plaintext absolute path. The row also holds the file's inode, the byte offset read so far, and a CRC-32 of the file's first bytes. An erasure keeps the key; see Data-subject rights below. - Opencode scan watermark (only with the Opencode collector enabled —
--opencodeor thecollectors.opencodeconfig key) — the Opencode collector shares the samewatcher_checkpointtable, under a row whose key is an install-secret HMAC ofopencode-db:<path to opencode.db>:<scope digest>. The scope digest is SHA-256 of the sorted configured repository paths (absolute where possible) and slugs; changing their order does not change the key. Adding a repository changes the key and triggers an idempotent backfill, including its rows older than the previous watermark. Legacy keys without the digest are not resumed, so an upgrade also triggers one idempotent backfill. Itsmetadatablob holds two integers and a schema tag, and nothing else: the highest Opencodemessage.time_updatedalready processed (epoch milliseconds), and the number of rows in Opencode's ownmigrationtable (a schema-drift tripwire). It carries no cwd, no branch, no session id, no model name and no developer identifier — unlike the JSONL watcher's blob above, which describes a session; this one describes only how far a scan got. - Raw GitHub webhook payloads (only when the GitHub webhook path is
enabled — i.e. you set
webhook_secret/TIER_WEBHOOK_SECRETand point GitHub deliveries atPOST /webhook/github; that endpoint is fail-closed and stays disabled without a secret) — for each processed delivery (pull_request,push,workflow_run), TIER retains the raw GitHub webhook request body, gzipped, in thewebhook_payloadstable so a score input can be re-derived months later (#137). Unlike every token-capture path above, this raw body does contain personal data: commit author names and email addresses, PR titles and descriptions, and commit messages — whatever GitHub places in a standard webhook payload (up to 25 MiB forpush, 1 MiB for the other accepted event types). It is the same PR/commit metadata GitHub itself already stores, and TIER adds no prompt text, completion text or file contents to it. ⚠️ The titles, descriptions and commit messages are free text, stored as written: anything someone pasted into them (code, a prompt, a secret) is stored with them. A body is kept for every delivery of those event types, before any filter on action or branch, so pull requests that never merge and pushes to any branch are kept too; per GitHub's documentation, anediteddelivery also carries the previous title and description. Editing text out on GitHub does not remove TIER's copy, and the per-developer erasure does not reach it: it stays until the retention bound below prunes it, unless you delete the rows directly (see Known residual —webhook_payloads). Retention is bounded: rows are pruned at startup and then every 24 hours while the server runs, by two limits — a 90-day age cap and a 50,000-row cap (oldest evicted first), enforced byPruneWebhookPayloads(internal/store/audit.go). Both bounds are currently compile-time constants (webhookPayloadRetentionDays/webhookPayloadMaxRowsininternal/store/audit.go), not config keys — there is no setting to change the window or cap without a code change, and no flag to keep the webhook path enabled while suppressing retention. The one operator control is the path itself: to store none of this, run TIER without the webhook path (JSONL capture andtierd shipnever populatewebhook_payloads). - Sealed reports (
sealed_report, #913) — the finished aggregate report for a closed reporting period, stored once and never recomputed, with the settings it was built under. It holds only aggregate figures. - Sealed per-label totals (
sealed_rollup, #913) — for each label in a sealed period, the totals the report was built from (points, costs, outcome counts) before small groups were folded together. They are never served, and a label with a single member holds that one person's totals. - Sealed person keys (
sealed_person, #913) — for each label and measure in a sealed period, one keyed hash per person counted there. The hash is computed from the person's canonical id under the install secret, not stored as the id, but whoever holds the database file can recompute it from a guessed id. - Install secret (
install_secret, #913) — one random key per database, used for the sealed person keys. It names no one. - Canonical-id history (
canonical_id_history, #975) — every link from an alias to a canonical id, with the last sealed period committed before the alias edit that made it and before the edit that re-pointed or deleted the alias, so an erasure can find sealed person keys computed from a canonical id an alias edit has since retired. A link that already existed when schema version 4 first opened the database has no start time: when it began is unknown. Rows are never rewritten, except that a link's end is set once; an erasure deletes rows.
This list is not every table: the other per-developer tables (actual_spend,
org_hierarchy, period_membership, quality_events, quality_history,
repo_repair_audit, push_outcome_commits and push_outcome_audit) are in the erasure table under Data-subject rights
below, which also covers the audit ledgers and the operator-identifying fields
they carry (actor, reason, forgotten_by).
None of this is prompt text, completion text, code, file contents, or diffs, with
these exceptions: free text in raw webhook payloads (for example PR titles,
descriptions and commit messages) and the matching raw head ref or quoted title
in outcome_exclusions are stored as written, with whatever was pasted into them. The session_id above is likewise an opaque UUID — it groups a
session's events without persisting anything the session said or produced.
Server logs are a separate store. They can contain commit subjects, author logins, developer ids in request paths and client IP addresses. Commit subjects are free text and may contain whatever someone put there. Log retention and copies depend on the operator's logging setup; database erasure does not erase those logs.
Two of the entries above are conditional and hold data an auditor
should note. An ordinary watcher tail-state row is derived operational state,
safe to delete — the watcher rebuilds it from the live file on the next change.
⚠️ An erasure tombstone is not (see Data-subject rights below): deleting
one while its session file still exists lets the watcher read that file from the
start and store the erased session again. The tail-state contains no prompt or
completion text and no file contents; its key is an install-secret HMAC of the
path. Its cwd and (with worktree attribution on) its worktree path are absolute paths (filesystem locations, not
the contents of any file), which can reveal an OS username or an internal project
name. Raw webhook payloads hold third-party personal data (contributor names and
emails) at rest, bounded to ~90 days. Revert exclusion evidence retains a
matching raw branch or title, which can also contain third-party identifiers;
it persists until the author's erasure or database deletion. Operators should
treat the local database file (mode 0600, single-tenant) accordingly — and may
decline the webhook path entirely to store none of the webhook PII.
Retention and the developer-mode upgrade (#826/#1371)
The retention engine and startup/daily scheduler run in real, writable developer mode, which requires an operator API token and provisioned subject viewer credentials. Once the permanent watermark is armed, pruning continues after a switch to team or division mode. Org audit names are pruned in every real, writable aggregation mode, including before that watermark is armed. The synthetic demo does not initiate age-prunes.
The default is 90 days, the minimum 60 days, and the maximum 365
days. The minimum is derived from store.RevertObservationWindow, the longest
finite quality-change window; a smaller setting is refused with that reason.
The ceiling supports a full annual coaching cycle, not performance reviews.
An explicit serve --developer-retention-days N, TIER_DEVELOPER_RETENTION_DAYS
or developer_retention_days config key pins the window, in that order. Otherwise
the root Settings page's saved value wins over the default. Bounds are validated
before use; there is no off value. A page save that lowers the window requires a
preview and explicit confirmation and takes effect at the next prune.
One transaction tombstones affected watcher sessions before deleting their cost
rows, preserves cost horizons, folds already-priced costs, paid invoices and
allocated outcome points into a permanent name-free roll-up, freezes connected
correction chains, scrubs old endpoints, removes expired named rows and advances
a permanent watermark. Any failure rolls all of these changes back. Costs age
by event time, outcomes by merge time, quality rows with their outcome, and paid
spend by the end of its billing period. Current hierarchy and open memberships
stay; closed memberships age by their end. A leaver's aliases, canonical history
and current hierarchy disappear after the last retained reference ages out.
Small team/division labels fold into other; no person key enters the roll-up.
Org /scores totals for old fleet-wide windows include that history without
re-pricing. Historical repo/individual reconstruction is unavailable.
Widening the window works forward only and never restores pruned data. Before
the first prune, installation backfill remains available. Afterwards, every
historical named writer respects the maximum cutoff ever applied, in every
mode. /events returns rejected_before_watermark; ship prints that count and
treats those rows as final. The report manifest and startup log disclose the
name-free refusal count. Consented graft imports separately retain safe source name-free totals under their destination node.
Event 3 reviews have no age deadline on intact, unpruned data. After a prune
freezes a connected chain, later reviews are recorded and return
ErrCorrectionFrozen without moving any allocation. Old endpoints read
aged out. Sealed periods ending before the cutoff lose their person keys and
keep counts and their original report bodies. Comparisons that need the expired
keys refuse explicitly; the operator must confirm that replay policy before
merge. Raw webhook bodies never outlive the developer window (and retain their
existing 90-day/50,000-row cap).
Checkpoint keys are HMACs of the path using the install secret; the migration
re-keys existing rows atomically without changing offsets or head fingerprints.
Pruned sessions retain a tombstone and the watcher skips appends, including
after restart. Historical checkpoint metadata copied to a new filename is
also refused and replaced by a tombstone, using last activity time (or the
legacy start time) and retained session events. Sessions with retained activity
keep capturing. After a successful startup filesystem walk, unresolved file and
subagent checkpoint keys are deleted; other collectors' keys are preserved.
The job checks the result of wal_checkpoint(TRUNCATE) and retries within a
bounded deadline. If a reader prevents truncation, the committed prune remains
successful and logs a warning to use tierd backup. Successful truncation
removes old WAL page images; pages freed before secure deletion was enabled
still require the tierd backup / VACUUM INTO procedure below.
Back up before the retention upgrade. For a developer-mode deployment requiring a full annual coaching window, select 365 days. Its database must be backed up before upgrading. Use the installed pre-upgrade binary against the actual meter database, writing a new snapshot file, for example:
tierd backup --db /path/to/meter.db --out /path/to/backups/meter-before-retention.db
Keep that snapshot separately and set developer_retention_days: 365 (or
--developer-retention-days 365) before the upgraded server's first start. The
snapshot contains personal data and needs the same access controls as the live
database. A backup is a separate copy: erasure of the live database does not
erase it. Age-pruning is destructive; the permanent name-free roll-up cannot
reconstruct individual records. A rollback needs the pre-upgrade database
snapshot.
Data-subject rights: access and erasure (GDPR Art. 15 / Art. 17)
Because TIER stores personal data keyed to a named developer, it ships two bearer-gated (write/admin-scoped) admin endpoints so an operator can honour a data-subject access or erasure request without hand-editing the database (#184):
-
Erasure —
DELETE /api/v1/developer/{id}deletes every row stored under that person's identifiers, and erases their watcher tail-state (see Watcher tail-state below), in one transaction (all-or-nothing). It first resolves{id}through the alias map (single-hop) and then removes rows stored under the canonical id or any raw login that aliases to it, across all developer-PII tables:Table What it holds token_eventsper-developer token counts and list-price cost outcomesper-developer PR/issue outcomes, weight, quality outcome_exclusionsrecognised revert PR tombstones and whole moved outcomes, PR author, classification evidence (the matching raw head ref or quoted title), source actor and timestamp; included in access exports and hard-deleted on erasure actual_spendper-developer actual-paid amounts org_hierarchythe developer's tree-derived team/division/org and node ID org_peopleenrolment origin, creation node and server-clock departure vendor_mappingsroot-authored vendor IDs and their prospective effective dates, End and Void dates; exported and hard-deleted on erasure org_auditsubject and actor names, frozen node names and named detail; erasure redacts these in place, including subject credential IDs, rather than deleting the row period_membershipthe developer's org-membership windows hierarchy_membershipthe developer's dated team/division history, including the rows each alias carries from the day it was added (#886, #914) quality_eventsper-developer CI/revert quality signals quality_historyper-developer quality-transition log repo_repair_auditwhich repositories a tierd repair-reporun moved this developer's stored spend intopush_outcome_commitsthe SHA, repository, commit time and GitHub push time ( push_order, #938) of each direct commit push capture folded into an outcome (#849); erasure removes only this developer's entries: a push outcome that also holds another developer's commit is kept and re-owned to that developerpush_outcome_auditeach time a push outcome in this developer's name was superseded by a merged PR or re-owned (#849); erasure also blanks this developer's commit SHAs in other developers' rows developer_aliasthe developer's alias→canonical mappings canonical_id_historyevery link from one of the developer's ids to a canonical id, and every link from another alias to one of the developer's ids (#975) Watcher tail-state (
watcher_checkpoint) is erased by a tombstone, not always deleted (#919). The table has nodevelopercolumn. The erasure finds the person's rows through the session ids of their storedtoken_eventsrows, read before those rows are deleted. For each row it finds, erasure keeps a tombstone under the HMAC key. The watcher can delete the row when it observes the original path's removal. Erasure itself cannot reverse that hash to stat an orphan file. Otherwise itsmetadata(thecwd, branch, session id, model, start time, counters and worktree path) is replaced by the marker{"erased":true}, and the row keeps its path key, inode, byte offset and head CRC. The watcher reads that row before every read of the file and skips the file unless a full read of its first bytes (as many as the row recorded) finds different content: appends included, across restarts, and when the file is replaced by a byte-identical copy with a new inode (a backup restore,rsync, an atomic save). A file at the same path whose first bytes differ is a new session and is captured normally. Seven limits, stated plainly:- The HMAC key is kept. It allows the watcher to recognise the same path without storing its OS username, directory or session filename. A current session's export includes the stored hash, not the plaintext path.
- A tombstone can outlive its session file. The watcher deletes it on an observed removal, but an orphan hash cannot be reversed at startup to stat the file. Retaining the name-free marker is safe; dropping it while a session still exists could permit capture again. A developer retention prune also preserves these markers.
- Unjoinable rows are not found. A row whose session left no stored
token_eventsrow cannot be linked to a person, so neither the erasure nor the export can find it. For example, the watcher can record tail-state for a session whose working directory is outside every watched repository, and such a session stores no events. These rows hold the samecwd, branch and session id. Nothing ages them out today. They go when their session file is deleted. ⚠️ Do not delete such a row while its session file exists: the watcher then reads that file again from the start and stores whatever it can attribute. To retire one while its file exists, tombstone it instead: set itsmetadatato{"erased":true}. Erasures made before this release did not touch this table at all, so each one left that person's rows here as ordinary rows that nothing now links to them; retire those the same way. - Lines being read during the erasure are stored after it. If the erasure
commits while the watcher is already reading new lines from that person's
file, those lines are stored after the erasure; calling the erasure
endpoint again removes them. Where the file already had a row, the
tombstone stops every later read. A file on its very first read has no row
yet, so it gets no tombstone: the watcher saves an ordinary row for it and
goes on capturing it. Calling the erasure endpoint again once any of that
session's lines are stored removes them and tombstones the file. There is
one gap: if the erasure commits after the watcher stored lines from a file
with no row but before it saved the row, the erasure removes those lines
and the watcher then saves an ordinary row after them. The erased lines do
not return, but no stored line now joins that row, so neither the erasure
nor the export can find it, and it keeps the session's
cwd, branch and session id. It stays that way until the session writes a new line: the watcher stores that line, and calling the erasure endpoint again removes it and tombstones the file. If the session never writes again, the row goes when its session file is deleted. The same holds for a new session file at a path whose tombstone the watcher has just released. - A message held back by worktree attribution can be stored after the
erasure. With worktree attribution on, the watcher holds back a message
still being written until its session file has had no new line for 600
seconds (see
Worktree attribution). If the
person's session has no stored event yet when the erasure commits (a
session that has just started, for example), its file's checkpoint row
names the session id, but no stored
token_eventsrow carries that id, so the erasure cannot link the row to the person and does not tombstone it. When the hold ends, the watcher stores the message under the person, as an ordinary row, and goes on capturing the file. A session that already has a stored event is found and tombstoned as usual. If worktree attribution is on for the server's watcher, call the erasure endpoint again after the person's session files have had no new line for more than 600 seconds, and check the person's export: that call removes what was stored in between and tombstones the file. - A moved or renamed session file is not covered. Once the old path is gone the watcher deletes its tombstone, and it reads the file at its new path from the start (a move within the watched projects directory is seen at once), storing the erased session again. Calling the erasure endpoint again after that removes those rows and tombstones the file at its new path.
tierd shipis not covered. The tombstone protects only the watcher on the server host.tierd shipre-posts every event in its--sincewindow (90 days by default) on each run, and the server de-duplicates by idempotency key. After an erasure there is nothing left to de-duplicate against, so the next ship run stores those events again for as long as the lines are still in the JSONL files on the shipping machine. Stop runningtierd shipfor that person before erasing.
Sealed reporting periods keep the person's contribution (#913, #914). An erase removes everything attributable to the person, but a sealed k-anonymous aggregate keeps their contribution — with small labels combined into a safe residual before sealing; an unsafe residual retains no pre-fold figures — because removing it would let a reader recover their figures by subtraction. A sealed period stores its served report, per-label pre-fold totals, and for each label and measure the people counted, each as a keyed hash of their canonical id (HMAC-SHA256 under a random per-install secret). The erasure deletes none of these rows and changes no report or total. It replaces with a fresh random tombstone each keyed hash computed from an id the person has at erase time (their canonical id and every alias pointing to it), and, in each period, each keyed hash computed from a canonical id that one of those ids was linked to when that period was sealed (
canonical_id_history, #975; a period counts when it was sealed after the alias edit that made the link and before the edit that ended it, in the order the database committed them, and a link with no start time counts from the beginning). For example, ifalice-ghpointed atalicewhen July was sealed and was later re-pointed atalice.smith, erasingalice.smithtombstones July's key foralice; an alias created by mistake and removed before any period was sealed tombstones nothing. One tombstone is used per period, reused under every label and measure the hash was held under, so no stored key can be recomputed from those ids afterwards. The erasure'ssealed_personcount (whichtotal_deletedincludes) is the rows this erasure tombstoned. What remains:- each replaced row keeps a
tombstonedflag, so a label the person alone held in a period still shows that its only member was later erased; - ⚠️ the erasure deletes stored rows only under the ids the person has at
erase time, never under a retired canonical id. A retired id's own rows
(for example events recorded under
aliceitself) are not deleted, because that id may now belong to someone else. Its keys in periods sealed while the person's alias was linked to it are still tombstoned, and that changes no figure. - ⚠️ links are followed only from the ids the person has at erase time.
A period attributed through an alias that has since been deleted, or
re-pointed at someone else, keeps its key live (for example:
alice-ghpointed atalicewhen July was sealed and was later deleted; erasingalice.smithdoes not reach July's key foralice), because following links that point at a retired canonical id could tombstone another person's keys. In that case also erase the retired id by name, after checking it is not now another person's id.
The companion
repo_repair_row_auditledger is not listed because it has nodevelopercolumn to erase by: only a repair id, a row id, and the before/after repository slug (old_repo,new_repo). It deliberately does not copy the resolvingsession_id, precisely so that an audit record designed to outlive the row it describes cannot carry a session id past an erasure. Erasure deletes the subject's before-images by row id, before their token events, so their repository slugs do not survive it. A slug can identify a person (a personal repository such asalice/side-projectnames its owner; see Repository above). Other developers' before-images stay: this ledger is the per-row before-image atierd repair-reporun is reversed from by hand (there is no--undo). Developer retention removes before-images with aged source events or audit timestamps.All five audit ledgers —
reprice_audit,reprice_row_audit,repo_repair_audit,repo_repair_row_audit,cost_correction_audit— are append-only by construction: no code path mutates them; the tables themselves are not protected against direct database access. One of them carries more than that, and it is narrow:cost_correction_audit(the money-rewrite ledger) refusesUPDATEat the schema level, so no code path can silently rewrite a recorded correction — a mutation would have to drop the trigger first.DELETEis deliberately not refused on any of them — erasure (below) and future retention/GC are lawful deletes, and no database constraint can tell a lawful delete from tampering. None of this is tamper-proofing against someone holding the database file.The
cost_correction_auditledger (#346, the sanctionedPOST /costscost-correction override) follows the SAME pattern and is also not listed: it holds nodevelopercolumn, and — after an earlier draft got this backwards — deliberately does not copy the correction'sidempotency_keyeither, precisely because that field is often client-chosen and could embed personal data (an email, a ticket reference), and this ledger is designed to outlive the row it describes. It carries only a server-generatedtoken_event_id(which simply stops resolving once the row it names is erased or pruned — correct, not a bug) plusold_cost_micro/new_cost_microand the operator-suppliedactor/reasonthat explain the correction.actor/reasonname the OPERATOR who made the correction, not the data subject whose spend was corrected — the same third-party-identifier class as thewebhook_payloadsresidual below — so they are not erased by this endpoint. Operators must not put a data subject's own name or email inreason; nothing technical enforces that today.The
price_table_registrytable (#714 — the record binding each price-tableversion:to the content identity of the table served under it) is also not listed, and holds no personal data: a version integer, two content hashes, an effective date, a model count, a source, atool_version, afirst_seentimestamp, and — once retired —forgotten_atplus aforgotten_bythat names the OPERATOR who retired it (self-asserted, and a third party to any data subject whose spend was measured — the same class ascost_correction_audit'sactor). The companionprice_forget_auditledger carries the same fields and is likewise not personal data about a data subject. It describes a price table, not a person, and it carries nodevelopercolumn to erase by. ⚠️ One honest caveat, stated rather than glossed: itssourceis a filesystem path, and on a workstation install that path can incidentally contain an OS username (/Users/alice/prices.yaml). That is operator-environment provenance — the same third-party/operator class astool_versionand thewebhook_payloadsresidual below, not personal data about the data subject whose spend was measured. It is never served over HTTP. ⚠️tierd prices forget-version --commitdoes NOT remove it. It retires the identity — the row is kept and stamped, and aprice_forget_auditentry is written — deliberately, so the record of what a price version meant survives its own retirement. Treat that as a retention decision: neither command erases this row, and the same override path is in any case written to stderr at startup and intotierd score-log --json, both far likelier to leave the machine than a database row. There is no erasure path for it today; if a deployment ever needs one, it belongs alongside the other audit-ledger retention questions, not as a side effect of the escape hatch.⚠️
actoris a self-asserted claim, not a verified identity — the audit trail records who the caller says they are. It is unvalidated free text the caller chooses (length-capped only), and nothing checks it against the credential that made the request. Nothing can today:POST /api/v1/costsis gated — when a token is configured at all — by a single global write token with no subject, so there is no principal for the server to record instead. Treat everyactorvalue as a claim to be corroborated, not as an attribution. Binding it to a verified principal requires the identity layer tracked as #65.The same holds for the corrected row's
developer: nothing binds the authenticating principal to it either, which is the half that matters for data accuracy (Art. 5(1)(d)). What is bounded is reach —POST /api/v1/costsforcessource="api"and the correction path compares it, so an override can only ever touch a manually imported row. Automatically captured spend cannot be corrected through this endpoint at all.The erasure endpoint returns per-table affected-row counts under the historical JSON names
deletedandtotal_deleted, not physical row deletions. The correction counts are rows scrubbed:correction_declarationsincludes both the subject's declarations subsequently deleted and shared declarations retained with text cleared;corrective_relationsandcorrection_auditcount retained rows with identifiers and shared text cleared.total_deletedsums these affected-row counts, including retained rows. Thewatcher_checkpointcount (and sototal_deleted) is rows tombstoned plus rows deleted. It is idempotent (a second call, or a call for an unknown developer, deletes nothing and returns404). -
Access (DSAR) —
GET /api/v1/developer/{id}/exportreturns every row stored under the same resolved identifier set, plus the person's watcher tail-state rows (path key andmetadataincluded), found by the same session-id join the erasure uses, as JSON — the portable artifact you hand to the data subject. It cannot return the unjoinable tail-state rows described under Erasure above, because nothing stored links them to the person. ⚠️ It does not list the person's sealed person keys either (which sealed periods, labels and measures counted them), although TIER could compute them. That is a known residual; whether the export should list them is not yet decided. The same holds for the person'scanonical_id_historyrows (#975), which the erasure deletes but the export does not return; that question is also still open.
Known residual — webhook_payloads. The structured erasure above does not
rewrite the raw GitHub webhook bodies retained in webhook_payloads (only present
when the webhook path is enabled). As described in What IS stored above, those
raw bodies may embed a contributor's name or email address. Surgically editing a
gzipped raw audit body to redact one identifier is out of scope for the erasure
endpoint; the mitigation is the retention bound — every such row is pruned at
startup and then every 24 hours while the server runs, by a 90-day age cap and a
50,000-row cap (PruneWebhookPayloads), shortened by an applied developer
retention cutoff,
so any identifier embedded there ages out within ~90 days. An operator who must
guarantee immediate removal of that residual must delete the affected
webhook_payloads rows directly. Running TIER without the webhook path stops new
payloads from being stored, but rows already stored stay until the retention bound
removes them or someone deletes them. The bound still runs with the webhook path
off: each time tierd opens the database, and every 24 hours while tierd serve
runs (cmd/tierd/webhook_prune.go).
org_actual_spend is org-level (no developer column) and holds no personal data,
so it is correctly outside the erasure/export scope.
Deleted bytes are overwritten at the next checkpoint. TIER opens its database
with SQLite's secure_delete on, so a prune or an erasure zeroes the deleted rows
once SQLite next checkpoints its write-ahead log into the database file
(automatically after about 1,000 page writes, and at shutdown) instead of leaving
them readable in free pages (#862). Until then the old page can remain readable in
the database file or the -wal file.
The -wal file also keeps its own copy of recently written rows after that
checkpoint. SQLite trims the file back to 4 MiB, but that removes only what lies
beyond the first 4 MiB; a row inside them stays readable until SQLite writes over
it. The -wal file is removed only when the last connection to the database closes
cleanly. If TIER crashes or is killed, or another program such as tierd backup
still has the database open when TIER stops, the file stays on disk until the
database is next opened and closed cleanly.
This covers deletes made by this version and later. secure_delete zeroes a
page when a delete frees it; it does not go back over pages that were already free.
Rows pruned or erased by an earlier TIER version therefore stay readable in the
database file after you upgrade, until SQLite reuses those pages. To clear them
now, replace the database with a compacted copy:
- Stop
tierd serve. - As the user that runs TIER, run
tierd backup --db <path> --out <new-path>, where<path>is the--dbyou givetierd serve(default~/.tier/tier.db) and<new-path>does not exist yet.tierd backupwrites the copy withVACUUM INTO, which rebuilds the file from its live rows only, so the copy has no free pages and no deleted rows. - Move the old database file, and its
-waland-shmfiles if they exist, out of the directory. Rename the copy to the old database's name. - Start
tierd serveand confirm your data is there. Then delete the old files you moved aside: they still hold the deleted rows.
Where it goes
The pure tier-federation/v1 package (#1430) defines the aggregate push
format used by the receiver; it performs no I/O beyond reading the supplied payload and adds no
outbound traffic. Its allowlist contains period bounds, k, aggregate points and
micro-costs, org-node ids/names/levels, fold membership, price versions and
server/version metadata. Unknown properties, including developer, person_id,
contributors and count, are refused at every object level. Withheld totals
and groups must have zero figures. Duplicate periods, duplicate group ids within
a period and self-parent groups are refused. The golden fixture withholds its
total so its single withheld group cannot be recovered by subtraction. Refusal
errors contain only a stable code.
Population counts are absent, so this decoder cannot establish a cohort's size
from its figures. The period builder must apply the sealing audience
RowAccess{}, k on bound people, dominance and complementary suppression before
publishing each frozen closed increment. Points and costs may be fractional,
zero or signed and do not stand in for counts. The content digest covers the
complete period except digest, including org names; it is not derived from a
credential and supplies integrity checking, not authentication.
The database is a local file (SQLite). Outbound network traffic depends on the commands you run, the collectors you configure and the traffic you route:
- the child federation pusher, when configured, sending sealed, suppressed
monthly org-node aggregates to the host-pinned HTTPS central address as
POST /api/v1/federation/push. Its connection test reads onlyGET /api/v1/federation/status. No person fields or sub-k counts leave the child; see Child federation exports; - the reverse proxies (
/anthropic/,/openai/,/gemini/) forwarding your API calls to their upstream: by defaultapi.anthropic.com,api.openai.comandgenerativelanguage.googleapis.com, or whatever--anthropic-target,--openai-target(any OpenAI-compatible host) and--gemini-targetname. This happens only for traffic you route through them; - the Anthropic Admin API and OpenAI Usage API pollers, which fetch your
organization's usage and cost reports from
api.anthropic.comandapi.openai.com— only when thecollectors.anthropic_adminorcollectors.openai_usageconfig block is present; - the Copilot billing poller, which calls
api.github.comby default for organisation AI-credit billing usage, the paginated Copilot billing seats list and, in GitHub App mode, the installation token exchange. It sends the organisation, requested day and, for per-user requests, a lowercased query identifier, with a bearer credential. Query identifiers come from mapped numeric IDs resolved to current seat logins, plus every rostered developer id and every alias key whose canonical target is rostered, if the text matches^[A-Za-z0-9][A-Za-z0-9-]{0,38}$with no trailing or doubled hyphen; the reserved unattributed developer is excluded. These identifiers can be local OS usernames or configured developer ids, not just GitHub logins. An alias key that is a local username but happens to be a different org member's GitHub login is queried as that login, so its spend is attributed to the alias's developer unless an effective root mapping for that seat overrides it for that day; keep alias keys that are not GitHub logins unambiguous. Only root mappings supply counting evidence. Seat lists stay in memory for the pass, and missing seats are reported only as a count. Missing organization GitHub Copilot Business: read permission disables mapping polling; accept the App's updated permissions after upgrading. App mode sends an App-signed JWT and installation id to obtain the bearer token; the private key stays local. It runs only withcollectors.copilot_billingconfigured (or a configured doctor probe). The root-only Unmapped vendor users panel (GET /api/v1/org/vendor-mappings/unmapped) also fetches/orgs/{org}/copilot/billing/seatsand can trigger the installation token exchange, including on a--read-onlyserver; - the Cursor Admin poller, which calls
api.cursor.comby default for team usage events. The root-only Unmapped vendor users panel (GET /api/v1/org/vendor-mappings/unmapped) also fetches/teams/members, including on a--read-onlyserver. It sends the team Admin API key as HTTP Basic authentication with an empty password; usage requests also include the day's start/end timestamps and pagination fields. It sends no email or conversation id in the request. It runs only withcollectors.cursor_adminconfigured (or a configured doctor probe). Both new pollers allow an explicitapi_base_urloverride; outbound requests then go to that configured origin; tierd backfill,tierd outcomes,tierd qualityandtierd score --outcomes, which read the GitHub REST API (api.github.com, or the--github-api-urlyou pass) for repository, PR, commit and workflow evidence.score --outcomesalso reads/user. They send the GitHub credential and the requested repository, PR, SHA or run id, only when you run them;tierd doctor --config, which probes each configured provider, including Anthropic and OpenAI, using its configured credential, only when you run it;- the company import pull, which sends a one-time export key to the source
server's
GET /api/v1/export/bundleover HTTPS and receives consenting people's named data and name-free totals, only when root requests a preview; tierd hierarchy import, which sends the rows of your team CSV (developer,team,division,org) to a centraltierdyou operate, asPOST /api/v1/org_hierarchyto the--serverURL — only when you run it without--dry-run;tierd doctor --server, which sendsGET /api/v1/scoresto the centraltierdyou name, to check that it answers, accepts your token, and agrees on the clock and the price table, and thenGET /api/v1/report_manifest, to check whether sealing is stalled. It also readsGET /api/v1/diagnostics/captured-dollars— only when you pass--server;tierd seal --status --server, which sends oneGET /api/v1/report_manifestto thetierdyou name, to read why sealing is stalled — only when you pass--server;tierd issues --server, which sends oneGET /api/v1/issuesto thetierdyou name, andtierd healthcheck, which probes the configured server health path — only when you run them; andtierd shipforwarding captured usage events (the fields listed above, never content) to a centraltierdyou operate — from Claude Code sessions by default, from Codex rollout logs when--codex-rolloutis passed, from the Opencode session store when--opencodeis passed, and from Muse Code session logs when--museis passed. All four produce the same event shape; none forwards acwd, a Museworkspace_rootor any content. With--worktree-attributionon, Claude Code events also carry theirattribution_rulelabel and may carry theunattributed:foreign-repoissue label; no tool-call path is forwarded.
TIER does not call home or send telemetry to the project maintainers. The configured connections above can send data to your servers, GitHub and AI vendors.
Legal deployment guidance
This document bounds what TIER stores as content. It does not resolve the labor-law question: per-developer cost attribution is personal data about a named individual, and in TIER's EU target markets (DE Betriebsrat co-determination, FR/NL works councils, GDPR Art. 22 profiling) measuring named individuals is co-determined or restricted. For deployment guidance — works-council prerequisites, DPIA pointers, and a team-only vs per-developer decision table by jurisdiction — see legal-and-privacy.md. (Positioning guidance, not legal advice.)
Corrective work evidence
correction_declarations, corrective_relations, and correction_audit are
personal data covered by access/export and erasure. Each endpoint includes
nullable declaration and outcome ids, developer login, canonical repository and
PR number (repo#PR), merge SHA, and occurrence timestamps. Bounded supporting text,
disposition and appeal reasons, audit reason text and reviewer rationale are
subject-bearing content, bounded to 4,096 bytes per text field. Audit actor and
role are self-asserted; they do not establish authenticated identity.
The admin (api-token) scope opens GET /api/v1/corrections/{kind} for
relations, assessments, allocations and audit, as keyset-paginated JSON or
CSV. It includes all stored evidence fields described here, including free text,
actor/reviewer identities and exact allocation values. Read tokens cannot open
this route; it is absent from read-only demo mode and refused in team/division
aggregation modes.
Data-subject access exports all stored columns for either endpoint, including
shared declaration text and the counterparty's identifiers on shared relations.
For a reviewer who is neither endpoint, access returns only audit rows where
they are the actor and assessments where they are the reviewer; it does not
return other developers' allocations, relations or declarations.
Canonical repo/PR matching includes excluded outcomes under either
their original developer or trusted author, even when no outcome row remains.
Erasure resolves aliases, scrubs the subject's copied identifiers and
indirect declaration links, and clears shared text and audit actors before deleting
the subject's declarations and outcomes, including their declaration stats and
recorded timestamps. Relations and audit rows remain without the erased endpoint;
surviving evidence is retained. Evidence capture alone does not enable scoring.
When the operator enables Event 3 scoring, resolved allocations affect live
points as described below. Public GitHub can still link the surviving endpoint
to the erased subject through its PR history; this residual linkage is accepted by design.
Erasing a reviewer who is neither endpoint clears only matching actor/reviewer
identity columns. It preserves roles, reasons, supporting/disposition text and
reviewed sizing, and never freezes or reallocates the parties' allocations.
Schema version 8 prevents older erasers leaving this data behind.
Canonical declaration replays preserve the first evidence. A replay with an edited
declaration state or parent set returns the existing declaration id and the distinct
unchanged-ignored status (ErrCorrectionDeclarationIgnored) to the store caller;
it leaves declarations, relations, and audit rows unchanged. An identical state
and parent set is a successful no-op, including reordered parents.
The identifier-free API diff-stat cutover marker remains: pre-upgrade all-zero
API evidence is conservatively unavailable, including genuine zeros recorded by
the pointer-aware version-7 API writer, because the marker cannot distinguish
those from older omitted stats stored as zeros.
Event 3 also stores correction_assessments and correction_allocations as
endpoint-linked personal evidence. Assessments include reviewed original/own and
before/after cumulative sizes, comparison commit identities, bounded scope and
rationale, reviewer identity and review time. Allocations retain raw size, own
quality, share, final allocated points and marginal growth as distinct exact
rational values, plus pending/provisional/disputed and ranking eligibility state.
Export includes only rows where the subject's outcome (including aliases) is an
endpoint or participant, the subject is the reviewer, or the relation is a direct
subject relation. Export does not traverse the connected chain. Erasure deletes
the subject's own assessment and allocation rows, scrubs comparison identities,
scope, rationale, reviewer and review time from direct subject evidence, and
freezes every connected surviving allocation after severing its relation and
parent links. Reviews of unrelated survivors remain intact. Only survivors'
numeric allocations remain unchanged; they cannot be upgraded or reallocated
after any party is erased. Counts for these tables include rows deleted,
scrubbed or frozen. These rows do not enable scoring: the stored policy defaults off. Historical sealed bodies and
digests are preserved; the reserved policy/rubric columns remain NULL for old seals.
See Enable Event 3 scoring for configuration, rollback and startup behavior.
Enabled evidence and disputed state
Enabling scoring uses the stored reviewed assessments and allocations: reviewed cumulative growth is separate from individual allocated share. A pure repair can receive points transferred from the original without adding growth. Eligible unconfirmed same-author pairs use a provisional churn/file-count approximation; unconfirmed cross-author declarations cannot debit the other author. These figures are evidence with stated limits, not independently verified scope.
Actor and role are self-asserted local CLI input, not authentication. A
single-operator installation confirms its own pairs. Requiring an actor label
different from the declarer is advisory; it does not prove a separate reviewer.
A dispute or withdrawal after confirmation keeps the previous treatment until
operator settlement. Disputed state remains visible in developer/operator
views, authorized exports, the corrective digest and new seals; team/division
views return not_shown for the diagnostics. This suppression does not conceal
the underlying rows from an operator who can read the database. Existing sealed
reports and digests never change after enablement, disputes, settlement, erasure,
or late evidence.
See Event 3 coverage for recorded declarations and limits.
Identity credentials and view records (#1370)
Subject/viewer identifiers, credential labels and kinds, issue/revocation times,
team-map manager/skip-level/works-agreement declarations, view timestamps and
routes, and remap old/new values are personal data. identity_credentials,
coaching_view_audit and identity_remap_audit join the developer PII registry.
Erasure removes subject and viewer credential/log rows and clears incoming
viewer declarations and historical references. Exports include their identity
metadata; credential hashes and operator credential fingerprints are secret
material and are excluded.
org_people holds the developer id, creation and departure times,
created_node_id (the node where the person was created), and creator/departure
actor ids. Developer erasure removes these rows and developer export includes
them. On-roster people stay live; a person marked left ages out from the departure
click recorded at the server clock. Org write actors accept only credential UUIDs
or the host-cli and break-glass sentinels, keeping developer names out of
immutable node-edge history.
Token events also store server-written provenance and the immutable
named_subject binding. Both are exported, and erasure finds events by either
raw developer or frozen subject. Existing rows remain legacy_unverified.
The view log's --view-log-days retention (default 90, range 1–365) is capped at the developer retention window whenever developer retention/pruning is enabled. See
developer access and upload identity.
Coaching card boundary (#1372)
The card builder takes a subject and no viewer argument. Authorization and the fail-closed view-log write run before it. Its one read snapshot includes every retained view (admin included); different viewer contexts produce identical bytes over the same snapshot. A new request adds a new view to that history. No additional table or persistent copy of the card is created.
The route is unavailable without a valid configured viewer credential for the
subject and positive verified spend in its window. Named score reads return
this same card. Its spend uses the immutable named subject and the census's
same evidence predicate: bound_upload with the subject's own local credential
or server_watch under --watch-as. Vendor rows do not count without
root-authored vendor mapping provenance.
Remapping an alias cannot promote excluded rows. The excluded-spend share uses
rows bound to the subject or unbound rows recorded under that exact identifier;
it does not infer ownership of old aliases. Excluded dollars never leave this
calculation. The current card stores no additional personal data.
The 50% attributed-spend floor is independent of capture fidelity. Below it, TIER, its CI and work-type/no-issue figures are withheld with neutral text and an information icon. Typical ranges pool outcome costs across at least k other eligible same-team/org contributors, each above the attribution floor; they reveal neither identities nor per-person point values. Bot and remapped duplicate identities cannot pad that floor. Evidence is PR and opaque session-id links only, without titles, prompts, code or session contents. Structured annotations are deferred (PR8); the notes section states that explicitly.
Raw-data retention is implemented separately. The card header states that its configuration is separate; it does not claim retention is running. Operators must merge and verify the retention batch before releasing developer cards: 90 days by default, at most 365 days for a full annual coaching cycle. The card has no code dependency on that batch.
Org tree and append-only audit
org_levels, org_nodes and dated org_node_edges describe the organisation;
org_people records named enrolments. org_hierarchy, hierarchy_membership
and retention_rollup carry node IDs to retain placement. Tree nodes and edges
are shared structure and remain after a person's erasure. Their labels are
organisation metadata, so operators should avoid putting personal identifiers
in node names.
org_audit separates nullable named fields (developer, actor_developer,
subject_credential_id, node_name_chain, detail_named) from retained action,
time, opaque actor credential ID, subject node ID, node ID chain and name-free
detail. It stores no credential verifier, token hash, prefix or token length.
New hierarchy membership writers also use opaque IDs or the host-cli /
break-glass sentinels. Pre-existing fingerprint rows are not rewritten.
Audit rows cannot be deleted or rewritten. The only update is a named value becoming NULL. Erasure clears those fields in rows naming the subject, actor, or a declared viewer. Access exports include exactly those rows; a row not about the subject carries only the subject's own name. Age pruning uses each row's timestamp and the same watermark-clamped developer window, within the developer prune transaction; team/division servers also run an audit-only scrub when full history folding has never been armed. Names in recent rows survive until their own cutoff, even if their subject has no recent activity. Audit rows never feed the live person set used by retention. Widening a window cannot restore scrubbed names.
Who sees org people and keys (#1421–#1423)
Root credentials see the entire organisation tree, people, declared viewers and
public credential inventory. Managing node credentials see names, membership,
creation metadata, reporting dates and public credential metadata within their
own subtree. Read-only node credentials see the tree within that subtree, but
neither the people nor the key inventory. Node people responses omit
declared_viewers, and ancestors see creation and departure as dates only.
These administrative inventories do not grant individual score or card access.
Targets outside the branch return 404, the same as missing targets. A move of a
visible node requires authority over both parents; missing that authority gives
403. Shared and legacy tokens cannot reach these routes. Developer identifiers
are global, so POST /api/v1/org/people answering 409 tells a manager that the
name exists somewhere in the organisation, even outside their branch.
Personal credentials see only their own devices and exact last-data timestamps;
ancestors receive dates and freshness chips. No shared view receives these
inventories. A secret appears only in its creation/reissue response, marked
no-store. Lists contain opaque credential IDs, labels and lifecycle metadata,
never secrets, hashes, prefixes or token lengths. Reissue closes the old key and
creates the replacement atomically. Revocation retains device history and does
not erase work or revoke sibling devices.
Every successful administrative write appends an audit row in the same
transaction with the actor's opaque credential ID. Names, labels and level
names use the nullable named fields; permanent detail contains only approved
name-free values. The existing developer retention window scrubs audit names
in every aggregation mode. A newly enrolled person is not_counted until
eligible named evidence arrives. The creation cap (default 25 per UTC day across
a credential's whole branch, --people-cap-per-day) limits padding, but a
manager can still invent people and upload their data: a node can pad its own
subtree. k does not protect that subtree against its own manager.
Personal-key custody and invitations (#1425)
Managers and root distribute one-time claim codes, not working personal keys.
A manager who keeps a code could redeem it before the person does; that is
detected, not prevented: the person then sees "already claimed", and root sees
the reuse refusal and the redemption (audited with the claim-code actor). A code works only on
POST /api/v1/claim, expires after 72 hours and can be consumed once. The person
receives their personal key once on redemption and can later rotate it from
/me/key/rotate. Root recovery keeps their existing key active until the new
code is redeemed, then revokes every personal key of theirs and every device
those keys minted. Managers can end personal access only by marking the person
left. A stolen unused code can be redeemed by whoever holds it; secure hand-off
is still required. An already-used-code refusal states its claim time and is
recorded for root. Failed codes share the IP authentication lockout.
org_claims holds developer, node, issue/expiry/consumption/cancellation times,
opaque claim id, creator credential id or host sentinel, kind and the SHA-256
code hash. Plain codes are never stored or listed. Subject-access exports
include lifecycle metadata and viewer_notices, excluding code hashes as
credential security material. Erasure deletes both tables' subject rows in the
same transaction as other personal data. Retention deletes whole claim and
notice rows by their creation timestamps under the developer window, including
when only audit retention is armed; they never keep a subject live.
Claim issue, use, observed expiry and cancellation are audited and notified to
the subject. D10 applies: developer, actor developer, subject credential id,
node names and named detail age out; action, time, actor credential id, node
ids and opaque claim id remain. No code or verifier enters audit or notices.
Devices added by a manager/root show that creator's opaque credential id and
developer id to the holder, who can revoke the device. Mark-as-left stores the
stated leave date on the person's org_people row; it is exported with that
row and erased with it.
Viewer notices and settings changes (#1440)
Root alone declares card viewers and erases a person through the new admin surface. Node placement gives managers group access, never implicit access to a person's card. A live declaration permits a root, node or personal sign-in key whose holder is declared to open that card in developer mode; each such open is written to the existing view log before response. The card shows its current viewers. Legacy pair credentials continue to work as before. A departed subject's card remains closed.
A viewer declaration commits a notice for the subject and an org audit row.
Viewer names go only in nullable named audit columns, never free detail. D10
scrubs those names with the watermark-clamped developer window; notice rows are
deleted whole at their cutoff. Erasing a viewer also deletes other subjects'
notices that name that viewer. /me/notices and /me/audit return only rows about
the caller. Self audit omits named detail, node names and other actors’ names;
notices and live cards supply the viewer list. My card also reads
GET /api/v1/me/views with the personal sign-in to show its own
coaching_view_audit rows: exactly viewer, label, route and ts (time). This read writes nothing
and works in team, division and developer modes, including on a read-only server
and without verified spend. It accepts no subject id or query parameters. Root,
node and shared tokens cannot use this path. A coaching card itself still needs
verified spend in the window and returns 404 without it. Erasure's permanent audit retains its action, time and actor's opaque
credential ID and contains no erased subject name.
Root settings saves record old/new validated values and the actor credential ID, never credential-derived material. Explicit host flags, environment variables or config keys pin settings; defaults do not. Unsaved controls use false evaluation mode, warn shared ingest and 90 retention days. Saved changes apply without restart; lowering retention requires a token-bound preview and confirmation. Pruning remains scheduled, so confirmation reports a pending fold. The preview runs the scheduled prune inside a rolled-back savepoint and reports its current deletions and scrub counts without changing data or the watermark. Data arriving before the scheduled prune can change these counts. Existing view-log retention is capped at the effective developer window. Widening cannot restore previously removed data. Read-only serving mounts no save route.
In team/division mode and the developer-mode org sealer's team view, sealed months use live settings and record their value and source in immutable nullable metadata alongside the census-rule stamp. Verification uses the recorded values; changing today's Settings does not reinterpret a stored month. Pre-upgrade seal metadata stays unknown. The settings shared-ingest counts use the existing daily request tally and reveal no people, credentials or event contents.
Consented graft snapshots and temporary previews
The Source export and Company import dashboard pages use the existing cookie
session and CSRF checks. The source page exposes only the signed-in person's
consent; root can issue a shown-once export key with its expiry. Hide, same-tab
navigation and pagehide erase that key from the page. The company page clears
the pasted export key immediately when it starts the preview request, even if
the request fails. Neither page writes secrets, previews, personal mapping
choices or the returned apply summary to browser storage, URLs or history.
Late responses cannot restore those values after navigation. Every clash needs
an explicit saved map/new choice and target before apply is enabled; matching
IDs never preselect a merge. The summary shows the API's stored receipt and
keeps withheld totals withheld.
Choosing map sends only the preview's matched existing person; editing a new
identity cannot redirect that mapping. A preview conflict clears the staged
batch and clash controls before another pull. Handoff notices about imported
people appear only when the stored summary reports named people.
A graft bundle contains named events and dated outcomes only for people with active consent on the source and accepted by the pinned v1 import policy. Whoever holds the person's personal key can give or withdraw consent; this is only as strong as claim-code custody. A root or managing node that redeems the person's onboarding claim code can obtain that key and consent with it. Each consent change writes a subject notice as well as an audit row. Consent covers the fixed event subject (the stored developer when no fixed subject exists) and exact outcome developer ID; aliases are not expanded into additional named histories. Departed holders are excluded from named export. The bundle projects an explicit field allowlist: credentials, viewer declarations, audit records, and view logs are excluded. Stored micro-dollar costs, price versions, pricing bases, and provenance travel with named events; the source generates deterministic keys for unkeyed events. The v1 digest covers the entire bundle except the digest field itself, using the same canonical JSON helper as the federation contract.
Bundle v1 exports jsonl, codex-rollout, opencode, and muse events only at
realtime fidelity, with nonnegative tokens and list costs within the import
ceiling (10,000,000,000 micro-dollars). Other source classes, incompatible fidelity,
negative cost or tokens, over-ceiling events, and invalid transport figures stay
on the source. Non-push outcomes without a PR number stay too; push outcomes may
have a null PR. New capture sources require a contract bump to travel in bundles.
Negative or over-ceiling name-free cost totals are omitted as unsafe, without
adding non-consenters to any retained-row report. Finite negative outcome and
name-free points keep their existing semantics.
A required digest-covered left_behind report aggregates only active,
non-departed consenters' retained rows, without a per-person split. Its
basis: "source-claimed" label distinguishes it from verified destination usage.
Its fixed ordered classes are jsonl, codex-rollout, opencode, muse, proxy,
anthropic-admin, openai-usage, cursor-admin, copilot-billing, api, other
(unknown event sources), and outcomes (retained outcomes, zero cost). Each
source_class carries a count and signed net_cost_micro, including zeros.
Missing, null, malformed, or overflowing reports are refused, never read as zero.
Preview and the stored apply receipt show the report and left_behind_rows,
separate from destination retention skips. Count and cost sums are overflow-checked.
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 report does not add destination spend or census evidence. Preview refuses
unacceptable row claims before storage; apply also refuses forged or hand-built stages.
The bundle's v1 change uses existing JSON storage without a database migration.
Consent responses, consent notices, and bundle_pulled records state:
"Some rows stay on the source because bundle v1 exports only accepted events and outcomes."
Imports are exempt from --people-cap-per-day because apply is root-only and
audited; ordinary people creation still follows the cap.
Non-consenting people and departed consenters contribute only to safe name-free
closed-month totals; departed consenters remain excluded from named rows and
the retained-row report.
These totals use retained raw history, a floor of at least three locally bound
native contributors, and the shared suppression/dominance checks. A cohort is
omitted if either points or cost lacks k contributors, if an alias or bot would
supply cover, or if any contributing identity lacks qualifying evidence.
Open months, unsafe totals, and history whose subject detail has already been
retention-folded are omitted. These generated totals are marked sealed:false
and evaluation_mode:false; they are not authoritative stored seals. Bundles with
a name-free total marked sealed:true or evaluation_mode:true are rejected. No
non-consenting person ID or sub-k count accompanies an omitted total. These totals
are recomputed from live retained history on each pull. Two pulls can therefore
be differenced as history or consent changes; closed-month suppression does not
provide protection against that cross-pull inference. Consenting people's figures
are excluded from name-free totals, including rows retained by the import policy.
Those rows remain in the source ledger and are accounted for by left_behind
in the bundle; consenters' source event counts and net cost equal exported plus
retained events. Pull does not delete or subtract source ledger rows.
Named exports write a subject-keyed bundle_pulled audit row for every exported
person, visible in their /me/audit. The stored row keeps the destination label
in named detail.
A recognized export key sent over plain HTTP is consumed before refusal. HEAD
and other non-GET methods return 405 without consuming the key.
For an import preview, the company server fetches from whatever HTTPS host root names in the source URL and sends the export key to that host. Choosing that host is a root-only trust decision; internal, loopback and link-local addresses are allowed. The URL and fetch limits do not restrict which addresses it can reach.
On the company server the preview holds the validated bundle for at most 24 hours
of use. A background sweep removes expired bundle bytes and mappings within the
next minute while the server is running, and startup also cleans expired stages.
The export credential is never retained. A source response echoing that credential
is refused. DSAR, erasure, and retention select held staged people and batch-person
metadata by their explicitly mapped local identity, falling back to the source ID
only when unmapped. Erasing or ageing out that local subject invalidates the whole
staged bundle. A colliding source name neither receives the held mapping nor
invalidates the mapped person's stage. After staging is cleared, source-named
subjects can export source metadata without the mapping's target developer,
and erase that metadata. Erasing a source-only name in an id_mapped audit row
nulls only source_developer; the target retains their audit row.
Consent and mappings participate in DSAR and erasure, and
named consent/mapping rows do not keep an otherwise aged identity alive.
A copy already imported by another server remains that server's responsibility: erasure must be requested there too. Withdrawing consent or erasing the source after a pull does not retract a copy previously delivered to the company server. The admin-only private-address SSRF residual is described in Security.
Applying a graft is one transaction, including the company import_applied
audit. Mapping decisions have company id_mapped audits, and delivery already
has the source bundle_pulled audit. No callback credential is retained.
An identical bundle has one effect even if previewed again. Reused event keys
or UIDs are skipped; conflicting content is flagged in the apply receipt only
when a stored fingerprint exists and differs. Native rows without stored fingerprints cannot be compared;
existing events are never overwritten. Source pricing and provenance are kept,
including an unknown price version of zero, without applying local prices. Apply refuses negative or over-ceiling list costs and capture-source or fidelity claims outside the contract-pinned v1 local-collector policy. Rows before the permanent retention watermark are skipped as final, counted in the receipt, and never recreated; later rows still apply successfully. Identical content staged as a new preview records an apply audit referring to the original batch.
New previews record a replay identity that excludes bundle issuance time while
leaving the wire digest unchanged. A fresh export of that same content returns
the original batch identity and lock times after destination erasure and
recreates no named rows. A fresh batch rechecks name-free totals against the
current destination k and preserves sticky withholding in storage and its
receipt. Exact retries of an applied batch retain their frozen receipt.
Before returning a replay receipt, apply removes the erased person’s plaintext
batch-person and mapping rows. Mapping to an erased target also leaves the
source person behind with the nameless erasure report line, even after remapping.
Older applied batches with cleared staging have only their old issuance-dependent
digest, so this identity cannot be reconstructed from that digest. Retrying
their original batch ID still returns its receipt. Presenting the exact original
bundle also preserves the receipt and records a stable identity for later pulls;
older pending previews acquire that identity when applied. Changed content is a
new import, subject to the erased-person record below.
Imported event rows cannot establish local census evidence even when their source
provenance says bound_upload or server_watch. New imported people count zero
until an upload bound to their own credential on this server; local watcher
activity alone does not bind them. Imported people receive no viewer declarations,
keys, or claim codes through apply. Existing mapped people's local declarations
are preserved. A mapped identity with local history but no org-person row stays native when registered at the destination. Imported spend stays in the Unknown identity bucket until that subject has qualifying local evidence, then joins the subject in live totals and future seal populations. Imported rows never supply census evidence; existing sealed populations and apply receipts remain frozen. Imported event provenance is included in the person's DSAR;
erasure deletes their named events through the existing subject erasure path.
Company name-free totals remain separate from named histories and the local
census, in external_totals at the destination node. They appear as Other in the
apply summary. Each source cohort must independently meet the destination k,
floored at three; otherwise figures are null and no count is disclosed. Re-imports
persist the highest threshold and any withholding, so a lower k cannot release a previously withheld total. Small
cohorts are not pooled to claim k. A changed or overlapping total from the same
source is refused atomically rather than added twice. These are source-reported
aggregates: the company cannot verify distinct people or dominance from the
name-free data. Source sealed and price-version metadata are retained; apply
does not turn a generated snapshot total into an authoritative local seal.
Source event identity aliases are retained for deduplication, including identities whose content matched an existing event. They are exported only for their mapped subject, erased with that subject, and pruned with the canonical event or an aged subject. They never supply census evidence or keep an identity live for retention.
Federation acceptance and refusal records
Federated totals are reported by them, not re-checked here. Central accepts name-free frozen aggregate periods from explicitly allowed, authenticated servers, each confined to one place in the tree. The wire contract excludes person fields and population counts. The child must establish k, dominance and complementary suppression using its sealing audience; central cannot reconstruct or verify the underlying people. Federated people supply no local k cover. Federated nodes remain opaque. Under the S83 named D5 exception, a federated node moved mid-month attaches its opaque total to its month-end parent.
Acceptance is append-only: an already accepted period cannot be rewritten or revised provisionally. Identical periods re-sent under a later sequence are skipped; every other overlap with accepted history is refused, including shifted or spanning bounds. Revoking or reissuing a federation token retains accepted history. HTTPS is required before credential lookup, including loopback, with forwarded HTTPS trusted only from configured proxy peers. A leaked federation key permits false totals in that one place until revoked; it grants no report reads or access to another server's place. Child-side credential storage must follow D8: a 0600 host file and a host-pinned central URL; this API does not configure an upstream.
Refused pushes are recorded without the presented token or any value derived from it. Records contain UTC time, a source-address class (loopback, private, public or unknown), a fixed reason, and the authenticated server id when known. Raw addresses and body-claimed identities are not stored. Root alone sees source classes. A top-level node reader sees only identified servers in its branch, plus unassigned attempts and hourly reason counts. Other node keys cannot read this log. Detail is bounded to 90 days and 10,000 rows; overflow retains only hour, reason and count, for 90 days. Refusals buffer in bounded process memory and flush on the existing 60-second worker and shutdown. The worker prunes durable detail and hourly counts even with no new refusals. A crash can lose unflushed entries: normally the last minute, longer during flush failures. No refused caller triggers a database write, failed flushes retain a bounded retry batch, and read-only serving performs no refusal writes. These records never feed the local census or retention_live.
People-page handoff and card notices (#1449)
The People page displays a newly issued claim code or invited manager's node key only in its transient result. Hide, same-tab navigation, pagehide and session loss clear it; it is never placed in localStorage, sessionStorage, browser history or a URL. As with the Keys page, opening a link in a new tab leaves the original page's shown-once result visible until it is hidden or that page is left. Recovery controls are root-only and use the existing claim-code API; a root sees "code used by someone else" from existing claim-reuse audit rows. This is a reuse warning, not proof of the redeemer's identity.
The People page distinguishes the stated leave date from the server-click date when they differ. The input defaults to and permits dates through the UTC day. Membership and keys end at the click; the stated date changes no figures. Erasure requires root and an exact typed person ID, separately from Mark as left. Viewer declarations notify the subject atomically in the API. The coaching-card notice panel displays those declaration snapshots and later effective-access grants and removals, with their recorded timestamps. It reads notices without writing read markers. These dated notices describe changes when they happened; they do not grant card access.
Live vendor identity review
Only root can explicitly fetch the People page's unmapped vendor users. The server reads current Copilot billing seats and Cursor team members with the configured collector credentials, holds the lists only for that request, and joins them to existing mappings and exact roster/alias matches. Opening People does not fetch a list. No fetched unmapped login, email or vendor ID is written to a table, cache, log, audit detail, error text or metric label. The GET works without writes, including under read-only serving. Vendor failures return fixed messages and no stale list; CSV import remains available.
The browser keeps review data only in page memory, clears it on navigation or sign-out, and uses no persistent browser storage. Root acceptance or CSV import creates the existing developer-keyed vendor mappings and one audit row per pair; those records retain their existing erasure and access-export coverage. A suggestion alone creates no mapping and never makes earlier usage count.
Per-node sealed reports (#1441, P18 + P19)
Each month stores one final shared-audience decision for the complete dated
forest in every aggregation mode. Developer-mode org publication reuses the
shared team sealer; developer/self live routes retain their existing audience.
Set seal_from (or use an already pinned seal floor) to arm that background
publication. In v0.7.0, node and shared read keys receive sealed calendar months
only; live month-to-date increments, arbitrary windows and quarter/year aggregates
are not published on these routes. Compare places stored months side by side. Tree
moves and late data never recompute an earlier month's folds. Per-node seals
retain the shared floor of at least three even in evaluation mode. The per-node
migration advances schema 21 to 22, followed by persisted settings at 23 and
vendor mappings at 24, consented graft staging and mapping erasure at 25,
import apply at 26, mapping-audit source redaction at 27, and erased-import
identifiers at 28; older binaries must refuse the final schema.
A canonical person with directly attributed activity in two or more dated node slices is a mover, including activity attributed directly to an internal node. Zero-cost active slices participate; inactive slices do not. Contributors are counted once in the earliest active membership slice, with slice-start, activity timestamp, then node ID as the tie-break. Cost and points stay with their dated membership. Ancestor counts come from an independent distinct-person read over that selected dated closure. Movers supply no dominance or equality cover, and only single-slice people with qualifying D4 evidence establish the safety k floor. Published contributor counts retain the first-active rule. No mover flag or count is served.
D′ promises an uncertainty interval: a target remains uncertain by at least its
node's single-slice cover. Absolute per-person figures supply that cover even
when corrections are signed. The equality guard does not rule out every
numeric coincidence among unrelated populations: cousin rows with no movers
may sum to one person's figure. This is a documented residual, pinned by the
known {5,2,3} and {50,-20,-20,30} case, whose cousin totals sum to 50.
Cross-month linkage and people using unlinked identities are outside this
per-month, canonical-identity promise.
The caller checks every published row, fold and residual, grows the forced withholding set deterministically within the node-count bound. A node seal it cannot verify becomes an unavailable month; its legacy sealed body still commits unchanged. Intermediate decisions are never stored. Per-node rows and fold decisions refuse updates, deletion and replacement while their sealed period exists; whole-period retention removes them with that period.
Named D5 exception — federated mid-month move: an opaque federated month's total attaches for the entire month to its month-end parent. Native figures continue to follow dated placement. In v0.7.0, federation is sealed-month-only until the separate exact-audit month-to-date work is ruled and built.
Authorized subtree reads accumulate an in-memory hourly tally keyed by tenant,
opaque credential ID, selected node and canonical month. Shared read-token
traffic uses the fixed label shared-read-token; no token-derived value is
stored. A background worker flushes every 60 seconds and after HTTP drains at
shutdown, adding counts atomically. Failed batches merge back. After two
consecutive failed flushes, subtree reads return 503 until a flush succeeds.
A crash can lose up to 60 seconds of unflushed reads during healthy operation.
At the 4096-key cap, additional keys can use one reserved overflow bucket with
sentinel node/period values, retaining its tenant, credential and hour. The
combined pending and in-flight key set is bounded to 4097 keys. Reads needing
another key return 503 without a partial tally; existing keys continue to count.
Overflow uses a bounded warning flag, with no credential identifier. Read-only servers mount no subtree report routes and
start no tally writer. Unauthorized and refused reads create no tally entry.
Every shared-token legacy publication in the stored monthly body is fixed public in the node seal, including its named group rows, emitted residual and company Total. Legacy compare rows coarsen those same public rows. Constraints use canonical person-slice atoms, never node names: an exact node stays fixed; a sibling union forms a visible synthetic parent; other unions form a visible child of their lowest common ancestor and withhold every intervening node. Cross-surface cover distinguishes person-slice atoms even when a legacy label changes while the structural node ID stays stable; such split people supply no cover. Ancestor remainders exclude the legacy row's atoms. Legacy rows cannot be folded or withheld by the node gate. The runtime uses bounded parent-minus-visible checks and D′ cover; exhaustive signed combinations remain a test oracle. If immutable legacy rows alone fail the privacy check, the month stores no node or fold rows, records typed unavailable status and logs a warning about the legacy surface. Node reads return 404; federation must treat this as a missing month, never a zero total. Legacy reports and their bytes are preserved.
Spend without dated placement belongs to a reserved Unplaced child of the root.
Its sentinel ID cannot be created, adopted, renamed or moved as an org node.
It follows the same bound-person census, D′ and complementary checks, including
a separate slice when someone is placed for only part of the month. The root
includes this spend with the legacy Total's unattributed-spend exclusion.
The child is flagged unplaced, has no team name, appears only at the root,
and must never be sent upstream as a named team.
Deterministic node failures permanently record a constrained operator cause:
legacy, g_prime_refused, data_defect, no_tree or panic. The node step
uses a savepoint and panic recovery; an unavailable audit never accompanies node
or fold rows. The legacy month commits unchanged and the next month proceeds.
Database and I/O failures roll back the whole transaction for retry. The bounded
privacy fixpoint runs before the write lock; the transaction verifies byte equality
with the stored legacy body before writing node rows. Unsupported non-empty
legacy publishing keys and named labels without atoms make the node month
unavailable with cause legacy.
Only root’s /api/v1/org/report/status and the typed federation MISSING seam
expose causes. Report, compare and CSV share one 404 for unavailable and never
sealed. legacy logs a warning about the legacy surface; data_defect and
panic log operator findings at ERROR. g_prime_refused and no_tree log their
own cause. Re-sealing unavailable history is not supported or promised.
This server treats federated nodes as opaque: they expose no figure or count,
never fold with native siblings, and supply nothing to local ancestor totals.
P30 adds their remote totals separately. Empty local rows may show zero local
people and figures. Developer-mode seal controls operate on the shared team
view at max(k, 3). Shutdown bounds the org writer join before the final read
audit flush. Shared read tokens receive the same IP lockout and 60-per-minute
credential budget as root/node reads.
Central rollups reuse the stored local node decision and accepted federation periods. Federated totals are reported by them, not re-checked here; central does not subtract inside an opaque server subtree. Federated ancestor totals use month-end placement; pre-admission backfills use the first place. Moves preserve historical ancestor amounts while current scope controls access to server details. Whole-company figures cover one identical, complete closed calendar month on every included server. Missing local node reports, gaps and late servers cannot become zero totals. Withheld local or federated components keep the combined figure withheld. Revocation preserves accepted history and excludes that server from current server counts. A revoked server's missing month is excluded when revocation is at or before month start. The shared read-token report carries no rollup, federated total or server count. Node readers' combined totals are labeled with the selected subtree path; root readers retain "Whole company". Totals JSON/CSV reuse the in-memory subtree tally and perform no request-time database writes.
Reporting coverage is scoped to the root/node reader's subtree in every
aggregation mode. Its native evidence is a valid own-credential bound-upload
receipt, with the census's roster, alias and bot exclusions. Shared and
imported-unbound uploads cannot make a local group reporting. Native groups below k or complementary-withheld show status only, including
in CSV; even setup/stopped reasons are omitted. Shown groups may expose an
aggregate UTC report date, receipt-day end boundary, next daily receipt date
and setup/stopped reason after the full dated forest passes the census and
complementary gate. Monthly details require a shown stored node decision;
months without receipts use that stored gate, retaining status-only at a higher
configured k. Live and monthly modes share the same reason definition.
No native group publishes contributor counts, even above k. This also prevents any new sealed-month team count from
composing with legacy or node amounts. The N-of-M summary counts group statuses,
never contributors. Server rows describe the configured reporter's schedule
and acceptance state, not individual freshness. Coverage is a read snapshot
with no request-time database writes. It reuses the report/totals D9 in-memory
subtree tally and fail-closed behavior and is absent under --read-only.
Unended monthly requests return 400. Live server Current uses the end boundary
of the latest month whose end plus the shared 14-day default seal grace has passed; a complete
accepted horizon remains Current even when its last push is over seven days old.
The Organization page displays sealed cells only. Withheld figures and counts stay “Withheld” for root, “Withheld — ask your admin” for other readers. Published children remain visible under a withheld parent; the server suppresses complements. Its renderer omits contributor columns and counts for read-only node principals, including comparison and fold rows. Where provided, the API’s “Reported” or “Not reported” status replaces withheld people counts. Unavailable and never-sealed months both display “not available”, clear the previous figures and CSV link, and never become zero. Comparison places stored months side by side without computing a multi-month total or changing suppression.
The native “Teams on this server” rollup uses published root cells once, including dated slices, and never adds descendants to their ancestors. A historical cell whose parent was redacted is not treated as an additional organization root. If any required cell is withheld, this subtotal is withheld. When the current subtree contains an opaque federated place, the native subtotal is structurally “not available”. The report API also provides separate native/federated rollup fields, as described above; this page uses its existing sealed-cell renderer and does not derive a native remainder by subtraction or invent federation totals. Federated cells show “Read only · managed on its own server.” without figures or counts. Unplaced appears only at the tree root, labelled “Unplaced” in a distinct flagged row without a team contributor count. Root readers default to the structural company root when there is one. Organization page state and responses are never written to browser storage, and stale requests cannot repaint after navigation or session clearing. The shared sign-in shell stores only a bounded return path in tab storage, as described in Identity and access; sign-out and denied pages clear it.
For anonymized sealed months, /time-to-merge serves median and p90 stored once
from the seal snapshot inside the seal transaction; every count is null with a
null reason, including exclusions and evaluation contributor counts. Months
sealed before TTM storage return null durations with
sealed_before_ttm_stored and are never backfilled. Live windows are unchanged.
Child federation exports (P28 + P29)
Federation exports stored shared-audience node months only, even when the child serves evaluation or developer mode. It never runs live scoring for a push and never exports person identifiers, evaluation rows or population counts. Stored withholding and fold decisions are retained; figures for withheld groups remain zero on the wire. Federated nodes with NULL local figures are withheld with zero figures on the wire; their descendants remain opaque, and the month remains eligible for a push. Unplaced is omitted from named groups and fold membership while its contribution remains in the sealed root total. An unavailable node month, regardless of cause, is MISSING upstream: no period is sent for that month, never a zero or empty present period.
A pending body contains aggregate figures and org names, but no credentials; it is stored durably so retries and restarts cannot revise a frozen publication. The central treats child figures as reported by that child, not independently rechecked. A connection test sends no aggregate body and reads only central status. The host bearer remains in a 0600 file and is re-read for each request; responses, audit and persistent push errors contain no credential-derived data. Pasted replacements stay in a 0600 temporary file until settings and audit commit. Database failure removes that file and preserves the old bearer. Rename failure is reported after commit and the temporary file is removed.
Federation and coverage display (P31 + P33)
The Federation page labels outside totals “reported by them, not re-checked here.” A missing or unavailable closed month displays no monetary amount; withheld totals also show no amount. A Company totals 404 renders None for all three rollups and leaves the CSV unavailable. Refusal detail and hourly roll-ups are bounded separately; roll-ups retain their counts. These pages add no per-team PR or person counts for sealed anonymized months. Coverage groups below the privacy floor show only Reported or Not reported, with dates and other details withheld. Reporting-detail checks use distinct reporting people per dated cell as their figures, preserving the existing equality, dominance, and complementary gates. Safe siblings retain their detail; a small group's necessary complement stays protected. Monthly detail also requires the sealed group to be released. CSV uses the existing API suppression rules. Live server status is separate from the historical monthly report, and revoked servers are excluded from the current server denominator while their accepted history is retained.
Federation allow/reissue responses reach only the transient secret result.
Browser persistence, URL parameters, and inventory rows never receive the secret.
The child token paste field uses autocomplete="one-time-code" outside the
schedule form. Pasted child tokens clear after submission and on navigation;
the existing host file service owns atomic 0600 replacement and re-reads the file for every push
and connection test. The page displays only the host-pinned central address.
v0.7.0 organizations: who can do what
The credential scope map
links the rules for creating people, issuing keys and reading reports. The
people and key inventory boundary
also states the limit that a node can pad its own subtree. Padded people with
qualifying bound evidence also lend k to ancestor rows, so the figures of a
neighbouring real sub-k group can publish in an ancestor or fold. Anyone who can read that
ancestor and knows the invented amounts could subtract them to learn the
remainder. Per-person and fold dominance gates still apply (code:
internal/scoring/treesuppress.go, SuppressTree, treeFoldSafe;
internal/scoring/sealtree.go, sealPeopleSafe). Read it with the
claim-code custody limit: k is
not proof that an administrator has enrolled distinct real people.
One server per developer. Choose one reporting server for each person's live data. This is a deployment rule, not a check across servers. Federation supplies no per-person records for central to match. Central cannot recognize the same person reported by two children, or by a child and its own local capture. Reporting that person's work through both paths can count it twice. See federation acceptance for why federated totals are reported by them, not re-checked here.
When moving history between installations, use the graft consent and explicit mapping rules. The graft snapshot boundary explains why withdrawing consent or erasing the source cannot retract a copy already delivered to another server.
The child upstream SSRF residual covers outbound requests to the host's configured central origin. For a separate deployment review, see Legal & Privacy. TIER is built for coaching, not for evaluating developers. Per-person figures are not accurate enough to judge an individual.
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.
See the root API commands for one-identifier clear and counsel-refusal clear-all.
Import recording warnings (EXTREME)
Each source pull on both servers, and each destination apply, records its own
attempt before taking the writer lock. Cleanup retries on a detached context for
up to five seconds without holding the import-holder mutex during database-lock
retries. The last holder releases waiting captures after cleanup completes. Abandoned attempts and failed live captures set a warning in
the existing settings table, with the earliest and latest UTC dates when events
may have been lost. Only overlapping report periods carry
data_quality.live_capture_may_be_lost; org reports, totals and compare retain
the warning stored with each sealed month. Later losses do not change old seals.
Read-only serving re-reads the warning on every report. Busy startup recovery
warns with a sanitized error class and serving continues. Pending warnings retry
and writable close flushes them. No clear command is needed for later periods.
The dashboard says: “TIER may have missed some AI costs in this period, because a server restart or an import interrupted recording.” At most 10,000 captures run after responses concurrently; above that cap the response waits for capture. The erase dialog and success receipt disclose the retained erased-import record. Consent alone on a source server is not an import trace for that record.