mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary
Expands read-replica usage on the relay per the Rev 2 design (thread
39d1e174 in #buzz-read-only-replica-usage): replaces the
Aurora-incompatible WAL-LSN read-side fence observation with a portable
**heartbeat token**, makes the freshness proof **snapshot-local to the
serving reader session**, and adds a default-off bounded-staleness gate
for head fetches.
### Probe (writer side, unchanged ordering)
The ordered writer scan is kept verbatim — `S = clock_timestamp()` →
masked-visibility `pg_stat_activity` oldest-xact scan — and now ends by
committing a heartbeat token **last** on the same pinned connection
(single-row `UPDATE replica_heartbeat ... RETURNING token, epoch`,
migration `0026`). The single-row update serializes all pods' probes, so
tokens are globally commit-ordered; the three-bucket completeness
argument carries over unchanged. Ring retains `(token, committed_at,
fence_wall)`; epoch change resets the ring; a same-epoch token
regression (restore adversary) clears the ring **and rotates the epoch
on the writer** so pre-rewind readers fail the epoch check. Cadence 1s.
### Routing (snapshot-local proof)
Every routed read opens `BEGIN ISOLATION LEVEL REPEATABLE READ, READ
ONLY` on a reader session and observes the heartbeat as the
transaction's **first statement** — the proof's snapshot is exactly the
snapshot the page, participants batch, and bridge aux closure read from
(`ReadSession` carries the open transaction; drop = rollback).
Fail-closed everywhere: begin failure, missing heartbeat row, epoch
mismatch, token below the ring, over-budget entry → writer.
- **Predicate B (cursor pages, default-on):** same completeness math as
the existing fence — cursor timestamp must be ≤ the proved wall; thread
candidate-terminal and above-wall pages re-run on the writer.
- **Predicate A (head fetches, default-OFF):** gated by
`BUZZ_REPLICA_HEAD_MAX_AGE_SECS` (0 = off, clamped to fence staleness).
Bounded-stale head semantics are an explicit product decision — **do not
enable anywhere without Tyler's backdated-event-semantics acceptance.**
### Observability
`buzz_db_route_decision{path, decision, reason}` across all five paths,
`buzz_db_replica_heartbeat_age_seconds` gauge, and per-decision debug
logs carrying the proved token plus backend identity: `addr:port pid=N`,
prefixed with `aurora_db_instance_identifier()` when the endpoint
supports it (probed once per process on an autocommit checkout; SQLSTATE
42883 caches a definitive false; identity is evidence, never a routing
gate).
## Review & verification
- **Wren:** full review 9/9/9 at `5f81b10e5`; identity delta re-review
approved at exact head `fedb46368` (90/90 unit, clippy `-D warnings`,
fmt independently reproduced).
- **Max:** local E2E **PASS** at `5f81b10e5` — isolated PG17 writer +
two streaming standbys behind HAProxy; paused-reader legs split exactly
as designed (6 replica/fresh + 6 writer/stale), recovery clean, RR
snapshot hardening observed in runtime logs. Evidence:
`WORK_LOGS/2026-07-28_REPLICA_HEARTBEAT_REPLACEMENT_SHA_E2E.md`.
- **My gates at head (same shell):** buzz-db 90 unit + 143
Postgres-gated green (scratch DBs); buzz-relay 767/768 — lone red is the
pre-existing `mesh_demo` flake, red on base; clippy `-D warnings` + fmt
clean.
- Key regression tests:
`routed_request_holds_one_snapshot_across_page_and_aux`
(mutation-verified both directions), same-epoch rotation,
capability-probe negative, head-gate truth table, divergent-fixture
routing suite.
## Post-merge plan
Merge publishes the immutable `sha-*` main image → bb-public PR pins it
→ ArgoCD sync → Max runs the production cursor-routing canary on Aurora
(positive identity branch proven live). Head gate stays off. Client
`since`-widening ships separately.
---
## Update — full read routing (Rev 6, commit `9fa3c9c0b`)
Extends routing to the remaining read seams per
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` Rev 6 (thread cf5deba4 in
#buzz-read-only-replica-usage).
### New routed seams — ALL deploy-default dark
The new seams (`query_events_routed`, `query_events_routed_bounded`,
`count_events_routed`, `get_events_by_ids_routed`,
`query_feed_{mentions,needs_action,activity}_routed`) are
**Bounded-only** and gated on `BUZZ_REPLICA_READ_MAX_AGE_MS`: unset ⇒
every new seam records `writer/disabled` and merging is a no-op. COUNT
and feed/by-ids never take the covered arm (deletion visibility: covered
bounds insert-completeness only). **Note:** the pre-existing cursor
paths (channel windows, thread pages) are *not* gated by this env var —
they route at B=0 today and that status quo is unchanged.
### Reader pool (D4/D5)
- Lazy pool (`connect_lazy`, `min_connections(0)`): reader-down at boot
can't crash the relay; a warn-only boot ping is the only boot-time
visibility, and it primes the Aurora identity capability cache. Priming
is an optimization only — the routed path itself spends a single acquire
budget regardless (see the single-checkout fix below), because a boot
ping that *fails* is correlated with exactly the reader-unavailable case
the budget bound exists for.
- `READER_ACQUIRE_TIMEOUT` = 150ms; a miss fails closed to the writer
with reason **`reader_acquire_timeout`** — named for the mechanism, not
a diagnosis. The budget includes cold connects and sqlx's `size` counts
in-flight dials, so this metric alone does not distinguish contention
from slow connection establishment (see `proved_reader` doc-comment for
the runbook guidance; the pool gauges are 10s samples and are for
capacity planning).
- `BUZZ_DB_READ_POOL_SIZE` sizes the reader independently (invalid/0
inherits writer sizing); `read_pool_stats().max` reports the reader's
own ceiling.
### Community isolation (formal-model question)
Isolation is structural — an explicit `community_id = $n` predicate
compiled into every query builder; no RLS, no session-GUC tenant state
(zero `CREATE POLICY` across migrations). The `_on` variants reuse the
exact same builders with only the executor swapped. Proven by a
seven-seam two-community divergent-fixture test
(`routed_reads_are_confined_to_the_requested_community`),
mutation-tested by Dawn: all four single-predicate stubs killed; the
mention-join feeds are defended in depth (three independent predicates)
so only complete removal leaks there.
### Accepted limitation
D6: client-side staleness on bounded reads (up to `_MS`) is accepted
product behavior when the gate is enabled; gate stays off at merge.
## Update — single acquire budget per routed read (commit `dd26caa9f`)
Max found (and Dawn independently reproduced, 302–330ms measured) that
the boot-unavailable cold path spent **two** stacked
`READER_ACQUIRE_TIMEOUT` budgets: the Aurora capability probe did its
own `pool.acquire()` before `begin_with` acquired again. Fixed by
acquiring **once** per routed read — the capability probe runs on the
held connection (`reader_aurora_capability_on`) and the read-only
`REPEATABLE READ` transaction begins on that same connection
(`Transaction::begin` accepts a `PoolConnection` at `'static` via sqlx's
`MaybePoolConnection`). Reason codes unchanged; capability still never
negatively cached. Measured routed fallback: ~150ms (one budget).
Ships with a PG-gated regression fixture
(`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold`,
authored by Dawn): size-1 reader saturated, capability cache asserted
cold, routes through `count_events_routed`, asserts writer answer +
one-budget elapsed + `writer/reader_acquire_timeout` label
(mutation-tested: `pool_busy` and `reader_validation_error` mutants both
killed). It fails at 330ms on the previous commit and passes on this
one.
### Verification at `dd26caa9f` (pinned toolchain rustc 1.95.0 via repo
`bin/`; ambient rustc 1.89 fails sqlx resolution — always prepend
`bin/`)
- buzz-db: 94 unit + **150/151** PG-gated serial (`--test-threads=1`;
suite is not parallel-safe against one Postgres). The 1 red is
`test_usage_metrics_lock_has_single_owner_and_releases_on_drop` — a
**pre-existing** same-key-same-database advisory-lock collision with any
live relay pointed at the shared `buzz` DB (the relay re-arms
`USAGE_METRICS_LOCK_KEY` on a 300s tick). It passes on a private scratch
DB with the relay still running — verified this run. Remedy is a scratch
`TEST_DATABASE_URL`, **not** terminating lock holders: inspect
`pg_locks`/`pg_stat_activity` and resolve the owning process first; an
idle advisory holder may be a live dev relay. Pre-existing, not a #3268
blocker — filed as #3619.
- buzz-relay: 773/773; clippy `-D warnings` + fmt clean. (`mesh_demo`
echo test is a known pre-existing Redis-dependent flake — Dawn confirmed
it fails identically on the base commit, untouched by this PR.)
- Dawn: independent re-verification at `dd26caa9f` — byte-identical diff
application confirmed, suite reproduced, and fixture proven to still
discriminate (fails at 320ms with only the production fix reverted).
Gate clear. Max: independent rig pass at `dd26caa9f` — 94/94 unit,
**15/15 routing/fallback matrix** (cursor behavior, default-dark seams,
seven-seam confinement, one-snapshot, dead-reader/no-URL fallback,
hard-delete fail-closed, reader max), 773/773 relay, private-DB lock
control. Gate clear; recommends merge + staged bb-block rollout
(no-reader-URL no-op → cursor-only → observe `buzz_db_route_decision` →
conservative nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS`; rollback is
config-only).
## Known gap — CI does not run the PG-backed fixtures (#3622)
Dawn found post-sign-off (confirmed by Max and Eva at `dd26caa9f`):
**every Postgres-backed fixture in this PR is `#[ignore]`d and no CI job
selects it.** `Unit Tests` runs `cargo nextest run -p buzz-db --lib`
(`Justfile:279-285`, ignored tests excluded); the only `--run-ignored
ignored-only` invocations (`ci.yml:690`, `:702`) filter to
`relay_invite::tests`. So the one-budget regression, the seven-seam
isolation fixture, and the fence/floor-guard/fallback tests have zero
automated execution — the verification record above is exhaustive but
manual, at this exact SHA.
Not introduced by this PR (the `#[ignore]` + narrow-filter pattern
predates it; 34 ignored tests total) and not a merge blocker: all new
seams are dark until `BUZZ_REPLICA_READ_MAX_AGE_MS` is set. But it
changes the rollout gate — **do not set a nonzero
`BUZZ_REPLICA_READ_MAX_AGE_MS` in bb-block until CI enforces these
fixtures.** Filed as #3622 (explicit CI selection against the existing
backend-integration Postgres archive; ordering note: #3619 must land
first or be excluded, since widening the filter would select the
colliding usage-metrics lock test).
## Live redteam results (Max, real Helm + PG17 streaming replication at
`dd26caa9f`) — #3643, #3644
Max deployed this exact SHA via the repo chart against a physical
writer/standby pair and attacked it. **The PR's fenced routing passed
every fault**: healthy baseline routes proven on the real standby;
paused WAL replay moved head/cursor reads to `writer/stale`; B=0 moved
routed reads to `writer/disabled`; reader-URL removal rolled back
cleanly.
The redteam also found a **pre-existing** production risk this PR does
not create and does not fix: NIP-50 search builds its own eager,
unfenced pool straight from `READ_DATABASE_URL` (`main.rs:389-402`,
introduced by #2084, present in the deployed bb-block `sha-dd222a5` and
bb-public `sha-22be8bb` images, and both production ESOs already
template `READ_DATABASE_URL`). Measured: ~30s search stall on reader
loss, acknowledged-but-unsearchable writes under replica lag (unaffected
by `BUZZ_REPLICA_READ_MAX_AGE_MS`), fatal eager connect blocking pod
startup when the reader is down, and `replica=true` FTS even at B=0.
Filed as **#3643** (fix: pin FTS to the writer, or fence it like the
routed seams) with **#3644** for the writer-pointing-reader-URL
telemetry gap. Aurora's cluster-ro writer-fallback DNS softens the
outage modes in prod but not the lag mode.
**Revised ladder consequence:** step 2 ("add `READ_DATABASE_URL`, budget
unset") is NOT a no-op with today's binary — it hands FTS an unfenced
replica pool. Rollout order is now: merge (still a true no-op —
bb-block/bb-public already run the unfenced-FTS code and this PR only
adds dark seams) → fix #3643 → CI enforcement #3622 → then reader URL +
budget per the original ladder, re-running Max's three deployment tests
(reader-down latency, reader-down cold boot, paused-replay
read-your-own-write search).
## Activation gate (consolidated) — merge is clear; ALL of the below
precede any nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS`
Six independent verification passes at `dd26caa9f` (Dawn ×2 incl.
byte-level + defang check, Max matrix + live Helm redteam, Wren full
CRUD regression + greenfield 0026 + 151/151 PG, Eva). Every induced
fault was either handled correctly by this PR's machinery or traced to
pre-existing main code. Remaining work gates **activation, not merge**:
1. **#3643** — unfenced NIP-50 FTS pool (pre-existing #2084; live in
prod today; also explains the blackhole search hang Wren observed — the
routed path was measured bounded at 151-152ms against a silent endpoint,
sqlx `inner.rs:252-255`).
2. **#3651** — reader `statement_timeout`: statements after acquire are
unbounded; mid-transaction blackhole reproduced hanging ≥15s (Dawn).
Small fix via `.after_connect` on the reader pool.
3. **#3622** — CI enforcement of the PG fixtures (with #3619 ordering).
4. **Recovery-conflict cancellation live test** — standby cancels a
routed read mid-snapshot → complete writer page, never partial (#3651's
fix also bounds the cancellation-never-arrives shape).
5. **DDL replication lag test** — migration on writer + paused replay +
budget on → fallback, not client-visible SQL errors.
6. **Reader-tx soak** — 30-60min mixed load; no idle-in-tx accumulation
on the standby.
Then Max's three deployment tests (reader-down latency, reader-down cold
boot, paused-replay read-your-own-write search) re-run on the fixed
binary before the first nonzero budget on bb-block.
---------
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
1074 lines
55 KiB
PL/PgSQL
1074 lines
55 KiB
PL/PgSQL
-- Buzz initial Postgres schema — multi-tenant.
|
|
--
|
|
-- 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;
|
|
|
|
-- ── Custom types ──────────────────────────────────────────────────────────────
|
|
|
|
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
|
|
CREATE TYPE channel_visibility AS ENUM ('open', 'private');
|
|
CREATE TYPE member_role AS ENUM ('owner', 'admin', 'member', 'guest', 'bot');
|
|
CREATE TYPE workflow_status AS ENUM ('active', 'disabled', 'archived');
|
|
CREATE TYPE run_status AS ENUM ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled');
|
|
CREATE TYPE approval_status AS ENUM ('pending', 'granted', 'denied', 'expired');
|
|
CREATE TYPE delivery_method AS ENUM ('webhook', 'websocket');
|
|
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(),
|
|
archived_at TIMESTAMPTZ,
|
|
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 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',
|
|
description TEXT,
|
|
canvas TEXT,
|
|
created_by BYTEA NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
archived_at TIMESTAMPTZ,
|
|
deleted_at TIMESTAMPTZ,
|
|
nip29_group_id VARCHAR(255),
|
|
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
max_members INT,
|
|
topic TEXT,
|
|
topic_set_by BYTEA,
|
|
topic_set_at TIMESTAMPTZ,
|
|
purpose TEXT,
|
|
purpose_set_by BYTEA,
|
|
purpose_set_at TIMESTAMPTZ,
|
|
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)
|
|
);
|
|
|
|
-- 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 (
|
|
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(),
|
|
invited_by BYTEA,
|
|
removed_at TIMESTAMPTZ,
|
|
removed_by BYTEA,
|
|
hidden_at TIMESTAMPTZ,
|
|
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 (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 (
|
|
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),
|
|
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,
|
|
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
|
|
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,
|
|
kind INT NOT NULL,
|
|
tags JSONB NOT NULL,
|
|
content TEXT NOT NULL,
|
|
-- Full-text search vector (Typesense → Postgres FTS). Generated/STORED so
|
|
-- it is a single source of truth — no sidecar indexer to keep coherent
|
|
-- (Quinn option A, Lane-0 call). 'simple' config = no stemming/stopwords,
|
|
-- matching the existing substring-ish search semantics; the search lane can
|
|
-- revisit the config behind evidence. Tenant scoping is by the
|
|
-- community-leading btree filters BitmapAnd-ed with the GIN probe, so the
|
|
-- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat:
|
|
-- avoid btree_gin unless EXPLAIN proves it buys something).
|
|
-- Privacy: encrypted/private routing wrappers and p-gated membership notices
|
|
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
|
|
-- never matches `@@`.
|
|
-- Keep in sync with migrations (final state: 0001 + 0005 + 0009).
|
|
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
|
CASE WHEN kind IN (1059, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
|
|
ELSE to_tsvector('simple', content)
|
|
END
|
|
) STORED,
|
|
sig BYTEA NOT NULL,
|
|
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
channel_id UUID,
|
|
deleted_at TIMESTAMPTZ,
|
|
d_tag TEXT,
|
|
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
|
|
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
|
|
CREATE TABLE events_p2026_01 PARTITION OF events
|
|
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
|
CREATE TABLE events_p2026_02 PARTITION OF events
|
|
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
|
CREATE TABLE events_p2026_03 PARTITION OF events
|
|
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
|
CREATE TABLE events_p2026_04 PARTITION OF events
|
|
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
|
CREATE TABLE events_p2026_05 PARTITION OF events
|
|
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
|
CREATE TABLE events_p2026_06 PARTITION OF events
|
|
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
|
CREATE TABLE events_p_future PARTITION OF events
|
|
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
|
|
|
-- 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;
|
|
-- Full-text search. Minimal GIN over the generated tsvector; community scoping
|
|
-- is supplied by the community-leading btree filters above (BitmapAnd), so this
|
|
-- stays a single-column GIN. The search lane confirms the final spelling with
|
|
-- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat).
|
|
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
|
|
|
-- ── 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 (community_id, pubkey_hex, event_id)
|
|
);
|
|
|
|
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 (
|
|
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,
|
|
filter_since TIMESTAMPTZ,
|
|
filter_until TIMESTAMPTZ,
|
|
delivery_method delivery_method NOT NULL DEFAULT 'webhook',
|
|
delivery_url TEXT,
|
|
status subscription_status NOT NULL DEFAULT 'active',
|
|
pause_reason pause_reason,
|
|
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(),
|
|
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,
|
|
method delivery_method,
|
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
success BOOLEAN,
|
|
http_status INT,
|
|
error_message TEXT,
|
|
attempt_number INT DEFAULT 1,
|
|
PRIMARY KEY (delivered_at, id)
|
|
) PARTITION BY RANGE (delivered_at);
|
|
|
|
CREATE TABLE delivery_log_p_past PARTITION OF delivery_log
|
|
FOR VALUES FROM (MINVALUE) TO ('2026-03-01');
|
|
CREATE TABLE delivery_log_p2026_03 PARTITION OF delivery_log
|
|
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
|
CREATE TABLE delivery_log_p2026_04 PARTITION OF delivery_log
|
|
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
|
CREATE TABLE delivery_log_p2026_05 PARTITION OF delivery_log
|
|
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
|
CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
|
|
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
|
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 (
|
|
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,
|
|
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(),
|
|
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 (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 (
|
|
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,
|
|
execution_trace JSONB NOT NULL DEFAULT '[]',
|
|
trigger_context JSONB,
|
|
started_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
error_message TEXT,
|
|
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 (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 (
|
|
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,
|
|
status approval_status NOT NULL DEFAULT 'pending',
|
|
approver_pubkey BYTEA,
|
|
note TEXT,
|
|
granted_at TIMESTAMPTZ,
|
|
denied_at TIMESTAMPTZ,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
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 (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 is server provenance: the scheduler passes
|
|
-- workflow.community_id from list_all_enabled_workflows(), never a client input.
|
|
-- workflow_id is NOT globally unique under the (community_id, id) workflow key, so
|
|
-- the claim binds both community and id explicitly rather than resolving from id.
|
|
-- workflow_run_id links the won claim to the run it created (audit; NULL until the
|
|
-- post-insert attach, and stays NULL if run creation failed after a won claim).
|
|
-- The FK to workflow_runs uses NO ACTION (not SET NULL): community_id is shared
|
|
-- with the claim PK and is NOT NULL, so SET NULL is unimplementable here; a future
|
|
-- delete of a still-linked run is blocked rather than orphaning the at-most-once
|
|
-- claim row. workflow_runs are not pruned today, so this is a guardrail, not a path.
|
|
|
|
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(),
|
|
workflow_run_id UUID,
|
|
PRIMARY KEY (community_id, workflow_id, scheduled_for),
|
|
FOREIGN KEY (community_id, workflow_id)
|
|
REFERENCES workflows (community_id, id) ON DELETE CASCADE,
|
|
FOREIGN KEY (community_id, workflow_run_id)
|
|
REFERENCES workflow_runs (community_id, id) ON DELETE NO ACTION
|
|
);
|
|
|
|
-- 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 (
|
|
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,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ,
|
|
last_used_at TIMESTAMPTZ,
|
|
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),
|
|
limit_value INT,
|
|
actual_value INT,
|
|
action_taken VARCHAR(64)
|
|
);
|
|
|
|
-- ── 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,
|
|
parent_event_id BYTEA,
|
|
parent_event_created_at TIMESTAMPTZ,
|
|
root_event_id BYTEA,
|
|
root_event_created_at TIMESTAMPTZ,
|
|
depth INT NOT NULL DEFAULT 0,
|
|
reply_count INT NOT NULL DEFAULT 0,
|
|
descendant_count INT NOT NULL DEFAULT 0,
|
|
last_reply_at TIMESTAMPTZ,
|
|
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
|
|
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 (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,
|
|
emoji VARCHAR(64) NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
removed_at TIMESTAMPTZ,
|
|
reaction_event_id BYTEA,
|
|
PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji)
|
|
);
|
|
|
|
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 (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
pubkey BYTEA NOT NULL,
|
|
added_by BYTEA,
|
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
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 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(),
|
|
PRIMARY KEY (community_id, pubkey)
|
|
);
|
|
|
|
CREATE INDEX idx_relay_members_role ON relay_members (community_id, role);
|
|
|
|
-- ── Join policy acceptances ──────────────────────────────────────────────────
|
|
-- Durable evidence of the policy version accepted when an invite claim grants
|
|
-- relay membership. The composite foreign key keeps evidence bound to a live
|
|
-- member in the same community and removes it with that membership.
|
|
|
|
CREATE TABLE join_policy_acceptances (
|
|
community_id UUID NOT NULL,
|
|
pubkey TEXT NOT NULL,
|
|
policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
|
|
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, pubkey, policy_version),
|
|
FOREIGN KEY (community_id, pubkey)
|
|
REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
|
|
);
|
|
|
|
-- ── Relay invites (use-limited invite links) ──────────────────────────────────
|
|
-- Conformance: durable invite records for atomic redemption, community-scoped.
|
|
-- Stores only SHA-256(code) as 32-byte BYTEA; never the reusable bearer code.
|
|
-- PK and UNIQUE both lead with community_id. max_uses NULL = unlimited.
|
|
|
|
CREATE TABLE relay_invites (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32),
|
|
role TEXT NOT NULL DEFAULT 'member' CHECK (role = 'member'),
|
|
max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000),
|
|
use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_by TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, id),
|
|
UNIQUE (community_id, token_hash),
|
|
CHECK (max_uses IS NULL OR use_count <= max_uses)
|
|
);
|
|
|
|
CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at);
|
|
|
|
-- ── Archived identities (NIP-IA) ──────────────────────────────────────────────
|
|
-- Conformance: archive cannot hide a key in another community. PK scoped.
|
|
|
|
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(),
|
|
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);
|
|
|
|
-- ── NIP-56 reports (kind:1984 ingest) ─────────────────────────────────────────
|
|
-- One row per accepted report event. Reports are signals, never triggers:
|
|
-- nothing auto-actions on them (NIP-56). Reporter identity is visible to
|
|
-- moderators in the queue but never revealed to the reported author.
|
|
|
|
CREATE TABLE moderation_reports (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
-- The signed kind:1984 event id (stored for audit/idempotency).
|
|
report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32),
|
|
reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32),
|
|
-- What was reported. Exactly one target class per row (CHECK-enforced below).
|
|
target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')),
|
|
target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32),
|
|
target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32),
|
|
target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32),
|
|
-- Channel inferred from an in-tenant target event row, when resolvable.
|
|
channel_id UUID,
|
|
-- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other.
|
|
report_type TEXT NOT NULL,
|
|
-- Reporter's optional free-text context (mod-queue-only; never public).
|
|
note TEXT,
|
|
status TEXT NOT NULL DEFAULT 'open'
|
|
CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')),
|
|
resolved_by BYTEA,
|
|
resolved_at TIMESTAMPTZ,
|
|
-- moderation_actions row that resolved this report, if any.
|
|
action_id UUID,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, id),
|
|
-- Exactly one target class per row: target_kind is authoritative and the
|
|
-- matching column (only) is populated. Queue/action code never guesses.
|
|
CHECK (
|
|
(target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR
|
|
(target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR
|
|
(target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL)
|
|
),
|
|
-- Same-community channel provenance (channels are soft-deleted, never
|
|
-- hard-deleted, so this FK cannot dangle).
|
|
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
|
);
|
|
|
|
-- Queue reads: open reports, newest first, per community.
|
|
CREATE INDEX idx_moderation_reports_status
|
|
ON moderation_reports (community_id, status, created_at DESC);
|
|
-- Group-by-target for triage aggregation.
|
|
CREATE INDEX idx_moderation_reports_target_event
|
|
ON moderation_reports (community_id, target_event_id)
|
|
WHERE target_event_id IS NOT NULL;
|
|
CREATE INDEX idx_moderation_reports_target_pubkey
|
|
ON moderation_reports (community_id, target_pubkey)
|
|
WHERE target_pubkey IS NOT NULL;
|
|
-- Idempotency: one row per report event per community.
|
|
CREATE UNIQUE INDEX idx_moderation_reports_event
|
|
ON moderation_reports (community_id, report_event_id);
|
|
|
|
-- ── Bans + timeouts (one restriction row per member) ──────────────────────────
|
|
-- Ban = connection block, enforced at the NIP-42 auth seam
|
|
-- ("blocked: you are banned from this community") + join/ingest surfaces.
|
|
-- Timeout = write-block only ("restricted: you are timed out until <ts>").
|
|
-- A row may be ban-only, timeout-only, or both over its lifetime.
|
|
|
|
CREATE TABLE community_bans (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32),
|
|
banned BOOLEAN NOT NULL DEFAULT false,
|
|
-- NULL + banned=true ⇒ permanent.
|
|
ban_expires_at TIMESTAMPTZ,
|
|
ban_reason TEXT,
|
|
-- Write-block until this timestamp; NULL or past ⇒ not timed out.
|
|
muted_until TIMESTAMPTZ,
|
|
mute_reason TEXT,
|
|
-- Moderator who last modified this row.
|
|
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, pubkey)
|
|
);
|
|
|
|
-- ── Moderation audit ──────────────────────────────────────────────────────────
|
|
-- One row per accepted moderation action. Full detail (reporter identities,
|
|
-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public
|
|
-- tombstone carries only action_id + reason_code + sanitized public_reason.
|
|
|
|
CREATE TABLE moderation_actions (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
|
action TEXT NOT NULL CHECK (action IN (
|
|
'delete_message', 'kick', 'ban', 'unban',
|
|
'timeout', 'untimeout', 'dismiss_report', 'escalate',
|
|
'resolve:delete', 'resolve:kick', 'resolve:ban',
|
|
'resolve:timeout')),
|
|
target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32),
|
|
target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32),
|
|
channel_id UUID,
|
|
-- Machine-readable rule/reason code (e.g. "spam", "community_rule_3").
|
|
reason_code TEXT,
|
|
-- Sanitized, safe for the public tombstone.
|
|
public_reason TEXT,
|
|
-- Mod-only context; never leaves the audit surface.
|
|
private_reason TEXT,
|
|
-- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only,
|
|
-- the client never learns which.
|
|
matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, id),
|
|
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
|
);
|
|
|
|
CREATE INDEX idx_moderation_actions_created
|
|
ON moderation_actions (community_id, created_at DESC);
|
|
CREATE INDEX idx_moderation_actions_target_pubkey
|
|
ON moderation_actions (community_id, target_pubkey)
|
|
WHERE target_pubkey IS NOT NULL;
|
|
|
|
-- Same-community resolution provenance: a report can only be resolved by an
|
|
-- action row in its own community. Added after moderation_actions exists.
|
|
ALTER TABLE moderation_reports
|
|
ADD FOREIGN KEY (community_id, action_id)
|
|
REFERENCES moderation_actions (community_id, id);
|
|
|
|
-- ── 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');
|
|
-- NIP-PL effective lease state and durable wake outbox. Every key is led by
|
|
-- community_id: client-provided origin is confirmation only, never routing.
|
|
CREATE TABLE push_leases (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
author BYTEA NOT NULL CHECK (length(author) = 32),
|
|
installation_id TEXT NOT NULL CHECK (octet_length(installation_id) BETWEEN 1 AND 64),
|
|
source_event_id BYTEA NOT NULL CHECK (length(source_event_id) = 32),
|
|
source_created_at BIGINT NOT NULL,
|
|
generation BIGINT NOT NULL CHECK (generation > 0),
|
|
active BOOLEAN NOT NULL,
|
|
endpoint_enabled BOOLEAN NOT NULL DEFAULT true,
|
|
app_profile TEXT,
|
|
endpoint_hash BYTEA CHECK (endpoint_hash IS NULL OR length(endpoint_hash) = 32),
|
|
endpoint_grant TEXT,
|
|
max_class TEXT CHECK (max_class IS NULL OR max_class IN ('silent','default','time_sensitive','urgent')),
|
|
subscriptions JSONB,
|
|
expires_at BIGINT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, author, installation_id),
|
|
UNIQUE (community_id, source_event_id),
|
|
CHECK ((active AND app_profile IS NOT NULL AND endpoint_hash IS NOT NULL AND endpoint_grant IS NOT NULL AND max_class IS NOT NULL AND subscriptions IS NOT NULL)
|
|
OR (NOT active AND app_profile IS NULL AND endpoint_hash IS NULL AND endpoint_grant IS NULL AND max_class IS NULL AND subscriptions IS NULL))
|
|
);
|
|
CREATE UNIQUE INDEX push_leases_endpoint_unique
|
|
ON push_leases (community_id, author, app_profile, endpoint_hash)
|
|
WHERE active;
|
|
CREATE INDEX push_leases_expiry ON push_leases (community_id, expires_at) WHERE active;
|
|
|
|
CREATE TABLE push_wake_outbox (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
author BYTEA NOT NULL CHECK (length(author) = 32),
|
|
installation_id TEXT NOT NULL,
|
|
lease_generation BIGINT NOT NULL CHECK (lease_generation > 0),
|
|
endpoint_hash BYTEA NOT NULL CHECK (length(endpoint_hash) = 32),
|
|
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
|
class TEXT NOT NULL CHECK (class IN ('silent','default','time_sensitive','urgent')),
|
|
expires_at BIGINT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','sending','delivered','failed')),
|
|
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
lease_until TIMESTAMPTZ,
|
|
claim_id UUID,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, id),
|
|
FOREIGN KEY (community_id, author, installation_id)
|
|
REFERENCES push_leases (community_id, author, installation_id),
|
|
UNIQUE (community_id, endpoint_hash, event_id)
|
|
);
|
|
CREATE INDEX push_wake_outbox_due
|
|
ON push_wake_outbox (community_id, next_attempt_at) WHERE state = 'pending';
|
|
CREATE INDEX push_wake_outbox_recovery
|
|
ON push_wake_outbox (community_id, lease_until) WHERE state = 'sending';
|
|
-- Durable event-to-push matching follower. The trigger runs in the event insert
|
|
-- transaction, so every accepted persistent event has a crash-safe match job and
|
|
-- rejected/rolled-back events never do. Processing is idempotent through the
|
|
-- push_wake_outbox endpoint/event unique key.
|
|
CREATE TABLE push_match_queue (
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
|
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
|
|
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
lease_until TIMESTAMPTZ,
|
|
claim_id UUID,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (community_id, event_id)
|
|
);
|
|
CREATE INDEX push_match_queue_due
|
|
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
|
|
CREATE INDEX push_match_queue_recovery
|
|
ON push_match_queue (lease_until) WHERE state = 'matching';
|
|
|
|
-- T1b push gate (keep in sync with migrations/0023). Enqueue only when the
|
|
-- community has an active, endpoint-enabled, unexpired lease; the shared
|
|
-- advisory lock pairs with the exclusive lock taken by lease activations
|
|
-- (crates/buzz-db/src/push.rs) to close the lost-wake race.
|
|
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
|
|
-- Centralizing it on the events table covers every durable producer,
|
|
-- including internal paths that bypass live dispatch.
|
|
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
|
|
PERFORM pg_advisory_xact_lock_shared(
|
|
hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0));
|
|
IF EXISTS (
|
|
SELECT 1 FROM push_leases
|
|
WHERE community_id = NEW.community_id
|
|
AND active
|
|
AND endpoint_enabled
|
|
AND expires_at > EXTRACT(EPOCH FROM now())::bigint
|
|
) THEN
|
|
INSERT INTO push_match_queue (community_id, event_id)
|
|
VALUES (NEW.community_id, NEW.id)
|
|
ON CONFLICT DO NOTHING;
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END
|
|
$$;
|
|
|
|
CREATE TRIGGER events_enqueue_push_match
|
|
AFTER INSERT ON events
|
|
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
|
|
|
|
-- Channel TTL refresh (keep in sync with migrations/0024). Runs deferred, in
|
|
-- the transaction that makes a channel-scoped event durable, so a TTL
|
|
-- transition committed while ingest was in flight is never missed. The
|
|
-- per-channel advisory lock is SHARED here — permanent-channel commits admit
|
|
-- each other — and taken EXCLUSIVE by TTL transitions (update_channel in
|
|
-- crates/buzz-db/src/channel.rs), which forces the same total order the
|
|
-- 0022 row lock provided without serializing the hot path.
|
|
CREATE FUNCTION refresh_channel_ttl_after_event_insert() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
channel_ttl INTEGER;
|
|
BEGIN
|
|
-- Kind 9007 creates the channel and initializes its deadline itself.
|
|
IF NEW.channel_id IS NOT NULL AND NEW.kind <> 9007 THEN
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock_shared(hashtextextended(
|
|
'buzz_channel_ttl:' || NEW.community_id::text || ':' || NEW.channel_id::text, 0));
|
|
|
|
SELECT ttl_seconds INTO channel_ttl
|
|
FROM channels
|
|
WHERE community_id = NEW.community_id AND id = NEW.channel_id;
|
|
|
|
IF channel_ttl IS NOT NULL THEN
|
|
UPDATE channels
|
|
SET ttl_deadline = clock_timestamp() + make_interval(secs => ttl_seconds)
|
|
WHERE community_id = NEW.community_id
|
|
AND id = NEW.channel_id
|
|
AND ttl_seconds IS NOT NULL
|
|
AND archived_at IS NULL
|
|
AND deleted_at IS NULL;
|
|
END IF;
|
|
EXCEPTION WHEN OTHERS THEN
|
|
-- Preserve the existing best-effort contract: a TTL refresh failure
|
|
-- must not reject an otherwise valid durable event.
|
|
RAISE WARNING 'channel TTL refresh failed for community %, channel %: %',
|
|
NEW.community_id, NEW.channel_id, SQLERRM;
|
|
END;
|
|
END IF;
|
|
RETURN NULL;
|
|
END
|
|
$$;
|
|
|
|
CREATE CONSTRAINT TRIGGER events_refresh_channel_ttl
|
|
AFTER INSERT ON events
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW EXECUTE FUNCTION refresh_channel_ttl_after_event_insert();
|
|
|
|
-- Replica-fence floor guard (keep in sync with migrations/0021). A deferred
|
|
-- constraint trigger re-checks, inside COMMIT processing, that channel-bearing
|
|
-- event rows are no older than `buzz.created_at_floor` seconds before commit
|
|
-- time (clock_timestamp(), NOT the transaction-frozen now()). This turns the
|
|
-- relay's ingest-time created_at envelope into a commit-time storage
|
|
-- invariant, which is what lets keyset-cursor pages below the replica fence
|
|
-- be served by a read replica without holes. Enforcement is armed per session
|
|
-- via the GUC (set by the relay's writer pool on connect); sessions without
|
|
-- the GUC (pg_restore, manual backfills) bypass it and must hold the replica
|
|
-- fence closed for their duration. The only structural exemption is
|
|
-- channel_id IS NULL: those rows never appear in keyset-paged windows.
|
|
CREATE FUNCTION events_created_at_floor_guard() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE
|
|
floor_secs numeric := nullif(current_setting('buzz.created_at_floor', true), '')::numeric;
|
|
BEGIN
|
|
IF floor_secs IS NOT NULL
|
|
AND floor_secs > 0
|
|
AND NEW.channel_id IS NOT NULL
|
|
AND NEW.created_at < clock_timestamp() - make_interval(secs => floor_secs)
|
|
THEN
|
|
RAISE EXCEPTION
|
|
'events.created_at % is more than % s before commit time %; below the replica-fence floor',
|
|
NEW.created_at, floor_secs, clock_timestamp()
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
RETURN NULL;
|
|
END
|
|
$$;
|
|
|
|
-- INSERT OR UPDATE OF: an UPDATE can move a previously exempt row into the
|
|
-- guarded set (channel_id NULL -> NOT NULL) or move a channel row's
|
|
-- created_at below the fence, so both mutation paths re-run the guard on the
|
|
-- NEW row. A created_at rewrite that crosses partition bounds runs as
|
|
-- DELETE + INSERT and hits the cloned AFTER INSERT guard on the destination
|
|
-- partition; an in-partition rewrite fires the UPDATE OF arm.
|
|
CREATE CONSTRAINT TRIGGER events_created_at_floor
|
|
AFTER INSERT OR UPDATE OF created_at, channel_id ON events
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION events_created_at_floor_guard();
|
|
|
|
-- Durable, deployment-global authority for the public NIP-PL push gateway.
|
|
-- This state is intentionally outside relay community tenancy: installations
|
|
-- delegate to relay signing keys and may authorize multiple relay deployments.
|
|
CREATE TABLE push_gateway_challenges (
|
|
id UUID PRIMARY KEY,
|
|
challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_at);
|
|
|
|
CREATE TABLE push_gateway_installations (
|
|
id UUID PRIMARY KEY,
|
|
app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128),
|
|
app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256),
|
|
assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295),
|
|
app_profile TEXT NOT NULL CHECK (app_profile IN ('buzz-ios-production','buzz-ios-sandbox')),
|
|
token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048),
|
|
token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32),
|
|
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (app_profile, token_fingerprint)
|
|
);
|
|
CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL;
|
|
|
|
CREATE TABLE push_gateway_delegations (
|
|
id UUID PRIMARY KEY,
|
|
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
|
|
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
|
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
|
|
generation BIGINT NOT NULL CHECK (generation > 0),
|
|
not_before TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (installation_id, relay_pubkey),
|
|
CHECK (not_before < expires_at)
|
|
);
|
|
CREATE INDEX push_gateway_delegations_expiry ON push_gateway_delegations (expires_at) WHERE revoked_at IS NULL;
|
|
|
|
CREATE TABLE push_gateway_endpoint_quotas (
|
|
token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32),
|
|
window_started_at TIMESTAMPTZ NOT NULL,
|
|
admitted BIGINT NOT NULL CHECK (admitted >= 0),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX push_gateway_endpoint_quotas_updated ON push_gateway_endpoint_quotas (updated_at);
|
|
|
|
CREATE TABLE push_gateway_delivery_auth_replays (
|
|
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
|
auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
PRIMARY KEY (relay_pubkey, auth_event_id)
|
|
);
|
|
CREATE INDEX push_gateway_delivery_auth_replays_expiry ON push_gateway_delivery_auth_replays (expires_at);
|
|
|
|
CREATE TABLE push_gateway_delivery_request_replays (
|
|
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
|
request_id UUID NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
PRIMARY KEY (relay_pubkey, request_id)
|
|
);
|
|
CREATE INDEX push_gateway_delivery_request_replays_expiry ON push_gateway_delivery_request_replays (expires_at);
|
|
|
|
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
|
('push_gateway_challenges', 'public gateway one-time challenges span relay communities'),
|
|
('push_gateway_installations', 'public gateway installation authority spans relay communities'),
|
|
('push_gateway_delegations', 'public gateway relay delegations span relay communities'),
|
|
('push_gateway_endpoint_quotas', 'public gateway endpoint abuse ceilings span relay communities'),
|
|
('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities'),
|
|
('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities');
|
|
|
|
-- ── Replica heartbeat (read-replica freshness fence) ─────────────────────────
|
|
-- Portable read-side freshness observation for the replica fence (see
|
|
-- crates/buzz-db/src/replica_fence.rs and migrations/0026). Exactly one row;
|
|
-- the single-row token UPDATE is the serialization point that makes tokens
|
|
-- globally commit-ordered across relay pods. `epoch` detects token resets
|
|
-- (restore/re-seed) so a stale retained token can never masquerade as fresh
|
|
-- coverage. Deployment-global by design: describes replication topology,
|
|
-- never tenant data.
|
|
|
|
CREATE TABLE replica_heartbeat (
|
|
id smallint PRIMARY KEY CHECK (id = 1),
|
|
epoch uuid NOT NULL DEFAULT gen_random_uuid(),
|
|
token bigint NOT NULL DEFAULT 0
|
|
);
|
|
|
|
INSERT INTO replica_heartbeat (id) VALUES (1);
|
|
|
|
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
|
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data');
|