ADR-027: Privacy-Bounded CLI Update Check With Deployment-Gated Rollout
The native CLI cannot currently tell a user that a newer release exists. A small update request can answer that question and provide aggregate evidence about versions and platforms that make update checks. It is also a trust-sensitive network side effect: an automatic request can reveal more than its JSON body through application logging, stable identifiers, repeated attempts, or accidental command and filesystem context.
- Status: Accepted; rollout gated on endpoint readiness
- Date: 2026-08-22 (amended 2026-08-27: shared service, project and cohort fields — #1036)
- Issue: #855
Context
The endpoint does not exist yet. version.sebastian-software.dev/check is the
agreed shared route for all Sebastian Software CLIs, owned by the public
version-service
repository, and is not deployed. Shipping a hard-coded call to that hostname
before deployment would add a broken network request and falsely imply that
the server-side privacy contract was in operation.
The no-identifier boundary has one important measurement consequence. A 24-hour client cache rate-limits requests from one installation, but the server cannot recognize that installation again on a later day. Weekly unique active installations are therefore not derivable. The honest measurements are rate-limited request volume, version, OS, architecture, and CI/local distributions, and — through a coarse year-month install cohort — how many older installations remain active.
Decision
The native pmds binary owns one advisory update-check mechanism:
- It starts only after Clap has accepted a regular subcommand. There is no daemon, service, postinstall hook, or separate scheduled process.
- Release builds enable the mechanism only when
PALAMEDES_UPDATE_ENDPOINTis exactly the owned HTTPS routehttps://version.sebastian-software.dev/check. Missing values keep it disabled; malformed, alternate-origin, credentialed, port-qualified, query, or fragment values fail the build. The release workflow must not set that build variable until the production endpoint passes the deployment guide in the version-service repository. DO_NOT_TRACK=1andPALAMEDES_UPDATE_CHECK=0disable the mechanism before cache or network access.- A platform cache records an attempted check before network I/O. Linux uses
$XDG_CACHE_HOMEwith$HOME/.cachefallback, macOS uses$HOME/Library/Caches, and Windows uses%LOCALAPPDATA%. An atomic directory claim prevents concurrent processes from producing more than one request in the same 24-hour window. Missing, corrupt, or unwritable cache state never fails the command; if the rate limit cannot be recorded, the request is skipped. - The HTTPS POST body contains exactly the project identifier (
palamedes), the CLI version, Rust target OS, Rust target architecture, a CI boolean, and a year-month install cohort. It contains no installation ID, telemetry ID, command, arguments, current directory, project path, config, hostname, username, or other application identifier. - The install cohort is deliberately never finer than a month. Combined with
the other payload dimensions, a day- or week-precision value could form
singleton combinations whose daily requests become linkable across days — a
de-facto identifier. The value is derived once, persisted beside the cache
as
installed-since-v1, validated on read, and recomputed when missing, malformed, or in the future. A deleted cache restarts the cohort. - Redirects are disabled and the entire request/response operation has a two-second deadline. The response body is limited to 4 KiB. The check runs beside the command; joining it can add only the remainder of that deadline to a due fast command. Offline, invalid, oversized, failed, or panicked checks are silent and cannot affect the command's exit code.
- A strictly newer semantic version produces one short notice on stderr after command output. Stdout, including JSON output, remains unchanged.
The server half is a shared, multi-project endpoint owned by the public
version-service
repository: a Bunny Edge Scripting script that validates the documented JSON
fields against a per-project allowlist, answers with the latest released
version read from the project's distribution registry (cached briefly
server-side; publishing a release is the synchronization), and forwards only
the documented aggregate dimensions to the self-hosted Rybbit analytics
instance with neutralized IP and user-agent values. Application code does not
access or persist client IP addresses, forwarded headers, user agents, or
other identifying headers. It reads the request URL path, Content-Type, and
Content-Length only for route, media-type, and body-size validation; none of
that protocol metadata reaches the analytics sink or application logs. The
edge platform still necessarily processes network metadata as infrastructure;
the application contract is that we do not persist or query it. The sink is
swappable behind the stable wire contract without rebuilding any client.
Deployment, DNS/TLS, sink configuration, and live privacy verification remain explicit external readiness work tracked in the version-service repository. Until that work is complete, the default release build makes no request and this issue must not be described as a deployed service.
Alternatives Considered
Hard-code the proposed endpoint before deployment
Rejected. A non-fatal failure is still an undisclosed, predictably broken network side effect and cannot prove the server privacy contract.
Let clients query npm or GitHub directly
Rejected for the client. That would move the request to a third party, would not implement the self-controlled aggregation contract, and would make its data-retention policy part of CLI behavior. The shared service does read the distribution registry server-side as its version source — behind the owned endpoint, where the retention contract stays ours.
Per-project Cloudflare Worker with Workers Analytics Engine
Superseded (original decision of 2026-08-22). Cloudflare is not part of our operated stack, and a per-repository service does not scale to the other CLIs. The shared Bunny Edge Scripting service with the Rybbit sink replaces it; the reviewed request-validation contract carried over unchanged.
Add a stable anonymous installation identifier
Rejected. It would make weekly unique counts possible but violates the chosen minimal-data contract and materially increases fingerprinting and disclosure risk. Identified telemetry, if ever wanted, is #856 as an explicit opt-in with its own decision.
Send a day-precision install date
Rejected. Roughly 365 values per year multiplied with the version, OS, and architecture dimensions produce singleton cells; a singleton that pings daily is trackable without any explicit identifier. Year-month buckets keep the cohort signal while individuals stay inside their cohort.
Count IP addresses as installations
Rejected. NAT, dynamic addressing, VPNs, and CI make the measure inaccurate, and retaining IP-derived identity contradicts the privacy requirement.
Detach the request and let the process exit
Rejected. A detached thread has no delivery or notice guarantee once a short CLI process exits. The bounded join makes ownership explicit and caps the only possible delay at the documented two-second deadline.
Consequences
- Update notices and aggregate request evidence share one small, explicit protocol without creating general CLI telemetry.
- Opt-outs, stream separation, rate limiting, concurrency, time, transport, cohort derivation, and semantic-version comparison have deterministic tests; server validation is tested in the version-service repository.
- An offline due check is not retried until the next 24-hour window. This favors bounded impact over maximizing observations.
- A release build with no endpoint has no cache or network side effect.
- Request volume is a usage proxy, not a unique-installation count; the year-month cohort adds a retention signal without an identifier.
- The service and its deployment guide live outside this repository; changing the endpoint host requires a coordinated build-allowlist update here.
- Adding TLS/HTTP code to the shipped CLI must remain within the separate
pmdsbinary-size ceiling and be remeasured on the release profile.