feat(lane0): community_id-native schema + host normalization

The frozen base for the multi-tenant rewrite. Consolidated 0001 schema
makes community_id a first-class, server-resolved key on every scoped
row, mapped table-by-table to docs/multi-tenant-conformance.md.

Schema highlights:
- channels PK is (community_id, id): the same channel UUID may legitimately
  co-exist in two communities; child FKs (channel_members, workflows,
  thread_metadata) are composite (community_id, channel_id) so a child can
  never reference a cross-community channel — DB-enforced, not by handler
  discipline. channels.community_id is immutable (BEFORE UPDATE trigger).
- communities.host uniqueness is UNIQUE(lower(host)); normalize_host applies
  the same rule on the resolution side, so case/dot/default-port variants
  can never split one tenant into two.
- every scoped unique/PK leads with community_id; cross-community dedup of
  the same signed event is allowed, within-community dup rejected.
- new tables: communities (host map), scheduled_workflow_fires (the cron
  at-most-once claim), audit_log (per-community chain), and an explicit
  _operator_global_tables registry the migration lint reads.

buzz-core:
- normalize_host(host): the one shared host-canonicalization rule.
- TenantContext fence doc corrected to say plainly it is a lint-and-review
  fence, not a compiler fence (resolved()/from_uuid are pub) — honest about
  the guarantee the API actually gives.

Schema proven against Postgres with an adversarial fence suite (re-tenant
rejected, cross-community FKs rejected, same-UUID/same-event cross-community
allowed, host-case collision rejected). buzz-core: 189 tests + 2 doctests
green.

Folds in review round 1 from Mari (channel global-uniqueness leak, host
normalization, fence-claim honesty) and Sami (NIP-98 localhost normalization
to be dropped in the auth lane).

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-06-26 20:36:57 -04:00
co-authored by Eva
parent 8f69d35810
commit d948830c02
3 changed files with 398 additions and 76 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ pub use error::VerificationError;
pub use event::StoredEvent;
pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey};
pub use presence::PresenceStatus;
pub use tenant::{CommunityId, TenantContext};
pub use tenant::{normalize_host, CommunityId, TenantContext};
pub use verification::verify_event;
#[cfg(any(test, feature = "test-utils"))]
+93 -6
View File
@@ -10,12 +10,19 @@
//! model (conformance "row zero"): a request's community is *resolved from the
//! connection host by the server*, never supplied or influenced by the client.
//!
//! [`TenantContext`] encodes that invariant in the type system. It has no
//! `Default`, no `Deserialize`, and no public constructor other than
//! [`TenantContext::resolved`], which is meant to be called *only* from the
//! host-resolution path. Downstream code receives `&TenantContext` and can read
//! the community but cannot mint one — so "the client chose this community"
//! cannot type-check anywhere outside resolution.
//! [`TenantContext`] expresses that invariant in the type system as far as the
//! type system can carry it: there is no `Default`, no `Deserialize`, and no
//! way to *parse* a community from client input. A `CommunityId` only ever
//! comes from host resolution or from a DB row the server already scoped.
//!
//! This is a **lint-and-review fence, not a compiler fence.**
//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the
//! host-resolution path (in another crate) can call them — which means a
//! determined caller elsewhere *could* call them too. The migration-lint
//! harness forbids constructing a `TenantContext` outside host resolution and
//! tests; the type only removes the *accidental* path (deserializing a
//! client-chosen community), and review/lint closes the deliberate one. We say
//! this plainly rather than overclaim a guarantee the `pub` API doesn't give.
use std::fmt;
use uuid::Uuid;
@@ -90,6 +97,46 @@ impl TenantContext {
}
}
/// Normalize a connection `Host` into the canonical form used as the community
/// lookup key.
///
/// This is the *one* normalization rule shared by both sides of the fence:
/// the `communities.host` column is stored already-normalized, and host
/// resolution normalizes the incoming `Host` header with this same function
/// before looking it up. Because both sides agree by construction,
/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to
/// the one community — they can never split into distinct tenants.
///
/// Rules (host only — the caller has already split off any path/scheme):
/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986);
/// - strip a single trailing dot (the FQDN root label);
/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept,
/// since a deployment may legitimately serve different communities on
/// different ports of the same name.
///
/// The input is trimmed of surrounding whitespace. An empty result (e.g. the
/// caller passed `""`) is returned as-is; resolution treats an empty or
/// unmapped host as a fail-closed rejection, never a default tenant.
#[must_use]
pub fn normalize_host(host: &str) -> String {
let host = host.trim();
let mut host = host.to_ascii_lowercase();
// Strip default ports. We only touch a `:port` suffix that is exactly a
// default port, so IPv6 literals like `[::1]` (which contain colons but no
// trailing `:80`/`:443`) are left intact.
if let Some(stripped) = host
.strip_suffix(":443")
.or_else(|| host.strip_suffix(":80"))
{
host = stripped.to_string();
}
// Strip a single trailing FQDN-root dot.
if let Some(stripped) = host.strip_suffix('.') {
host = stripped.to_string();
}
host
}
#[cfg(test)]
mod tests {
use super::*;
@@ -109,4 +156,44 @@ mod tests {
assert_eq!(ctx.community().as_uuid(), &u);
assert_eq!(ctx.host(), "relay.example");
}
#[test]
fn normalize_host_collapses_tenant_split_variants() {
// All of these are the SAME tenant and must normalize identically —
// this is the property that stops accidental split-tenant.
let canonical = "relay.example";
for variant in [
"relay.example",
"Relay.Example",
"RELAY.EXAMPLE",
"relay.example.", // trailing FQDN root dot
"relay.example:443", // default https port
"relay.example:80", // default http port
"Relay.Example.:443",
" relay.example ", // surrounding whitespace
] {
assert_eq!(normalize_host(variant), canonical, "variant {variant:?}");
}
}
#[test]
fn normalize_host_keeps_nondefault_port() {
// A non-default port is a legitimate distinct selector — keep it.
assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443");
assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000");
}
#[test]
fn normalize_host_leaves_ipv6_literal_intact() {
// IPv6 literals contain colons but no trailing default-port suffix.
assert_eq!(normalize_host("[::1]"), "[::1]");
assert_eq!(normalize_host("[::1]:443"), "[::1]");
}
#[test]
fn normalize_host_empty_stays_empty() {
// Empty / whitespace-only resolves to empty; resolution fails closed.
assert_eq!(normalize_host(""), "");
assert_eq!(normalize_host(" "), "");
}
}
+304 -69
View File
@@ -1,6 +1,25 @@
-- Buzz initial Postgres schema.
-- Buzz initial Postgres schema — multi-tenant.
--
-- This migration is the source of truth for fresh database setup.
-- Source of truth for fresh database setup. This is a clean, from-scratch
-- schema in which `community_id` is a first-class, server-resolved key on
-- every tenant-scoped row. It is NOT additive over the single-community
-- schema; the rewrite replaces it. Existing single-community deployments
-- migrate via the documented backfill migration (0002), which assigns all
-- pre-existing rows to one default community.
--
-- The governing contract is docs/multi-tenant-conformance.md. Every table
-- below cites the conformance surface it implements. The invariant behind the
-- whole schema (conformance "row zero"): a request's community is resolved
-- from the connection host by the server, never supplied by the client, and
-- every scoped row carries that immutable `community_id`.
--
-- Migration-lint obligations enforced by the Lane 0 lint harness:
-- 1. Every tenant-scoped table has `community_id NOT NULL`.
-- 2. No UNIQUE / PRIMARY KEY / FK on a scoped table is observable across
-- communities: each leads with `community_id` (or, for child rows whose
-- parent already pins the community, joins carry the community tuple).
-- 3. `channels.community_id` is immutable (trigger below; no UPDATE path).
-- 4. Operator-global tables are named in the explicit allowlist, not implied.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
@@ -17,10 +36,42 @@ CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted');
CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
-- ── Communities ───────────────────────────────────────────────────────────────
-- Conformance: row zero (host binding). The host map. `resolve_host(host)`
-- reads exactly one row here to mint the request's TenantContext. This table
-- is OPERATOR-GLOBAL: it is the registry of tenants, not itself tenant-scoped,
-- so it carries no `community_id` of its own (its `id` IS the community key).
-- Listed in the lint allowlist as operator-global.
--
-- Host normalization (Lane 0 contract): `host` is stored already-normalized —
-- ASCII-lowercased, trailing dot stripped, default port omitted. The UNIQUE is
-- on `lower(host)` belt-and-suspenders so `Relay.Example` and `relay.example`
-- can never become two tenants even if a writer forgets to normalize.
-- `resolve_host()` (buzz-core) applies the identical normalization before
-- lookup, so resolution and storage agree by construction.
CREATE TABLE communities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host VARCHAR(255) NOT NULL,
signing_key BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
);
CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host));
-- ── Channels ──────────────────────────────────────────────────────────────────
-- Conformance: "Channels and channel membership". `community_id` immutable.
-- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique:
-- the PK is `(community_id, id)`, so the same UUID may legitimately exist in two
-- communities (conformance lists "same channel UUID collision in two
-- communities" as a required isolation test). Handlers always carry `ctx`, so
-- `(ctx.community, h)` names exactly one channel; a client-supplied `h` can
-- never reach another community's channel.
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(),
community_id UUID NOT NULL REFERENCES communities(id),
name VARCHAR(255) NOT NULL,
channel_type channel_type NOT NULL DEFAULT 'stream',
visibility channel_visibility NOT NULL DEFAULT 'open',
@@ -31,7 +82,7 @@ CREATE TABLE channels (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
nip29_group_id VARCHAR(255) UNIQUE,
nip29_group_id VARCHAR(255),
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
max_members INT,
topic TEXT,
@@ -43,20 +94,44 @@ CREATE TABLE channels (
participant_hash BYTEA,
ttl_seconds INT,
ttl_deadline TIMESTAMPTZ,
PRIMARY KEY (community_id, id),
CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
);
CREATE INDEX idx_channels_type ON channels (channel_type);
CREATE INDEX idx_channels_visibility ON channels (visibility);
CREATE INDEX idx_channels_created_by ON channels (created_by);
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash);
-- nip29 group id and DM participant hash are unique WITHIN a community, not globally.
CREATE UNIQUE INDEX idx_channels_nip29_group ON channels (community_id, nip29_group_id)
WHERE nip29_group_id IS NOT NULL;
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (community_id, participant_hash)
WHERE participant_hash IS NOT NULL;
CREATE INDEX idx_channels_community_type ON channels (community_id, channel_type);
CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibility);
CREATE INDEX idx_channels_created_by ON channels (community_id, created_by);
CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline)
WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL;
-- channels.community_id is immutable: a channel can never be re-tenanted.
-- (Conformance: "Migration lint forbids channel re-tenanting except through an
-- explicitly modeled admission path." We have no such path, so: hard block.)
CREATE FUNCTION channels_community_id_immutable() RETURNS TRIGGER AS $$
BEGIN
IF NEW.community_id IS DISTINCT FROM OLD.community_id THEN
RAISE EXCEPTION 'channels.community_id is immutable (channel % cannot be re-tenanted)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_channels_community_id_immutable
BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION channels_community_id_immutable();
-- ── Channel members ───────────────────────────────────────────────────────────
-- Conformance: "Channels and channel membership". PK leads with community_id.
CREATE TABLE channel_members (
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities(id),
channel_id UUID NOT NULL,
pubkey BYTEA NOT NULL,
role member_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -64,35 +139,56 @@ CREATE TABLE channel_members (
removed_at TIMESTAMPTZ,
removed_by BYTEA,
hidden_at TIMESTAMPTZ,
PRIMARY KEY (channel_id, pubkey)
PRIMARY KEY (community_id, channel_id, pubkey),
FOREIGN KEY (community_id, channel_id)
REFERENCES channels (community_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_channel_members_pubkey ON channel_members (pubkey)
CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey)
WHERE removed_at IS NULL;
-- ── Users ─────────────────────────────────────────────────────────────────────
-- Conformance: "Users, profiles, NIP-05, and user search". One profile per
-- (community, pubkey): the same key reposts kind:0 in each community it joins.
CREATE TABLE users (
pubkey BYTEA PRIMARY KEY,
nip05_handle VARCHAR(255) UNIQUE,
community_id UUID NOT NULL REFERENCES communities(id),
pubkey BYTEA NOT NULL,
nip05_handle VARCHAR(255),
display_name VARCHAR(255),
avatar_url TEXT,
about TEXT,
agent_type VARCHAR(255),
capabilities JSONB,
okta_user_id VARCHAR(255) UNIQUE,
okta_user_id VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ,
metadata_event_id BYTEA,
agent_owner_pubkey BYTEA REFERENCES users(pubkey) ON DELETE SET NULL,
agent_owner_pubkey BYTEA,
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32)
PRIMARY KEY (community_id, pubkey),
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32),
-- agent owner is a user in the SAME community.
FOREIGN KEY (community_id, agent_owner_pubkey)
REFERENCES users (community_id, pubkey) ON DELETE SET NULL
);
-- NIP-05 handle and Okta id unique within a community, not globally.
CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle))
WHERE nip05_handle IS NOT NULL;
CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id)
WHERE okta_user_id IS NOT NULL;
-- ── Events (partitioned by month on created_at) ──────────────────────────────
-- Conformance: "Channel-less global events and DMs". `community_id` leads the
-- PK and every hot-path index. Partition stays BY RANGE (created_at) — the
-- monthly partition manager is unchanged (Max's call, plan §5/Lane0 contract).
-- Cross-community dedup: same signed event may exist in two communities;
-- (community_id, created_at, id) dedupes within one, allows across.
CREATE TABLE events (
community_id UUID NOT NULL REFERENCES communities(id),
id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
@@ -104,7 +200,9 @@ CREATE TABLE events (
channel_id UUID,
deleted_at TIMESTAMPTZ,
d_tag TEXT,
PRIMARY KEY (created_at, id)
not_before BIGINT,
delivered_at BIGINT,
PRIMARY KEY (community_id, created_at, id)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_p_past PARTITION OF events
@@ -124,33 +222,55 @@ CREATE TABLE events_p2026_06 PARTITION OF events
CREATE TABLE events_p_future PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at);
CREATE INDEX idx_events_channel_created ON events (channel_id, created_at);
CREATE INDEX idx_events_kind_created ON events (kind, created_at);
CREATE INDEX idx_events_id ON events (id);
CREATE INDEX idx_events_deleted ON events (deleted_at);
CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at);
CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL;
-- Direct id lookup: the PK can't serve `WHERE id=$1` because created_at sits
-- between community_id and id. This index makes the scoped form
-- `WHERE community_id=$ AND id=$` index-served, not a partition scan.
CREATE INDEX idx_events_community_id ON events (community_id, id, created_at DESC);
-- Hot-path indexes, all community-leading.
CREATE INDEX idx_events_community_channel_created
ON events (community_id, channel_id, created_at DESC, id);
CREATE INDEX idx_events_community_pubkey_kind_created
ON events (community_id, pubkey, kind, created_at DESC, id);
CREATE INDEX idx_events_community_kind_created
ON events (community_id, kind, created_at DESC, id);
CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at);
-- Addressable (replaceable) and NIP-33 parameterized lookups.
CREATE INDEX idx_events_addressable
ON events (community_id, kind, pubkey, channel_id, deleted_at);
CREATE INDEX idx_events_parameterized
ON events (community_id, kind, pubkey, d_tag, created_at DESC, id)
WHERE d_tag IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_events_not_before ON events (community_id, not_before)
WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;
-- ── Event mentions ────────────────────────────────────────────────────────────
-- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to
-- events MUST carry the community tuple (e.community_id = m.community_id AND
-- e.id = m.event_id) — bare e.id = m.event_id would leak cross-community
-- mentions (Max, verified at event.rs:222).
CREATE TABLE event_mentions (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey_hex VARCHAR(64) NOT NULL,
event_id BYTEA NOT NULL,
event_created_at TIMESTAMPTZ NOT NULL,
channel_id UUID,
event_kind INT,
PRIMARY KEY (pubkey_hex, event_id)
PRIMARY KEY (community_id, pubkey_hex, event_id)
);
CREATE INDEX idx_event_mentions_pubkey_created ON event_mentions (pubkey_hex, event_created_at DESC);
CREATE INDEX idx_event_mentions_pubkey_kind_created ON event_mentions (pubkey_hex, event_kind, event_created_at DESC);
CREATE INDEX idx_event_mentions_pubkey_created
ON event_mentions (community_id, pubkey_hex, event_created_at DESC);
CREATE INDEX idx_event_mentions_pubkey_kind_created
ON event_mentions (community_id, pubkey_hex, event_kind, event_created_at DESC);
-- ── Subscriptions ─────────────────────────────────────────────────────────────
-- Conformance: "Mesh, agents, ACP/MCP, and CLI" (persisted subscriptions).
CREATE TABLE subscriptions (
id VARCHAR(255) PRIMARY KEY,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
community_id UUID NOT NULL REFERENCES communities(id),
id VARCHAR(255) NOT NULL,
owner_pubkey BYTEA NOT NULL,
filter_kinds JSONB,
filter_authors JSONB,
filter_channel_ids JSONB,
@@ -163,12 +283,17 @@ CREATE TABLE subscriptions (
delivered_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey)
);
-- ── Delivery log (partitioned by month on delivered_at) ──────────────────────
-- Conformance: subscription delivery audit. community_id carried for tenant
-- attribution; child of subscriptions.
CREATE TABLE delivery_log (
community_id UUID NOT NULL REFERENCES communities(id),
id BIGINT GENERATED ALWAYS AS IDENTITY,
subscription_id VARCHAR(255),
event_id BYTEA,
@@ -194,28 +319,40 @@ CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
CREATE TABLE delivery_log_p_future PARTITION OF delivery_log
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
CREATE INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id);
-- ── Workflows ─────────────────────────────────────────────────────────────────
-- Conformance: "Workflows, runs, approvals, webhooks, schedules". Definition's
-- community fixed at create from req.community; runs/approvals inherit it.
CREATE TABLE workflows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
channel_id UUID REFERENCES channels(id),
owner_pubkey BYTEA NOT NULL,
channel_id UUID,
definition JSONB NOT NULL,
definition_hash BYTEA NOT NULL,
status workflow_status NOT NULL DEFAULT 'active',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey),
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
);
CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled);
CREATE INDEX idx_workflows_channel_active ON workflows (community_id, channel_id, status, enabled);
-- Scheduler scans enabled schedule workflows; community_id returned per row so
-- side effects run under the owning tenant's context (Lane0 contract §4a.5).
CREATE INDEX idx_workflows_enabled ON workflows (enabled, status) WHERE enabled;
-- ── Workflow runs ─────────────────────────────────────────────────────────────
CREATE TABLE workflow_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL,
status run_status NOT NULL DEFAULT 'pending',
trigger_event_id BYTEA,
current_step INT NOT NULL DEFAULT 0,
@@ -224,18 +361,24 @@ CREATE TABLE workflow_runs (
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, workflow_id)
REFERENCES workflows (community_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id);
CREATE INDEX idx_workflow_runs_status ON workflow_runs (status);
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (community_id, workflow_id);
CREATE INDEX idx_workflow_runs_status ON workflow_runs (community_id, status);
-- ── Workflow approvals ────────────────────────────────────────────────────────
-- token-hash lookup scoped: approval token grants cannot act on another
-- community's same hash (conformance).
CREATE TABLE workflow_approvals (
token BYTEA PRIMARY KEY,
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES communities(id),
token BYTEA NOT NULL,
workflow_id UUID NOT NULL,
run_id UUID NOT NULL,
step_id VARCHAR(64) NOT NULL,
step_index INT NOT NULL,
approver_spec TEXT NOT NULL,
@@ -245,19 +388,47 @@ CREATE TABLE workflow_approvals (
granted_at TIMESTAMPTZ,
denied_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, token),
FOREIGN KEY (community_id, workflow_id)
REFERENCES workflows (community_id, id) ON DELETE CASCADE,
FOREIGN KEY (community_id, run_id)
REFERENCES workflow_runs (community_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id);
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (run_id);
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status);
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (community_id, workflow_id);
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (community_id, run_id);
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (community_id, status);
-- ── Scheduled workflow fires (cron claim) ─────────────────────────────────────
-- Plan §5: the at-most-once cron fire claim. UNIQUE (community_id, workflow_id,
-- scheduled_for) — only the pod that wins the claim insert creates the run.
-- Restart-safe (DB-durable). community resolved server-side from workflow_id,
-- never a caller-supplied claim parameter (S1 tenant binding).
CREATE TABLE scheduled_workflow_fires (
community_id UUID NOT NULL REFERENCES communities(id),
workflow_id UUID NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, workflow_id, scheduled_for),
FOREIGN KEY (community_id, workflow_id)
REFERENCES workflows (community_id, id) ON DELETE CASCADE
);
-- The interval anchor reads MAX(scheduled_for) per workflow; the janitor prunes
-- by claimed_at globally (operator concern). See plan §5 retention coupling.
CREATE INDEX idx_scheduled_fires_claimed_at ON scheduled_workflow_fires (claimed_at);
-- ── API tokens ────────────────────────────────────────────────────────────────
-- Conformance: "API tokens and NIP-98 replay". token_hash uniqueness scoped to
-- (community_id, token_hash); channel claims reference channels in same community.
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL UNIQUE,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL,
owner_pubkey BYTEA NOT NULL,
name VARCHAR(255) NOT NULL,
scopes JSONB NOT NULL,
channel_ids JSONB,
@@ -267,13 +438,21 @@ CREATE TABLE api_tokens (
revoked_at TIMESTAMPTZ,
revoked_by BYTEA,
created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (community_id, id),
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey),
CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_tokens_hash ON api_tokens (community_id, token_hash);
-- ── Rate limit violations ─────────────────────────────────────────────────────
-- OPERATOR-GLOBAL: a deployment-health / abuse table, never tenant-observable.
-- Listed in the lint allowlist. Carries community_id as an attribution label
-- only (nullable, no uniqueness over it).
CREATE TABLE rate_limit_violations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
community_id UUID,
pubkey BYTEA,
violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
limit_type VARCHAR(64),
@@ -283,11 +462,13 @@ CREATE TABLE rate_limit_violations (
);
-- ── Thread metadata ───────────────────────────────────────────────────────────
-- Conformance: thread lookups filter by community before event matching.
CREATE TABLE thread_metadata (
community_id UUID NOT NULL REFERENCES communities(id),
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
channel_id UUID NOT NULL REFERENCES channels(id),
channel_id UUID NOT NULL,
parent_event_id BYTEA,
parent_event_created_at TIMESTAMPTZ,
root_event_id BYTEA,
@@ -297,17 +478,21 @@ CREATE TABLE thread_metadata (
descendant_count INT NOT NULL DEFAULT 0,
last_reply_at TIMESTAMPTZ,
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (event_created_at, event_id)
PRIMARY KEY (community_id, event_created_at, event_id),
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
);
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (parent_event_id);
CREATE INDEX idx_thread_metadata_root ON thread_metadata (root_event_id);
CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (channel_id, depth, event_created_at);
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (event_id);
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (community_id, parent_event_id);
CREATE INDEX idx_thread_metadata_root ON thread_metadata (community_id, root_event_id);
CREATE INDEX idx_thread_metadata_channel_depth
ON thread_metadata (community_id, channel_id, depth, event_created_at);
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, event_id);
-- ── Reactions ─────────────────────────────────────────────────────────────────
-- Conformance: reactions filter by community before event/pubkey matching.
CREATE TABLE reactions (
community_id UUID NOT NULL REFERENCES communities(id),
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
@@ -315,42 +500,92 @@ CREATE TABLE reactions (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
reaction_event_id BYTEA,
PRIMARY KEY (event_created_at, event_id, pubkey, emoji)
PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji)
);
CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at);
CREATE INDEX idx_reactions_pubkey ON reactions (pubkey);
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id);
CREATE INDEX idx_reactions_event ON reactions (community_id, event_id, event_created_at);
CREATE INDEX idx_reactions_pubkey ON reactions (community_id, pubkey);
-- A reaction's source event id is unique within a community.
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (community_id, reaction_event_id)
WHERE reaction_event_id IS NOT NULL;
-- ── Pubkey allowlist ──────────────────────────────────────────────────────────
-- Conformance: "Relay membership, pubkey allowlist, archived identities".
-- PK becomes (community_id, pubkey).
CREATE TABLE pubkey_allowlist (
pubkey BYTEA PRIMARY KEY,
community_id UUID NOT NULL REFERENCES communities(id),
pubkey BYTEA NOT NULL,
added_by BYTEA,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT
note TEXT,
PRIMARY KEY (community_id, pubkey)
);
-- ── Relay members (NIP-43) ────────────────────────────────────────────────────
-- Conformance: membership gate, community-scoped. pubkey stored as hex TEXT
-- (unchanged wire form). PK (community_id, pubkey).
CREATE TABLE IF NOT EXISTS relay_members (
pubkey TEXT PRIMARY KEY,
CREATE TABLE relay_members (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
added_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey)
);
CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role);
CREATE INDEX idx_relay_members_role ON relay_members (community_id, role);
-- ── Archived identities (NIP-IA) ──────────────────────────────────────────────
-- Conformance: archive cannot hide a key in another community. PK scoped.
CREATE TABLE IF NOT EXISTS archived_identities (
pubkey TEXT PRIMARY KEY,
CREATE TABLE archived_identities (
community_id UUID NOT NULL REFERENCES communities(id),
pubkey TEXT NOT NULL,
consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')),
actor TEXT NOT NULL,
reason TEXT,
replaced_by TEXT,
request_event_id TEXT NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT now()
archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey)
);
-- ── Audit log ─────────────────────────────────────────────────────────────────
-- Conformance: "Audit log and observability". Per-community hash chain:
-- uniqueness (community_id, seq) and (community_id, hash). One chain per tenant.
-- (Lane Audit/Dawn builds the chain logic; Lane 0 fixes the scoped schema.)
CREATE TABLE audit_log (
community_id UUID NOT NULL REFERENCES communities(id),
seq BIGINT NOT NULL,
hash BYTEA NOT NULL,
prev_hash BYTEA,
action VARCHAR(64) NOT NULL,
actor_pubkey BYTEA,
object_id TEXT,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (community_id, seq)
);
CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash);
-- ── Lint allowlist registry ───────────────────────────────────────────────────
-- The explicit registry of tables that are deliberately operator-global (NOT
-- tenant-scoped). The migration-lint harness reads this: any table NOT listed
-- here MUST carry a NOT NULL community_id and lead its uniques with it. Making
-- the allowlist a DB table (not a hard-coded list in the linter) keeps the
-- registry next to the schema it governs and reviewable in one migration diff.
CREATE TABLE _operator_global_tables (
table_name TEXT PRIMARY KEY,
reason TEXT NOT NULL
);
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('communities', 'the tenant registry itself; id IS the community key'),
('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'),
('_operator_global_tables', 'the registry table itself');