TIER docs

Security hardening guide

Identity batch (#1370): Real-data developer startup requires an active permitted viewer credential, a nonempty admin token and a named audited operator. Shared reads are name-free; named card reads use verified provenance and log each view before building it. Identity, the #1371 raw-data retention engine and the coaching card ship together in v0.7.0. See developer access and upload identity for current commands and restrictions.

This guide is written for the person deploying TIER and for the security reviewer doing diligence on it. TIER's security model is a set of deliberate, documented trade-offs: they are safe only when the operator understands them. Every behavioral claim below carries a (code: ...) pointer so you can verify it against the source in minutes rather than take it on faith.

Scope note: TIER today is single-process, single-tenant. There is no per-organization isolation — developer identifiers are global. Do not deploy a single tierd as a shared multi-organization service.

Related reading:


1. What TIER stores — and what it never stores

TIER never persists the prompt or completion content of the usage it captures. The token-capture path parses provider responses and Claude Code session files through an allowlist: from that path, only token counts, model names, timestamps, and attribution identifiers (developer, issue, repository) reach disk. Prompt text, completion text, source code, file contents, and diffs are never deserialized and never stored. The optional reverse proxy is the one path that carries them: by design it forwards your requests to the provider and the responses back to you, reading each response in memory as it passes to find its usage. It sends them nowhere else. The one exception to never persisting such text is the free text of raw GitHub webhook payloads, stored as written, with whatever was pasted into it (below).

Why the guarantee holds — the parsers are allowlist structs, so any field not named in the struct is dropped by the JSON decoder before it exists in memory:

What the capture path persists is the token_events table. Its columns are defined once, in internal/store/store.go (schemaTables, the CREATE TABLE token_events statement), and none holds prompt or completion text. Which stored fields can identify a person, and how, is listed once in privacy.md § What IS stored.

Branch names are read into memory to derive issue_id, and only the derived identifier is written to a token_events row; the branch string is never stored there (code: internal/store/store.go, the token-event insert path). The Claude Code watcher's resume checkpoint (watcher_checkpoint) does store branch names, the working directory and the session id; privacy.md lists its full contents (code: internal/collector/watcher.go, checkpointFromState).

The webhook table stores third-party free text, as written. When the GitHub webhook path is enabled, TIER retains the raw GitHub webhook body (gzipped) for every processed delivery in the webhook_payloads table, so a score input can be re-derived later (code: internal/store/store.go, webhook_payloads; internal/webhook/handler.go, InsertWebhookPayload; #137). That body does contain personal data: commit author names and email addresses, PR titles and descriptions, and commit messages — the same PR/commit metadata GitHub itself already stores. It is bounded by a 90-day age cap and a 50,000-row cap (code: internal/store/audit.go, PruneWebhookPayloads), stored in the same 0600 single-tenant database, and is deliberately excluded from the GDPR erase route (DELETE /api/v1/developer/{id}), so a contributor name embedded in an old payload is aged out by retention, not by erasure (code: internal/store/store.go, EraseDeveloper table list). It is the only table that holds third-party PR/commit text; that text is stored as written, so it can hold whatever someone pasted into it (privacy.md), and it exists only when you run the webhook. It is not the only free text at rest: privacy.md lists every other stored field, including the client-chosen and operator-supplied strings, and walks through the allowlist in more depth.


2. The API token is org-secret-grade

A single TIER_API_TOKEN gates legacy writes, the legacy score GETs, /metrics, and all three reverse proxies (/anthropic, /openai, /gemini) (code: internal/api/handler.go, requireAuth / requireRead / ProxyAuth). You choose its value (openssl rand -hex 32; see README → What you need before you start). Treat this token like a CI deploy key, not a per-user credential. Any holder of the write token can:

Access and trust. Real-data developer mode requires an active permitted viewer credential, a nonempty admin token and a named audited operator. Each developer ships with their own bound credential. Shared read credentials expose only name-free responses; shared/admin uploads remain team-only. Administrators and host/database owners remain trusted operators, and host access can read the whole database. Shared team/division reports remain name-free; organization inventories follow their separate credential scopes. Identity, the #1371 raw-data retention engine and the coaching card ship together in v0.7.0. The synthetic tierd demo keeps its startup exemption and invented data; tierd score remains a local cost report.

Before the D4 census, the team/division k-floor was not robust against an untrusted shared ingest holder. Measured then at k=3 in both modes: invented unrostered IDs did not count, but uploads impersonating two rostered IDs with existing outcomes and no spend caused a previously withheld one-spender cohort to publish its aggregate. In v0.7.0, shared and legacy uploads count zero toward k; the census requires qualifying bound evidence. A manager can still pad its own subtree by inventing people and issuing device keys, as described in the inventory trust limit. With qualifying bound evidence, those people also lend k to ancestor rows: the figures of a neighbouring real sub-k group can publish in an ancestor or fold. Anyone who can read that ancestor and knows the invented amounts could subtract them to learn the remainder. Per-person and fold dominance gates still apply (code: internal/scoring/treesuppress.go, SuppressTree, treeFoldSafe; internal/scoring/sealtree.go, sealPeopleSafe).

#1370 binds viewer and upload credentials to a subject. Self, declared manager, optional skip-level and works-agreement viewers receive separate hashed tokens; team membership grants no access. Named reads are audited before construction, including admin reads. Each developer ships with their own --bound-token. Shared uploads remain team-only; only verified provenance can enter a named view. Identity, the #1371 raw-data retention engine and the coaching card ship together in v0.7.0. See identity access for issuance, revocation and watcher checks. Consequence: if forged attribution matters to you, do not hand the write token to individual developers. Front tierd with your own authenticating proxy that sets attribution from an authenticated identity, and keep the TIER token server-side.

Read-only viewer scope (shipped, #190). A separate --read-token / TIER_READ_TOKEN (generate it the same way, in its own file) grants a least-privilege viewer credential: outside real-data developer mode it is accepted on routes in registerReadRoutes (internal/api/handler.go) — the score reads, the report manifest, the bulk exports, the quality and fidelity reads, and, in developer mode only, GET /metrics (§ The metrics token). Real-data developer mode instead applies identityRoutePolicies: shared reads are name-free, GET /api/v1/events is denied, and /metrics requires the write or metrics token (code: internal/api/identity.go, identityBoundary). The read token is rejected with 403 on every write, on the finance/admin GETs (org_actual_spend, developer_alias), on the GDPR export/erase routes, and on the proxies (code: internal/api/handler.go, requireRead and the Register route table). It lets you hand a CFO or VP-Eng dashboard access without the write, erase, or forge power the write token confers. The read token must differ from the write token — tierd refuses to start otherwise (code: cmd/tierd/main.go, checkReadToken) — and, on its own, does not permit a non-loopback bind: the bind check is gated on the write token alone (code: cmd/tierd/main.go, validateBind).

Rotation is a restart with a new token — there is no session state to invalidate. All four tokens support @/path/to/file indirection and env-var sourcing so the secret stays out of ps output and shell history (code: cmd/tierd/main.go, secret @file resolution; #37). If an @file secret is readable by group or other users, tierd logs one WARN naming the setting and mode, never the path or the contents, and still starts, since container secret mounts are often 0444 (no check on Windows). Restrict the file to the user tierd runs as: chmod 600 if that user owns it; for a container or Kubernetes secret mount, set the mount's file mode (mode/defaultMode) instead, only as far as tierd's user can still read it. A WARN on a mount tierd reads as another user is expected (#920).

Token scopes

Server flag / environment / config Credential grants
--api-token / TIER_API_TOKEN / http.api_token Admin writes, reads, exports, erasure and provider proxies
--read-token / TIER_READ_TOKEN / http.read_token Read-scoped routes subject to identityRoutePolicies in real-data developer mode; /events and /metrics are denied there. /metrics is allowed in synthetic developer mode.
--metrics-token / TIER_METRICS_TOKEN / http.metrics_token GET /metrics only
--ingest-token / TIER_INGEST_TOKEN / http.ingest_token POST /api/v1/events only

All non-empty tokens must differ; startup refuses any equal pair. The shared constant-time classification and failed-auth lockout cover all four scopes. Auth is enabled by the admin token; the other tokens alone do not permit a non-loopback bind or enable auth. Public health/version routes remain open.

The ingest token (#1144)

For new installs, issue per-developer upload credentials with tierd tokens issue --db tier.db --from-team-map --subject maya --label laptop --upload after importing the team map. Transfer each credential securely and use ship --developer maya --bound-token @/secure/maya-upload-token. These bind uploads to the developer; see identity access.

The shared ingest token is a legacy transition credential for existing shippers. Configure it through serve --ingest-token, TIER_INGEST_TOKEN or http.ingest_token (CLI > environment > config, with @/path/to/file support). Existing laptops send it with ship --api-token @/path/to/ingest-token. It grants only POST /api/v1/events; retire it using the shared-token request tally.

Legacy shared-token limits: an ingest-token holder can post events under any developer id, rostered or not: there is no roster check, but these uploads count zero toward the D4 k census and never enter named coaching evidence. A holder can also pre-claim an idempotency key a teammate would later use, suppressing that teammate's row. The credential has no bound developer identity, and these limits still apply. A holder who knows a key can replay it under the same developer with larger counts, raising that row's token counts and cost (recomputed at the row's original rates, #1231). Replaying a teammate's key this way raises that teammate's cost (the TIER denominator); real-data developer mode denies GET /api/v1/events, so read-token holders cannot obtain keys through that route (code: internal/api/identity.go, identityRoutePolicies, identityBoundary).

A collision with an existing key under a different developer is now rejected: the original row stays unchanged, the response reports rejected_cross_developer, and the server logs a WARN. ship prints a warning naming the count without failing or retrying the batch. Same-developer replays still deduplicate; the global idempotency index is unchanged. Rejecting the later collision does not undo a holder's earlier pre-claim.

The metrics token (#944)

--metrics-token / TIER_METRICS_TOKEN (or @/path/to/file, or the metrics_token YAML key) is a scrape-only credential. It opens GET /metrics and is refused with 403 on every other route and on the proxies (code: internal/api/handler.go, requireMetrics). In team and division mode the read token gets 403 on /metrics, so a scraper there needs the metrics token or the write token; with no metrics token configured, only the write token scrapes. In real-data developer mode, /metrics also requires the write or metrics token; the read token can scrape in synthetic developer mode (code: internal/api/identity.go, realIdentity, identityBoundary). The metrics token must differ from the write and read tokens (tierd refuses to start otherwise; code: cmd/tierd/main.go, checkDistinctTokens), shares the failed-auth lockout, and on its own does not permit a non-loopback bind. With no token configured at all (a loopback install), /metrics is open in every mode, like every read route.

The metrics token is operator-only. /metrics exports running counters of spend and activity. Two scrapes a short interval apart give what arrived in between, which in a quiet interval is one person's spend and activity timeline, below the k floor the anonymised modes enforce everywhere else. The token decides who can take that difference; it does not make the counters safe. Never give it to a viewer, and never wire it into a dashboard other people can read. Anything that can query the Prometheus that scrapes it (Grafana, federation, remote-write) sees the same counters, so its readers must be operator-only too.


3. Tokenless mode and its trust boundary

Running with an empty TIER_API_TOKEN disables bearer auth. This is safe by default, not unconditionally. tierd fails closed: with no token it refuses any non-loopback bind at startup (code: cmd/tierd/main.go, validateBind; #59). Three residual caveats you must understand:

  1. The literal hostname localhost is trusted by convention. validateBind accepts localhost without resolving it. An attacker-controlled /etc/hosts entry that points localhost at a routable address can therefore defeat the check. Bind to the 127.0.0.1 literal if you want the IP-level guarantee.
  2. Loopback can be re-exposed from outside the process. An SSH tunnel, or a container port-map such as docker run -p 0.0.0.0:8080:8080, re-publishes a loopback listener to a network. The bind check cannot see past the process boundary — that exposure is on you.
  3. Zoned IPv6 literals are refused (for example [::1%lo0]) — deliberately, because the safe direction is to ask for the plain form rather than guess.

In tokenless mode the read token has no effect, and the reverse proxies are an unauthenticated relay to the upstream provider — which is exactly why the bind is restricted to loopback. If you need to expose TIER on a network, set a token.


4. Filesystem hardening

The SQLite database and its -wal / -shm sidecars are created mode 0600 (owner read/write only), and legacy files are repaired to 0600 on every start (code: internal/store/store.go, Open; #130). Files are created tight (O_CREATE with 0600), so there is no transient world-readable window, and a chmod sweep at the end of Open tightens any pre-existing 0644 file. The containing directory is created 0700 (code: cmd/tierd/main.go) — but if you pre-create the state directory yourself, MkdirAll leaves its existing mode untouched, so set it 0700 in that case. You do not need to chmod the database by hand.

Caveats:


5. Rate-limit lockout topology

tierd has a per-IP failed-authentication lockout: after --auth-max-failures failures within --auth-failure-window, the offending IP is locked out with a 429 for --auth-lockout (defaults: 10 failures / 60s → 15 minutes) (code: internal/api/ratelimit.go, DefaultRateLimitConfig). It keys on the direct TCP peer and, by default, ignores X-Forwarded-For — because a client controls that header, and honoring it would let an attacker mint unlimited lockout buckets and defeat the limiter entirely (code: internal/api/ratelimit.go, clientIP).

Consequence behind a shared TLS terminator, reverse proxy, or NAT: every client arrives from the same direct peer address, so they all share one lockout bucket. A single misconfigured client sending bad tokens can lock out the whole organization for the lockout window.

Mitigations, in order of preference:

  1. Configure --trusted-proxy-cidr (shipped, #131). When the direct peer is inside a trusted CIDR you supply, the limiter instead keys on the real client from X-Forwarded-For — specifically the rightmost hop not inside a trusted CIDR, which is the address your own edge appended and thus the one value a client cannot forge (code: cmd/tierd/main.go, --trusted-proxy-cidr; internal/api/ratelimit.go, clientIP). This flag is also settable in the config file as http.trusted_proxy_cidrs. Set it to your terminator's address range and per-client lockout is restored — this works only if your edge actually sets a trustworthy X-Forwarded-For; if the trusted peer forwards no XFF, clientIP falls back to the shared peer address.
  2. Fix the failing client (it is usually a stale or wrong token).
  3. Raise --auth-max-failures, or rate-limit at your terminator instead.

Note: the limiter keys on the full client address; there is no coarser subnet bucketing, so distinct clients get distinct buckets once --trusted-proxy-cidr is configured correctly.


6. Webhook authentication

The GitHub webhook is authenticated with HMAC-SHA256 over the request body, validated against the X-Hub-Signature-256 header on every request (code: internal/webhook/handler.go, verifySignature). It is fail-closed: with no TIER_WEBHOOK_SECRET configured, the route is not even mounted, and if the handler is reached without a secret it rejects every request with 403 (code: cmd/tierd/main.go, webhook mount guard; internal/webhook/handler.go, #60). An unauthenticated webhook would let anyone who can reach the listener forge merged-PR outcomes.

Supply the secret via TIER_WEBHOOK_SECRET or the @/path/to/file indirection so it stays out of ps output and shell history (code: cmd/tierd/main.go, #37). See webhook-setup.md for the GitHub-side configuration.


7. Proxy header hygiene

The reverse proxies attribute spend from three internal request headers — X-Tier-Developer, X-Tier-Issue, and X-Tier-Repo — and strip every X-Tier-* header from the outbound request before forwarding upstream, so none reaches the provider (code: internal/proxy/proxy.go, the Rewrite hook). That includes the X-Tier-Token proxy credential, in every auth mode; it is checked only when an API token is configured (--api-token, TIER_API_TOKEN or the http.api_token config key; code: internal/api/handler.go, ProxyAuth). A non-canonical X-Tier-Repo value is ignored and never logged.


8. Log hygiene (log-injection resistance)

Every value derived from an untrusted request that reaches a log record flows through a sanitizer (internal/logsafe, wrapped at call sites as logSafeStr / logSafeErr). It strips carriage returns and line feeds — the explicit CR/LF removal that forms the forged-log-record barrier — then %q-quotes the result to escape any remaining control bytes, invalid UTF-8, or quotes, and caps the length. A structured logger plus this sanitizer means an attacker cannot inject a newline to forge a second, attacker-authored log line.

A small, deliberate set of logged fields are left un-wrapped because they are provably constrained by construction and cannot carry a control byte: GitHub issue references (issue-<digits>), commit SHAs ([0-9a-f]{40}), the webhook event type (allowlist-bounded to pull_request / push / workflow_run), upstream-validated period strings, and numeric fields such as PR numbers. Each is documented at its call site.

Errors are the sixth class, and the rule for them is narrower than "wrap everything". An error is not exempt from the barrier just because it is an error: a store or validation error can wrap a free-form identifier (a developer id, org, team, or division that is length-capped but never charset-validated), and those sinks go through logSafeErr — the hierarchy writes, the /outcomes insert, and the per-developer score reads all do. The majority of err values are logged bare instead, and the discriminator is whether the error's text can carry a client value, not whether the operation touched one: the SQLite driver does not echo bound parameters, so a failed INSERT whose arguments include a hostile developer id still produces a constraint message that names only the table and column. A sweep of the request-facing packages (internal/api, internal/webhook, internal/proxy, cmd/tierd) found the values that are interpolated into internal/store error text are either operator-supplied (config route prefixes, DB paths, table names) or routed through logsafe at the point of construction. Wrapping the rest would add noise and blur exactly the client/operator distinction this barrier depends on.

An earlier revision of that sentence listed "price-table model names" among the operator-supplied values. That was backwards, and it is corrected here rather than quietly dropped because the error it excused was real. The model names interpolated into store.Reprice's GUESS-gate error are by construction the models absent from the price table — the producer-supplied strings that matched no operator-curated entry, arriving through POST /api/v1/events, which length-caps model and applies no charset check. They are now routed through logsafe.Join at construction, pinned by TestReprice_GuessGateErrorIsNotForgeable.

Note on static analysis: an automated code scanner may report these constrained bare fields as potential log-injection findings. They are false positives — the scanner cannot prove the regex/allowlist constraint that the code guarantees — and are triaged as such, rather than "fixed" by wrapping a value that already cannot forge a record.

What the barrier actually buys, measured (#321, 2026-08-04). Two honest qualifications, because "a sanitizer is applied" is not by itself a threat model:

Code scanning checks this class on the development repo. The go/log-injection query declares @precision medium, and GitHub's default code-scanning suite includes only precision: high|very-high (misc/suite-helpers/code-scanning-selectors.yml in the github/codeql repository), so the query runs only under the extended suite. The private development repo, where every change is made, runs CodeQL default setup with query_suite: "extended", so it runs this query (measured 2026-09-25). The public mirror ran the same extended setup on 2026-08-04; on 2026-09-25, while the mirror was private, GitHub reported code scanning unavailable there. Either way, a clean Security tab is not proof that this section holds — the named tests are (internal/logsafe, TestProxy_ContentEncodingNotForgeable, and the per-sink *NotForgeable tests in internal/api, internal/webhook, internal/collector, internal/store, and cmd/tierd).

⚠️ Read that list precisely, because it was once read too generously. Until 2026-08-04 the only such test in cmd/tierd covered the access log (TestRequestLogger_PathNotForgeable), and no test covered a report writer — a different sink class with a different threat model, since the access log is slog (which escapes CR/LF even when the caller forgets) and a report writer is raw fmt.Fprintf (which escapes nothing). The sentence read as coverage of a class that had none, and cmd/tierd/reprice.go diverged from its correctly-wrapped sibling repairrepo.go without a single test going red. The report writers are now covered by TestPrintRepriceResult_NotForgeable and TestPrintRepairRepoResult_NotForgeable.


9. Reporting a vulnerability

If you find a security issue in TIER, report it privately — do not open a public issue, and do not include a working exploit in a public channel.

Use GitHub's private vulnerability reporting on this repository (Security → Report a vulnerability), which opens a disclosure channel visible only to the maintainers.

The full disclosure policy, supported-version window, and response-time commitment are in SECURITY.md.


Child federation credentials and transport (P28 + P29, EXTREME)

The upstream bearer authorizes reports into one central place. A stolen bearer can forge that child's totals until central revokes and reissues it. Install it in an absolute host file owned by tierd's user with mode 0600. The containing directory must have the same owner and deny group/other writes. Token reads refuse symlinks, hard links, nonregular files and unsafe modes; pastes use a synced temporary 0600 file and atomic rename in a pinned directory. Unsupported platforms refuse credential I/O. The token is re-read for every request and never stored in SQLite or returned by the child API. No credential-derived diagnostic is recorded.

The host pins the HTTPS central origin and issued server ID. Root browser/API settings cannot change the destination or token path. Restart refuses different pins for an existing upstream, so pending bytes cannot cross into another central or server identity. TLS certificate checks stay enabled, redirects are never followed, request bodies are at most 1 MiB, responses at most 64 KiB and requests time out after 15 seconds. Network errors and response bodies are never copied into logs, status or audit.

Sequence and pending body commit before send. Network failure, cancellation and restart retain the identical request for the central's exact-retry path. Only a matching acknowledgement advances the cursor; 409 never triggers a rewritten period or automatic new sequence. The worker respects persistent backoff, runs only in writable serving and joins the bounded shutdown before database close. The root-only connection test uses central status GET without changing sequence or push history. See API compatibility for configuration and route contracts.


Planned / not yet shipped

For accuracy, these are not current capabilities — do not rely on them:

Graft preview fetch residual

The root-only import preview makes one outbound HTTPS GET with a one-time export credential in the Authorization header. It follows no redirects, limits the body to 64 MiB (67,108,864 bytes), and bounds the fetch with a 30-second timeout. The destination checks both declared and streamed size before staging; over-limit responses return 413 with a size lower bound and the limit, without importing a truncated bundle. The server write timeout remains 60 seconds. URL user information, queries, fragments, and paths other than the bundle endpoint are refused. A large import can use about 1.4 GB of memory for a 64 MiB bundle, holds the database writer during staging and apply, and grows the write-ahead log. The 64 MiB wire limit does not bound these costs to 64 MiB.

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

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

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

Each source pull or apply durably records its own unique attempt before its lock. A failed record write refuses the operation. Every exit removes only its own record with a detached context. Startup treats any leftover record as a possible loss and sets the durable data_quality.live_capture_may_be_lost flag; read-only startup reads that warning without writing it. Exhaustion and final write failures also set the flag once a write is possible, increment tier_proxy_writes_total{provider,outcome="error"}, and log an ERROR without names, credentials or underlying error text.

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

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

The response must be a valid v1 bundle with a matching content digest. Errors expose no remote error text, transport detail, or credential material. A source 413 may supply only validated numeric size and limit fields for the refusal.

Private-address targets are intentionally allowed because company TIER servers may be internal. This leaves an admin-only SSRF residual: a root principal can cause an HTTPS request to an internal, loopback, or link-local address reachable from this server, including a hostname resolving to such an address. DNS rebinding and server-side network reachability are not restricted by an address denylist. Network egress policy can constrain that reach. The export credential is sent to the root-selected host; a wrong host receives a live key that may be able to pull the named bundle from the real source before consumption or expiry. The source URL must therefore identify the intended source server. The fetch honours proxy environment variables via http.ProxyFromEnvironment (code: internal/api/graft.go, fetchImportBundle; internal/store/graft_source.go, graftExportIdentity). The export credential is not persisted or forwarded on redirects.

v0.7.0 organizations: child upstream SSRF residual

The child upstream administration rules put the HTTPS central origin in host configuration. The central address on the Child server settings page is display-only. Only root can invoke Test connection; it reads central status. The transport contract describes credential storage, TLS checks, request bounds and refusal to follow redirects.

A residual remains: the client does not block private, loopback or link-local addresses, and it does not pin the hostname to an IP address. The configured origin can resolve to an internal service, including after a DNS change. The child can open a TCP connection there even if TLS later fails. Sending the bearer or report body requires a TLS certificate valid for the configured hostname under the host's trust roots. Root can trigger a bearer-authenticated GET /api/v1/federation/status there. The writable child's scheduled sender can send the bearer and aggregate body to /api/v1/federation/push there. Checking the returned status or body happens after the service receives the request.

This permits requests from the child to an internal HTTPS service at the host-chosen origin. It does not let a browser user choose another destination or an arbitrary path. No redirect is followed. Treat the host's destination and DNS configuration as part of this trust boundary. The credential scope map and reporting trust limits cover who can act inside an organization and what its totals establish.