Skip to content

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; service readiness complete, activation configured for the next release
  • Date: 2026-08-22 (amended 2026-08-27: shared service, project and cohort fields; amended 2026-09-02: production hostname; amended 2026-09-07: release activation and readiness evidence — #1036)
  • Issue: #855

Context

The production hostname now exists at version-service.sebastian-software.de, owned by the public version-service repository. Production readiness was completed on September 6 in version-service #11. The verification is associated with candidate 04b56723d03c28e9b94a4db9fbbc57a2ee1a3940; this is deployment-history correlation, not attestation of the revision active inside Bunny. The release workflow now configures activation for the next release. A consuming release and its production-check evidence are still pending in #1036 and version-service #12.

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 — requests from older cohorts, not counts of retained installations.

Decision

The native pmds binary owns one advisory update-check mechanism:

  1. It starts only after Clap has accepted a regular subcommand. There is no daemon, service, postinstall hook, or separate scheduled process.
  2. Release builds enable the mechanism only when PALAMEDES_UPDATE_ENDPOINT is exactly the owned HTTPS route https://version-service.sebastian-software.de/check. Missing values keep it disabled; malformed, alternate-origin, credentialed, port-qualified, query, or fragment values fail the build. With service readiness complete, publish-native sets that variable for all six CLI artifacts, including the two host-built musl targets. Ordinary builds and general validation remain default-off. Packaging smoke checks set PALAMEDES_UPDATE_CHECK=0, explicitly inside the musl container too; this runtime opt-out is not a packaged default and does not remove the compiled endpoint.
  3. DO_NOT_TRACK=1 and PALAMEDES_UPDATE_CHECK=0 disable the mechanism before cache or network access.
  4. A platform cache records an attempted check before network I/O. Linux uses $XDG_CACHE_HOME with $HOME/.cache fallback, macOS uses $HOME/Library/Caches, and Windows uses %LOCALAPPDATA%. A per-cache operating-system file lock prevents concurrent processes from producing more than one request in the same 24-hour window. A timestamp up to one interval in the future is tolerated as clock skew; a value farther ahead is treated as corrupt and replaced by the next claim. Missing, corrupt, or unwritable cache state never fails the command; if the rate limit cannot be recorded, the request is skipped.
  5. 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.
  6. 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. Coarse dimensions reduce specificity; they do not guarantee anonymity or prevent every linkable combination.
  7. 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.
  8. 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 for 10 minutes 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.

The service accepts exactly the six request fields, bounded to 1024 bytes, and returns {"latestVersion":"<SemVer>"} on success. Invalid payloads return 400, unsupported methods 405, and configuration, registry, or analytics failures 503; the client treats these as silent, nonfatal outcomes. Rybbit acceptance requires HTTP 200 and an exact bounded {"success":true} acknowledgement. That acknowledgement does not prove later event visibility, and a client timeout can follow ingestion.

Bunny raw request logging was observed off during readiness verification; this is not a historical-deletion guarantee. Shield/per-client rate limiting is intentionally absent. Aggregate monitoring and health checks provide limited detection, not abuse prevention or proof of individual ingestion.

Release execution remains Palamedes-owned and production observation requires operator authorization. Keep #1036 and version-service #12 open until an actual published artifact and a valid matching private service-side event are verified, with sanitized evidence linked in both issues. A silent CLI exit or an unrelated aggregate increase is insufficient; colliding aggregate dimensions leave attribution ambiguous. Follow the service runbook's bounded observation window and do not delete the cache to retry an ambiguous check.

For immediate user-side disable, set DO_NOT_TRACK=1 or PALAMEDES_UPDATE_CHECK=0. Maintainers can remove endpoint embedding from future builds and publish a new patch release; existing binaries stay enabled until users opt out or install a disabled build. Service-side containment belongs to version-service operators. Disarming BUNNY_DEPLOY_ENABLED only prevents future deployments, not requests to an already 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 can be linkable without any explicit identifier. Year-month buckets retain a coarser cohort signal without promising that every combination is nonunique.

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 pmds binary-size ceiling and be remeasured on the release profile.