Files
Rendezvous/docs/architecture/0004-atomic-ephemeral-state.md
T
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

102 lines
5.5 KiB
Markdown

# 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.