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