Skip to content

0220: Immutable local server registration custody

Date: 2026-10-18 Status: Withdrawn 2026-10-19. Reverted: the schema-2 cutover had no migration, so every older server record made servers list, stop, gc and port allocation throw, and every tunnel started before the change read as unknown. Server stop again signals the process group.

A server ID names a local service or one preview scope. The registry currently overwrites an existing ID, and cleanup can remove a later registration by matching only its PID. A cached PID cannot prove that its process generation or registration still belongs to the caller.

  • Last writer wins preserves the existing overwrite and custody loss.
  • A PID and process token distinguish process generations, but the same process can unregister and register again under the same ID. They cannot distinguish those registrations.
  • A read followed by a write leaves concurrent callers able to pass the same check.
  • A schema compatibility reader would let records without a registration receipt participate in cleanup authority.

Server records use schema version 2 and require a random registration_id. Registration, touch and removal hold the same per-ID no-clobber lease under .harnery/servers/.custody/. A lease whose owner is live or unknown refuses mutation; age alone never permits recovery.

registerServer(input, options) refuses any existing malformed, older-schema, remote-host or unknown record. An exact repeat of the same local PID, process start token, owner instance, kind, type, scope and helper witnesses is idempotent: it returns the original record without rewriting timestamps or metadata. A different registration may replace a record only after every recorded process is proven absent.

unregisterServer(record, options) accepts the observed schema-2 receipt, not an ID or PID guard. Under the lease it compares the registration ID and immutable process identity against the current record before removal. withServerRegistrationCustody(record, options, effect) holds that same lease through a synchronous result or a settled Promise, and rejects missing or forged receipts before invoking the callback. The callback receives no mutation bypass. Activity updates preserve the receipt. Stop completion and stale pruning use the receipt they observed, so they cannot remove a replacement registration.

All direct callers cut over together. A caller preserves its returned receipt for later cleanup; a status lookup never manufactures custody for a cached PID. Existing schema-1 records remain on disk and refuse replacement. No compatibility alias or automatic migration is added. A separate explicit migration would need its own custody proof.

Focused reproductions cover live duplicate registration, immutable idempotency, owner transfer, malformed and unknown records, simultaneous contenders, stale cleanup after replacement, and lease contention. Public callers and host integration tests use the same receipt API. Process signaling remains governed by ADR 0218.

Tunnel launch, reload and stop also hold a per-instance lifecycle lease. A Tailscale route is identified by its HTTPS port and path, independent of the instance name or Serve/Funnel mode. Route leases use that same identity, and retained route inventory refuses a second named owner. Unknown inventory refuses provider effects.

Canonical tunnel publication and deletion hold the exact server receipt as well. A refused launch writes its witnesses to a unique failure file instead of replacing a winner’s state. Tailscale admission atomically publishes pending route intent before the provider mutation; only a successful effect publishes ready state. A lost provider outcome remains pending and cannot produce a live delivery URL. Provider cleanup failure retains the original intent.

Saved numeric PIDs do not establish that a tunnel still owns its URL. The toolkit now exposes tunnelReadiness(state, verifier), returning ready, dead or unknown. The verifier is mandatory. There is no PID-only boolean alias or default verifier.

The injected verifier observes each original PID and saved process token and holds the original registration through those observations. The product adapter createTunnelReadinessVerifier(options) matches the saved nonce, gate PID, token, port and URL to the local registry receipt before taking custody. A status read never adopts a newly observed token.

Cloudflare state uses cloudflared_pid and cloudflared_start_token for its provider process. Tailscale has no provider child and requires a ready external route intent. Obsolete generic provider fields are removed from the public type. Raw records containing those fields remain on disk as unknown evidence; they cannot authorize reuse, replacement, reload or shutdown. No compatibility reader translates them into current authority.

Ready requires the original registration and every required process generation to match. Dead requires positive ESRCH absence for every original process. Missing authority, changed generations, uncertain probes, partial absence and pending provider effects remain unknown. up refuses unknown state before cleanup or a new launch. Status labels it unknown, and file and artifact links select a tunnel only when readiness is ready. Existing destructive custody and lifecycle leases remain required.

Pending Tailscale intent permits only its existing exact cleanup path. The original receipt and every saved process witness remain required; shutdown must prove process absence and provider cleanup success before clearing captured state. Pending intent never authorizes reuse or reload. Missing canonical Cloudflare process witnesses refuse effects even when the gate receipt matches. Cleanup requires the recorded Tailscale mode, path and HTTPS port without defaults. Tailscale ready publication keeps the original pending receipt after the provider effect; it never registers again to adopt a replacement nonce.

Commands read the exact selected state filename and require its raw instance name to match the request. Unreadable, malformed or mismatched existing frames remain unknown; only ENOENT means absent. Enumeration uses the same admission rule. Publication and provider effects share the complete original receipt match, including local host, service type, saved URL and gate port.

findLiveTunnelForOrigin and tunnelFileUrl also require the explicit verifier. The toolkit remains independent of coordination imports; commands and product readers supply the registry adapter. All callers cut over together.

Focused counterexamples cover legacy fields, recycled PIDs, missing or replaced receipts, unknown retention before effects and delivery URL suppression. Effect and guard reversals must fail each new callback’s intended assertion, then pass after exact restoration.