-- §6 / §10 / v0.16.0: owner-only invite for per-RFC contribution + -- discussion (roadmap item #12). -- -- Distinct from a platform-level grant (`users.permission_state`, -- v0.8.0 / item #6). This row is per-RFC membership: the RFC's owner -- invites a specific email to either open PRs against that RFC -- (`role_in_rfc='contributor'`) or to participate in the RFC's PR-less -- discussion only (`role_in_rfc='discussant'`). Non-invited users keep -- the v0.6.0 anonymous-read contract — they can read but cannot -- write/discuss that specific RFC. -- -- Coordinates with item #16's parallel work this wave: that item -- adds platform-wide invitation tokens; this one adds per-RFC -- collaboration rows. To avoid table-name + concept collisions the -- two surfaces are scoped distinctly — this migration owns slot 018 -- and names everything `rfc_*` (RFC-scoped); #16 will use a later -- slot and name its tables under a different prefix (`invite_tokens` -- or similar) at the user/platform level. -- -- Tables in this migration: -- -- * `rfc_invitations` — one row per (rfc, invitee_email) invite -- issued by the RFC's owner. Carries the role-in-RFC the -- invitation grants, the opaque token the email link encodes, -- the lifecycle state, and the audit trail (who invited, when -- accepted, by which user_id if any). -- -- * `rfc_collaborators` — one row per (rfc, user_id, role_in_rfc) -- after an invitation is accepted. This is the table the -- write-gate consults: "is the viewer named here for this RFC?" -- Separating the two means the invitation row carries the -- issue/accept lifecycle while the collaborator row is the -- compact membership-check substrate. A grant via collaborator -- can exist independently of a live invitation (admin-only -- direct insert is a §19.2 candidate; v0.16.0 only writes -- collaborator rows via the accept path). -- -- Authorization model the application layer enforces on top of these -- rows (not encoded in SQL — the schema is just storage): -- -- * Writes (open PR, post discussion message, open discussion -- thread) to an RFC require ONE of: -- (a) the viewer is named in this RFC's `rfc_collaborators` -- with the appropriate role_in_rfc, OR -- (b) the viewer holds a globally privileged role (admin, -- owner of the platform) per the existing §6 helpers, OR -- (c) the viewer is named in the RFC's frontmatter owners -- list (the §6 RFC-owner concept, which already grants -- the maximal per-RFC capability). -- -- * Reads remain on the v0.6.0 anonymous-read contract — anyone -- can read any non-withdrawn RFC. Item #12 does not narrow this. -- -- * Only the RFC's owner (per `cached_rfcs.owners_json`) can -- invite. App admins/owners also can (they have the maximal -- per-RFC capability by construction). -- -- Storage shape — `rfc_invitations`: -- -- * `id` — surrogate key; the revoke-by-id surface addresses a -- single row without leaking the token shape. -- -- * `rfc_slug` — TEXT NOT NULL; the RFC the invitation scopes to. -- We FK against `cached_rfcs(slug)` so a withdrawn/deleted RFC -- cascades its invitations away cleanly. The §4 cache contract -- says cached_rfcs is rebuildable from Gitea; per the same -- contract, invitations are app-truth (no Git substrate), so -- the cascade is the right direction. -- -- * `inviter_user_id` — the owner who issued the invite. ON -- DELETE SET NULL because losing the inviter's user row should -- not cascade-delete invitations they sent (the row stays as -- audit; the UI renders "by (deleted user)" the same way the -- audit log does for orphaned actors). -- -- * `invitee_email` — TEXT NOT NULL; the email the invitation -- was sent to. Stored verbatim (case-preserved) so the email -- body can address the invitee in their original shape; the -- accept path matches case-insensitively. -- -- * `role_in_rfc` — CHECK in {'contributor' | 'discussant'}. -- `contributor` lets the user open PRs against the RFC AND -- post in its discussion (PR-permission strictly includes -- discussion-permission); `discussant` only lets them post -- in discussion. Future roles (e.g., 'arbiter') would be -- additions; v0.16.0 ships the two. -- -- * `status` — CHECK in {'pending' | 'accepted' | 'revoked' | -- 'expired'}. Default 'pending'. `accepted` flips on the -- accept endpoint; `revoked` on the owner's revoke gesture; -- `expired` lazily on read (the accept endpoint refuses a -- row whose expires_at has passed, regardless of the column -- value). -- -- * `token` — opaque high-entropy string the email link -- encodes. Stored verbatim (not hashed) because the -- invitation token is single-use and lower-stakes than a -- session token: it grants per-RFC role only, and is bounded -- by expires_at. Hashing the token here is a §19.2 candidate -- if/when the threat model demands it. UNIQUE so the accept -- path is a single-row lookup. -- -- * `expires_at` — TEXT timestamp. Set to `created_at + 30 days` -- at insert time by the application layer. Accept refuses past -- this point; the row can still be revoked or re-issued. -- -- * `created_at` — when the invitation was issued. -- -- * `accepted_at` — when the invitee accepted (NULL until then). -- -- * `accepted_by_user_id` — the user row that accepted. NULL -- until acceptance. On a fresh email (no platform user yet) -- the accept endpoint requires the invitee to sign in first -- via the v0.7.0 OTC path; that path provisions the user row, -- after which the accept call lands the user_id here. -- -- Indexing: -- -- * UNIQUE on `token` so the accept lookup is a primary-key-shape -- hit and accidental collisions are detectable at insert time. -- * (rfc_slug, status) for the owner's "list pending/accepted for -- this RFC" surface — the most frequent query. -- * (invitee_email, status) for a future cross-RFC "show me my -- pending invites" inbox; v0.16.0 doesn't ship that surface but -- the index slot is cheap and aligned with the data shape. -- -- Storage shape — `rfc_collaborators`: -- -- * `id` — surrogate key. -- * `rfc_slug` — TEXT NOT NULL FK cached_rfcs(slug) ON DELETE CASCADE. -- * `user_id` — INTEGER NOT NULL FK users(id) ON DELETE CASCADE. -- A deleted user loses every per-RFC role automatically (mirrors -- the device_trust / passcode cascade shape). -- * `role_in_rfc` — same CHECK as the invitation table. -- * `invitation_id` — INTEGER FK rfc_invitations(id) ON DELETE -- SET NULL. Audit pointer to the row that minted this -- collaborator; NULL is allowed so a future admin-direct grant -- path (a §19.2 candidate) can mint a collaborator with no -- originating invitation. v0.16.0 always populates this. -- * `created_at` — when the collaborator row was minted. -- -- Indexing on collaborators: -- * UNIQUE on (rfc_slug, user_id) — a single user can hold at most -- one role per RFC. Re-accepting an invitation upgrades the row -- (discussant → contributor) but never duplicates. -- * (user_id) for "what RFCs am I a collaborator on?" reads. CREATE TABLE rfc_invitations ( id INTEGER PRIMARY KEY AUTOINCREMENT, rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE, inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, invitee_email TEXT NOT NULL, role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')), token TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), accepted_at TEXT, accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL ); CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token); CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status); CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status); CREATE TABLE rfc_collaborators ( id INTEGER PRIMARY KEY AUTOINCREMENT, rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')), invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (rfc_slug, user_id); CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);