Skip to content

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

  1. What a property group actually is: composition, via a new OwnableId for the group itself. OwnableId is already just a strongly-typed Guid wrapper (Code/Ownerships/OwnableId.cs), and Door.Id already is an OwnableId directly, not a translated foreign key (that same file's header comment) — so "the group is its own ownable thing" costs nothing new in Ownerships, which stays exactly as generic as it is today. A property group gets one Ownership record, keyed by a fresh OwnableId minted for the group, exactly like a Door or WorldContainer gets one for itself. Member entities (Door, WorldContainer) each gain a new, independently-added GroupId : OwnableId? field (nullable — most doors and containers are never part of a group). This is deliberately not generic on Housing's side either: Door.GroupId and (if/when Vendors or another World entity wants to join a group) WorldContainer.GroupId are separate fields on their own types, each added by the module that owns that type, the same way Door.IsLocked and WorldContainer'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 single Ownership record, which points 3 below needs.
  2. Purchase semantics: bought as one unit, at a group-level price. A Housing module owns a HousingListingDefinition GameResource (ADR-0004's precedent — ItemDefinition, JobDefinition — for content authored outside C#), naming a fixed set of member OwnableIds and a group price. Housing.PurchaseGroupAsync mints the group's OwnableId the first time the listing is bought, assigns ownership to it via the existing IOwnershipService.AssignOwnerAsync (unchanged), sets each named member's GroupId to point at it, and debits the group price — replicating PropertyStore.PurchaseAsync's own ownership-before-debit ordering and its per-entity _purchasesInFlight concurrent-purchase guard (Code/World/PropertyStore.cs), just keyed by the group's OwnableId instead of a door's. Player-assembled linking (closer to legacy's setMaster) 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."
  3. Access propagation: nothing to cascade, because IOwnershipService is never touched. A group member's own Ownership record (keyed by the door's own OwnableId) is simply never populated once it's part of a group — PropertyStore.SetLockedAsync and any future Housing-aware access check resolve ownership via door.GroupId ?? door.Id before calling IOwnershipService.LoadOrCreateAsync/HasAccess, so every member of a group defers to the same, single Ownership record (the group's) for owner and every AccessGrant. Granting a friend access to "the house" is exactly one call to the existing IOwnershipService.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: IOwnershipService gains 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 "GroupId on generic Ownership" alternative below). All group-awareness lives in World/Housing's own call sites, as one small resolution step (door.GroupId ?? door.Id) added at each place that already loads a door's OwnershipPropertyStore.SetLockedAsync today, Housing's own equivalent checks for anything it owns directly.
  4. Confirmed: no dependency on Milestone 13. Milestone 13 (Character creation, ADR-0011) shipped and merged without touching Code/Ownerships at all — OwnerKind is still exactly { Character, Team, Gang } (Code/Ownerships/OwnerKind.cs), and Code/Ownerships/README.md still names OwnerKind.Team/.Gang resolution in HasAccess as a gap belonging to a future Jobs-module pass, unrelated to Character. Housing depends only on Ownership and World, 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, and Lock/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's Ownership directly by door.Id) has to change to resolve door.GroupId ?? door.Id once Door gains that field — a small, mechanical edit, but to already-shipped, already-tested code (World module, Milestone 6/7), not a purely additive one. Resolved more cheaply than originally feared: IOwnershipService itself needs no new surface (Decision point 3).
  • Purchase-as-one-unit means PropertyStore.PurchaseAsync's single-entity assumption (one buyer, one Ownership record, one price) gets a sibling, not a modification — Housing.PurchaseGroupAsync replicates its ordering and concurrency-guard shape at the group's OwnableId rather than reusing the door-shaped method directly, real new code, not a copy-paste of the existing flow.

Neutral

  • Does not touch Vehicles or BanksHousing depends only on Ownership and World, per Legacy/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 by GroupId becoming a field on Door — it loads with GroupId = null and 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.