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>
346 lines
16 KiB
PL/PgSQL
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;
|