Files
hackerspace.sh/docs/DATABASE_SCHEMA.md
T
Moheeb Zara 92c5737a25 feat(classes): migration 037 classes.required_form_id (optional form gate)
Nullable FK classes.required_form_id -> forms(id) ON DELETE SET NULL.
Mirrored in schema.sql, types, DATABASE_SCHEMA, DB_SCHEMA_MAP. Additive
nullable column on the already-policied classes table (no RLS change);
the signup gate is enforced in the app, mirroring the equipment cert-gate.
2026-05-16 21:33:53 -07:00

29 KiB

Hackerspace.sh - Database Schema Reference

Last Updated: 2026-05-15
Database: PostgreSQL via self-hosted Supabase
Tables: ~30
Source of Truth: scripts/schema.sql (canonical, idempotent); numbered migrations scripts/0NN_*.sql upgrade existing deployments. The 13-table reference below is the original baseline; tables added by migrations 014-025 are summarized in the "Migrations 014-025" section at the end.


Table of Contents

  1. Schema Overview
  2. Enum Types
  3. Table Definitions
  4. Relationships
  5. Row Level Security
  6. Triggers & Functions
  7. Indexes
  8. Migration History

1. Schema Overview

┌─────────────────┐     ┌──────────────────┐
│     spaces      │────<│  space_members   │
└─────────────────┘     └──────────────────┘
        │                       │
        │                       │
        ├───────────────────────┼───────────────────┐
        │                       │                   │
        ▼                       ▼                   ▼
┌───────────────┐     ┌──────────────┐     ┌────────────────┐
│    tasks      │     │   projects   │     │   payments     │
└───────────────┘     └──────────────┘     └────────────────┘
        │
        │
┌───────────────┐     ┌──────────────────┐
│   contacts    │     │  knowledge_base  │
└───────────────┘     └──────────────────┘
        │                     │
        │                     │
┌───────────────┐     ┌──────────────┐     ┌────────────────┐
│    secrets    │     │  area_leads  │     │  integrations  │
└───────────────┘     └──────────────┘     └────────────────┘
        │
        │
┌─────────────────┐     ┌──────────────────┐
│ comms_channels  │────<│  comms_messages  │
└─────────────────┘     └──────────────────┘
        │
        │
┌─────────────────┐
│  activity_log   │
└─────────────────┘

2. Enum Types

member_role

CREATE TYPE member_role AS ENUM ('admin', 'board', 'treasurer', 'member', 'associate');
Value Description
admin Full access to all features
board Members, payments, projects, board secrets
treasurer Payments and financial data
member Standard member access
associate Read-only, can claim chores

member_tier

CREATE TYPE member_tier AS ENUM ('plus', 'basic', 'associate');
Value Description
plus Full membership
basic Standard membership
associate Limited membership

member_status

CREATE TYPE member_status AS ENUM ('current', 'late', 'inactive', 'unverified');
Value Description
current Active, approved member
late Payment overdue
inactive No longer active
unverified Awaiting approval

IMPORTANT: Values active and pending do NOT exist. Use current and unverified.

task_type

CREATE TYPE task_type AS ENUM ('chore', 'task');
Value Description
chore Recurring maintenance task
task One-time task

task_status

CREATE TYPE task_status AS ENUM (
  'open', 'claimed', 'in_progress', 'overdue', 
  'due_today', 'completed', 'done', 'blocked'
);

recurrence_type

CREATE TYPE recurrence_type AS ENUM ('none', 'daily', 'weekly', 'biweekly', 'monthly');

NOTE: No quarterly value exists.

project_status

CREATE TYPE project_status AS ENUM ('backlog', 'in_progress', 'review', 'done', 'blocked');

kb_visibility

CREATE TYPE kb_visibility AS ENUM ('all_members', 'board', 'admin_only');

NOTE: Value is admin_only not admins_only.

contact_type

CREATE TYPE contact_type AS ENUM ('vendor', 'supplier', 'partner', 'landlord', 'city');

NOTE: No contractor value exists.

area_lead_status

CREATE TYPE area_lead_status AS ENUM ('active', 'vacant', 'handoff');

payment_platform

CREATE TYPE payment_platform AS ENUM ('paypal', 'zeffy', 'venmo', 'cash');
CREATE TYPE payment_link_status AS ENUM ('linked', 'unlinked');

channel_type

CREATE TYPE channel_type AS ENUM ('general', 'area', 'ops', 'project');

NOTE: No board channel type exists.


3. Table Definitions

spaces

Primary hackerspace/makerspace entity.

CREATE TABLE spaces (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name            TEXT NOT NULL,
  slug            TEXT UNIQUE NOT NULL,
  city            TEXT,
  invite_code     TEXT UNIQUE NOT NULL,
  require_approval BOOLEAN DEFAULT true,
  public_member_directory BOOLEAN DEFAULT false,
  webhook_secret  TEXT,
  created_at      TIMESTAMPTZ DEFAULT now(),
  updated_at      TIMESTAMPTZ DEFAULT now()
);
Column Type Nullable Default Description
id UUID No gen_random_uuid() Primary key
name TEXT No - Display name
slug TEXT No - URL-safe identifier
city TEXT Yes - Location
invite_code TEXT No - Join code (HSL-YYYY-XXXX)
require_approval BOOLEAN No true Require admin approval
public_member_directory BOOLEAN No false Public visibility
webhook_secret TEXT Yes - Webhook verification
created_at TIMESTAMPTZ No now() Creation timestamp
updated_at TIMESTAMPTZ No now() Last update

Constraints:

  • slug UNIQUE
  • invite_code UNIQUE

space_members

User membership in a space.

CREATE TABLE space_members (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id        UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  user_id         UUID REFERENCES auth.users(id) ON DELETE SET NULL,
  display_name    TEXT NOT NULL,
  email           TEXT,
  handle          TEXT,
  phone           TEXT,
  role            member_role DEFAULT 'member',
  tier            member_tier DEFAULT 'basic',
  status          member_status DEFAULT 'current',
  approved        BOOLEAN DEFAULT true,
  joined_at       TIMESTAMPTZ DEFAULT now(),
  last_paid_at    TIMESTAMPTZ,
  has_card_access BOOLEAN DEFAULT false,
  payment_status  TEXT,
  payment_note    TEXT,
  UNIQUE(space_id, user_id),
  UNIQUE(space_id, email)
);
Column Type Nullable Default Description
id UUID No gen_random_uuid() Primary key
space_id UUID No - FK to spaces
user_id UUID Yes - FK to auth.users (nullable for offline members)
display_name TEXT No - Full name
email TEXT Yes - Email address
handle TEXT Yes - Username/handle
phone TEXT Yes - Phone number
role member_role No 'member' Permission role
tier member_tier No 'basic' Membership tier
status member_status No 'current' Account status
approved BOOLEAN No true Approval status
joined_at TIMESTAMPTZ No now() Join date
last_paid_at TIMESTAMPTZ Yes - Last payment date
has_card_access BOOLEAN No false Physical access card
payment_status TEXT Yes - Payment status label
payment_note TEXT Yes - Payment notes

IMPORTANT: Column is last_paid_at NOT last_payment_at.


tasks

Tasks and recurring chores.

CREATE TABLE tasks (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id            UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  title               TEXT NOT NULL,
  description         TEXT,
  type                task_type DEFAULT 'task',
  status              task_status DEFAULT 'open',
  area                TEXT,
  recurrence          recurrence_type DEFAULT 'none',
  due_date            TIMESTAMPTZ,
  assigned_to         UUID REFERENCES auth.users(id),
  assigned_to_name    TEXT,
  claimed_by          UUID REFERENCES auth.users(id),
  claimed_by_name     TEXT,
  requested_by        UUID REFERENCES auth.users(id),
  requested_by_name   TEXT,
  subtask_total       INTEGER DEFAULT 0,
  subtask_completed   INTEGER DEFAULT 0,
  last_done_at        TIMESTAMPTZ,
  completed_at        TIMESTAMPTZ,
  created_at          TIMESTAMPTZ DEFAULT now(),
  updated_at          TIMESTAMPTZ DEFAULT now()
);

IMPORTANT: Column is type NOT task_type.


projects

Project tracking with kanban status.

CREATE TABLE projects (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id        UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  title           TEXT NOT NULL,
  description     TEXT,
  status          project_status DEFAULT 'backlog',
  area            TEXT,
  tags            TEXT[],
  assignee_names  TEXT[],
  task_count      INTEGER DEFAULT 0,
  tasks_completed INTEGER DEFAULT 0,
  due_date        TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT now(),
  updated_at      TIMESTAMPTZ DEFAULT now()
);

NOTE: No progress, blocked, priority, or owner_id columns.


knowledge_base

Documentation and procedures.

CREATE TABLE knowledge_base (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id        UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  title           TEXT NOT NULL,
  content         TEXT,
  icon            TEXT,
  visibility      kb_visibility DEFAULT 'all_members',
  area            TEXT,
  tags            TEXT[],
  is_pinned       BOOLEAN DEFAULT false,
  updated_by_id   UUID REFERENCES auth.users(id),
  updated_by_name TEXT,
  created_at      TIMESTAMPTZ DEFAULT now(),
  updated_at      TIMESTAMPTZ DEFAULT now()
);

IMPORTANT: Column is is_pinned NOT pinned. Column is updated_by_name NOT updated_by.


secrets

Encrypted credentials (admin/board only).

CREATE TABLE secrets (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id    UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  title       TEXT NOT NULL,
  description TEXT,
  value       TEXT NOT NULL,
  area        TEXT,
  icon        TEXT,
  created_at  TIMESTAMPTZ DEFAULT now(),
  updated_at  TIMESTAMPTZ DEFAULT now()
);

area_leads

Area leadership assignments.

CREATE TABLE area_leads (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id    UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  area_name   TEXT NOT NULL,
  area_code   TEXT NOT NULL,
  lead_id     UUID REFERENCES auth.users(id),
  lead_handle TEXT,
  status      area_lead_status DEFAULT 'active',
  created_at  TIMESTAMPTZ DEFAULT now(),
  updated_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE(space_id, area_code)
);

contacts

Vendor and partner directory.

CREATE TABLE contacts (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id     UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  name         TEXT NOT NULL,
  code         TEXT NOT NULL,
  contact_type contact_type NOT NULL,
  email        TEXT,
  phone        TEXT,
  details      TEXT,
  note         TEXT,
  group_label  TEXT,
  tags         TEXT[],
  created_at   TIMESTAMPTZ DEFAULT now(),
  updated_at   TIMESTAMPTZ DEFAULT now()
);

payments

Payment records for dues tracking.

CREATE TABLE payments (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id         UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  platform         payment_platform NOT NULL,
  amount           NUMERIC(10,2) NOT NULL,
  from_identifier  TEXT,
  from_note        TEXT,
  member_id        UUID REFERENCES space_members(id),
  member_name      TEXT,
  link_status      payment_link_status DEFAULT 'unlinked',
  transaction_date TIMESTAMPTZ NOT NULL,
  created_at       TIMESTAMPTZ DEFAULT now()
);

comms_channels

Chat channels.

CREATE TABLE comms_channels (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id       UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  name           TEXT NOT NULL,
  icon           TEXT,
  channel_type   channel_type DEFAULT 'general',
  area_reference TEXT,
  project_id     UUID REFERENCES projects(id) ON DELETE CASCADE,
  member_count   INTEGER DEFAULT 0,
  created_at     TIMESTAMPTZ DEFAULT now()
);

NOTE: No description, topic, unread_count, or is_archived columns.


comms_messages

Chat messages.

CREATE TABLE comms_messages (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  channel_id   UUID NOT NULL REFERENCES comms_channels(id) ON DELETE CASCADE,
  space_id     UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  user_id      UUID REFERENCES auth.users(id),
  display_name TEXT NOT NULL,
  handle       TEXT,
  content      TEXT NOT NULL,
  created_at   TIMESTAMPTZ DEFAULT now()
);

IMPORTANT: Column is content NOT message or body. Column is user_id NOT sender_id.


integrations

External platform connections.

CREATE TABLE integrations (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id     UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  name         TEXT NOT NULL,
  platform     TEXT NOT NULL,
  description  TEXT,
  is_connected BOOLEAN DEFAULT false,
  config       JSONB,
  created_at   TIMESTAMPTZ DEFAULT now(),
  updated_at   TIMESTAMPTZ DEFAULT now(),
  UNIQUE(space_id, platform)
);

activity_log

Audit trail for all actions.

CREATE TABLE activity_log (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id     UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  user_id      UUID REFERENCES auth.users(id),
  display_name TEXT,
  action       TEXT NOT NULL,
  entity_type  TEXT,
  entity_id    UUID,
  details      TEXT,
  created_at   TIMESTAMPTZ DEFAULT now()
);

4. Relationships

Foreign Keys

From Table Column To Table Column On Delete
space_members space_id spaces id CASCADE
space_members user_id auth.users id SET NULL
tasks space_id spaces id CASCADE
tasks assigned_to auth.users id SET NULL
tasks claimed_by auth.users id SET NULL
tasks requested_by auth.users id SET NULL
projects space_id spaces id CASCADE
knowledge_base space_id spaces id CASCADE
knowledge_base updated_by_id auth.users id SET NULL
secrets space_id spaces id CASCADE
area_leads space_id spaces id CASCADE
area_leads lead_id auth.users id SET NULL
contacts space_id spaces id CASCADE
payments space_id spaces id CASCADE
payments member_id space_members id SET NULL
comms_channels space_id spaces id CASCADE
comms_channels project_id projects id CASCADE
comms_messages channel_id comms_channels id CASCADE
comms_messages space_id spaces id CASCADE
comms_messages user_id auth.users id SET NULL
integrations space_id spaces id CASCADE
activity_log space_id spaces id CASCADE
activity_log user_id auth.users id SET NULL

5. Row Level Security

All tables have RLS enabled. Helper function:

CREATE OR REPLACE FUNCTION public.get_user_space_ids(uid uuid)
RETURNS SETOF uuid
LANGUAGE sql SECURITY DEFINER STABLE
SET search_path = 'public'
AS $$
  SELECT space_id FROM public.space_members WHERE user_id = uid
$$;

Policy Summary

Table Policy Operation Condition
spaces spaces_select_members SELECT space_id IN get_user_space_ids(auth.uid())
spaces spaces_insert_authenticated INSERT auth.uid() IS NOT NULL
spaces spaces_update_admins UPDATE Has admin role
space_members space_members_select_own SELECT user_id = auth.uid()
space_members space_members_select_same_space SELECT Same space
space_members space_members_insert_authenticated INSERT auth.uid() IS NOT NULL
space_members space_members_update_admins UPDATE Admin/Board or self
space_members space_members_delete_admins DELETE Admin only
tasks tasks_*_members ALL Members of space
projects projects_*_members ALL Members of space
knowledge_base kb_select_members SELECT Members + visibility
knowledge_base kb_insert_members INSERT Members
knowledge_base kb_update_members UPDATE Members
knowledge_base kb_delete_board DELETE Board/Admin
secrets secrets_*_admins ALL Admin/Board only
payments payments_*_treasurer ALL Treasurer/Board/Admin
contacts contacts_*_members ALL Members
comms_channels channels_select_members SELECT Members
comms_channels channels_*_board INSERT/UPDATE/DELETE Board/Admin
comms_messages messages_select_members SELECT Members
comms_messages messages_insert_members INSERT Members (user_id = auth.uid())
integrations integrations_*_admins ALL Admin only
activity_log activity_*_members SELECT/INSERT Members

6. Triggers & Functions

handle_space_signup()

Triggered after auth.users INSERT. Creates space and/or member based on signup metadata.

CREATE OR REPLACE FUNCTION public.handle_space_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
-- Checks raw_user_meta_data for:
-- space_action: 'create' | 'join'
-- If 'create': creates space + admin member
-- If 'join': creates member in existing space
$$;

CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_space_signup();

create_default_channels()

Triggered after spaces INSERT. Creates default chat channels.

CREATE OR REPLACE FUNCTION public.create_default_channels()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO public.comms_channels (space_id, name, channel_type) VALUES
    (NEW.id, 'general', 'general'),
    (NEW.id, 'announcements', 'general'),
    (NEW.id, 'random', 'general'),
    (NEW.id, 'facilities', 'ops');
  RETURN NEW;
END;
$$;

CREATE TRIGGER tr_create_default_channels
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.create_default_channels();

7. Indexes

Recommended indexes (may need to be added):

-- Space lookups
CREATE INDEX idx_spaces_slug ON spaces(slug);
CREATE INDEX idx_spaces_invite_code ON spaces(invite_code);

-- Member queries
CREATE INDEX idx_space_members_user_id ON space_members(user_id);
CREATE INDEX idx_space_members_space_status ON space_members(space_id, status);

-- Task queries
CREATE INDEX idx_tasks_space_status ON tasks(space_id, status);
CREATE INDEX idx_tasks_space_due ON tasks(space_id, due_date);

-- Project queries
CREATE INDEX idx_projects_space_status ON projects(space_id, status);

-- Payment queries
CREATE INDEX idx_payments_space_link ON payments(space_id, link_status);

-- Message queries
CREATE INDEX idx_messages_channel ON comms_messages(channel_id, created_at);

-- Activity log
CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);

8. Migration History

Script Version Description
000_reset_schema.sql Dev Drops and recreates everything (dev only)
001_create_schema.sql 1.0 Initial schema, all tables and enums
002_schema_fixes.sql 1.1 Added create_default_channels trigger
003_signup_trigger.sql 1.2 Added handle_space_signup trigger
003_add_approved_column.sql 1.2 Added approved column to space_members
004_fix_rls_recursion.sql 1.3 Fixed RLS infinite recursion
005_fix_spaces_insert.sql 1.4 Fixed spaces INSERT policy
006_comprehensive_rls_fix.sql 1.5 Complete RLS rewrite
007_schema_audit_fixes.sql 1.6 Added task_status 'done', contacts note/group_label, unique constraints
008_fix_approved_default.sql 1.7 space_members.approved DEFAULT true
009_fix_member_insert_rls.sql 1.8 Fixed member INSERT RLS for admin/board
010_fix_channel_trigger.sql 1.9 Removed non-existent description column from trigger
011_fix_member_status_enum.sql 2.0 Fixed enum values (active→current, pending→unverified)

Migrations 014-035 (additions since the 13-table baseline)

scripts/schema.sql is the canonical idempotent schema; each numbered migration is mirrored as a section in it. Tables/columns added:

Mig Adds
014 space_members.user_id made nullable (offline/imported members)
015 prevent_member_self_role_change() trigger (self-escalation guard)
016 Governance kernel: proposals, proposal_votes, incidents, incident_updates, policies + quorum/tally/SLA functions
017 Governance RLS hardening (cross-tenant edge cases)
018 space_members.skills/interests/willing_to/affiliations/coi_last_disclosed_at; knowledge_base.is_meeting_minutes/meeting_date; financial/member-directory visibility settings
019 Auto-expire open proposals past their voting window
020 space_areas (per-space configurable areas) + seeded defaults
021 forum_threads, comments (polymorphic: forum_thread/proposal/incident/policy), space_tiers + space_members.tier_id, space_role_labels, space_custom_roles, space_member_custom_roles, space_invites; secrets.encrypted_value/encryption_version; knowledge_base.render_markdown; comms_channels member-creatable
022 space_onboarding_steps; space_members.onboarding_completed_at/onboarding_progress; seed default steps
023 space_role_permissions, ops_acl; user_effective_roles(), user_has_permission() (SECURITY DEFINER); secrets/knowledge_base SELECT rewritten as existing-rule OR ops_acl-match (additive)
024 prevent_member_self_role_change() extended to also block self-change of tier_id and onboarding_completed_at
025 Re-asserts the hardened incidents_insert policy verbatim (production convergence; access-neutral, no schema change)
026 forms, form_submissions (custom forms + waivers). jsonb field schema; per-submission snapshot of schema/legal-text/version for immutable waiver records. RLS additive + default-deny: forms SELECT = forms.manage holders (all) or members (published only), write = user_has_permission(..., 'forms.manage'); form_submissions SELECT = forms.manage only, NO write policy (every submission is written by one validated service-client server action, and submissions are immutable). New forms.manage permission seeded to board + backfilled
027 Adds 'form' to the space_onboarding_steps.step_type CHECK so an onboarding step can embed a custom form/waiver (referenced form id stored in the step's existing config jsonb; no new column)
028 Form slug is unique PER SPACE: drops the global UNIQUE(forms.slug), adds UNIQUE(space_id, slug). Public form URL is /f/[space]/[slug]
029 space_invites.role (member_role enum, default member) so an invite can grant a chosen role; legacy invites and the spaces.invite_code path keep granting member. Who may create an admin-granting invite is enforced in-app (only an admin)
030 certifications (per-space cert types; optional validity_months; is_active archive) + member_certifications (per-member grants; expires_at snapshotted at grant time; soft revoked_at/revoked_by; partial unique on one active grant per member+cert). RLS additive + default-deny: certifications SELECT = any space member, write = certifications.manage; member_certifications SELECT = certifications.manage/certifications.grant (all) or member (own), INSERT/UPDATE = certifications.grant, NO DELETE policy (history immutable). New certifications.manage + certifications.grant permissions (the latter = the Instructor capability) seeded to board + backfilled. No anonymous path
031 secrets_select additively also honors the ops.secrets.read role permission (was admin/board OR per-secret ops_acl only; the permission was never consulted). Access-neutral unless an admin has granted the permission. Reveal/list server-side gates updated to let RLS be the boundary; write paths unchanged
032 classes, class_sessions, class_signups (class offerings, scheduled sessions, member signups; optional payment_link and grants_certification_id; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = classes.manage (all) or member (is_active), class_sessions SELECT = any space member, class_signups SELECT = manage/instruct (all) or member (own), UPDATE = classes.instruct, NO INSERT/DELETE policy (signup/cancel via a validated service-client action enforcing capacity/waitlist/dedupe). New classes.manage + classes.instruct permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs certifications.grant). No anonymous path
033 equipment (registry; status available/maintenance/retired; optional required_certification_id; is_active archive) + equipment_reservations (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = equipment.manage (all) or member (is_active), reservations SELECT = equipment.manage (all) or member (own), UPDATE = equipment.manage, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New equipment.manage permission seeded to board + backfilled. No anonymous path
034 member_cards (RFID/NFC card UID -> member; the UID is a credential -- door.manage-only RLS, no member SELECT policy; masked self-view via a server action). New door.manage + door.operate permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path
035 door_connections (per-space controller integration; shared password NOT stored -- secret_ref -> AES-256-GCM secrets vault, decrypted server-side only; pinned_host SSRF pin; adapter native_heatsync
036 door_card_slots (per-connection integer-slot allocation map for controllers that key cards by slot, e.g. HeatSync 0-200). UNIQUE (connection_id, slot) lets the DB arbitrate concurrent grants; UNIQUE (connection_id, card_id) makes re-grant idempotent. Allocation policy (lowest-free, range bounds) is pure unit-tested logic, not SQL, so it stays adapter-generic. RLS additive + default-deny: SELECT = door.manage/door.operate, NO write policy (validated service-client executor only, in lockstep with the controller). No new permission codes. No anonymous path
037 classes.required_form_id nullable FK -> forms(id) ON DELETE SET NULL. A class can optionally require a form (waiver/intake); signup is hard-gated on the member having a completed form_submissions row for it (waiver-on-file), with a classes.manage override + sign-up-on-behalf, mirroring the equipment required-cert gate. Gate enforced in the app (pure logic + service-client boolean check); no RLS change (additive nullable column on the already-policied classes)

New enum: comment_entity_type = forum_thread | proposal | incident | policy.

Every new table has RLS enabled with SELECT/INSERT/UPDATE/DELETE policies (writes admin/board, deletes admin, reads space members) and, where it has updated_at, a touch_updated_at trigger. The ACL model is additive: with zero ops_acl/space_role_permissions rows the app behaves exactly as the role-based baseline.