779ba6db59
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>
2606 lines
131 KiB
Markdown
2606 lines
131 KiB
Markdown
# RFC App — Specification
|
||
|
||
This is the agreed-upon model for the rewrite of the Wiggleverse RFC Contributor app.
|
||
It captures the structural decisions made before any UX work on the main document
|
||
pane, per-RFC conversations, revisions, and PRs. Those areas are deliberately
|
||
out of scope here and will be designed in a follow-up session that takes this
|
||
spec plus the existing prototype as context.
|
||
|
||
The technical stack is unchanged from the prototype: FastAPI with SSE streaming
|
||
on the backend, React + Vite + Tiptap (ProseMirror) on the frontend, Gitea as
|
||
the Git host, multiple LLM providers (Anthropic, Google, OpenAI / GitHub
|
||
Copilot). The code base is a clean rewrite; the existing app is reference only.
|
||
|
||
---
|
||
|
||
## 1. Repository topology
|
||
|
||
Each RFC is its own Gitea repository. There is in addition exactly one **meta
|
||
repository** that serves as the authoritative directory of all RFCs in the
|
||
system — drafts, active work, and retired entries alike.
|
||
|
||
All Git operations across all repositories are performed by a single **bot
|
||
service account** in Gitea. Real human users do not have meaningful Gitea
|
||
permissions on the repos themselves; their accounts exist for OAuth identity
|
||
only. The bot is the author of every commit, the opener of every PR, and the
|
||
merger of every merge. Authorization decisions are made by the app, in app
|
||
data, *before* the bot acts on the user's behalf.
|
||
|
||
This means the only contribution surface is the app itself. Raw `git clone`
|
||
plus `git push` is not a supported contribution path. This is a deliberate
|
||
tradeoff: in exchange for losing that path, we gain a permission model that
|
||
can express things Gitea cannot (per-branch contribute grants, per-RFC
|
||
delegated ownership, per-user capability overrides) without contorting Gitea.
|
||
|
||
---
|
||
|
||
## 2. The meta repository's schema
|
||
|
||
The meta repo's `main` branch contains:
|
||
|
||
- `rfcs/` — exactly one markdown file per RFC entry, regardless of state.
|
||
Filenames are the entry's slug, e.g. `rfcs/open-human-model.md`. Slugs are
|
||
the stable identifier from the moment an idea is proposed; integer
|
||
`RFC-NNNN` IDs are only assigned at graduation (see §13).
|
||
- `PHILOSOPHY.md` — the framework's mission and rationale. Hand-authored.
|
||
Updated via PR like any other document. The canonical statement of why
|
||
this exists and what it is producing. Its opening section is a short-form
|
||
distillation (the deck) suitable for use both as the meta-repo README
|
||
header and as the app's pre-login landing copy (see §14).
|
||
- `README.md` — the meta-repo's front page. The **header is hand-authored**
|
||
— a short-form distillation of `PHILOSOPHY.md`, suitable for a reader
|
||
arriving at the meta-repo via Git rather than via the app. The **index
|
||
below the header is regenerated by CI on every merge to main** and lists
|
||
active RFCs, super-drafts, and (eventually) retired entries with links
|
||
into the corresponding entry files and, when present, the RFC's own
|
||
repository.
|
||
- `CONTRIBUTING.md` — explains how to propose, claim, and contribute.
|
||
- A workflow file (Gitea Actions) that regenerates the README index.
|
||
|
||
That's the entirety of the meta repo. App-level permission state, user
|
||
accounts, chat history, audit logs, and branch visibility grants do **not**
|
||
live in the meta repo — they live in the app database (see §5).
|
||
|
||
### 2.1 Entry file format
|
||
|
||
```markdown
|
||
---
|
||
slug: open-human-model
|
||
title: Open Human Model
|
||
state: super-draft # super-draft | active | withdrawn
|
||
id: null # null until graduated; then "RFC-0042"
|
||
repo: null # null until graduated; then "wiggleverse/rfc-0042-open-human-model"
|
||
proposed_by: ben@wiggleverse.org
|
||
proposed_at: 2026-05-22
|
||
graduated_at: null
|
||
graduated_by: null
|
||
owners: [] # contributors elevated for this RFC
|
||
arbiters: [ben] # contributors with merge authority for this RFC
|
||
tags: [identity, schema]
|
||
---
|
||
|
||
## Why this RFC is needed
|
||
|
||
(One- or two-paragraph pitch from the proposer. While the entry is a
|
||
super-draft, the body may grow into the actual draft document. On
|
||
graduation, this body migrates to RFC.md in the new repo; see §13.)
|
||
```
|
||
|
||
### 2.2 Idea submission as PR
|
||
|
||
Proposing a new RFC means opening a PR against the meta repo that adds one
|
||
new file under `rfcs/`. This is what the "Propose new RFC" button in the
|
||
left pane drives. One file per PR keeps idea submissions atomic and
|
||
conflict-free even with concurrent proposers.
|
||
|
||
### 2.3 No integer ID until graduation
|
||
|
||
Integer `RFC-NNNN` IDs are assigned at graduation time as `max(existing
|
||
integer IDs) + 1`. Super-drafts have no integer ID; their stable identifier
|
||
is the slug. This avoids the race condition the prototype has (where two
|
||
concurrent submissions can try to claim the same number) and means
|
||
proposing an idea costs nothing in identifier space.
|
||
|
||
---
|
||
|
||
## 3. RFC states
|
||
|
||
There are three canonical states stored in entry frontmatter:
|
||
|
||
- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
|
||
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone
|
||
can claim ownership; an owner is required before graduation.
|
||
- **`active`** — the entry has been graduated. A dedicated RFC repo
|
||
exists, `repo:` points to it, and real branches/PRs/conversation happen
|
||
there.
|
||
- **`withdrawn`** — pulled before becoming canonical. Stays in the
|
||
directory as historical record, hidden from default views, filterable in.
|
||
|
||
A fourth concept — **`idea`** — is not stored in frontmatter. It is the
|
||
*derived view* of "there is an open PR against the meta repo proposing
|
||
this entry." Pending ideas surface in the left pane through a separate
|
||
affordance (§7), not by reading frontmatter.
|
||
|
||
Two further states (`accepted`, `deprecated`) are deliberately deferred.
|
||
The OHM process does not currently need a hard "this is now official"
|
||
moment; `active` covers that role implicitly. We will revisit if a real
|
||
need emerges.
|
||
|
||
### 3.1 Legal transitions and who can perform them
|
||
|
||
```
|
||
(no entry) ──[idea PR merged by owner/admin]──▶ super-draft
|
||
super-draft ──[graduate, owner or admin]─────▶ active
|
||
super-draft ──[withdraw, proposer or owner/admin]──▶ withdrawn
|
||
active ──[withdraw, owner/admin]─────────────▶ withdrawn
|
||
withdrawn ──[reopen, owner/admin]────────────▶ super-draft
|
||
```
|
||
|
||
Every transition is a commit to the meta repo. State history is auditable
|
||
through `git log rfcs/<slug>.md`. The app maintains a separate audit log
|
||
for "who clicked the button" (see §6.5).
|
||
|
||
### 3.2 State change side-effects
|
||
|
||
For now, changing state in the meta repo entry is the *only* required
|
||
operation for a state transition. Graduation has additional side effects
|
||
(creating the new repo, seeding it); those are covered in §13. We
|
||
deliberately do not tag commits, lock branches, or post notices on
|
||
state change for now — the entry frontmatter is the single source of
|
||
truth and any further automation is a later refinement.
|
||
|
||
---
|
||
|
||
## 4. Storage architecture: Git is truth, app keeps a cache
|
||
|
||
Gitea remains the source of truth for everything Git-shaped: meta repo
|
||
content, RFC repo content, branches, PRs, commits. Nothing in this system
|
||
overrides Gitea on those concerns.
|
||
|
||
The app maintains a **SQLite database**, colocated with the FastAPI
|
||
process, that serves three purposes:
|
||
|
||
1. **Metadata cache** — mirrors only what the left pane and tree view
|
||
need to render fast. Reconstructible from Gitea at any time.
|
||
2. **App data** — things Gitea cannot express. User accounts, role
|
||
assignments, per-branch grants, branch visibility settings, chat
|
||
history, audit logs. This data is canonical; it is not cached, it
|
||
is owned by the app.
|
||
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and
|
||
each super-draft's entry body) is cached for left-pane previews and
|
||
read-without-roundtrip. Branch bodies are *not* cached; the editor
|
||
fetches them live from Gitea when opened.
|
||
|
||
### 4.1 Cache freshness
|
||
|
||
Two paths keep the cache current, running in parallel:
|
||
|
||
- **Gitea webhooks** push events on every meaningful change: meta-repo
|
||
merge, branch create/push/delete, PR open/close/merge, repo create.
|
||
A webhook handler does a focused re-read of just what changed.
|
||
Typical latency: sub-second.
|
||
- **A periodic reconciler** runs every five minutes and does a full
|
||
sweep — list meta-repo entries, list each RFC repo's branches and
|
||
PRs, diff against the cache, fix drift. This is the safety net for
|
||
missed webhooks and downtime.
|
||
|
||
The app never writes to the cache from user actions directly. All cache
|
||
writes flow from webhook arrival or reconciler runs, both of which read
|
||
from Gitea. If the cache is lost or corrupted, the reconciler rebuilds
|
||
from scratch.
|
||
|
||
### 4.2 Operational shape
|
||
|
||
SQLite for now. Colocated with the FastAPI process. Single file, backed
|
||
up alongside the rest of the app. We accept the implication that the
|
||
app can only run as a single process for the foreseeable future. If we
|
||
outgrow this, we plan a maintenance window and migrate to Postgres on
|
||
a separate host.
|
||
|
||
Body full-text search is not part of v1. Title, ID, slug, and tag search
|
||
hit the cache directly. If/when we want full-text search over RFC bodies,
|
||
the natural path is SQLite FTS5 indexed off the reconciler.
|
||
|
||
---
|
||
|
||
## 5. The app's data model (canonical app tables)
|
||
|
||
These are the tables that are app-owned (not cached from Gitea). Names
|
||
and exact columns are illustrative; the implementing session can adjust.
|
||
|
||
- `users` — `id`, `email`, `display_name`, `gitea_login`, `role` (one
|
||
of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2
|
||
app-wide write-mute, distinct from the per-RFC and per-user
|
||
notification mutes in §15.8), `created_at`, `last_seen_at`. Plus the
|
||
per-user notification preferences inlined here for proximity:
|
||
`email_personal_direct` (bool, default true), `email_watched_structural`
|
||
(bool, default false), `email_admin_actionable` (bool, default true for
|
||
admins/owners and unused for contributors), `digest_cadence`
|
||
(`off` / `weekly` / `daily`, default `weekly`),
|
||
`notification_quiet_hours_start` (nullable time),
|
||
`notification_quiet_hours_end` (nullable time),
|
||
`notification_quiet_hours_timezone` (nullable text, IANA tz name).
|
||
The watched-RFC-churn category has no column — it is permanently off
|
||
per §15.4 and surfaces in settings as a disabled toggle naming the
|
||
refusal.
|
||
- `branch_visibility` — `id`, `rfc_slug`, `branch_name`, `read_public`
|
||
(bool, default true), `contribute_mode` (`just-me` / `specific` /
|
||
`any-contributor`, default `just-me`).
|
||
- `branch_contribute_grants` — `id`, `rfc_slug`, `branch_name`,
|
||
`grantee_user_id`, `granted_by`, `granted_at`.
|
||
- `stars` — `id`, `user_id`, `rfc_slug`, `starred_at`.
|
||
- `threads` — every conversation in the system, whether scoped to an RFC's
|
||
main view, a branch, or a span within a branch's document. Columns:
|
||
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
|
||
RFC's main view), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
|
||
`anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
|
||
`thread_kind` (`chat` | `flag` | `review` — `review` is the diff-anchored
|
||
PR-review thread defined in §10.4), `label` (short human-authored summary;
|
||
for flags this is the entire content), `state` (`open` | `resolved` |
|
||
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
|
||
Visibility is derived from the underlying branch (§11.1).
|
||
- `thread_messages` — the actual chat content for `chat`-kind threads.
|
||
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
|
||
`author_user_id` (nullable; null for assistant), `model_id` (nullable;
|
||
set when `role = assistant`), `text`, `quote` (the optional selection
|
||
the user attached to the message), `created_at`. Flag-kind threads
|
||
have no rows here unless they get converted to chats, at which point
|
||
messages accumulate normally. System-author messages (`role = system`,
|
||
`author_user_id = null`) are also used to mark manual-edit flushes on
|
||
PRs per §10.6.
|
||
- `changes` — structured proposed edits to a branch's document. Each
|
||
change is either AI-proposed (from a `<change>` block in an assistant
|
||
message, per the protocol carried over from the prototype, §18) or
|
||
manually authored (typed directly into the editor). Columns: `id`,
|
||
`rfc_slug`, `branch_name`, `thread_id` (nullable; null for direct
|
||
manual edits not tied to a thread), `source_message_id` (nullable;
|
||
set for AI-proposed changes), `kind` (`ai` | `manual`), `state`
|
||
(`pending` | `accepted` | `declined`), `original`, `proposed`,
|
||
`reason`, `was_edited_before_accept` (bool), `stale_since` (nullable
|
||
timestamp; set when an AI proposal's `original` no longer matches the
|
||
branch's current text because a subsequent manual edit overlapped it,
|
||
per §8.11; orthogonal to `state`, which stays `pending` until the
|
||
contributor acts on the stale card), `acted_by`, `acted_at`,
|
||
`commit_sha` (nullable; the bot commit that materialized the
|
||
acceptance, see §8.6). The `<change>` / `<original>` / `<proposed>`
|
||
/ `<reason>` parsing protocol is unchanged from the prototype.
|
||
- `pr_seen` — per-user, per-PR seen-cursor for the "what changed since
|
||
my last visit" mechanism in §10.3. Columns: `id`, `user_id`, `rfc_slug`,
|
||
`pr_number`, `last_seen_commit_sha`, `last_seen_message_id`, `seen_at`.
|
||
Advanced on view; one row per (user, PR).
|
||
- `branch_chat_seen` — per-user, per-branch-chat seen-cursor, the
|
||
within-branch-chat sibling of `pr_seen`. Columns: `id`, `user_id`,
|
||
`rfc_slug`, `branch_name`, `last_seen_message_id`, `seen_at`. Advanced
|
||
on view; one row per (user, branch). Supports the in-context "new
|
||
messages since last visit" accent on branch chats and closes the
|
||
inbox-reconciliation loop for chat-kind notifications per §15.7.
|
||
- `watches` — per-user, per-RFC subscription state for the implicit
|
||
watch model in §15.6. Columns: `id`, `user_id`, `rfc_slug`, `state`
|
||
(`watching` | `following` | `muted`), `set_by` (`auto` | `explicit`),
|
||
`set_at`, `last_participation_at` (nullable; used by the 90-day
|
||
decay rule in §15.6). One row per (user, RFC); absent row means
|
||
no relationship (no signals generated).
|
||
- `notifications` — per-recipient, per-event signal rows that back the
|
||
inbox, badges, email, and digest per §15. Columns: `id`,
|
||
`recipient_user_id`, `event_kind` (enum, see §15.1), `rfc_slug`
|
||
(nullable), `branch_name` (nullable), `pr_number` (nullable),
|
||
`thread_id` (nullable), `change_id` (nullable), `actor_user_id`
|
||
(nullable — null for system-generated events per §15.9), `payload`
|
||
(JSON: the short rendered text the inbox row and email body share,
|
||
plus any kind-specific extras), `created_at`, `read_at` (nullable),
|
||
`email_sent_at` (nullable), `digest_included_at` (nullable). Fan-out
|
||
is at signal-generation time per §15.7.
|
||
- `notification_digests` — per-recipient digest emissions, used by §15.5's
|
||
event-window dedup. Columns: `id`, `recipient_user_id`, `sent_at`,
|
||
`period_start`, `period_end`, `signal_ids_included` (JSON array of
|
||
notification ids). One row per emitted digest.
|
||
- `notification_user_mutes` — per-recipient mute of notifications
|
||
produced by a specific actor, per §15.8. Columns: `id`,
|
||
`muter_user_id`, `muted_user_id`, `muted_at`. Notification-volume
|
||
only; does not affect content visibility anywhere in the app. Not
|
||
available to arbiters or admins acting in their authority capacity
|
||
on RFCs where they hold authority (§6.3) — the role contractually
|
||
requires receiving signals from everyone.
|
||
- `permission_events` — append-only audit log for every role change and
|
||
capability override.
|
||
- `actions` — append-only audit log for every state transition, every
|
||
graduation, every grant change. Includes the acting user, the bot
|
||
commit hash if any, and the on-behalf-of trailer applied.
|
||
|
||
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
||
names a branch on the **meta repo** rather than on a per-RFC repo —
|
||
see §9.5 for the unit-of-work mapping. No column changes are required;
|
||
the interpretation flows from the entry's state. Threads on a
|
||
pending-idea PR (see §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 become the super-draft's main-chat threads on
|
||
merge with no data movement.
|
||
|
||
---
|
||
|
||
## 6. Permission model
|
||
|
||
Authorization is owned by the app. Gitea sees only the bot account.
|
||
|
||
### 6.1 Four roles, each a strict superset of the one below
|
||
|
||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||
every RFC repo's main branch), read any branch whose `read_public`
|
||
is true, read any PR. Cannot chat, propose, create branches, or
|
||
open PRs.
|
||
2. **Contributor.** Default role for any authenticated account.
|
||
Everything anonymous can do, plus: propose new RFCs (open a PR
|
||
against the meta repo), create branches on any RFC repo, open PRs
|
||
from branches they have contribute access to, chat on anything
|
||
they can read, claim ownership of unclaimed super-drafts.
|
||
3. **Admin.** Everything contributor can do, plus: act on any RFC
|
||
(merge PRs on behalf of arbiters, graduate super-drafts, set
|
||
branch visibility on anyone's behalf, downgrade or restore
|
||
individual contributor capabilities, grant or revoke admin to
|
||
others, withdraw or reopen entries).
|
||
4. **Owner.** Everything admin can do, plus: grant or revoke owner,
|
||
disable an account entirely. Ben is owner zero.
|
||
|
||
### 6.2 Per-user capability overrides — "muted"
|
||
|
||
Owners and admins can mute a contributor. A muted user retains read
|
||
access and existing branches, but cannot create new branches, open
|
||
PRs, propose RFCs, or chat. Existing branches remain in the system
|
||
subject to the standard 30/90 hygiene rules (§12). Restoring is the
|
||
reverse action. Every mute and restore is logged in
|
||
`permission_events`.
|
||
|
||
This write-mute is structurally distinct from the two notification
|
||
mutes introduced in §15.8 — the per-RFC notification mute (the
|
||
`muted` state on the `watches` row, §15.6) and the per-user
|
||
notification mute (`notification_user_mutes`, §15.8). The three
|
||
share a word and nothing else: the write-mute is an admin-imposed
|
||
restriction on a contributor's ability to act; the notification
|
||
mutes are self-imposed preferences about receiving signals. They
|
||
live in separate columns and never gate each other. A write-muted
|
||
contributor continues to receive notifications normally (so they can
|
||
triage what they can't act on, and the restore lands cleanly); a
|
||
self-DND'd contributor's own gestures continue to fire signals to
|
||
others normally.
|
||
|
||
### 6.3 Per-RFC delegated authority
|
||
|
||
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
|
||
frontmatter) are contributors who, **within that RFC**, can perform
|
||
admin-scope actions: grant contribute access on any branch in the
|
||
RFC, merge PRs, set branch visibility, withdraw the RFC. They are
|
||
not app-wide admins; their elevated powers are scoped to that
|
||
single RFC. This is what lets work distribute without Ben being on
|
||
the hook for every action.
|
||
|
||
### 6.4 Per-branch contribute grants
|
||
|
||
Each branch has a `contribute_mode`:
|
||
|
||
- `just-me` (default) — only the branch creator can push.
|
||
- `specific` — only the branch creator and the users in
|
||
`branch_contribute_grants` can push.
|
||
- `any-contributor` — any signed-in contributor can push (the
|
||
"I want help" mode).
|
||
|
||
The branch creator and the RFC's owners/arbiters can change this
|
||
setting at any time. Anonymous users can never push regardless of
|
||
setting.
|
||
|
||
### 6.5 Audit trail
|
||
|
||
Every commit the bot makes carries an `On-behalf-of:` trailer naming
|
||
the acting user. Gitea's commit log is for code archaeology; the
|
||
`actions` and `permission_events` tables are the real accountability
|
||
record.
|
||
|
||
---
|
||
|
||
## 7. The left pane
|
||
|
||
The default view is a single scrollable flat list of every meta-repo
|
||
entry whose state is `super-draft` or `active`. State is conveyed by
|
||
visual cue on each row (muted styling and a "super-draft" tag for
|
||
super-drafts; normal weight for active; an integer ID badge for active
|
||
entries only). The list is **not** grouped by state — grouping forces
|
||
a hierarchy on the user that gets in the way of finding by title.
|
||
|
||
### 7.1 Above the list
|
||
|
||
- **Search box** — fuzzy-matches title, slug, and integer ID.
|
||
- **Sort dropdown** — default "Recently active" (max of the RFC's
|
||
last main-branch merge and the meta-repo entry's last commit, for
|
||
super-drafts with no repo). Other options: Created date, Title,
|
||
ID, State.
|
||
- **Filter chip strip** — multi-select, AND-combined. Chips:
|
||
`State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner
|
||
or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty
|
||
`owners:`), `Tag: …`.
|
||
|
||
### 7.2 The list rows
|
||
|
||
Active entries render as `RFC-0042 · Open Human Model`. Super-drafts
|
||
render as `super-draft · Open Human Model` (no integer ID, by design).
|
||
A small star icon on the row left-edge for any RFC the signed-in
|
||
user has starred — starred RFCs pin to the top of the current sort
|
||
order. A small unseen-activity dot — binary, not a count — appears on
|
||
the row's right edge for any RFC the signed-in user is `watching` or
|
||
`following` (per §15.6) that has at least one unread notification
|
||
since the user's last visit. The dot is the only per-row notification
|
||
signal the catalog carries; counts per row would turn the catalog
|
||
into a leaderboard, which is the engagement-bait failure mode §15.1
|
||
refuses.
|
||
|
||
When a super-draft graduates per §13, the row's transition from
|
||
`super-draft · Title` to `RFC-NNNN · Title` renders as a brief
|
||
crossfade — on the order of 300–400ms — with the integer-ID badge
|
||
animating into the row's left edge as the muted super-draft styling
|
||
fades to the standard weight. The transition fires on the first
|
||
render after the post-graduation webhook for every viewer whose
|
||
catalog is currently mounted, not just the admin who confirmed; a
|
||
contributor browsing the catalog when graduation lands sees the
|
||
same acknowledgment. The animation is the catalog's one signal that
|
||
a structural act happened; the in-dialog "Graduation complete" frame
|
||
from §13.3 carries the ceremonial beat for the confirming admin.
|
||
|
||
### 7.3 Below the list
|
||
|
||
- **"Pending ideas" disclosure** — a small expandable section showing
|
||
open meta-repo PRs that are proposing new entries. This is the only
|
||
place idea-state items appear. Reading is open to all; reacting in
|
||
the PR discussion requires contributor; merging requires
|
||
owner/admin.
|
||
- **"+ Propose new RFC" button** — kicks off idea submission, which
|
||
is a PR against the meta repo adding one file to `rfcs/`.
|
||
|
||
### 7.4 Drilling into an RFC
|
||
|
||
Selecting an entry opens its RFC view. The structural shape of that
|
||
view — a tree of main / open PRs / open branches / closed-but-not-
|
||
deleted branches (hidden by default) — is established here. The
|
||
document pane, chat, and branch navigation inside that view are
|
||
specified in §8. The revision flow and PR flow inside the view
|
||
remain deferred (see §16).
|
||
|
||
---
|
||
|
||
## 8. The RFC view: document, chat, and branch navigation
|
||
|
||
When a user selects an active RFC from the left pane, the app opens
|
||
its RFC view. This section captures the structural decisions about
|
||
that view: layout, the document, the chat, and how the user moves
|
||
between branches.
|
||
|
||
### 8.1 Layout
|
||
|
||
The RFC view inherits the three-column shape from the prototype:
|
||
|
||
- **Left column** — the RFC catalog from §7, unchanged.
|
||
- **Center column** — a thin breadcrumb strip at the top showing the
|
||
current branch with a dropdown affordance; the editor (or diff view
|
||
in review mode) below it; a prompt bar at the bottom for chat input.
|
||
- **Right column** — the chat thread for the currently-selected
|
||
branch, with a change-card panel below it in contexts where editing
|
||
is enabled (see §8.3).
|
||
|
||
The center column's breadcrumb reads, for example: `OHM › main ▾ · 3
|
||
branches · 1 PR`. The chevron opens a dropdown listing `main` at the
|
||
top, then open branches sorted by recent activity (with a visibility
|
||
indicator for any private ones), then open PRs with their status, and
|
||
a "Show closed branches" toggle at the bottom. Selecting any item
|
||
swaps both the document body and the right-pane chat thread together.
|
||
The URL updates accordingly (`/rfc/<slug>/branches/<name>`), so a
|
||
branch view is shareable and back/forward navigation works.
|
||
|
||
### 8.2 Default view on selection
|
||
|
||
Selecting an active RFC opens the center column on the RFC's `main`
|
||
body, rendered in the editor in read-only mode (the discuss-mode
|
||
default — see §8.3). The right column shows main's chat thread. The
|
||
breadcrumb indicates `main`. Selecting a different branch via the
|
||
dropdown swaps the body to that branch's `RFC.md` and the chat to
|
||
that branch's thread.
|
||
|
||
### 8.3 Discuss vs. contribute mode (branch-scoped)
|
||
|
||
The prototype's discuss-vs-contribute distinction survives, but is
|
||
scoped to the current branch rather than global.
|
||
|
||
- **Discuss mode** is the default on any branch (including main).
|
||
The editor is read-only. The chat is enabled (subject to §6's
|
||
role rules and §11.4's visibility rules). AI-proposed changes are
|
||
*buffered* — they appear in chat but do not apply to the document —
|
||
and a contribution CTA surfaces in the right column when buffered
|
||
changes exist.
|
||
- **Contribute mode** flips a single branch into edit-enabled. The
|
||
editor becomes editable. AI changes apply to the change-card panel.
|
||
Manual edits are tracked. The mode is reversible; the user can
|
||
return to discuss mode on the same branch without losing state.
|
||
|
||
On main, contribute mode is not available directly — main is read-only
|
||
by definition (PRs are the only path to change main). The "Start
|
||
Contributing" button on main instead creates a new branch and lands
|
||
the user on it in contribute mode. New-branch naming defaults to an
|
||
auto-generated value (user-renamable); the exact format is an
|
||
implementation detail.
|
||
|
||
Discuss vs. contribute is an *intent* affordance, not a *permission*
|
||
affordance. A user without contribute access to a branch sees the
|
||
toggle disabled, with a sign-in or request-access path (see §8.7).
|
||
|
||
### 8.4 Chat threading: per-branch with lineage by link
|
||
|
||
Each branch — including main — has its own chat thread. Threads are
|
||
not shared across branches.
|
||
|
||
- Main's chat is about the RFC overall.
|
||
- Each branch's chat is about that branch's work.
|
||
- A branch chat starts empty. The branch creation event records
|
||
the main-chat message that motivated it (when applicable), and
|
||
the branch chat header surfaces a "Forked from this conversation
|
||
→" link to that message. The link is a UI affordance, not a chat
|
||
message — it does not appear in transcripts.
|
||
- On PR merge, the branch chat persists attached to the (now-closed)
|
||
branch as historical record. It does not merge into main's chat.
|
||
|
||
Chat visibility follows the branch's read visibility (§11.1). Posting
|
||
requires contributor role and (for branches in non-public contribute
|
||
modes) the relevant grant per §6.4.
|
||
|
||
### 8.5 AI conversation continuity into PRs
|
||
|
||
When a branch becomes a PR, the PR review view surfaces the diff and
|
||
the branch's chat thread together — alongside, or stacked, per layout
|
||
constraints. The chat is shown in *compressed* form by default:
|
||
|
||
- Messages that produced accepted changes (and their immediately
|
||
surrounding context) are expanded.
|
||
- The rest is collapsed behind a "Show full conversation" toggle.
|
||
|
||
This treats the conversation as first-class evidence (per the
|
||
framework's philosophy — see `PHILOSOPHY.md`) without overwhelming
|
||
reviewers with exploratory back-and-forth. The exact rendering of the
|
||
compressed view is an implementation detail; the binding part is that
|
||
the conversation is the default surface, not a sidequest.
|
||
|
||
### 8.6 Tracked changes → commits
|
||
|
||
Each accepted AI change becomes one commit on the branch immediately.
|
||
The commit message body contains the change's `original`, `proposed`,
|
||
and `reason`. Trailers carry the conversation message ID that produced
|
||
the change and the standard `On-behalf-of:` per §6.5.
|
||
|
||
Manual edits buffer locally in the editor. They flush as one commit
|
||
on:
|
||
|
||
- A short idle window after the user stops typing (default ~5 min;
|
||
exact value is an implementation detail).
|
||
- Branch switch.
|
||
- Explicit save gesture.
|
||
|
||
Every commit pushes immediately to Gitea. There is no local-vs-remote
|
||
split — the branch on Gitea is the canonical state. Collaborators on
|
||
a shared branch see each other's commits as they land.
|
||
|
||
### 8.7 Read-only fallbacks
|
||
|
||
Anonymous users and muted contributors (per §6.1, §6.2) can read but
|
||
not write or chat. Their experience in the RFC view:
|
||
|
||
- **Document.** Renders as in the standard view. The editor is
|
||
read-only regardless of mode.
|
||
- **Chat thread.** Visible per §11.4. Chat input is shown but
|
||
disabled, with a "Sign in to chat" button (anonymous) or a muting
|
||
banner ("Your account is muted. Contact an admin." — muted).
|
||
- **Selection tooltip.** Highlighting still works; the submit action
|
||
reads "Sign in to ask" or is disabled with a muting hint.
|
||
- **Header.** "Start Contributing" is replaced with "Sign in" for
|
||
anonymous users; for muted contributors, the button is disabled
|
||
with a tooltip explaining why.
|
||
- **Breadcrumb dropdown.** Lists only the branches the user can read
|
||
per §11.1. Muted contributors retain their own existing branches in
|
||
the listing.
|
||
|
||
The intent is to keep the invitation visible while making it
|
||
impossible to act without the appropriate role.
|
||
|
||
### 8.8 The change-card panel
|
||
|
||
The change-card panel is the right-column surface where the branch's
|
||
proposed and acted-on changes accumulate. In contribute mode it
|
||
renders below the chat as a persistent second pane — chat above,
|
||
changes below — with pending cards stacked on top of resolved ones
|
||
inside the panel. In discuss mode the panel is hidden; a single
|
||
contribution-CTA card surfaces in its place per §8.14.
|
||
|
||
The chat and the panel are bound bidirectionally. Each assistant
|
||
message that produced changes carries a clickable "↓ N changes added
|
||
below" hint that scrolls the panel to those cards and flashes them.
|
||
Each card carries an "↑ from this message" affordance that scrolls
|
||
the chat back to the originating message and highlights its bubble.
|
||
The conversation is the evidence the change was earned; the
|
||
click-binding keeps both halves of that evidence reachable from
|
||
either side without depending on the contributor remembering where
|
||
in the timeline they're standing.
|
||
|
||
A pending card carries the proposed change's diff (inline word-level
|
||
for both AI and manual), the originating author label, and the
|
||
accept/decline/edit affordances specified in §8.9. A resolved card
|
||
collapses to a compact one-line stub — author · state · short reason —
|
||
that stays in the panel as evidence. Resolved cards never return to
|
||
pending; revising a previously-declined or previously-accepted
|
||
decision means producing a new card from a new conversation turn.
|
||
|
||
### 8.9 Accept, decline, and edit
|
||
|
||
Three actions resolve a pending card. **Accept** runs a ProseMirror
|
||
transaction that locates the change's `original` text in the editor as
|
||
a range, deletes it, inserts the `proposed` text in its place, and
|
||
wraps both insertion and deletion in tracked-change marks carrying
|
||
the change's ID. The commit fires immediately per §8.6 and the card
|
||
moves to its resolved stub form. The fallback for ambiguous ranges
|
||
— the `original` text appearing in more than one place in the
|
||
document — is to refuse the automatic apply and surface a "this
|
||
change can't be auto-applied; review and accept manually" state on
|
||
the card.
|
||
|
||
**Edit-before-accept** is one card with a `was_edited_before_accept`
|
||
flag (§5), not two. The card opens an in-place textarea pre-filled
|
||
with `proposed`; saving updates `proposed` to the contributor's
|
||
revision and flips the flag. The subsequent accept produces a single
|
||
commit; the commit body carries both the AI's original proposed text
|
||
under an `AI proposed:` section and the contributor's accepted
|
||
revision in the usual `proposed` body, so the timeline preserves what
|
||
was offered and what landed as distinct artifacts. The commit's
|
||
`On-behalf-of:` trailer per §6.5 names the contributor, not the AI;
|
||
the AI's authorship survives only in the `AI proposed:` body section
|
||
and in the `source_message_id` linkage.
|
||
|
||
**Decline** is not a commit — no document state changed — but the
|
||
card persists. The card moves to its resolved stub form
|
||
(author · declined · the AI's short reason) and stays in the panel
|
||
and the `changes` table with `state='declined'`. There is no undo
|
||
affordance. The framework's claim depends on refusal being legible:
|
||
a contributor scrolling the resolved list should see what they
|
||
considered and rejected, not just what they accepted. To re-propose,
|
||
ask the AI again — the new proposal lands as a new card with the
|
||
declined predecessor still visible.
|
||
|
||
### 8.10 Tracked-change markup and the review-mode toggle
|
||
|
||
Two visual layers carry change information in the editor. The first
|
||
is a paragraph-margin marker — a thin gutter accent on any paragraph
|
||
that differs from the branch's open-session baseline, rendered by a
|
||
ProseMirror plugin against a baseline snapshot taken when the editor
|
||
opens. The second is inline `tracked-delete` / `tracked-insert`
|
||
markup at the exact range of an accepted change, with the deleted
|
||
text shown struck-through and the inserted text shown in an additive
|
||
style; both carry the change's ID via a data attribute, enabling the
|
||
click-to-card binding from §8.8. The margin marker is scannable
|
||
("did anything change in this region?"); the inline markup is
|
||
precise ("what changed here?"). The two answer different questions
|
||
and both are kept.
|
||
|
||
The inline markup is session-local. Each accepted change is already
|
||
a clean commit on Gitea per §8.6, so the branch's canonical state at
|
||
any reload is the integrated text without markup. Regenerating
|
||
markup on load by diffing against earlier commits is technically
|
||
possible but adds a mechanism — a per-user seen-cursor for accepted
|
||
changes, an explicit dismiss UI — to solve a problem that DiffView
|
||
already solves durably and at higher fidelity. The editor is for
|
||
writing; layering permanent diff overlay on top of writable text
|
||
degrades the writing surface, so the markup clears on reload and
|
||
DiffView is the durable artifact for inspecting accepted changes in
|
||
context.
|
||
|
||
DiffView is the read-only render surface invoked via a toolbar
|
||
toggle, carried over from the prototype per §8.15. It reads from the
|
||
`changes` table for the branch, reconstructs the markup for every
|
||
accepted change in branch history, and renders the result in-place
|
||
where the editor was. Hovering any marked span surfaces a tooltip
|
||
with the change's type badge (`ai` or `manual`), the model
|
||
identifier where applicable, the `was_edited_before_accept` flag
|
||
where set, the user prompt and selection-quote that drove the
|
||
change, and the AI's `reason`. The toggle is reversible; returning
|
||
to the editor restores the live writing surface and reattaches the
|
||
session-local baseline.
|
||
|
||
### 8.11 Manual edits and collisions with AI proposals
|
||
|
||
Manual edits accumulate in the editor and surface in the change-card
|
||
panel as a single pending card per flush window, growing one inline
|
||
word-diff per touched paragraph as the contributor types (debounced
|
||
on the order of a second — exact value is an implementation detail).
|
||
The card carries a quiet status line — `unsaved · auto-save in 4:32`
|
||
— that counts down toward the §8.6 idle threshold, plus an explicit
|
||
`Save now` button that flushes immediately. On flush — idle, branch
|
||
switch, or save — the card freezes into its resolved stub form with
|
||
the commit SHA from §8.6 attached, and a fresh pending manual card
|
||
opens on the next keystroke. The pending card's diffs and the
|
||
resolved card's diffs each match exactly one Git commit, preserving
|
||
the §8.6 evidence-unit framing.
|
||
|
||
When an AI proposal's `original` text can no longer be located in
|
||
the current document because a manual edit has changed it since the
|
||
proposal was generated, the AI's card is marked **stale** — the
|
||
`stale_since` timestamp on the `changes` row is set, distinct from
|
||
`state`, which stays `pending` until the contributor acts. The card
|
||
surfaces a warning badge and a `Re-ask` button that re-prompts the
|
||
AI with the current text to regenerate the proposal anchored to the
|
||
new phrasing. Accept is gated behind a confirmation step on a stale
|
||
card — "text has changed since this was proposed; apply anyway?" —
|
||
for the case where the contributor judges the AI's proposal still
|
||
applicable despite the drift. Silent re-merging is refused: the
|
||
AI's argument was about a specific phrasing, and applying it to
|
||
different phrasing produces text neither party authored.
|
||
|
||
### 8.12 Threads on a branch: anchors and the chat surface
|
||
|
||
Every branch has a default `whole-doc` thread — the branch chat
|
||
referenced throughout §8 and §10. Beyond that default, sub-threads can
|
||
be anchored to a range or a paragraph (`anchor_kind` per §5) and
|
||
accumulate inside the same chat surface. The branch chat is the
|
||
unified chronological feed of every message across every thread on
|
||
the branch; threads are the structural grouping inside that feed,
|
||
not a separate surface.
|
||
|
||
Range threads are created by the selection tooltip carried over per
|
||
§8.15. Submitting a tooltip prompt creates a new `thread_kind='chat'`,
|
||
`anchor_kind='range'` thread anchored to the selection, or continues
|
||
an existing open thread whose anchor overlaps the new selection
|
||
substantially — a threshold (default in the neighborhood of 50% of
|
||
characters) prevents one passage from spawning a forest of single-Q&A
|
||
orphans while still allowing a deliberately different framing to
|
||
start a new conversation. Paragraph threads are created via a
|
||
margin-icon affordance that appears on hover beside any paragraph;
|
||
clicking opens a thread-kind picker (chat or flag — flags per §8.13)
|
||
and a composer for the first message.
|
||
|
||
In the feed, sub-threads render with a thin colored gutter and a
|
||
small anchor preview ("quoted: …"), grouping their messages visually
|
||
without breaking chronology. A top-of-chat disclosure shows aggregate
|
||
counts — `N open threads · M open flags` — and expands to a list of
|
||
open threads with anchor previews and per-thread filter affordances
|
||
that collapse the feed down to a single thread. Clicking the anchor
|
||
preview on any message scrolls the editor to that anchor and
|
||
highlights the range or paragraph. A `Reply` affordance on any
|
||
message posts back into its thread; the AI participant can be
|
||
invoked into any thread, not only the whole-doc default, and its
|
||
`<change>` proposals carry the `thread_id` they were generated
|
||
within.
|
||
|
||
A thread is resolved by its creator, by the branch creator, by any
|
||
of the RFC's owners or arbiters per §6.3, or by app-wide admins or
|
||
owners. Resolution writes `state='resolved'` with `resolved_by` and
|
||
`resolved_at`; in the chat feed, resolved threads collapse to a
|
||
one-line stub (expandable) and any editor anchor markers fade to
|
||
nearly invisible. Threads auto-transition to `state='stale'` when
|
||
their anchor no longer maps to the document — the paragraph deleted,
|
||
the range no longer locatable after edits — and the editor anchor
|
||
surface is replaced with an "(anchor lost)" affordance that disables
|
||
the scroll-to-editor binding. Resolved and stale threads stay in the
|
||
data; the chat feed's filter affordances surface or hide them on
|
||
demand.
|
||
|
||
### 8.13 Flags
|
||
|
||
A flag is the lightweight "I'm pointing at this, it's a problem"
|
||
gesture — a single declarative assertion that something is wrong,
|
||
incomplete, or unclear, anchored to a range, a paragraph, or the
|
||
document as a whole. The flag's entire content lives in
|
||
`threads.label` (§5); no back-and-forth is required to drop one. The
|
||
schema is shared with chat threads — `thread_kind='flag'` — so the
|
||
surfacing, resolution, and stale mechanics from §8.12 apply
|
||
unchanged. The distinction is gestural and visual, not structural.
|
||
|
||
Creation requires contributor role but not contribute access to the
|
||
branch: any signed-in contributor who can read a passage can point at
|
||
it and say it's wrong. Flags are created via the same two affordances
|
||
as chat sub-threads — the margin-icon picker on a paragraph, or the
|
||
selection tooltip's `Flag` button alongside its prompt input — with a
|
||
short text input (capped on the order of 200 characters; exact value
|
||
is implementation detail) for the flag content. In the editor, flag
|
||
anchors render with a flag icon at the anchor in a color distinct
|
||
from the chat-thread margin icon. In the chat feed, flags appear
|
||
inline chronologically with a flag badge. In the top-of-chat
|
||
disclosure, flags are counted separately from chat threads.
|
||
|
||
A flag can convert to a chat by anyone replying to it; the schema
|
||
accommodates this directly — `thread_messages` rows accumulate
|
||
against the flag's thread, and `thread_kind` stays `flag` as the
|
||
lineage marker. The AI participant can be invoked on a flag via an
|
||
`Ask Claude to propose a fix` button on the flag in the right pane,
|
||
which generates a `<change>` proposal anchored to the flag's thread
|
||
and implicitly converts the flag to a chat, since the AI's response
|
||
is the first reply. Accepting a `<change>` proposal generated from a
|
||
flag does *not* auto-resolve the flag — the flag-creator's claim may
|
||
have been broader than the specific change addressed, and the
|
||
explicit resolution gesture from §8.12 stays the only way to mark
|
||
the flag closed.
|
||
|
||
Flags do not block PR merge per §10.5's principle — making them a
|
||
merge gate would re-create the "resolved to unblock" failure mode
|
||
the §10.5 rationale already refuses for review threads. But they are
|
||
prominent: the PR header from §10.3 surfaces the open-flag count
|
||
alongside the open-review-thread count, and the count is sized to
|
||
register on a reviewer's scan.
|
||
|
||
### 8.14 Discuss-mode buffered proposals
|
||
|
||
Per §8.3, discuss mode buffers AI-proposed changes rather than
|
||
applying them. The buffer is not a separate data store: every
|
||
`<change>` block parsed from an assistant message becomes a `changes`
|
||
row with `state='pending'` immediately, regardless of mode. The mode
|
||
determines only what the UI renders. In contribute mode the panel
|
||
from §8.8 shows the pending rows as full cards; in discuss mode the
|
||
panel is hidden and a single contribution-CTA card surfaces in its
|
||
place, listing the count of pending proposals and offering a `Preview
|
||
proposed changes` disclosure that reveals the cards in read-only
|
||
form. The primary action on the CTA is `Start Contributing →`.
|
||
|
||
What that action does depends on which branch the contributor is on.
|
||
On a non-main branch, `Start Contributing` is a pure mode flip — the
|
||
contributor is already on the branch, the pending rows are already
|
||
anchored to it, and surfacing the action affordances is the only
|
||
state change. On main, where contribute mode is unavailable per
|
||
§8.3, `Start Contributing` cuts a new branch from main's tip,
|
||
re-anchors the pending rows by mutating their `branch_name` from
|
||
`main` to the new branch's name — the changes haven't been acted on
|
||
yet, so there's no audit trail to corrupt — and navigates the
|
||
contributor to the new branch in contribute mode. The pending rows'
|
||
`source_message_id` continues to reference messages in main's chat;
|
||
the schema permits the cross-branch reference and the UI labels it
|
||
as `from a conversation on main`, while the `Forked from this
|
||
conversation →` link in the new branch's chat header from §8.4
|
||
closes the loop in the opposite direction.
|
||
|
||
Toggling contribute back to discuss on a non-main branch hides the
|
||
panel without touching data. Re-flipping to contribute resurfaces
|
||
the panel identically. The buffer-vs-applied distinction is purely
|
||
surface — under the data model, every AI proposal is a first-class
|
||
artifact from the moment it lands, on whatever branch the
|
||
conversation produced it on.
|
||
|
||
### 8.15 Carryovers and implementation-deferred details
|
||
|
||
These prototype affordances carry over with branch-scoped behavior:
|
||
the selection tooltip (elaborated in §8.12 as the range-thread
|
||
entry point); the review-mode toggle and `DiffView` for inspecting
|
||
accepted changes in context (§8.10); the discuss-mode banner
|
||
indicating read-only status; the `<change>` / `<original>` /
|
||
`<proposed>` / `<reason>` AI protocol (§18).
|
||
|
||
The following are implementation-level details that the build session
|
||
will decide:
|
||
|
||
- New-branch naming format on the "Start Contributing from main"
|
||
flow (§8.14).
|
||
- Exact idle-window length for the manual-edit commit flush (§8.11).
|
||
- The compressed-conversation rendering in PR review (§8.5).
|
||
- The exact overlap threshold for continue-vs-create-new on
|
||
range-anchored threads (§8.12).
|
||
- Stable paragraph-ID machinery for paragraph-anchored thread
|
||
payloads (§5's `anchor_payload`).
|
||
- The 200-character cap on flag content (§8.13).
|
||
|
||
Super-draft document-pane behavior, the propose flow, and editing a
|
||
super-draft body are covered in §9 — a sibling section that maps the
|
||
same machinery onto the super-draft surface, with meta-repo edit
|
||
branches as the unit of work in place of per-RFC branches.
|
||
|
||
---
|
||
|
||
## 9. The super-draft view and lifecycle
|
||
|
||
The catalog (§7) settles the left-pane treatment of super-drafts; the
|
||
active-RFC view (§8) settles the surface a graduated RFC is read and
|
||
edited on; the graduation flow (§13) settles the bridge between
|
||
super-draft and active. This section settles the middle of that arc:
|
||
how an idea enters the catalog at all, what a super-draft looks like
|
||
once it does, and how its body and conversation evolve up to the
|
||
graduation moment.
|
||
|
||
The framing claim from PHILOSOPHY.md does most of the structural work
|
||
here. Super-drafts are the moment a word enters the conversation;
|
||
most proposals will not survive the argument, and that is fine. The
|
||
super-draft view is where survival or non-survival gets argued out —
|
||
which means the argument tooling has to be the same tooling §8 spent
|
||
its length specifying. Stripping the revision flow on super-drafts
|
||
would strip it from the phase where definitions are most generative.
|
||
|
||
### 9.1 Proposing a new RFC
|
||
|
||
The "+ Propose new RFC" affordance in the left pane (§7.3) opens the
|
||
**propose modal**. Available to any contributor; anonymous viewers
|
||
see the affordance replaced with a sign-in CTA per §8.7's pattern.
|
||
|
||
The modal collects four fields:
|
||
|
||
- **Title** — required short text input. The word or topic this RFC
|
||
would define. Contributor-typed; title-first matches the natural
|
||
cognitive flow, since a proposer arrives with a word in mind, not
|
||
with a pitch in search of a word.
|
||
- **Slug** — deterministically kebab-cased from the title as the
|
||
contributor types, inline-editable. Validated for uniqueness
|
||
against `rfcs/` on the meta-repo main *and* against the slugs of
|
||
any open idea PRs, so concurrent proposers cannot collide. A
|
||
collision surfaces inline ("`open-human-model` is taken — try
|
||
`open-human-model-2`?"). The API re-checks atomically at submit.
|
||
- **Pitch** — required textarea. One or two paragraphs answering
|
||
"why this RFC is needed," contributor-typed. Becomes the entry
|
||
file's body per §2.1.
|
||
- **Tags** — optional chip input with AI-suggested chips populating
|
||
after a debounce on the pitch. Contributor can accept, dismiss, or
|
||
type their own.
|
||
|
||
No proposer name or email — the logged-in identity is canonical and
|
||
need not be retyped. No proposed-owner or working-group fields —
|
||
ownership flows through the post-merge claim flow (§13.1), and
|
||
arbiters are admin work, not the proposer's call. AI's drafting role
|
||
is intentionally narrow: tag suggestions only. The proposer is
|
||
making a specific claim about a specific word, and having AI propose
|
||
the claim for them would undercut the gesture.
|
||
|
||
Primary action: **"Open proposal PR"** — naming the actual Git
|
||
artifact produced, consistent with §10.1's *Open PR* and §13's
|
||
*Graduate*.
|
||
|
||
### 9.2 The proposal PR
|
||
|
||
Submitting the modal opens a PR against the meta repo per §2.2,
|
||
adding exactly one file at `rfcs/<slug>.md`. The file's frontmatter
|
||
is populated from the modal and session:
|
||
|
||
- `slug`, `title`, `tags` — from the modal.
|
||
- `state: super-draft`, `id: null`, `repo: null`,
|
||
`graduated_at: null`, `graduated_by: null` — fixed at creation.
|
||
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
|
||
- `owners: []` — empty; the claim flow (§13.1) fills this.
|
||
- `arbiters: []` — empty; arbiters are admin work, not the
|
||
proposer's call.
|
||
|
||
The file's body is the pitch as typed. The bot is the author per §1;
|
||
the standard `On-behalf-of:` trailer per §6.5 names the proposer.
|
||
|
||
The PR title and the file-add commit subject share a fixed pattern:
|
||
**`Propose: <Title>`**. Mechanical, scannable — an owner viewing the
|
||
meta-repo's PR list sees "Propose: Open Human Model," "Propose:
|
||
Trait," "Propose: Consent" and triages at a glance.
|
||
|
||
The PR description is AI-drafted from the pitch — two or three
|
||
sentences in spec voice making the *case for catalog admission*,
|
||
distinct from the pitch body itself. The admission case answers: is
|
||
this in scope, does it overlap an existing entry, is it the right
|
||
shape for a single RFC. Audience is an owner or admin, per §13.1's
|
||
framing for the merge actor. Editable inline in the modal before
|
||
submit, and editable post-open on the PR by the proposer or any
|
||
owner/admin. This is the propose-flow analogue of §10.2's
|
||
AI-drafted-from-chat description — different source material, same
|
||
role.
|
||
|
||
No reviewer picker. Meta-repo owners and admins are the implicit
|
||
reviewer set, same logic as §10.2.
|
||
|
||
### 9.3 The pending-idea view
|
||
|
||
Submitting the modal closes it and navigates the proposer to the
|
||
**pending-idea view** — the in-app read surface for any open idea
|
||
PR, available to anyone per §7.3's "Reading is open to all." The
|
||
left pane's pending-ideas disclosure expands and highlights the new
|
||
entry. There is no success interstitial; the navigation *is* the
|
||
success state, consistent with §1's claim that the app is the
|
||
contribution surface.
|
||
|
||
The pending-idea view renders the proposed entry's body and
|
||
frontmatter (read-only) using the same renderer §9.4 specifies for
|
||
super-drafts proper. A status banner reads "Pending idea — awaiting
|
||
review." The header strip carries:
|
||
|
||
- **Withdraw proposal** — proposer-only. Closes the meta-repo PR.
|
||
The view enters a "Withdrawn" terminal read-only state, mirroring
|
||
§10.8's PR treatment.
|
||
- **View on Gitea** — unobtrusive footer link. The power-user
|
||
escape hatch.
|
||
- **Merge proposal** — visible to owners and admins per §6.1.
|
||
Merging is what creates the super-draft entry on meta-repo main.
|
||
- **Decline** — visible to owners and admins. Opens a two-step
|
||
dialog: first a comment composer (required textarea), then a
|
||
preview-and-confirm step rendering exactly what the proposer will
|
||
read — decliner display name, decline date, the comment verbatim.
|
||
The admin can edit the comment from the preview or confirm to
|
||
send. Confirming closes the meta-repo PR with the comment recorded
|
||
both as a meta-repo PR comment (the durable Git artifact) and as a
|
||
system-author message in the pending-idea chat thread ("Declined
|
||
by @admin: <comment>"), so the chat record per the rules in this
|
||
section carries the act inline. The preview step is the
|
||
ceremony — decline is the sister gesture to graduation, and seeing
|
||
what the proposer will read before sending makes the cost of the
|
||
act concrete.
|
||
|
||
A chat thread accumulates on the pending-idea view pre-merge —
|
||
contributors can argue about whether the entry belongs in the
|
||
catalog before it is admitted. On merge, that chat migrates to the
|
||
super-draft's main chat per the same principle §13.4 commits for
|
||
graduation: chat follows the work. The data path is straightforward.
|
||
The threads carry `rfc_slug = <proposed slug>` pre-merge (slugs are
|
||
reserved during the idea PR per §9.1's uniqueness check), and on
|
||
merge they simply surface under the super-draft's main view with no
|
||
data movement. On decline, the threads stay attached to the closed
|
||
PR as historical record and do not surface in any default view, the
|
||
same treatment a withdrawn entry receives.
|
||
|
||
Outcome signals to the proposer are narrow. On next visit after
|
||
merge or decline, the pending-idea view is either the super-draft
|
||
view itself (merged) or a read-only "Declined" banner (declined),
|
||
and a one-time in-app toast surfaces — "Your proposal *Open Human
|
||
Model* was merged. It's now a super-draft." or "Your proposal *Open
|
||
Human Model* was declined." The "Declined" banner carries the
|
||
decliner's display name, the decline date, and the comment from the
|
||
two-step dialog verbatim, rendered above the proposal body and
|
||
frontmatter (which remain readable below for context). Beneath the
|
||
banner, a small "Propose a revised entry" affordance opens the
|
||
propose modal pre-filled from the prior proposal's title, slug, and
|
||
pitch — the philosophy frames non-survival of a proposal as
|
||
expected, and a one-step path back to the modal honors a proposer
|
||
who has absorbed the decline reason. Beyond the toast and the
|
||
banner, the proposal-merged and proposal-declined events also fire
|
||
into the proposer's inbox as personal-direct notifications per §15.4
|
||
(default-on email category), giving out-of-session reach for an
|
||
event whose timing the proposer cannot anticipate.
|
||
|
||
### 9.4 The super-draft view
|
||
|
||
Once the idea PR merges, the entry exists as a super-draft per §3,
|
||
and the catalog renders it as a `super-draft` row per §7.2.
|
||
Selecting it opens the **super-draft view** — a sibling surface to
|
||
§8's active-RFC view, sharing the three-column layout, the
|
||
breadcrumb, and all of §8's center-pane mechanics.
|
||
|
||
The differences are scoped. There is no integer ID in the breadcrumb
|
||
per §2.3. The breadcrumb dropdown lists open meta-repo edit branches
|
||
(per §9.5) in the place an active RFC lists its branches, and the
|
||
dropdown's first position is **"canonical body"** — the entry as it
|
||
appears on meta-repo main — in the place an active RFC's main sits.
|
||
There is no PR flow scoped to *this* RFC's own repo (it has none
|
||
yet); there can be open body-edit PRs against the meta repo, surfaced
|
||
inline with the edit branches in the dropdown.
|
||
|
||
The document pane uses the same Tiptap editor as active RFCs, in
|
||
read-only mode by default, identical to §8.2. The toolbar shape, the
|
||
selection-tooltip from §8.12, the §8.13 flag affordances, and the
|
||
review-mode/DiffView toggle from §8.10 all map across. The discuss
|
||
vs. contribute mode toggle from §8.3 exists; the canonical body is
|
||
in discuss mode like main on an active RFC, and the "Start
|
||
Contributing" affordance cuts an edit branch per §9.5 rather than
|
||
flipping mode inline.
|
||
|
||
§8.7's read-only fallbacks apply identically. Anonymous viewers see
|
||
the full body, the selection tooltip works but its submit is
|
||
disabled, the chat input renders disabled with a "Sign in to chat"
|
||
hint, and the "Start Contributing" affordance is replaced with
|
||
"Sign in." Muted contributors see the same surface with a muting
|
||
banner per §6.2.
|
||
|
||
### 9.5 Editing a super-draft body
|
||
|
||
Super-drafts have no per-RFC repo, so they have no branches in the
|
||
§8 sense. Edits to the body propagate through PRs against the meta
|
||
repo per §2.2's framing. To carry the §8 revision flow onto this
|
||
target without ceremony, the unit of work is a **branch on the meta
|
||
repo, scoped to the super-draft's edit**.
|
||
|
||
The super-draft view's center column behaves like an active RFC's
|
||
`main` view per §8.3: read-only by default, with a "Start
|
||
Contributing" CTA. Activating it cuts a fresh branch off the meta
|
||
repo's `main`, naming it `edit/<slug>/<auto-name>` (exact format an
|
||
implementation detail), and lands the contributor on that branch in
|
||
contribute mode. The branch's only file under edit is
|
||
`rfcs/<slug>.md`; the editor never exposes the rest of the meta
|
||
repo's contents, and attempts to navigate elsewhere on the branch
|
||
fall back to the super-draft view.
|
||
|
||
On the edit branch, everything from §8.4 through §8.14 applies
|
||
unchanged. The per-branch chat (§8.4), AI proposals materializing
|
||
as `<change>` rows, accept/decline/edit-before-accept (§8.9),
|
||
manual-edit flushes (§8.6), the change-card panel (§8.8), range and
|
||
paragraph sub-threads (§8.12), flags (§8.13), DiffView (§8.10),
|
||
stale-change handling (§8.11). The "branch" abstraction §8 was
|
||
written against is the meta-repo edit branch; the machinery does
|
||
not care whether the underlying repo is per-RFC or meta.
|
||
|
||
Opening a PR is §10.1's gesture, with the meta-repo branch as
|
||
source and the meta repo as target. The PR creation modal (§10.2),
|
||
the review page (§10.3), the review-comment surface (§10.4), and
|
||
the seen-cursor mechanism from §10.3 all apply unchanged. The merge
|
||
actor set is the **super-draft's owners and arbiters per §6.3,
|
||
plus app-wide admins/owners per §6.1**. An unclaimed super-draft has
|
||
no owners, so until the claim flow (§13.1) runs, only app-wide
|
||
admins/owners can merge body-edit PRs — sensible because an
|
||
unclaimed super-draft is by definition awaiting an owner, and
|
||
admin oversight is the only path to canonicalizing edits in the
|
||
interim.
|
||
|
||
Multiple body-edit branches can coexist on the same super-draft. The
|
||
super-draft view's breadcrumb dropdown lists them exactly the way
|
||
an active RFC's lists its branches. Merge conflicts invoke §10.9's
|
||
resolution-branch replay path against the meta repo.
|
||
|
||
Frontmatter edits — title and tags — are out of scope for the
|
||
editor's in-line edit surface in v1. A small **metadata pane** on
|
||
the super-draft view permits title and tag edits; each edit produces
|
||
a tiny meta-repo PR via the bot, distinct from a body-edit branch.
|
||
Slug renames are not supported in v1 — a slug rename is a file
|
||
rename in Git plus a cache-key rewrite, rare enough to defer to a
|
||
future topic (see §19.2). The proposer's slug choice is canonical
|
||
until graduation, at which point the integer ID becomes the stable
|
||
handle per §13.2.
|
||
|
||
### 9.6 Chat and threads on a super-draft
|
||
|
||
The chat and thread model from §8.4, §8.12, and §8.13 extends to
|
||
super-drafts in the natural way:
|
||
|
||
- The super-draft has a **main chat** — the whole-doc default
|
||
thread, stored as a `threads` row with the super-draft's slug as
|
||
`rfc_slug` and `branch_name = null`, identical in shape to an
|
||
active RFC's main thread per §8.4. This is the durable
|
||
conversation surface about the canonical body as it appears on
|
||
meta-repo main.
|
||
- **Each meta-repo edit branch has its own chat** per §8.4, with
|
||
`branch_name` set to the edit branch's name. On merge or close,
|
||
the chat persists attached to the (now-closed) branch as
|
||
historical record, exactly per §8.4's rule. After §12's 90-day
|
||
deletion timer fires, the metadata row remains in the cache for
|
||
historical reference and the chat data remains in the app
|
||
database.
|
||
- **Range and paragraph sub-threads (§8.12) and flags (§8.13)**
|
||
apply on both the super-draft's main view and on edit branches.
|
||
The AI participant is invocable on any thread. The
|
||
selection-tooltip and margin-icon affordances from §8.12 work
|
||
identically.
|
||
- **Anchor stability.** §8.12's stale mechanic is general enough to
|
||
cover both cases. On an edit branch, anchors churn
|
||
commit-to-commit, same as on an active-RFC branch. On the
|
||
super-draft's main view, anchors churn only at body-edit-PR merge
|
||
boundaries — same volatility as on an active RFC's main. Anchors
|
||
that fail to relocate get the "(anchor lost)" affordance from
|
||
§8.12.
|
||
|
||
§5's existing `threads` schema accommodates all of this without
|
||
column changes. The interpretive rule recorded in §5 is: when the
|
||
entry referenced by `rfc_slug` is in state `super-draft`,
|
||
`branch_name` names a branch on the meta repo rather than on a
|
||
per-RFC repo. The `changes` table inherits the same rule.
|
||
|
||
### 9.7 Visibility and contribute on a super-draft
|
||
|
||
§11 specifies visibility (read) and contribute (push) for branches
|
||
on active-RFC repos. Because §9.5 maps super-draft body edits onto
|
||
meta-repo edit branches, §11 transfers in place with two
|
||
super-draft-specific clarifications:
|
||
|
||
- **The super-draft's canonical body is always publicly readable.**
|
||
It lives on the meta repo's main branch, which is public by
|
||
definition (§2, §14.2). There is no `read_public` toggle at the
|
||
super-draft level — nothing to hide, nothing to flip. The
|
||
pending-idea view (§9.3) is publicly readable for the same reason:
|
||
meta-repo PRs are public.
|
||
- **Edit branches inherit §11 in full.** Default
|
||
`read_public = true`, default `contribute_mode = just-me`,
|
||
per-branch contribute grants per §6.4, §11.3's PR-becomes-public
|
||
rule, §11.4's chat-inherits-from-branch rule, §12's 30/90 hygiene
|
||
timers. The set of people who can flip an edit branch's settings
|
||
is the branch creator, the super-draft's owners/arbiters (where
|
||
any have been claimed), and app-wide admins/owners. Until the
|
||
claim flow (§13.1) runs, that set is the branch creator and
|
||
app-wide admins/owners only — falling out of §6.3's strict
|
||
super-set framing without amendment.
|
||
|
||
"Contribute" as a super-draft-scope verb resolves to the concrete
|
||
set of gestures: chatting on the super-draft's main thread, opening
|
||
an edit branch via "Start Contributing," posting in an edit branch's
|
||
chat (subject to per-branch grants), claiming ownership per §13.1,
|
||
dropping a flag per §8.13, and posting on an open idea PR's
|
||
discussion pre-merge. All require contributor role per §6.1; none
|
||
requires more than that on the super-draft itself, except per-branch
|
||
grants on a `just-me` or `specific` edit branch.
|
||
|
||
The super-draft's main chat is publicly readable; posting requires
|
||
contributor — identical to an active RFC's main chat per §11.4 plus
|
||
§6.1. Anonymous viewers see the same read-only fallback per §8.7.
|
||
|
||
### 9.8 Graduation handoff additions
|
||
|
||
§13's graduation sequence was written before this section's
|
||
machinery existed. The mechanics §13 needs to absorb fold inline
|
||
into §13.2 and §13.4 in their respective sections; the substantive
|
||
additions are captured here for cross-reference:
|
||
|
||
- **Open body-edit PRs block graduation.** §13.3's step 3 removes
|
||
the meta-repo entry's body field, and an open body-edit PR
|
||
post-graduation would attempt to re-introduce a body to a
|
||
frontmatter-only entry. The Graduate dialog disables the confirm
|
||
button if any meta-repo PR is open against `rfcs/<slug>.md`. The
|
||
precondition is enforced before the bot starts §13.3's sequence,
|
||
so §13.3's rollback complexity does not grow.
|
||
- **Bare edit branches survive graduation.** Edit branches without
|
||
an open PR are not blocked. They remain on the meta repo subject
|
||
to §12's hygiene timers. The contributor can re-cut against the
|
||
new RFC repo's main if they still want the work. The branch chat
|
||
persists per §8.4 as historical record even after auto-close, so
|
||
the argument that produced the work is preserved regardless of
|
||
whether the work itself merges.
|
||
- **Chat migration includes range and paragraph sub-threads.**
|
||
§13.4's chat-follows-the-work rule covers the whole-doc main
|
||
thread; it extends to range and paragraph sub-threads on the
|
||
super-draft's main view, which migrate as part of the same
|
||
movement. Anchors re-resolve against `RFC.md` on the new repo;
|
||
since §13.3's step 2 seeds `RFC.md` from the super-draft body
|
||
verbatim, anchors typically locate the same content. Where they
|
||
do not, §8.12's stale mechanic engages.
|
||
- **Pre-graduation history surfaces from the new RFC view.**
|
||
Meta-repo edit-branch chats, flag threads, and `changes` rows
|
||
stay attached to their original `branch_name` on the meta repo;
|
||
they do not migrate. A **"Pre-graduation history"** affordance on
|
||
the new RFC view surfaces these — the slug remains the canonical
|
||
key per §2.3, so the query is a straightforward lookup of
|
||
`threads` and `changes` rows where `rfc_slug = <slug>` and
|
||
`branch_name` begins with `edit/<slug>/`. UI affordance; no data
|
||
movement, no rollback cost.
|
||
|
||
---
|
||
|
||
## 10. The PR flow
|
||
|
||
PR opening is structurally separable from doing the work. Branches
|
||
accumulate commits independently per §8.6 — one per accepted AI change,
|
||
debounced flushes for manual edits — so opening a PR is not a side-effect
|
||
of editing. It is a deliberate "ready for review" gesture against an
|
||
existing branch, and it is the surface on which the RFC's arbiters consume
|
||
the branch's evidence and decide whether to fold it into main.
|
||
|
||
### 10.1 Opening a PR
|
||
|
||
PRs open from an explicit **Open PR** affordance on the branch view. The
|
||
only hard precondition is that the branch has at least one commit ahead
|
||
of main. Any pending manual edits buffered in the editor flush
|
||
immediately at submit time — the same flush §8.6 specifies for branch
|
||
switch and explicit save. Pending AI change-cards in the branch chat —
|
||
proposed but neither accepted nor declined — remain pending. They are
|
||
chat artifacts, not document state, and they travel into the PR view as
|
||
part of the conversation surface; making them blocking would force
|
||
contributors to perform decline-busywork on every speculative suggestion
|
||
just to file for review.
|
||
|
||
If the branch is currently private (per §11.1), the submit affordance
|
||
opens a confirmation modal first — "Opening this PR will make the branch
|
||
and its history publicly readable. Continue?" — per §11.3's universal-
|
||
public rule.
|
||
|
||
### 10.2 The PR creation modal
|
||
|
||
The modal collects two fields, both AI-drafted from the diff plus the
|
||
branch chat, both editable inline before submit:
|
||
|
||
- **Title** — a one-line structural description of the change, in spec
|
||
voice. What was edited, in what way.
|
||
- **Description** — two to four sentences pulling from the chat: what
|
||
was argued, what shifted, what the contributor is asking the arbiters
|
||
to consider.
|
||
|
||
The model is told the audience is *an arbiter*, not Ben specifically —
|
||
the framework has to scale past one person. Title and description remain
|
||
editable post-open by the contributor or any of the RFC's arbiters.
|
||
|
||
There is no reviewer picker. The RFC's arbiters (§6.3) are the implicit
|
||
reviewer set; surfacing a per-PR picker would either duplicate that or
|
||
imply a notion of non-arbiter reviewers with weight that the spec
|
||
deliberately does not introduce.
|
||
|
||
### 10.3 The PR review page
|
||
|
||
The PR page inherits the three-column shape from the RFC view (§8.1).
|
||
The catalog on the left is unchanged. The center column carries the diff
|
||
in place of the editor — toggleable between unified and split views. The
|
||
right column carries the compressed conversation per §8.5, with the
|
||
review-comment surface inline below it. A header strip above the diff
|
||
carries title, editable description, status, the merge button (§10.5)
|
||
when the viewer is an arbiter or app-wide admin/owner, and aggregate
|
||
counts surfaced for reviewer scanning: open review threads (§10.4),
|
||
open chat threads on the branch (§8.12), and open flags (§8.13). The
|
||
flag count in particular is sized to register at a glance.
|
||
|
||
The conversation is rendered per §8.5's compressed default: messages
|
||
that produced accepted changes (and immediate context) expanded, the
|
||
rest behind a "Show full conversation" toggle.
|
||
|
||
**What changed since my last visit.** Each PR records a per-user
|
||
seen-cursor — the most recent commit and the most recent thread message
|
||
the viewer has seen on this PR. New diff hunks and new conversation
|
||
messages since that cursor render with a subtle accent on the next visit.
|
||
The cursor advances on view; reviewers do not have to mark anything as
|
||
read. This is what makes incremental review tractable on a PR that is
|
||
still receiving commits (§10.6).
|
||
|
||
### 10.4 Review comments
|
||
|
||
Review comments are messages in the branch chat, not a separate surface.
|
||
A reviewer selecting a range in the diff and posting a comment creates a
|
||
thread with `anchor_kind = range` — where the range refers to the post-PR
|
||
document state — and `thread_kind = review`, a new value alongside `chat`
|
||
and `flag` in §5's threads table. Review threads surface in the PR view's
|
||
right column inline with the AI conversation, visually distinguished.
|
||
Both arbiters and the original contributor (and the AI participant, on
|
||
invocation) post into review threads on equal footing; they are
|
||
resolvable per §5's existing `state` field on threads.
|
||
|
||
This is a deliberate refusal of the partition between "conversation" and
|
||
"review" that other PR surfaces draw. The framework's claim, in
|
||
`PHILOSOPHY.md`, is that the transcript of the argument is the evidence
|
||
the definition was earned. Separating review onto its own surface would
|
||
say the opposite — that review is a different kind of thing from
|
||
conversation. It isn't. The disagreement an arbiter raises about a
|
||
proposed tightening is the same kind of disagreement that produced the
|
||
proposed tightening in the first place; the only difference is its
|
||
position in the timeline.
|
||
|
||
Because review threads live in the branch chat, §8.4's persistence rule
|
||
applies to them unchanged. On merge they remain attached to the
|
||
(now-closed) branch as part of the historical record.
|
||
|
||
### 10.5 Merge
|
||
|
||
Per §6.3, the RFC's owners and arbiters can merge; per §6.1, app-wide
|
||
admins and owners retain this capability too. The PR header surfaces a
|
||
single **Merge** button to those viewers. Merging produces a
|
||
no-fast-forward merge commit on main carrying the standard
|
||
`On-behalf-of:` trailer per §6.5 and preserving the per-acceptance
|
||
commit nodes from §8.6 as individual reachable commits in main's
|
||
history. The framework's evidence claim depends on those commits
|
||
remaining inspectable — squash-merging would collapse the argument into
|
||
a single anonymous artifact and is not supported.
|
||
|
||
Merge is hard-blocked only by Git-level conflicts with main. Open
|
||
review threads, pending AI change-cards, unresolved chat threads, and
|
||
open flags (§8.13) do not block merge. Making any of those a merge
|
||
gate would re-create the GitHub failure mode where threads get hastily
|
||
"resolved" to unblock a button — which corrupts the evidence record
|
||
the framework exists to preserve.
|
||
|
||
### 10.6 Updates after open
|
||
|
||
New commits — whether from accepted AI changes on the open PR's chat
|
||
or from manual-edit flushes per §8.6 — push to the branch and
|
||
immediately surface on the open PR. The diff re-renders; new hunks and
|
||
new conversation messages are accented via the seen-cursor mechanism
|
||
from §10.3. There is no notion of review invalidation; reviews are not
|
||
discrete approval gestures (§10.5), so there is nothing to invalidate.
|
||
|
||
Manual-edit flushes additionally drop a system-author message into the
|
||
branch chat noting the flush — e.g. "manual edit: 12 lines changed in
|
||
§3.2." The conversation surface is the framework's canonical
|
||
evidence timeline, and a silent diff shift would corrupt it.
|
||
AI-accepted-change commits need no separate marker: the assistant
|
||
message whose `<change>` block produced the commit is already in the
|
||
chat, and §5's `changes.commit_sha` binds the two.
|
||
|
||
### 10.7 Post-merge
|
||
|
||
After merge, the PR page renders identically with a "Merged" banner in
|
||
the header strip. The diff, the compressed conversation, and all review
|
||
threads become read-only. The branch enters the closed state per
|
||
§12's hygiene table (the 90-day deletion timer starts; owners and
|
||
arbiters can still pin to disable it). The branch chat persists per
|
||
§8.4 as historical record, with new posts disabled. The PR page remains
|
||
at its stable URL indefinitely — it is the canonical surface for "show
|
||
me how this definition came to be."
|
||
|
||
Post-merge questions about the merged work belong on main's chat (§8.4),
|
||
not on the merged branch's. Main is the surface for ongoing argument
|
||
about the canonical document; a new branch cut from main is the path to
|
||
propose a revision.
|
||
|
||
### 10.8 Withdrawing a PR
|
||
|
||
Either the original contributor or any of the RFC's arbiters can
|
||
withdraw an open PR. Withdrawal renders the PR page read-only with a
|
||
"Withdrawn" banner — identical archive treatment to a merged PR, with a
|
||
different label. The branch is *not* deleted; it remains in its current
|
||
state, subject to §12's hygiene timers. Withdrawing a PR is not the
|
||
same gesture as withdrawing the work — the contributor may want to
|
||
return to it, or another contributor may pick it up.
|
||
|
||
### 10.9 One PR per branch; conflict resolution by replay
|
||
|
||
A branch may have at most one open PR at any time. Attempting to open a
|
||
second surfaces "This branch already has an open PR."
|
||
|
||
Merge conflicts with main are surfaced on the PR page as a read-only
|
||
banner identifying the conflicting regions and offering a **Start
|
||
resolution branch** affordance. Activating it cuts a fresh branch off
|
||
the current tip of main, replays the source branch's diff into it —
|
||
running the AI participant against unambiguous conflicts and surfacing
|
||
the rest for the contributor to resolve manually — and opens a new PR.
|
||
The resolution branch's chat is seeded with a "Forked from this
|
||
conversation →" link to the original branch's chat per §8.4, so the
|
||
argument chain remains traceable across the hop. The original PR
|
||
auto-closes when the resolution PR merges.
|
||
|
||
Conflict resolution by fixup commit on the existing branch is not
|
||
supported. Per-accepted-change commit granularity (§8.6) is the
|
||
framework's evidence unit; admitting plumbing commits — "fix merge
|
||
conflict with main" — into that timeline would dilute the signal each
|
||
commit is meant to carry.
|
||
|
||
---
|
||
|
||
## 11. Branches and PRs: visibility, contribute, lifecycle
|
||
|
||
### 11.1 Visibility (read)
|
||
|
||
A branch's `read_public` defaults to true. Anyone, including
|
||
anonymous viewers, can see the branch exists, view its diffs, and
|
||
view its associated chat. The branch creator can flip a branch to
|
||
private, which restricts read to: the creator, any explicit
|
||
grantees, and the RFC's owners and arbiters. Owners and arbiters
|
||
can flip it back.
|
||
|
||
### 11.2 Contribute (push)
|
||
|
||
Default `contribute_mode` is `just-me` (see §6.4 for the three
|
||
modes). The creator and the RFC's owners/arbiters can change this
|
||
setting at any time.
|
||
|
||
### 11.3 PRs are always fully public
|
||
|
||
Opening a PR makes the branch and its history publicly readable
|
||
regardless of prior visibility. If the branch was private, the PR
|
||
modal warns the user: "Opening this PR will make the branch and
|
||
its history publicly readable. Continue?" There is no concept of
|
||
a private PR.
|
||
|
||
### 11.4 Chat visibility inherits from the branch
|
||
|
||
The chat thread attached to a branch is readable by exactly the
|
||
people who can read the branch. Anonymous users can read branch
|
||
chat on a public branch but cannot post. Posting requires
|
||
contributor or higher.
|
||
|
||
### 11.5 Branch lifecycle hygiene
|
||
|
||
A branch with no associated PR auto-closes at 30 days from last
|
||
commit. A closed branch is deleted at 90 days. Closed branches
|
||
remain publicly readable (if they were public) through a "show
|
||
closed" filter in the RFC's tree view — closing is a state, not
|
||
a censorship event. Deleted branches are gone from Gitea but a
|
||
metadata row remains in the cache for historical reference.
|
||
|
||
The 30/90 timers reset on any push to the branch. Owners and
|
||
arbiters can pin a branch to disable the auto-close timer if the
|
||
work is paused but legitimately ongoing.
|
||
|
||
---
|
||
|
||
## 12. Branch hygiene policy (formalized)
|
||
|
||
| State | Trigger | Effect |
|
||
| ----------------------- | ----------------------------- | -------------------------------------------- |
|
||
| Open | branch created | normal contribution |
|
||
| Open, idle | 30 days no commit, no PR | auto-close |
|
||
| Closed | auto or manual close | hidden from default tree, surfaceable |
|
||
| Deleted | 60 days after close (90 from last activity) | branch removed from Gitea, row remains |
|
||
| Pinned | owner/arbiter pins | auto-close disabled |
|
||
|
||
Future story (not v1): out-of-band reopening of a deleted branch by
|
||
email request to an owner.
|
||
|
||
---
|
||
|
||
## 13. The graduation flow (super-draft → active RFC repo)
|
||
|
||
Graduation is initiated by an owner or admin clicking "Graduate to RFC
|
||
repo" on a super-draft's page. The button is disabled with a tooltip
|
||
when the super-draft has no owners (see §13.1) or when any meta-repo
|
||
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open
|
||
body-edit PRs would attempt to re-introduce a body to a frontmatter-
|
||
only entry after step 3 of §13.3). Bare edit branches without an open
|
||
PR do not block graduation; they remain on the meta repo subject to
|
||
§12's hygiene timers.
|
||
|
||
### 13.1 Claim ownership (prerequisite)
|
||
|
||
If a super-draft has no owner, any signed-in contributor can click
|
||
"Claim ownership," which opens a PR against the meta repo adding their
|
||
username to the `owners:` field of the entry. Owners and admins can
|
||
merge. (A self-merge window for un-acted claims is not enabled in v1;
|
||
configurable later if needed.) Multiple claims simply append.
|
||
|
||
### 13.2 The Graduate dialog
|
||
|
||
Clicking "Graduate to RFC repo" opens a small dialog with three
|
||
editable fields:
|
||
|
||
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
|
||
formatted as `RFC-NNNN`. Editable to allow gap reservations but the
|
||
default is just the next number.
|
||
- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but
|
||
constrained to valid Gitea repo names.
|
||
- **Initial owners** — pre-filled from the entry's `owners:`, with an
|
||
"add owner" picker. Must have at least one.
|
||
|
||
Each field validates inline as the admin types, with a short
|
||
debounce, against the catalog cache and a regex — integer-ID
|
||
collision against existing IDs, repo-name pattern against valid
|
||
Gitea name rules, the at-least-one-owner constraint on the picker.
|
||
Errors render as a short line of text beneath the offending field.
|
||
The repo-name collision check is re-issued atomically server-side
|
||
on confirm, since a concurrent graduation could land between
|
||
dialog-open and submit. While any field is invalid, the confirm
|
||
button is disabled and its tooltip names the first blocker
|
||
specifically — "Integer ID 42 is already taken," "Repo name must be
|
||
lowercase letters, digits, and dashes," "Add at least one initial
|
||
owner" — the same grammar the precondition popover below uses, so
|
||
the dialog and the gate read as one surface rather than two
|
||
competing styles.
|
||
|
||
The dialog's confirm button is also disabled when the preconditions
|
||
from §13's opening paragraph fail — no owners on the entry, or any
|
||
open meta-repo PR against `rfcs/<slug>.md`. The disabled button
|
||
opens a small popover on hover or click that lists each failing
|
||
precondition as its own line item with an inline remediation
|
||
affordance per item. "No owners claimed yet" surfaces a "Copy share
|
||
link" affordance for surfacing the super-draft to a would-be
|
||
claimer, plus a secondary "Claim ownership yourself" — admins are
|
||
contributors per §6.1, so they can claim if they intend to graduate
|
||
solo. "N open body-edit PRs" expands inline within the popover to a
|
||
list of the offending PRs, one per row, carrying each PR's title,
|
||
author, and last-activity timestamp plus inline merge, withdraw,
|
||
and open-in-new-tab affordances; admins hold §6.3 authority on
|
||
those PRs and can resolve the precondition from the popover without
|
||
leaving the Graduate context.
|
||
|
||
The preconditions are enforced before the bot starts §13.3's
|
||
sequence, so §13.3's rollback complexity is unchanged.
|
||
|
||
### 13.3 The transactional sequence
|
||
|
||
Confirming the dialog runs this sequence as the bot:
|
||
|
||
1. Create the new Gitea repo.
|
||
2. Seed it with an initial commit on `main` containing:
|
||
- `README.md` (header pointing at the meta-repo entry, plus the
|
||
super-draft's pitch body migrated over).
|
||
- `RFC.md` (the actual document, starting from the super-draft body
|
||
or a template if the body is empty).
|
||
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for
|
||
future tooling.
|
||
3. Open a PR against the meta repo updating the entry: `state: active`,
|
||
`id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`,
|
||
`graduated_by: <admin username>`. The meta-repo entry's body field
|
||
is removed (frontmatter only, plus a generated "see the full RFC at
|
||
<repo>" link).
|
||
4. Auto-merge the PR (the same admin who clicked the button is the
|
||
merge actor).
|
||
5. Webhook flow updates the SQLite cache; left pane reflects the new
|
||
state immediately.
|
||
|
||
The dialog renders the sequence in flight as a stack of the five
|
||
named steps with per-step states — `pending`, `running`, `done`,
|
||
`failed`, `not reached` — and a one-line caption beneath the current
|
||
step naming the concrete operation ("Creating repository
|
||
wiggleverse/rfc-0042-open-human-model…"). The stack streams from
|
||
the server via the SSE surface in §17, one event per step
|
||
transition. On success, a brief "Graduation complete" frame holds
|
||
for a moment before the dialog closes and the catalog row
|
||
transitions per §7.2.
|
||
|
||
If any step fails partway, the app rolls back: deletes the
|
||
half-created repo, abandons the unmerged PR, surfaces a clear error
|
||
to the admin. The rollback is itself a visible step appended to the
|
||
stack on failure — the admin sees that cleanup ran, not just that
|
||
the act failed. The failed step turns red, later original-sequence
|
||
steps mark "not reached," and a "What happened" panel renders below
|
||
the stack explaining what was rolled back, what wasn't (if anything
|
||
is unrecoverable), and what to do next. The panel persists until the
|
||
admin dismisses it — a failure surface is not auto-dismissed.
|
||
Graduation is rare enough to afford this level of care.
|
||
|
||
### 13.4 Chat history follows the work
|
||
|
||
The chat thread attached to the super-draft moves to the new repo's
|
||
main-branch chat at graduation. This covers both the whole-doc main
|
||
thread per §8.4 and any range or paragraph sub-threads per §8.12
|
||
anchored to the super-draft's main view; anchors re-resolve against
|
||
`RFC.md` on the new repo and, where they fail, §8.12's stale
|
||
mechanic engages. The meta-repo entry retains a generated link
|
||
"Conversation continues at <repo URL>." The chat is about the RFC,
|
||
not the meta-repo entry, and it should travel with the work.
|
||
|
||
Meta-repo edit-branch chats, flag threads, and `changes` rows from
|
||
the super-draft phase **do not migrate**. They stay attached to
|
||
their original `branch_name` on the meta repo and surface from the
|
||
new RFC view via a **"Pre-graduation history"** affordance — a
|
||
straightforward lookup of `threads` and `changes` rows where
|
||
`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/`
|
||
(the slug remains the canonical key per §2.3, before and after
|
||
graduation). UI affordance; no data movement, no rollback cost.
|
||
|
||
The affordance renders as a section in the §8.1 breadcrumb dropdown
|
||
on the new RFC view, alongside `main`, open branches, and open PRs,
|
||
headed "Pre-graduation history (N)" with each pre-graduation edit
|
||
branch listed as its own row. Selecting a row swaps the center
|
||
column to a read-only render of that branch's body at its last
|
||
commit and the right column to that branch's chat, with associated
|
||
change-cards and flags inline — the same machinery a closed branch
|
||
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation
|
||
threads resolve against the pre-graduation body, not against
|
||
`RFC.md` on the new repo. The pre-graduation set is kept distinct
|
||
from the post-graduation "Show closed branches" filter in the same
|
||
dropdown — "branches that closed normally on this repo" and
|
||
"branches that lived on the meta repo before this repo existed" are
|
||
semantically different sets, and conflating them would obscure the
|
||
graduation hop.
|
||
|
||
### 13.5 Graduation is not reversible
|
||
|
||
Once an entry is graduated to `active`, the path forward is
|
||
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly
|
||
is operationally messy (existing commits in the new repo, etc.) and
|
||
the cost of not having it is low — withdraw and re-graduate as a
|
||
fresh idea if needed.
|
||
|
||
---
|
||
|
||
## 14. Outside the RFC view: landing and the philosophy surface
|
||
|
||
The bulk of this spec describes what is inside an RFC and what is in
|
||
the left pane around it. This short section captures the structural
|
||
decisions about everything *outside* the RFC view — the app's chrome
|
||
and its public face.
|
||
|
||
### 14.1 Pre-login landing
|
||
|
||
The app's root URL, accessed by an unauthenticated visitor, renders a
|
||
landing page consisting of the title, the subtitle, and the short-form
|
||
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
|
||
single primary action: "Sign in with Gitea." Beneath that, a secondary
|
||
link: "Read the full philosophy" → `/philosophy`.
|
||
|
||
This is the front door. It sets expectation before the user encounters
|
||
the mechanics, so the mechanics (super-drafts, graduation, public
|
||
arguments, AI participation in chat) read as load-bearing rather than
|
||
novel.
|
||
|
||
### 14.2 The `/philosophy` route
|
||
|
||
Authenticated and anonymous visitors alike can reach `/philosophy`,
|
||
which renders the full body of `PHILOSOPHY.md` from the meta repo. The
|
||
content is sourced from the meta repo's main branch, cached and
|
||
refreshed on the same cadence as RFC bodies (§4). The page is plain
|
||
markdown rendering with no editing affordance.
|
||
|
||
### 14.3 Persistent "About" link
|
||
|
||
The app's header carries an unobtrusive link — "About" or "Why this
|
||
exists" — to `/philosophy`, available from every authenticated screen.
|
||
The philosophy is a reference document for the framework's mechanics,
|
||
not just marketing; a contributor mid-PR who wonders why a conversation
|
||
is public should be able to find the answer in two clicks.
|
||
|
||
### 14.4 What is not done
|
||
|
||
The philosophy is not pushed at returning users via banners or modals,
|
||
is not gated in front of the working surfaces, and is not embedded
|
||
inside the document pane. It earns its place by being available, not
|
||
by being insisted on.
|
||
|
||
The visual design of the landing page and the `/philosophy` route —
|
||
typography, layout, illustrations if any — is deferred. The structural
|
||
decisions above are the binding part.
|
||
|
||
---
|
||
|
||
## 15. Notifications
|
||
|
||
The framework's public-async-work model has accumulated signals across
|
||
every flow — proposal-merged, proposal-declined, contribute-granted,
|
||
PR-opened-on-watched-RFC, commit-added, review-thread-new, change-
|
||
proposed-on-edited-passage, graduation-ready, graduation-complete,
|
||
withdrawal — without committing a surface to receive them. This
|
||
section is that surface.
|
||
|
||
The framing constraint is from `PHILOSOPHY.md`: the framework is a
|
||
tool for thought, not a feed. Notifications exist because the public-
|
||
async work model breaks without them — a contributor whose thread of
|
||
work moves on Tuesday cannot first find out the following Saturday
|
||
when they happen to log in — and they must read as honest signal
|
||
rather than engagement bait. Defaults are conservative; the
|
||
contributor raises volume deliberately rather than dialing it down
|
||
under pressure. The system never invents attribution where none
|
||
exists (§15.9), never aggregates the user's own gestures into other
|
||
people's inboxes, and never optimizes for return visits as an end
|
||
in itself.
|
||
|
||
The signals themselves are produced by gestures specified elsewhere
|
||
in the spec. This section commits the **surface** that receives them,
|
||
the **subscription model** that decides whether a given user receives
|
||
a given signal, the **storage shape** that makes triage tractable, and
|
||
the **out-of-session channels** (email, digest) that let asynchrony
|
||
actually work.
|
||
|
||
### 15.1 The signal-surface stack
|
||
|
||
Five surfaces, each with one narrow job:
|
||
|
||
- **In-app inbox** — the durable, global triage surface. One mental
|
||
space across all RFCs the contributor has any relationship to, with
|
||
per-RFC and per-category filtering. The substrate for §15.2.
|
||
- **Badges** — ambient pull-ins. A single integer beside the inbox
|
||
icon in the app header (count of unread notifications); a small
|
||
binary dot on individual catalog rows for watched RFCs with unseen
|
||
activity per §7.2. No per-row count, no per-section count.
|
||
- **Toasts** — transient, in-session signals. Used only for the
|
||
contributor's own actions completing (proposal opened, change
|
||
accepted, graduation complete) and for arrivals during the current
|
||
session of signals about work the user is *actively viewing*.
|
||
Never the channel for activity elsewhere; that is what the inbox
|
||
is for.
|
||
- **Email** — out-of-session reach. Opt-in per category per §15.4,
|
||
conservative defaults. The single channel that escapes the app.
|
||
- **Digest** — aggregation. The catch-up surface for activity on
|
||
watched RFCs the user hasn't triaged through any of the real-time
|
||
channels. Cadence and dedup contract in §15.5.
|
||
|
||
The five compose, they do not overlap by accident. Each event is
|
||
routed to exactly the surfaces appropriate to its category, the
|
||
recipient's watch state, and the recipient's email/digest
|
||
preferences; the deduplication contract in §15.5 prevents the same
|
||
event from reaching the user twice through real-time and digest
|
||
paths.
|
||
|
||
**Event kinds.** The `notifications.event_kind` enum captures the
|
||
signal taxonomy this section commits to. The starting set:
|
||
`proposal_merged`, `proposal_declined`, `proposal_opened_on_watched_topic`,
|
||
`pr_opened`, `pr_merged`, `pr_withdrawn`, `pr_commit_added`,
|
||
`pr_review_thread_new`, `pr_review_thread_reply`,
|
||
`pr_conflict_with_main`, `chat_message_in_participated_thread`,
|
||
`chat_reply_to_my_message`, `change_proposed_on_edited_passage`,
|
||
`flag_dropped_on_watched_rfc`, `flag_resolved_on_my_flag`,
|
||
`contribute_grant_added`, `contribute_grant_revoked`,
|
||
`branch_pinned`, `branch_auto_closed`, `super_draft_graduation_ready`,
|
||
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
||
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
||
`permission_change_affecting_me`, `app_wide_mute_set`,
|
||
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
|
||
build session adjusts as new gestures are wired in.
|
||
|
||
### 15.2 The inbox
|
||
|
||
The inbox is a global view, reachable from a header icon present on
|
||
every authenticated screen alongside the §14.3 About link. Selecting
|
||
it opens a center-pane overlay (or a route, `/inbox`, depending on
|
||
the build session's chrome choice — the binding part is *one*
|
||
inbox, not per-RFC inboxes).
|
||
|
||
Each row carries: the actor's display name (or "system" attribution
|
||
for null-actor rows per §15.9), a verb phrase rendered from the event
|
||
kind plus payload, a scope chip (RFC title, branch name where
|
||
applicable, PR number where applicable), and a relative timestamp.
|
||
Clicking the row navigates to the work the signal is about and
|
||
marks the row read on next inbox refresh via the visit-advances-
|
||
cursor reconciler in §15.7. A `Mark read` affordance on each row is
|
||
available for triage without navigation.
|
||
|
||
Above the list, three controls: a **filter chip strip** (multi-
|
||
select, AND-combined: `Unread only`, `RFC: …`, `Category: personal-
|
||
direct / structural / churn`, `From: <user>`), a **bundle toggle**
|
||
(switches between per-row and per-RFC + per-event-kind grouping —
|
||
"3 new commits on PR #4 / RFC-0042" as a single bundle row,
|
||
markable in one gesture), and a **Mark all read** action that
|
||
respects the current filter (so the user can mark all churn read
|
||
without touching personal-direct rows).
|
||
|
||
The inbox is *not* the place watch-state preferences, email
|
||
categories, digest cadence, quiet hours, or per-user mutes are
|
||
configured — those live on a separate notification-settings surface
|
||
whose UX is the natural next topic (§19.2). The inbox is the triage
|
||
surface; the settings panel is where the underlying preferences
|
||
live.
|
||
|
||
### 15.3 Badges, toasts, and ambient signals
|
||
|
||
The badge in the app header reads the unread notification count
|
||
directly; it caps at 99+ visually and reads "99+" rather than
|
||
escalating into a four-digit number that contributes nothing to
|
||
triage. Tapping the badge opens the inbox; the count refreshes from
|
||
the same SSE stream that backs the inbox itself per §17, so badge
|
||
and inbox never disagree.
|
||
|
||
The per-row catalog dot from §7.2 is binary, not numeric. Its rule:
|
||
visible on a row when the row's RFC has any unread notification for
|
||
the signed-in user *and* the user's watch state on that RFC is
|
||
`watching` or `following` (per §15.6). The dot clears the moment
|
||
the unread count for that RFC reaches zero, whether through inbox
|
||
mark-read or through in-context visit-advances-cursor reconciliation
|
||
per §15.7.
|
||
|
||
Toasts surface in three narrow cases. First, the user's own action
|
||
completing — proposal opened, change accepted/declined, PR opened,
|
||
graduation confirmed; these are mid-session feedback for a gesture
|
||
the user just made. Second, an event firing on the *exact view the
|
||
user is currently looking at* — a commit arriving on a PR the user
|
||
is viewing, a chat reply landing in a branch chat the user has open;
|
||
these are scoped to the live view and never persist (the inbox row
|
||
still appears for the same event, but the toast carries the
|
||
"something just happened here" beat). Third, the system-author
|
||
messages per §10.6 and §8.6 are *not* toasts — they are inline chat
|
||
content; toasts are a chrome surface, not a content surface. Toasts
|
||
are dismissible by gesture and auto-dismiss after a short interval
|
||
(implementation-detail timing); the chrome never stacks more than a
|
||
small number of toasts on top of each other (a fourth arriving while
|
||
three are visible queues, not stacks).
|
||
|
||
The contribute-mode entry in §8.3, the graduation-complete frame in
|
||
§13.3, and the proposal-merged/declined banners in §9.3 already
|
||
carry their own visual beats and are not duplicated as toasts.
|
||
|
||
### 15.4 Email
|
||
|
||
Email is the single channel that escapes the app. The discipline
|
||
governing it: opt-in per category with category-specific defaults,
|
||
body mirrors the inbox row text verbatim, one-click unsubscribe per
|
||
category, single non-spoofing From identity.
|
||
|
||
Four categories, each with a distinct default and rationale:
|
||
|
||
- **Personal-direct events — default on.** Signals where the
|
||
recipient is the named subject: proposal merged or declined (§9.3,
|
||
recipient is the proposer); graduation completed on a super-draft
|
||
the recipient owns; contribute grant added or revoked (recipient
|
||
is the grantee); reply directly to a thread the recipient posted
|
||
in; AI proposal targeted at a passage the recipient previously
|
||
edited (§8.11); the recipient's PR merged or withdrawn by someone
|
||
else. The contract: when the contributor's name is on the action,
|
||
the system reaches out of band. Opt-in here would let the contract
|
||
fail silently.
|
||
- **Watched-RFC structural events — default off.** PR opened on a
|
||
watched RFC, PR merged, graduation, withdrawal, decline-of-
|
||
someone-else's-proposal — the structural beats on RFCs the
|
||
recipient watches or follows but is not personally subject to.
|
||
Inbox and badges already carry these; the email toggle is opt-in
|
||
for contributors who want out-of-session reach.
|
||
- **Watched-RFC churn — never email.** New commits on a PR the
|
||
recipient did not open, additional chat messages in a thread the
|
||
recipient did not participate in, new flags on a watched RFC. The
|
||
email toggle for this category appears in settings as permanently
|
||
disabled, with a tooltip naming the refusal explicitly: *"Per-
|
||
commit and per-message email is intentionally not offered. The
|
||
digest aggregates this activity weekly."* Naming the refusal is
|
||
more honest than silently omitting the toggle.
|
||
- **Admin-actionable events — default on for admins/owners,
|
||
unused for contributors.** Super-draft graduation-ready (an
|
||
owner has been claimed and no body-edit PRs are blocking per
|
||
§13.2); mute/restore events affecting the recipient as the actor
|
||
or subject; permission-change events affecting the recipient.
|
||
Admin actions are time-pressured against waiting contributors.
|
||
The column on `users` is unused for contributors and ignored at
|
||
generation time.
|
||
|
||
The email envelope: subject `[Wiggleverse] <Event>: <RFC title or
|
||
super-draft title>`. From: a single noreply identity (e.g.
|
||
`Wiggleverse <notifications@…>`), regardless of which user's gesture
|
||
produced the signal per §15.9. Body: the inbox row's rendered text
|
||
verbatim, plus a one-line context line ("by @alice on
|
||
RFC-0042 / branch <name> · 5 minutes ago"), plus one primary link to
|
||
the changed thing. No marketing footer. Two trailing links:
|
||
`Unsubscribe from <this category>` (one-click, no confirmation page;
|
||
signed URL, idempotent) and `Manage all preferences`.
|
||
|
||
Email is held during the recipient's quiet hours per §15.8 and
|
||
released at window end; held messages bundle into a single "Activity
|
||
while you were away" email when more than a small threshold (the
|
||
build session can set, in the neighborhood of five) accumulated,
|
||
otherwise sending individually at window end.
|
||
|
||
Bounces and complaints route to the global email opt-out path
|
||
automatically. The `users` table does not need a separate bounce
|
||
column — a global opt-out is the only durable response to a hard
|
||
bounce.
|
||
|
||
### 15.5 The digest
|
||
|
||
The digest is the catch-up surface for activity on watched RFCs
|
||
that the recipient hasn't triaged through any other channel. It
|
||
runs on its own cadence and carries the *churn* class that §15.4's
|
||
email policy intentionally excluded.
|
||
|
||
Default cadence is **weekly**, fired Sunday evening UTC (most
|
||
regions wake up to it Monday morning). Per-user configurable on the
|
||
`users.digest_cadence` column to `daily`, `weekly`, or `off`. The
|
||
digest is not sent if there is nothing to report.
|
||
|
||
Coverage is **event-window**, not time-window: each digest covers
|
||
"everything since the recipient's last digest," tracked via the
|
||
`notification_digests` row's `period_start` / `period_end`. A
|
||
recipient switching cadence — weekly to daily, or off back to
|
||
weekly — does not see overlapping coverage.
|
||
|
||
Three exclusion rules, applied in order at digest assembly time:
|
||
|
||
1. Exclude any notification with `email_sent_at` set — those were
|
||
already triaged out-of-session via the personal-direct or
|
||
structural email categories. The digest does not re-report things
|
||
the recipient already saw in email.
|
||
2. Exclude any notification with `read_at` set — those were
|
||
triaged in the inbox or in-context via the visit-advances-
|
||
cursor reconciler in §15.7.
|
||
3. Include all remaining notifications, but annotate any still-
|
||
unread row as "still unread in your inbox." The digest is the
|
||
catch-up surface; suppressing unread items would defeat its
|
||
purpose, but the recipient deserves the disclosure that the
|
||
item is double-tracked.
|
||
|
||
Each included notification's `digest_included_at` is set when the
|
||
digest is emitted, making the three rules idempotent and queryable
|
||
at audit time.
|
||
|
||
Format: subject `[Wiggleverse] Weekly digest — N events across M
|
||
RFCs` (or `Daily digest`). Body in spec voice: a single summary
|
||
sentence, then per-RFC sections ordered by activity volume, each
|
||
section grouping events by kind — "3 PRs opened on RFC-0042 (Open
|
||
Human Model): #12, #13, #14"; "5 commits on PR #4 (still open)";
|
||
"12 new chat messages across 3 threads"; "2 flags dropped." Each
|
||
event line links to the work; the per-RFC section header links to
|
||
the RFC view. Trailing links match §15.4's email envelope:
|
||
`Manage digest preferences`, `Manage all preferences`.
|
||
|
||
The digest does not aggregate personal-direct events (those have
|
||
their own email channel), does not include events on RFCs the user
|
||
has muted via the `watches.muted` state, and does not send to
|
||
admins for admin-actionable events (those are personal-direct).
|
||
|
||
### 15.6 The watch / subscription model
|
||
|
||
Three watch levels per RFC, all stored on the `watches` table per
|
||
§5: `watching`, `following`, `muted`. Each row is per (user, RFC);
|
||
absence of a row means no relationship and no signal generation.
|
||
|
||
Watches are **implicit, earned by gestures**, with manual override
|
||
always available. The auto-rules:
|
||
|
||
- A **substantive gesture** sets `watching` if no row exists, or
|
||
upgrades `following` → `watching` (never downgrades). The
|
||
substantive gestures: post in a chat thread; create a branch;
|
||
open a PR; accept or decline a change; claim ownership; receive
|
||
a contribute grant; drop a flag; merge a PR; resolve a thread.
|
||
- **Starring** sets `following` if no row exists. Star never
|
||
downgrades a `watching` row to `following` (the substantive
|
||
gesture wins); star never overrides an explicit `muted` (the
|
||
explicit setting wins). The star itself is independent of the
|
||
watch row — unstarring does not change the watch state.
|
||
- **Role assignment** as arbiter or owner per §6.3 implicitly
|
||
treats the RFC as `watching` for the purpose of signal
|
||
generation, regardless of `watches` row state. The role *is*
|
||
the subscription, and it never auto-decays. An owner or
|
||
arbiter can still explicitly set `muted` on a row; the explicit
|
||
mute wins over the role-implicit watch, which is intentional
|
||
(an admin should be able to step back from a specific RFC's
|
||
notifications even while keeping the authority).
|
||
|
||
Each level's signal scope:
|
||
|
||
- **`watching`** — the full stream for the RFC: PR open, PR merged,
|
||
PR commits, PR review-thread activity, chat messages in
|
||
participated-in threads, change-proposed-on-edited-passage, flag
|
||
drops, flag resolutions on the recipient's own flags, contribute
|
||
grants on branches the recipient has touched, structural events
|
||
(graduation, withdrawal, reopen). Plus all personal-direct
|
||
events that happen to land on this RFC.
|
||
- **`following`** — structural beats only: PR open, PR merged,
|
||
graduation, withdrawal, decline-of-others'-proposals. No commit-
|
||
level churn, no chat noise, no flag drops on threads the
|
||
recipient didn't participate in. Plus all personal-direct
|
||
events that happen to land on this RFC.
|
||
- **`muted`** — no signals from this RFC, including no personal-
|
||
direct events on this RFC. The muted state is explicit-only and
|
||
is the strongest "leave me alone about this" gesture available.
|
||
|
||
Per-PR and per-thread signaling is **derived from existing
|
||
participation tables**, not stored on a separate `pr_watches` or
|
||
`thread_watches` table:
|
||
|
||
- A user receives signals about a PR if they have ever advanced
|
||
`pr_seen` on it, have a row in `changes.acted_by` on its branch,
|
||
or are the PR's opener. These are the "you touched this PR"
|
||
signals — the muted state on the RFC suppresses them.
|
||
- A user receives signals about a thread if `thread_messages`
|
||
carries a row authored by them in that thread. The muted state
|
||
on the RFC suppresses them.
|
||
|
||
This keeps the watch surface narrow (one row per user × RFC) while
|
||
preserving the precision the inbox needs.
|
||
|
||
**90-day decay.** A `watching` row that has not accumulated a
|
||
substantive gesture from the user in 90 days auto-decays to
|
||
`following`. The `watches.last_participation_at` column tracks the
|
||
last-substantive-gesture timestamp; a nightly job downgrades rows
|
||
whose timestamp is older than the threshold. Star never decays;
|
||
explicit settings never decay; role-implicit watching never decays
|
||
(it is not stored on the row, it is computed). On the user's next
|
||
visit to the decayed RFC, a small unobtrusive line in the chrome
|
||
notes the change ("Following since [date]"); a one-click affordance
|
||
restores `watching`.
|
||
|
||
A small **watch-state affordance** on the RFC view header lets the
|
||
user move between the three levels at any time. Explicit choices
|
||
set `set_by = 'explicit'` and are exempt from auto-decay. Manual
|
||
override always wins over auto-rules.
|
||
|
||
### 15.7 The unread mechanism
|
||
|
||
Two cursor families serve two different jobs, and the section is
|
||
careful not to conflate them.
|
||
|
||
**The notifications cursor — per-event read state.** Each row in
|
||
`notifications` carries its own `read_at`. The inbox renders unread
|
||
rows accented and clears the accent on read. The unread count
|
||
backing the header badge is `COUNT(*) WHERE recipient = me AND
|
||
read_at IS NULL`. Marking a row read is per-row, per-bundle, or
|
||
per-filter (the `Mark all read` action). Per-event read state is
|
||
what the inbox needs because triage is per-event: "I read the email
|
||
about the decline; the corresponding inbox row should be marked
|
||
read; but I haven't visited the proposal page yet."
|
||
|
||
**The scope cursors — within-scope freshness.** `pr_seen` (§10.3)
|
||
and `branch_chat_seen` (§5) both track per-user, per-scope cursors
|
||
for the in-context "what changed since my last visit" accent
|
||
inside a PR review surface or a branch chat. These are *within-
|
||
scope* cursors; they answer "have I seen the contents of *this
|
||
scope*?" rather than "have I triaged the signal that *this scope*
|
||
moved?"
|
||
|
||
**Reconciliation between the two.** Visiting the work advances the
|
||
scope cursor (per §10.3 for PRs, per §5 for branch chats); a
|
||
post-visit reconciler then marks related notifications read on
|
||
next inbox refresh. The reconciler's rule: for each scope cursor
|
||
that just advanced, find all `notifications` rows where
|
||
`recipient_user_id = me` AND `(rfc_slug, pr_number)` or
|
||
`(rfc_slug, branch_name)` matches the advanced scope AND `read_at
|
||
IS NULL` AND the event's logical timestamp is at or before the new
|
||
cursor position. Set `read_at` for those rows. This closes the
|
||
loop in the visit-advances-triage direction; the inverse (marking
|
||
the inbox row read does not advance the scope cursor) is also
|
||
intentional, because the user may know enough from the email or
|
||
inbox text not to need to visit the work yet, and we do not want
|
||
the scope cursor falsely claiming the user has read the diff.
|
||
|
||
Per-signal vs per-bundle read advance: the inbox renders each
|
||
notification as its own row by default; the bundle toggle from §15.2
|
||
collapses rows by RFC + event kind, and marking a bundle read marks
|
||
all its constituent rows. Both modes operate on the same per-row
|
||
`read_at` column.
|
||
|
||
### 15.8 Do-not-disturb
|
||
|
||
Three mechanisms, each narrowly scoped:
|
||
|
||
- **Quiet hours.** A single per-user window in the user's local
|
||
time, stored in `users.notification_quiet_hours_start /
|
||
_end / _timezone`. Default off (all columns null). During the
|
||
window, email and digest delivery is held; inbox and badges are
|
||
unaffected (they do not make sound). At window end, held
|
||
messages bundle into a single "Activity while you were away"
|
||
email per §15.4 if more than the bundling threshold accumulated;
|
||
otherwise they fire individually. A single window is the
|
||
schema commitment — workday quiet hours separately from
|
||
nighttime quiet hours is the kind of refinement that earns its
|
||
schema later, after evidence.
|
||
- **Per-RFC mute.** The `watches.state = 'muted'` setting from
|
||
§15.6. No separate mechanism. Suppresses all signal generation
|
||
for the (user, RFC) pair, including personal-direct events
|
||
scoped to that RFC.
|
||
- **Per-user mute.** A row in `notification_user_mutes` keyed by
|
||
(muter_user_id, muted_user_id) suppresses any notification whose
|
||
`actor_user_id` matches the muted user. Notification-volume
|
||
only; does not affect content visibility in any view — the muted
|
||
user's posts, commits, flags, and review threads remain
|
||
readable. The framework's evidence claim depends on the muted
|
||
user's words remaining visible; muting their *name* in the
|
||
recipient's inbox is the narrow tool the recipient needs.
|
||
|
||
Per-user mute is **not available** to admins or owners exercising
|
||
app-wide authority, and not available to arbiters exercising
|
||
§6.3 authority on RFCs where they hold it. The role
|
||
contractually requires receiving signals from everyone; muting
|
||
one's way out of an arbitration responsibility undercuts the
|
||
role. The UI surfaces the constraint at the moment of attempted
|
||
mute, with the rationale inline rather than as a silent disable.
|
||
|
||
These three are strictly orthogonal to §6.2's app-wide write-mute,
|
||
per the clarification folded into §6.2 in this section's pass.
|
||
|
||
### 15.9 Notification authorship
|
||
|
||
Notifications surface the **underlying user** as actor, not the
|
||
bot. The `notifications.actor_user_id` column holds the underlying
|
||
user's id for any event produced by a human gesture; the inbox row
|
||
reads "@alice opened PR #4 on RFC-0042 (Open Human Model)," and
|
||
the email subject reads "[Wiggleverse] @alice opened PR #4:
|
||
<title>." Triage requires the human's name in the noun slot — the
|
||
reader's first question is "whose work is this?" and surfacing the
|
||
bot puts the wrong noun in the way.
|
||
|
||
**System-generated events have `actor_user_id = null`.** The auto-
|
||
close at 30 days (§11.5, §12), the digest emission, the anchor-
|
||
lost transition on threads (§8.12), the graduation-readiness
|
||
detection (§13.1), the post-graduation catalog transition (§7.2)
|
||
— none have a human actor. The inbox row names no one: "PR #4
|
||
auto-closed (30 days idle)." Absence of an actor is the honest
|
||
signal that no human acted; the system does not invent attribution.
|
||
|
||
**The bot account does not appear in notifications.** It remains
|
||
the §1 Git-layer persona that opens commits and PRs on behalf of
|
||
users, and §6.5's `On-behalf-of:` trailer remains the Git-side
|
||
accountability mechanism the audit log relies on. Surfacing the
|
||
bot on the notification side would conflate two distinct
|
||
accountability boundaries — the Git-layer "who opened this PR"
|
||
and the social "whose argument is this" — and the conflation
|
||
would cost the framework the precision it depends on. The same
|
||
underlying-actor-not-bot rule applies to **every other user-visible
|
||
attribution surface** — the §7.3 pending-ideas disclosure's
|
||
"by @alice," the §10.3 PR header's opener line, any in-app
|
||
rendering of "who did this." The §4 cache resolves the actor for
|
||
these surfaces by joining against the §6.5 `actions` log keyed on
|
||
`(rfc_slug, pr_number, action_kind)`, falling back to parsing the
|
||
`On-behalf-of:` trailer when the audit log row is absent (e.g.
|
||
when the cache was rebuilt from a meta repo populated outside the
|
||
app's history). The bot login surfaces only in the Git log and in
|
||
the `On-behalf-of:` trailer itself, never as a noun in the app's
|
||
prose.
|
||
|
||
**Email envelope identity is a single non-spoofing address** —
|
||
`Wiggleverse <notifications@…>` — regardless of which user's
|
||
gesture produced the signal. Spoofing the actor's email address
|
||
creates SPF/DKIM trouble for legitimate domains and gets the
|
||
framework's mail marked spam; surfacing two competing identities
|
||
(bot in From, user in subject) breaks email-client filtering rules
|
||
that key on From. One stable envelope identity, the actor named in
|
||
content.
|
||
|
||
---
|
||
|
||
## 16. What is deliberately deferred
|
||
|
||
Calling these out so the follow-up session knows what is *not yet
|
||
specified* and what is intentionally out of scope for v1.
|
||
|
||
- **The main document pane.** How `RFC.md` (or the super-draft body)
|
||
renders, the Tiptap editor configuration, how the editor handles
|
||
rich markdown, headings, links, code blocks.
|
||
- **The per-RFC and per-branch chat UX.** Threading model, AI
|
||
participation, the discuss-vs-contribute mode distinction from the
|
||
prototype, the selection tooltip, the prompt bar, model picker.
|
||
- **The revision flow.** How proposed changes from AI or contributors
|
||
appear, the change-card panel, accept/decline/edit, tracked
|
||
insertions/deletions in the editor.
|
||
- **Body full-text search.** Title/slug/ID/tag search ships in v1;
|
||
body content search is a later layer.
|
||
- **Featured super-drafts.** A `featured: true` frontmatter flag for
|
||
owner/admin curation. One-line addition we can ship when the need
|
||
is real.
|
||
- **Accepted / deprecated states.** Reintroducible if an OHM moment
|
||
("this is now official") becomes desirable.
|
||
- **Raw `git clone` + push as a contribution path.** All contribution
|
||
flows through the app.
|
||
- **Self-merge window for un-acted ownership claims.** Configurable;
|
||
off in v1.
|
||
|
||
---
|
||
|
||
## 17. Backend surfaces (API shape, illustrative)
|
||
|
||
The follow-up session will refine this. A minimal starting set:
|
||
|
||
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||
search, sort, filter chips, and the `unclaimed` predicate.
|
||
- `GET /api/rfcs/<slug>` — one entry's full metadata plus tree
|
||
view (open PRs, open branches, closed branches if requested).
|
||
- `GET /api/rfcs/<slug>/main` — main-branch body.
|
||
- `GET /api/rfcs/<slug>/branches/<branch>` — branch body and metadata.
|
||
- `POST /api/rfcs/propose` — open the idea-submission PR per §9.1.
|
||
Body carries `title`, `slug`, `pitch`, `tags`. Server-validates
|
||
slug uniqueness against `rfcs/` on the meta-repo main and against
|
||
the slugs of any open idea PRs. Returns the new PR number plus a
|
||
redirect handle for the pending-idea view (§9.3).
|
||
- `GET /api/proposals` — list open idea PRs for the pending-ideas
|
||
disclosure in §7.3.
|
||
- `GET /api/proposals/<pr_number>` — pending-idea view data per
|
||
§9.3: the proposed file's body and frontmatter, the PR title and
|
||
description, the chat thread id, the viewer's available
|
||
affordances.
|
||
- `POST /api/proposals/<pr_number>/withdraw` — proposer-only;
|
||
closes the meta-repo PR per §9.3.
|
||
- `POST /api/proposals/<pr_number>/decline` — owner/admin only;
|
||
closes the meta-repo PR with a required comment per §9.3.
|
||
- `POST /api/proposals/<pr_number>/merge` — owner/admin only;
|
||
merges the meta-repo PR, creating the super-draft entry on main.
|
||
Threads on the pending idea become the super-draft's main-chat
|
||
threads per §9.3 with no data movement.
|
||
- `POST /api/rfcs/<slug>/claim` — open the ownership-claim PR per
|
||
§13.1.
|
||
- `POST /api/rfcs/<slug>/metadata` — title or tag edits on a
|
||
super-draft per §9.5's metadata pane; opens a small meta-repo PR
|
||
via the bot. Slug renames not supported in v1.
|
||
- `POST /api/rfcs/<slug>/start-edit-branch` — the "Start
|
||
Contributing" gesture on a super-draft per §9.5; cuts a fresh
|
||
meta-repo branch `edit/<slug>/<auto-name>`, returns the branch's
|
||
metadata, lands the contributor in contribute mode. The body
|
||
optionally carries a desired branch name; otherwise auto-generated.
|
||
- `POST /api/rfcs/<slug>/graduate` — run the graduation sequence
|
||
(admin/owner only). Body carries the dialog's three fields —
|
||
candidate integer ID, candidate repo name, initial owners. Returns
|
||
a stream handle for the progress SSE below; the body fields are
|
||
re-validated atomically server-side at the top of the sequence
|
||
(the repo-name collision check in particular) since a concurrent
|
||
graduation could land between dialog-open and confirm.
|
||
- `GET /api/rfcs/<slug>/graduate/check` — inline validation for the
|
||
Graduate dialog per §13.2. Query params: `id` (the candidate
|
||
integer ID), `repo` (the candidate repo name). Returns per-field
|
||
collision/validity status from the catalog cache plus a
|
||
server-authoritative repo-name collision check. Debounced from
|
||
the client; the dialog calls this as the admin types.
|
||
- `GET /api/rfcs/<slug>/graduate/progress` — SSE stream of the
|
||
five-step transactional sequence per §13.3, one event per step
|
||
transition (`pending` → `running` → `done` / `failed`), plus a
|
||
trailing `rollback` step's events if any earlier step fails. The
|
||
Graduate dialog opens this stream on confirm and renders the step
|
||
stack from the events. The stream closes on success or on
|
||
rollback completion.
|
||
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
||
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
||
PR number, title, author, last-activity timestamp, and the
|
||
viewer's available actions (merge, withdraw, open-in-new-tab) per
|
||
§6.1 / §6.3.
|
||
- `POST /api/rfcs/<slug>/withdraw` — withdraw an entry.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/visibility` — set
|
||
read_public and contribute_mode.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/grants` — add/remove
|
||
per-branch contribute grants.
|
||
- `POST /api/rfcs/<slug>/branches/main/promote-to-branch` — the
|
||
`Start Contributing` flow on main per §8.14: cuts a new branch
|
||
from main's tip, re-anchors pending `changes` rows from `main` to
|
||
the new branch's name, returns the new branch's metadata. Body
|
||
optionally carries a desired branch name; otherwise an
|
||
auto-generated value is used.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/accept`
|
||
— accept a pending change per §8.9; runs the immediate commit per
|
||
§8.6. Body carries the possibly-edited `proposed` text, the
|
||
`was_edited_before_accept` flag, and an optional
|
||
`force_apply_stale` flag for the stale-change confirmation path
|
||
per §8.11.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/decline`
|
||
— decline per §8.9. No commit; row state moves to `declined` and
|
||
persists as evidence.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/reask`
|
||
— regenerate a stale AI proposal against current text per §8.11.
|
||
Triggers a fresh streaming assistant message in the originating
|
||
thread; the old row stays for audit.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/manual-flush` — explicit
|
||
save gesture for buffered manual edits per §8.11; the API
|
||
equivalent of the in-card `Save now` button. Idempotent for an
|
||
empty buffer.
|
||
- `GET /api/rfcs/<slug>/branches/<branch>/threads` — list threads on
|
||
the branch with filtering by `state` and `thread_kind`. Includes
|
||
the whole-doc default thread.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/threads` — create a thread
|
||
per §8.12 or §8.13. Body: `thread_kind`, `anchor_kind`,
|
||
`anchor_payload`, `label` (required for `flag`, optional for
|
||
`chat`), and an optional first `message` for chat-kind.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/messages`
|
||
— post a message into a thread per §8.12; the same endpoint is the
|
||
reply path. Body: `text`, optional `quote`.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
|
||
— resolve a thread per §8.12; permission per the rules in that
|
||
section.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
|
||
§10.1; body carries the AI-drafted (and possibly edited) title and
|
||
description.
|
||
- `GET /api/rfcs/<slug>/prs/<pr_number>` — PR metadata, diff handle,
|
||
thread ids for the conversation and review surfaces.
|
||
- `POST /api/rfcs/<slug>/prs/<pr_number>/seen` — advance the per-user
|
||
seen-cursor (§10.3).
|
||
- `POST /api/rfcs/<slug>/prs/<pr_number>/review` — post a review-kind
|
||
thread anchored to a diff range per §10.4.
|
||
- `POST /api/rfcs/<slug>/prs/<pr_number>/merge` — merge per §10.5
|
||
(arbiter/admin/owner only).
|
||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||
fresh resolution branch and replay per §10.9.
|
||
- `POST /api/admin/users/<id>/role` — set role (owner/admin only).
|
||
- `POST /api/admin/users/<id>/mute` — mute/unmute (the §6.2 app-wide
|
||
write-mute, not the §15.8 notification mutes).
|
||
- `POST /api/stars/<slug>` — star/unstar.
|
||
- `POST /api/webhooks/gitea` — webhook receiver.
|
||
- `GET /api/notifications` — list inbox rows for the signed-in user,
|
||
per §15.2. Query params: `unread` (bool), `rfc_slug`, `category`
|
||
(`personal-direct` | `structural` | `churn`), `actor_user_id`,
|
||
`bundled` (bool — server-side grouping by RFC + event_kind per
|
||
§15.2). Returns row payload plus the unread count for the badge.
|
||
- `POST /api/notifications/<id>/read` — mark a single row read.
|
||
- `POST /api/notifications/read` — mark read by filter; body carries
|
||
the same filter params as the list endpoint. Used by `Mark all
|
||
read` and by bundle-collapse `mark read` actions per §15.2.
|
||
- `GET /api/notifications/stream` — SSE stream of inbox additions
|
||
and read-state changes for the signed-in user. Backs the live
|
||
inbox refresh and the header badge counter per §15.3.
|
||
- `GET /api/watches` — list the signed-in user's watch rows per
|
||
§15.6, with per-row state and `set_by`.
|
||
- `POST /api/rfcs/<slug>/watch` — set watch state explicitly per
|
||
§15.6; body carries `state` (`watching` | `following` | `muted`).
|
||
Sets `set_by = 'explicit'` and exempts the row from the 90-day
|
||
auto-decay.
|
||
- `POST /api/rfcs/<slug>/branches/<branch>/chat-seen` — advance the
|
||
per-user `branch_chat_seen` cursor per §5 / §15.7; body carries
|
||
`last_seen_message_id`. Triggers the inbox reconciler for
|
||
chat-kind notifications scoped to this branch.
|
||
- `GET /api/users/me/notification-preferences` — read the per-user
|
||
email category toggles and digest cadence per §15.4 / §15.5.
|
||
- `POST /api/users/me/notification-preferences` — set them.
|
||
- `GET /api/users/me/quiet-hours` — read the per-user quiet-hours
|
||
window per §15.8.
|
||
- `POST /api/users/me/quiet-hours` — set or clear it; body carries
|
||
start, end, timezone (all required to set; all null to clear).
|
||
- `POST /api/users/<id>/notification-mute` — add a per-user
|
||
notification mute per §15.8. Idempotent. Refused if the signed-in
|
||
user is acting in an admin/owner authority capacity or as an
|
||
arbiter on an RFC where the muted user is also active.
|
||
- `DELETE /api/users/<id>/notification-mute` — remove it.
|
||
- `GET /api/email/unsubscribe` — one-click per-category
|
||
unsubscribe per §15.4. Signed URL; idempotent; redirects to a
|
||
short confirmation page.
|
||
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
|
||
per §15.4; sets the recipient's global email opt-out.
|
||
|
||
Plus all the chat / streaming / model-picker endpoints inherited from
|
||
the prototype, scoped to per-RFC and per-branch threads.
|
||
|
||
The `branches/<branch>/...` endpoint family covers both per-RFC-repo
|
||
branches and meta-repo edit branches; per §9.5, the routing collapses
|
||
on a single rule — when `<slug>` resolves to an entry in state
|
||
`super-draft`, `<branch>` names a branch on the meta repo. The
|
||
endpoint implementations dispatch on the entry's state to pick the
|
||
right Gitea repo; the API surface is the same.
|
||
|
||
---
|
||
|
||
## 18. Carryover from the prototype
|
||
|
||
These are confirmed unchanged from the existing app and should be
|
||
preserved as-is in the rewrite unless a downstream decision changes
|
||
them:
|
||
|
||
- FastAPI + SSE for streaming chat.
|
||
- React + Vite + Tiptap (ProseMirror) for the editor.
|
||
- The structured `<change>` / `<original>` / `<proposed>` / `<reason>`
|
||
protocol for AI-proposed edits.
|
||
- Multi-provider LLM support: Anthropic Claude, Google Gemini, OpenAI /
|
||
GitHub Copilot. `ENABLED_MODELS` and per-provider API keys via env.
|
||
- The discuss-vs-contribute distinction inside an RFC view (to be
|
||
fully specified in the follow-up session).
|
||
- Gitea OAuth for user authentication. The OAuth identity is the basis
|
||
for the app's user account; authorization is layered on top per §6.
|
||
|
||
---
|
||
|
||
## 19. Topics still to settle
|
||
|
||
This spec captured the topics settled across thirteen sessions —
|
||
the structural model (repos, states, storage, permissions, list),
|
||
the RFC view (document, chat, branches, changes, threads, flags),
|
||
the super-draft view and lifecycle, the PR flow, the graduation
|
||
flow, the chrome (landing, philosophy), and now the notification
|
||
surface. With Topic 13 folded in, the structural surface is
|
||
complete. What follows is no longer "topics that block specifying
|
||
v1" but "topics to address during or shortly after the v1 build."
|
||
|
||
### 19.1 Next slice: the active-RFC view in full
|
||
|
||
Slice 1 of the build has landed. The repository scaffolding
|
||
(`backend/`, `frontend/`, `scripts/`, `docs/`) is in place; the §5
|
||
canonical app tables exist as numbered SQLite migrations with the
|
||
§4 cache mirror beside them; the §1 bot wrapper is the single
|
||
chokepoint every Git write flows through, with the §6.5
|
||
`On-behalf-of:` trailer applied uniformly and an `actions` row
|
||
recorded; Gitea OAuth provisions a `users` row on first sign-in
|
||
with role resolved from `OWNER_GITEA_LOGIN`; the §4.1 webhook
|
||
receiver and the periodic reconciler both write to the cache and
|
||
neither user actions nor the API do; the §7 left pane (catalog
|
||
with search, sort, state-filter chips, pending-ideas disclosure,
|
||
"+ Propose New RFC" button) renders against `GET /api/rfcs` and
|
||
`GET /api/proposals`; and the end-to-end propose-to-super-draft
|
||
vertical works: propose modal opens the idea PR, owner merges from
|
||
the pending-idea view, webhook (or reconciler sweep) updates the
|
||
cache, the catalog crossfades the super-draft in, and the
|
||
super-draft view renders the body. The vertical is covered by
|
||
integration tests against an in-process Gitea simulator.
|
||
|
||
Several §9 affordances that depend on infrastructure that has not
|
||
yet been built were deferred from Slice 1 to Slice 2 — they are
|
||
not new candidate topics, only delivery sequencing:
|
||
|
||
- The §9.1 propose modal's AI-suggested tags and the §9.2
|
||
AI-drafted PR description — the AI surface lands with chat.
|
||
- The §9.3 two-step composer-then-preview decline dialog —
|
||
shipped as a single-step required-comment input in Slice 1, with
|
||
the preview-and-confirm ceremony pulled into the existing §19.2
|
||
"pending-idea view's interaction design (remainder)" topic
|
||
alongside the merge-confirmation ceremony.
|
||
- The §9.3 pre-merge chat thread on a pending-idea view and its
|
||
migration to the super-draft on merge — depends on the per-RFC
|
||
/ per-branch chat infrastructure Slice 2 builds.
|
||
|
||
**Slice 2 is the active-RFC view per §8 in full.** The view
|
||
inherits the three-column shape (§8.1), opens on `main` in
|
||
discuss mode by default (§8.2), supports the §8.3
|
||
discuss-vs-contribute mode flip on non-main branches, hosts §8.4's
|
||
per-branch chat with AI participation (the §18 `<change>`
|
||
protocol parsing into `changes` rows per §8.6), the §8.8
|
||
change-card panel with §8.9's accept / decline /
|
||
edit-before-accept resolution, the §8.10 tracked-change markup
|
||
and DiffView toggle, the §8.11 manual-edit flushes, the §8.12
|
||
range and paragraph sub-threads, the §8.13 flag affordance, and
|
||
the §8.14 discuss-mode buffer. The carryover assets — the Tiptap
|
||
configuration, the SelectionTooltip, the `<change>` parser, the
|
||
prompt-bar selection-quote machinery, the multi-provider LLM
|
||
abstraction, the SSE streaming — are present in working form in
|
||
the prototype at
|
||
`/Users/benstull/projects/wiggleverse/rfc-app-prototype/` and
|
||
should be lifted directly per §18.
|
||
|
||
The next build session should read `SPEC.md`, `README.md`, and
|
||
`docs/DEV.md` and pick up Slice 2 cleanly without re-briefing.
|
||
The working agreement in §19.3 carries forward: implement the
|
||
slice, correct the spec only where running code reveals it was
|
||
wrong at a structural level, accumulate new candidate topics in
|
||
§19.2, do not extend the spec beyond what the slice requires.
|
||
|
||
### 19.2 Candidate topics for sessions after the v1 build lands
|
||
|
||
These remain unsettled and will earn their own sessions as the
|
||
build surfaces evidence for which one matters next. Topics are
|
||
listed roughly in order of expected weight; the order is not
|
||
binding.
|
||
|
||
- **Per-RFC model availability and credential delegation.** Which
|
||
AI models contributors can pick from when chatting on a given
|
||
RFC, and who supplies the API resources for those models.
|
||
Replaces §18's app-level `ENABLED_MODELS` and env-supplied keys
|
||
with per-RFC-scoped configuration. Touches every AI surface —
|
||
every chat thread (§8.12), every change-proposal turn (§8.9,
|
||
§8.11), every flag-resolution invocation (§8.13), the AI-drafted
|
||
PR title and description (§10.2), and the propose modal's
|
||
AI-suggested tags (§9.1). Touches §5 (schema for model config
|
||
and credentials), §6 (possibly a `funder` role, or a per-RFC
|
||
capability extension along the lines of §6.2's per-user
|
||
overrides), and §18 (carryover supersession). Subdividable into
|
||
"model availability" (lighter, UX-shaped) and "credential
|
||
delegation and the funder role" (heavier — security, billing,
|
||
abuse mitigation, rotation, mid-conversation key failure) if the
|
||
session driver judges the combined scope too large. Load-bearing
|
||
once the framework runs past single-operator deployment;
|
||
defer-able until then.
|
||
- **Admin surfaces.** Where role management, muting, audit-log
|
||
views, the graduation-readiness queue, and Topic 13's
|
||
notification-preferences settings (email categories per §15.4,
|
||
digest cadence per §15.5, quiet hours per §15.8, per-user mute
|
||
list per §15.8, watch-state overview per §15.6) live in the
|
||
chrome. Topics 12 and 13 both expanded the admin's repertoire
|
||
without giving it a centralized home base; consolidating it is
|
||
the natural next move once the build is on its feet.
|
||
- **The notification settings UI.** Topic 13 settled the schema
|
||
and the per-category rules; the surface where a contributor
|
||
finds the per-category email toggles, digest cadence, quiet
|
||
hours config, watch states, and per-user mute list is the
|
||
natural follow-on. Likely overlaps the admin-surfaces topic for
|
||
admins and stands alone for contributors. Small-to-medium scope.
|
||
- **The conflict-replay UX in detail.** §10.9 commits to a
|
||
resolution-branch path where the AI participant replays the diff
|
||
onto fresh main, with manual fallback for ambiguous conflicts.
|
||
The resolution chat surface, the manual-resolve gesture, and how
|
||
the AI's replay attempts surface in the conversation are
|
||
unspecified. Narrow and concrete; defer-able until conflicts
|
||
happen often enough in real use to design against.
|
||
- **The pending-idea view's interaction design (remainder).** Topic
|
||
12 settled the decline ceremony per §9.3 (two-step composer-
|
||
then-preview dialog, "Propose a revised entry" affordance for
|
||
the declined proposer). The merge-confirmation ceremony and the
|
||
inline render of the meta-repo PR's diff against the catalog
|
||
remain unspecified and could earn their own topic.
|
||
- **The metadata pane UX.** §9.5 lands the metadata pane as a
|
||
structural commitment for super-draft title and tag edits. The
|
||
interaction design — when the pane opens, how multiple edits
|
||
queue into a single meta-repo PR, how AI assists tag editing —
|
||
is its own small UI topic.
|
||
- **The public face of discuss mode.** §8.14 settles what a
|
||
contributor sees in discuss mode on a branch with buffered
|
||
proposals. An anonymous reader or muted contributor on the same
|
||
branch is not yet specified — what they see of the pending
|
||
count, the preview disclosure, and the `Start Contributing`
|
||
invitation (which they can't act on) is its own small but
|
||
distinct topic.
|
||
- **Super-draft slug renames.** §9.5 defers renames on the basis
|
||
that a slug rename is a file rename in Git plus a cache-key
|
||
rewrite, rare enough to skip in v1. If usage shows real demand
|
||
— e.g., a super-draft whose framing shifted enough that the
|
||
original slug misleads — this earns its own topic, settling the
|
||
file-rename bot sequence, the redirect handling for any links
|
||
into the old slug, and the cache and threads migration.
|
||
- **Persistent accepted-change markup for returning contributors.**
|
||
§8.10 commits the editor's tracked-change markup to session-
|
||
local scope and points returning-contributor needs at DiffView.
|
||
A future session may revisit this with a per-user, per-branch
|
||
seen-cursor for accepted changes (mirroring §10.3's PR seen-
|
||
cursor) — markup persisting across reloads, dismissible with a
|
||
"mark as seen" gesture. Triggered by evidence of contributors
|
||
asking for it, not ahead of evidence.
|
||
- **AI participation as a notification source.** Topic 13 settled
|
||
that user-driven events carry the underlying user as actor and
|
||
system-generated events carry null. AI participant completions
|
||
(long-running change generation in a thread, an `Ask Claude to
|
||
propose a fix` invocation per §8.13 that returns minutes later)
|
||
are a third case the section did not explicitly settle — the
|
||
build will reveal whether they fire notifications at all,
|
||
whether they carry a synthetic "Claude" actor or fall under
|
||
null-system, and how the bot-vs-user distinction in §15.9
|
||
extends. Will surface during build if AI turn-times grow large
|
||
enough to warrant.
|
||
- **Cache bootstrap from a pre-existing meta repo.** §4.1 covers
|
||
steady-state cache freshness — the webhook is the fast path, the
|
||
reconciler the safety net — but assumes the cache grew up
|
||
alongside the bot. If the cache is rebuilt from scratch against
|
||
a meta repo that has history the bot did not author (a
|
||
transferred meta repo, a disaster-recovery rebuild after the app
|
||
database is lost), the reconciler has no `actions` rows to join
|
||
against for the §15.9 underlying-actor-not-bot resolution. The
|
||
Slice 1 build chose a fallback chain — audit log first, then
|
||
`On-behalf-of:` trailer parsing on the commit/PR body, then the
|
||
raw Gitea login — that is good enough for v1 but earns its own
|
||
topic once the cost of "the cache thinks the bot proposed
|
||
everything pre-app" becomes concrete. Touches §4.1 (the
|
||
reconciler's job description) and §15.9 (the attribution rule).
|
||
- **Body full-text search.** When the time comes.
|
||
|
||
Topic 13 (notifications) is settled and folded into §5 (the
|
||
notifications, watches, branch_chat_seen, notification_user_mutes,
|
||
notification_digests tables and the per-user notification
|
||
preference columns on users), §6.2 (the orthogonality clarification
|
||
against §15.8's notification mutes), §7.2 (the per-row catalog dot
|
||
for watched RFCs with unseen activity), §9.3 (the personal-direct
|
||
email channel for proposal-merged and proposal-declined events),
|
||
the new §15 (Notifications, in full), and §17 (the notification
|
||
endpoints — list, mark-read, stream, watch mutation, preferences,
|
||
quiet-hours, per-user mute, unsubscribe, bounce webhook).
|
||
|
||
### 19.3 Working agreement for the queue
|
||
|
||
Pre-build sessions ran on the queue agreement from prior versions
|
||
of this section: pick the next topic from §19.1, drive it to
|
||
decision, fold it in, update §19.1 and §19.2, hand off. The build
|
||
session and any sessions after it run on a modified shape:
|
||
|
||
1. The build session implements a slice of the spec. The spec is
|
||
the source of truth; the build does not extend it.
|
||
2. Where running code reveals the spec was wrong, the build
|
||
session corrects the spec in the appropriate numbered section
|
||
with a brief note explaining what running code revealed.
|
||
3. New design questions surfaced during the build that are not
|
||
resolvable as implementation details accumulate in §19.2 as
|
||
new candidate topics, in the same shape as the existing
|
||
entries.
|
||
4. Sessions after the build pick from §19.2 by user choice, run
|
||
on the original queue agreement (drive to decision, fold,
|
||
update §19.2), and need not be sequential — multiple §19.2
|
||
topics can be settled in any order the user prefers.
|