Files
Rendezvous/docs/architecture/0004-atomic-ephemeral-state.md
KyuubiYoru 49564c7e7e
quality-gate / quality (push) Successful in 55s
feat: add presence-gated session leases (#7)
Closes #7
2026-07-16 05:58:47 +02:00

5.5 KiB

ADR 0004: atomic ephemeral state and single-active availability

  • Status: Accepted
  • Date: 2026-07-16
  • Tracking: #6

Context

Listings, leases, endpoint observations, join attempts, and replay decisions must move together. A partially committed authorization can expose an expired listing, reuse a capability, or introduce an endpoint that was never authorized. V1 is a single-active service, so it needs honest bounded in-memory behavior rather than a database-shaped abstraction that implies unavailable durability or scale.

Decision

IEphemeralRendezvousStore is the atomic boundary for directory, lease, presence, attempt, endpoint, replay, revocation, and drain transitions. The v1 implementation serializes each transition under one process-local lock. This deliberately favors simple, auditable correctness at the initial 25,000-listing/10,000-attempt ceiling. It retains only immutable listing data, opaque credential fingerprints, observed endpoints, monotonic deadlines, and bounded idempotency/replay records.

Every collection has an independent configured ceiling. An operation checks all of the capacity it needs before changing any collection. Exhaustion returns CapacityExceeded; it does not evict live state, partially insert an operation, or grow a fallback queue. Policy-provided per-owner listing and per-tenant active attempt quotas are evaluated inside the same creation transition, so concurrent requests cannot pass a check performed outside the store. New join authorization returns ServiceUnavailable when the atomic store is unavailable and Draining once drain starts.

Time and cleanup

Expiry uses an injected monotonic clock. Wall time is used only to return an informational ExpiresAt value. Moving the wall clock forward or backward cannot expire or prolong authority. Cleanup runs deterministically at the start of every store operation and removes presence, attempts, listings, replay entries, idempotency records, and revocations at their deadline. Removal of a listing also removes its presence handle and every linked attempt before another caller can observe the store.

Concurrency and idempotency

  • Listing registration and join-attempt creation use a tenant-and-owner-scoped idempotency key plus a canonical request fingerprint. An exact duplicate returns the original live result; reuse with different input returns Conflict; replay after the resource has expired returns Expired until the bounded idempotency record itself expires. Configuration requires idempotency retention to cover every listing and attempt lifetime, preventing a live duplicate after eviction.
  • Lease renewal is compare-and-swap by version. A stale renewal returns the latest version as Conflict. Renew/delete races are serialized: renewal either commits before deletion or observes the listing as absent.
  • Host presence refresh is an atomic whole-endpoint replacement because NAT mappings can legitimately change. Attempt capabilities are different: the first endpoint bound for each role wins, an identical datagram is idempotent, and a different replay is rejected. Introduction is consumed once atomically.
  • Cancellation is checked before waiting for the lock and again after acquiring it. A cancellation observed at either point makes no change. Once a synchronous transition starts, it completes atomically and does not expose partial state.

Visibility and revocation

A listing is visible or joinable only when its lease and authenticated UDP host presence are both fresh. Public browsing is tenant/protocol scoped, excludes unlisted sessions, and uses a stable listing-ID order with the contract page ceiling. Revoking a listing or principal removes every listing, presence, and attempt path in the same transition. A revocation is inserted before removal; if the bounded revocation pool is full, the operation rejects without deleting anything.

Restart and graceful drain

A process restart creates a new store instance ID and starts empty. Old listing, lease, attempt, endpoint, idempotency, and consumption state is not recovered. Publishers must re-register; old callers receive typed NotFound, Expired, or ServiceUnavailable outcomes rather than an ambiguous success. No database is required or supported for the single-active MVP.

Drain is idempotent. It immediately rejects new registrations, attempts, and lease extensions, while already-created attempts may bind endpoints and consume their introduction during the configured window (at most 30 seconds). At the deadline all active state is cleared atomically. Readiness is false while draining or unavailable, and application shutdown starts drain before teardown.

Future shared-store mapping

The interface uses explicit typed outcomes, TTLs, compare-and-swap versions, idempotency records, and all-or-nothing multi-record transitions. A future Redis implementation therefore requires authenticated transport, tenant-prefixed keys, server-side scripts or transactions for each transition, TTLs based on the store's authoritative time, and deterministic mediator routing. It must preserve these semantics and pass the same contract tests before issue #18 may enable more than one active instance.

Consequences

  • V1 has deterministic failure and restart behavior without durable gameplay state.
  • A single lock is a measured capacity constraint, not a claim of horizontal scale.
  • Transport and HTTP modules cannot bypass the store for authorization decisions.
  • Operational code must treat CapacityExceeded, Draining, and ServiceUnavailable as normal typed overload/availability outcomes.