cocottetech/@platform/infrastructure/sql/migrations/0006_peer_social.sql
natalie 1b719e1fd7 chore(bootstrap): initial V4 commit
Clean successor to V3 (forge: lilith/atlilith). Seeded from local Mac
working tree at ~/Code/@projects/@cocottetech/. node_modules and build
artifacts excluded via .gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:11:41 -07:00

346 lines
16 KiB
PL/PgSQL

-- 0006_peer_social.sql
--
-- Provider social network — peer-to-peer plane per Brief AE.
--
-- Adds inter-provider tables for the social layer atop Brief N's coop safety-intel
-- and Brief Y's referral plane. The corpus invariant previously was "coops are the
-- only inter-provider plane" (brief Y §Out of scope); AE amends Y to make peer
-- social (DM, follow + colleague, salons, endorsements, mentorships, collaborations,
-- aggregated feed) the broader plane. Coops (N) remain the dedicated safety-intel
-- channel.
--
-- AE's hard constraints carried into schema:
-- * Per-provider RLS floor unchanged. Two providers connected on AE still cannot
-- see each other's prospects, drafts, journals, audit, analytics, or personas.
-- AE only adds tables in this migration; it does not alter per-provider RLS on
-- existing per-provider tables.
-- * `peer_messages.agent_action_id` references `agent_actions(id)` for the
-- canonical content row, per AE-Q3 lean — `peer_messages` holds only delivery
-- state + IDs, the canonical (eventual-ciphertext-after-0007-AF) payload lives
-- on `agent_actions.outcome_json` (single source of truth on the I append-only
-- spine).
-- * Connection rows store the pair in canonical order (`user_a < user_b`) to avoid
-- duplicate (A,B) + (B,A) rows. Direction is captured via `requested_by`.
-- * Hard ON DELETE CASCADE on user/peer references — brief V V2 erasure cascades
-- to peer plane. AF cryptographic erasure (0007) supersedes; this is the
-- pre-AF baseline.
--
-- Application layer sets `app.current_user_id` GUC per session/connection
-- (same convention as 0001-0003). RLS policies catch bugs; app remains primary.
BEGIN;
-- ---------------------------------------------------------------------------
-- Enums
-- ---------------------------------------------------------------------------
CREATE TYPE peer_connection_kind AS ENUM (
'follow', -- asymmetric (A follows B; B does not auto-follow back)
'colleague' -- symmetric, requires acceptance; unlocks DM + salon-invite + endorse
);
CREATE TYPE peer_connection_state AS ENUM (
'requested',
'connected',
'muted',
'blocked',
'declined',
'disconnected'
);
CREATE TYPE peer_posture AS ENUM (
'incognito', -- default for new accounts: no peer-facing surface
'discoverable', -- coop-mediated discovery on; no platform-wide directory
'open' -- coop + directory + salons + endorsements + feed
);
CREATE TYPE peer_group_kind AS ENUM (
'open', -- anyone in a shared coop can join
'closed' -- invite-only
);
CREATE TYPE peer_group_state AS ENUM (
'proposal',
'awaiting_quorum',
'active',
'archived'
);
CREATE TYPE peer_group_member_role AS ENUM (
'member',
'mod'
);
CREATE TYPE peer_collab_kind AS ENUM (
'co_tour', -- multi-leg tour (R6) crossing two peers' itineraries
'cross_promo', -- coordinated posts across peers' surfaces
'shared_playbook' -- peer-authored templates shared into recipient's library
);
CREATE TYPE peer_collab_state AS ENUM (
'proposed',
'accepted',
'active',
'completed',
'declined',
'cancelled'
);
CREATE TYPE peer_mentorship_state AS ENUM (
'offered',
'accepted',
'active',
'check_in_pending',
'ended_clean',
'ended_unilateral'
);
-- ---------------------------------------------------------------------------
-- peer_profiles — opt-in identity, AE5 directory shape, AE11 posture
-- ---------------------------------------------------------------------------
CREATE TABLE peer_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
handle TEXT NOT NULL UNIQUE,
bio TEXT NOT NULL DEFAULT '',
surfaces_operated TEXT[] NOT NULL DEFAULT '{}', -- list of surface_kind values per brief O
languages TEXT[] NOT NULL DEFAULT '{en}', -- ISO 639-1 codes
region TEXT NULL, -- country / metro only, never precise
discoverable_in UUID[] NOT NULL DEFAULT '{}', -- coop_ids opted into for AE1a
platform_wide_discoverable BOOLEAN NOT NULL DEFAULT FALSE,
posture peer_posture NOT NULL DEFAULT 'incognito',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT peer_profiles_handle_format CHECK (
handle ~ '^[a-z0-9][a-z0-9_.-]{2,31}$'
),
CONSTRAINT peer_profiles_bio_length CHECK (char_length(bio) <= 280)
);
CREATE INDEX idx_peer_profiles_posture ON peer_profiles(posture);
CREATE INDEX idx_peer_profiles_region ON peer_profiles(region);
CREATE INDEX idx_peer_profiles_surfaces ON peer_profiles USING GIN (surfaces_operated);
CREATE INDEX idx_peer_profiles_languages ON peer_profiles USING GIN (languages);
CREATE INDEX idx_peer_profiles_discoverable ON peer_profiles USING GIN (discoverable_in);
-- ---------------------------------------------------------------------------
-- peer_connections — follow + colleague pair states (AE2)
-- ---------------------------------------------------------------------------
CREATE TABLE peer_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_a UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_b UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind peer_connection_kind NOT NULL,
state peer_connection_state NOT NULL,
requested_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT peer_connections_canonical_order CHECK (user_a < user_b),
CONSTRAINT peer_connections_requested_by_member CHECK (
requested_by = user_a OR requested_by = user_b
),
UNIQUE (user_a, user_b, kind)
);
CREATE INDEX idx_peer_connections_user_a ON peer_connections(user_a, state);
CREATE INDEX idx_peer_connections_user_b ON peer_connections(user_b, state);
-- ---------------------------------------------------------------------------
-- peer_messages — AE3 ai-mediated peer DM. Canonical content lives in
-- agent_actions; this table holds delivery state + IDs only (AE-Q3 lean).
-- ---------------------------------------------------------------------------
CREATE TABLE peer_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
agent_action_id UUID NOT NULL REFERENCES agent_actions(id) ON DELETE CASCADE,
turn_id UUID NOT NULL,
delivered_at TIMESTAMPTZ NULL,
read_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT peer_messages_distinct_parties CHECK (sender_id <> recipient_id)
);
CREATE INDEX idx_peer_messages_recipient ON peer_messages(recipient_id, created_at DESC);
CREATE INDEX idx_peer_messages_sender ON peer_messages(sender_id, created_at DESC);
CREATE INDEX idx_peer_messages_turn ON peer_messages(turn_id);
-- ---------------------------------------------------------------------------
-- peer_groups — AE4 salons
-- ---------------------------------------------------------------------------
CREATE TABLE peer_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
topic TEXT NOT NULL,
kind peer_group_kind NOT NULL,
coop_id UUID NULL, -- closed salons may not be coop-bound
quorum_count INTEGER NOT NULL DEFAULT 3,
state peer_group_state NOT NULL DEFAULT 'proposal',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
archived_at TIMESTAMPTZ NULL,
CONSTRAINT peer_groups_quorum_positive CHECK (quorum_count >= 2),
CONSTRAINT peer_groups_name_length CHECK (char_length(name) BETWEEN 1 AND 64),
CONSTRAINT peer_groups_topic_length CHECK (char_length(topic) BETWEEN 1 AND 280)
);
CREATE INDEX idx_peer_groups_state ON peer_groups(state);
CREATE INDEX idx_peer_groups_coop ON peer_groups(coop_id) WHERE coop_id IS NOT NULL;
CREATE TABLE peer_group_members (
group_id UUID NOT NULL REFERENCES peer_groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role peer_group_member_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX idx_peer_group_members_user ON peer_group_members(user_id);
CREATE TABLE peer_group_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES peer_groups(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
agent_action_id UUID NOT NULL REFERENCES agent_actions(id) ON DELETE CASCADE,
turn_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_peer_group_messages_group ON peer_group_messages(group_id, created_at DESC);
-- ---------------------------------------------------------------------------
-- peer_endorsements — AE7 scope-claimed positive reputation
-- ---------------------------------------------------------------------------
CREATE TABLE peer_endorsements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
endorser_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endorsee_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope_claim TEXT NOT NULL,
evidence TEXT NULL,
accepted_at TIMESTAMPTZ NULL,
withdrawn_at TIMESTAMPTZ NULL,
public BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT peer_endorsements_distinct CHECK (endorser_id <> endorsee_id),
CONSTRAINT peer_endorsements_scope_length CHECK (char_length(scope_claim) BETWEEN 1 AND 80),
CONSTRAINT peer_endorsements_evidence_length CHECK (
evidence IS NULL OR char_length(evidence) <= 200
),
CONSTRAINT peer_endorsements_public_requires_accept CHECK (
public = FALSE OR accepted_at IS NOT NULL
)
);
CREATE INDEX idx_peer_endorsements_endorsee_public
ON peer_endorsements(endorsee_id) WHERE public = TRUE AND withdrawn_at IS NULL;
CREATE INDEX idx_peer_endorsements_endorser ON peer_endorsements(endorser_id);
-- ---------------------------------------------------------------------------
-- peer_collaborations — AE6 co_tour / cross_promo / shared_playbook
-- ---------------------------------------------------------------------------
CREATE TABLE peer_collaborations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind peer_collab_kind NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
state peer_collab_state NOT NULL DEFAULT 'proposed',
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT peer_collaborations_distinct CHECK (owner_id <> peer_id)
);
CREATE INDEX idx_peer_collaborations_owner ON peer_collaborations(owner_id, state);
CREATE INDEX idx_peer_collaborations_peer ON peer_collaborations(peer_id, state);
-- ---------------------------------------------------------------------------
-- peer_mentorships — AE8 structured pairings (4 vigils + 30d check-in)
-- ---------------------------------------------------------------------------
CREATE TABLE peer_mentorships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mentor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mentee_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope_claim TEXT NOT NULL,
state peer_mentorship_state NOT NULL DEFAULT 'offered',
round INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NULL,
ended_at TIMESTAMPTZ NULL,
review_json JSONB NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT peer_mentorships_distinct CHECK (mentor_id <> mentee_id),
CONSTRAINT peer_mentorships_round_range CHECK (round BETWEEN 0 AND 4),
CONSTRAINT peer_mentorships_scope_length CHECK (char_length(scope_claim) BETWEEN 1 AND 80)
);
CREATE INDEX idx_peer_mentorships_mentor ON peer_mentorships(mentor_id, state);
CREATE INDEX idx_peer_mentorships_mentee ON peer_mentorships(mentee_id, state);
-- ---------------------------------------------------------------------------
-- RLS — every peer table requires current_user_uuid() to match at least
-- one party. Application layer remains primary enforcer; RLS catches bugs.
-- ---------------------------------------------------------------------------
ALTER TABLE peer_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_group_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_group_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_endorsements ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_collaborations ENABLE ROW LEVEL SECURITY;
ALTER TABLE peer_mentorships ENABLE ROW LEVEL SECURITY;
-- peer_profiles: owner sees self; discoverable peers visible per posture +
-- intersected coop / directory rules. P0 baseline policy: self-row always
-- visible; discoverability filtering happens in application layer (it's a
-- multi-axis predicate that's awkward in SQL). RLS prevents the obvious
-- "read every row" mistake.
CREATE POLICY tenant_isolation ON peer_profiles
USING (user_id = current_user_uuid())
WITH CHECK (user_id = current_user_uuid());
-- peer_connections: visible if the caller is either party.
CREATE POLICY tenant_isolation ON peer_connections
USING (user_a = current_user_uuid() OR user_b = current_user_uuid())
WITH CHECK (user_a = current_user_uuid() OR user_b = current_user_uuid());
-- peer_messages: visible if caller is sender or recipient.
CREATE POLICY tenant_isolation ON peer_messages
USING (sender_id = current_user_uuid() OR recipient_id = current_user_uuid())
WITH CHECK (sender_id = current_user_uuid() OR recipient_id = current_user_uuid());
-- peer_groups: visible if caller is a member.
CREATE POLICY tenant_isolation ON peer_groups
USING (
id IN (SELECT group_id FROM peer_group_members WHERE user_id = current_user_uuid())
);
-- peer_group_members: visible if caller is in the same group.
CREATE POLICY tenant_isolation ON peer_group_members
USING (
user_id = current_user_uuid()
OR group_id IN (
SELECT group_id FROM peer_group_members WHERE user_id = current_user_uuid()
)
)
WITH CHECK (user_id = current_user_uuid());
-- peer_group_messages: visible if caller is in the group.
CREATE POLICY tenant_isolation ON peer_group_messages
USING (
group_id IN (SELECT group_id FROM peer_group_members WHERE user_id = current_user_uuid())
)
WITH CHECK (sender_id = current_user_uuid());
-- peer_endorsements: visible to either party.
CREATE POLICY tenant_isolation ON peer_endorsements
USING (endorser_id = current_user_uuid() OR endorsee_id = current_user_uuid())
WITH CHECK (endorser_id = current_user_uuid() OR endorsee_id = current_user_uuid());
-- peer_collaborations: visible to either party.
CREATE POLICY tenant_isolation ON peer_collaborations
USING (owner_id = current_user_uuid() OR peer_id = current_user_uuid())
WITH CHECK (owner_id = current_user_uuid() OR peer_id = current_user_uuid());
-- peer_mentorships: visible to either party.
CREATE POLICY tenant_isolation ON peer_mentorships
USING (mentor_id = current_user_uuid() OR mentee_id = current_user_uuid())
WITH CHECK (mentor_id = current_user_uuid() OR mentee_id = current_user_uuid());
COMMIT;