TIER docs

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, and message.{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 extract id/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.

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:

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.

Paths under /tmp and ~/.claude are ignored.

What it stores.

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.

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

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:

  1. Stop tierd serve.
  2. As the user that runs TIER, run tierd backup --db <path> --out <new-path>, where <path> is the --db you give tierd serve (default ~/.tier/tier.db) and <new-path> does not exist yet. tierd backup writes the copy with VACUUM INTO, which rebuilds the file from its live rows only, so the copy has no free pages and no deleted rows.
  3. Move the old database file, and its -wal and -shm files if they exist, out of the directory. Rename the copy to the old database's name.
  4. Start tierd serve and 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:

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.

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.