Restores the §8.10 paragraph-margin marker layer Phase 1 dropped when
Tiptap left, this time against the CM6 raw pane on the left half of
the Contribute split. The matching inline tracked-insert/tracked-
delete overlay Phase 3 shipped lives on the preview pane to the
right; the two visual layers answer different questions on the two
halves of the split — "did anything change in this region?" (gutter,
amber, scannable) vs. "what changed here?" (inline, green/red,
precise) — and are deliberately separate signals.
New file: frontend/src/components/diffGutterExtension.js. The
extension exposes a `setBaseline` StateEffect and a gutter that marks
every line whose text differs from the same-indexed line of the
baseline (the last server-confirmed branch body). Per-line, not
paragraph-block — CM6's natural unit; collapsing to paragraph ranges
is more spec-faithful but adds code, and the per-line stance is the
pre-fancy default. A TODO is left for a future paragraph-collapse
pass if the result reads noisy.
MarkdownSourceEditor.jsx changes:
- Install the gutter extension in the editor's extension list.
- Seed the baseline to `initialDoc` at construct time.
- In the existing `initialDoc`-watching effect, dispatch the doc
replacement AND a `setBaseline` effect in the SAME transaction so
there's no one-frame window where the new doc reads as "divergent"
against the old baseline. This carries through every server-
confirmed branch refresh that RFCView already wires (accept,
decline, manual flush, branch switch); no RFCView changes needed
because all four paths already re-push `initialDoc` after pulling
fresh state.
Design calls per the Phase 4 prompt's open list:
• Per-line marks, not paragraph-block ranges. Pre-fancy stance.
• Amber (#f59e0b) thin 3px vertical bar, distinct from the
preview pane's green-on-light / red-on-light tracked-change
inline overlay. Reads as "in-flight / not yet on the server."
• Baseline reset on every branchView refresh (accept / decline /
manual flush / branch switch), matching RFCView's existing
originalSourceLinesRef discipline. Gutter then reads as "what's
in the buffer but not on the server."
• No hover / no click. The inline overlay already carries the
click-to-card binding; the gutter is scannable only.
Known caveats kept deliberately:
• Insert/delete shifts. Adding a line in the middle shifts every
subsequent line's index and the naive compare lights up
everything below — tolerated as the honest "you've touched
stuff below this point" cue. A future LCS-anchored pass could
fix it; Phase 4 doesn't need to.
SPEC §8.10:
- First paragraph rewritten to name the CM6 raw pane as the gutter's
home and replace the stale "ProseMirror plugin" wording with the
CodeMirror gutter framing. Mirrors how Phase 3 named the preview
pane as the inline overlay's home.
- DiffView retirement paragraph adjusted: gutter and inline overlay
together (across the split) cover its review affordance, not
"both layers in the same surface" — the layers are deliberately on
different surfaces.
Verification:
- Vite preview sandbox eval — standalone CM6 mount, dispatch tests
across construct (no marks on identity), per-line edit (mark on
exactly the touched line, confirmed by Y-coordinate matching the
line-number gutter element), baseline reset clears, atomic
doc+baseline dispatch leaves zero marks (the RFCView accept-flow
path), insert-in-middle exhibits the expected cascade, and
computed-style proof for the amber bar (`#f59e0b`, 3px wide,
positioned left of the line-numbers gutter at x=21 vs x=24).
- Screenshot captures the bar on the touched line only.
- 125 backend integration tests still green.
- Live RFCView accept → branch refresh → gutter clear flow against a
real branch was not driven (backend not running this session,
carrying forward Phase 2/3's verification gap). The sandbox-level
proof covers the atomic dispatch correctness; the next session
with a backend up should drive that golden path before piling
Phase 5 work on top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 KiB
RFC App — Specification
This is the agreed-upon model for the Wiggleverse RFC Contributor app —
the host for the Open Human Model (OHM), the corpus of RFCs the
framework produces. Each RFC defines one word; the first defines
human. OHM is English-first by design: the markdown bodies are
canonical, and the OpenXML APIs and UX surfaces a downstream system
needs to actually let humans and machines interact are derived from
that English source, not authored alongside it. The framework's why
lives in PHILOSOPHY.md; this document is the
binding what for the app that hosts OHM.
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 as context.
The technical stack: 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).
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/human.md. Slugs are the stable identifier from the moment an idea is proposed; integerRFC-NNNNIDs 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 ofPHILOSOPHY.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
---
slug: human
title: Human
state: super-draft # super-draft | active | withdrawn
id: null # null until graduated; then "RFC-0042"
repo: null # null until graduated; then "wiggleverse/rfc-0042-human"
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]
# models: # optional per-§6.6 — absent inherits the
# - claude # operator universe; [] opts the RFC out of AI
# - gemini # entirely; populated narrows the picker
# funder: alice # optional per-§6.7 — names whose registered
# API credentials pay for AI calls. Inert until
# the named user consents from /settings/funder.
---
## 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 where two concurrent
submissions could 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'srfcs/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:
- Metadata cache — mirrors only what the left pane and tree view need to render fast. Reconstructible from Gitea at any time.
- 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.
- 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 ofowner/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, defaultweekly),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, defaultjust-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—reviewis 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 forchat-kind threads. Columns:id,thread_id,role(user|assistant|system),author_user_id(nullable; null for assistant),model_id(nullable; set whenrole = 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 §18 protocol) 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'soriginalno longer matches the branch's current text because a subsequent manual edit overlapped it, per §8.11; orthogonal tostate, which stayspendinguntil 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 specified in §18.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 ofpr_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
- Anonymous. Can read public RFCs (the meta repo's main branch,
every RFC repo's main branch), read any branch whose
read_publicis true, read any PR. Cannot chat, propose, create branches, or open PRs. - 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.
- 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).
- 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 inbranch_contribute_grantscan 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.
6.6 Per-RFC model availability
Which AI models contributors can pick from is configurable per RFC.
The configuration lives in the meta-repo entry's frontmatter as an
optional models: list of model identifiers (see §2.1), in the same
shape as owners: and arbiters: — frontmatter-native, edited via
the meta-repo PR flow that already governs the rest of the entry's
canonical state, mirrored into the §4 cache for read-without-roundtrip.
The field is structurally
optional and the absent/empty distinction is load-bearing:
- Absent (
models:key omitted) — the RFC inherits the operator's universe. The operator's universe is the set of models the deployment is provisioned to run, configured at the process level per §18. A freshly proposed super-draft and any RFC whose contributors haven't expressed a preference fall here. - Present and non-empty — the RFC opts into a specific subset. The picker's option list, at every AI surface scoped to this RFC, is the intersection of this list with the operator's currently provisioned models. Models the RFC names but the operator no longer provisions are silently hidden from the picker; the frontmatter list is preserved so a later operator change can restore them.
- Present and empty (
models: []) — the RFC opts out of AI participation entirely. Every AI surface on the RFC honors the refusal honestly: the chat composer's AI affordances are absent, flag-resolution's "Ask Claude to propose a fix" is absent, the §10.2 PR-draft falls back to its deterministic stub, the §9.1 AI-suggested tags surface is absent. A contributor who tries to invoke the AI sees the refusal in the surface, not a mid-turn failure.
The RFC default model is the first entry in the resolved list (intersection with the operator's provisioned set; or the operator universe when the field is absent). Where a deterministic single model must be chosen and the contributor has not picked one — the §10.2 PR-draft, the §9.1 tag suggestions, the §8.13 "Ask Claude to propose a fix" invocation from an empty thread — that's the model used. Per-message picker grain inside a chat thread (per §18) is preserved: each message can name a different model from the resolved list, and the picker's currently-selected entry persists across messages within a session.
Editing the field — adding a model, removing one, switching to the
empty-list opt-out — follows the same authority rules as editing
owners: or arbiters:. Super-draft entries: the claimed owners
and app-wide admins. Active RFCs: the RFC's owners and arbiters per
§6.3, plus app-wide admins. The dedicated chrome for the edit (the
metadata pane equivalent that the §19.2 metadata-pane UX topic will
settle for super-drafts, and whatever surface admins use for active-
RFC frontmatter edits) is a downstream concern — the structural
commitment here is the field, the resolution rule, and the
configuration capability, not the click path.
This section names which models are permitted on a given RFC. It
does not name whose API resources pay for them — the credential-
delegation half settles in §6.7, where the operator-credentials
default is preserved and a per-RFC funder: field optionally points
the calls at a contributor-supplied credential set.
6.7 Per-RFC credential delegation — the funder role
§6.6 settles which AI models are permitted on a given RFC. This
section settles whose API credentials pay for them. The two halves
are parallel — both live in the meta-repo entry's frontmatter, both
follow the same edit-authority rules as owners: and arbiters:,
both mirror through the §4 cache.
A new optional funder: frontmatter field names a single
gitea_login — the user whose registered API credentials pay for AI
calls on this RFC. Absent means the operator-supplied credentials per
§18 are used; this is the v1 default and the status quo for every
RFC without an explicit funder.
The funder role is purely credential-binding. It confers none of the §6.3 owner/arbiter authority and none of the §6.1 admin/owner authority. The funder gains exactly one capability — withdraw consent — and gains no new read access beyond what their underlying role already affords.
Frontmatter + consent hybrid. The frontmatter names the funder
(RFC-side approval, via the standard meta-repo PR flow). A
funder_consents app-db record records the funder-side approval —
the user explicitly opts in per-slug from /settings/funder. The
frontmatter can name a user who has not consented; the binding stays
operationally inert until both halves match. Both sides have a veto.
The funder universe replaces the operator universe. When a funder is in effect for an RFC — frontmatter names them and they have consented — the picker for that RFC resolves to the intersection of:
- the §6.6
models:list (or the implicit "all permitted" when absent), and - the funder's registered universe — the picker keys the operator has enabled and for which the funder has supplied a provider-family API key.
If the intersection is empty the RFC falls into the §6.6 opt-out shape and the AI surfaces refuse honestly. The operator universe is not augmented; the resolution is deterministic and attribution- clean — a call on an RFC with a consenting funder is paid entirely from the funder's credentials. If the funder cannot satisfy a requested model, the model is unavailable for this RFC. The "fall back to operator credentials when a funder call fails" semantics belong to the operational-realities half (§19.2); the lighter half deliberately does not blend funders and operators per-call.
A funder cannot expand the operator universe through registration —
registering an Anthropic key on a deployment whose operator has not
enabled claude does not make claude available on RFCs the funder
funds. The operator's enabled set bounds the picker; the funder
narrows it further.
Three revocation paths, each restoring the operator-credentials status quo. First — the funder withdraws consent. Instant; the next AI call resolves through the operator universe. Second — an RFC owner, arbiter, or app admin edits the frontmatter to remove the field or name a different funder. Flows through the standard meta-repo PR. Third — the named user's account is disabled per §6.1. Their consent rows are treated as inactive; the resolution flips back without an explicit revocation gesture.
Editing the funder field. Same authority as editing models:
(or owners: / arbiters:). Super-draft entries: the claimed
owners and app-wide admins. Active RFCs: the RFC's owners and
arbiters per §6.3, plus app-wide admins. The dedicated chrome for
the edit is downstream — clustered with the §19.2 metadata-pane UX
topic for super-drafts and with whatever surface admins use for
active-RFC frontmatter edits.
Explicitly deferred to the operational-realities half (§19.2). Mid-conversation credential failure handling; retry-with-fallback to operator credentials when a funder call fails; per-RFC billing surface and cost visibility; per-funder rate-limit attribution and quota-exhaustion behavior; the key-rotation ceremony for a funder swapping in a fresh key without dropping in-flight conversations. The lighter half ships the structural shape — frontmatter, consent, resolution, revocation. The heavier half ships the runtime hardening.
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 emptyowners:),Tag: ….
7.2 The list rows
Active entries render as RFC-0042 · Human. Super-drafts
render as super-draft · Human (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 uses a three-column shape:
- 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: Human › 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 discuss-vs-contribute distinction 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
center column splits into a markdown-source pane on the left and a
rendered preview on the right; the preview reflects the source pane
live and renders fenced
mermaiddiagrams inline. 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 Contribute split.
The first is a paragraph-margin marker on the left raw-source pane —
a thin gutter accent on any line that differs from the branch's
open-session baseline, rendered by a CodeMirror gutter extension
against a baseline reset on every server-confirmed branch refresh
(accept, decline, manual flush, branch switch). The second is inline
tracked-delete / tracked-insert markup on the right preview pane
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
on the two halves of the split and both are kept.
The inline markup lives in the rendered preview surface, not on the
writable editor. With the Contribute-mode split (§8.3) the raw markdown
source is a CodeMirror buffer that can't host HTML decorations, and
even when a writable surface could host them — as Tiptap did in earlier
revisions — layering permanent diff overlay on top of writable text
degraded the writing surface. The preview pane is the natural home: on
the right side of the Contribute split it shows the editor's own work
in-context (default-on, since the pane exists for that editorial
review), and in Discuss mode the single preview pane gates the overlay
behind a toolbar toggle so the default reading experience stays clean
prose. The overlay regenerates on every render from the branch's
changes table — no per-user seen-cursor, no dismiss UI; every
accepted change on the branch is included, drift is tolerated by
skipping spans whose proposed text no longer matches verbatim, and
mermaid intersections are skipped as a known limitation. 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.
DiffView is the legacy read-only render surface invoked via the
Contribute-mode toolbar toggle (§8.15) — a full-editor swap-in that
reads the same changes table. It is retained as an interim path while
the split-pane layers mature, and is slated for retirement once
the raw-pane gutter and the preview-pane inline overlay together cover
its review affordance; at that point the toolbar toggle collapses.
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.
The chat's model picker (§18) draws its option list from the
resolved per-RFC set per §6.6 — the
intersection of the entry's optional models: frontmatter (or the
operator universe when absent) with the operator's currently
provisioned providers. An RFC whose resolved list is empty surfaces
the AI affordances as absent rather than disabled-but-present, so
the refusal reads as a property of the RFC, not as a transient
error.
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 Branch-scoped affordances and implementation-deferred details
These affordances are scoped to the current branch:
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 ("humanis taken — tryhuman-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: Human," "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: "), 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
threadsrow with the super-draft's slug asrfc_slugandbranch_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_nameset 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_publictoggle 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, defaultcontribute_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.mdon the new repo; since §13.3's step 2 seedsRFC.mdfrom 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
changesrows stay attached to their originalbranch_nameon 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 ofthreadsandchangesrows whererfc_slug = <slug>andbranch_namebegins withedit/<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. The model used for the draft is the RFC's default per §6.6 — the first entry in the resolved per-RFC list. When that list is empty (the RFC opts out of AI per §6.6), the draft falls back to a deterministic stub naming the RFC title; the contributor edits the prefilled text as usual. 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 90-day timer rides on §12's hygiene sweep rather than its own schedule — the actuator is one extra branch in the sweep that already handles meta-repo edit branches. 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 |
The bot is the actuator on the deletion: per §1 the bot is the only
Git writer, and the §12 sweep fires through it. The audit row lands
with actor_user_id = NULL and on_behalf_of = <bot login> per §15.9
— the timer is system-generated, "the app" in the noun slot. No
notification fires on a hygiene gesture; the affected population would
be churn-grade noise per §15.4.
The per-user message-cursor preservation contract: chat history
survives the branch's deletion in Gitea because the chat tables
(thread_messages, branch_chat_seen) are app-canonical, not cached.
Only the Gitea branch and the cached_branches row are touched by
the sweep; every branch_chat_seen row that points at a message on
the deleted branch stays intact, so a returning contributor sees the
same cursor when the conversation surfaces in the §9.8 pre-graduation
history affordance (or wherever else the app renders archived chat).
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 asRFC-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:
- Create the new Gitea repo.
- Seed it with an initial commit on
maincontaining: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.
- 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 " link). - Auto-merge the PR (the same admin who clicked the button is the merge actor).
- 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-human…"). 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 ." 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. The content is cached
in the app process and refreshed on demand; the source of the file is
a deployment-time decision — Slice 7's build sources it from the app
repo (the file lives alongside SPEC.md, since the philosophy is the
framework's design document rather than an RFC entry), and a
PHILOSOPHY_PATH env var can point at a meta-repo working-tree clone
or any other sync target if a deployment prefers that shape. 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
usersis 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 · 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:
- Exclude any notification with
email_sent_atset — 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. - Exclude any notification with
read_atset — those were triaged in the inbox or in-context via the visit-advances- cursor reconciler in §15.7. - 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
watchingif no row exists, or upgradesfollowing→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
followingif no row exists. Star never downgrades awatchingrow tofollowing(the substantive gesture wins); star never overrides an explicitmuted(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
watchingfor the purpose of signal generation, regardless ofwatchesrow state. The role is the subscription, and it never auto-decays. An owner or arbiter can still explicitly setmutedon 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_seenon it, have a row inchanges.acted_byon 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_messagescarries 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_muteskeyed by (muter_user_id, muted_user_id) suppresses any notification whoseactor_user_idmatches 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 (Human)," and
the email subject reads "[Wiggleverse] @alice opened PR #4: