-- §22.8 S6 — request-to-join a scope + the cross-collection inbox. -- -- A gated project or collection is invisible to non-members (§22.5), so a user -- who knows a scope exists can ask to join it: they name a desired role and the -- request is recorded here, then fanned out to that scope's Owners *across the -- subtree* (a collection request reaches the collection's Owners, its project's -- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner -- accepts (which writes the `memberships` row via memberships.grant) or declines; -- the requester is §15-notified of the decision either way. -- -- This mirrors `contribution_requests` (migration 024) but at the scope grain -- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair -- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a -- project or collection a user discovers, not the deployment), and accept grants -- a scope role rather than minting an RFC invitation. -- -- The request row is the persistent record; the inbox notification is the -- owner-facing actionable surface keyed back to it via `notification_id`. CREATE TABLE IF NOT EXISTS join_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- The target scope. 'global' is intentionally excluded: the deployment is -- not a thing one "discovers and joins" (§22.8 names a project/collection). scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')), scope_id TEXT NOT NULL, requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- The role the requester is asking for ({owner, contributor}, the §22.6 -- unified vocabulary). The accepting Owner may grant this or a narrower role. requested_role TEXT NOT NULL CHECK (requested_role IN ('owner', 'contributor')), -- Optional free text — "who I am / why I want in". Bounded by the API layer. message TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')), created_at TEXT NOT NULL DEFAULT (datetime('now')), decided_at TEXT, decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- The role actually granted on accept (may differ from requested_role if the -- Owner narrowed it); NULL until accepted. granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')), -- The owner-facing notification row that carries the Accept/Decline action. notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_join_requests_scope ON join_requests(scope_type, scope_id, status); CREATE INDEX IF NOT EXISTS idx_join_requests_requester ON join_requests(requester_user_id, status); -- At most one open (pending) request per (scope, requester): a second ask while -- one is still pending is a 409, not a duplicate row. A decided request -- (accepted/declined) does not block a fresh ask later. CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open ON join_requests(scope_type, scope_id, requester_user_id) WHERE status = 'pending';