ADR-0009 — Vehicles as a second Ownership consumer¶
Status: Accepted Date: 2026-07-29 Supersedes: — Superseded by: —
Context¶
Ownership was built generic on purpose. Code/Ownerships/README.md states its purpose as
"one generic claim over an entity — who owns it, and who else may access it — applying
uniformly to doors, vehicles, and any other ownable world object" and names its own gap
explicitly under "Not responsible for": "What kind of entity is ownable, or what owning one
lets you do (open a door, drive a vehicle) — that is each entity's own module (World,
eventually Vehicles)." ADR-0006 and
08_DATA_MODEL.md §3 both
already name vehicles as a future IInventoryHolder (trunk space), alongside characters and
world containers, sharing the one inventory implementation. Nothing has built the Vehicles
module either of those documents assumes will eventually exist — this ADR is that module's
foundational decision, the same way ADR-0007 was Applejack.Movement's.
What the legacy game actually did. Legacy/13_DEPENDENCY_GRAPH.md §1–§2 identifies the
vehicles plugin as one of only two engine-level Lua files (alongside sv_entity.lua) that
genuinely diverged between the two legacy copies audited — everything else was content. The
dependency graph records the mechanism precisely: items/base/vehicle.lua is an inventory
item that delegates to the vehicles plugin, which "manufactures vehicle items" —
i.e. a vehicle was purchased and held as an item, then turned into a world entity through the
plugin, not bought and spawned directly. Legacy/04_ENTITIES_DOORS_PROPERTY.md §1 further
confirms vehicles used the same generic _Owner table as doors (cider.entity.makeOwnable),
just with no dedicated documentation chapter of their own — the original invested far less
design into vehicles than into doors, jobs, or the economy.
What already exists to build on. The door purchase flow —
DoorComponent.RequestPurchaseDoor → PropertyStore.PurchaseAsync — is this codebase's one
traced, reviewed purchase pattern
(07_NETWORKING.md §7):
load-or-create the buyer's economy and the entity's ownership, check unowned, check
afford, assign ownership before debiting money (deliberately reproducing the safe half of
the legacy failure mode named in 13_DEPENDENCY_GRAPH.md §3, never the unsafe half). Vehicles
are the second thing this codebase will ever sell. Ownerships.md's own "Future improvements"
already names a second AccessLevel as waiting on "a real consumer" beyond AccessLevel.Use
— vehicles (which need to distinguish who may drive from who may merely ride or access
the trunk) are plausibly that consumer.
What does not exist yet. No physical player presence existed anywhere in this codebase
until ADR-0007's PawnComponent/IPawnLocator
(Milestone 11, currently landing — see Code/Movement/). Actually entering and driving a
vehicle is a pawn-to-pawn (or pawn-to-vehicle-entity) interaction with no precedent in this
codebase at all; this ADR deliberately scopes itself to ownership, purchase, and inventory,
leaving driving itself to a follow-on decision once Applejack.Movement has landed and been
exercised by at least one real consumer (the admin teleport/spectate commands it was built
for).
If we decide nothing, Vehicles stays the one module every existing document gestures at but
none commits to, and any implementation attempt improvises its own purchase flow and access
model instead of reusing the two that already exist.
Decision¶
Applejack.Vehiclesis a new module, depending onOwnerships,Economy,Items, andCharacters. A vehicle is sold as an instanced key item, not spawned directly on purchase:VehicleKeyBehaviour(anItemDefinitionbehaviour, alongsideEdible/Equippable/Container) stamps the purchased vehicle'sOwnableIdinto the newItemInstance'sItemStateBagat mint time. The vehicle'sOwnershiprecord is the single source of truth for who owns it and who may access it; the key is a physical, losable, giveable credential pointing at that record — never a second copy of the ownership data.
Concretely, mirroring DoorComponent/PropertyStore exactly:
namespace Applejack.Vehicles;
/// <summary>
/// One purchasable, ownable vehicle. See Documentation/Architecture/08_DATA_MODEL.md §3 (the
/// vehicle as IInventoryHolder for its trunk) and ADR-0009.
/// </summary>
public sealed class VehicleComponent : Component
{
/// <summary>Generated once on first spawn -- the persistence key for this vehicle's
/// Ownership record, mirroring DoorComponent.DoorId.</summary>
[Property] public Guid VehicleId { get; set; }
[Property] public string DisplayName { get; set; } = "Vehicle";
[Sync(SyncFlags.FromHost)] public bool IsOwned { get; private set; }
[Sync(SyncFlags.FromHost)] public string OwnerName { get; private set; } = "";
// RequestPurchaseVehicle mirrors DoorComponent.RequestPurchaseDoor exactly: Identity from
// Rpc.Caller.GetCharacter(), Distance from IPawnLocator (dealership lot, not "anywhere"),
// Affordability and Concurrency delegated entirely to VehicleStore.PurchaseAsync.
[Rpc.Host]
public void RequestPurchaseVehicle() { /* delegates to IVehicleService.PurchaseAsync */ }
}
/// <summary>Per-instance state stamped onto a vehicle key ItemInstance at mint time. Empty
/// ItemStateBag entry name: "VehicleKey". See ADR-0006's ItemStateBag and ADR-0009.</summary>
public sealed class VehicleKeyState
{
public required OwnableId VehicleId { get; init; }
}
IVehicleService.PurchaseAsync(Character buyer, VehicleComponent vehicle) follows
PropertyStore.PurchaseAsync's exact sequence — load-or-create economy, load-or-create
ownership, reject if already owned, check afford, assign ownership, then debit, then mint
one ItemInstance from a VehicleKey ItemDefinition with VehicleKeyState.VehicleId set,
into the buyer's inventory via the existing IInventory/InventoryStore — no new persistence
mechanism, no new economy path.
Access. AccessLevel gains Drive and Passenger alongside the existing Use
(Ownerships.md's named future work), and IOwnershipService.HasAccess takes an
AccessLevel parameter rather than being a bare boolean. Giving someone your car is
IOwnershipService.GrantAccessAsync(vehicleId, granteeCharacterId, AccessLevel.Drive) — the
same generic mechanism a Team-owned door will eventually use, applied to a second consumer
for the first time. Handing over the item (dropping the key, trading it) and handing over
access are deliberately separate acts: losing your key without an access grant to match it
still means the finder can enter and drive an unlocked vehicle, exactly as a real lost car key
would — access, not possession of the key object, is the actual behavioural gate this ADR
introduces, and matching the two is a UX affordance built on top, not a shortcut around it.
Explicitly out of scope for this ADR: entering/exiting/driving a vehicle (needs
Applejack.Movement's PawnComponent, exercised by a real consumer first), fuel/upkeep as a
recurring cost, an ownership cap or impound mechanic, and dealership UI. Each is a smaller,
independent follow-on decision once this foundation exists — see Notes.
Alternatives considered¶
Purchase spawns the vehicle directly, no item indirection¶
The simpler mental model, and closer to how DoorComponent's purchase works today (a door is
never "held," it's a fixed map object you unlock a claim on). Rejected as the sole mechanism
because a vehicle, unlike a door, is legacy's own precedent for "ownable thing represented as
an inventory item" (items/base/vehicle.lua) — losing that precedent would also lose, for
free, everything ItemStateBag/instanced inventory already gives us: the key occupying
scarce inventory space, being stealable, tradeable, and droppable with /dropmoney-style
physicality, with zero new subsystem. A direct-spawn model would need its own answer to "how
do I lend my car to a friend" that AccessGrant already answers for free once a key exists to
carry the VehicleId around. This ADR chooses the key; a server operator who wants
instant-spawn dealerships (no key, no loss risk) is free to build that as a separate,
simpler Vehicles configuration later — this decision does not preclude it, it just isn't the
default.
One dedicated VehicleOwnership type instead of reusing Ownership¶
Would let vehicle-specific fields (VIN, mileage, colour) live directly on the ownership
record instead of split between Ownership and a separate Vehicle domain type. Rejected for
the same reason Ownerships.md gives for why Ownership is generic at all: "this module has
no idea what a door is" — duplicating that split per entity type (a VehicleOwnership,
eventually a BoatOwnership) is exactly the "three-different-runtime-types-in-one-field"
shape D-16 already named as a legacy defect, just
inverted. Vehicle (VIN, model, colour) stays its own domain type in Applejack.Vehicles,
holding an OwnableId the way Door already does — one more instance of a pattern already
proven, not a new one.
AccessLevel.Use covers driving; no new enum members¶
Cheapest option — ship nothing new on Ownership. Rejected because a vehicle genuinely needs
to distinguish "may drive" from "may ride along" from "may open the trunk," three different
answers a door never had to give (a door is binary: you may open it or you may not). Reusing
one flat Use for all three would force Vehicles to invent its own second access list
alongside Ownership's, recreating exactly the "two systems tracking who's allowed to do
what" seam Ownership exists to prevent.
Consequences¶
Positive¶
- Reuses two already-reviewed patterns (
PropertyStore.PurchaseAsync's purchase ordering,ItemStateBag's per-instance state) instead of inventing a third economy path or a third state-storage mechanism. AccessLevelfinally gains its named second member, and it's driven by a real caller (Vehicles) rather than spec'd speculatively ahead of one.- Vehicle theft, loaning, and resale all fall out of existing mechanics (item transfer,
AccessGrant) instead of needing bespoke vehicle-specific plumbing. - Cleanly separable from the movement/pawn-binding work still landing — this module can be
built and unit-tested under
OfflineTests/(ownership, economy, item state are all engine-agnostic) well before anyone can physically sit in a car.
Negative¶
- Two sources of truth to keep synchronised in spirit, if not in data: the key item can be
duplicated by a bug, traded to the wrong person, or destroyed independently of the
Ownershiprecord it points at. This ADR does not solve "what happens to theOwnershiprecord if every copy of its key is lost" (see Notes) — an operator-visible anomaly, tracked the same honest wayPropertyStore.PurchaseAsync's header comment already tracks its own rare debit-ordering anomaly. - A vehicle now depends on
Itemsin addition toOwnerships/Economy/Characters— one more module edge than a door has, and one more thingVehicles' ownOfflineTestscoverage has to exercise. - Splitting "possess the key" from "hold the access grant" is a real design subtlety a future contributor could easily miss and quietly conflate — flagged explicitly in Compliance below specifically so a reviewer checks for it.
Neutral¶
Applejack.VehiclesjoinsApplejackBootstrap.Modules' fixed compile-time list, same as every other module (02_MODULE_MODEL.md §4).Vehicle(VIN, model, colour) is a new domain type;Ownership/ItemDefinition/ItemInstancegain no new required fields — vehicles fit the existing shapes rather than reshaping them.
Compliance¶
IVehicleService.PurchaseAsync's ordering (ownership-then-debit-then-mint) is reviewed againstPropertyStore.PurchaseAsync's own header comment at implementation time — any divergence needs its own justification, not silent drift.- Every future
[Rpc.Host]method this module adds is walked against the exploit checklist (Standards/03_TESTING_STANDARDS.md), same as every other RPC in this codebase. - Review checklist item for this module specifically: any PR that adds a feature checking "do
they have the key item" as a stand-in for "do they have
AccessLevel.Drive" (or vice versa) should be flagged — the two are deliberately separate per this ADR's Decision, and conflating them is the single most likely way this design gets quietly undone.
Notes¶
Open questions, deliberately deferred rather than decided here:
- What happens when a vehicle's last key is destroyed or lost with no matching AccessGrant
left standing — a re-key mechanic (e.g. via a dealership, for a fee), a permanent lock-out,
or something else. Needs its own follow-on decision once ownership-cap/impound design
(a separate flagged task) exists to interact with.
- Whether VehicleKeyBehaviour should refuse to stack (almost certainly yes, mirroring any
other unique/non-fungible item — ItemDefinition.IsStackable = false is already the default
per ADR-0006) — not expected to be controversial, noted so it isn't silently forgotten.
- Entering/exiting and actually driving a vehicle is intentionally not decided here — write a
follow-on ADR once Applejack.Movement's PawnComponent has a second real consumer beyond
admin teleport/spectate, per this ADR's Context section.
Revisit this when Jobs lands and Ownership's OwnerKind.Team/.Gang resolution gap
closes (Ownerships.md's own named gap) — a team-owned squad car is a plausible, common case
this ADR's AccessGrant-only answer will feel thin against once faction ownership actually
resolves.