ADR-0013 — Housing as property groups¶
Status: Accepted Date: 2026-07-31 Supersedes: — Superseded by: —
Context¶
Legacy had no housing concept beyond individual door ownership
(Legacy/01_FEATURE_INVENTORY.md). It
did, however, have master/slave door linking (setMaster etc.,
Legacy/01_FEATURE_INVENTORY.md), and this
codebase's own Milestone 7 World system already named that as deferred, framed exactly as this
ADR's starting point: "master/slave door linking... becomes property groups"
(ROADMAP.md, Milestone 7 build item #43). Housing is not a feature invented
from nothing — it is that already-named deferral, finally scoped.
What already exists to build on. Door/DoorComponent/IPropertyService/PropertyStore
(purchase, lock, seal — 20_WORLD_LOCKS_AND_CONTAINERS.md),
WorldContainer/LockableContainerComponent (a second ownable, lockable entity type, proving
Lock/Seal is a reusable capability), and Ownership/OwnerRef/AccessGrant/AccessLevel
generically covering "who owns this, who else may access it."
What forces a real decision. A property group is not just "N doors with the same owner" —
that's already achievable today by buying N doors individually. The actual gap is: does buying
one door in a group grant access to the others, does the group have its own purchase price
distinct from the sum of its doors, and does access-granting propagate (grant a friend access to
"the house" rather than to each door individually)? None of that exists yet, and
Ownership/AccessGrant currently model access per-entity, not per-group.
Decision¶
- What a property group actually is: composition, via a new
OwnableIdfor the group itself.OwnableIdis already just a strongly-typedGuidwrapper (Code/Ownerships/OwnableId.cs), andDoor.Idalready is anOwnableIddirectly, not a translated foreign key (that same file's header comment) — so "the group is its own ownable thing" costs nothing new inOwnerships, which stays exactly as generic as it is today. A property group gets oneOwnershiprecord, keyed by a freshOwnableIdminted for the group, exactly like aDoororWorldContainergets one for itself. Member entities (Door,WorldContainer) each gain a new, independently-addedGroupId : OwnableId?field (nullable — most doors and containers are never part of a group). This is deliberately not generic onHousing's side either:Door.GroupIdand (if/when Vendors or anotherWorldentity wants to join a group)WorldContainer.GroupIdare separate fields on their own types, each added by the module that owns that type, the same wayDoor.IsLockedandWorldContainer's lock state are separately-owned fields sharing a capability rather than a base class. The association alternative (a tag/set layered on top of individually-owned doors) is rejected: it can't give the group its own singleOwnershiprecord, which points 3 below needs. - Purchase semantics: bought as one unit, at a group-level price. A
Housingmodule owns aHousingListingDefinitionGameResource(ADR-0004's precedent —ItemDefinition,JobDefinition— for content authored outside C#), naming a fixed set of memberOwnableIds and a group price.Housing.PurchaseGroupAsyncmints the group'sOwnableIdthe first time the listing is bought, assigns ownership to it via the existingIOwnershipService.AssignOwnerAsync(unchanged), sets each named member'sGroupIdto point at it, and debits the group price — replicatingPropertyStore.PurchaseAsync's own ownership-before-debit ordering and its per-entity_purchasesInFlightconcurrent-purchase guard (Code/World/PropertyStore.cs), just keyed by the group'sOwnableIdinstead of a door's. Player-assembled linking (closer to legacy'ssetMaster) is rejected: it can't give the group a real, distinct price (assembling from already-individually-purchased doors makes the group free, or forces an awkward separate "upgrade to group" transaction), and it collapses back into the "N doors with the same owner, computed on demand" alternative this ADR's own Alternatives section already rejected as not expressing "buy this house as one unit." - Access propagation: nothing to cascade, because
IOwnershipServiceis never touched. A group member's ownOwnershiprecord (keyed by the door's ownOwnableId) is simply never populated once it's part of a group —PropertyStore.SetLockedAsyncand any futureHousing-aware access check resolve ownership viadoor.GroupId ?? door.Idbefore callingIOwnershipService.LoadOrCreateAsync/HasAccess, so every member of a group defers to the same, singleOwnershiprecord (the group's) for owner and everyAccessGrant. Granting a friend access to "the house" is exactly one call to the existingIOwnershipService.GrantAccessAsync(groupId, ...)— there is no second copy of the grant to propagate to member doors, because member doors never carry their own. This resolves the negative consequence this ADR originally flagged ("HasAccess's resolution logic likely needs to become group-aware") more cheaply than expected:IOwnershipServicegains zero new surface, zero group concept, and stays exactly as ignorant of what a door or a group is as it already is of what a door is today (this ADR's own rejected "GroupIdon genericOwnership" alternative below). All group-awareness lives inWorld/Housing's own call sites, as one small resolution step (door.GroupId ?? door.Id) added at each place that already loads a door'sOwnership—PropertyStore.SetLockedAsynctoday,Housing's own equivalent checks for anything it owns directly. - Confirmed: no dependency on Milestone 13. Milestone 13 (Character creation, ADR-0011)
shipped and merged without touching
Code/Ownershipsat all —OwnerKindis still exactly{ Character, Team, Gang }(Code/Ownerships/OwnerKind.cs), andCode/Ownerships/README.mdstill namesOwnerKind.Team/.Gangresolution inHasAccessas a gap belonging to a future Jobs-module pass, unrelated to Character. Housing depends only onOwnershipandWorld, as this ADR's own Consequences → Neutral section already said, and that holds unchanged.
Alternatives considered¶
No new concept — a property is just "the doors a player owns," computed on demand¶
Cheapest option, ships nothing new. Rejected as the sole answer: it can't express "buy this house as one unit" or "grant a friend access to the whole house in one action," both of which are the actual point of calling this a housing feature rather than "doors, but more of them."
Extend Ownership itself with a GroupId field rather than a separate Housing module¶
Would put group membership directly on the generic Ownership record. Rejected for the same
reason ADR-0009 rejected a dedicated VehicleOwnership type in the other direction —
Ownership's whole design point is that it "has no idea what a door is"; a housing-specific
GroupId on every Ownership record (including vehicles, which will never have one) reverses
that. A Housing module holding its own group records, each pointing at member OwnableIds,
keeps the generic/specific split intact.
Consequences¶
Positive¶
- Finally resolves a gap this codebase has named and deferred since Milestone 7, rather than leaving it open indefinitely.
- Reuses
Ownership,AccessGrant, andLock/Seal(WorldContainer's proof that these are genuinely reusable) instead of inventing new mechanics.
Negative¶
- Be honest:
PropertyStore.SetLockedAsync(and any other existing call site that loads a door'sOwnershipdirectly bydoor.Id) has to change to resolvedoor.GroupId ?? door.IdonceDoorgains that field — a small, mechanical edit, but to already-shipped, already-tested code (Worldmodule, Milestone 6/7), not a purely additive one. Resolved more cheaply than originally feared:IOwnershipServiceitself needs no new surface (Decision point 3). - Purchase-as-one-unit means
PropertyStore.PurchaseAsync's single-entity assumption (one buyer, oneOwnershiprecord, one price) gets a sibling, not a modification —Housing.PurchaseGroupAsyncreplicates its ordering and concurrency-guard shape at the group'sOwnableIdrather than reusing the door-shaped method directly, real new code, not a copy-paste of the existing flow.
Neutral¶
- Does not touch
VehiclesorBanks—Housingdepends only onOwnershipandWorld, perLegacy/13_DEPENDENCY_GRAPH.md §4's existing module table.
Compliance¶
- Any new or modified
[Rpc.Host]surface (Housing.PurchaseGroupAsync, group access grant) is walked against the exploit checklist. - A regression test proves that granting access to a group actually grants access to every
member named in that group's
HousingListingDefinition— membership is fixed at purchase time from the listing (Decision point 2), not assembled or changed afterward, so there is no "members added later" case to cover this milestone. - A migration/compatibility test proves an existing, already-purchased standalone
Door(never part of a group) is unaffected byGroupIdbecoming a field onDoor— it loads withGroupId = nulland every existing lock/access check resolves exactly as it did before this ADR.
Notes¶
Milestone 13 dependency check: done, see Decision point 4. No change to the answer — this
ADR still depends only on Ownership and World. Open question deliberately deferred:
whether a property group can be resold as a unit, and what happens to member access grants on
resale — needs its own follow-on once the base mechanic exists, the same way ADR-0009 deferred
its own re-key/impound questions.