Slice 1: scaffolding + propose-to-super-draft vertical

Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the
§5 schema (six numbered migrations), Gitea OAuth + §6 user
provisioning, the §7 catalog left pane, and the propose-to-merge
vertical: propose modal opens an idea PR against the meta repo, an
owner merges from the pending-idea view, the cache picks it up via
webhook or reconciler sweep, and the catalog renders the new
super-draft.

Per §1 the bot is the only Git writer; every commit, branch
creation, and PR merge carries the §6.5 On-behalf-of: trailer and
an `actions` audit row. Per §4 the cache is never written from a
user action — it's webhook+reconciler only.

Covered by `backend/tests/test_propose_vertical.py` against an
in-process Gitea simulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 04:31:11 -07:00
commit 779ba6db59
42 changed files with 10385 additions and 0 deletions
@@ -0,0 +1,65 @@
-- §5 / §6: users, permission events, and audit log.
--
-- The users table is the app-owned canonical account record. Per §6.1,
-- role is one of owner / admin / contributor; anonymous is the absence
-- of a row (or the absence of a session). The §6.2 app-wide write-mute
-- lives here as `muted`, structurally distinct from the §15.6 per-RFC
-- mute (on watches) and the §15.8 per-user mute (notification_user_mutes).
--
-- Per §15, the per-user notification preferences are inlined for
-- proximity. The watched-RFC-churn category has no column per §15.4 —
-- it is permanently off and surfaces in settings as a disabled toggle.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER UNIQUE NOT NULL,
gitea_login TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0, -- §6.2 app-wide write-mute
email_personal_direct INTEGER NOT NULL DEFAULT 1, -- §15.4 default on
email_watched_structural INTEGER NOT NULL DEFAULT 0, -- §15.4 default off
email_admin_actionable INTEGER NOT NULL DEFAULT 1, -- §15.4 default on for admins/owners; ignored for contributors
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')), -- §15.5
notification_quiet_hours_start TEXT, -- §15.8 ISO-8601 local time HH:MM
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT, -- IANA tz name
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_role ON users (role);
-- §6.5: permission-change audit. Append-only. Every mute, role grant,
-- or capability override produces a row here.
CREATE TABLE permission_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
subject_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_kind TEXT NOT NULL, -- e.g. role_changed, muted, restored
details TEXT, -- JSON blob with before/after, reason, etc.
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_permission_events_subject ON permission_events (subject_user_id, created_at);
-- §5: append-only action log. Every state transition, every graduation,
-- every grant change. Includes the on-behalf-of trailer per §6.5 so the
-- audit log and the Git log carry the same accountability.
CREATE TABLE actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
on_behalf_of TEXT NOT NULL, -- the gitea_login the bot acted on behalf of
action_kind TEXT NOT NULL, -- propose_rfc, merge_proposal, graduate, etc.
rfc_slug TEXT,
branch_name TEXT,
pr_number INTEGER,
bot_commit_sha TEXT,
details TEXT, -- JSON blob with kind-specific extras
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_actions_rfc ON actions (rfc_slug, created_at);
CREATE INDEX idx_actions_actor ON actions (actor_user_id, created_at);
+82
View File
@@ -0,0 +1,82 @@
-- §4: the metadata cache. Reconstructible from Gitea at any time by the
-- §4.1 reconciler; never written from user actions, only from webhook
-- handlers and reconciler sweeps. Body content is cached for main-branch
-- reads (§4 #3); branch bodies are not.
--
-- These tables are not in §5's "canonical app tables" list because they
-- are cache, not truth — but they are required for the left-pane render
-- path and for serving super-draft and main-branch bodies without a
-- Gitea round-trip on every navigation.
-- One row per meta-repo rfcs/<slug>.md entry. Mirrors §2.1 frontmatter
-- plus the cached body for super-draft preview (graduated entries have
-- frontmatter-only bodies per §13.3 step 3, but the field is reused).
CREATE TABLE cached_rfcs (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn')),
rfc_id TEXT, -- 'RFC-NNNN' or NULL until graduated
repo TEXT, -- 'org/repo' or NULL until graduated
proposed_by TEXT, -- gitea login or email
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT, -- super-draft body or main RFC.md body
body_sha TEXT, -- the commit sha the body was fetched at
last_main_commit_at TEXT, -- §7.1's "Recently active" sort
last_entry_commit_at TEXT, -- last meta-repo commit touching this entry
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
-- One row per branch the bot knows about on either a per-RFC repo
-- (rfc_slug, state='active'') or on the meta repo as a super-draft edit
-- branch (rfc_slug, state='super-draft', branch_name like 'edit/<slug>/...').
-- §11.5: closed branches stay; deleted branches keep their metadata row
-- per §12 ("branch removed from Gitea, row remains").
CREATE TABLE cached_branches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
UNIQUE (rfc_slug, branch_name)
);
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- One row per PR the bot knows about. Includes meta-repo idea PRs (rfc_slug
-- carries the proposed slug, see §5 super-draft scoping note) and meta-repo
-- body-edit PRs and per-RFC-repo PRs. The pr_kind disambiguates.
CREATE TABLE cached_prs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
pr_kind TEXT NOT NULL CHECK (pr_kind IN ('idea', 'meta_body_edit', 'meta_metadata', 'meta_claim', 'rfc_branch')),
repo TEXT NOT NULL, -- 'org/repo' the PR lives on
pr_number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed', 'withdrawn')),
opened_by TEXT, -- gitea login (resolved from On-behalf-of trailer where present)
opened_at TEXT,
merged_at TEXT,
closed_at TEXT,
head_branch TEXT,
base_branch TEXT NOT NULL DEFAULT 'main',
head_sha TEXT,
UNIQUE (repo, pr_number)
);
CREATE INDEX idx_cached_prs_rfc ON cached_prs (rfc_slug, state);
CREATE INDEX idx_cached_prs_kind ON cached_prs (pr_kind, state);
@@ -0,0 +1,38 @@
-- §5 / §6.4 / §11.1: per-branch visibility and contribute settings.
-- These rows are app data, not cache. They describe what the app permits
-- for a given branch; the bot enforces them before acting. Absence of a
-- row means defaults: read_public=1, contribute_mode='just-me'.
CREATE TABLE branch_visibility (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
UNIQUE (rfc_slug, branch_name)
);
CREATE TABLE branch_contribute_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (rfc_slug, branch_name, grantee_user_id)
);
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- §5 / §7.2: starred RFCs pin to the top of the current sort order.
CREATE TABLE stars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug)
);
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
@@ -0,0 +1,73 @@
-- §5: threads, thread_messages, changes — the conversation and revision
-- substrate.
--
-- Per the §5 super-draft scoping note, rows with rfc_slug pointing at a
-- super-draft entry use branch_name to name a meta-repo branch rather
-- than a per-RFC-repo branch. The schema is identical either way; the
-- interpretation flows from the entry's state in cached_rfcs.
--
-- Threads on a pending-idea PR (§9.3) carry the proposed slug as rfc_slug
-- pre-merge — slugs are reserved during the idea PR per §9.1's uniqueness
-- check — and surface under the super-draft on merge with no data movement.
CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT, -- NULL = scoped to the RFC's main view
anchor_kind TEXT NOT NULL CHECK (anchor_kind IN ('whole-doc', 'range', 'paragraph')),
anchor_payload TEXT, -- JSON: ProseMirror range or paragraph id
thread_kind TEXT NOT NULL CHECK (thread_kind IN ('chat', 'flag', 'review')),
label TEXT, -- short summary, or full flag content
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'resolved', 'stale')),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT,
resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_threads_scope ON threads (rfc_slug, branch_name, state);
CREATE INDEX idx_threads_kind ON threads (thread_kind, state);
-- §5: chat content. Only chat-kind threads have rows here unless a flag
-- has been converted to a chat (§8.13). System-author messages (role='system',
-- author_user_id=NULL) carry the §10.6 manual-edit-flush markers, the §9.3
-- decline-comment record, and similar system-narration entries.
CREATE TABLE thread_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
model_id TEXT, -- set when role='assistant'
text TEXT NOT NULL,
quote TEXT, -- optional selection the user attached
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_thread_messages_thread ON thread_messages (thread_id, created_at);
CREATE INDEX idx_thread_messages_author ON thread_messages (author_user_id, created_at);
-- §5 / §8.6 / §8.9 / §8.11: structured proposed edits. AI-proposed (parsed
-- from <change> blocks per the §18 carryover) or manually authored.
-- stale_since is orthogonal to state: a stale AI proposal stays 'pending'
-- until the contributor acts on the staleness warning per §8.11.
CREATE TABLE changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL,
source_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
kind TEXT NOT NULL CHECK (kind IN ('ai', 'manual')),
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'accepted', 'declined')),
original TEXT NOT NULL,
proposed TEXT NOT NULL,
reason TEXT,
was_edited_before_accept INTEGER NOT NULL DEFAULT 0,
stale_since TEXT,
acted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
acted_at TEXT,
commit_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_changes_scope ON changes (rfc_slug, branch_name, state);
CREATE INDEX idx_changes_thread ON changes (thread_id);
@@ -0,0 +1,45 @@
-- §5 / §10.3 / §15.6 / §15.7: the freshness cursors and the watch model.
--
-- Two cursor families per §15.7: per-event read state lives on notifications
-- (added in 006); per-scope freshness lives on pr_seen and branch_chat_seen.
-- They serve different jobs and are reconciled by the visit-advances-cursor
-- reconciler in §15.7.
CREATE TABLE pr_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug, pr_number)
);
CREATE TABLE branch_chat_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug, branch_name)
);
-- §15.6: the watch model. Three states; auto-rules upgrade but never
-- downgrade; explicit settings exempt from the 90-day decay. Per-RFC
-- mute lives here as state='muted'.
CREATE TABLE watches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT, -- 90-day decay key per §15.6
UNIQUE (user_id, rfc_slug)
);
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
+57
View File
@@ -0,0 +1,57 @@
-- §5 / §15: the notification substrate. Per §15.7, per-row read_at is what
-- the inbox needs because triage is per-event. Per §15.9, system-generated
-- events carry actor_user_id = NULL; the bot account does not appear here.
--
-- Fan-out is at signal-generation time per §15.7: each recipient gets their
-- own row. This trades storage for query simplicity at the inbox surface,
-- and the §15.5 digest's exclusion rules need per-recipient timestamps
-- (email_sent_at, digest_included_at) anyway.
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_kind TEXT NOT NULL, -- §15.1 enum, extensible
rfc_slug TEXT,
branch_name TEXT,
pr_number INTEGER,
thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL,
change_id INTEGER REFERENCES changes(id) ON DELETE SET NULL,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- NULL = system per §15.9
payload TEXT NOT NULL DEFAULT '{}', -- JSON: rendered row text + extras
created_at TEXT NOT NULL DEFAULT (datetime('now')),
read_at TEXT, -- §15.7 per-event triage
email_sent_at TEXT, -- §15.5 exclusion rule 1
digest_included_at TEXT -- §15.5 exclusion rule 3 audit
);
CREATE INDEX idx_notifications_inbox ON notifications (recipient_user_id, read_at, created_at);
CREATE INDEX idx_notifications_scope ON notifications (rfc_slug, branch_name, pr_number);
CREATE INDEX idx_notifications_digest ON notifications (recipient_user_id, digest_included_at);
-- §15.5: per-recipient digest emissions. The period_start / period_end
-- pair makes the event-window dedup queryable at audit time.
CREATE TABLE notification_digests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
signal_ids_included TEXT NOT NULL DEFAULT '[]' -- JSON array of notification ids
);
CREATE INDEX idx_digests_recipient ON notification_digests (recipient_user_id, sent_at);
-- §15.8: per-user notification mute. Notification-volume only; never
-- gates content visibility. The §6.2 clarification reads: an admin or
-- arbiter exercising authority on an RFC cannot mute participants on
-- that RFC. Enforcement of the role-exemption is in the API layer; the
-- schema just stores the mute.
CREATE TABLE notification_user_mutes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
muter_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (muter_user_id, muted_user_id)
);
CREATE INDEX idx_user_mutes_muter ON notification_user_mutes (muter_user_id);