TIER docs

Developer access and upload identity

The #1370 identity batch replaces the blanket refusal of real-data developer mode. serve --aggregation developer requires a nonempty admin API token, an --admin-viewer operator identifier, and at least one active permitted viewer credential in the database: a legacy subject-bound viewer credential or an eligible personal/node key for a current, mapped person. Team and division modes remain first-class. The synthetic tierd demo keeps its startup exemption and invented data.

Identity introduced schema version 13; opening the database now upgrades it to schema version 28, including personal-key claim codes (#1425), vendor mappings, consented graft staging, import apply, and mapping-audit source redaction. Back it up before upgrading; older binaries refuse this schema, and rollback requires restoring the backup.

Credentials now carry a random, non-secret credential id. Uploads record which credential sent each event and when the server received it; credentials also record their last authenticated upload and last upload that added data. Nothing derived from the secret is stored in these upload records or shown.

The store supports viewer, upload, root, node, personal, federation, and export credential kinds. Node keys bind a node and a manage or read capability; federation keys bind a node and server id. Export keys have an expiry and can be consumed once. Inventory records include the minter credential id or a host-cli/break-glass sentinel. Root credentials can be minted on the host with tierd tokens issue --root. The credential-kind CLI change added no web routes; the organization web routes now available are described below. Existing viewer and upload credentials keep their ids and verifiers.

The pure tier-federation/v1 wire contract (#1430) carries a server UUID and org-node aggregates. It contains no credential or person fields, and its decoder refuses unknown fields at every level. Server and org-node ids require canonical lowercase UUIDs; duplicate group ids within a period and self-parent groups are refused. The Child upstream administration section describes credential handling and network routes: the child keeps the federation token in a 0600 host file, re-reads it for each push, pins the central address on the host and requires HTTPS without redirects.

The identity layer is batch 3 of #826. Batch 5 (#1372) adds the coaching card: GET /api/v1/developer/{id}/coaching and the existing named score route return it through the same subject authorization and audit boundary. Its builder takes no viewer parameter. Each new request adds a log entry before the builder reads the card snapshot. No card opens without active subject viewer credentials and positive verified spend. The dashboard opens one subject at a time, with no shared-scale named-developer work-type panel.

Batch 4 (#1371) adds the bounded raw-data retention engine. The v0.7.0 release gate requires identity, retention and the card together; all three are now built. See the coaching card retro. TIER is for coaching AI practice, not pay, promotion, performance reviews, discipline or dismissal. Anyone with host/database access can read the database; these controls apply at the API.

Import declarations before capture

Start an administrative server in team mode to import your map, then stop it to issue credentials locally. Import the team map before starting pollers. An unmapped vendor row stays team-only even after a later import or alias edit.

The existing CSV import accepts optional manager, skip_level and works_viewers columns. works_viewers contains semicolon-separated viewer identifiers. Use identifiers, not display names. Empty skip-level means off. An absent CSV column or omitted JSON field preserves the stored declaration; an explicit empty cell/string clears it. A team has one declared manager. Team membership never grants access; a works-agreement declaration cannot grant a teammate access.

developer,team,division,org,manager,skip_level,works_viewers
maya,platform,engineering,example,maya-em,,agreement-viewer
priya,platform,engineering,example,maya-em,,
tierd hierarchy import --server http://127.0.0.1:8080 team-map.csv
# On the server host, with access to its database:
tierd tokens issue --db tier.db --from-team-map --all --out-dir /secure/tier-keys
tierd serve --db tier.db --aggregation developer \
  --api-token @/secure/admin-token --admin-viewer operator-id

Token management runs on the server host through tierd tokens; viewer-tokens remains an alias. That legacy CLI remains separate from the /keys dashboard, which administers org keys through POST /api/v1/org/keys. Bulk issue --all --out-dir DIR is the first-time bootstrap. Add later hires with issue --from-team-map --subject ID. Before bulk issuance creates any file or credential, it checks every requested row for permission and an active label; any failures refuse the whole run and list each failing row on stderr. Bulk issuance requires --all --out-dir DIR. The directory must not exist, or must be empty, owned by the caller and mode 0700. A new directory is created with mode 0700. Each credential goes into a separate numbered 0600 file, created exclusively: existing files and symlinks are never overwritten or followed. Bulk stdout contains only subject/viewer/kind/label/file mappings, so you can transfer each file to its owner through your normal secret channel. Each file contains just the token and can be used directly with --bound-token @FILE. Bulk private-file issuance requires a supported Unix platform. macOS ACLs are not checked on --out-dir; the directory checks cover ownership and mode bits.

For a single subject, issue --from-team-map --subject ID prints one tab-separated subject/viewer/kind/token record per declared viewer to stdout, with a stderr banner: “shown once — store it now”. Keep this output out of logs. Add --viewer ID to select one viewer. The database stores SHA-256 hashes of random 256-bit credentials, never plaintext tokens.

Root bootstrap needs no team map, but requires an existing TIER database schema. Use --db to select the server database; a missing or schema-less database is refused before initialization:

tierd tokens issue --db tier.db --root --label recovery

issue --root mints one root credential with minted_by=host-cli; its host holder is recorded as host-cli and its viewer is empty. Its label defaults to root. An active root label cannot be issued again, including by concurrent issues, which check and insert in one transaction; revoke its credential id before issuing a replacement. The same subject/viewer/kind/token stdout record and show-once stderr banner apply; only the SHA-256 hash is stored. Every root issue also prints its non-secret credential_id on stderr; retain it for revocation. If stdout fails after minting, stderr gives tierd tokens revoke --credential-id ID --db PATH, naming the same database, shell-quoted. Store the secret immediately through your normal secret channel and keep stdout out of logs. Only --db and --label may accompany --root; subject, viewer, upload, team-map and bulk options are refused. Root secrets go to stdout only; write a captured file under umask 077. Root minting is host CLI only; this command does not enable web sign-in or new API permissions.

For viewer and upload credentials, labels are device handles. The default label is the viewer identifier; supply --label laptop or --label desktop to hold several active credentials of the same kind. An active label cannot be issued again for the same subject/viewer/kind. Labels must be nonempty, at most 256 bytes, and contain no leading/trailing whitespace or control characters. Credentials always stay bound to their original developer. Viewer credentials open reads; --upload selects upload-only credentials.

# Issue two upload credentials for Maya's devices (each is shown once):
tierd tokens issue --db tier.db --from-team-map \
  --subject maya --label laptop --upload
tierd tokens issue --db tier.db --from-team-map \
  --subject maya --label desktop --upload
# Inventory shows metadata only, including revoked rows and upload freshness:
tierd tokens list --db tier.db
tierd tokens list --db tier.db --subject maya --upload
# Revoke exactly one credential using its non-secret id from list:
tierd tokens revoke --db tier.db --credential-id CREDENTIAL_ID
# Revoke or replace just the laptop credential; desktop keeps working:
tierd tokens revoke --db tier.db --subject maya --label laptop --upload
tierd tokens reissue --db tier.db --subject maya --label laptop --upload
# Issue a manager's named viewer credential:
tierd tokens issue --db tier.db --from-team-map \
  --subject maya --viewer maya-em --label manager-laptop
# Compatibility: omit --label to revoke all labels for this subject/viewer/kind:
tierd tokens revoke --db tier.db --subject maya --viewer maya --upload

revoke --credential-id ID leaves every sibling credential untouched, even when labels match. Repeating it succeeds with “credential already revoked” and preserves the original revocation time. An unknown id fails with “unknown credential id”. Only --db may accompany this selector; it cannot be combined with subject, viewer, label or kind selectors. Like --root, it refuses a missing or schema-less database rather than creating one, and reports an unreadable database as “cannot read database”.

With a label and no --viewer, the viewer defaults to the subject. Reissue revokes all active credentials under that exact subject/viewer/kind/label and issues one replacement in the same transaction, including when a legacy database has duplicate labels. Without --label, reissue revokes every active label for each selected subject/viewer/kind and issues one credential under the default viewer label. list flags duplicates with the single-token status duplicate-active:2 (or the actual count). Root duplicate rows give the hint “revoke with tokens revoke --credential-id ID --db PATH” on stderr; root reissue is refused. The column order is subject, viewer, kind, label, credential_id, issued_at, revoked_at, last_seen_at, last_data_at, status, node, capability. Other status values are active, revoked, consumed and expired; used or expired export keys are not shown as active. Unbound nodes and capabilities, and unknown timestamps, show an unquoted -. Real node values are quoted like subjects and labels, so a node named - shows "-". No secret, hash, prefix, suffix or secret length is shown.

If issuance or output fails partway through, previously issued credentials remain issued. Inspect list and revoke/reissue the affected viewer or upload labels; for root credentials, revoke by credential id and issue a replacement. Bulk files already written remain private, and retrying into that nonempty directory is refused. Before retrying bulk issuance into a fresh empty directory, revoke the already-issued labels first, or use issue --subject ID for the remaining hires. The server also refuses to reuse an issued identity token (even a revoked one) as an admin, shared read, ingest or metrics secret. Removing a viewer declaration denies their credentials on subsequent requests. Labels, credentials' identity fields, view logs and remap logs are personal data included in erasure/export; secret hashes and operator credential fingerprints are excluded from exports.

Read boundary and view log

A viewer credential opens GET /api/v1/scores/{subject} only for its bound subject. Every permitted viewer receives the same response bytes for the same window and database state. The subject's self credential can read GET /api/v1/developer/{subject}/views. A manager cannot read that log endpoint. With a personal sign-in, My card reads GET /api/v1/me/views to show only the caller's own coaching_view_audit rows: exactly viewer, label, route and ts (time). The page shows the viewer as well as the label. The subject comes from that identity; caller-supplied ids and all query parameters are refused. Root, node and shared tokens cannot use this path. It writes nothing and works in team, division and developer modes, on read-only servers and without verified spend. A coaching card needs verified spend in the window and still returns 404 without it. An empty log says “Nobody has viewed your card yet.”

In real developer mode, administrators are named readers: their named reads, identity/finance reads and compliance exports are audited too, including exports through aliases. In team or division mode with evaluation off, a write-token export is not logged. The legacy DELETE /api/v1/developer/{id} writes no audit row; the root DELETE /api/v1/org/people/{dev} writes a name-free person_erased audit row.

The audit row is written before the response is built, and commits before any response is released. Admin discovery, audit and construction share one transaction/snapshot so concurrent remaps cannot add an unlogged subject. A failed write refuses the read with 503 and Retry-After. Named reads are limited to 60 per minute per credential. --view-log-days sets the log's retention (default 90, range 1–365), capped at the developer retention window whenever developer retention/pruning is enabled; it is pruned at startup, on audit writes and daily. Retention scrubs expired credentials and remap metadata and clears incoming viewer references when a subject has no retained activity or membership.

On real developer data, /scores contains no developer identifiers or named rows. Totals require one stored, sealed UTC calendar month, fleet-wide. Omit bounds to select the latest sealed month; an installation without seals returns a withheld total. Arbitrary/nested/overlapping windows and any repo or before parameter are refused with 400. Seal months in team/division mode before switching to developer mode. A total is published only when the credential can see every contributor, or at least k verified contributors with positive spend remain hidden (default k=5). Each hidden contributor must supply at least 1% of hidden spend, and none more than 50%; otherwise the total is withheld. Unknown attribution cannot establish that every contributor is visible. Visibility requires both a current declaration and an active subject/viewer credential; the shared token also applies this check to configured viewers so it cannot bypass their differencing guard. No team slices, per-developer compare or fidelity rows, raw bulk event/outcome/quality exports, are opened to viewers. The manifest returns only name-free identity-mode discovery, without amounts, row counts or digests. /metrics is operator-only because successive scrapes can reveal individual changes. Named bars on a shared scale are removed on real data; the synthetic demo keeps its descriptive panels. Any future "not on this card" field must be a share, never dollars.

All API routes, including admin reads, have an explicit credential policy; unclassified routes fail closed. Health/version probes remain public. Compliance exports still require the admin credential and are recorded in "Viewed by".

Each developer ships with their own credential

tierd ship --server https://tier.example --repo /path/to/repo \
  --developer maya --bound-token @/secure/maya-upload-token

TIER_BOUND_TOKEN is the environment equivalent of --bound-token. An explicit --api-token takes precedence over that environment fallback. With a bound credential, --developer must match its subject; omit it to let the server stamp the subject. The server stamps the subject on every event, including events omitting developer, and refuses the entire batch if an event names a different developer. Shared ingest and admin credentials upload team-only events; shared ingest can be retired as described below. Clients cannot supply identity provenance: a posted provenance value is ignored.

token_events.provenance is server-written:

Provenance Named eligibility
bound_upload Frozen credential subject
vendor_attributed Frozen root-authored mapping subject; counts only while its mapping evidence is valid
server_watch Only the local watcher running under --watch-as
shared_upload Team totals only, including unbound local collectors/proxies
admin_cost /costs; team totals only
legacy_unverified Existing/unclassified rows; team totals only

Unmapped vendor identifiers and real service accounts stay team-only. A vendor event receives its named subject and mapping ID inside the insert transaction, from a root-authored mapping. Alias/team-map edits never change that binding or promote an earlier event, including on replay. Old alias-stamped vendor rows have no mapping ID and remain uncounted. Alias/team-map edits are audited. Shared uploads cannot change verified rows through an idempotency-key replay.

Root-authored vendor mappings (#1476)

Root alone may create, list, End or Void mappings through /api/v1/org/vendor-mappings. Browser requests use the root session, exact same-origin checks and CSRF protection for mutations; scripts may use root bearer credentials. Node, personal, viewer, upload and static tokens receive 403. The store independently re-resolves root authority in the transaction.

Create accepts a pairs list of vendor, vendor_user_id, and developer. The developer must already be a canonical roster person who has not left. Alias names are refused as mapping targets. Bulk creation accepts 1–200 pairs. Copilot IDs are decimal GitHub numeric user IDs, never logins; leading zeroes are removed. Cursor IDs are trimmed, lowercased emails. Empty IDs, the literal "null", and recognizable Cursor service IDs without @ (sa_… or serviceAccountId…, ignoring case) are refused; emails such as sa_jones@corp.com remain valid. Normalized IDs are limited to 320 bytes for either vendor; control characters are refused. Only one active mapping can exist for each vendor ID.

Both vendor pollers read all mapping partitions once per pass, including ended and voided rows; day membership follows the effective interval and excludes voids. Copilot resolves numeric IDs through the current billing seats list; renamed logins follow the same ID, and reused logins require a mapping for their new ID. Alias-only polling continues for spend and supplies no counted identity. Cursor uses the mapping normalizer on usage emails; "null" and service/automation accounts supply no counted identity. The vendor ID travels only as transient insert input: it is never a token_events column, serialized event field, log value, error detail or metric label. Only the opaque vendor_mapping_id binds event evidence; mapping-specific reconciliation keys contain that opaque mapping ID, never the vendor ID. Copilot installations need organization GitHub Copilot Business: read in addition to billing access; accept the App's updated permissions after upgrading. A missing seat permission disables mapping polling and appears in the credential probe; alias spend capture continues. When a mapped ID is absent from a successful seats read, its active or ended, non-voided partition preserves its stored rows exactly; the organization remainder subtracts that spend and the day can settle. A mapping with no stored rows and no seat contributes nothing. A failed seats read keeps days with stored non-voided mapping partitions incomplete and unsettled for retry.

The server sets effective_from to the next UTC midnight. Usage from before that date never counts. Mapping creation never promotes an already inserted event. Each pair creates one org audit row; bulk creation commits all mappings and audits in one transaction, or none.

End (POST /{id}/end) closes the mapping at server time and keeps evidence from before that time, including events first received after End whose timestamps fall inside the mapping interval. Invalid vendor identities leave spend stored unbound. Void (POST /{id}/void) withdraws all its past counting too. Void is final, including for an ended mapping; a replacement needs a new mapping with a new effective date. Neither operation changes stored sealed report bytes. Both write an org audit row. Erasure hard-deletes the person's mappings, including ended or voided rows assigned to other people carrying the same vendor and vendor ID, and scrubs named audit detail matching both vendor and vendor ID to NULL. Access exports include every mapping with the subject's vendor and vendor ID, including rows assigned to other people, and its dates; events omit a NULL vendor_mapping_id to preserve pre-mapping export shapes.

The vendor-mapping migration is schema step 24, followed by graft at step 25, import apply at step 26, mapping-audit source redaction at step 27, and erased-import identifiers at step 28. Upgrading a database from before v24 stamps version 28 and prints a one-time vendor-mapping notice that older binaries cannot reopen it.

The pollers pass transient mapping identity for eligible usage. Root-mapped vendor events count from effective_from; alias-only events stay uncounted. Replay inside the lookback moves voided spend to the legacy partition without rewriting old rows. Erased, unknown partitions leave only their day incomplete; later days still ingest.

For root, the People page offers Unmapped vendor users, with a count only after fetching. Clicking Fetch vendor users requests GET /api/v1/org/vendor-mappings/unmapped; merely opening the page does not contact either vendor. Copilot reads billing seats keyed by numeric GitHub user ID; Cursor reads /teams/members using the configured Admin API key over HTTPS without redirects. Removed members, service accounts and the literal "null" are excluded. Lists exist only in request memory, with no database, cache, log, audit or metric retention. The GET is available in read-only serving and performs no writes.

Suggestions require a normalized login or email equal to a current roster ID or alias. Ambiguous exact matches receive no suggestion. Root reviews the vendor ID, current login and person before accepting one or Accept all N. Confirmation shows the unmapped GET's effective_from date when a list has been fetched; CSV confirmation before a fetch makes no date estimate. Saved feedback and the Status cell show the first counted date returned by the server when saving. Saved users leave the unmapped list using the server's normalized identities. The bulk route remains atomic: a conflict saves none of the batch, is shown on its row, and leaves the other rows available for review. Each saved pair has its own audit row. CSV paste uses columns vendor,vendor_user_id,developer, checks syntax and roster choices without echoing the file, and uses the same bulk route (200 pairs max). IDs are sent as raw trimmed text for server validation and normalization.

An unavailable or unconfigured vendor returns Copilot seat list unavailable — try again or Cursor seat list unavailable — try again, without its previous list. Missing Copilot seat permission returns Copilot seat permission not granted — accept the App's updated permissions. CSV import remains available when either live fetch fails. Overlapping fetches or fetches less than 60 seconds apart receive 429 with Retry-After; each vendor has its own 20-second deadline. End uses a plain confirmation; Void requires typing the vendor ID and warns that past usage stops counting. The person-erasure dialog includes vendor mappings.

Retire the shared upload token

http.shared_ingest: accept | warn | refuse is a server-wide setting. The default in v0.7.0 is warn. An explicit flag, environment variable or YAML key pins it (flag > environment > YAML); otherwise the saved Settings value wins over the default. Root saves take effect immediately without a restart. An invalid mode fails startup. Keep http.ingest_token configured in every mode, including refuse, so the server can recognize the retired credential. Without it, the old shared token gets 401 and counts toward the per-IP lockout.

accept and warn accept shared uploads. warn additionally warns at startup. Startup logs, tierd doctor --db /path/to/tier.db and admin-only GET /api/v1/admin/shared-token report requests authenticated with the configured shared ingest token: accepted/refused totals for 7 and 30 days and the last UTC day used (none when absent). Windows include today and the preceding 6/29 UTC days; last use includes older history. Each successful batch counts once, including replays, in the batch's write transaction. Rolled-back batches, admin-token uploads, bound uploads and unbound server-side capture do not count. Only exact retired-token matches increment refused; tally write errors are logged at WARN without changing the 403 or lockout behaviour. Unknown bearers cause no tally writes. Read-only serving skips the startup query. Before serve first opens a database without the table, doctor reports "unavailable until tierd serve has opened this database once" without migrating it. Startup and doctor retain a secondary shared_upload event-provenance count, which includes admin/local sources and must not be used to decide retirement.

refuse returns HTTP 403 with the existing JSON error envelope and the message shared upload token retired — ask your admin for a personal upload token when the bearer exactly matches the configured ingest token. The comparison is constant-time. These requests neither increment nor reset the per-IP auth-failure counter, so a laptop retrying its retired credential cannot lock out an admin on the same IP. Other invalid tokens still consume the failure budget. An existing lockout still returns 429. Bound per-developer uploads and admin-token uploads continue to work; admin uploads remain team-only.

Migration runbook: import the team map, then issue a personal upload credential for each developer with tierd tokens issue --db tier.db --from-team-map --subject maya --label laptop --upload. Transfer each credential through your normal secret channel and switch each laptop to tierd ship --bound-token @/secure/maya-upload-token. Leave the server on warn and inspect the tally using tierd doctor --db tier.db. Switch to refuse when the shared ingest token shows 0 accepted for 7 days: save shared_ingest: refuse in Settings, or change its explicit host pin and restart, retaining the configured ingest token. Shared-token history stays team-only; issuing personal keys does not promote old history. Legacy tierd tokens management runs on the server host. The /keys dashboard administers org keys through POST /api/v1/org/keys; My devices manages a personal sign-in's own device keys.

Bind the server's own watcher

serve --watch-repo /path/to/repo --watch-as maya requires maya in the imported team map. The projects directory (~/.claude/projects) must already exist. Every watched projects directory/subdirectory and evidence file must be owned by the serving user, with no group/other write permission. Existing insecure trees refuse startup; dynamic evidence opens and new project subtrees repeat the checks. Repository validation is startup-only, restricted to the repository root and attribution metadata: .git config, HEAD, refs, packed-refs and worktrees (including linked/common Git directories). Source files and dependency trees such as node_modules are outside that check. Symlinks in checked paths are refused. Platforms without the required descriptor-relative Unix opens refuse this mode.

Checks use fstat on opened descriptors. Directory traversal uses openat with O_NOFOLLOW; stat, fingerprint and parsing use the same opened file description. Path replacement after checking cannot substitute the bytes being parsed. Without --watch-as, local capture continues but its spend remains team-only. Other local collectors do not inherit the watcher's identity declaration.

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. See Privacy for the viewer rule and labels. Turn evaluation mode off and set k_anonymity to 3 or more (default 5) to share team reports. While k is below 3, shared viewers see team rows of 3 or more people.

aggregation: team
k_anonymity: 1
evaluation_mode: true # default off; admin and sole-contributor self access only

A self credential is issued with tierd tokens issue --from-team-map --subject X --viewer X. Its kind is viewer and its developer and viewer are both X.

Admin web credentials and sign-in (#1417)

The admin scope is available in every aggregation mode. GET /api/v1/me accepts a root, node or personal bearer key, or an in-memory session. Node keys always bind to the key's node, independently of the holder's membership or minter. Personal keys follow the holder's current node. Revocation or a holder's recorded departure refuses the next request. Upload, legacy viewer, federation and export credentials cannot enter this scope. These keys and sessions do not open legacy routes. The org administration APIs are described below. The dashboard shell is available at /signin (#1437, plan P20). Keys, My devices and Organization pages are available; page bodies awaiting their administration APIs remain empty and labelled with their page title.

Keys and My devices keep their signed-in navigation and banner when a list cannot load, with “Couldn't load the list. Try again.” on the page; a list 401 restarts sign-in. Key mutations show local copy only: 403/404 say “Your key cannot make this change.”; 429 says “Too many key requests. Try again in {n} seconds.” for a usable Retry-After delay, otherwise “Too many key requests. Try again later.” Duplicate-label conflicts retain the label guidance, while an already-revoked key says “This key was already revoked. Refresh to see the current keys.” and an inactive holder says “This person has left, so their key cannot be reissued.” Server error text is never displayed.

The full --api-token remains break-glass for existing routes and is refused on /api/v1/me and /api/v1/session: “the full admin token (break-glass) does not open these pages”. The shared --read-token dashboard path is unchanged. Existing tokens and requests without credentials retain their previous route behavior in each aggregation mode, and never open the new routes.

The first root, node or personal key insert, or an admin tree edit (naming a level; creating, renaming, moving or removing a node; adding a person; marking a departure), permanently arms a database flag in the same transaction. Nothing else arms it: not the upgrade migration, not legacy team-map writes (hierarchy PUT/POST, tierd hierarchy import, the demo baseline), not erasure and not the retention prune, so a no-token loopback install keeps serving after placing developers. Once armed, tierd serve requires a nonempty --api-token, including in team and division modes and under --read-only, and refuses a specific non-loopback --addr with no --trusted-proxy, where sign-in could never succeed (loopback and wildcard binds still accept loopback sign-in). The only exemptions are the synthetic demo and offline verify-report / score --outcomes; admin-web routes are absent there. Schema 19 prevents older servers ignoring this flag.

To sign in, send POST /api/v1/session with the key in the Bearer header, Content-Type: application/json, an exact Origin, and {} or {"return_to":"/org"}. Unknown fields and trailing JSON are refused. return_to must start with exactly one slash; backslashes and control characters are refused. The JSON response contains csrf_token and return_to. Sign-in bootstraps CSRF using bearer authentication and the Origin check; later cookie mutations require the server-held token in X-CSRF-Token and the session cookie. Org administration writes sent by a script with a bearer key and no cookie need neither (see below). DELETE /api/v1/session requires both, JSON content type and the same Origin check, returns 204, and expires the cookie. Bearer-only sign-out is refused. Cookie-authenticated GET /api/v1/me returns the same CSRF token in its X-CSRF-Token response header so sign-out works after a full page navigation. Bearer reads and refusals do not return that header. When Origin is absent, Sec-Fetch-Site: same-origin also passes; an explicit mismatched Origin is always refused. Missing both gives 403, including at sign-in.

Sessions are process-local memory only, with 60-minute idle and 12-hour absolute limits. Requests re-resolve the credential and holder from the database; a restart signs everyone out. Sessions do not write the database and work on read-only servers. Cookies use HttpOnly, SameSite=Strict, Path=/ and no Domain. Secure and __Host- apply whenever the effective origin is HTTPS; on permitted plain-HTTP loopback, cookies use HttpOnly + SameSite=Strict without Secure. The name is __Host-tier-session for HTTPS and tier-session for permitted plain-HTTP loopback, with the nonstandard serving port appended (for example tier-session-8080). Set, read and clear use the effective origin's cookie name; the other name is ignored on reads and cleared when setting or clearing the session cookie. On /api/v1/me and /api/v1/session, cookie and Authorization together return 400 and clear the cookie, so a stale cookie cannot block the next sign-in; a cookie alone never signs in (401). Legacy routes ignore the cookie entirely: it neither authorizes nor refuses a request there. Any cookie-bearing admin-web request with an explicit Origin that differs from the effective serving origin is refused 403 before session lookup or rate accounting. A GET without Origin keeps the fetch-metadata rule: a GET /api/v1/me carrying the cookie with Sec-Fetch-Site other than same-origin or none is refused 403 and does not refresh the session. Sessions are bound to their serving origin. Plain HTTP sign-in requires both a loopback peer and a loopback host; otherwise HTTPS is required. Only direct peers in --trusted-proxy (alias --trusted-proxy-cidr) may supply X-Forwarded-Proto and X-Forwarded-Host. Configure the proxy to replace these headers; untrusted forwarding and client-supplied XFF never prove HTTPS.

All admin-web responses carry Cache-Control: no-store, CSP and nosniff. Requests are limited to 60 per minute per credential, shared between bearer and session use. Unknown keys count against the per-IP failed-auth lockout; recognized revoked or departed-holder credentials neither increment nor reset it. A credential keeps at most 16 sessions (a new sign-in evicts its oldest), within 4096 per process; capacity refusal is 503. A root, node or personal key sent as a bearer to a legacy route is refused 403, unless the client IP is locked out, when it gets exactly the legacy answer an unknown token gets. Node credentials receive shared-viewer, k-floored subtree reports; /api/v1/me grants no per-person score access. Card access requires the separate live declaration check.

The shell learns sign-in state only from /api/v1/me, using its page allowlist and the approved role navigation. Root navigation includes Organization, People, Keys, Federation, Coverage and Settings. Managing node keys see Organization, People, Keys and Coverage; read-only node keys see Organization, Federation and Coverage. Personal keys see My card and My devices. The acting banner uses the returned holder, key kind and node path. Unknown roles or failed identity reads leave navigation and identity hidden. Direct navigation to a page without its allowlist grant keeps that URL and shows “This page is outside this sign-in.” with page content hidden, and clears any return target. Keys and My devices also require their role navigation grant. Import pages check the role before loading: Company import requires root; Source export requires root or personal. A wrong role clears the return target and returns to its first allowed navigation page. This allowlist gates every shell page, including auxiliary pages; no key kind bypasses it. A 401 identity response opens sign-in. Before navigating, the shell stores the exact requested path and query, without a fragment, in the tab’s sessionStorage under tier_admin_return_to, never in a URL parameter or localStorage. Sign-in sends that path as return_to; the server validates it again. After a successful identity reload, the shell opens the server-returned path and clears the stored value. An existing session opening /signin also resumes the stored path. Both client and server require a relative path starting with a single /, with no backslashes or control characters. The client bounds targets to 2048 characters; the server bounds them to 2048 bytes. Hostile stored or returned paths fall back to the first allowed page, as does sign-in without a requested page. A failed identity reload, and every failed sign-in except a 400 conflicting-session refusal, preserve the target for retry; the 400 clears it. Sign-out clears the target even if the sign-out request fails. If tab storage is unavailable, sign-in still works using the default page. There is no MFA step. Other identity failures leave the form hidden and offer a session-check retry, including after a successful sign-in POST whose identity reload fails. The shell pages and /admin.js return 404 in synthetic demo and unsealed recompute modes, matching the disabled admin web API.

Sign-in sends the form key only as a bearer header on POST /api/v1/session, with JSON and the browser's same-origin Origin check. The field clears as soon as the POST starts, including on refusal or network failure. The shell never reads the HttpOnly cookie or assumes its name; keys and CSRF tokens are never persisted by its JavaScript. Sign-in failures distinguish rejected keys, 429 lockout with Retry-After seconds, unavailable service, network failure and a conflicting session request. Sign-out sends the cookie and in-memory CSRF token. Success or 401/403 clears local shell state and returns to sign-in. Other sign-out failures preserve the shell and CSRF token and show an explicit failure so the reader can try signing out again. Organization state clears on every sign-out attempt, including failures. All old and new dashboard pages and scripts serve CSP, nosniff and no-store.

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

Root keys read the whole tree and manage levels, nodes, people and keys. Managing node keys read people and public key metadata within their branch and edit tree nodes strictly below their binding. Read-only node keys may read the tree only; the people and key inventories refuse them with 403, and they mutate nothing. These inventories contain administrative names and status, not individual scores. Node report audiences remain k-floored in every aggregation mode.

The tree, people and key GET routes accept bearer credentials or the admin session, and refuse unknown, repeated or undecodable query parameters with 400. Writes follow D2: a script sends the key as a bearer, with no session cookie, and needs neither X-CSRF-Token nor Origin, because a browser never attaches a bearer by itself. A cookie session's write requires its X-CSRF-Token and P08's exact Origin check. Both kinds require JSON content type; cookie plus bearer is 400. Legacy admin/read/viewer credentials cannot use these APIs. All responses are no-store; the shared per-credential limit is 60 requests per minute. Mutations are absent under --read-only, except claim redemption which explicitly refuses with 403. Every successful org administration write, including key issue, revoke and reissue and manager invitation, permanently arms the D1 admin-web flag in its own transaction.

A write re-resolves the credential verifier, holder lifecycle, membership and current tree after acquiring the database write lock. orgauth.Authorize makes the authorization decision in that same transaction. Audit insertion and the D1 sticky activation flag commit with the edit. Revoking a credential or marking its holder left between the initial request check and the write therefore prevents the write. Lock contention returns retryable 503.

Creating a person requires developer and node_id. It creates their native membership and one claim code, returned once in claim.code alongside claim.expires_at; no list can recover that code. Existing people, alias identifiers or historical credential holders give 409; developer identifiers are global, so that 409 tells the caller the name exists somewhere in the organisation. Each credential may create at most 25 people per UTC day across its whole branch (tierd serve --people-cap-per-day, 1–10000), so creating child nodes does not multiply it; root's branch is the whole organisation. The count is persisted across credentials and restarts, and the append-only creation audits enforce it even after erasure. Placement or origin alone does not count a person: status starts as not_counted until named evidence with own-credential bound_upload or server_watch provenance arrives, or a vendor event arrives with valid root-authored mapping evidence. This status is an inventory indicator, not the window-specific k census. Shared and legacy uploads never activate it.

Manager invitation requires an existing active holder within the caller's branch and a strict-descendant node binding. Its fixed label is manager; a second active invitation for the same person and node gives 409, while one person may manage several nodes. Managing node keys may grant manage; only root may grant read, as required by the authorization core. Invitation does not create viewer declarations or disclose individual cards.

POST /org/keys accepts holder, kind, label and optional capability. Only upload and node kinds are accepted; personal keys cannot be issued or reissued through this inventory. Direct personal-key revocation through /org/keys/{cid}/revoke is root-only. For node keys, the binding is the holder's current stored membership, with manage or read capability. Other kinds reject capability. Root credentials remain host-CLI-issued. Personal keys use /me/devices to mint, revoke or reissue only their own upload keys, whose holders cannot be supplied in the device request. Device keys cannot sign in.

Key operations use opaque credential IDs. Reissue revokes just that credential and mints its replacement under one write lock, with key_revoked, key_issued and key_reissued audit rows; a failure rolls everything back. Revoking or reissuing an already-revoked credential gives 409. The replacement retains the label, holder, kind and binding, but starts unused with no last-data timestamp. Other devices keep working. Duplicate active labels are scoped to holder and kind, and for node keys also to the binding. Revocation does not require an active holder: Mark-as-left revokes a leaver's keys at the click, and any key of theirs still live stays revocable by root and by managers above their last placement; a legacy key whose holder was never placed is revocable by root. Only root revokes a personal key directly; a manager ends one only by marking the person left. Reissue requires an active holder and otherwise gives 409. A key listed in the caller's inventory that it may not change (such as a node key at its own binding) gives 403; a key outside the inventory gives 404. On /me/devices/{id}, another holder's key, a non-device key and an unknown id all give the same 404.

Root inventories include declared viewers; node people lists omit that field. People lists show created_at and left_on as UTC dates, never exact times. Ancestor key inventories show dates and Fresh/Stale/Silent/Never chips; the personal device list additionally shows exact last_data_at (or null). Fresh means under 24 hours, Stale means 1–7 days, Silent means over 7 days, Never means no data. used means persisted credential use or data receipt, not signing in: sign-in remains free of database writes. Key lists contain no secrets, verifier hashes, prefixes or token lengths.

Level-0 node renaming returns 409 until billing membership and actual-spend follow-through is implemented. Moving a subtree with people under another level-0 org returns 409 and leaves placement, dated membership and billing membership unchanged. Empty subtrees can move between orgs. Moving a node to its current parent changes nothing and appends no audit row. Nonempty node removal returns 409, including nodes with people, children, active node keys, unrevoked keys of a leaver placed there or a federated-server binding. Root's full key inventory retains revoked keys after their placement node is removed.

When an existing native person predates the legacy upload membership projection, the first authorized device issue fills only that missing projection and starts membership at the server clock. Existing membership and viewer declarations are preserved; the device issue and audit enclose the projection update atomically.

For placements below the three legacy levels, the compatibility team is the person's node, division is its parent and org is the level-0 ancestor. Creation, rename and move keep that projection and the dated membership in the same transaction. Existing level-0–2 skipped-level mappings retain their behavior.

Personal-key claim codes (#1425, ruling E with 6a)

Only the person redeeming a code receives the working personal key. Both root and node(manage) administrators hand out a claim code, which opens no card, session or upload route. The credential table never stores a claim as a key. The authorization core refuses direct personal-key minting by every principal. The holder may replace their own existing personal key with POST /api/v1/me/key/rotate; its response shows the replacement once. Save it in a password manager. Old personal keys and their sessions then stop working; device keys keep working.

Add-person returns {developer,claim:{code,expires_at}}. Give the person this one-time code securely. It has 256 random bits, is stored only as a SHA-256 hash, is bound to their developer and node, and expires after 72 hours. The person sends {code} to POST /api/v1/claim, with JSON content type and the server's Origin (or same-origin fetch metadata), without a bearer or a TIER admin-session cookie (other cookies, such as an access proxy's, are ignored). Redemption requires HTTPS off loopback, as sign-in does, and returns {credential_id,secret} once. Consumption, personal-key creation, audit and notice commit in one transaction. Read-only servers refuse redemption. Unknown, expired, cancelled and already-used codes count toward the shared per-IP failed-auth lockout. An expired code gets its own 410 refusal. A used code receives “already claimed at — ask your admin”; root can see its refusal in /org/audit, recorded at most once per code per hour. Redemption is audited with the claim-code actor sentinel, so root can tell it from host CLI use.

POST /org/people/{dev}/claim cancels earlier unused codes and issues a new one. A managing node may do this only while the subject has no active personal key. Root issues a recovery claim at any time for an active member. Issuing it keeps the old key working; redeeming it revokes every personal key of the holder, including one created by rotation, and every device key any of them minted, in the same transaction. Holder rotation cancels only outstanding onboarding codes; a pending recovery code survives it, so a stolen key cannot rotate itself out of recovery. A move away from the code's bound node makes it unusable; issue a new code for the current membership.

Person move (POST /api/v1/org/people/{dev}/move) accepts {"node_id":"..."} from root or a node(manage) principal that contains both the person's current node and the target node. Authorization is checked inside the write transaction. A hidden or unknown person returns 404; a visible person with a destination outside the manager's branch returns 403. A left person returns 409, a same-node move returns 400, and unknown, removed or federated destinations are refused. At one server click instant, the person's and aliases' dated memberships close and new memberships open at the destination. Same-org moves leave billing unchanged. A cross-org move follows the hierarchy import (UpsertHierarchy) rule: non-destination open seats close in the click's UTC month, and a destination seat opens unless the person has an unsuperseded deliberate end there. When a destination seat opens, the person holds a seat in both orgs for the click month, as the billing ledger records months. Earlier work stays attributed to the old node. Personal and device keys stay with the person; viewer declarations remain unchanged. A move that changes who may open a coaching card (a declared manager, skip level or works viewer becoming or ceasing to be a teammate) sends each affected subject one notice listing the viewers granted and removed. A non-root manager's move that would give that same manager new access returns 409. One person_moved audit records the actor's opaque credential_id and the old and new node IDs. No date parameter is accepted.

Mark-as-left (POST /org/people/{dev}/leave) cancels unused codes, revokes every key the person holds and every viewer key others hold on their card, and closes both membership ledgers (for every alias of the person) at the server click time. A managing node must be allowed to revoke each of those keys: a root key, or a node key at or above its binding, refuses the whole leave with 403 and nothing is written. A leave that would revoke the last live root key is refused with 409. The stated leave date is stored on the person as stated_leave_date and in the audit; it never backdates membership or figures. The response is {revoked:[credential_id],membership_closed_at}.

The people inventory exposes state, never codes: Invited · expires , Claimed at , Expired, or Left. A person with no claim history is Uninvited; a cancelled code can show Cancelled. Lists derive Expired without writing; claim_expired is recorded once, by the next write that touches the code (redemption, reissue or leave). claim_issued, key_claimed, claim_expired and claim_cancelled each create an org audit row and a subject notice, available through /me/audit and /me/notices after sign-in. Personal and node principals read every audit action about themselves through /me/audit, with named detail and other actors’ names redacted, and only their own notices through /me/notices. Managing nodes' subtree audit remains restricted to claim lifecycle events; other subtree audit actions are root-only. D10 separates names from permanent action, time, actor credential id, node ids and opaque claim id.

/me/devices includes manager/root-added devices with added_by (the manager's opaque credential id) and added_by_label (the minting manager's developer id). The person may revoke these devices with their own personal key.

This amends plan §2.2's personal “Minted by” row to the person redeeming a claim. D4's “bulk issue personal keys” becomes bulk issue claim codes; the planned bulk workflow must use this same claim path. Existing legacy viewer/upload bulk CLI behavior is unchanged. The changed Add-person, claim and replaced-key screen mocks still require operator approval before merge, as ruled in S80; these APIs do not constitute that UI approval.

The claim rollout stamps database schema version 20. Older binaries refuse this database: they lack claim/notices erasure and allow direct personal-key issuance. Keep backups suitable for rollback; reopening the upgraded database with an older server is refused. The first start on an older database prints a one-time upgrade notice.

Real-person census and last uploads (#1431)

The k floor counts fixed named people once per reporting window, regardless of how many devices they own. Evidence must be bound_upload from the person's own local credential, server_watch (--watch-as), or vendor_attributed with valid root-authored insert-time mapping evidence. Roster-login matches and legacy aliases alone never count. Census and coaching use the same evidence predicate, including the own-credential check. Shared/legacy tokens, manual costs, roster-only people, imported-unbound people and federated people count zero. A later alias edit cannot reassign frozen evidence. Shared-token teams may become withheld after upgrading; distribute claim codes and have each person create their own device keys. A node that controls its subtree's keys can still invent people with uploads. New seals record census rule d4-c2-v2 and use seal fold rule 4 (up from 3). The fold-rule number versions grouping, census and folding together; d4-c2-v2 names the mapping-aware D4 census within rule 4. Schema 21 refuses older binaries; earlier sealed bytes and config digests remain unchanged and rule 2 seals still read and verify. Credential-free bound_upload evidence written by dev builds between 2026-10-24 02:42Z and 22:16Z counts zero; no release writes that state.

Root and managing nodes read per-person last_upload_date and freshness on GET /api/v1/org/people and per-key last_data_date and freshness on GET /api/v1/org/keys. These are UTC dates. A personal key reads its own exact last_upload_at on GET /api/v1/me and device last_data_at on GET /api/v1/me/devices. Reads recheck org authorization in the data snapshot. Shared upload and read tokens receive neither date nor freshness fields.

Fresh is less than 24 hours since the latest bound server receipt. Stale is 24 hours through exactly 7 days; Silent is more than 7 days. Never means there is no bound receipt, including legacy rows with NULL credential ID. Server-watch can count for k but does not claim an upload receipt. People, device and self views retain Silent and the last upload date or time after retention using persisted upload-credential receipt metadata older than the retention watermark.

GET /api/v1/org/nodes/{id}/reporting returns reporting_7d and people_total for active people in the authorized subtree, with the 7-day boundary included. Counts are omitted when qualifying non-bot evidence within that seven-day window cannot meet k; eligibility and reporting use the same precise receipt-time comparison, including fractional seconds. The existing tree complementary suppression gates the full forest before selecting a subtree, preventing parent-minus-children disclosure of a sub-k sibling. At evaluation k=2, tree suppression conservatively retains its shared floor of 3; k=1 has no positive sub-k residual. Root evaluation summaries use the configured evaluation floor and carry evaluation_mode: true; node keys always retain the shared floor of at least 3. The root-only withheld message names the cause; other principals see “Withheld — ask your admin”. Outside-subtree nodes return 404, including for read-only node keys. Startup and local tierd doctor --db use the report census's roster, bot and remap eligibility when naming the no-bound-evidence cause, without identifying people.

Viewer declarations, erasure and persisted settings (#1440)

Only root may PUT /api/v1/org/people/{dev}/viewers with {"manager":"manager-id","skip_level":"","works_viewers":[]}. The declaration, viewers_declared audit and subject notice commit together. A declaration that would give the same team in the same org two distinct nonempty managers returns 409 and changes nothing. Placement beneath a node grants no card access. A declared viewer may open the existing GET /api/v1/developer/{id}/coaching or /scores/{developer} card in developer mode using their own root, node or personal sign-in key, as a bearer or browser session, when that key's holder is declared. Self access uses the same live check. Each open commits the existing view log before responding; revocation, departure or declaration removal ends access on the next request. These cards include declared_viewers from the current map. Legacy pair credentials retain their existing card behavior and response shape.

Only root may DELETE /api/v1/org/people/{dev} with {"confirm":"<dev>"}. Authorization, erasure and the name-free person_erased audit share one transaction. Managers may mark people as left. The legacy developer DELETE route retains its existing authentication and response contract.

Root may GET and PUT /api/v1/org/settings. PUT accepts any nonempty subset of evaluation_mode (boolean), shared_ingest (accept, warn, refuse) and retention_days (integer, 60–365). GET returns settings, keyed by setting name, with string value, source and pinned metadata. An explicit flag, environment variable or config key pins the control, even when explicitly set to its default; a pinned-key save returns 409 with its host change instruction. Otherwise saved DB values win, then defaults: false, warn, 90. The shared resolver applies to serve, seal and seal status. Environment pins are TIER_EVALUATION_MODE, TIER_SHARED_INGEST, and TIER_DEVELOPER_RETENTION_DAYS; host flags/config retain their existing names. PUT validates the whole resulting set, so evaluation mode cannot be disabled while k is below 3. Saves are audited with opaque credential IDs and old/new values and apply without restart. Read-only serve mounts no PUT.

Lowering retention first returns 200 with preview{cutoff,rows}, a confirmation_token, and unchanged settings. The preview executes the same scheduled prune inside a rolled-back savepoint, returning exact current deletions by table, rows_pruned, rollup_rows, and audit_rows_scrubbed. It changes no stored data or watermark. Counts describe the preview time; data arriving before the next scheduled prune can change them. Repeat the identical PUT with the token within ten minutes using the same root credential. A different change, changed settings, another credential, an expired token or a consumed token is refused with 409. The new window applies at the next retention prune; the confirmation response says the fold is pending. Widening never restores history. GET also includes shared_counts{last7,last30,last_seen}, from the existing daily shared-ingest request tally, including accepted and refused requests. Buffered refusals appear after the normal tally flush.

Schema v23 follows per-node sealed decisions (v22), adds server_settings with tenant-leading uniqueness and extends nullable immutable seal metadata with the resolved settings values and sources. Existing viewer_notices is reused. In team/division mode and the developer-mode org sealer's team view, sealing uses that same live snapshot for evaluation labels and contributor counts. Verify-report replays recorded values for stamped seals regardless of current settings. Old seals retain unknown (NULL) settings and keep their existing replay rules. Existing seal bodies and digests are unchanged by this metadata addition.

Graft credentials and explicit identity mappings

Schema v25 adds consented graft staging and mapping erasure after the v23 settings and v24 vendor-mapping migrations.

Whoever holds a person's personal key can read or change that person's graft consent through /api/v1/me/consent. Consent is only as strong as claim-code custody: root or a managing node that obtains and redeems the person's onboarding claim code can hold that personal key and consent with it. Root and node keys cannot call this route directly. The body has no subject field. Every consent change is audited and writes a consent_changed notice for the subject. Withdrawal affects future snapshots; it cannot recall an earlier snapshot. The /import-source dashboard page reads and changes only this signed-in personal holder's consent. Giving requires a destination label; withdrawal sends only {"move_named":false}. Root sees export issuance instead of consent controls. /import is root-only; it offers active native destinations and requires a saved explicit map/new decision and target for every clash before apply is enabled. The page renders the API's stored apply summary, and never persists the preview, mapping choices or secret in browser storage. The dashboard fixes map to the matched existing person shown in the preview. The new choice alone allows an edited target. Import pages stay outside the main navigation; root reaches Company import from Organization's Import a team link. Both import pages still require their /api/v1/me grant and role check. 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. Consent responses, notices, and bundle_pulled records state: "Some rows stay on the source because bundle v1 exports only accepted events and outcomes." Bundle v1 pins jsonl, codex-rollout, opencode, and muse at realtime; refused event and outcome rows remain on the source. Its digest-covered, source-claimed left_behind report aggregates count and net cost by fixed class for active, non-departed consenters only, with no per-person split. Preview and the stored apply receipt carry it and a separate left_behind_rows counter. An absent report is refused. Name-free totals exclude all active consenters; the retained-row report accounts for their retained rows without changing the source ledger. See the contract.

A root credential mints a shown-once export credential valid for one hour. That credential opens only the HTTPS bundle GET endpoint, once, with bearer authorization. HEAD and other methods return 405 before rate limiting or consumption. Sending a recognized export credential over plain HTTP consumes it before returning 403; a later HTTPS pull is refused. Revoking the issuing root also prevents pulls. It cannot sign in, open cards, or read organization data. Named snapshot construction, consumption, and source bundle_pulled audits share one transaction; a failed audit releases no snapshot and does not consume the credential. Name-free totals are advisory and computed in an earlier read snapshot. Consent and the erasure epoch are rechecked before release; a change returns a retryable 503 without consuming the credential. Unknown guesses count toward IP lockout; recognized refusals are limiter-neutral. The bundle endpoint rejects cookies and trusts HTTPS forwarding only from configured proxies. IP lockout returns 429 with Retry-After, including for a valid export key.

Only root can pull a company-side preview or save mappings. The destination must be an active native node, checked before fetching and again before staging. The company server fetches from whatever HTTPS host root names in the source URL and sends the export key there. Choosing the host is a root-only trust decision; internal, loopback and link-local addresses are allowed. The export credential is used only for the outbound request; it is never stored in the batch, mapping, audit, or session. Authentication and destination scope are resolved again in the staging write transaction. An equal source/local developer ID is a clash, even if both strings appear to name the same person. An explicit mapping is required before apply can proceed. Preview and mapping changes are audited; preview does not bind or create an imported person.

Data-subject export, erasure, and retention resolve held staged people and their batch-person metadata through the explicit mapping before matching local IDs. Unmapped people match the source ID. A colliding source name cannot export the held mapping or invalidate another local person's mapped stage. Once staging is absent, source-named subjects can export source metadata with the mapping's target developer omitted, and erase that metadata. Erasing a source-only name in an id_mapped audit row nulls only source_developer, preserving the target's row.

Only root can POST /api/v1/org/imports/{batch}/apply. Imports are explicitly exempt from --people-cap-per-day because this route is root-only and audited. The request has no fields; Origin, session CSRF, rate limits, and no-store match the other admin mutations. Apply resolves authorization and rechecks readiness in the same write transaction that creates people, imports usage, consumes staging, and audits the result. Refused or failed applies leave all of that state unchanged. Applied receipts support retries after staging has been cleared. A matching bundle cannot be redirected to another destination or used to recreate erased named history.

A new imported person is created at the chosen native destination with origin imported and status imported_unbound. Apply issues no keys, claims, or viewer declarations. The ordinary root-managed onboarding and device-key flow is needed for an own-credential local upload to count them toward k. Source events never count as local evidence, and watcher evidence alone cannot bind an imported person. Imported spend remains Unknown until the subject has qualifying local evidence, then joins that subject in live identity totals and future seal populations. Imported rows never supply census evidence; existing sealed populations and apply receipts remain frozen. A mapping to an existing person preserves that person's local placement, keys, and viewer declarations. Non-consenting source people have no person row; their safe source totals are retained as name-free Other under the destination. Departed consenters also contribute to these protected closed-month totals; their names and named histories remain excluded.

Allowed federated servers

A federated server reports its team totals to this server on a schedule. Root and managing node keys may allow a server below a native group in their own branch. It occupies one place within the six-level limit. Accepted reports carry internal group names, IDs, parent links, level names, withholding states, fold membership, and released points and list costs to the receiving server. Root can read those stored groups and figures through /api/v1/org/totals; node credentials can read accepted server detail only within their authorized branch. Individual people and events do not travel through federation. Placement, token revocation and token reissue are audited. Managers can read these lifecycle audit rows in their branch through /api/v1/org/audit.

Allow and reissue responses show a random federation token once. Inventories return only its opaque credential id, issue/revocation times and status. Reissue revokes the old token and creates its replacement in one transaction. Revocation stops future pushes and preserves accepted periods and the server's place. A sibling-branch manager cannot revoke that server (403). Federation credentials have no holder and cannot open admin pages or read reports; their only read is their own /api/v1/federation/status.

POST /api/v1/federation/push requires a federation bearer token and HTTPS, including on loopback; plaintext is refused before credential lookup. Only the configured trusted proxy's forwarded HTTPS assertion is accepted. The merged tier-federation/v1 decoder validates payloads; pushes are limited to 1 MiB and 1–31 already-closed periods. Storage uses the server's assigned place, never a node supplied by the child. Accepted periods are immutable. Under a later sequence, the same period and digest are skipped; new periods are accepted. Every other overlap with an accepted period is refused, including shifted or spanning bounds. Only the immediately preceding sequence with exactly the same complete request bytes returns the original successful response.

Failed guesses count toward the existing IP lockout. Recognized refusals remain neutral. The refusal log keeps time, source address class, reason and a server id only when a federation token identified it. No presented credential, verifier, prefix, length or unauthenticated claimed server identity enters the log. Refusals buffer in bounded memory and flush every 60 seconds and after HTTP shutdown. A crash loses unflushed entries (normally up to 60 seconds, longer during a flush failure); failed flushes retain the bounded batch for retry. Detail is capped at 10,000 rows and 90 days; overflow becomes hourly reason counts, also retained for 90 days. The worker prunes both durable stores even when no new refusals are buffered. Root can read source classes. Top-level node readers can read refusals without source classes and see identified servers only in their branch. Unidentified attempts and hourly reason counts have no branch attribution. Read-only serving mounts no push or server-management mutation and never buffers or flushes refusal writes.

People, Settings and first-run pages (#1449)

/people uses the reader's subtree inventory. Native groups in that subtree are available for person placement and moves, excluding the person's current node from move choices; manager invitations require a strict descendant of the current sign-in's binding. Move is offered before authorization is rechecked by the API, including the 409 refusal for a non-root self-grant. Root alone sees viewer-declaration, recovery and typed erasure controls. Existing claim-reuse audit rows flag the affected person with a recovery action. Claim codes and invited-manager node keys use the same ephemeral result and cleanup rules as Keys and My devices; neither enters browser storage or navigation. Open card links use /card for the holder and /api/v1/scores/{dev} for a declared viewer. Leave dates default to the UTC day, which also bounds the input and submit validation.

/settings is root-only. Pinned settings show their source and host change instruction with disabled controls. Unpinned saves take effect immediately; lowering retention first displays the exact server preview and then submits the unchanged preview request plus the confirmation token. No browser save sends an Authorization bearer. The cookie session's server-held CSRF token accompanies every mutation. Root can open /first-run from Settings to enable evaluation mode on an installation with at most one person; pins apply there too. The page shows no invented evaluation figures. Card notices are read only for the signed-in subject, with declaration snapshots and both directions of effective-access changes.

Sealed subtree reports (#1441)

Root and node credentials can read GET /api/v1/org/report, GET /api/v1/org/report/compare and GET /api/v1/org/report.csv. A node key defaults to its own subtree; node= selects a narrower subtree. Authorization is re-resolved in the same read snapshot as the stored report; outside-subtree, missing and removed nodes return 404. Personal, upload, federation, metrics and break-glass admin tokens do not open these routes. The configured shared read token can select a subtree but receives no contributor counts, including on fold rows and in CSV. Cookie plus bearer is refused.

Supply exactly one period=YYYY-MM; repeat compare=YYYY-MM for up to twelve additional stored months. Every row retains its sealed withholding decision in every compare order. Missing per-node history returns 404 and is never rebuilt from live activity. In v0.7.0, node keys receive only sealed months with the shared audience, including in evaluation mode. No live window, month-to-date increment or multi-month total is available. A moved node's new ancestors can read its stored history within their current scope; they do not receive historical siblings outside that scope.

Native costs and points follow dated placement. Movers count in the first team they were active on that month, while their amounts remain split by date. Named D5 exception: a federated node moved mid-month attaches its whole opaque month to its month-end parent. No mover indicator is exposed.

Subtree reads use the hourly in-memory audit tally described in Privacy. Two failed minute flushes close these routes with 503 until recovery. A read that cannot fit the bounded tally also returns 503, without a partial comparison tally. --read-only mounts none of these three routes; it starts no subtree audit writer.

Shared read-token reports retain the configured granularity: division omits rows below each branch’s own legacy division node, using org_node_labels (level 1 at legacy depths, the leaf’s parent below level 2); team/developer allow leaf rows. Default root and shared reads without node= include the root Unplaced cell; subtree targets below the root omit it. A finer shared-token target returns 404. Root/node scope uses org authorization in every mode.

Legacy shared-token rows remain fixed public constraints when the node month is sealed. Duplicate labels and reorgs can withhold additional node cells. A legacy surface finding can make the node month unavailable (404) while its original legacy report remains available unchanged. Consumers, including federation, must treat that month as missing rather than zero.

Root reports may include a reserved unplaced: true cell for spend without dated placement. It has no team name and cannot be managed as an org node; subtree reports below the root never include it. Partial-month placement keeps placed and unplaced figures in separate dated slices under the same privacy gate.

Root alone can read /api/v1/org/report/status, including the permanent unavailability cause (legacy, g_prime_refused, data_defect, no_tree, panic). Ordinary reports, compare and CSV use the same 404 for unavailable and never-sealed history. Federation receives typed MISSING, never zero. Shared read tokens use the same IP lockout and 60-per-minute credential budget as root/node credentials. Federated nodes are opaque here: no figures or counts, no native fold membership, and no contribution to local ancestors. Developer mode’s tierd seal --arm, --status and --skip control its shared team sealer with the shared k floor.

Root/node credentials can read /api/v1/org/totals and /api/v1/org/totals.csv for one sealed month in their subtree. The same rollup appears on the existing subtree JSON report. Local amounts are stored sealed decisions; federated amounts are reported by those servers, not re-checked here. Whole-company figures identify the latest common month, with every server covering that same month. Late or unavailable months remain missing. Revoked history remains visible, while revoked servers leave the requested month's N-of-M server count. Totals use the subtree in-memory audit tally and are absent in read-only serving. The existing shared-token report remains sealed-only and count-free; it omits the entire rollup field, including federated totals and server counts. Shared tokens cannot open the new totals routes. Node readers see their selected subtree path as the combined total label; root readers retain "Whole company".

Root/node credentials can read /api/v1/org/coverage for live status or a month=YYYY-MM report, with format=csv for export. Authorization applies to the selected subtree in every aggregation mode; personal, device, federation and legacy shared/break-glass tokens are refused. A node cannot discover an outside group's status, name or server schedule. Coverage shows no individual freshness or contributor counts. Sub-k and complementary-withheld group rows show status only, without reasons or dates. Shown groups may show setup/stopped reasons and aggregate UTC report, period-end and next-expected dates. Monthly details also require an available, shown node seal; no-receipt months use that stored gate. The same setup/no-reports/stopped definition applies to live and monthly views. No group publishes contributor counts, including in sealed months. Server rows describe their own schedule, accepted history and setup/stopped reason. The N-of-M summary counts native groups separately from servers. Server Current uses the end boundary of the latest month whose end plus the shared 14-day default seal grace has passed, ahead of the Silent push-age rule. Unended month= requests return 400. Coverage reuses the D9 in-memory tally and fail-closed behavior, is absent under --read-only, and never writes to the database per request.

The Organization page (/org, P21) uses the same cookie session, role navigation and in-memory CSRF token as Keys and My devices. Root and node readers see only their returned subtree. Managing node keys edit descendants and create children within that branch; root also names up to six levels. Federated places remain read only. Moves need authority over both parents. Tree mutations send JSON and session CSRF; 403/404 and unknown 409 refusals (including self-grant refusals) show “Your key cannot make this change.” Known duplicate-name, cycle and depth conflicts use cause-specific messages; 400 asks readers to check the group name, level and parent. Report-read 403 uses “Your key cannot read this report.” Both reads and edits use the rate-limit countdown and disable editing during Retry-After, without displaying raw response error text.

Child upstream administration (P28 + P29)

Only root can read or save /api/v1/org/upstream or invoke its /test action. Node managers, personal keys, legacy tokens and federation keys cannot configure an upstream or test its credential. Browser writes use the existing session, CSRF and Origin checks. A save re-resolves root while holding the write lock and records upstream_configured; refused calls do not touch the credential file.

The host configures the HTTPS central origin, issued server ID and absolute token file through the federation flags, environment or YAML block described in API compatibility. The central address is display-only. Root may paste a replacement bearer into the 0600 host file when its directory permits atomic replacement. The new file is staged first and replaces the active token only after settings and audit commit. Database failure preserves the old token; rename failure is reported after commit. Temporary files are removed on failure. GET and PUT responses expose only token_stored, never a secret or its file path. Test connection reads only central status and leaves push sequence and history intact. Read-only serving offers GET only and starts no background pusher.

Federation and coverage pages (P31 + P33)

Federation and Coverage use the existing root/node session and subtree APIs. Only root sees the Child server settings link and receives upstream in its page allowlist. Managing node keys can allow and rotate tokens within their branch; read-only node keys receive no mutation controls. Placement choices come from the authorized native tree. Refused attempts are read only by root or a top-level node reader; other readers see an empty Refused attempts table. Company totals are shown and fetched only for root and top-level nodes. Managing node navigation follows the mock and omits Federation; the page remains authorized by the server allowlist. The Federation audit section is restricted to root and managing nodes.

Shown-once federation tokens are cleared on Hide, same-tab navigation, pagehide, and session clearing, and are never stored in browser persistence or URLs. Child settings never returns a stored token or offers an editable central address. Its paste clears after submission and writes through the existing root-only, CSRF-protected upstream API. Coverage and totals download links use the same cookie session and API authorization as their displayed rows. Downloads keep the session shell visible. A host with no upstream configuration shows its setup state with Save and Test connection disabled; connection-test results distinguish upstream failures from session failures.

v0.7.0 organizations: who can do what

Use these v0.7.0 scope rules alongside the earlier CLI recipes. A node is a group in the organization tree; its subtree is that group and all groups below it. Legacy tokens keep their existing routes.

Credential Where to find its authority and limits
Root Host-only root issuance; people, tree and key administration; viewer declarations, erasure and settings are root-only on /org routes.
Managing node People creation, device keys and delegation to strict descendants; branch inventories and their limits.
Read-only node Tree access without people or key inventories; k-floored subtree reports.
Personal Claim-code redemption and holder rotation; own device management; graft consent.
Device / upload Holder-bound event uploads; issuance by an administrator or the holder's personal key.
Federation Branch-scoped issuance, pushes and own-server status only.
Shared / legacy Shared ingest limits and legacy viewer and read boundaries. The write token cannot open /api/v1/org/* routes, but PUT /api/v1/org_hierarchy/{developer} and POST /api/v1/org_hierarchy create nodes and people, move people's placement, and set manager, skip-level and works viewers honoured by card-access checks. DELETE /api/v1/developer/{id} erases developer data. Treat the write token as equivalent to organization administration for trust purposes.

For individual cards, read the live declaration and view-log rule. Administrative authority over a branch is separate from permission to open a person's card. TIER is built for coaching, not for evaluating developers. Per-person figures are not accurate enough to judge an individual.

The related trust limits are subtree padding and one server per developer, federated totals reported by them, not re-checked here, and the child upstream SSRF residual.

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.

The root erase dialog and success message disclose the fixed erased_import_record notice: TIER retains a record so a completed erasure stays effective, and the organisation decides its legal basis with counsel. Import previews and completion receipts show the nameless erasure_notice as text. A mapping to an erased target leaves the source person behind at apply, including a replacement for an earlier mapping. Each source pull on both servers records its own import attempt; a failure before the pull starts leaves the export key usable. The import recording warning is dated and appears only on overlapping reports; sealed org reports, totals and compare keep their stored warning.