-- 030: collectives — group tree (P1 of the collectives architecture, -- docs/design/2026-10-12-collectives-architecture.md §"Collective model"). -- -- A COLLECTIVE IS A GROUP in the Hub's group tree: -- * groups.parent_id becomes self-referencing (top-level groups = collectives, -- children = subcollectives, recursive); -- * groups.kind is the minimal discriminator ('collective' | 'subcollective' | -- 'plain') so the tree query never has to infer structure from parent_id -- alone — a legacy sharing-scope group (008) and a collective are both -- parentless, and 'plain' keeps the existing semantics untouched; -- * group_rooms maps each collective/subcollective to its room set -- (open | closed | onboarding), extending the archive mapping pattern -- (ROOM_MAPPINGS / chat_media_archive) into a durable, tenant-scoped table. -- -- This deliberately rides the existing groups/RBAC/files machinery -- (group_files, group_members, chat_media_archive are keyed workspace+group) -- instead of introducing cross-tenant writes. -- -- ON DELETE RESTRICT on groups.parent_id (not CASCADE), on purpose: -- * Every other child table of groups (group_members, group_connections, -- group_assets, chat_media_archive, group_rooms) CASCADES, so deleting a -- group already tears down its own members/assets/rooms rows correctly. -- * A collective with subcollectives is a TREE ROOT whose children are -- independent units with their own room sets and their own files folders. -- CASCADE would delete a whole subtree on a single root delete — silently -- removing sibling subcollectives' chat archives and room mappings that the -- operator never named. The audit log would then record one delete while -- the tree lost N groups. -- * RESTRICT forces the explicit, audited order the product wants: delete (or -- re-parent) the subcollectives first, then the collective. Deleting a -- subcollective still cascades its own rooms/members exactly like today. -- * The FK is tenant-pinned via the existing composite (id, workspace_id), so -- RESTRICT can never be a cross-tenant coupling: a subcollective cannot -- reference a parent in another workspace even if ids collided. -- -- RLS / NULLIF / reject_workspace_change / sp_app grants follow the 001/008/029 -- conventions: no app.workspace_id => NULL => no rows; FORCE binds sp_app too. BEGIN; -- --------------------------------------------------------------------------- -- groups.parent_id — self-reference, tenant-pinned, RESTRICT -- --------------------------------------------------------------------------- -- NULL = top-level (a collective or a legacy plain group). Tenant-pinned by the -- composite FK so the tree can never span workspaces, even transiently. ALTER TABLE groups ADD COLUMN parent_id uuid; ALTER TABLE groups ADD CONSTRAINT groups_parent_fk FOREIGN KEY (parent_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE RESTRICT ON UPDATE RESTRICT; -- Subcollective lookup (children of a collective) — the only parent->children -- access path P1 needs (the tree read walks parent_id in the other direction). CREATE INDEX groups_parent_idx ON groups (parent_id, workspace_id); -- No cycle guard at the DB level in P1: a cycle needs two UPDATEs of rows the -- API never writes directly (the service creates roots and one-level children -- only, and rejects a parent that is not itself a collective or subcollective). -- A trigger walking an unbounded graph inside the mutating transaction would -- burn locks for a shape the API layer structurally prevents; revisit if/when -- re-parenting becomes a product surface. -- --------------------------------------------------------------------------- -- groups.kind — minimal discriminator -- --------------------------------------------------------------------------- -- 'plain' is the DEFAULT and is exactly the 008 semantics: a sharing scope for -- connections/assets with no collective meaning. Existing rows are backfilled -- explicitly (batch would be pointless: the table is small and the migration is -- already inside one transaction). -- Two named constraints so the backstop below can be added without guessing a -- PostgreSQL auto-generated name. ALTER TABLE groups ADD COLUMN kind text NOT NULL DEFAULT 'plain'; ALTER TABLE groups ADD CONSTRAINT groups_kind_values CHECK (kind IN ('collective','subcollective','plain')); -- A subcollective must actually be a child; a collective must be a root. Kept -- as a DB backstop so a bad write can never split kind from parent_id — the -- typed validation lives in the API service (collectives.service.ts). ALTER TABLE groups ADD CONSTRAINT groups_kind_shape CHECK ( (kind = 'subcollective' AND parent_id IS NOT NULL) OR (kind IN ('collective','plain') AND parent_id IS NULL) ); -- --------------------------------------------------------------------------- -- group_rooms — the immutable group ↔ room mapping -- --------------------------------------------------------------------------- -- Extends the ROOM_MAPPINGS pattern (archive bot / chat_media_archive): room -- IDs only, never aliases (`!opaque:server`), because a mapping must stay -- resolvable. room_type is one of the three product room kinds. One row per -- (group, room_type): a collective has exactly one open, one closed room; a -- user's onboarding room is keyed to the group it onboards into. room_id is -- UNIQUE across the table: a Matrix room belongs to exactly one slot in one -- workspace, so a mis-provisioned duplicate cannot be silently re-pointed. CREATE TABLE group_rooms ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, group_id uuid NOT NULL, -- 'open' (public+federated, never E2EE) | 'closed' (invite-only, E2EE at -- birth, local only) | 'onboarding' (invite-only, disposable, bot-posted). room_type text NOT NULL CHECK (room_type IN ('open','closed','onboarding')), -- Matrix room ID, never an alias — same immutability rule as ROOM_MAPPINGS. room_id text NOT NULL CHECK (room_id ~ '^![^:\s]+:[^\s]+$'), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT group_rooms_ws_group FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT, -- Exactly one room of each type per group. CONSTRAINT group_rooms_type_unique UNIQUE (group_id, room_type), -- A room is mapped to exactly one (workspace, group, slot). CONSTRAINT group_rooms_room_unique UNIQUE (workspace_id, room_id) ); CREATE TRIGGER trg_group_rooms_ws_immutable BEFORE UPDATE ON group_rooms FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE group_rooms ENABLE ROW LEVEL SECURITY; ALTER TABLE group_rooms FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON group_rooms USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); -- Room address lookup per collective (tree read joins on this), and the -- reverse "which group does this room belong to" lookup the archive bot path -- needs once mappings move out of env config (P2). CREATE INDEX group_rooms_group_idx ON group_rooms (group_id, room_type); CREATE INDEX group_rooms_room_idx ON group_rooms (room_id); GRANT SELECT, INSERT, UPDATE, DELETE ON group_rooms TO sp_app; COMMIT;