Resource: Spaces (+ SpaceLayouts, SpaceTags)
Tag: Spaces (single tag — space-layout and space-tag ops fold under it per C9; they are
sub-resources of Space, not their own tags) · Module: space (extracted from organization)
The persistent 2D collaboration environment a team inhabits. Named Space (not "office" or "room") on the
wire — the N1 term, settled 2026-06-15 when the product broadened beyond work offices: "office" was too
narrow (a space's backdrop can be a lecture hall, lounge, or event floor — those are just SpaceLayouts), so
the neutral category term wins (it's also Gather's core noun for the same concept). The DB table stays
private.rooms internally (behind the repository — no rename); only the wire term and the new module are
Space. The former "open space" zone (the open, non-room area) is renamed "open area" in lockstep so
it no longer collides with the container term.
Implied FE refactor (not this pass):
officeLayoutStore,assets/layouts, thevirtual-officemodule /officefeature,RoomData/roomId, and the literal "open space" zone term all rename to the Space / open-area vocabulary. Large real-code change — tracked as its own migration.
This resource is delivered as one unit: the module extraction off organization, the endpoint reshape, the
type fixes, and the new SpaceLayout/SpaceTag sub-resources.
Operations
Space (CRUD)
| OperationID | Method + Path | RBAC | Notes |
|---|---|---|---|
create-space | POST /spaces/create | CreateRoom | returns the created Space |
list-spaces | POST /spaces/list | ListRooms | name filter + tag filter + sort (below) |
get-space | POST /spaces/{id}/get | ListRooms | id = space UUID |
update-space | POST /spaces/{id}/update | UpdateRoom | partial patch |
delete-space | POST /spaces/{id}/delete | DeleteRoom |
SpaceLayouts (sub-resource — Spaces tag)
| list-space-layouts | POST /space-layouts/list | ListRooms | org-scoped, server-authored layout set |
SpaceTags (sub-resource — Spaces tag)
| list-space-tags | POST /space-tags/list | ListRooms | |
| create-space-tag | POST /space-tags/create | ManageSpaceTags | new permission |
| update-space-tag | POST /space-tags/{id}/update | ManageSpaceTags | |
| delete-space-tag | POST /space-tags/{id}/delete | ManageSpaceTags | |
RBAC keeps its internal *Room permission names (like the DB table, they're behind the boundary); only the
net-new permission is Space-named (ManageSpaceTags).
The 6 legacy room ops collapse to 5 CRUD ops: room-by-name is a name filter on list-spaces, and
room-by-id is get-space. LiveKit room-auth is not here — it belongs to the Realtime resource.
Identifier — space UUID
Spaces key on RoomId uuid.UUID (room.go:13, uniqueIndex) — opaque, non-enumerable, already a string.
The int64 PK is internal and never crosses the wire.
DTOs
Space
{
"id": "uuid",
"name": "string",
"description": "string",
"layoutId": "uuid", // references a SpaceLayout (see §Layouts)
"tags": [ /* SpaceTag */ ], // see §Tags
"occupantUserIds": ["string"], // who is in the space now — snapshot (see §Occupancy)
"createdAt": "2026-06-14T10:00:00Z", // RFC3339
"updatedAt": "2026-06-14T10:00:00Z" // RFC3339
}
create-space
// Request
{
"name": "string", // required
"description?": "string", // C8 — optional
"layoutId": "uuid", // required
"tagIds?": ["uuid"] // C8 — optional
}
// Response: the created Space
list-spaces
// Request
{
"query?": "string", // C8 — optional, free-text search on space name
"tagIds?": ["uuid"], // C8 — optional, match spaces carrying ANY of these tags
"sort?": "relevance", // C8 — optional; relevance | nameAsc | nameDesc | occupancy
"limit": 20, // 1..100, default 20
"offset": 0
}
// Response
{ "data": [ /* Space */ ], "page": { "limit": 20, "offset": 0, "total": 42 } }
sort values: nameAsc/nameDesc alphabetical; occupancy by occupantUserIds length (most occupied
first); relevance is a personalized server-side ranking from the requesting user's context (spaces where
their recent DMs occurred, spaces tagged like their team, spaces where teammates are present). The
relevance formula is computed server-side and may evolve without a contract change.
get-space / delete-space ({id} in path)
// Request body — empty {}
// get-space → the Space object directly (no wrapper)
// delete-space → 204 No Content
update-space ({id} in path) — partial patch, only provided fields change
{
"name?": "string", // C8 — all fields optional (partial patch)
"description?": "string",
"layoutId?": "uuid",
"tagIds?": ["uuid"] // replaces the space's tag set
}
// Response: the updated Space
§Layouts — SpaceLayout
A space's visual template. The legacy (layout, dimension, style) triple is reified into a first-class
resource with a surrogate id, and the space references a single layoutId. This makes validation a simple
FK check, keeps updates to one field, guarantees a non-null layout via a default, and gives the client a
server-authored list of selectable layouts (which it cannot derive otherwise — the legacy
valid_room_combinations table is exposed by no endpoint today). Layouts are how a space's setting varies
(office, lecture hall, lounge, …) without changing what a Space is.
// SpaceLayout
{
"id": "uuid",
"name": "string", // display name for the picker
"layout": "string", // the original triple, encapsulated here
"dimension": "string",
"style": "string",
"thumbnailUrl": "string | null"
}
// list-space-layouts → { "data": [SpaceLayout], "page": {...} }
list-space-layoutsreturns the selectable layouts. There is no availability field — a layout that isn't usable is simply not returned.- A space's
layoutIdmust reference an existing layout; a default layout always exists. - Org-specific custom layouts are a future extension and are not modeled yet (YAGNI).
Schema: space_layouts table (= valid_room_combinations + id, name, thumbnail);
rooms.layout_id uuid FK; backfill existing rooms' triples → layout ids; then drop
rooms.layout/dimension/style.
§Tags — SpaceTag
Org-scoped tags an organization defines and applies to spaces (e.g. "Engineering", "Marketing").
// SpaceTag
{ "id": "uuid", "name": "string", "color": "string | null" }
- Schema:
space_tags (id, org_id, name, color)+space_tag_assignments (space_id, tag_id)M2M. - Tag CRUD requires the
ManageSpaceTagspermission. Spaces receive tags viatagIdson create/update;list-spacesfilters bytagIds(ANY match).
§Occupancy — occupantUserIds
Stringified user ids currently in the space — a point-in-time snapshot. "Occupant" names the spatial
fact (who is in this space), independent of transport — not "online" (globally connected) or "connected"
(implies a connection mechanism). Sourced today from the room_presence table (per-space, mirroring
metrics/.../CountByOrg); that table will later be replaced by a dedicated realtime service, so the field is
source-agnostic and the contract does not change when the source does. Count is derived (length); the
occupancy sort orders by it. The list returns the initial snapshot only — live updates flow through the
realtime/presence channel, not by re-fetching spaces.
Module extraction (backend)
New internal/features/space/ (standard module layout), landing with this resource:
space/api/←organization/api/room_*.go, renamedspace_{create,get,list,update,delete}(by_id folds into get, by_name into list).space/service/←organization/service/room_*.go.space/models/←RoomDTO→Space,RoomCreateRequest→CreateSpaceRequest, etc.space/module/+routes.go← DI +RegisterRoutes.- Shared, unchanged:
domain/database/room.go+ the room repository (internal/repository) — also used by the realtime auth feature. - New tables/repos:
space_layouts,space_tags,space_tag_assignments(+ theroom_presenceper-space count query). organizationshrinks to org-only (CreateOrganization,UpdateOrganization,SwitchOrganization).- The duplicated
PaginationMaxLimit/DefaultLimitconsts move to a shared package (see members pass).
Scope note
Beyond a thin extraction, this resource adds 3 tables, a layout-id migration (backfill + column
drop), the ManageSpaceTags permission, and a per-space presence query — real Phase-2 work to budget.
Plus the implied FE rename (Space / open-area vocabulary) noted up top.
Deferred / verify at implementation
- The
relevanceranking formula (signals settled; exact weighting decided later — no contract impact). - Confirm no live rooms hold null
layout/dimension/stylebefore the backfill (a default covers any that do).