Files
hackerspace.sh/docs/DATABASE_SCHEMA.md
T
Moheeb Zara 2687c1578d fix(secrets): honor ops.secrets.read permission for reveal/list
A regular member granted reveal access either via the ops.secrets.read
role permission or a per-secret ops_acl entry could not actually see or
reveal secrets: revealSecret hard-gated on built-in role admin/board
(ignoring both the permission and the ACL), and secrets_select RLS never
consulted the permission, and the Ops page short-circuited the secrets
fetch by built-in role.

Migration 031 additively adds an OR user_has_permission(...,
'ops.secrets.read') branch to secrets_select; the admin/board and
per-secret ops_acl branches are unchanged so deployment is access-neutral
unless an admin has granted the permission. revealSecret now resolves
the member and lets RLS be the boundary (a denied row simply does not
return). The Ops page always queries secrets (RLS filters the set) and
shows the section to admin/board, a permission holder, or anyone with an
ACL-granted secret. Write paths (create/update/delete) are unchanged.
Mirrored in schema.sql; migration docs and HANDOFF updated.
2026-05-16 19:07:06 -07:00

26 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-031 (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

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.