Skip to content

lease.fbs โ€” the Ephemeral Ownership Lease

Source: modules/Descent.Vtt.Protocol/schemas/lease.fbs at build time. Nothing was withheld from this file.

// ADR-050 โ€” the Ephemeral Ownership Lease, on the wire.
//
// Read schemas/README.md first; its three rules are assumed here.
//
// **Why a fifth schema rather than fields on command.fbs and delta.fbs.** A lease request
// is not an intent: `SubmitIntent` carries a *proposal the rules engine adjudicates*, and a
// lease is a claim on the transform path that produces no domain event at all. Folding it
// into `SubmitIntent` would have put a message with no action name and no arguments through
// the action-resolution path, where every field it does not use is a field somebody must be
// told to ignore. And a lease grant must reach the client **faster than the tick** (ยง5.1.1
// clause 6 pushes forced expiry immediately), so it cannot ride inside `WorldDelta`, whose
// whole shape assumes tick cadence.
//
// **Every deadline on this wire is a tick sequence number, never a wall clock or a
// duration.** Q-028 says so in as many words -- "expressed in tick sequence rather than wall
// clock" -- and the wire is where that matters most: a duration would have to be interpreted
// against the receiver's clock, which is the one thing a distributed lease must never depend
// on. The client compares `expires_at_tick` against the `transform_tick` it is already being
// sent in every `WorldDelta`.
//
// **What this schema deliberately does NOT carry.** No viewer id on the request. Same rule
// as command.fbs: ยง8.2 forbids the client asserting its own identity, and the server
// resolves the caller from the connection. `holder` appears only on the SERVER -> client
// direction, where it is an answer rather than a claim.
include "snapshot.fbs";
namespace Descent.Vtt.Protocol;
/// Why a lease request did not result in the caller holding the lease.
///
/// An enum rather than a bool, because "rejected" and "the room is not ready" require
/// different client behaviour: the first retries after `expires_at_tick`, the second waits
/// for hydration. Collapsing them would make the client poll through a hydration.
enum LeaseRejection : ubyte {
/// Another viewer holds it. `holder` and `expires_at_tick` are populated.
Held = 0,
/// The room has not finished hydrating (ยง7.1). Not a refusal -- a "not yet".
NotReady = 1,
/// No such entity in this room.
UnknownEntity = 2,
/// A renew or release from a viewer that is not the holder.
NotHolder = 3,
}
/// What the caller wants done with a lease.
///
/// **`Unknown = 0` is the entire reason this is an enum and not a bare `ubyte`.** FlatBuffers
/// gives an absent scalar its default, and the default of a `ubyte` is `0` โ€” so a truncated
/// buffer, a zero-filled one, or one written by a peer that never set the field decodes as
/// whatever value happens to sit at `0`. That value used to be `Request`, which made *silence
/// on the wire* decode as a **claim on the transform path**: the one direction ADR-050 must
/// never fail in, and the mirror of the argument `LeaseResponse.granted` already records for
/// the server -> client leg. Zero now names a verb that `LeaseCodec` refuses.
///
/// The values are therefore **1-based on purpose**; do not renumber them to close the gap.
enum LeaseVerb : ubyte {
/// Not a verb. Never written deliberately โ€” it is what an absent or truncated `verb`
/// field reads as, and `LeaseCodec.TryDecode` answers it with `UnknownVerb`.
Unknown = 0,
/// Take the lease. Idempotent for the current holder.
Request = 1,
/// Extend a held lease by Q-028 ticks.
Renew = 2,
/// Hand the lease back -- the finalise path.
Release = 3,
}
/// Why a lease ended. Decides what the client does with the token it was dragging.
enum LeaseEndReason : ubyte {
/// The holder finalised. Hand-back keeps the position (ยง5.1.1 clause 5).
Finalised = 0,
/// Q-028 ticks elapsed with no renewal. The entity **returns** to `stale_anchor`.
Expired = 1,
/// GM override, entity destroyed, room paused, timeline checkout. Also returns.
Forced = 2,
}
/// Client -> server. Take, renew or drop the lease on one entity.
///
/// One table for all three verbs rather than three tables: they share every field, and a
/// client that can encode a renew can encode a request. The verb is the only difference and
/// it is one byte.
table LeaseCommand {
/// ยง9.6: every packet carries this, field id 0.
protocol_version: uint32;
/// The entity being claimed. **Not an authority claim** -- see the header note.
entity: Uuid;
/// **An enum rather than the `ubyte` this was.** The original note here read "a ubyte is
/// what this already is on the C# side, and the three values are exhaustive by
/// construction" โ€” the second half was the defect. Three values are exhaustive only over
/// what a *correct* client writes; the wire also carries what a truncated or hostile one
/// writes, and there the absent value is `0`. See `LeaseVerb`.
verb: LeaseVerb;
/// Correlates the grant or rejection back to the pointer event that caused it, so a
/// client that issued two grabs quickly can tell which one was answered.
correlation: Uuid;
}
/// Server -> client. The answer, pushed rather than polled.
///
/// **`granted` is a field and not the absence of `rejection`.** A client that inferred a
/// grant from a missing field would treat a truncated or unknown-version message as
/// permission to predict, which is the one direction this protocol must not fail in.
table LeaseResponse {
protocol_version: uint32;
/// Echoes `LeaseCommand.correlation`.
correlation: Uuid;
entity: Uuid;
/// True only when the addressed viewer holds the lease.
granted: bool;
/// Meaningful only when `granted` is false.
rejection: LeaseRejection;
/// The tick the lease expires ON (Q-028: granted_tick + 40). The client renews before
/// this and stops predicting at it.
///
/// Populated on rejection too, when `rejection` is `Held`: it is when the entity next
/// becomes free, so a rejected client retries once instead of polling.
expires_at_tick: int64;
/// The current holder, when `rejection` is `Held`. Populated so the UI can say who has
/// the token rather than that it is merely unavailable.
holder: Uuid;
}
/// Server -> client. A lease ended without the holder releasing it.
///
/// Separate from `LeaseResponse` because it is unsolicited: there is no correlation to echo,
/// and a client that received it as a response would have nothing to match it against.
table LeaseRevoked {
protocol_version: uint32;
entity: Uuid;
reason: LeaseEndReason;
/// Where the entity returns to. **Labelled stale on purpose** (ADR-050): this is where it
/// was when the lease was taken, not where the drag had reached, because the drag was
/// never committed. ยง5.1.1 requires the client render the return as deliberate rather
/// than as a glitch, which it can only do if it knows the return is a return.
stale_anchor_raw_x: int32;
stale_anchor_raw_y: int32;
}
/// The coalesced advisory uplink of ADR-050 clause 7.
///
/// **One message per room per tick, not one per drag.** F-39 found that the server cannot
/// filter a dragged entity out of an observer's AOI because it only knows the anchor -- so
/// a GM dragging a hidden creature behind cover would broadcast its every coordinate. Lease
/// holders therefore publish an advisory position for filtering only.
///
/// **These values are non-authoritative by construction** and produce no events, no
/// database writes and no trigger evaluation. They exist so ยง9.6.3's allowlist can be
/// recomputed against where the entity actually is.
table LeasePositionsUpdate {
protocol_version: uint32;
tick: int64;
entities: [Uuid];
raw_x: [int32];
raw_y: [int32];
}
root_type LeaseCommand;
/// Identifies a lease buffer on disk or in a capture.
file_identifier "DVL1";