# RFC App — Specification

This is the agreed-upon model for the Wiggleverse RFC Contributor app —
a substrate for collaborative, RFC-style standardization of whatever
subject a given deployment chooses to standardize. A *deployment*
brings a corpus, a domain framing, and a brand; the framework provides
the process, the storage, the conversation, and the lifecycle. Each
RFC defines one entry in the corpus — a word, a vaccine, a literary
work, a protocol, a concept; the framework does not constrain the
subject. The first deployment of this framework, the Open Human Model
(OHM), standardizes natural-language vocabulary and is the corpus
referenced as an example throughout these pages; the framework itself
is independent of any one corpus. Markdown bodies are the canonical
text per RFC; any downstream APIs or UX surfaces a deployment exposes
are derived from those bodies, not authored alongside them. The
framework's *why* lives in [`PHILOSOPHY.md`](./PHILOSOPHY.md); this
document is the binding *what* for the app. The relationship between
the framework and its downstream deployments is set out in §20.

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

There is exactly one **meta repository** that holds every RFC in the
system as a single markdown entry under `rfcs/` — drafts, active work,
and retired entries alike — regardless of state. An RFC is a single
canonical document, and its document *is* its meta entry: the body
lives in the entry file at every state, from idea through active. There
are **no per-RFC repositories**.

For a deployment, this single repository is its **content repository**:
the one place every RFC document lives (under `rfcs/`), alongside the
framework's `PHILOSOPHY.md`, `README.md`, and `CONTRIBUTING.md` (§2). A
deployment names it concretely — `<deployment>-content` reads cleanly
— and the `META_REPO` setting carries the name. The term *meta
repository* persists for the config surface and
historical continuity, but under the meta-only topology this repo is,
functionally, the deployment's content repo: "one repo, your RFCs are
in `rfcs/`" is the whole mental model a new deployer needs.

> **Topology change (v0.31.0, meta-only — supersedes the original
> per-RFC-repo model).** This spec originally said "each RFC is its own
> Gitea repository," and graduation created a dedicated `rfc-NNNN-<slug>`
> repo and moved the body into its `RFC.md`. That model is **retired**.
> The per-RFC-repo machinery never paid for itself under this design:
> authorization is decided in app data before a single bot acts (below),
> not by per-repo Gitea permissions; raw `git clone`+`push` was never a
> supported contribution path; and the super-draft phase already ran
> meta-only. So an RFC now lives in its meta entry for its whole life,
> and graduation is an in-place state flip rather than a repo-creation
> transaction (see §13). Where later sections still say "the RFC's repo"
> or "RFC.md on the new repo," read it as "the RFC's meta entry body" —
> the editing, branch, PR, and chat machinery is unchanged; only the
> location collapses onto the meta repo. The one RFC graduated under the
> old model, **RFC-0001 `human`**, was folded back into its meta entry
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
> decision record is OHM ROADMAP #36.

> **Three-tier change (v0.45.0 — supersedes the single-corpus topology;
> see §22).** A deployment is no longer one corpus in one repo. It has a
> **registry** (§22.2) naming N **projects**, each owning **one content
> repo** (§22.3) that holds N typed **RFC collections** as subfolders.
> "This single repository is its content repository" now reads "each
> *project* names one content repository; the deployment's registry lists
> them." The bot and app-owned-authorization paragraphs below are
> unchanged and now read **org-wide** across every content repo and the
> registry repo. The single-corpus deployment is the N=1 case and keeps
> running unchanged via a generated default project + default collection
> (§22.13). §22 is the binding model.

All Git operations on the meta repository are performed by a single **bot
service account** in Gitea. Real human users do not have meaningful Gitea
permissions on the repo itself; 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; integer
  `RFC-NNNN` IDs are only assigned at graduation (see §13).
- `PHILOSOPHY.md` — the framework's mission and rationale. Hand-authored.
  Updated via PR like any other document. The canonical statement of why
  this exists and what it is producing. Its opening section is a short-form
  distillation (the deck) suitable for use both as the meta-repo README
  header and as the app's pre-login landing copy (see §14).
- `README.md` — the meta-repo's front page. The **header is hand-authored**
  — a short-form distillation of `PHILOSOPHY.md`, suitable for a reader
  arriving at the meta-repo via Git rather than via the app. The **index
  below the header is regenerated by CI on every merge to main** and lists
  active RFCs, super-drafts, and (eventually) retired entries with links
  into the corresponding entry files. (There is no per-RFC repository to
  link to under the meta-only topology, §1.)
- `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).

> **Three-tier amendment (v0.45.0 — see §22).** Slugs are unique **per
> collection** (§22.4): `model/intro` and `specs/intro` coexist. The entry
> **metadata schema is collection-configured** (§22.4a, as amended by
> v0.46.2) — each collection declares a `fields:` schema in its
> `.collection.yaml` and stores per-entry values in a `<slug>.meta.yaml`
> sidecar; the fields below are the `document` baseline, **not**
> type-driven. The §2.3 `RFC-NNNN` `max+1` allocation is
> **removed** (the slug is the identity); pre-change `id` values survive as
> frozen legacy labels. New `active`-entry frontmatter: `unreviewed` (bool)
> and the `reviewed_at` / `reviewed_by` provenance pair (§22.4c).
> Collection configuration (`type`, `visibility`, `initial_state`) lives in
> a `.collection.yaml` manifest in the content repo, mirrored like entry
> frontmatter (§22.2).

### 2.1 Entry file format

```markdown
---
slug: human
title: Human
state: super-draft        # super-draft | active | withdrawn
id: null                  # null until graduated; then "RFC-0042"
repo: null                # always null under the meta-only topology (§1).
                          # Retained for schema stability + historical
                          # entries; never populated by graduation (§13).
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 grows into the actual draft document. The body
stays in this entry at every state — graduation is an in-place state
flip and does not move it (§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 four canonical states stored in entry frontmatter:

- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
  directory. Anyone signed in can chat on it; anyone can claim ownership;
  an owner is required before graduation.
- **`active`** — the entry has been graduated: it carries
  `graduated_at`/`graduated_by` and, **optionally**, an integer `id`
  (`RFC-NNNN`). The integer id is not required — graduation may proceed
  with no number, in which case `id` stays null and the **slug remains
  the canonical identifier** (§2.3, §13.2). It lives in the same
  `rfcs/<slug>.md` entry it always did — graduation is an in-place state
  flip (§13), not a move. Branches, PRs, and conversation happen on the
  meta repo against that entry, exactly as they did while it was a
  super-draft. `repo:` stays null (§1).
- **`withdrawn`** — pulled before becoming canonical. Stays in the
  directory as historical record, hidden from default views, filterable in.
- **`retired`** — soft-deleted. The entry stays in the `rfcs/` directory
  as historical record (git is truth — nothing is hard-deleted), but it
  is **removed from every UI surface**: it does not appear in the
  catalog, is not addressable through the ordinary RFC view, and is
  excluded from link pickers and search. Unlike `withdrawn` (which is
  merely hidden-by-default and filterable back in), `retired` is not
  surfaced anywhere a contributor browses. Retiring is for entries an
  owner wants gone from the working set; it is reversible only by a site
  owner (§13.7).

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
super-draft ──[retire, RFC owner or site owner]──▶ retired
active ──[retire, RFC owner or site owner]───▶ retired
retired ──[un-retire, site owner]────────────▶ prior state
```

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).

**Who may retire.** Retire (§13.7) is deliberately *narrower* than
withdraw: it is available to the RFC's own `owners` (frontmatter) and to
holders of the site **`owner`** role only — **not** to app admins. It is
the one destructive-feeling action in the lifecycle (the entry leaves the
working set entirely), so the authority to perform it is held closer than
withdraw's owner/admin set. Un-retire — bringing a retired entry back to
the state it held before — is held tighter still: **site owners only**.
An RFC owner can retire their own entry but cannot bring it back; that
asymmetry is intentional, so a soft-delete is always recoverable by the
site's operator. The entry file is never removed from `rfcs/`, so a
retired entry remains recoverable by editing frontmatter directly even if
the app surfaces were lost.

### 3.2 State change side-effects

Changing state in the meta repo entry is the *only* required operation
for a state transition — graduation included. Graduation additionally
assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the
same commit (§13); it has no other side effects under the meta-only
topology (no repo to create, nothing to seed). 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, branches, PRs, commits — all of which live on the single meta
repo under the meta-only topology (§1). Nothing in this system overrides
Gitea on those concerns.

The app maintains a **SQLite database**, colocated with the FastAPI
process, that serves three purposes:

1. **Metadata cache** — mirrors only what the left pane and tree view
   need to render fast. Reconstructible from Gitea at any time.
2. **App data** — things Gitea cannot express. User accounts, role
   assignments, per-branch grants, branch visibility settings, chat
   history, audit logs. This data is canonical; it is not cached, it
   is owned by the app.
3. **Cached bodies** — the main-branch body of each RFC, read from its
   `rfcs/<slug>.md` meta entry (the same source for super-drafts and
   active RFCs alike under the meta-only topology), 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 the meta 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.

> **Three-tier amendment (v0.45.0 — see §22).** Every entry-scoped table
> below carries the corpus grain, which is now the **collection**: the
> column is **`collection_id`** (not `project_id`), and `cached_rfcs` is
> keyed `(collection_id, slug)`. A denormalized `project_id` rides the
> high-churn cache/notification rows for filtering. New app-owned tables:
> **`projects`** (one content repo, project settings), **`collections`**
> (the immutable `type`, `subfolder`, `initial_state`, `visibility`,
> mirrored from `.collection.yaml`, §22.2), and **`memberships`**
> (`scope_type ∈ {global, project, collection}`, the unified
> `{owner, contributor}` roles — §22.6, replacing M2's `project_members`).
> `users.role` is the **deployment** admission tier only (§22.7).

- `users` — `id`, `email`, `display_name`, `gitea_login`, `role` (one
  of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2
  app-wide write-mute, distinct from the per-RFC and per-user
  notification mutes in §15.8), `created_at`, `last_seen_at`. Plus the
  per-user notification preferences inlined here for proximity:
  `email_personal_direct` (bool, default true), `email_watched_structural`
  (bool, default false), `email_admin_actionable` (bool, default true for
  admins/owners and unused for contributors), `digest_cadence`
  (`off` / `weekly` / `daily`, default `weekly`),
  `notification_quiet_hours_start` (nullable time),
  `notification_quiet_hours_end` (nullable time),
  `notification_quiet_hours_timezone` (nullable text, IANA tz name).
  The watched-RFC-churn category has no column — it is permanently off
  per §15.4 and surfaces in settings as a disabled toggle naming the
  refusal.
- `branch_visibility` — `id`, `rfc_slug`, `branch_name`, `read_public`
  (bool, default true), `contribute_mode` (`just-me` / `specific` /
  `any-contributor`, default `just-me`).
- `branch_contribute_grants` — `id`, `rfc_slug`, `branch_name`,
  `grantee_user_id`, `granted_by`, `granted_at`.
- `stars` — `id`, `user_id`, `rfc_slug`, `starred_at`.
- `threads` — every conversation in the system, whether scoped to an RFC's
  main view, a branch, or a span within a branch's document. Columns:
  `id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
  RFC's main view, the PR-less per-RFC discussion surface per §10's
  closing note; non-null means scoped to a branch's work, including
  PR-comment threads), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
  `anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
  `thread_kind` (`chat` | `flag` | `review` — `review` is the diff-anchored
  PR-review thread defined in §10.4), `label` (short human-authored summary;
  for flags this is the entire content), `state` (`open` | `resolved` |
  `stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
  Visibility is derived from the underlying branch (§11.1); for the
  null-branch PR-less discussion surface, visibility follows the RFC
  (anonymous read open per the §14 / v0.3.0 contract, write requires
  contributor per §6.1, tightened toward anon-write-refused in v0.6.0).
- `thread_messages` — the actual chat content for `chat`-kind threads.
  Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
  `author_user_id` (nullable; null for assistant), `model_id` (nullable;
  set when `role = assistant`), `text`, `quote` (the optional selection
  the user attached to the message), `created_at`. Flag-kind threads
  have no rows here unless they get converted to chats, at which point
  messages accumulate normally. System-author messages (`role = system`,
  `author_user_id = null`) are also used to mark manual-edit flushes on
  PRs per §10.6.
- `changes` — structured proposed edits to a branch's document. Each
  change is either AI-proposed (from a `<change>` block in an assistant
  message, per the §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's `original` no longer matches the
  branch's current text because a subsequent manual edit overlapped it,
  per §8.11; orthogonal to `state`, which stays `pending` until the
  contributor acts on the stale card), `acted_by`, `acted_at`,
  `commit_sha` (nullable; the bot commit that materialized the
  acceptance, see §8.6). The `<change>` / `<original>` / `<proposed>`
  / `<reason>` parsing protocol is 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 of `pr_seen`. Columns: `id`, `user_id`,
  `rfc_slug`, `branch_name`, `last_seen_message_id`, `seen_at`. Advanced
  on view; one row per (user, branch). Supports the in-context "new
  messages since last visit" accent on branch chats and closes the
  inbox-reconciliation loop for chat-kind notifications per §15.7.
- `watches` — per-user, per-RFC subscription state for the implicit
  watch model in §15.6. Columns: `id`, `user_id`, `rfc_slug`, `state`
  (`watching` | `following` | `muted`), `set_by` (`auto` | `explicit`),
  `set_at`, `last_participation_at` (nullable; used by the 90-day
  decay rule in §15.6). One row per (user, RFC); absent row means
  no relationship (no signals generated).
- `notifications` — per-recipient, per-event signal rows that back the
  inbox, badges, email, and digest per §15. Columns: `id`,
  `recipient_user_id`, `event_kind` (enum, see §15.1), `rfc_slug`
  (nullable), `branch_name` (nullable), `pr_number` (nullable),
  `thread_id` (nullable), `change_id` (nullable), `actor_user_id`
  (nullable — null for system-generated events per §15.9), `payload`
  (JSON: the short rendered text the inbox row and email body share,
  plus any kind-specific extras), `created_at`, `read_at` (nullable),
  `email_sent_at` (nullable), `digest_included_at` (nullable). Fan-out
  is at signal-generation time per §15.7.
- `notification_digests` — per-recipient digest emissions, used by §15.5's
  event-window dedup. Columns: `id`, `recipient_user_id`, `sent_at`,
  `period_start`, `period_end`, `signal_ids_included` (JSON array of
  notification ids). One row per emitted digest.
- `notification_user_mutes` — per-recipient mute of notifications
  produced by a specific actor, per §15.8. Columns: `id`,
  `muter_user_id`, `muted_user_id`, `muted_at`. Notification-volume
  only; does not affect content visibility anywhere in the app. Not
  available to arbiters or admins acting in their authority capacity
  on RFCs where they hold authority (§6.3) — the role contractually
  requires receiving signals from everyone.
- `permission_events` — append-only audit log for every role change and
  capability override.
- `actions` — append-only audit log for every state transition, every
  graduation, every grant change. Includes the acting user, the bot
  commit hash if any, and the on-behalf-of trailer applied.
- `cookie_consent` — per-user record of the §14.5 cookie consent
  choice. One row per user. Columns: `user_id` (PK, FK users), three
  flags (`essential`, `analytics`, `other_cookies`), and
  `recorded_at`. `essential` is permanently 1; `recorded_at` is set
  on first write and updated on every change. Absence of a row means
  "no choice yet" — the banner shows. Anonymous viewers persist their
  choice in `localStorage` only, with no corresponding row here.
- `device_trust` — per-row record of the §6.2 device-trust gesture
  (v0.11.0, roadmap item #9). One row per `(user, trusted device)`
  pair; a user with three trusted devices has three rows. Columns:
  `id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash`
  (bcrypt at rest, with a unique index documenting the no-collision
  invariant of the 256-bit CSPRNG token space), `created_at`,
  `expires_at` (`created_at + 30 days`), `user_agent` (verbatim,
  application-layer-truncated to 1024 chars), `last_seen_at`
  (refreshed on every successful lookup), `revoked_at` (NULL means
  active). The raw token never lives in this table — only the hash.

**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.

> **Three-tier amendment (v0.45.0 — see §22.6–§22.7).** The deployment
> roles described in this section are the **global** tier of a four-layer
> most-permissive union (global → project → collection → per-entry).
> Scope roles use one vocabulary — **Owner** and **RFC Contributor** —
> attached at `{global, project, collection}` via the `memberships` table
> and inheriting downward with no negative override. A plain granted
> `contributor` account is **not** an implicit global write role: it can
> sign in and read, but writing an explicitly-created collection requires
> an explicit `memberships` grant. The one carve-out is the N=1
> default-collection baseline, where the pre-multi-project implicit-public
> write capability is grandfathered (§22.6 keystone note). The §6.3
> per-RFC delegated-authority idea is now **Owner** at project/collection
> scope, sitting above per-entry authority in the union.

Authentication has three paths, in the order a visitor encounters
them:

1. **Email + one-time code (OTC).** The v0.7.0 primary path: a
   visitor enters their email address, receives a six-digit code via
   SMTP, and exchanges the code for a session. Used by every visitor
   on first sign-in, and as the fallback for the other two paths.
2. **Email + passcode (with OTC fallback).** Added in v0.10.0
   (roadmap item #8). After a successful OTC sign-in, the visitor
   may set a user-chosen passcode (4–20 characters, bcrypt-hashed at
   rest) and use email + passcode on subsequent sign-ins. Five
   consecutive failed verifies lock the passcode path for 15 minutes
   (HTTP 423); during the lockout the user falls back to OTC. The
   OTC path is unaffected by the passcode lockout, so a forgotten
   passcode is recovered by requesting a fresh OTC — there is no
   separate "forgot passcode" flow. The user can remove the passcode
   at any time from the §6.2 sign-in settings tab, returning to
   OTC-only.
3. **Device trust (cookie-only, 30 days).** Added in v0.11.0
   (roadmap item #9). After a successful OTC or passcode sign-in,
   the visitor may check "trust this device for 30 days." The
   framework then mints a server-issued opaque token, hashes it
   (bcrypt) into the `device_trust` table, and sets a long-lived
   HttpOnly + Secure + SameSite=Lax cookie carrying the raw token.
   On a subsequent visit, `POST /auth/device-trust/start` resolves
   the cookie and re-establishes the session without an OTC /
   passcode roundtrip. The user can list and revoke their trusted
   devices from the `/settings/notifications` "Trusted devices"
   section; a revoked or expired cookie is cleared on the next
   request. The cookie is "essential" per §14.5 — it is part of
   authentication, not analytics, and is set regardless of the
   user's analytics / other-cookies choice. The raw token only
   ever lives in the outbound `Set-Cookie` header and the inbound
   `Cookie` header; server-side storage is the hash, with
   constant-time comparison on lookup.
4. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
   callback remains functional during the v0.7.0 window, with a
   small "Sign in with Gitea (fallback)" link on `/login` so users
   with active OAuth sessions or older invite paths still have a
   way in. Scheduled for removal in a future release per §19.2.

`users.gitea_id` is preserved on existing rows so a grandfathered
user signing in via any of the three paths resolves to the same row.
`users.email` is the identity key for everything provisioned after
v0.7.0; `users.gitea_id` is the grandfathering linker (nullable,
partial-unique). The Gitea bot user + token are still required for
server-side git operations (repo reads, PR creation); only the
operator-facing sign-in surface moved.

Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the
v0.3.0 `allowed_emails` table forward as the admission gate at the
OTC request surface — emails not on the list got a silent drop.
v0.8.0 (roadmap item #6) reverses that: any valid email receives an
OTC, the fresh `users` row lands in `permission_state='pending'`,
and an admin grant flips the column to `'granted'` before write
endpoints accept the user. The capture-fields step (first name,
last name, free-text "why I should be included in the beta") feeds
the admin's triage queue. The `allowed_emails` table stays in the
schema as a fast-path bypass — the v0.3.0 admin UI still manages
it — but the OTC request path no longer consults it. v0.9.0
(roadmap item #7) shipped the user-management surface that
consumes the `permission_state` column: `/admin/users` carries
every user with their state, profile, and sign-up reason, plus
Grant / Revoke controls that flip the column and write a
`permission_events` audit row. The capture-form submission also
fans a `new_beta_request` notification out to every admin/owner
through the §15 substrate, so the queue surfaces in the inbox +
email channels admins already have.

The `/admin/allowlist` sub-tab stays in place alongside
`/admin/users` rather than merging: the two surfaces have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing rather
than clarifying. The allowlist's role narrowed to "fast-path
bypass for known-good emails" with the v0.8.0 admission shift;
v0.9.0 retains that role unchanged.

### 6.1 Four roles, each a strict superset of the one below

1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
   every RFC repo's main branch), read any branch whose `read_public`
   is true, read any PR. Cannot chat, propose, create branches, or
   open PRs. v0.6.0 (roadmap item #4) closed the audit: every
   write-shaped endpoint surveyed in §17 enforces an explicit
   `auth.require_contributor` (or stricter) gate before doing any
   state-changing work; anonymous writes refuse 401. The explicit
   audit covers propose, branch create, branch threads, PR-less
   discussion threads + messages, PR open / merge / withdraw,
   funder credentials + consent, admin allowlist add, graduation
   kickoff + claim. Anonymous reads on every catalog and RFC-body
   surface remain open per the v0.3.0 contract.
2. **Contributor.** Default role for any authenticated account. A
   first OTC sign-in by a previously unknown email provisions a row
   at this role; v0.8.0 replaced the v0.3.0 / v0.7.0 allowlist gate
   with an admin-grant flow (roadmap item #6, see opening of §6).
   The contributor capabilities below — propose, branch, PR, chat,
   claim — are gated by `users.permission_state='granted'` as well
   as by the role. A pending contributor (the post-OTC waiting
   state) has the same read access as anonymous and zero write
   capability until an admin grants. Everything anonymous can do,
   plus: propose new RFCs (open a PR against the meta repo), create
   branches on any RFC repo, open PRs from branches they have
   contribute access to, chat on anything they can read, claim
   ownership of unclaimed super-drafts.
3. **Admin.** Everything contributor can do, plus: act on any RFC
   (merge PRs on behalf of arbiters, graduate super-drafts, set
   branch visibility on anyone's behalf, downgrade or restore
   individual contributor capabilities, grant or revoke admin to
   others, withdraw or reopen entries).
4. **Owner.** Everything admin can do, plus: grant or revoke owner,
   disable an account entirely. Ben is owner zero.

### 6.2 Per-user capability overrides — "muted"

Owners and admins can mute a contributor. A muted user retains read
access and existing branches, but cannot create new branches, open
PRs, propose RFCs, or chat. Existing branches remain in the system
subject to the standard 30/90 hygiene rules (§12). Restoring is the
reverse action. Every mute and restore is logged in
`permission_events`.

The write-mute is keyed on `users.id` and is auth-path-agnostic: a
contributor muted under the v0.1 OAuth-era flow stays muted after
the v0.7.0 email/OTC migration, since the same row is reused via the
email-match linker. Identity in this section means the `users.id`
column; the v0.7.0 identity-key shift (`gitea_id` → `email`) is
about which column carries the unique constraint for new
provisioning, not about which column the permission gates read.

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.

v0.8.0 adds a fourth structurally-distinct field on the same row:
`users.permission_state` (the admission gate the v0.8.0 release
ships, see opening of §6 and §6.1). The four — role, muted,
permission_state, the notification mutes — are orthogonal and the
gate semantics compose:

  * `role` answers "what scope of action is this user authorized to
    perform if they're admitted at all?" (anonymous / contributor /
    admin / owner).
  * `muted` answers "is this contributor write-restricted by an
    admin gesture against their existing grant?" (a sanctions
    primitive — owner/admin imposed).
  * `permission_state` answers "is this user admitted to the beta
    at all?" (the v0.8.0 admin-grant gate — 'pending' / 'granted' /
    'revoked'). The default for grandfathered rows at migration time
    is `'granted'`; OTC freshly provisions `'pending'`.
  * The notification mutes answer "does this user want to receive
    signals about a particular RFC or from a particular other
    user?" (self-imposed preference).

The four never gate each other. A pending user with `role=owner`
(impossible by construction in v0.8.0 — fresh OTC always provisions
role=contributor — but the orthogonality holds at the column level)
would still refuse write endpoints because the admission gate
runs first; a granted contributor whose row is also muted refuses
writes via the mute gate; a granted contributor with notification
mutes set still passes the contributor gate and writes normally.
v0.6.0's anon-write audit (item #4) is the structural floor for all
four — every write-shaped endpoint funnels through
`auth.require_contributor`, which checks all three of {authenticated,
not muted, permission_state='granted'} in order.

### 6.3 Per-RFC delegated authority

An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
frontmatter) are contributors who, **within that RFC**, can perform
admin-scope actions: grant contribute access on any branch in the
RFC, merge PRs, set branch visibility, withdraw the RFC. They are
not app-wide admins; their elevated powers are scoped to that
single RFC. This is what lets work distribute without Ben being on
the hook for every action.

### 6.4 Per-branch contribute grants

Each branch has a `contribute_mode`:

- `just-me` (default) — only the branch creator can push.
- `specific` — only the branch creator and the users in
  `branch_contribute_grants` can push.
- `any-contributor` — any signed-in contributor can push (the
  "I want help" mode).

The branch creator and the RFC's owners/arbiters can change this
setting at any time. Anonymous users can never push regardless of
setting.

### 6.5 Audit trail

Every commit the bot makes carries an `On-behalf-of:` trailer naming
the acting user. Gitea's commit log is for code archaeology; the
`actions` and `permission_events` tables are the real accountability
record.

### 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.

### 6.8 Sign-in state resume (roadmap item #29, v0.23.0)

Signing in lands the user back on their most recently-viewed app
state instead of always on the empty-state home view. The model is
**per-user**, not per-device — the safe default the roadmap calls
for: a sign-in on any device resumes the most-recently-recorded
route. A per-device split and a profile-settings toggle UI are
follow-ups; v0.23.0 ships the column-level opt-out flag
(`resume_enabled`, default on) and the default-on behavior.

**Storage.** A single row per user in `user_session_state`
(migration 022): `user_id` (PK, FK → `users.id`, cascade-delete),
`last_route` (TEXT, the frontend pathname), `last_route_state`
(TEXT, JSON-encoded — SQLite has no native JSONB, so JSON-as-TEXT
matches how the app stores every other JSON blob), `resume_enabled`
(INTEGER, default 1), and `last_updated_at`.

**Wiring.** A debounced (~1s) frontend route-change hook posts the
current route to `PUT /api/me/last-state` for authenticated users
(anonymous: no-op). The stored `last_route` is folded onto the
existing `/api/auth/me` payload (no extra round-trip); on sign-in,
after the §21-Part-C Amplitude `identify` fires, the frontend
`navigate()`s to it. The identify-then-redirect ordering is
preserved: the redirect is gated on identify having fired.

**Stale state.** If the stored route is an RFC since withdrawn or
one the user lost rights to read, the redirect is a graceful no-op:
`navigate(last_route)` lands on whatever that route renders today,
and the existing routing already falls through to the
catalog/empty-state for a missing/unreadable RFC. No special-casing
on the server.

**Privacy (binding).** The stored state is **route + light view
state ONLY** — scroll anchors, open-tab selection, filter chips, and
the like. It **MUST NOT** carry draft-buffer contents, PR/comment
draft text, or any user-typed content. The frontend never sends such
content; the `last_route_state` column comment in migration 022 and
this paragraph are the contract. Resume state is purposely cheap to
discard: a deliberate "clear" (or `resume_enabled = 0`) drops the
user back to today's empty-state behavior.

---

## 7. The left pane

The default view is a single scrollable flat list of every meta-repo
entry whose state is `super-draft` or `active`. State is conveyed by
visual cue on each row (muted styling and a "super-draft" tag for
super-drafts; normal weight for active; an integer ID badge for active
entries only). The list is **not** grouped by state — grouping forces
a hierarchy on the user that gets in the way of finding by title.

### 7.1 Above the list

- **Search box** — fuzzy-matches title, slug, and integer ID.
- **Sort dropdown** — default "Recently active" (max of the RFC's
  last main-branch merge and the meta-repo entry's last commit, for
  super-drafts with no repo). Other options: Created date, Title,
  ID, State.
- **Filter chip strip** — multi-select, AND-combined. Chips:
  `State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner
  or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty
  `owners:`), `Tag: …`. A collection that declares a metadata field
  schema (§22.4a) replaces this chip strip with **faceted filter
  groups** — one collapsible group per `enum`/`tags` field plus state,
  each showing per-value result counts and multi-select checkboxes
  (OR within a field, AND across fields); see the Configurable
  Collection Metadata design. A collection with no `fields:` schema
  keeps the chip strip described here unchanged.

### 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 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.

Below roughly 1280px wide the three-column shape adapts: the right
column collapses into an off-canvas drawer toggled from a Chat button
in the editor toolbar (with a pending-count badge), rather than the
columns being suppressed or stacked. Above the breakpoint the inline
three-column layout is unchanged. This is the pre-fancy adaptation;
responsive primitives for the catalog column are out of scope here.

### 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 `mermaid` diagrams 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.

The AI chat is an editing activity — a turn can emit `<change>`
proposals — so it runs on an edit branch, not on read-only main, whose
right column is the human-discussion surface (§8.12 is branch-scoped).
Invoking the AI **Ask** affordance (the selection tooltip's prompt, or
the prompt bar) from main therefore transparently cuts an edit branch
via the same dispatch as "Start Contributing" (promote-to-branch for an
active RFC, start-edit-branch for a super-draft per §9.5; idempotent, so
an existing edit branch is reused rather than a second one cut), lands
the user on it, and runs the question — text plus any selected quote —
as the branch's first chat turn. A viewer who cannot contribute has the
branch cut rejected and the error surfaced (no branch is created); a
signed-out viewer keeps the §8.7 read-only path. **Flag** from main is
unaffected — it opens a human discussion thread on main, not a branch.

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

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, the legacy full-editor swap-in for inspecting accepted
changes in context, was retired in the Contribute rewrite once the
raw-pane gutter and the preview-pane inline overlay together covered
its review affordance.

### 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 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 ("`human` is taken — try `human-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 proposer-owner field either: the proposer is
implicitly the first owner of their own RFC, set automatically at
submit-time from the session user (see §9.2); the post-merge claim
flow (§13.1) remains for *other* contributors to add themselves as
owners on an RFC they didn't propose. No working-group field —
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. (§19.3 rule-2
correction: 0.4.0 narrowed this paragraph; the prior version asserted
"no proposed-owner field" without qualification, but running code
revealed that forcing a separate claim-flow gesture for the
proposer's own RFC was UX friction with no benefit — the proposer
already self-identified by proposing.)

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: [<proposer.gitea_login>]` — the proposer is the first
  owner at propose time, set automatically from the session user.
  The §13.1 claim flow remains available for *additional* owners on
  the same entry. (§19.3 rule-2 correction: 0.4.0 changed this from
  `owners: []`; the prior shape required a separate claim-flow
  gesture for the proposer's own RFC, which running code surfaced
  as needless UX friction.)
- `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: <comment>"), so the chat record per the rules in this
  section carries the act inline. The preview step is the
  ceremony — decline is the sister gesture to graduation, and seeing
  what the proposer will read before sending makes the cost of the
  act concrete.

A chat thread accumulates on the pending-idea view pre-merge —
contributors can argue about whether the entry belongs in the
catalog before it is admitted. On merge, that chat migrates to the
super-draft's main chat per the same principle §13.4 commits for
graduation: chat follows the work. The data path is straightforward.
The threads carry `rfc_slug = <proposed slug>` pre-merge (slugs are
reserved during the idea PR per §9.1's uniqueness check), and on
merge they simply surface under the super-draft's main view with no
data movement. On decline, the threads stay attached to the closed
PR as historical record and do not surface in any default view, the
same treatment a withdrawn entry receives.

Outcome signals to the proposer are narrow. On next visit after
merge or decline, the pending-idea view is either the super-draft
view itself (merged) or a read-only "Declined" banner (declined),
and a one-time in-app toast surfaces — "Your proposal *Open Human
Model* was merged. It's now a super-draft." or "Your proposal *Open
Human Model* was declined." The "Declined" banner carries the
decliner's display name, the decline date, and the comment from the
two-step dialog verbatim, rendered above the proposal body and
frontmatter (which remain readable below for context). Beneath the
banner, a small "Propose a revised entry" affordance opens the
propose modal pre-filled from the prior proposal's title, slug, and
pitch — the philosophy frames non-survival of a proposal as
expected, and a one-step path back to the modal honors a proposer
who has absorbed the decline reason. Beyond the toast and the
banner, the proposal-merged and proposal-declined events also fire
into the proposer's inbox as personal-direct notifications per §15.4
(default-on email category), giving out-of-session reach for an
event whose timing the proposer cannot anticipate.

### 9.4 The super-draft view

Once the idea PR merges, the entry exists as a super-draft per §3,
and the catalog renders it as a `super-draft` row per §7.2.
Selecting it opens the **super-draft view** — a sibling surface to
§8's active-RFC view, sharing the three-column layout, the
breadcrumb, and all of §8's center-pane mechanics.

The differences are scoped. There is no integer ID in the breadcrumb
per §2.3. The breadcrumb dropdown lists open meta-repo edit branches
(per §9.5) in the place an active RFC lists its branches, and the
dropdown's first position is **"canonical body"** — the entry as it
appears on meta-repo main — in the place an active RFC's main sits.
There is no PR flow scoped to *this* RFC's own repo (it has none
yet); there can be open body-edit PRs against the meta repo, surfaced
inline with the edit branches in the dropdown.

The document pane uses the same Tiptap editor as active RFCs, in
read-only mode by default, identical to §8.2. The toolbar shape, the
selection-tooltip from §8.12, and the §8.13 flag affordances 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), the tracked-change
markup (§8.10), stale-change handling (§8.11). The "branch"
abstraction §8 was written against is the meta-repo edit branch;
the machinery does not care whether the underlying repo is per-RFC
or meta.

Opening a PR is §10.1's gesture, with the meta-repo branch as
source and the meta repo as target. The PR creation modal (§10.2),
the review page (§10.3), the review-comment surface (§10.4), and
the seen-cursor mechanism from §10.3 all apply unchanged. The merge
actor set is the **super-draft's owners and arbiters per §6.3,
plus app-wide admins/owners per §6.1**. An unclaimed super-draft has
no owners, so until the claim flow (§13.1) runs, only app-wide
admins/owners can merge body-edit PRs — sensible because an
unclaimed super-draft is by definition awaiting an owner, and
admin oversight is the only path to canonicalizing edits in the
interim.

Multiple body-edit branches can coexist on the same super-draft. The
super-draft view's breadcrumb dropdown lists them exactly the way
an active RFC's lists its branches. Merge conflicts invoke §10.9's
resolution-branch replay path against the meta repo.

Frontmatter edits — title and tags — are out of scope for the
editor's in-line edit surface in v1. A small **metadata pane** on
the super-draft view permits title and tag edits; each edit produces
a tiny meta-repo PR via the bot, distinct from a body-edit branch.
Slug renames are not supported in v1 — a slug rename is a file
rename in Git plus a cache-key rewrite, rare enough to defer to a
future topic (see §19.2). The proposer's slug choice is canonical
until graduation, at which point the integer ID becomes the stable
handle per §13.2.

### 9.6 Chat and threads on a super-draft

The chat and thread model from §8.4, §8.12, and §8.13 extends to
super-drafts in the natural way:

- The super-draft has a **main chat** — the whole-doc default
  thread, stored as a `threads` row with the super-draft's slug as
  `rfc_slug` and `branch_name = null`, identical in shape to an
  active RFC's main thread per §8.4. This is the durable
  conversation surface about the canonical body as it appears on
  meta-repo main.
- **Each meta-repo edit branch has its own chat** per §8.4, with
  `branch_name` set to the edit branch's name. On merge or close,
  the chat persists attached to the (now-closed) branch as
  historical record, exactly per §8.4's rule. After §12's 90-day
  deletion timer fires, the metadata row remains in the cache for
  historical reference and the chat data remains in the app
  database.
- **Range and paragraph sub-threads (§8.12) and flags (§8.13)**
  apply on both the super-draft's main view and on edit branches.
  The AI participant is invocable on any thread. The
  selection-tooltip and margin-icon affordances from §8.12 work
  identically.
- **Anchor stability.** §8.12's stale mechanic is general enough to
  cover both cases. On an edit branch, anchors churn
  commit-to-commit, same as on an active-RFC branch. On the
  super-draft's main view, anchors churn only at body-edit-PR merge
  boundaries — same volatility as on an active RFC's main. Anchors
  that fail to relocate get the "(anchor lost)" affordance from
  §8.12.

§5's existing `threads` schema accommodates all of this without
column changes. The interpretive rule recorded in §5 is: when the
entry referenced by `rfc_slug` is in state `super-draft`,
`branch_name` names a branch on the meta repo rather than on a
per-RFC repo. The `changes` table inherits the same rule.

### 9.7 Visibility and contribute on a super-draft

§11 specifies visibility (read) and contribute (push) for branches
on active-RFC repos. Because §9.5 maps super-draft body edits onto
meta-repo edit branches, §11 transfers in place with two
super-draft-specific clarifications:

- **The super-draft's canonical body is always publicly readable.**
  It lives on the meta repo's main branch, which is public by
  definition (§2, §14.2). There is no `read_public` toggle at the
  super-draft level — nothing to hide, nothing to flip. The
  pending-idea view (§9.3) is publicly readable for the same reason:
  meta-repo PRs are public.
- **Edit branches inherit §11 in full.** Default
  `read_public = true`, default `contribute_mode = just-me`,
  per-branch contribute grants per §6.4, §11.3's PR-becomes-public
  rule, §11.4's chat-inherits-from-branch rule, §12's 30/90 hygiene
  timers. The set of people who can flip an edit branch's settings
  is the branch creator, the super-draft's owners/arbiters (where
  any have been claimed), and app-wide admins/owners. Until the
  claim flow (§13.1) runs, that set is the branch creator and
  app-wide admins/owners only — falling out of §6.3's strict
  super-set framing without amendment.

"Contribute" as a super-draft-scope verb resolves to the concrete
set of gestures: chatting on the super-draft's main thread, opening
an edit branch via "Start Contributing," posting in an edit branch's
chat (subject to per-branch grants), claiming ownership per §13.1,
dropping a flag per §8.13, and posting on an open idea PR's
discussion pre-merge. All require contributor role per §6.1; none
requires more than that on the super-draft itself, except per-branch
grants on a `just-me` or `specific` edit branch.

The super-draft's main chat is publicly readable; posting requires
contributor — identical to an active RFC's main chat per §11.4 plus
§6.1. Anonymous viewers see the same read-only fallback per §8.7.

### 9.8 Graduation handoff additions

> **Meta-only update (v0.31.0).** This section originally reconciled
> §13's per-RFC-repo graduation transaction with the §9-era editing
> machinery. Under the meta-only topology (§1, §13) the entry never
> moves and its body is never stripped, so the frictions this section
> existed to manage **dissolve**. The bullets are retained, struck
> through, for the audit trail of what the per-repo model required.

Under meta-only, graduation is a single frontmatter-flipping commit
to `rfcs/<slug>.md` (§13.3). Nothing else changes: the body stays in
the entry; branches, edit-PRs, threads, flags, and `changes` rows all
remain exactly where they were, keyed by the slug per §2.3, and keep
working against the same meta entry after the flip as before it. There
is no entry move, no body strip, no per-repo seed, and therefore no
"handoff" to coordinate.

- ~~**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.~~ **No longer applies** — the body is kept,
  so an open body-edit PR coexists with graduation. Graduation touches
  only the frontmatter; a body-edit PR that merges after graduation
  edits the same entry's body just as it would have before. The
  Graduate dialog no longer gates on open body-edit PRs (§13.2).
- ~~**Bare edit branches survive graduation.**~~ Trivially true now —
  no repo boundary is crossed, so every edit branch simply remains a
  meta-repo branch on the same slug, subject to §12's hygiene timers,
  with no "re-cut against the new repo" step.
- ~~**Chat migration includes range and paragraph sub-threads.**~~ No
  migration occurs: whole-doc, range, and paragraph threads stay on
  their `(rfc_slug, branch_name)` rows. Their anchors resolve against
  the same entry body, which did not move, so §8.12's stale mechanic is
  not provoked by graduation.
- ~~**Pre-graduation history surfaces from the new RFC view.**~~ There
  is no "new RFC view" distinct from the entry's own view, so there is
  no pre-graduation hop to bridge. Edit-branch threads, flags, and
  `changes` rows surface on the active RFC the same way they did on the
  super-draft — same slug, same surface.

---

## 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.

### 10.10 PR-less discussion vs. contribution

PR comments and branch chat (§10.4, §8.4) are PR-scoped: they live on
the `threads` rows whose `branch_name` names a branch (the PR's head
or, pre-PR, a feature branch). They are the right surface for *this
specific proposed change*. They are not the right surface for "what
about this part of the RFC overall?" or "have we considered…?" — a
question that doesn't yet warrant cutting a branch and that would
distort a PR's review timeline if it landed there.

The RFC view carries a **discussion surface** distinct from PR
comments. Its substrate is `threads` rows whose `branch_name IS NULL`
(§5) — the same conversation table used by branch chat, with the
nullable column doing the segregating. Posting a discussion thread or
message does not open a PR; the §1 chokepoint is unaffected because
chat messages never produced Git writes. Contribution — proposing
edits the document will land — still requires opening a PR via the
§10.1 affordance.

The distinction in one line: **discussion is what the RFC is for;
contribution is how the RFC changes.** Either is honest; conflating
them was the failure mode of generic-PR-comments-as-only-conversation.

Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
contract: anyone can see the conversation. Writes require contributor
role per §6.1: v0.5.0 implemented the gate on the three discussion
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
#4) audited the adjacent surfaces and added the matching test net
(`test_anon_offlimits_vertical.py`) so a regression on any write
endpoint is caught immediately. The gates use `auth.require_contributor`
as the canonical helper. The notification routing reuses the
existing `chat_message_in_participated_thread` /
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
fan-out row; the §15.7 reconciler and §15 inbox prose render
identically whether the chat lives on a branch or on the RFC's
discussion surface. A distinct `open_rfc_discussion_thread` event
kind is a §19.2 candidate if evidence demands the split.

---

## 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, in place)

Graduation is initiated by an owner or admin clicking "Graduate" on a
super-draft's page. The button is disabled with a tooltip when the
super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no
longer block graduation: under the meta-only topology (§1) the body is
kept in the entry, so graduation touches only frontmatter and coexists
with body edits (see §9.8). Bare edit branches are likewise unaffected;
they remain on the meta repo subject to §12's hygiene timers.

> **Meta-only rewrite (v0.31.0).** §13 originally described a
> transactional create-repo-seed-flip sequence (`super-draft → active
> RFC repo`) with rollback. That is **retired** (§1). Graduation is now
> a single in-place state flip on the entry: no repo is created, the
> body is not moved or stripped, and there is nothing to roll back. The
> subsections below are rewritten to the new model; §13.3 records what
> the old transaction did, struck through, for the audit trail.

### 13.1 Claim ownership (prerequisite)

The proposer is already the first owner of any super-draft they
proposed (per §9.2; auto-set at propose time). The claim flow exists
for *additional* contributors to become owners on an existing super-
draft — typically a draft whose proposer has stepped away, or a draft
graduating with a working group. 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.
(§19.3 rule-2 correction: 0.4.0 narrowed this section's framing; the
prior version implied owners always started empty and the claim flow
was a hard prerequisite to graduation, but with the proposer now
auto-set as first owner, the claim flow is a graduation-time
broadening rather than a precondition for the proposer's own RFC.)

### 13.2 The Graduate dialog

Clicking "Graduate" opens a small dialog with two editable fields:

- **Integer ID** — **optional.** Pre-filled as `max(existing integer
  IDs) + 1`, formatted as `RFC-NNNN`, purely as a *suggestion* the
  graduating owner may keep, change (to reserve gaps), or **clear
  entirely**. Leaving it blank graduates the entry to `active` with no
  number — `id` stays null and the slug remains the canonical identifier
  (§2.3). The field's helper text states this: blank means "graduate
  without a number." A blank id is the default-accepted case, not an
  error.
- **Initial owners** — pre-filled from the entry's `owners:`, with an
  "add owner" picker. Must have at least one.

(The old **Repo name** field is gone — there is no repo to name under
the meta-only topology.)

Each field validates inline as the admin types, with a short
debounce, against the catalog cache and a regex. The integer-ID field
is valid when it is **blank** (graduate without a number) *or* matches
`^RFC-\d{4,}$` and is not already taken by another entry; the owners
picker enforces the at-least-one constraint. Errors render as a short
line of text beneath the offending field. The integer-ID collision
check is re-issued atomically server-side on confirm, since a
concurrent graduation could land between dialog-open and submit (only
relevant when a number was supplied — a blank id can never collide).
While any field is invalid, the confirm button is disabled and its
tooltip names the first blocker specifically — "Integer ID 42 is
already taken," "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. A missing number is **never**
a blocker.

The dialog's confirm button is also disabled when the entry has no
owners. The disabled button opens a small popover on hover or click
listing the failing precondition with an inline remediation
affordance: "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. Open
body-edit PRs are **not** a precondition anymore (§9.8): the body is
kept, so they coexist with graduation.

### 13.3 The flip

Confirming the dialog runs a single operation as the bot: open a PR
against the meta repo that re-serializes `rfcs/<slug>.md` with
`state: active`, `id: RFC-NNNN` **or `id: null` when no number was
supplied**, `graduated_at: <timestamp>`, `graduated_by: <admin
username>`, and the `owners:` from the dialog — **leaving the body
unchanged** — then auto-merge it (the admin who clicked is the merge
actor). The webhook flow updates the SQLite cache and the catalog row
transitions per §7.2. When `id` is null, the catalog and RFC view
identify the entry by its slug (no "RFC-NNNN" chip is rendered).

```
super-draft entry  ──[graduate]──▶  same entry, state: active, id assigned OR null
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
```

There is no repo to create, nothing to seed, and the body is neither
moved nor stripped, so there is **no multi-step transaction and no
rollback**. If opening or merging the flip PR fails, the entry simply
stays a super-draft and the admin sees the error — nothing partial was
created that needs cleaning up. The dialog reports a single in-flight
"Graduating…" state that resolves to success (the PR merged) or a
plain error (the PR could not be opened or merged), rather than the
old five-step stack. On success, a brief "Graduation complete" frame
holds for a moment before the dialog closes.

> ~~**The old transactional sequence (per-RFC-repo model, retired).**
> Confirming created a new Gitea repo; seeded it with `README.md`,
> `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo
> PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the
> entry body** to frontmatter-only with a "see the full RFC at <repo>"
> link; auto-merged it; refreshed the cache. A five-step SSE stack
> rendered progress, and any mid-sequence failure rolled back (delete
> the half-created repo, abandon the PR). All of that machinery is
> removed — the body strip was the only reason most of it existed.~~

### 13.4 Chat, branches, and history stay put

Nothing moves at graduation. The whole-doc main thread (§8.4), range
and paragraph sub-threads (§8.12), edit-branch chats, flag threads,
and `changes` rows all remain on their existing `(rfc_slug,
branch_name)` rows — the slug is the canonical key per §2.3 and does
not change. Their anchors resolve against the same entry body, which
did not move, so §8.12's stale mechanic is not provoked by graduation.

Because there is no repo boundary to cross, there is no
"pre-graduation history" hop: the active RFC's view is the same view
the super-draft had, listing the same `main`, open branches, and open
PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during
the super-draft phase surface through the ordinary "Show closed
branches" filter — there is no separate "lived on the meta repo before
the repo existed" set to distinguish, because the repo never existed.

### 13.5 Reversing graduation

The canonical forward path from `active` is still `withdrawn` (§3.1),
and `withdrawn → super-draft` reopens an entry. Under the meta-only
topology, reversing a graduation is no longer operationally messy —
there are no repo commits to orphan, only a frontmatter flip — but the
state graph in §3.1 remains the authority: `active → withdrawn →
super-draft` is the supported route, and the integer `id`, once
assigned, is not reclaimed (gap-free allocation per §2.3 tolerates
gaps from withdrawals). A direct `active → super-draft` un-graduate is
not exposed in v1; withdraw-and-reopen covers the need.

### 13.6 RFC-0001 fold-back (migration record)

RFC-0001 `human` was graduated under the original per-RFC-repo model
(2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that
repo's `RFC.md` and its meta entry stripped to frontmatter. When the
meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session
0041.0), RFC-0001 was folded back to the single model: the full
`RFC.md` body was restored into `rfcs/human.md` in the meta repo
(`repo:` set null, `state: active` and `id: RFC-0001` retained), and
`wiggleverse/rfc-0001-human` was archived with a `README` pointing at
the canonical home in the app. RFC-0001 is therefore an ordinary
meta-only active RFC like any other; no grandfathered per-repo path
remains in the code.

### 13.7 Retire (soft delete)

Retire takes an entry out of the working set without destroying it. It
is the same shape as the other lifecycle transitions — an in-place
frontmatter flip on the meta entry, committed via an auto-merged bot PR
(§13.3's machinery, reused) — but with two distinguishing rules:

- **Authority (§3.1).** Only the RFC's own `owners` (frontmatter) and
  holders of the site `owner` role may retire. App admins may *not*
  (this is the one lifecycle action where admin authority does not
  apply). Un-retire is **site owners only**: an RFC owner can retire but
  cannot reverse it, so every soft-delete remains recoverable by the
  operator.
- **Visibility.** A `retired` entry is removed from **every** browsing
  surface — the catalog (`GET /api/rfcs`), the ordinary RFC view (`GET
  /api/rfcs/<slug>` returns 404 for everyone except a site owner, so the
  un-retire affordance has somewhere to live), link pickers, and search.
  This is stronger than `withdrawn`, which is merely hidden-by-default
  and filterable back in.

The flip sets `state: retired` and leaves every other field — including
the integer `id`, if one was assigned — untouched, so an un-retire
restores the entry exactly as it was. Retire is allowed from
`super-draft` or `active`; the prior state is recorded in the audit log
(`retire` action) so un-retire (`unretire` action) can restore it.
Because the entry file is never removed from `rfcs/`, the meta repo
remains the source of truth and the reconciler reproduces the
`retired` state on every sweep.

A site owner finds retired entries through an admin surface (a
"Retired" list, owner-gated) and un-retires from there; the entry then
reappears in the catalog under its restored state. The integer `id`,
once assigned, is never reclaimed or reissued by retire/un-retire
(gap-tolerant allocation per §2.3).

---

## 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" → the email + one-time-code surface
at `/login` (per §6.2). Beneath that, a secondary link: "Read the
full philosophy" → `/philosophy`. The v0.1 landing said "Sign in
with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
gesture, with a small "Sign in with Gitea (fallback)" link surviving
on `/login` itself for the migration window.

`/login` itself is a stepped surface, driven by which auth path the
viewer is currently on (§6):

1. **Email step.** The viewer enters their email. The frontend
   consults `GET /auth/passcode/check?email=…` to learn whether
   this email has a passcode set. The check endpoint is
   account-enumeration-safe — it returns `has_passcode: false` for
   both "unknown email" and "known email without passcode", so a
   probing client cannot distinguish the two from the response.
2. **Either the passcode step or the OTC code step.** If the email
   has a passcode set, the viewer is asked for it (v0.10.0).
   Otherwise an OTC is dispatched and the viewer is asked for the
   six-digit code from their email (v0.7.0).
3. **Optional post-OTC passcode-offer step.** After a successful
   OTC verify on an account with no passcode set, the surface
   asks "Set a passcode for faster sign-in next time?" — the user
   can dismiss the offer or set one inline. The skip-for-now path
   redirects straight to `/`.

The passcode step carries a "Use a code instead" link that
re-dispatches an OTC and switches to the code step — the same path
the lockout response (HTTP 423) takes automatically after five
consecutive failed passcode verifies.

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.

v0.8.0 (roadmap item #6) added a third sign-in step the surface
runs conditionally — on the first OTC sign-in by a previously
unknown email, the verify response carries `needs_profile=true`
and the surface prompts for first name, last name, and a free-text
"why I should be included in the beta" before bouncing the user to
`/beta-pending`. The page displays a "your request is in review"
message keyed on `users.permission_state='pending'` (repurposed
from the v0.3.0 post-OAuth-rejection surface). Anonymous viewers
and pending viewers see the same read surfaces; only the write
affordances differ. A persistent thin "Your beta access is in
review" banner shows on every page (other than `/beta-pending`
itself) until an admin grants access.

### 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.

### 14.5 Cookie / privacy consent (v0.13.0)

The framework ships a non-modal cookie consent banner reachable by
every viewer — authenticated and anonymous alike. The banner appears
at the bottom of the viewport on first load and stays visible until
the user makes a choice, after which it hides and the choice is
persisted. The `/settings/notifications` page carries a "Privacy &
cookies" tab that surfaces the current choice and re-opens the banner
on demand.

The choice has three categories, presented as a single-select:

- **Essential only** — the framework's strictly-necessary cookies
  (sign-in session, signed payloads, the consent-choice record
  itself). Always on; the user cannot switch this off because the
  app cannot function without it.
- **Essential + analytics** — adds the optional analytics layer
  gated by this choice. As of v0.13.0 no analytics SDK ships in the
  framework; roadmap item #13 (v0.15.0) lands one behind this gate.
  Off by default — the user has to opt in.
- **Essential + analytics + other** — adds third-party embeds or
  social widgets a deployment may configure. The framework ships no
  such cookies by default; this category exists so deployments that
  add them have a categorized opt-in to wire them behind.

Storage shape:

- **Anonymous viewer** — choice persists in `localStorage` only
  (`rfc-app.cookie-consent.v1`). The same browser carries the choice
  forward; a different browser, or cleared storage, re-prompts.
- **Authenticated viewer** — choice persists in the `cookie_consent`
  row keyed by `user_id`. On sign-in, the server row (if present)
  overrides the local snapshot; if the server has no row, the local
  choice is uploaded.

The `essential` flag is permanently true at the API surface. The
endpoint accepts it for symmetry but never persists a false value.
A deployment that wants strictly-necessary cookies to be optional
must change the framework contract, not flip a flag.

The framework exports a small JavaScript helper (`frontend/src/lib/
consent.js`) for downstream surfaces:

- `getConsent()` — current snapshot.
- `hasChosen()` — true once the user has made a choice.
- `onConsentChange(cb)` — subscribe to updates.
- `setConsent({analytics, other})` — record a new choice locally
  (the banner / settings surface handles server persistence on top).

Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
read consent, then conditionally `import()` the SDK module. The gate
is wired before the SDK lands so the contract is already in place.

### 14.6 Privacy and cookies policy pages

The framework ships two policy routes:

- `/privacy` — a minimal default privacy policy that describes the
  framework's stance (what is stored, why, how to revoke consent,
  how to reach the deployment operator). The page is reachable by
  anonymous and authenticated viewers alike.
- `/cookies` — the framework's cookies policy, listing exactly which
  cookies the framework sets, by category. Self-documenting: a future
  framework release that adds or removes a cookie updates this page
  as part of the change.

Each page links to the other and to the §14.5 banner. The consent
banner links to both.

Deployments override the policy content via two optional build-time
env vars documented in `frontend/.env.example`:

- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
  links to as the "full deployment policy". The framework's stub
  always renders above the link so the framework-level contract is
  always visible; the link layers deployment-specific content on
  top.
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.

Both are optional. Unset is the supported default; the stub pages are
sufficient for a default-config deployment that has nothing
deployment-specific to add. The framework chose the env-var path over
a content-repo file because it composes with the existing build-time
config layer; the content-repo-file alternative is the §19.2
candidate.

---

## 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.

(The framework's separate **analytics + session-replay** surface —
Amplitude wiring, event taxonomy, identity lifecycle, consent
contract — is a peer cross-cutting concern specified in §21.
Notifications cover in-product signal-of-others-acting-on-your-work;
analytics covers observability of how the product is used. The two
surfaces do not overlap.)

### 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`, `new_beta_request`, `digest_emitted`.
The enum is extensible; the build session adjusts as new gestures
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
#7) is framework-scoped rather than RFC-scoped — the row's
`rfc_slug` is NULL and the deep-link points `/admin/users`
instead of `/rfc/<slug>` — but otherwise rides the standard
fan-out chokepoint with category `admin-actionable` so the §15.4
email gate only reaches owners/admins.

### 15.2 The inbox

The inbox is a global view, reachable from a header icon present on
every authenticated screen alongside the §14.3 About link. Selecting
it opens a center-pane overlay (or a route, `/inbox`, depending on
the build session's chrome choice — the binding part is *one*
inbox, not per-RFC inboxes).

Each row carries: the actor's display name (or "system" attribution
for null-actor rows per §15.9), a verb phrase rendered from the event
kind plus payload, a scope chip (RFC title, branch name where
applicable, PR number where applicable), and a relative timestamp.
Clicking the row navigates to the work the signal is about and
marks the row read on next inbox refresh via the visit-advances-
cursor reconciler in §15.7. A `Mark read` affordance on each row is
available for triage without navigation.

Above the list, three controls: a **filter chip strip** (multi-
select, AND-combined: `Unread only`, `RFC: …`, `Category: personal-
direct / structural / churn`, `From: <user>`), a **bundle toggle**
(switches between per-row and per-RFC + per-event-kind grouping —
"3 new commits on PR #4 / RFC-0042" as a single bundle row,
markable in one gesture), and a **Mark all read** action that
respects the current filter (so the user can mark all churn read
without touching personal-direct rows).

The inbox is *not* the place watch-state preferences, email
categories, digest cadence, quiet hours, or per-user mutes are
configured — those live on a separate notification-settings surface
whose UX is the natural next topic (§19.2). The inbox is the triage
surface; the settings panel is where the underlying preferences
live.

### 15.3 Badges, toasts, and ambient signals

The badge in the app header reads the unread notification count
directly; it caps at 99+ visually and reads "99+" rather than
escalating into a four-digit number that contributes nothing to
triage. Tapping the badge opens the inbox; the count refreshes from
the same SSE stream that backs the inbox itself per §17, so badge
and inbox never disagree.

The per-row catalog dot from §7.2 is binary, not numeric. Its rule:
visible on a row when the row's RFC has any unread notification for
the signed-in user *and* the user's watch state on that RFC is
`watching` or `following` (per §15.6). The dot clears the moment
the unread count for that RFC reaches zero, whether through inbox
mark-read or through in-context visit-advances-cursor reconciliation
per §15.7.

Toasts surface in three narrow cases. First, the user's own action
completing — proposal opened, change accepted/declined, PR opened,
graduation confirmed; these are mid-session feedback for a gesture
the user just made. Second, an event firing on the *exact view the
user is currently looking at* — a commit arriving on a PR the user
is viewing, a chat reply landing in a branch chat the user has open;
these are scoped to the live view and never persist (the inbox row
still appears for the same event, but the toast carries the
"something just happened here" beat). Third, the system-author
messages per §10.6 and §8.6 are *not* toasts — they are inline chat
content; toasts are a chrome surface, not a content surface. Toasts
are dismissible by gesture and auto-dismiss after a short interval
(implementation-detail timing); the chrome never stacks more than a
small number of toasts on top of each other (a fourth arriving while
three are visible queues, not stacks).

The contribute-mode entry in §8.3, the graduation-complete frame in
§13.3, and the proposal-merged/declined banners in §9.3 already
carry their own visual beats and are not duplicated as toasts.

### 15.4 Email

Email is the single channel that escapes the app. The discipline
governing it: opt-in per category with category-specific defaults,
body mirrors the inbox row text verbatim, one-click unsubscribe per
category, single non-spoofing From identity.

Four categories, each with a distinct default and rationale:

- **Personal-direct events — default on.** Signals where the
  recipient is the named subject: proposal merged or declined (§9.3,
  recipient is the proposer); graduation completed on a super-draft
  the recipient owns; contribute grant added or revoked (recipient
  is the grantee); reply directly to a thread the recipient posted
  in; AI proposal targeted at a passage the recipient previously
  edited (§8.11); the recipient's PR merged or withdrawn by someone
  else. The contract: when the contributor's name is on the action,
  the system reaches out of band. Opt-in here would let the contract
  fail silently.
- **Watched-RFC structural events — default off.** PR opened on a
  watched RFC, PR merged, graduation, withdrawal, decline-of-
  someone-else's-proposal — the structural beats on RFCs the
  recipient watches or follows but is not personally subject to.
  Inbox and badges already carry these; the email toggle is opt-in
  for contributors who want out-of-session reach.
- **Watched-RFC churn — never email.** New commits on a PR the
  recipient did not open, additional chat messages in a thread the
  recipient did not participate in, new flags on a watched RFC. The
  email toggle for this category appears in settings as permanently
  disabled, with a tooltip naming the refusal explicitly: *"Per-
  commit and per-message email is intentionally not offered. The
  digest aggregates this activity weekly."* Naming the refusal is
  more honest than silently omitting the toggle.
- **Admin-actionable events — default on for admins/owners,
  unused for contributors.** Super-draft graduation-ready (an
  owner has been claimed and no body-edit PRs are blocking per
  §13.2); mute/restore events affecting the recipient as the actor
  or subject; permission-change events affecting the recipient.
  Admin actions are time-pressured against waiting contributors.
  The column on `users` is unused for contributors and ignored at
  generation time.

The email envelope: subject `[Wiggleverse] <Event>: <RFC title or
super-draft title>`. From: a single noreply identity (e.g.
`Wiggleverse <notifications@…>`), regardless of which user's gesture
produced the signal per §15.9. Body: the inbox row's rendered text
verbatim, plus a one-line context line ("by @alice on
RFC-0042 / branch <name> · 5 minutes ago"), plus one primary link to
the changed thing. No marketing footer. Two trailing links:
`Unsubscribe from <this category>` (one-click, no confirmation page;
signed URL, idempotent) and `Manage all preferences`.

Email is held during the recipient's quiet hours per §15.8 and
released at window end; held messages bundle into a single "Activity
while you were away" email when more than a small threshold (the
build session can set, in the neighborhood of five) accumulated,
otherwise sending individually at window end.

Bounces and complaints route to the global email opt-out path
automatically. The `users` table does not need a separate bounce
column — a global opt-out is the only durable response to a hard
bounce.

### 15.5 The digest

The digest is the catch-up surface for activity on watched RFCs
that the recipient hasn't triaged through any other channel. It
runs on its own cadence and carries the *churn* class that §15.4's
email policy intentionally excluded.

Default cadence is **weekly**, fired Sunday evening UTC (most
regions wake up to it Monday morning). Per-user configurable on the
`users.digest_cadence` column to `daily`, `weekly`, or `off`. The
digest is not sent if there is nothing to report.

Coverage is **event-window**, not time-window: each digest covers
"everything since the recipient's last digest," tracked via the
`notification_digests` row's `period_start` / `period_end`. A
recipient switching cadence — weekly to daily, or off back to
weekly — does not see overlapping coverage.

Three exclusion rules, applied in order at digest assembly time:

1. Exclude any notification with `email_sent_at` set — those were
   already triaged out-of-session via the personal-direct or
   structural email categories. The digest does not re-report things
   the recipient already saw in email.
2. Exclude any notification with `read_at` set — those were
   triaged in the inbox or in-context via the visit-advances-
   cursor reconciler in §15.7.
3. Include all remaining notifications, but annotate any still-
   unread row as "still unread in your inbox." The digest is the
   catch-up surface; suppressing unread items would defeat its
   purpose, but the recipient deserves the disclosure that the
   item is double-tracked.

Each included notification's `digest_included_at` is set when the
digest is emitted, making the three rules idempotent and queryable
at audit time.

Format: subject `[Wiggleverse] Weekly digest — N events across M
RFCs` (or `Daily digest`). Body in spec voice: a single summary
sentence, then per-RFC sections ordered by activity volume, each
section grouping events by kind — "3 PRs opened on RFC-0042 (Open
Human Model): #12, #13, #14"; "5 commits on PR #4 (still open)";
"12 new chat messages across 3 threads"; "2 flags dropped." Each
event line links to the work; the per-RFC section header links to
the RFC view. Trailing links match §15.4's email envelope:
`Manage digest preferences`, `Manage all preferences`.

The digest does not aggregate personal-direct events (those have
their own email channel), does not include events on RFCs the user
has muted via the `watches.muted` state, and does not send to
admins for admin-actionable events (those are personal-direct).

### 15.6 The watch / subscription model

Three watch levels per RFC, all stored on the `watches` table per
§5: `watching`, `following`, `muted`. Each row is per (user, RFC);
absence of a row means no relationship and no signal generation.

Watches are **implicit, earned by gestures**, with manual override
always available. The auto-rules:

- A **substantive gesture** sets `watching` if no row exists, or
  upgrades `following` → `watching` (never downgrades). The
  substantive gestures: post in a chat thread; create a branch;
  open a PR; accept or decline a change; claim ownership; receive
  a contribute grant; drop a flag; merge a PR; resolve a thread.
- **Starring** sets `following` if no row exists. Star never
  downgrades a `watching` row to `following` (the substantive
  gesture wins); star never overrides an explicit `muted` (the
  explicit setting wins). The star itself is independent of the
  watch row — unstarring does not change the watch state.
- **Role assignment** as arbiter or owner per §6.3 implicitly
  treats the RFC as `watching` for the purpose of signal
  generation, regardless of `watches` row state. The role *is*
  the subscription, and it never auto-decays. An owner or
  arbiter can still explicitly set `muted` on a row; the explicit
  mute wins over the role-implicit watch, which is intentional
  (an admin should be able to step back from a specific RFC's
  notifications even while keeping the authority).

Each level's signal scope:

- **`watching`** — the full stream for the RFC: PR open, PR merged,
  PR commits, PR review-thread activity, chat messages in
  participated-in threads, change-proposed-on-edited-passage, flag
  drops, flag resolutions on the recipient's own flags, contribute
  grants on branches the recipient has touched, structural events
  (graduation, withdrawal, reopen). Plus all personal-direct
  events that happen to land on this RFC.
- **`following`** — structural beats only: PR open, PR merged,
  graduation, withdrawal, decline-of-others'-proposals. No commit-
  level churn, no chat noise, no flag drops on threads the
  recipient didn't participate in. Plus all personal-direct
  events that happen to land on this RFC.
- **`muted`** — no signals from this RFC, including no personal-
  direct events on this RFC. The muted state is explicit-only and
  is the strongest "leave me alone about this" gesture available.

Per-PR and per-thread signaling is **derived from existing
participation tables**, not stored on a separate `pr_watches` or
`thread_watches` table:

- A user receives signals about a PR if they have ever advanced
  `pr_seen` on it, have a row in `changes.acted_by` on its branch,
  or are the PR's opener. These are the "you touched this PR"
  signals — the muted state on the RFC suppresses them.
- A user receives signals about a thread if `thread_messages`
  carries a row authored by them in that thread. The muted state
  on the RFC suppresses them.

This keeps the watch surface narrow (one row per user × RFC) while
preserving the precision the inbox needs.

**90-day decay.** A `watching` row that has not accumulated a
substantive gesture from the user in 90 days auto-decays to
`following`. The `watches.last_participation_at` column tracks the
last-substantive-gesture timestamp; a nightly job downgrades rows
whose timestamp is older than the threshold. Star never decays;
explicit settings never decay; role-implicit watching never decays
(it is not stored on the row, it is computed). On the user's next
visit to the decayed RFC, a small unobtrusive line in the chrome
notes the change ("Following since [date]"); a one-click affordance
restores `watching`.

A small **watch-state affordance** on the RFC view header lets the
user move between the three levels at any time. Explicit choices
set `set_by = 'explicit'` and are exempt from auto-decay. Manual
override always wins over auto-rules.

### 15.7 The unread mechanism

Two cursor families serve two different jobs, and the section is
careful not to conflate them.

**The notifications cursor — per-event read state.** Each row in
`notifications` carries its own `read_at`. The inbox renders unread
rows accented and clears the accent on read. The unread count
backing the header badge is `COUNT(*) WHERE recipient = me AND
read_at IS NULL`. Marking a row read is per-row, per-bundle, or
per-filter (the `Mark all read` action). Per-event read state is
what the inbox needs because triage is per-event: "I read the email
about the decline; the corresponding inbox row should be marked
read; but I haven't visited the proposal page yet."

**The scope cursors — within-scope freshness.** `pr_seen` (§10.3)
and `branch_chat_seen` (§5) both track per-user, per-scope cursors
for the in-context "what changed since my last visit" accent
inside a PR review surface or a branch chat. These are *within-
scope* cursors; they answer "have I seen the contents of *this
scope*?" rather than "have I triaged the signal that *this scope*
moved?"

**Reconciliation between the two.** Visiting the work advances the
scope cursor (per §10.3 for PRs, per §5 for branch chats); a
post-visit reconciler then marks related notifications read on
next inbox refresh. The reconciler's rule: for each scope cursor
that just advanced, find all `notifications` rows where
`recipient_user_id = me` AND `(rfc_slug, pr_number)` or
`(rfc_slug, branch_name)` matches the advanced scope AND `read_at
IS NULL` AND the event's logical timestamp is at or before the new
cursor position. Set `read_at` for those rows. This closes the
loop in the visit-advances-triage direction; the inverse (marking
the inbox row read does not advance the scope cursor) is also
intentional, because the user may know enough from the email or
inbox text not to need to visit the work yet, and we do not want
the scope cursor falsely claiming the user has read the diff.

Per-signal vs per-bundle read advance: the inbox renders each
notification as its own row by default; the bundle toggle from §15.2
collapses rows by RFC + event kind, and marking a bundle read marks
all its constituent rows. Both modes operate on the same per-row
`read_at` column.

### 15.8 Do-not-disturb

Three mechanisms, each narrowly scoped:

- **Quiet hours.** A single per-user window in the user's local
  time, stored in `users.notification_quiet_hours_start /
  _end / _timezone`. Default off (all columns null). During the
  window, email and digest delivery is held; inbox and badges are
  unaffected (they do not make sound). At window end, held
  messages bundle into a single "Activity while you were away"
  email per §15.4 if more than the bundling threshold accumulated;
  otherwise they fire individually. A single window is the
  schema commitment — workday quiet hours separately from
  nighttime quiet hours is the kind of refinement that earns its
  schema later, after evidence.
- **Per-RFC mute.** The `watches.state = 'muted'` setting from
  §15.6. No separate mechanism. Suppresses all signal generation
  for the (user, RFC) pair, including personal-direct events
  scoped to that RFC.
- **Per-user mute.** A row in `notification_user_mutes` keyed by
  (muter_user_id, muted_user_id) suppresses any notification whose
  `actor_user_id` matches the muted user. Notification-volume
  only; does not affect content visibility in any view — the muted
  user's posts, commits, flags, and review threads remain
  readable. The framework's evidence claim depends on the muted
  user's words remaining visible; muting their *name* in the
  recipient's inbox is the narrow tool the recipient needs.

  Per-user mute is **not available** to admins or owners exercising
  app-wide authority, and not available to arbiters exercising
  §6.3 authority on RFCs where they hold it. The role
  contractually requires receiving signals from everyone; muting
  one's way out of an arbitration responsibility undercuts the
  role. The UI surfaces the constraint at the moment of attempted
  mute, with the rationale inline rather than as a silent disable.

These three are strictly orthogonal to §6.2's app-wide write-mute,
per the clarification folded into §6.2 in this section's pass.

### 15.9 Notification authorship

Notifications surface the **underlying user** as actor, not the
bot. The `notifications.actor_user_id` column holds the underlying
user's id for any event produced by a human gesture; the inbox row
reads "@alice opened PR #4 on RFC-0042 (Human)," and
the email subject reads "[Wiggleverse] @alice opened PR #4:
<title>." Triage requires the human's name in the noun slot — the
reader's first question is "whose work is this?" and surfacing the
bot puts the wrong noun in the way.

**System-generated events have `actor_user_id = null`.** The auto-
close at 30 days (§11.5, §12), the digest emission, the anchor-
lost transition on threads (§8.12), the graduation-readiness
detection (§13.1), the post-graduation catalog transition (§7.2)
— none have a human actor. The inbox row names no one: "PR #4
auto-closed (30 days idle)." Absence of an actor is the honest
signal that no human acted; the system does not invent attribution.

**The bot account does not appear in notifications.** It remains
the §1 Git-layer persona that opens commits and PRs on behalf of
users, and §6.5's `On-behalf-of:` trailer remains the Git-side
accountability mechanism the audit log relies on. Surfacing the
bot on the notification side would conflate two distinct
accountability boundaries — the Git-layer "who opened this PR"
and the social "whose argument is this" — and the conflation
would cost the framework the precision it depends on. The same
underlying-actor-not-bot rule applies to **every other user-visible
attribution surface** — the §7.3 pending-ideas disclosure's
"by @alice," the §10.3 PR header's opener line, any in-app
rendering of "who did this." The §4 cache resolves the actor for
these surfaces by joining against the §6.5 `actions` log keyed on
`(rfc_slug, pr_number, action_kind)`, falling back to parsing the
`On-behalf-of:` trailer when the audit log row is absent (e.g.
when the cache was rebuilt from a meta repo populated outside the
app's history). The bot login surfaces only in the Git log and in
the `On-behalf-of:` trailer itself, never as a noun in the app's
prose.

**Email envelope identity is a single non-spoofing address** —
`Wiggleverse <notifications@…>` — regardless of which user's
gesture produced the signal. Spoofing the actor's email address
creates SPF/DKIM trouble for legitimate domains and gets the
framework's mail marked spam; surfacing two competing identities
(bot in From, user in subject) breaks email-client filtering rules
that key on From. One stable envelope identity, the actor named in
content.

---

## 16. What is deliberately deferred

Calling these out so the follow-up session knows what is *not yet
specified* and what is intentionally out of scope for v1.

- **The main document pane.** How `RFC.md` (or the super-draft body)
  renders, the Tiptap editor configuration, how the editor handles
  rich markdown, headings, links, code blocks.
- **The per-RFC and per-branch chat UX.** Threading model, AI
  participation, the discuss-vs-contribute mode distinction, the
  selection tooltip, the prompt bar, the model picker chrome (its
  option-list source is settled in §6.6 / §8.12; the visual treatment
  and per-thread persistence remain).
- **The revision flow.** How proposed changes from AI or contributors
  appear, the change-card panel, accept/decline/edit, tracked
  insertions/deletions in the editor.
- **Body full-text search.** Title/slug/ID/tag search ships in v1;
  body content search is a later layer.
- **Featured super-drafts.** A `featured: true` frontmatter flag for
  owner/admin curation. One-line addition we can ship when the need
  is real.
- **Accepted / deprecated states.** Reintroducible if an OHM moment
  ("this is now official") becomes desirable.
- **Raw `git clone` + push as a contribution path.** All contribution
  flows through the app.
- **Self-merge window for un-acted ownership claims.** Configurable;
  off in v1.

---

## 17. Backend surfaces (API shape, illustrative)

The follow-up session will refine this. A minimal starting set:

- `GET /api/health` — unauthenticated. Returns JSON
  `{version, status}` where `version` is the running framework
  version (the contents of `VERSION` per §20.1, read at process
  start) and `status` is `"ok"` (HTTP 200) or `"degraded"`
  (HTTP 503). v1 always reports `"ok"`; the 503 / `"degraded"`
  path is reserved scaffold for future degradation conditions.
  Used by ops tooling (e.g. the flotilla deploy control panel)
  as a post-flight probe — the structural check is that the
  returned `version` matches the tag the operator just deployed,
  catching the failure mode where a restart did not pick up the
  new code.
- `POST /auth/otc/request` — unauthenticated. Body carries `email`.
  Generates a six-digit code, stores its bcrypt hash with an expiry
  (`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email
  via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly.
  Returns HTTP 429 when the per-email cooldown
  (`OTC_REQUEST_COOLDOWN_SECONDS`, default 60) blocks back-to-back
  requests — the loud-failure shape for the abuse path. A re-request
  invalidates the prior unused code for the same email so only one
  code is outstanding at a time. v0.7.0 also dropped requests
  silently if the email wasn't on the `allowed_emails` list (the
  v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
  admission moved to `permission_state` on the freshly-provisioned
  `users` row, asserted at the contributor gate. v0.12.0 (item #10)
  gates this endpoint behind a CloudFlare Turnstile siteverify call:
  the body carries an optional `turnstile_token` field, the server
  POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/
  v0/siteverify` before the bcrypt hash + SMTP send, and a failed
  challenge refuses with HTTP 400 spending no rate budget. Two env
  vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret
  Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When
  the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is
  open (the dev / pre-rollout path); when the secret is unset and
  `TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500
  "auth misconfigured" so a future config drift fails loudly
  instead of silently disabling abuse defense.
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
  `code`. Validates the bcrypt hash against the most-recent unconsumed
  non-expired row for the email, marks the row consumed, provisions
  or links the `users` row by email (per §6.2's migration path —
  match by `users.email` case-insensitive, otherwise insert a fresh
  contributor row with `gitea_id = NULL` and
  `permission_state='pending'`), and stores the session cookie.
  Returns HTTP 200 on success; the response body carries
  `{ok, user, needs_profile}` where `needs_profile=true` iff the
  user is `permission_state='pending'` AND the row has no
  first_name / last_name / beta_request_reason yet (a fresh OTC
  sign-in). The `needs_profile` flag drives the Login.jsx surface's
  step-3 capture form. HTTP 400 on any failure (expired, consumed,
  wrong, unknown). The failure modes collapse to a single generic
  message so a probing client cannot distinguish "you got the
  wrong code" from "we don't know this email" — the operator logs
  carry the distinction.
- `POST /api/auth/me/beta-request` — authenticated. Body carries
  `first_name`, `last_name`, `beta_request_reason` (all required;
  bounded at 120 / 120 / 4000 chars). Writes the fields to the
  signed-in user's row and leaves `permission_state='pending'`.
  Idempotent for the same already-pending user (a re-submit
  updates the row so the admin sees the latest text). Refuses
  with HTTP 409 if the user is already `'granted'` or `'revoked'`.
  v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
  #6). v0.9.0's admin user-management page consumes this column
  set to render the request queue.
- `GET  /auth/passcode/check` — unauthenticated. Query param `email`.
  Returns `{has_passcode: boolean}`. The frontend's `/login` surface
  calls this after the email step to decide whether to render a
  passcode input or fall back to OTC. The response carries only the
  boolean; lockout state, the bcrypt hash, and the `passcode_set_at`
  stamp are not leaked. An unknown email and a known-without-passcode
  email both return `false`, so the endpoint is enumeration-safe.
- `POST /auth/passcode/set` — authenticated (any role). Body carries
  `passcode` (4–20 characters). bcrypt-hashes the passcode, writes
  `users.passcode_hash` + `users.passcode_set_at`, clears the failure
  counter and any active lockout. Refuses obvious patterns (a small
  denylist: `0000`, `1234`, `aaaa`, `password`, etc.) and length
  violations with HTTP 422. Replaces any prior passcode. v0.10.0.
- `DELETE /auth/passcode` — authenticated. Clears
  `users.passcode_hash` and `users.passcode_set_at`, returning the
  user to OTC-only on next sign-in. v0.10.0.
- `POST /auth/passcode/verify` — unauthenticated. Body carries
  `email` and `passcode`. Locates the user, checks the lockout
  window, and compares via bcrypt. On success: clears the failure
  counter, refreshes `last_seen_at`, stores the session cookie,
  returns HTTP 200 with the minimal user payload. On failure:
  increments `passcode_failed_attempts`. After five consecutive
  failures, stamps `passcode_locked_until = now + 15 minutes` and
  returns HTTP 423 with a `locked_until` field; subsequent attempts
  inside the window are refused with the same shape. After the
  window expires, the next attempt clears the counter and proceeds
  normally. The OTC path (§17 above) is unaffected by the passcode
  lockout — a locked-out user can still request and verify a fresh
  OTC. The wrong-passcode and unknown-email failure modes both
  return HTTP 400 with a generic message; the no-passcode-set
  failure also collapses to 400 so the response does not enumerate
  account state. v0.10.0.
- `POST /auth/device-trust/start` — unauthenticated. Reads the
  `rfc_device_trust` cookie (set previously by an OTC or passcode
  verify with `trust_device: true`). On a non-expired, non-revoked
  match, re-establishes the session and returns HTTP 200 with the
  minimal user payload. On a miss (no cookie, expired, revoked, or
  unknown), returns HTTP 401 and clears the stale cookie via the
  response's `Set-Cookie` header. The failure modes collapse to
  one shape so a probing client cannot enumerate "your row was
  revoked" vs. "this token never existed". v0.11.0.
- `GET /api/auth/me/devices` — authenticated. Returns the active
  (`revoked_at IS NULL` AND `expires_at > now`) device-trust rows
  for the signed-in user: `id`, `created_at`, `expires_at`,
  `last_seen_at`, `user_agent`. The bcrypt hash is structurally
  private and is never surfaced. v0.11.0.
- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps
  `revoked_at` on the row with id `{id}` belonging to the
  signed-in user. The user-id scope is enforced in SQL so a
  hostile client cannot revoke another user's row by guessing
  ids; a row that does not match returns HTTP 404. v0.11.0.
- `DELETE /api/auth/me/devices` — authenticated. Revokes every
  active row for the signed-in user; returns the count revoked.
  v0.11.0.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
  owners, last_active_at, has_open_prs, starred-by-me. Supports
  search, sort, filter chips, and the `unclaimed` predicate.
- `GET /api/rfcs/<slug>` — one entry's full metadata plus tree
  view (open PRs, open branches, closed branches if requested).
- `GET /api/rfcs/<slug>/main` — main-branch body.
- `GET /api/rfcs/<slug>/branches/<branch>` — branch body and metadata.
- `POST /api/rfcs/propose` — open the idea-submission PR per §9.1.
  Body carries `title`, `slug`, `pitch`, `tags`. Server-validates
  slug uniqueness against `rfcs/` on the meta-repo main and against
  the slugs of any open idea PRs. Returns the new PR number plus a
  redirect handle for the pending-idea view (§9.3).
- `GET  /api/proposals` — list open idea PRs for the pending-ideas
  disclosure in §7.3.
- `GET  /api/proposals/<pr_number>` — pending-idea view data per
  §9.3: the proposed file's body and frontmatter, the PR title and
  description, the chat thread id, the viewer's available
  affordances.
- `POST /api/proposals/<pr_number>/withdraw` — proposer-only;
  closes the meta-repo PR per §9.3.
- `POST /api/proposals/<pr_number>/decline` — owner/admin only;
  closes the meta-repo PR with a required comment per §9.3.
- `POST /api/proposals/<pr_number>/merge` — owner/admin only;
  merges the meta-repo PR, creating the super-draft entry on main.
  Threads on the pending idea become the super-draft's main-chat
  threads per §9.3 with no data movement.
- `POST /api/rfcs/<slug>/claim` — open the ownership-claim PR per
  §13.1.
- `POST /api/rfcs/<slug>/metadata` — title or tag edits on a
  super-draft per §9.5's metadata pane; opens a small meta-repo PR
  via the bot. Slug renames not supported in v1.
- `POST /api/rfcs/<slug>/start-edit-branch` — the "Start
  Contributing" gesture on a super-draft per §9.5; cuts a fresh
  meta-repo branch `edit/<slug>/<auto-name>`, returns the branch's
  metadata, lands the contributor in contribute mode. The body
  optionally carries a desired branch name; otherwise auto-generated.
- `POST /api/rfcs/<slug>/graduate` — run the graduation sequence
  (admin/owner only). Body carries the dialog's three fields —
  candidate integer ID, candidate repo name, initial owners. Returns
  a stream handle for the progress SSE below; the body fields are
  re-validated atomically server-side at the top of the sequence
  (the repo-name collision check in particular) since a concurrent
  graduation could land between dialog-open and confirm.
- `GET  /api/rfcs/<slug>/graduate/check` — inline validation for the
  Graduate dialog per §13.2. Query params: `id` (the candidate
  integer ID), `repo` (the candidate repo name). Returns per-field
  collision/validity status from the catalog cache plus a
  server-authoritative repo-name collision check. Debounced from
  the client; the dialog calls this as the admin types.
- `GET  /api/rfcs/<slug>/graduate/progress` — SSE stream of the
  five-step transactional sequence per §13.3, one event per step
  transition (`pending` → `running` → `done` / `failed`), plus a
  trailing `rollback` step's events if any earlier step fails. The
  Graduate dialog opens this stream on confirm and renders the step
  stack from the events. The stream closes on success or on
  rollback completion. Requires `auth.require_user` per v0.6.0
  (item #4): the step detail (repo name, PR number, rollback steps)
  is operator-visible state and isn't part of the v0.3.0
  anonymous-read contract for catalog and RFC bodies. The floor is
  `require_user` (not `require_contributor`) so a write-muted
  operator can still observe a graduation they kicked off before
  being muted.
- `GET  /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
  against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
  PR number, title, author, last-activity timestamp, and the
  viewer's available actions (merge, withdraw, open-in-new-tab) per
  §6.1 / §6.3.
- `POST /api/rfcs/<slug>/withdraw` — withdraw an entry.
- `POST /api/rfcs/<slug>/branches/<branch>/visibility` — set
  read_public and contribute_mode.
- `POST /api/rfcs/<slug>/branches/<branch>/grants` — add/remove
  per-branch contribute grants.
- `POST /api/rfcs/<slug>/branches/main/promote-to-branch` — the
  `Start Contributing` flow on main per §8.14: cuts a new branch
  from main's tip, re-anchors pending `changes` rows from `main` to
  the new branch's name, returns the new branch's metadata. Body
  optionally carries a desired branch name; otherwise an
  auto-generated value is used.
- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/accept`
  — accept a pending change per §8.9; runs the immediate commit per
  §8.6. Body carries the possibly-edited `proposed` text, the
  `was_edited_before_accept` flag, and an optional
  `force_apply_stale` flag for the stale-change confirmation path
  per §8.11.
- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/decline`
  — decline per §8.9. No commit; row state moves to `declined` and
  persists as evidence.
- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/reask`
  — regenerate a stale AI proposal against current text per §8.11.
  Triggers a fresh streaming assistant message in the originating
  thread; the old row stays for audit.
- `POST /api/rfcs/<slug>/branches/<branch>/manual-flush` — explicit
  save gesture for buffered manual edits per §8.11; the API
  equivalent of the in-card `Save now` button. Idempotent for an
  empty buffer.
- `GET  /api/rfcs/<slug>/branches/<branch>/threads` — list threads on
  the branch with filtering by `state` and `thread_kind`. Includes
  the whole-doc default thread.
- `POST /api/rfcs/<slug>/branches/<branch>/threads` — create a thread
  per §8.12 or §8.13. Body: `thread_kind`, `anchor_kind`,
  `anchor_payload`, `label` (required for `flag`, optional for
  `chat`), and an optional first `message` for chat-kind.
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/messages`
  — post a message into a thread per §8.12; the same endpoint is the
  reply path. Body: `text`, optional `quote`.
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
  — resolve a thread per §8.12; permission per the rules in that
  section.
- `GET  /api/rfcs/<slug>/discussion/threads` — list threads on the
  RFC's PR-less discussion surface per §10.10 (rows where
  `threads.branch_name IS NULL`). Anonymous-readable per the v0.3.0
  anonymous-read contract; the default whole-doc chat thread is
  materialized lazily on first read, mirroring the §8.12 branch-chat
  default.
- `POST /api/rfcs/<slug>/discussion/threads` — open a discussion
  thread per §10.10. Body: optional `label` (short summary), optional
  first `message`. Writes require contributor role; anonymous viewers
  receive 401. Thread is created with `anchor_kind='whole-doc'`,
  `thread_kind='chat'`, `branch_name=NULL`.
- `GET  /api/rfcs/<slug>/discussion/threads/<thread_id>/messages` —
  read messages on a discussion thread. Anonymous-readable.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages` —
  post a message into a discussion thread per §10.10. Body: `text`,
  optional `quote`. Writes require contributor role.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve` —
  resolve a discussion thread per §10.10; permission collapses to the
  thread creator, any RFC owner / arbiter per §6.3, and any app
  admin / owner per §6.1.
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
  §10.1; body carries the AI-drafted (and possibly edited) title and
  description.
- `GET  /api/rfcs/<slug>/prs/<pr_number>` — PR metadata, diff handle,
  thread ids for the conversation and review surfaces.
- `POST /api/rfcs/<slug>/prs/<pr_number>/seen` — advance the per-user
  seen-cursor (§10.3).
- `POST /api/rfcs/<slug>/prs/<pr_number>/review` — post a review-kind
  thread anchored to a diff range per §10.4.
- `POST /api/rfcs/<slug>/prs/<pr_number>/merge` — merge per §10.5
  (arbiter/admin/owner only).
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
  fresh resolution branch and replay per §10.9.
- `GET  /api/admin/users` — list users for the §6 / Slice 7 admin
  surface. v0.9.0 (roadmap item #7) widened the payload to carry
  `permission_state`, `first_name`, `last_name`, `beta_request_reason`,
  `created_at`, `permission_decided_at`, and the joined
  `permission_decided_by_login` / `permission_decided_by_display`
  for the user-management page. Sort order surfaces `pending` rows
  first (the daily admin queue), then `granted`, then `revoked`;
  within a bucket, owners precede admins precede contributors,
  with recency as the tiebreaker.
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
  or revoke `owner`; admins may flip contributor ↔ admin freely. An
  owner-self-demotion is refused on this endpoint; owner succession
  earns its own ceremony (§19.2). Writes a `permission_events` row.
- `POST /api/admin/users/<id>/mute` — set the §6.2 app-wide
  write-mute (not the §15.8 notification mutes). Refused on owners
  and admins — for them, the role-change channel is the right
  refusal. Writes a `permission_events` row.
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
  Flip `permission_state` between `pending`, `granted`, and `revoked`.
  Stamps `permission_decided_by` + `permission_decided_at` on the
  row and writes a `permission_events` row with event_kind in
  `{permission_granted, permission_revoked, permission_repended}`.
  Refuses with 422 if the admin tries to flip their own row
  (symmetric to the `set_mute` / `set_role` self-action refusals).
  v0.8.0 shipped the column shape with no admin UI — operators ran
  a manual `UPDATE users` to grant access; v0.9.0 retires the
  manual gesture.
- `GET  /api/admin/audit` — paged read of the `actions` log with
  filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
  for the page boundary. Returns the joined actor login/display so
  the surface can render row prose without a second round-trip.
- `GET  /api/admin/permission-events` — paged read of
  `permission_events` (role changes, write-mute toggles), joined
  against `users` for actor and subject. Same `before_id` paging.
- `GET  /api/admin/graduation-queue` — the §13.2-ready set: returns
  super-drafts partitioned into `ready` (owners set, zero open
  body-edit PRs) and `blocked` (one or both preconditions missing),
  with the precondition shape carried in each row.
- `GET  /api/users/me/notification-mutes` — list the §15.8 per-user
  mutes the signed-in user has set, joined against `users` for the
  rendered handle and display name. The companion read endpoint to
  the add/delete pair.
- `GET  /api/users/search` — typeahead over `gitea_login` and
  `display_name`, ten-row cap, prefix-first ranking. Powers the
  §15.8 mute-add typeahead in `/settings/notifications`. Excludes
  the caller. Open to any authenticated viewer.
- `POST /api/stars/<slug>` — star/unstar.
- `POST /api/webhooks/gitea` — webhook receiver.
- `GET  /api/notifications` — list inbox rows for the signed-in user,
  per §15.2. Query params: `unread` (bool), `rfc_slug`, `category`
  (`personal-direct` | `structural` | `churn`), `actor_user_id`,
  `bundled` (bool — server-side grouping by RFC + event_kind per
  §15.2). Returns row payload plus the unread count for the badge.
- `POST /api/notifications/<id>/read` — mark a single row read.
- `POST /api/notifications/read` — mark read by filter; body carries
  the same filter params as the list endpoint. Used by `Mark all
  read` and by bundle-collapse `mark read` actions per §15.2.
- `GET  /api/notifications/stream` — SSE stream of inbox additions
  and read-state changes for the signed-in user. Backs the live
  inbox refresh and the header badge counter per §15.3.
- `GET  /api/watches` — list the signed-in user's watch rows per
  §15.6, with per-row state and `set_by`.
- `POST /api/rfcs/<slug>/watch` — set watch state explicitly per
  §15.6; body carries `state` (`watching` | `following` | `muted`).
  Sets `set_by = 'explicit'` and exempts the row from the 90-day
  auto-decay.
- `POST /api/rfcs/<slug>/branches/<branch>/chat-seen` — advance the
  per-user `branch_chat_seen` cursor per §5 / §15.7; body carries
  `last_seen_message_id`. Triggers the inbox reconciler for
  chat-kind notifications scoped to this branch.
- `GET  /api/users/me/notification-preferences` — read the per-user
  email category toggles and digest cadence per §15.4 / §15.5.
- `POST /api/users/me/notification-preferences` — set them.
- `GET  /api/users/me/quiet-hours` — read the per-user quiet-hours
  window per §15.8.
- `POST /api/users/me/quiet-hours` — set or clear it; body carries
  start, end, timezone (all required to set; all null to clear).
- `POST /api/users/<id>/notification-mute` — add a per-user
  notification mute per §15.8. Idempotent. Refused if the signed-in
  user is acting in an admin/owner authority capacity or as an
  arbiter on an RFC where the muted user is also active.
- `DELETE /api/users/<id>/notification-mute` — remove it.
- `GET  /api/users/me/funder` — read the signed-in user's funder
  surface per §6.7: the list of provider families for which they
  have registered credentials (without the keys themselves) and the
  list of RFC slugs they have consented to fund.
- `POST /api/users/me/funder/credentials` — register or replace an
  API key for a provider family (`anthropic`, `google`, `openai`).
  Body: `provider`, `api_key`. The provider name must match one the
  operator has enabled; registering for a provider the operator has
  not enabled is refused since per §6.7 a funder cannot expand the
  operator universe.
- `DELETE /api/users/me/funder/credentials/<provider>` — remove the
  registered key for a provider family. Idempotent. Existing consent
  rows survive; the resolved funder universe for affected RFCs
  simply shrinks.
- `POST /api/rfcs/<slug>/funder/consent` — the signed-in user opts
  in to fund this RFC per §6.7. Idempotent. Refused if the signed-in
  user has no registered credentials at all (a consent without any
  registered universe would be inert).
- `DELETE /api/rfcs/<slug>/funder/consent` — withdraw consent per
  §6.7's first revocation path. Restores the operator-credentials
  status quo on the next AI call.
- `GET  /api/email/unsubscribe` — one-click per-category
  unsubscribe per §15.4. Signed URL; idempotent; redirects to a
  short confirmation page.
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
  per §15.4; sets the recipient's global email opt-out.
- `GET  /api/users/me/cookie-consent` — read the signed-in user's
  cookie consent record per §14.5. Returns `{essential, analytics,
  other, recorded_at}`. `recorded_at: null` means "no choice yet"
  and the banner should be shown; the framework treats absence of a
  row as equivalent to that. `essential` is permanently true.
- `PUT  /api/users/me/cookie-consent` — write the signed-in user's
  cookie consent record per §14.5. Body: `{essential, analytics,
  other}`. The `essential` flag is accepted for symmetry but always
  persisted as true. Upserts (a single row per user) and stamps
  `recorded_at` to now.

Plus all the chat / streaming / model-picker endpoints, scoped to
per-RFC and per-branch threads.

The `branches/<branch>/...` endpoint family covers both per-RFC-repo
branches and meta-repo edit branches; per §9.5, the routing collapses
on a single rule — when `<slug>` resolves to an entry in state
`super-draft`, `<branch>` names a branch on the meta repo. The
endpoint implementations dispatch on the entry's state to pick the
right Gitea repo; the API surface is the same.

---

## 18. Technical stack

The stack the app is built on:

- FastAPI + SSE for streaming chat.
- React + Vite + Tiptap (ProseMirror) for the editor.
- The structured `<change>` / `<original>` / `<proposed>` / `<reason>`
  protocol for AI-proposed edits.
- Multi-provider LLM support: Anthropic Claude, Google Gemini, OpenAI /
  GitHub Copilot. `ENABLED_MODELS` and per-provider API keys via env —
  these now define the operator's *universe* of available models, the
  set the deployment is provisioned to run. Per-RFC selection from
  that universe is settled in §6.6; per-RFC credential delegation
  (whose keys pay for the call) is settled in §6.7. Either the
  operator's keys or, when a consenting funder is named, the funder's
  registered keys — never blended per-call. The operational
  hardening (mid-call failure handling, retry, rotation, billing
  attribution) remains a §19.2 candidate.
- The discuss-vs-contribute distinction inside an RFC view (to be
  fully specified in the follow-up session).
- Gitea OAuth for user authentication. The OAuth identity is the basis
  for the app's user account; authorization is layered on top per §6.

---

## 19. Topics still to settle

This spec captured the topics settled across thirteen sessions —
the structural model (repos, states, storage, permissions, list),
the RFC view (document, chat, branches, changes, threads, flags),
the super-draft view and lifecycle, the PR flow, the graduation
flow, the chrome (landing, philosophy), and now the notification
surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build."

### 19.1 v1 is complete

Slice 8 — the hardening pass — has landed. With it, every slice of the
v1 build is in: the bot wrapper, the §4 cache, Gitea OAuth + user
provisioning, the §5 schema, the §7 catalog, the §8 active-RFC view in
full, the §9.4–§9.7 super-draft vertical, the §10 PR flow against both
per-RFC repos and meta-repo edit branches, the §13 graduation flow
with rollback, the §15 notifications surface in full, the §14 chrome
plus the `/settings/notifications` and `/admin` neighborhoods, and the
§12 + §10.7 branch hygiene with the §19.2 candidates the hardening
pass clustered in.

Subsequent sessions pick from §19.2 by user choice per §19.3's working
agreement. They need not be sequential — the user picks the next topic
based on what evidence the running app surfaces. The build itself is
the source-of-truth artifact; §19.2 is the queue of decisions to
settle when their turn comes.

The Slice 8 entry below is preserved for the record. Future sessions
that fold a §19.2 candidate into the spec should mark that candidate
*settled* with a brief pointer at the section that resolved it — the
same shape Topic 13's resolution used.

### 19.1.1 Slice 8 record

**Slice 7 background.** The §14.1 landing page now carries the title, the subtitle, the
short-form pitch from `PHILOSOPHY.md`, the sign-in affordance, the
secondary "Read the full philosophy" link, and a three-item deck
underneath the pitch that names what the framework is — one word per
RFC, argued in public with the model, graduation as the load-bearing
moment. The §14.2 `/philosophy` route reads `PHILOSOPHY.md` from
disk (via `backend/app/philosophy.py`, configurable through the
`PHILOSOPHY_PATH` env var) and renders it inline with the existing
`marked` library — the same renderer the proposal preview already
uses. §14.3's persistent About link sits in the header next to the
inbox badge and the new Settings / Admin (admin-only) entries; the
header's visual budget stays tight, and each entry reads as a quiet
text link rather than a button.

The notification-settings surface (`/settings/notifications`,
`frontend/src/components/NotificationSettings.jsx`) lands the five
sub-sections the §15 endpoints already supported: the §15.4 per-
category email toggles (with the `email_watched_churn` toggle
permanently disabled and the §15.4 refusal tooltip inline), the
§15.5 digest cadence dropdown, the §15.8 quiet-hours editor (three
inputs against `Intl.supportedValuesOf('timeZone')`, with all-three-
or-clear validation enforced server-side), the §15.6 watches
overview (per-row state selector that flips `set_by` to `explicit`
on override), and the §15.8 per-user mute list with an unmute
affordance and a typeahead add. Owners and admins see the §15.8
mute-list with the "cannot mute" prose inline. The §15.4 email
footer's `Manage all preferences` link — wired in Slice 6 — now
resolves to a real surface.

The admin home base (`/admin`, `frontend/src/components/Admin.jsx`)
runs as a tabbed left-rail with four panels: Users (role management
+ §6.2 write-mute, with the role-grant constraints enforced
server-side per §6.1 — only owners may grant owner; owners cannot
self-demote on the role endpoint), Graduation queue (super-drafts
partitioned by §13.2 readiness — owners set and zero blocking body-
edit PRs), Audit log (paged read of `actions` with filter chips for
`action_kind`, `actor_user_id`, and `rfc_slug`), and Permission
events (paged read of `permission_events` showing the role and
mute history). Every `/api/admin/*` endpoint guards independently
through `require_admin`, and the User-search endpoint (open to all
authenticated viewers) powers both the admin user-roster and the
mute typeahead.

`backend/app/api_admin.py` carries the seven new admin endpoints
plus the user-search. `backend/app/philosophy.py` carries the
disk-backed `/api/philosophy` source. `backend/app/api_notifications.py`
grew one read endpoint (`GET /api/users/me/notification-mutes`) for
the settings page's mute list. The §17 admin block was extended in
this corrected spec to name the seven endpoints; §14.2 was
corrected to acknowledge the deployment-time decision about where
`PHILOSOPHY.md` lives.

Slice 7 ships covered by `backend/tests/test_chrome_vertical.py` —
thirteen integration tests covering the philosophy route for both
anonymous and authenticated callers, the §15.4 / §15.5 / §15.8
preferences round-trip (including the permanent `email_watched_churn`
refusal), the quiet-hours all-or-nothing validation, the §15.8 mute
add/list/unmute round-trip, the user-search typeahead, the admin
role and write-mute round-trips with their `permission_events`
audit, the §6.1 refusal of owner-grant by non-owners, the audit-log
filter chips, the graduation-queue partition under both
preconditions, and the permission-events listing. The full Slices
1–7 test suite is 75/75 green.

**Slice 8 — the hardening pass — completed the v1 build.** Three
pieces hang together:

The §12 30/90 branch-hygiene timers ride on `HygieneScheduler` in
`backend/app/hygiene.py`, modeled on `DigestScheduler`'s shape. The
sweep runs hourly by default, exposes a `run_tick(now=...)` test
seam, and orders its queries delete-first so a single sweep crossing
both the 30d and 90d boundaries (the cache-bootstrap and clock-jump
case) lands the delete rather than spending one tick at `closed` with
a fresh `closed_at` that would defer the delete by another 90 days.
The §10.7 90-day deletion timer Slice 3 deferred is one branch of the
same sweep. The bot grew a `delete_branch` method that takes
`actor: Actor | None`; the timer paths pass `None` so the audit row
lands `actor_user_id = NULL` and `on_behalf_of = <bot login>` per
§15.9. No notifications fire on hygiene gestures (the action kinds
are absent from `_AUTO_WATCH_ACTIONS` and `_ROUTING`).

The §19.2 candidates folded in:

- **Cache bootstrap from a pre-existing meta repo** — the
  `_resolve_actor` fallback chain now has explicit integration
  coverage against history the bot did not author (audit-log row,
  trailer-parse, raw-login last-resort).
- **Branch-name path routing** — every `branches/<branch>` route is
  `{branch:path}` with the bare GET declared last among the
  branch-scoped GETs so deeper routes match first.
- **In-app merge for metadata PRs** — `api_prs._require_pr` accepts
  `pr_kind='meta_metadata'`; the existing merge endpoint handles them
  uniformly.
- **Graduation rollback's branch cleanup** —
  `api_graduation._undo_open_pr` deletes the
  `graduate-<slug>-<6hex>` branch after closing the PR.
- **Email bounce webhook authentication** — `WEBHOOK_EMAIL_BOUNCE_SECRET`
  is the signing seam; when set, the webhook requires the same value
  in `X-Webhook-Secret`.

The dev/prod deployment shape: `deploy/RUNBOOK.md` is rewritten from
the prior stub into a real runbook (prerequisites, first-time
bring-up, day-2 operations, rollback, troubleshooting); the README's
`.env` table grows the SMTP block, `HYGIENE_TICK_SECONDS`, and
`WEBHOOK_EMAIL_BOUNCE_SECRET`; the `.env.example` carries the same
fields with inline commentary.

Slice 8 ships covered by `test_hygiene_vertical.py` (seven cases),
`test_branch_path_routing.py` (four cases), `test_metadata_pr_merge.py`
(three cases), `test_cache_bootstrap.py` (four cases), and
`test_e2e_smoke.py` (three cases including the full lifecycle walk).
The full Slices 1–8 test suite is 96/96 green.

§12 grew an explicit note that the bot is the actuator and that the
per-user `branch_chat_seen` cursor survives branch deletion — the
§11.5 contract made implicit; running code asked for the load-bearing
line to live in §12 too. §10.7 grew a one-line pointer that the timer
rides on §12's sweep rather than its own schedule.

### 19.2 Candidate topics for sessions after the v1 build lands

These remain unsettled and will earn their own sessions as the
build surfaces evidence for which one matters next. Topics are
listed roughly in order of expected weight; the order is not
binding.

- **Per-RFC model availability — UX half.** *Settled in the
  post-v1 session that picked it. The meta-repo entry frontmatter
  now carries an optional `models:` list per §6.6; the resolution
  rule (absent inherits the operator universe, populated narrows
  the picker by intersection, `[]` opts the RFC out of AI
  entirely) is uniform across every AI surface — §8.12 chat
  picker, §8.9 / §8.11 change-proposal turns, §8.13 flag
  resolution, §10.2 PR draft, §9.1 tag suggestions. The §18
  `ENABLED_MODELS` env contract is reframed as the operator's
  universe of provisioned models; the per-RFC list picks from
  within it. The dedicated chrome for editing the field is
  downstream — clustered with the metadata-pane UX candidate
  below for super-drafts, and with whatever surface admins use
  for active-RFC frontmatter edits.*
- **Per-RFC credential delegation — funder role + grant shape.**
  *Settled in the post-v1 session that picked it. The meta-repo
  entry frontmatter grew an optional `funder:` field per §6.7,
  parallel in shape to `owners:` / `arbiters:` / `models:`. The
  frontmatter names the funder (RFC-side approval, via the meta-repo
  PR flow); a `funder_consents` app-db record records funder-side
  approval (the user opts in per-slug from `/settings/funder`); both
  halves are required for the binding to be operationally active.
  When in effect, the funder universe replaces — not augments — the
  operator universe for the RFC's picker. The funder role is purely
  credential-binding (no §6.3 / §6.1 authority granted). Three
  revocation paths each restore the operator-credentials status quo.
  The operational hardening — retry/fallback, mid-call failure,
  rotation, billing — lives on as the entry below.*
- **Per-RFC credential delegation — operational realities.** The
  heavier half of the original credential-delegation topic, split
  out when §6.7 settled the structural shape. What happens at
  runtime when funder credentials fail mid-conversation; whether
  failed funder calls fall back to operator credentials (and how
  that interacts with §6.7's attribution-clean rule); 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. Earns its session once a real
  multi-operator scenario surfaces and the structural §6.7 shape
  meets enough load to expose where the runtime path needs
  hardening.
- **Admin surfaces.** Where role management, muting, audit-log
  views, the graduation-readiness queue, and Topic 13's
  notification-preferences settings (email categories per §15.4,
  digest cadence per §15.5, quiet hours per §15.8, per-user mute
  list per §15.8, watch-state overview per §15.6) live in the
  chrome. Topics 12 and 13 both expanded the admin's repertoire
  without giving it a centralized home base; consolidating it is
  the natural next move once the build is on its feet.
- **The notification settings UI.** Topic 13 settled the schema
  and the per-category rules; the surface where a contributor
  finds the per-category email toggles, digest cadence, quiet
  hours config, watch states, and per-user mute list is the
  natural follow-on. Likely overlaps the admin-surfaces topic for
  admins and stands alone for contributors. Small-to-medium scope.
- **The conflict-replay UX in detail.** §10.9 commits to a
  resolution-branch path where the AI participant replays the diff
  onto fresh main, with manual fallback for ambiguous conflicts.
  The resolution chat surface, the manual-resolve gesture, and how
  the AI's replay attempts surface in the conversation are
  unspecified. Narrow and concrete; defer-able until conflicts
  happen often enough in real use to design against.
- **The pending-idea view's interaction design (remainder).** Topic
  12 settled the decline ceremony per §9.3 (two-step composer-
  then-preview dialog, "Propose a revised entry" affordance for
  the declined proposer). The merge-confirmation ceremony and the
  inline render of the meta-repo PR's diff against the catalog
  remain unspecified and could earn their own topic.
- **The metadata pane UX.** §9.5 lands the metadata pane as a
  structural commitment for super-draft title and tag edits. The
  interaction design — when the pane opens, how multiple edits
  queue into a single meta-repo PR, how AI assists tag editing —
  is its own small UI topic.
- **The public face of discuss mode.** §8.14 settles what a
  contributor sees in discuss mode on a branch with buffered
  proposals. An anonymous reader or muted contributor on the same
  branch is not yet specified — what they see of the pending
  count, the preview disclosure, and the `Start Contributing`
  invitation (which they can't act on) is its own small but
  distinct topic.
- **Super-draft slug renames.** §9.5 defers renames on the basis
  that a slug rename is a file rename in Git plus a cache-key
  rewrite, rare enough to skip in v1. If usage shows real demand
  — e.g., a super-draft whose framing shifted enough that the
  original slug misleads — this earns its own topic, settling the
  file-rename bot sequence, the redirect handling for any links
  into the old slug, and the cache and threads migration.
- **Persistent accepted-change markup for returning contributors.**
  §8.10 commits the editor's tracked-change markup to session-local
  scope via the preview-pane overlay (default-on in Contribute,
  toggle-gated in Discuss). A future session may revisit this with a
  per-user, per-branch seen-cursor for accepted changes (mirroring
  §10.3's PR seen-cursor) — markup persisting across reloads,
  dismissible with a "mark as seen" gesture. Triggered by evidence
  of contributors asking for it, not ahead of evidence.
- **AI participation as a notification source.** Topic 13 settled
  that user-driven events carry the underlying user as actor and
  system-generated events carry null. AI participant completions
  (long-running change generation in a thread, an `Ask Claude to
  propose a fix` invocation per §8.13 that returns minutes later)
  are a third case the section did not explicitly settle — the
  build will reveal whether they fire notifications at all,
  whether they carry a synthetic "Claude" actor or fall under
  null-system, and how the bot-vs-user distinction in §15.9
  extends. Will surface during build if AI turn-times grow large
  enough to warrant.
- **Cache bootstrap from a pre-existing meta repo.** *Settled in
  Slice 8 — the `_resolve_actor` fallback chain Slice 1 chose
  (audit log → `On-behalf-of:` trailer → raw Gitea login) now has
  explicit integration coverage in `test_cache_bootstrap.py`
  against history the bot did not author. The disaster-recovery
  and transferred-meta-repo cases work without the cache thinking
  the bot proposed everything pre-app.* §4.1 covers
  steady-state cache freshness — the webhook is the fast path, the
  reconciler the safety net — but assumes the cache grew up
  alongside the bot. If the cache is rebuilt from scratch against
  a meta repo that has history the bot did not author (a
  transferred meta repo, a disaster-recovery rebuild after the app
  database is lost), the reconciler has no `actions` rows to join
  against for the §15.9 underlying-actor-not-bot resolution. The
  Slice 1 build chose a fallback chain — audit log first, then
  `On-behalf-of:` trailer parsing on the commit/PR body, then the
  raw Gitea login — that is good enough for v1 but earns its own
  topic once the cost of "the cache thinks the bot proposed
  everything pre-app" becomes concrete. Touches §4.1 (the
  reconciler's job description) and §15.9 (the attribution rule).
- **Branch-name path routing.** *Settled in Slice 8 — every
  `branches/<branch>` route in `api_branches.py` and `api_prs.py` is
  now `{branch:path}`. The bare GET is declared last among the
  branch-scoped GETs so deeper routes (`threads`,
  `threads/{thread_id}/messages`) match before the greedy path
  matcher swallows their sub-paths. `test_branch_path_routing.py`
  covers the slashed-branch read and the deeper-route ordering.*
  Slice 2's `branches/<branch>`
  endpoints use FastAPI's default `{branch}` path-segment matcher,
  which refuses slashes. The Slice 2 auto-generated branch name
  steered around this with `<login>-draft-<hex>`, but a user who
  renames to a slashed name will 404 on read. The fix is to convert
  every `branches/<branch>` route to `{branch:path}` with the
  understanding that ordering matters (more-specific routes like
  `branches/main/promote-to-branch` must register first). Surfaced
  by Slice 2's testing; defer-able until a user actually wants a
  slashed branch name.
- **Markdown round-trip fidelity in the editor.** Slice 2's manual-
  flush converts the Tiptap document to text via `editor.getText()`,
  which discards markdown structure on round-trip (lists become
  flat lines, headings lose their `#`, links collapse to their text
  content). A faithful HTML-to-markdown serializer — or switching
  the on-disk format to a structured one the editor owns natively
  — earns its own session once usage shows where the loss bites.
  Touches §8.6 (commit unit) and §8.11 (the manual-edit card's
  diff fidelity).
- **The chat feed's per-thread filter affordances.** §8.12 commits a
  top-of-chat disclosure that lists open threads with anchor previews
  and per-thread filter affordances. Slice 2 wired the disclosure
  counts; the filter that collapses the feed down to a single
  thread, and the per-thread "Reply" affordance that posts back into
  a specific thread from the unified feed, are the natural follow-on.
  Small scope, defer-able until the feed grows busy enough to
  warrant.
- **Cross-branch source-message labelling.** §8.14's data-model rule
  permits a `changes` row whose `source_message_id` points at a
  message in main's chat — the row's `branch_name` was mutated from
  `main` to the new branch on promote-to-branch, but the message
  reference stays. Slice 2's frontend doesn't yet label these as
  "from a conversation on main" in the change panel; a small visual
  treatment is the natural follow-on. Surfaced by §8.14's data path
  going through Slice 2 for the first time.
- **The PR-page diff renderer.** Slice 3 ships a line-level
  unified/split diff between branch and main RFC.md bodies,
  computed client-side from the two strings via a small LCS pass.
  Sufficient for the single-file v1 surface, but the §10.3
  per-hunk seen-cursor accent — distinct from the file-level
  accent Slice 3 wires — and the inline `<change>`-block
  attribution from `changes.commit_sha` are the natural follow-on.
  Earns a topic when a contributor's PR carries enough changes
  that a reviewer wants to scope review to one hunk at a time.
  Touches §10.3 (the per-hunk accent voice) and §10.4 (anchoring
  review threads to specific hunks rather than free-text quotes).
- **The §10.2 modal's AI-drafted text when no provider is
  configured.** Slice 3 falls back to a deterministic stub
  (`Edits to <RFC title>` plus a character-count line) when the
  app has no LLM provider. The §6.6 settlement extends the same
  fallback to the case where the RFC's resolved model list is
  empty — the RFC has opted out of AI entirely. The fallback is
  functional but does not produce spec-voice text; improving the
  no-credential-available render remains its own future topic,
  defer-able until evidence shows the stub bites.
- **§10.9 replay AI participation.** Slice 3 implements the
  structural §10.9 path — fresh resolution branch off main, replay
  the accepted changes whose `original` text still locates exactly
  once, surface the rest as stale-pending changes on the
  resolution branch — but does not yet invoke the AI participant
  on the ambiguous conflicts to attempt a re-anchored proposal.
  The contributor re-anchors manually for now. The "AI runs
  against unambiguous conflicts" pass earns its own topic once
  conflicts happen often enough to design against; the §19.2
  "conflict-replay UX in detail" entry already names this.
- **PR title and description sync with Gitea.** Slice 3's
  `POST /api/rfcs/.../prs/<n>/description` updates the cache row
  but does not mirror the edit back to Gitea via the issues
  endpoint. The PR page is the canonical surface for v1 and the
  cache is its source of truth, so the divergence is fine within
  the app — but anyone reading the PR directly on Gitea sees the
  pre-edit text. A small follow-on that propagates the edit
  through the bot wrapper closes the loop.
- **The §10.7 90-day deletion timer wiring.** *Settled in Slice 8 —
  the timer rides on `HygieneScheduler`'s sweep alongside the §12
  30/90 idle timers. See §10.7 and §12 for the spec text and
  `backend/app/hygiene.py` for the implementation.* Slice 3 lands the
  PR-merged state and the read-only treatment but does not wire
  the §12 hygiene timer that fires the deletion. Slice 8
  ("Hardening") owns the §12 30/90 timers as a whole; calling out
  here so the dependency is explicit.
- **In-app merge for metadata PRs.** *Settled in Slice 8 —
  `api_prs._require_pr` now accepts `pr_kind='meta_metadata'`. The
  existing `prs/<n>/merge` endpoint handles them uniformly; the
  diff-rendered review surface degrades gracefully (a metadata PR
  has no body diff to render). `test_metadata_pr_merge.py`
  covers the merge, the contributor refusal, and the withdraw path.*
  Slice 4's metadata pane opens
  a meta-repo PR per §9.5; the merge surface for those PRs is the
  Gitea web UI for now, because `api_prs.py`'s merge endpoint is
  scoped to body-changing PRs (`rfc_branch` and `meta_body_edit`).
  A small follow-on adds a `prs/<n>/merge`-shaped path that handles
  `meta_metadata` kinds — likely a tiny variant since there's no
  diff-rendered review surface to inherit. Defer-able until usage
  shows admins finding the Gitea round-trip annoying.
- **Cache-rebuild discovery of meta-repo edit branches.** Slice 4's
  `refresh_meta_branches` scans every meta-repo branch and filters
  by name prefix (`edit-` / `edit/`) to discover super-draft edit
  branches. The reconciler hits this on every sweep, so it's
  cheap, but a dedicated index on `cached_branches.branch_name`
  would shorten the join-against-`cached_rfcs`-state for very
  large super-draft fleets. Trivial; defer until the cost shows up.
- **Graduation progress persistence across page reloads.** Slice 5's
  orchestrator holds the per-step state in a process-local dict
  keyed by slug, fed by an asyncio.Queue the §17 SSE drains. A page
  reload while the sequence is in flight loses the dialog but the
  sequence continues to completion on the server; the user can
  reopen the dialog and the snapshot event re-renders the current
  state (the in-memory entry persists until cleanup). What is not
  yet settled: how long to retain the entry after `finished=True`,
  whether to persist enough on `actions` to reconstruct the step
  stack from the audit log for a returning admin who missed the
  live stream, and whether the dialog should re-open automatically
  on a slug whose registry entry is still present. Touches §13.3
  (the rendered step stack's durability) and §15.2 (a graduation-
  in-progress signal as an inbox row would be a natural alternative
  surface for follow-along). Earns its own topic once the build
  hits cases where a single sequence runs long enough that the
  reload-during-graduation path matters.
- **Graduation PR auto-close on rollback's `close_graduation_pr`.**
  *Settled in Slice 8 — `api_graduation._undo_open_pr` now deletes
  the `graduate-<slug>-<6hex>` branch via `bot.delete_branch` after
  closing the PR. `test_hygiene_vertical.test_graduation_rollback_deletes_dash_suffixed_branch`
  covers it.*
  Slice 5's rollback closes the graduation PR via `gitea.close_pull`
  but leaves the dash-suffixed branch (`graduate-<slug>-<6hex>`)
  in place. The branch is short-lived in steady-state — graduation
  succeeds — but accumulated failed-graduation branches over time
  could clutter the meta repo's branch list. The §12 30/90 hygiene
  timers (Slice 8) would naturally sweep them, but a graduation-
  specific cleanup that deletes the branch on rollback would close
  the loop faster. Trivial to add when evidence shows the branches
  pile up.
- **The `_is_meta_target(rfc, branch)` dispatch helper.** Slice 5
  generalized the super-draft routing collapse (`_is_super_draft`
  alone) to also handle active-RFC reads against pre-graduation
  meta-repo branches per §9.8. The helper checks state plus a name-
  prefix list (`edit-`, `edit/`, `metadata-`, `metadata/`, `claim/`,
  `propose/`, `graduate-`). The prefix list is right for v1's
  surface, but a contributor renaming an active-RFC per-RFC branch
  to one of those prefixes would have reads route to the meta repo
  (and likely 404). The `_validate_branch_name` guard refuses
  reserved prefixes on creation, so the only way to reach this
  edge is a hand-renamed branch — defer-able until evidence shows
  it happens.
- **Test seam for synchronous graduation.** Slice 5's `?_sync=1`
  query param on `POST .../graduate` awaits the orchestrator inline
  so integration tests can assert post-conditions without driving
  the SSE. The seam is documented in code; it does not affect the
  production path. A cleaner long-term shape is to expose the
  orchestrator as importable for tests and remove the query-param
  branch from the route handler, but the current shape is the
  minimum that keeps the test surface terse without adding a
  separate test-only module.
- **Body full-text search.** When the time comes.
- **The §15.2 inbox grouping's per-RFC + per-event-kind bundle's
  represent-row choice.** Slice 6's bundle implementation collapses
  rows under the (rfc_slug, event_kind) key and picks the most-recent
  constituent as the representative. The §15.2 spec voice ("3 new
  commits on PR #4 / RFC-0042" as a single bundle row) names the
  count but not which representative's verb-phrase the bundle reads
  as. A future session may settle whether the bundle reads in the
  voice of the most-recent actor ("alice + 2 others added commits")
  or a structural verb ("3 new commits on …"), and how the bundle
  expands to its constituents (inline disclosure, modal, navigation).
  Defer-able until usage shows the per-row shape doesn't suffice.
- **AI participation as a notification source — confirmed.** §19.2
  already named this as a candidate; Slice 6 didn't settle it. The
  build chose null-system for AI-generated content for now (no
  `actor_user_id` since the LLM call has no user row), but the
  §15.9 framing of "the system did not invent attribution" reads
  cleanly only for genuinely unattributed events (auto-close,
  digest emission). An AI-authored chat reply produces a chat
  message and could fire a chat_message_in_participated_thread
  signal to other thread participants, with the actor reading as
  "the AI participant" — a candidate distinct entity. Touches
  §15.9 (the actor slot in inbox prose), §8.12 (the AI participant's
  authored-message shape), and the §19.2 per-RFC model availability
  topic (which AI participant is the right noun for a row coming
  out of that RFC?).
- **Inbox row prose for null-actor events.** Slice 6 renders
  null-actor rows with the literal noun "the app" per §15.9's
  "absence of an actor is the honest signal" framing. The phrase
  works for some events (the digest emission email body) but
  reads awkwardly for others ("the app started a resolution
  branch"). A future session may settle a per-event-kind null-
  actor verb form so each row reads naturally without picking up
  an apparent personification. Defer-able until contributor
  feedback surfaces an irritating render.
- **Email bounce webhook authentication.** *Settled in Slice 8 — the
  `WEBHOOK_EMAIL_BOUNCE_SECRET` env var is the shared-secret seam.
  When set, the webhook requires the same value in the
  `X-Webhook-Secret` header; when unset, the v1 unauthenticated
  behavior holds for dev. The per-provider signature scheme
  (Sendgrid signed events, SES SNS signatures, Postmark HMAC) maps
  cleanly onto the same seam: the operator picks one when wiring a
  real SMTP provider.*
  Slice 6's
  `/api/webhooks/email-bounce` accepts unauthenticated POSTs for
  v1 — the SMTP provider's callback URL is the contract. When an
  actual provider is wired in, the webhook needs a shared secret
  or signature verification (Sendgrid's signed events, AWS SES's
  SNS topic signature, etc.). Trivial to add per provider; the
  routing-and-flip-the-column logic doesn't change.
- **Owner succession ceremony.** Slice 7's `POST /api/admin/users/<id>/role`
  refuses self-demotion ("Use the explicit succession path to change
  your own role") because owner-zero is the only owner bootstrap path
  per §6.1 and a careless self-downgrade could orphan the role. The
  explicit succession path — how an owner steps down, whether owner-
  zero needs a co-owner present, how the `OWNER_GITEA_LOGIN` env var
  relates to the seated-owner set after the bootstrap moment — is the
  natural follow-on once a real owner-transition scenario shows up.
  Touches §6.1 (the owner-role bootstrap rule), §17 (the admin role
  endpoint), and possibly §3.1 (state-transition shape if owner
  changes are themselves a tracked transition).
- **Mute-from-actor on inbox rows and chat messages.** Slice 7's
  notification-settings page exposes the per-user mute list with an
  unmute affordance, and an intentionally clumsy typeahead for the
  add path. The natural add path — clicking the actor on an inbox
  row or a chat message — is the §19.2 candidate of its own this
  slice was always going to surface. Touches §15.8 (the mute add
  ergonomics), §15.2 (the inbox row's actor slot), and §8.12 (the
  chat message's author chip). Small scope, defer-able until the
  typeahead-only path proves annoying.
- **The "Following since &lt;date&gt;" disclosure on the RFC view header.**
  §15.6 commits this disclosure on the RFC view's header after an
  auto-decay. Slice 7 lands the watches overview at
  `/settings/notifications` — the centralized read — and defers the
  per-RFC chrome to a later session, since the disclosure earns its
  surface only once auto-decay has actually fired against a watch in
  production use. Touches §15.6 (the decay timer's UX) and §8.1 (the
  RFC view's header strip).
- **Audit-log row prose translation.** Slice 7's `/admin → Audit log`
  surface renders `action_kind` as the raw enum (`merge_branch_pr`,
  `propose_rfc`, `graduate_step_complete`, etc.) inside a `<code>`
  span. Admins know the kinds; a contributor-facing surface — if one
  ever earns its place — would need a per-kind English render
  ("merged the body-edit PR", "proposed RFC", "completed the
  repo-create step of graduation"). The translation table lives near
  `notify.SUMMARY` per §15.9 — a future session can lift the same
  surface to the admin view if usage shows reviewers want it.
  Defer-able until evidence of demand. Touches §6.5 (the audit-log
  surface) and §17 (the audit endpoint's prose shape).
- **Per-user mute exemption checks for arbiters.** §15.8 commits
  that arbiters cannot mute participants on RFCs where they hold
  authority. Slice 6's check uses "the muted user has a watches
  row on the same RFC where the muter is an arbiter" as the
  participation proxy. The spec doesn't define "active" precisely
  for this check; the watches-row proxy is generous (a user with a
  read-only relationship counts as active). A future session may
  settle a tighter definition (e.g., has any `actions` row on the
  RFC) if the generous proxy refuses too many legitimate mutes.
- **Health-check endpoint for ops tooling.** *Settled in the
  post-v1 session that picked it. The flotilla deploy control panel
  (a sibling operator-side tool spec'd in flotilla/SPEC.md) needs a
  small framework-side endpoint to verify a deploy landed correctly.
  Specifically: an unauthenticated `GET /api/health` returning JSON
  `{version, status}` where `version` is the running framework
  version recorded at startup and `status` is `"ok"` (HTTP 200) or
  `"degraded"` (HTTP 503). flotilla uses the endpoint as a
  post-flight probe — after `systemctl restart` reports active,
  flotilla polls the endpoint and verifies the returned `version`
  matches the tag just deployed. The version-match check is the
  structural catch for the failure mode where a restart did not
  actually pick up the new code. Scope intentionally small: one
  endpoint, version + status payload, no auth (no PII), ships in
  a patch release as a §17 addition. The endpoint becomes part of
  §20.3's versioned surface and is available to any deployment
  without operator action — flotilla is one consumer of many
  possible. Earns its session next: flotilla v1 is blocked on this
  dependency.*

  *Settled in this session as follows. The running process reads
  the `VERSION` file at the repo root at import time and caches the
  string as a module-level constant (`backend/app/health.py`),
  mirroring `backend/app/philosophy.py`'s disk-first shape. The
  §20.1 invariant guarantees `VERSION` and
  `frontend/package.json#version` are equal, so the choice is
  cleanliness rather than correctness: `VERSION` is the canonical
  source per §20.1, lives at the repo root, is one text line. A
  missing `VERSION` file fails loudly per §20.6 — the module raises
  at import time rather than serving a placeholder, because the
  whole point of the endpoint is the version-match check and a
  silent default would defeat it. `"degraded"` is reserved scaffold
  for v1: a healthy startup always reports `"ok"` with HTTP 200,
  and the `"degraded"` / 503 path stays in the response shape so a
  later release can wire real degradation conditions (reconciler
  stuck, migrations pending, provider universe empty) without
  breaking flotilla's parser. Probing SQLite at request time is
  out of scope — per §4.2 the DB is colocated, so a process that
  can respond can reach it; the check would add code for no
  signal. The §20.4 CHANGELOG treatment is the patch shape — no
  operator action required — with an `### Added` section naming
  the endpoint so operators discover it and one MAY-language step
  ("operators MAY configure their monitoring to probe `/api/health`;
  the endpoint is unauthenticated by design"). §17 now lists the
  endpoint in its illustrative table.*
- **PR-less discussion: range and paragraph anchors.** v0.5.0 lands
  the structural discussion surface (§10.10) but constrains every
  PR-less thread to `anchor_kind='whole-doc'` — the data model permits
  `range` and `paragraph` anchors (§5) but the UI work to surface a
  passage-anchored thread on a non-branch view is the deferred half.
  The natural follow-on is a margin-icon affordance on the main view
  matching §8.12's branch-side surface, with the anchor stored on the
  null-branch thread. Earns its session when discussion volume warrants
  the precision; v0.5.0's flat surface is sufficient for most "have we
  considered…?" gestures. Touches §10.10 and §8.12.
- **PR-less discussion: distinct notification event_kinds.** v0.5.0
  routes per-RFC discussion messages through the existing
  `chat_message_in_participated_thread` and `chat_reply_to_my_message`
  event kinds with `branch_name=null` on the fan-out row. The inbox
  prose reads identically whether the chat lives on a branch or on the
  RFC's discussion surface, which is honest signal: the conversation
  shape is the same; only the scope differs. A future session may
  introduce `open_rfc_discussion_thread` / `post_rfc_discussion_message`
  if evidence shows contributors want to filter discussion-vs-branch
  chat distinctly in the §15.2 inbox. Touches §15.1 (the event_kind
  enum), §15.2 (the inbox filter chips), and §10.10.
- **PR-less discussion: chat-seen cursor.** §15.7 commits the
  `branch_chat_seen` cursor for branch-scoped chat. The PR-less
  discussion surface has no equivalent cursor in v0.5.0; the inbox
  reconciler's keying on `(rfc_slug, branch_name)` does match a
  null-branch advance, but no write path advances it. A natural
  follow-on is a sibling table — `rfc_discussion_seen` or a
  null-branch row on `branch_chat_seen` — that the discussion panel
  advances on read, closing the §15.7 reconciliation loop for
  discussion-surface notifications. Defer-able until inbox volume on
  the new surface shows it matters.
- **PR-less discussion: AI participation.** v0.5.0's discussion
  surface is human-only — no AI participant invocation, no `<change>`
  block parsing, no per-thread model picker. The branch chat (§8.12)
  retains the §18 AI surface. The natural follow-on is wiring the AI
  participant into discussion threads (the model picker, the
  `Ask Claude` button on a selection tooltip) without enabling
  document edits — a discussion-only AI turn produces only chat
  content, no `changes` row, no commit. Contribution still requires a
  PR; the AI's discussion-side help is just better prompts. Touches
  §10.10, §8.12, and §18.
- **PR-less discussion: anonymous read polish.** v0.5.0 inherits the
  v0.3.0 anonymous-read contract — anyone can read; only signed-in
  contributors can write. The composer affordance for anonymous
  viewers ("Sign in to comment.") matches the existing read-only-bar
  treatment but the surface has not yet been audited for the v0.6.0
  hardening that tightens write gates app-wide. The §19.2 "public
  face of discuss mode" entry overlaps; this entry is its discussion-
  surface variant.
- **Deployment-supplied subject framing.** The framework was built
  with one deployment in mind (OHM, standardizing natural-language
  vocabulary), but the substrate generalizes to any domain that
  benefits from collaborative, transcript-evidenced standardization:
  medicine (a public, model-assisted process for revising the DSM's
  diagnostic criteria), law (model statutes, regulatory definitions,
  or clause libraries), bioethics, biosecurity (vaccine protocols),
  literature (canonical-edition standardization), software (library
  API contracts written in prose), policy (municipal ordinance
  templates), education (curriculum standards). Several surfaces in
  the framework still bake OHM's subject in:
  `backend/app/chat.py` SYSTEM_PROMPT names the framework "a
  standardization process for natural-language vocabulary" and tells
  the model the transcript is "the durable evidence the definition
  was earned"; `frontend/src/components/Landing.jsx` pitches the
  framework as "an open dictionary of the words humans and machines
  need to agree on" with deck items asserting "One word per RFC";
  `frontend/src/components/ProposeModal.jsx` field-help says "The
  word or topic this RFC will define"; the default
  `PHILOSOPHY.md` argues for the framework entirely in
  natural-language-vocabulary terms. A different deployment — one
  whose RFCs define vaccines, literary works, protocols, or
  anything else — would have the AI mis-modeling the domain on
  every turn and the user-facing copy claiming the wrong thing.
  The settling session lands the framework primitive that's
  missing: a deployment-supplied "subject" (probably
  `VITE_RFC_SUBJECT_SINGULAR` and `VITE_RFC_SUBJECT_PLURAL` for
  user-facing strings, plus a backend `RFC_DOMAIN_FRAMING` string
  injected into the SYSTEM_PROMPT in place of the hardcoded
  vocabulary clause) and lifts the OHM-specific Landing copy /
  PHILOSOPHY content out of the framework into deployment config
  (probably a `landing.json` the deployment provides and a
  `PHILOSOPHY_PATH` override the deployment supplies). Touches the
  opening of this spec (already noted: lines around §3 and §16
  still reference OHM directly), §1 (which currently names "Open
  Human Model" indirectly via `PHILOSOPHY.md`), §14.1 (the landing
  page), and §18 (the chat surface). Goes out as the 0.3.0 release
  with explicit MUST steps for OHM to set the new env vars and
  supply its own Landing / PHILOSOPHY content. Worth its own
  session; the analysis is in this candidate so the session can
  open with design rather than discovery.

Topic 13 (notifications) is settled and folded into §5 (the
notifications, watches, branch_chat_seen, notification_user_mutes,
notification_digests tables and the per-user notification
preference columns on users), §6.2 (the orthogonality clarification
against §15.8's notification mutes), §7.2 (the per-row catalog dot
for watched RFCs with unseen activity), §9.3 (the personal-direct
email channel for proposal-merged and proposal-declined events),
the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).

First-OTC profile capture (formerly a v0.7.0 candidate) is settled
and folded into §6.1 (the contributor role now requires
`permission_state='granted'`), §6.2 (the orthogonality of
permission_state vs role / muted / notification-mutes), §14.1
(the landing page's v0.8.0 first-OTC capture step), and §17
(the `POST /api/auth/me/beta-request` endpoint and the verify
endpoint's new `needs_profile` flag). The structural decision
landed as: capture is a third step on the `/login` surface
gated by `verify_response.needs_profile=true`; pending users
land on `/beta-pending` after submitting and see a thin banner
on every other page until an admin grants. v0.8.0 (roadmap item
#6) shipped the work.

Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):

- **Admin user-management page** (`/admin/users`). *Shipped in
  v0.9.0 (roadmap item #7).* The listing surfaces every user with
  permission_state, profile fields, sign-up reason, and a Grant /
  Revoke control set; the `POST /api/admin/users/<id>/permission`
  endpoint flips the column and writes a `permission_events` row.
  v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
  merging (see allowlist deprecation below). The grant/revoke
  notify-the-user surface is deferred (see the new candidate
  below).
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
  v0.9.0 considered merging `/admin/allowlist` into the new
  `/admin/users` page but kept the surface as a sibling sub-tab:
  the two have different keys (allowlist by email pre-sign-up,
  user list by user_id post-sign-up) and a union row would be
  confusing rather than clarifying. The fast-path-bypass role
  the allowlist has carried since v0.8.0 stays intact; the
  cutover to retire the table outright is a later session.
  Decision points unchanged from v0.8.0: drop the table outright
  (a schema migration) or leave it as a non-functional surface
  and remove only the UI (a frontend-only change); how to handle
  existing `allowed_emails` rows at the cutover (probably: walk
  them into the pending queue with `permission_state='granted'`
  for any matching `users` row, leave unmatched rows as a no-op
  since v0.8.0 doesn't consult them anymore). Earns its session
  once the v0.9.0 admin queue has run long enough to confirm the
  allowlist's bypass role is no longer pulling weight.
- **Admin notification on new beta request.** *Shipped in v0.9.0
  (roadmap item #7).* The `POST /api/auth/me/beta-request`
  handler now calls `notify.fan_out_new_beta_request`, which
  fans a `new_beta_request` event (category `admin-actionable`,
  rfc_slug NULL) out to every owner / admin. The §15 chokepoint
  handles the SSE broadcast and the §15.4 email dispatch; the
  email reaches only recipients whose `email_admin_actionable`
  toggle is on (the default for owners + admins).
- **Grant / revoke notification to the user.** *Surfaced by
  v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
  writes a `permission_events` row but does not yet signal the
  affected user that their state changed. A future release could
  fire a `personal-direct` notification (event_kind
  `permission_change_affecting_me`, already in the §15.1 enum) so
  a granted user sees "Your beta-access request was approved" in
  their inbox and email, and a revoked user sees a parallel
  refusal notice. Decision points: does revocation include a
  reason field (probably yes — symmetric with §9.3's decline
  comment); does grant carry a welcome message (probably no —
  the existing welcome surfaces are sufficient); does the
  notification escape the §15.8 mute path (probably yes — it's
  a personal-direct admission state change). Earns its session
  as a follow-up to the v0.9.0 page.
- **Decline-with-reason on permission revoke.** *Surfaced by
  v0.9.0.* The current Revoke gesture takes only a confirmation;
  there is no audit-visible reason captured. A future release
  could add a free-text reason input that lands in the
  `permission_events.details` JSON column (no schema change
  needed — the column is already JSON-shaped). This is the
  symmetric companion to the §9.3 proposal-decline contract.
  Earns its session alongside the grant/revoke notification
  candidate above.
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
  v0.7.0 keeps `/auth/callback` functional and links to it as a
  "Sign in with Gitea (fallback)" affordance on the new `/login`
  surface, so users with active OAuth sessions or older invite
  paths still have a way in during the migration window. A later
  release retires the route entirely. Decision points: how do we
  know "every active user has signed in via OTC at least once"
  (probably: a `users.otc_first_signed_in_at` timestamp added in
  v0.7.x and a query that confirms 100% population), how do we
  handle users who never come back (probably: silently leave them
  with stale rows; OAuth callback returning 404 is a sufficient
  message), and whether the `/auth/login` and `/auth/callback`
  routes get a tombstone redirect to `/login` or just 404. Earns
  its session once the OTC adoption curve flattens.
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
  item #9). The shape: a distinct `rfc_device_trust` cookie
  (HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
  server-issued opaque token, keyed against a `device_trust` table
  whose rows store the bcrypt hash. `POST /auth/device-trust/start`
  resolves a presented cookie at next visit. A
  `/settings/notifications` "Trusted devices" section lists active
  rows with per-row + bulk revoke. The trust outlives a sign-out
  (sign-out clears the session cookie, not the device-trust
  cookie) and is not affected by passcode set/change/clear — the
  next two items below carry the remaining open questions.*
- **Cross-device session revocation surface.** v0.11.0's
  `/settings/notifications → Trusted devices` revokes the
  long-lived device-trust grants. What it does NOT revoke is an
  active session cookie sitting in another browser, or the
  v0.10.0 passcode-failure-counter shape, or a stale
  password-equivalent that some future release ships. The natural
  next step is a single "active sessions and devices" surface
  that lists everything currently authenticating as this user —
  device-trust rows + active session cookies (if/when the
  framework moves to server-side sessions) + future credential
  shapes — and lets the user kill any of them with one gesture.
  Earns its session when a second cross-cutting concern lands
  (the most likely first trigger: future Yubikey / WebAuthn
  support, which surfaces another credential to revoke).
- **Password-equivalent change invalidates device trust.** v0.11.0
  intentionally leaves device-trust rows live across a passcode
  set / change / clear. The argument is structural: the user has
  the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
  fresh sign-in path via OTC, so the cookie is not a high-value
  bypass relative to the keys-to-the-account a passcode change
  signals. The argument against is the conventional "changing a
  password should kill every active session" expectation users
  bring from other systems. This earns its own session once the
  evidence is in: either a security-review finding that says
  "this is the wrong default," or user feedback that says "I
  expected my old laptop to sign out when I changed my passcode."
- **Device-trust window tunables via env.** v0.11.0 hard-codes
  the 30-day window in `backend/app/device_trust.py`
  (`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var
  (`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring
  follows the same pattern as the v0.10.0 passcode-lockout
  hard-coding — name the tunable when a deployment wants it
  different rather than shipping a knob that has no operator
  asking for it.
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
  v0.12.0 (roadmap item #10).* The OTC request endpoint is now
  gated behind a Turnstile siteverify call: the frontend renders
  the official widget on the `/login` email-entry step (and on the
  passcode step for the "Use a code instead" fallback dispatch),
  the captured token rides in the request body as
  `turnstile_token`, and the backend POSTs `secret` + `response`
  to `challenges.cloudflare.com/turnstile/v0/siteverify` before
  the bcrypt hash + SMTP send. The widget renders only on the
  email-entry / passcode-fallback dispatch points — the OTC
  verify step is bottlenecked on email delivery and protected by
  the five-minute TTL + single-use row consume, so a second
  challenge there would double the rate budget against the same
  abuse path without measurably more protection (revisit if bots
  adapt to the email-entry challenge specifically). Configured
  via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
  `VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
  third var `TURNSTILE_REQUIRED` (default `false`) lets the
  operator flip from "soft-fail when secret unset" (the dev /
  pre-rollout shape) to "fail-closed when secret unset" (HTTP 500
  "auth misconfigured", the production-locked shape). Tests mock
  the siteverify HTTP call at the `httpx.post` boundary in
  `app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in
  the v0.7.0 surfacing are still viable substitutes for a future
  deployment that wants them but the framework's tested path is
  Turnstile.

Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
roadmap item #8):

- **Passcode policy tunables via env.** v0.10.0 hard-codes the
  lockout shape (5 consecutive failures → 15-minute lockout) and
  the min/max passcode length (4 / 20) in
  `backend/app/passcode.py`. The denylist of obvious patterns is
  also hard-coded. A deployment that wants tighter or looser rules
  has to fork the constants. Two env vars
  (`PASSCODE_LOCKOUT_AFTER_ATTEMPTS`,
  `PASSCODE_LOCKOUT_DURATION_MINUTES`) would let operators
  reshape the lockout without forking; a third
  (`PASSCODE_MIN_LENGTH`) would cover the length floor. Earns its
  session if a deployment surfaces evidence that the v1 defaults
  bite.
- **Per-IP rate-limiting on `/auth/passcode/verify`.** v0.10.0's
  lockout is per-account: 5 failures against the same email lock
  that account for 15 minutes. A distributed attacker that knows
  many emails can fan out across them without ever tripping any
  one account's lockout. Adding a per-IP throttle (e.g., 30
  passcode-verify attempts / minute / IP, returning HTTP 429) is
  the natural pairing. Defer-able — the per-account lockout is
  the v1 shape that closes the loud-loop case; the per-IP
  distributed case waits on evidence. Touches §6.2 and §17.
- **Passcode-change "still know your old passcode" challenge.**
  v0.10.0 lets a signed-in user replace their passcode from
  `/settings/notifications` without re-entering the old one — the
  session is sufficient. A future hardening pass may require the
  old passcode (or a fresh OTC verify) before accepting the
  change, to mitigate session-hijack scenarios where the attacker
  rotates the passcode to lock the legitimate owner out. The same
  question applies to the clear gesture. Earns its session if
  session-hijack becomes a real threat surface.
- **Passkey / WebAuthn.** A much heavier next step than passcodes:
  hardware-backed device credentials that resist phishing. The
  v0.10.0 passcode shape is a stopgap for the "I'd rather not
  type a code every time" ergonomic problem; passkeys are the
  long-term answer. Out of scope for the current roadmap —
  earns a dedicated session if/when the deployment grows enough
  that the phishing surface justifies the integration cost.

Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
and §14.6):

- **Policy content via content-repo file vs env var.** v0.13.0
  shipped the deployment-policy-override path as two env vars
  (`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the
  framework's stub pages link out to. The alternative — accepting
  a markdown file path the framework renders inline, parallel to
  `PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose:
  a deployment could carry both an inline file (rendered above
  the fold) and an external link (rendered below). Earns its own
  topic when a real deployment ships a policy long enough that
  the link-out shape bites and renders the link unread.
- **Global Privacy Control / Do-Not-Track headers.** v0.13.0
  scoped the consent surface to the in-app banner and did not
  honor browser-side GPC or DNT signals. The framework's stance
  is that the in-app banner is the authoritative gesture — a
  user who clears their consent in the banner has expressed
  intent, and the GPC header is a coarser signal layered on top.
  Earns its own topic if a regulatory regime emerges that treats
  GPC as the legally-binding gesture, in which case the framework
  would honor GPC as an automatic "essential only" choice unless
  the user explicitly broadened it in-app.
- **Multi-language consent text.** The banner ships English-only.
  i18n of the framework's user-facing strings is a broader topic
  than the consent banner; carrying the work in that future topic
  rather than as a per-surface translation pass.
- **Analytics SDK gating against `consent.js`.** Roadmap item #13
  (target v0.15.0) lands the analytics SDK behind
  `lib/consent.js`'s `getConsent().analytics` gate. The framework
  contract is already in place; the SDK integration is the work
  the item ships. Listed here so the dependency is documented.

### 19.3 Working agreement for the queue

Pre-build sessions ran on the queue agreement from prior versions
of this section: pick the next topic from §19.1, drive it to
decision, fold it in, update §19.1 and §19.2, hand off. The build
session and any sessions after it run on a modified shape:

1. The build session implements a slice of the spec. The spec is
   the source of truth; the build does not extend it.
2. Where running code reveals the spec was wrong, the build
   session corrects the spec in the appropriate numbered section
   with a brief note explaining what running code revealed.
3. New design questions surfaced during the build that are not
   resolvable as implementation details accumulate in §19.2 as
   new candidate topics, in the same shape as the existing
   entries.
4. Sessions after the build pick from §19.2 by user choice, run
   on the original queue agreement (drive to decision, fold,
   update §19.2), and need not be sequential — multiple §19.2
   topics can be settled in any order the user prefers.

## 20. Versioning and downstream deployments

The framework is intended to host multiple deployments — separate
instances run by separate operators, each with its own brand, content,
and configuration. The Open Human Model corpus referenced in the
opening of this document is one such deployment; nothing in the
framework code, schema, or surfaces should depend on a particular
deployment existing. Code that bakes in a deployment-specific value
is a bug to fix in the framework: make the value configurable, ship a
new version, and let each deployment supply its own.

This section sets the contract by which downstream deployments depend
on the framework: how the framework is versioned, what each kind of
version change promises and breaks, and how a deployment upgrades.
The practical recipe — files to create, scripts to run, where the pin
lives — is in [`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md), which
deployments treat as the operating manual; this section is the
binding policy the manual implements.

### 20.1 Canonical version

The framework's current version is the contents of the `VERSION` file
at the repo root, written as a SemVer string with no leading `v`. The
`VERSION` file is the single source of truth; everything else mirrors
it. In particular, `frontend/package.json#version` is kept in sync
with `VERSION` on every release. A release where the two diverge is a
spec-level bug.

### 20.2 SemVer rules

Versions follow [Semantic Versioning](https://semver.org/) as
`MAJOR.MINOR.PATCH`. The framework is pre-1.0 until the first stable
cut; while pre-1.0, the rules below apply with one concession:
breaking changes are allowed at minor-version increments, and the
changelog (§20.4) carries the upgrade burden instead of the version
number.

A **patch** bump (`0.x.0 → 0.x.1`) is reserved for bug fixes that
require no action from any downstream deployment beyond rebuilding
and redeploying. No new env vars; no schema changes; no API surface
changes; no behavior changes that a deployment would notice in a
meaningful way. If an operator can apply the new version without
reading the changelog and have everything keep working, it is a
patch.

A **minor** bump (`0.x.y → 0.(x+1).0`) carries either backward-
compatible additions (new endpoints, new optional config, new
features that default to off or to the prior behavior) or, while
pre-1.0, breaking changes that the changelog documents explicit
upgrade steps for. Once the framework reaches 1.0, breaking changes
move to the major lane and minor bumps regain their strict additive
meaning.

A **major** bump (`0.x.y → 1.0.0`, then `1.0.0 → 2.0.0`) marks the
first stable cut and, afterwards, releases that break a deployed
instance unless its operator takes action. Major bumps always pair
with an upgrade-steps block in the changelog and may pair with
migration scripts checked into `scripts/`.

### 20.3 What is in the framework's surface

The version line covers any aspect of the framework that a downstream
deployment can observe or depend on. Concretely: the schema in `§5`,
the HTTP surface in `§17`, the backend env contract (the variables
documented in `backend/.env.example` and their semantics), the
frontend build contract (the variables documented in
`frontend/.env.example` and how they reach the bundle), the §6 / §9 /
§10 / §13 lifecycle behaviors a Gitea-watching operator would notice,
and the on-disk shapes the framework writes into a deployment's Git
repositories (RFC.md, README.md, `.rfc/metadata.yaml`, the entry
frontmatter per §2).

Things outside the version line: documentation phrasing, internal
refactors that do not change observable behavior, test
reorganization, comments, and dev-only tooling. Changes confined to
those areas may ship without a version bump and without a changelog
entry.

### 20.4 Changelog discipline

Every release adds an entry to `CHANGELOG.md`, in the format the
existing entries demonstrate: a header line with version and date, a
narrative summary of what changed, and — for any release that
requires deployment action — an explicit "upgrade steps" block
listing the actions in order.

Upgrade-steps blocks **shall** be written using the RFC 2119 / RFC
8174 normative-language convention, with the keywords interpreted as
defined in those RFCs. In the context of an upgrade from version `N`
to version `N+1`:

- A step worded with **MUST**, **SHALL**, or **REQUIRED** is an
  action without which the deployment will not function correctly on
  the new version. Skipping such a step is a regression the framework
  does not commit to handling. Building or starting the deployment
  after skipping a MUST step may fail loudly (which is the framework
  obligation in §20.6); it may also fail subtly later. Either way,
  the deployment is out of contract until the step is performed.
- A step worded with **MUST NOT** or **SHALL NOT** is an action that
  was previously valid but is no longer supported. Continuing to do
  it after the upgrade is a regression the framework does not commit
  to handling.
- A step worded with **SHOULD** or **RECOMMENDED** is the framework's
  recommended action, taken in testing and assumed in documentation.
  A deployment may deviate when it has a reason, but accepts that
  framework behavior is defined against the recommended path; non-
  recommended choices may interact poorly with later releases.
- A step worded with **SHOULD NOT** or **NOT RECOMMENDED** is the
  inverse: actions the framework discourages without forbidding.
- A step worded with **MAY** or **OPTIONAL** is an affordance the
  release exposes. A deployment can take it or skip it; both are
  equally supported, and the framework's behavior does not depend on
  the choice.

This convention is binding because it lets each adjacent version-step
be unambiguous in isolation. Cross-version upgrades — moving a
deployment from version `A` to version `B` where `B > A + 1` — **are
not pre-computed**; instead, the operator (or future tooling)
composes the upgrade by concatenating the MUST / SHOULD / MAY steps
from each adjacent release in the range `(A, B]` in order, applying
them as a single plan. Because each adjacent step is locally
unambiguous, the composition is mechanical; the framework's
obligation is to keep each adjacent step's language precise, not to
maintain a table of every A-to-B path.

If an upgrade step touches the deployment's environment (new env var,
changed default, removed var), the corresponding `*.env.example` file
**must** be updated in the same release so the contract and the
documentation land together.

### 20.5 The deployment pin

Each downstream deployment pins to a specific framework version. The
pin lives in the deployment's own repo as a single-line file named
`.rfc-app-version`, containing the SemVer string the deployment is
currently built against. The pin is the deployment's promise to its
own users about what behavior is in flight; bumping it is a
deliberate operator action that pairs with applying any upgrade
steps the new version requires.

When an operator decides to upgrade, the sequence is: read
`CHANGELOG.md` for every version between the current pin and the
target; apply each release's upgrade steps in order against the
deployment's environment; build the framework at the target version;
deploy; bump `.rfc-app-version` in the deployment repo to the target.

### 20.6 The framework's obligations to deployments

The framework, in exchange for being the substrate, commits to:

- not bake deployment-specific values into source — every per-
  deployment value goes behind config the deployment supplies;
- ship a loud failure path (build fails, server refuses to start,
  visibly missing UI) when a deployment forgets to supply a required
  value, rather than a silent default that ships the wrong thing;
- keep the changelog honest about what each release breaks and how to
  fix it;
- not regress a deployment that follows the upgrade-steps faithfully.

### 20.7 The deployment's obligations to the framework

Downstream deployments, in exchange for the contract above, commit to:

- pin to a specific framework version (the `.rfc-app-version` file);
- not fork the framework to embed deployment-specific behavior — if a
  deployment needs the framework to change, the change goes upstream,
  the framework ships a new version, and the deployment upgrades;
- read the changelog before bumping, and apply upgrade steps in
  order;
- supply every required env var the framework documents at the
  version they are running.

---

## 21. Analytics instrumentation and identity

The framework ships an Amplitude Analytics + Session Replay wrapper
in v0.15.0 (`frontend/src/lib/analytics.js`), gated by the v0.13.0
cookie/privacy consent surface (`frontend/src/lib/consent.js`,
§14.5). This section codifies the conventions that keep the
instrumentation **quality** healthy as features land — taxonomy
shape, autocapture hygiene, replay masking, the consent contract,
and the identity lifecycle. The conventions are framework-neutral:
every deployment of rfc-app that turns on the wrapper inherits
them.

This chapter is placed semantically after §15 (Notifications) and
§16 (deliberately deferred) as a peer cross-cutting framework
concern. It was added after §20 in the chapter sequence to avoid
renumbering the deferred-decisions surface §19.2, which is a
load-bearing project noun referenced across CLAUDE.md, transcripts,
and prior commits.

### 21.1 Event-taxonomy conventions

Events live in the public `EVENTS` constant in
`frontend/src/lib/analytics.js`. Callers **SHOULD** use one of the
named constants rather than firing arbitrary event strings — that
keeps the Amplitude dashboard coherent over time and makes the
taxonomy reviewable as a single source of truth.

- **Name form: Title Case, "Subject Verb".** E.g.
  `Comment Posted`, `Invitation Sent`, `RFC Viewed`,
  `User Signed In`, `Admin Permission Decision`. Spaces between
  words, no punctuation, no leading verbs (use `RFC Proposed`,
  not `Propose RFC`). The strings match the Amplitude dashboard
  names exactly.
- **Stability.** New events **SHOULD** land via a release, not
  ad-hoc — adding an entry to `EVENTS` is a CHANGELOG-worthy
  change because it widens the framework's observable surface
  (§20.3). Renaming an event after it has shipped breaks the
  dashboard's historical continuity; renames **SHOULD** be
  treated as a deprecation cycle (ship both, dashboard-migrate,
  drop the old one).
- **Opaque ids only in prop values.** Properties **MUST NOT**
  carry PII — no email, no display name, no IP, no free-text
  field bodies (titles, comment text, RFC drafts). Properties
  **SHOULD** be limited to:
  - opaque ids: `rfc_slug`, `rfc_id`, `pr_number`,
    `target_user_id`, `invited_by_admin_id`, `thread_id`,
    `comment_id`;
  - enums (lowercased): `method: 'otc' | 'passcode' |
    'device-trust' | 'admin-invite' | 'rfc-invite'`;
  - booleans: `trust_device`, `needs_passcode`, `passcode_set`;
  - timestamps (ISO 8601);
  - small bounded integers: `custom_message_chars` (coarse-grained
    signal of admin effort, NOT the message text itself).
- **Casing consistency.** Prop keys use `snake_case` (matches the
  backend's JSON shape). Enum values use lowercase with hyphens
  (`'rfc-invite'`, not `'rfcInvite'` or `'RFC_INVITE'`). Drift
  here ruins dashboard aggregation; the operator-side audit
  (§21.7 / `ohm-rfc/ROADMAP.md` #21 Part A) checks for it.

The starting taxonomy as of v0.17.0:

```
PAGE_VIEWED:               'Page Viewed'
RFC_VIEWED:                'RFC Viewed'
USER_SIGNED_IN:            'User Signed In'
USER_SIGNED_OUT:           'User Signed Out'
RFC_PROPOSED:              'RFC Proposed'
PR_OPENED:                 'PR Opened'
COMMENT_POSTED:            'Comment Posted'
BETA_ACCESS_REQUESTED:     'Beta Access Requested'
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision'
INVITATION_SENT:           'Invitation Sent'        # v0.16.0 / #12
INVITATION_ACCEPTED:       'Invitation Accepted'    # v0.16.0 / #12
USER_INVITED:              'User Invited'           # v0.17.0 / #16
INVITE_CLAIMED:            'Invite Claimed'         # v0.17.0 / #16
```

### 21.2 Required prop families per event kind

Each event family carries a small required prop set. These are
load-bearing for the dashboard's cohort analysis; releases that
add a new event in an existing family **SHOULD** carry the
family's required props.

- **Navigation events** (`Page Viewed`, `RFC Viewed`): carry
  `path` (string, pathname only — never the query string if it
  could carry a token) for `Page Viewed`; carry `rfc_slug` for
  `RFC Viewed`. `rfc_id` **MAY** be added when the cached row is
  in hand.
- **Auth-state events** (`User Signed In`, `User Signed Out`):
  `User Signed In` carries `method` (one of `'otc'`,
  `'passcode'`, `'device-trust'`, `'admin-invite'`). `User
  Signed Out` carries no props (the identity binding is cleared
  separately via `anonymize()`).
- **Authored-action events** (`RFC Proposed`, `PR Opened`,
  `Comment Posted`): carry `rfc_slug`. PRs additionally carry
  `pr_number` once the row exists. Comments additionally carry
  `thread_id`. None carry the body text.
- **Admin-action events** (`Beta Access Requested`,
  `Admin Permission Decision`): the latter carries `action`
  (lowercase: `'grant'` / `'revoke'`) and `target_user_id`.
- **Invite-side events** (`Invitation Sent`, `User Invited`): fire
  from the inviter's signed-in session. `Invitation Sent` (per-RFC,
  #12) carries `rfc_slug` + `role_in_rfc`. `User Invited`
  (admin-create, #16) carries `target_user_id` (the OHM user_id of
  the just-provisioned user) + `initial_role` +
  `custom_message_chars` (a bounded integer signal of admin
  effort, never the message text). Per #21 Part C: when the
  invitee is not yet a user (#12 per-RFC invitations to an email
  address that has never signed in), the invite-side event **MAY**
  carry a hashed `target_email` fingerprint (SHA-256 of the
  normalized lower-cased email) so the invite + claim pair can be
  correlated later. Plain-text `target_email` **MUST NOT** be
  carried.
- **Claim-side events** (`Invitation Accepted`, `Invite Claimed`):
  fire from the invitee's session, immediately after an
  `identify({ user_id, properties })` call binds the OHM user_id
  to the Amplitude record (see §21.6). The events carry the
  invite context (`rfc_slug` + `role_in_rfc` for the per-RFC
  shape; `invited_by_admin_id` + `initial_role` + `needs_passcode`
  + `trust_device` for the admin-create shape).

When in doubt, the principle: a property is correctly shaped iff
the operator could publish it in a session transcript without
hesitation.

### 21.3 Autocapture-friendly DOM patterns

The wrapper initializes Amplitude with `analytics.autocapture: true`,
which auto-instruments page views, clicks, and form interactions.
The *value* of those auto-captured events depends entirely on the
DOM the SDK observes. Releases that add interactive UI **SHOULD**
follow these patterns so the dashboard rows are readable rather
than rows like "Click on `<button>` at `:nth-child(7)`".

- **Stable visible text on interactive elements.** Buttons and
  links **SHOULD** have stable, human-readable text content (the
  same string Amplitude uses to label the row). Avoid generic
  labels like "Read more" / "Click here" that lose context.
- **`aria-label` on icon-only buttons.** Icon-only buttons (the
  chevron expanders, kebab menus, close `X`s) **MUST** carry a
  meaningful `aria-label`. Default autocapture for an unlabeled
  icon button reads as garbage. The `aria-label` is also an
  accessibility requirement — the two goals align.
- **`data-amp-track-*` for repeating-list per-row identifiers.**
  When a list renders many rows of the same shape (RFC rows,
  comment rows, PR rows in a listing), per-row interactive elements
  **SHOULD** carry a `data-amp-track-name` attribute that
  identifies the row's *kind* and a `data-amp-track-*` attribute
  carrying the row's stable id. The convention:

  ```html
  <button
    data-amp-track-name="RFC Row Expand"
    data-amp-track-rfc-slug={slug}
  >…</button>
  ```

  This makes per-RFC click counts aggregate to the RFC rather
  than to a generic label, and lets the dashboard answer "which
  RFCs got the most engagement" rather than "how many buttons
  were clicked."
- **`data-amp-track-suppress` for noise surfaces.** Crowded surfaces
  (the admin user-listing post-v0.9.0, the RFC discussion panel
  during heavy review) **MAY** apply
  `data-amp-track-suppress` (or its current equivalent in the
  SDK version in use) to elements whose clicks would flood the
  dashboard without informing anything. Suppression is a
  deliberate decision; document it inline.

### 21.4 Session-replay masking conventions

The wrapper initializes Amplitude with `sessionReplay.sampleRate: 1`
(100% of consented sessions are recorded for full-DOM playback —
vendor-recommended default for new Amplitude deployments). Replay
has a meaningfully larger privacy footprint than event counters,
and the masking discipline is binding.

- **Credentials MUST be masked.** The OTC code input, passcode
  input, any password-type field, the Turnstile widget internals,
  the magic-link-claim token if it survives in the URL bar
  (browser history, screenshot windows) — these **MUST** be masked
  with Amplitude's masking convention (the `.amp-mask` class or the
  `data-amp-mask` attribute, whichever the wrapper's SDK version
  uses; the wrapper's bootstrap comment names the current
  convention). Confirm each masking attribute survives the
  wrapper init by inspecting a recorded session before each
  release that touches an auth input.
- **PII SHOULD be masked or carefully un-masked.** Email-entry
  fields, real-name capture fields (the v0.8.0 first/last/why
  panel), free-text RFC body drafts, comment-compose text —
  each is arguably PII or near-PII. The per-field decision is
  the release's responsibility; document the choice in `SPEC.md`
  §21 (this section) and in the release CHANGELOG so future
  deployments inherit the call rather than re-deciding.
- **Privacy-policy alignment.** The recorded data **MUST** match
  what the deployment's privacy / cookies policy claims. If
  reality is broader than the document promises, update the policy
  text in the same release.
- **Selective redaction.** Amplitude supports field-level mask
  classes that hide value while preserving DOM shape (so the
  session is replayable but the value is not). Prefer this over
  whole-form masking when only a subset is sensitive.

### 21.5 Consent-gate contract

The wrapper is bound to the v0.13.0 cookie/privacy consent surface
(`frontend/src/lib/consent.js`, §14.5). The binding is **load-
bearing**: the SDK and the session-replay recorder **MUST NOT**
load before consent is granted, and any consent revocation **MUST**
take effect within one tick of the consent flip.

The wrapper's bootstrap implements this contract; releases that
touch the analytics surface **MUST** preserve it.

- **Pre-consent: no init, no network, no recording.** If
  `consent.analytics === true` is not currently true (either
  because the user denied, or because the banner is up and no
  decision has been recorded), the wrapper **MUST NOT** import
  the Amplitude SDK, **MUST NOT** open any network request to
  Amplitude, and **MUST NOT** start any session-replay recording.
  The consent-gated lazy `import('@amplitude/unified')` is the
  binding implementation; preserve it.
- **Denied → granted: init at the consent moment.** When consent
  flips from denied/undecided to granted, the wrapper **MUST**
  initialize the SDK at that moment (a new `initAll(KEY, …)` call
  through the lazy-import path). Track and identify calls made
  before init resolves **MUST** be queued and drained on init,
  so the first signed-in user's first event is not dropped on
  the cold-load race.
- **Granted → denied: setOptOut(true) within one tick.** When
  consent flips from granted to denied mid-session, the wrapper
  **MUST** call `amplitude.setOptOut(true)` so subsequent events
  are dropped client-side and session replay stops recording.
  The wrapper cannot unload the script tag (the SDK is already
  in memory), but the SDK's contract for "drop subsequent events"
  is `setOptOut(true)`. This **MUST** happen within one tick of
  the consent flip (i.e. synchronously inside the
  `onConsentChange` handler).
- **No silent re-grant.** A granted → denied → granted sequence
  **MUST** call `setOptOut(false)` (re-enabling the SDK that was
  paused) rather than firing a second `initAll` (which would
  double-init). The wrapper's bootstrap implements this; releases
  that touch the consent integration **MUST** preserve the
  distinction.
- **Build-time vs. runtime.** The API key is read from
  `import.meta.env.VITE_AMPLITUDE_API_KEY` at build time. When
  the env var is unset, the wrapper **MUST** log one console
  warning and no-op (every public function becomes a deterministic
  no-op) so dev environments without an Amplitude account keep
  working. The deploy gesture binds the key via the deployment's
  overlay verb (for OHM-shape deployments, `flotilla overlay set`);
  see §21.8 for the secret-vs-public discussion.

### 21.6 Identity lifecycle (per #21 Part C)

Amplitude's identity model has a specific pattern that the
framework follows verbatim. Every release that touches an
identity-meaningful surface **MUST** observe this pattern. The
pattern shipped inline across v0.15.0 / v0.16.0 / v0.17.0
(Session L's wave); this section codifies the contract so future
releases inherit it.

**On sign-in success** (`App.jsx`'s `me.user` resolution):

- The wrapper's `identify({ user_id, properties })` call **MUST**
  carry both the OHM user_id (`amplitude.setUserId(<id>)`
  internally) AND the user's durable property bag. `setUserId`
  alone is **NOT** sufficient — without properties, the Amplitude
  user record carries only the id, and cohort analysis loses the
  shape (role distribution, sign-in-method distribution, etc.)
  the dashboard depends on.
- Properties **MUST** be a bag of opaque ids, enums, booleans,
  and timestamps — no PII (no email, no display name, no IP).
- Properties **MUST** be classified `set` vs `setOnce` deliberately
  (see §21.6.1 below).
- The same `identify` call **MUST** be re-issued on every sign-in
  (idempotent at Amplitude's side; cheap; corrects any drift in
  the mutable property half).

**On user-state change mid-session** (role grant/revoke, trust-
device add, passcode set, beta-permission flip):

- The wrapper's `setUserProperties(properties)` call **MUST** fire
  so the Amplitude record stays current. Mid-session state changes
  **MUST NOT** wait for the next sign-in to surface — the dashboard
  cohort an admin uses to grant permission is the same dashboard
  that next sees the granted user's behavior; staleness here breaks
  the cohort feedback loop.

**On sign-out**:

- The wrapper's `anonymize()` call (internally `amplitude.reset()`)
  **MUST** fire. `reset` clears the device-id linking AND **MUST**
  also clear the property cache so the next anonymous session is a
  fresh slate (the wrapper's `anonymize()` does both — releases
  that touch the wrapper **MUST** preserve this).
- The `User Signed Out` `track()` call **MUST** fire *before*
  `anonymize()`, so the sign-out event is correctly attributed to
  the signing-out user rather than to the post-reset anonymous
  device.

**On invite-claim** (the v0.16.0 per-RFC invite + v0.17.0 admin-
create invite paths):

- The wrapper's `identify({ user_id, properties })` call **MUST**
  fire BEFORE the first `track()` event on the claim surface, so
  the Amplitude user record is created with the OHM user_id from
  the first event. **MUST NOT** fire `track()` first and `identify`
  later — that creates an anonymous device record that
  retroactively links, and the cohort attribution for
  invite-driven onboarding loses precision.
- The invite-context properties (`claim_method`,
  `invited_by_admin_id`, `invited_at`, `initial_role`) are
  `setOnce` (immutable user-history markers) — see §21.6.1.

**Inviter-side identification on invite-send events**:

- The inviter's `track('Invitation Sent', …)` and
  `track('User Invited', …)` events fire from the inviter's
  signed-in session, so the `user_id` attribution is already
  correct (it's the inviter's id). The event body carries
  `target_user_id` (#16 — admin-create, where the future user is
  provisioned at create-time) or a hashed `target_email`
  fingerprint (#12 — per-RFC invite, where the invitee is not
  yet a user) so the invite + claim pair can be correlated later
  in the dashboard.

#### 21.6.1 `set` vs `setOnce` taxonomy

Amplitude distinguishes two property-write semantics:

- **`set(k, v)`** — overwrites the property on every call. The
  user record reflects the most recent value.
- **`setOnce(k, v)`** — writes only if the property is not
  already present. Subsequent calls are no-ops. The user record
  reflects the first value ever written.

The wrapper's `applyProperties` function accepts both: a bare
value uses `.set()`; a sentinel-wrapped value
`['__setOnce__', value]` uses `.setOnce()`. Releases that add new
user properties **MUST** classify each one explicitly, by the
following rule:

- A property whose value is **expected to change over the user's
  lifetime** is `set`. Examples: `role` (can flip from
  `contributor` to `owner`), `permission_state` (pending → granted),
  `passcode_set` (false → true), `device_trusted` (changes per
  active device). On each sign-in, the latest value is written;
  the dashboard always sees current state.
- A property that is an **immutable historical marker** is
  `setOnce`. Examples: `first_sign_in_at` (the timestamp of the
  user's first observed sign-in — never re-write), `account_
  created_at` (the timestamp of provisioning),
  `invited_by_admin_id` (the admin who provisioned this user via
  the v0.17.0 path — preserved even if the user is later
  re-invited or has their role changed), `invited_at` (the
  timestamp at which the invite was sent — distinct from
  `claim_method` which is also setOnce because once claim_method
  is `'admin-invite'`, that's the path this user took).

The classification is part of the release's contract — flipping a
property from `set` to `setOnce` (or vice versa) mid-life corrupts
the user record and **MUST** be avoided. If a property's
semantics genuinely change, retire the old key and introduce a new
one (same deprecation discipline as event renames in §21.1).

### 21.7 Cohort-shape implications (informative)

The conventions above are designed so that the Amplitude dashboard
can answer cohort questions the operator actually asks:

- *"How many users signed in via the admin-invite path in week N
  vs. organic OTC?"* — uses `claim_method` (setOnce) on the user
  record + `User Signed In` events with `method`.
- *"Of admin-invited users, what fraction set a passcode within
  their first session?"* — uses `claim_method` + `passcode_set`
  on the user record + `Page Viewed` events to define "session."
- *"Which RFCs have the most owner-invited contributors?"* — uses
  per-RFC `Invitation Sent` / `Invitation Accepted` correlated
  via `rfc_slug` + `role_in_rfc`.
- *"What's the gap between invite-send and invite-claim, broken
  out by inviter?"* — uses `Invitation Sent` (inviter's session,
  inviter `user_id`) + `Invitation Accepted` (invitee's session,
  invitee `user_id` after the BEFORE-track identify) joined on
  `rfc_slug` + inviter (the inviter's id is the same id on both
  events because both invite and claim sides observe it).

The Part-A audit (per `ohm-rfc/ROADMAP.md` #21) confirms these
shapes against real data once a week of beta traffic is in. The
audit is a point-in-time pass; this chapter is the standing
discipline that keeps future work in shape.

### 21.8 Secret vs. public — overlay binding

The Amplitude browser API key (`VITE_AMPLITUDE_API_KEY`) is
**bundle-embedded by design**: it appears as a literal string in
the deployment's JavaScript bundle, visible to anyone with browser
dev tools. The vendor's installation prompt embeds it inline. This
puts it in the same category as Cloudflare Turnstile's site key
(`VITE_TURNSTILE_SITE_KEY`, v0.12.0) — public, not secret.

Deployments **MUST** bind such keys via their overlay verb (for
OHM-shape deployments, `flotilla overlay set`), not via the secret
binding. The matching secret half (the Cloudflare Turnstile
**secret** key, `CLOUDFLARE_TURNSTILE_SECRET`, used server-side
for siteverify) is a true secret bound via `flotilla secret set`.
This per-key distinction is the deployment's responsibility; the
framework's `*.env.example` files name the binding for each.

The binding rule baked in mid-Session-K is: **the operator's
secret bytes never enter the conversation with an assistant**,
even as one offered option. The conversation-layer corollary of
the build-pipeline §3-invariant-1 rule from
`ohm-rfc-app-flotilla/SPEC.md` is that sessions publish in full,
and a secret in a transcript is a leaked secret. The canonical
secret-set gesture for OHM is `pbpaste | flotilla secret set
<deployment> <SECRET_NAME>` (the value goes clipboard → stdin →
Secret Manager without ever appearing in shell history or the
model context). Non-OHM deployments inherit the same discipline
through their own deploy tooling.

### 21.9 §19.2 candidates surfaced by this chapter

- **Session-replay-specific consent category.** v0.13.0's cookie
  banner has a single `analytics` toggle that gates both event
  counters and full-DOM session replay. Recording has a larger
  privacy footprint than counters; a separate consent category
  for session replay is the cleaner shape. Captured here and in
  `ohm-rfc/ROADMAP.md` #21 Part A.
- **Bundle-size budget for the analytics wrapper.** The
  `@amplitude/unified` package adds ~150 KB gzipped (the session-
  replay recorder is the bulk). The consent-gated lazy import
  keeps the cost off the initial bundle for users who haven't
  opted in; the post-consent init path has not been measured
  for jank. Captured in #21 Part A.
- **Property-shape lint.** The conventions in §21.1 / §21.2 are
  enforced today by review discipline. A small lint (CI grep
  against `track(` / `identify(` callsites with a property-key
  allowlist + a PII-name denylist) is a future affordance that
  catches drift mechanically.
- **Hashed `target_email` derivation.** §21.2 names SHA-256 of
  the normalized lower-cased email as the hashing function. The
  framework does not currently expose a helper for this — a
  small `frontend/src/lib/hash.js` or `backend/app/hash.py` that
  centralizes the normalization + hash would make the contract
  enforceable. Captured here.

### 21.10 Open question

The wrapper currently uses the Amplitude SDK's autocapture +
session-replay defaults. The v0.13.0 consent surface has a single
toggle for "analytics." Splitting the consent into "analytics"
vs "session replay" is a §19.2 candidate (above) but settling it
also requires a privacy-policy update and a re-prompt of
existing consenters. The cleanest moment to do this is the next
material privacy-policy revision; the conventions in §21.4 hold
in the interim.

---

## 22. Three tiers: deployment → project → RFC collection

A **deployment** hosts one or more **projects**; each project owns one
content repository and holds one or more **RFC collections**; each
collection is a typed corpus of **entries**. This is the three-tier model
that supersedes the original single-corpus framing of §§1–21 and the
two-tier (deployment → project) draft that preceded it.

```
deployment  (= "global" in the UI)   one Gitea org, one bot, one account
  │                                   system, one inbox, one running process;
  │                                   the surface a visitor first lands on.
  └─ project                          a named grouping + project settings;
       │                              owns exactly ONE content repo. No type.
       └─ RFC collection              a typed corpus: type, slug namespace,
            │                         catalog, philosophy, initial_state,
            │                         unreviewed flag, members.
            └─ entry                  an RFC / spec / feature, identified by
                                      its slug within the collection.
```

Everything §§1–21 describe about *a corpus* is now *an RFC collection*.
Everything they describe about *a deployment* that is not corpus-specific —
accounts, the §6 admission gate, the §15 inbox, the §1 bot — stays at the
deployment level and is shared. A grouping layer, the **project**, sits
between: it owns the content repo and project-wide settings, and groups the
collections beneath it. The numbered sections that assume a single corpus are
amended in §22.14; **§22 is the binding model they defer to.**

> **Three-tier change (v0.40.0 → v0.45.0 — supersedes the single-corpus and
> the two-tier models).** §1 originally said "this single repository is its
> content repository," and an earlier draft of this section said "a deployment
> hosts N projects, each a corpus." The current model is three tiers: a
> deployment has a **registry** (§22.2) naming N **projects**, each project
> owns **one content repo** (§22.3) holding N **RFC collections** as typed
> subfolders (§22.2). The single-corpus deployment is the **N=1 case** and
> continues to run after migration via a generated default project carrying a
> generated default collection (§22.13); no deployment is forced to adopt more
> than one of either. Where earlier sections say "the meta repo," "the corpus,"
> or "the project," read "the collection's content repo" and "the collection's
> corpus." The slices that delivered this are S1–S6 (the design record is
> `docs/design/2026-06-05-three-tier-projects-collections.md`).

### 22.1 The tiers and isolation

One deployment, N projects (N ≥ 1); one project, N collections (N ≥ 1). A
collection belongs to exactly one project; a project to exactly one
deployment; neither moves. **Isolation (§22.5) holds at the collection
grain:** an RFC, branch, thread, star, or watch belongs to exactly one
collection, and no app surface joins across collections except the
per-account ones the deployment owns (the §15 inbox, the §6 account roster,
sign-in).

- **Deployment / "global."** The unchanged top tier. Owns accounts, the §6
  admission gate, the §15 inbox, the §1 bot, and the landing directory. Its
  management surface is **projects + global settings**.
- **Project.** Belongs to one deployment; never moves. Owns one content repo
  (§22.3) and carries project settings (name, tagline, theme, visibility,
  model universe). Has **no `type`** of its own. Its management surface is
  **RFC collections + project settings**.
- **RFC collection.** A typed subfolder of its project's content repo (§22.2).
  Carries everything the original draft pinned on a "project": the immutable
  `type` (§22.4a), the per-collection slug namespace (§22.4), `initial_state`
  (§22.4b), the `unreviewed` flag (§22.4c), catalog, philosophy.
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.

### 22.2 The registry and the collection manifests — git is still truth

Project and collection configuration is declared in git and mirrored into
cache tables (`projects`, `collections`) exactly the way content is mirrored
into `cached_rfcs` (§4). There are **two git sources**, both read by the bot:

1. **The registry repo** declares **projects**. A `projects.yaml` at the root
   of a dedicated **registry repo** under the deployment's Gitea org lists each
   project's `id`, `name`, `content_repo`, `visibility`, `theme`, and
   `enabled_models`. The framework learns the registry repo's location from a
   required env var (`REGISTRY_REPO`, the successor to `META_REPO`); the repo's
   *name* is the deployment's choice per the separation-of-concerns rule, and
   the framework fails loudly at startup if the var is unset. `content_repo`
   lives on the **project** (one repo per project), not the collection.

2. **Each project's content repo** declares its **collections** as typed
   subfolders, each carrying a **`.collection.yaml` manifest** (the
   collection's `type`, `visibility`, `initial_state`, `name`, and optional
   `enabled_models`). The registry mirror walks the content repo and reads
   these manifests, so collection configuration is git-truth and survives a
   cache rebuild — exactly as entry frontmatter does.

```yaml
# projects.yaml  (registry repo root)
deployment:
  name: Wiggleverse              # deployment display name (replaces VITE_APP_NAME)
  tagline: ...                   # deployment landing deck (§22.10)
projects:
  - id: ohm                      # url-stable slug, unique within the deployment
    name: Open Human Model
    content_repo: ohm-content    # ONE repo under the org; collections live inside it
    visibility: public           # gated | public | unlisted (§22.5)
    theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
    enabled_models: [claude, gemini]   # optional; falls back to deployment ENABLED_MODELS
```

```yaml
# ohm-content/model/.collection.yaml   (one per collection subfolder)
type: document                   # document | specification | bdd — immutable (§22.4a)
visibility: gated                # defaults to the project's, may only narrow (§22.5)
initial_state: super-draft       # super-draft | active — defaults from type (§22.4b)
name: The Model
# enabled_models: [claude]       # optional; narrows the project's universe (§22.12)
```

```
ohm-content/
  model/
    .collection.yaml      # type: document
    rfcs/intro.md
  specs/
    .collection.yaml      # type: specification
    rfcs/runtime.md
  features/
    .collection.yaml      # type: bdd
    rfcs/login.md
```

**Creation is in-app, wrapping a bot commit, at both tiers.** *+ New project*
(a global-Owner action, §22.6) has the bot create a Gitea content repo under
the org and commit a project entry to `projects.yaml`. *+ New collection* (a
project Owner / RFC-Contributor-with-create action) has the bot commit a new
subfolder + `.collection.yaml` to the project's content repo. The in-app
button is a thin convenience over a git write; nothing becomes app state that
git cannot rebuild. `projects` and `collections` cache rows are never written
from user actions directly — they flow from the mirror only. **Membership**
(§22.6) remains app state, as `rfc_collaborators` always has been — it churns
at user speed and is not document state.

### 22.3 Content repositories — one per project

Each project names one content repo under the deployment's single Gitea org
(convention `<project-id>-content`). Collections are **subfolders** within it
(§22.2). The §1 bot service account operates org-wide across every content
repo and the registry repo; nothing about the bot, the §6 app-owned
authorization, or the "app is the only contribution surface" stance changes.
There are no per-project Gitea orgs and no per-project bot accounts.

### 22.4 The slug namespace is per-collection; the slug is the identity

An entry's slug (§2) is unique **within its collection**: `model/intro` and
`specs/intro` coexist. The fully-qualified identity is `(project, collection,
slug)` — there is no type prefix and **no numeric ID**. The §22.4 retirement
of `RFC-NNNN` allocation stands: the slug is the identity, and graduation
(§13) flips state without allocating a number. The displayed *noun* around a
slug ("RFC", "Spec", "Feature") is a presentation concern driven by the
collection's `type` (§22.4a), not part of the identity.

**Legacy numbers.** Entries graduated *before* this change keep their existing
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label** —
preserved and shown so external "RFC-0001"-style citations still resolve, but
never used for routing or lookup. New entries are never assigned one.

### 22.4a Collection type

> **Metadata amendment (v0.46.2 — Configurable Collection Metadata).** Item 1
> below originally made the **entry metadata schema type-driven** — a
> `document`/`specification`/`bdd` frontmatter schema baked into a per-type
> module. That is **superseded**: entry metadata is **collection-configured**,
> not type-driven. Each collection declares a `fields:` schema in its
> `.collection.yaml`, and per-entry values live in a `<slug>.meta.yaml`
> **sidecar** (the `.md` body stays pure prose; a parser reads the sidecar
> else legacy top-of-doc frontmatter). See the
> [Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
> design, which supersedes the
> [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md) draft
> (D11). Item 3's **type-specific surfaces** (release planning for
> `specification`; scenario/coverage views for `bdd`) are **deferred** to a
> future design; the `bdd` **coverage** capability is recorded there as a
> future `ref`-field surface that maps features to the spec entries they
> verify **as hyperlinks**, honoring the §22 rule against fusing corpora
> across collections. What `type` still selects is the **terminology** (item 2,
> the entry noun, shipped v0.45.0) and the **default `initial_state` / review
> posture** (§22.4b–c).
>
> **Shipped status.** The design lands incrementally: sidecar storage +
> dual-read (SLICE-1, v0.47.0), the collection `fields:` schema + central
> validation (SLICE-2, v0.48.0), faceted left-pane filtering (SLICE-3, v0.49.0),
> and **single-entry metadata edit (SLICE-4, v0.50.0)** — the direct-commit
> `POST …/rfcs/{slug}/meta` editor (contributor+, validated at the write
> boundary), the schema-driven detail panel, all entry **write paths made
> sidecar-aware** (a migrated body-only `.md` never re-grows frontmatter), and
> the Owner-gated `POST …/collections/{cid}/migrate` endpoint. **Bulk
> tag/untag (SLICE-5, v0.51.0)** completes the design: the
> `POST …/collections/{cid}/meta/bulk` endpoint (`{slugs, op: set|add|remove,
> field, value}`) applies one field change to many entries' sidecars in a
> **single commit** (D7), validated per entry at the write boundary with
> partial-rejection reporting (`{applied, rejected}`), plus the catalog's
> row multi-select + sticky bulk action bar. See §9.5 for the edit-metadata
> write-through this reuses.

Every collection declares a `type` in its `.collection.yaml` manifest
(§22.2), chosen at creation and **immutable**: one of `document`,
`specification`, or `bdd`. Type does not change the engine — every type uses
the same content repo (§22.3), the same propose→branch→PR→discuss→graduate
lifecycle (§§9–13), the same threads, flags, and chat. Type selects:

1. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
   labels) — the entry noun, shipped v0.45.0;
2. the **default `initial_state`** a new entry lands in, and its review
   posture (§22.4b, §22.4c).

Entry **metadata** is **not** selected by type — it is **collection-configured**
(a `.collection.yaml` `fields:` schema + per-entry `<slug>.meta.yaml`
sidecars; see the amendment above, the Configurable Collection Metadata
design, and the §2 baseline). **Type-specific surfaces** layered on the shared
§7 catalog are **deferred** to a future design.

Type-specific behavior, where it exists, is implemented as a per-type module
the framework selects on `collection.type`; the engine itself treats every
entry as markdown + a metadata sidecar regardless of type. `type` is an
**open set** in shape — a future type is a new module plus a new allowed enum
value, no schema rebuild. The type names and their behavior are framework
concepts (like role names), not deployment content: a deployment picks which
type each collection is, but does not define or rename types.

- **`document`** — long-form normative prose (OHM: a model of principles and
  definitions). Metadata is the §2 baseline; no collection-configured `fields:`
  are required. The §22.13 generated default collection is a `document`
  collection with no `fields:`, so the N=1 case is unchanged.
- **`specification`** — a versioned technical specification (this framework's
  own `SPEC.md` is the archetype). A deployment that wants spec metadata
  (`version`, lifecycle `status` of draft/active/superseded, `supersedes`)
  declares those as collection `fields:`. **Release planning** — grouping
  entries into versioned releases with a changelog + §20-style upgrade-steps —
  is a **deferred** type surface.
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
  Given/When/Then scenarios with acceptance criteria. Feature metadata is
  declared as collection `fields:`. The **scenario/acceptance view** and a
  **coverage view** (mapping features to the spec entries they verify via a
  future `ref` field, rendered as hyperlinks — never fusing corpora across
  collections) are **deferred** type surfaces.

### 22.4b Initial state of a new entry

A collection sets the **landing state** a new entry takes when its creating
idea-PR merges (§2.4) — the `initial_state` manifest field, one of the §2.4
entry-states:

- **`super-draft`** (default for `document` and `specification`) — a new entry
  lands as a super-draft and must be explicitly graduated (§13) to reach
  `active`. This is today's flow.
- **`active`** (default for `bdd`) — a new entry lands `active` on the idea-PR
  merge, with the **`unreviewed` flag set** (§22.4c). The §13 graduate gate is
  not surfaced — the entry is already active — but because nothing reviewed
  it, the flag marks it as not-yet-vetted until an owner clears it.

The default comes from the collection's **type** (§22.4a), but `initial_state`
is an independent knob. It changes only the landing state and whether
graduation is required; the underlying engine is unchanged. The §22.13 default
collection keeps `super-draft`, preserving the N=1 flow.

### 22.4c The `unreviewed` flag

An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
`state`, recording whether a human gate has vetted it. An entry that reaches
`active` by the normal **graduate** path (§13) is never flagged — the graduate
action *is* the review. An entry that skips straight to `active` via
`initial_state: active` lands `unreviewed = true`. A collection **Owner**
(§22.6) clears it with a **mark-reviewed** action (§17), stamping
`reviewed_at`/`reviewed_by` for provenance. The flag is git-truth
(frontmatter, §2 amendment) and survives a cache rebuild. The §7 catalog gains
an **unreviewed filter** — the owner's worklist for the action.

### 22.5 Visibility applies at both project and collection

Visibility is `gated` | `public` | `unlisted`, and is carried at **both** the
project and the collection tier:

- **`gated`** — invisible to non-members: not shown in the directory (§22.10),
  returns 404 to non-members, and reading or writing requires a scope role
  (§22.6).
- **`public`** — any visitor may read under the §6.1 anonymous-read contract;
  appears in the directory; contributing still requires a grant (subject to
  the N=1 baseline, §22.6).
- **`unlisted`** — readable by anyone with a direct link, but not shown in the
  directory and not enumerated by the deployment/project listing.

**A collection defaults to its project's visibility and may only narrow it**
(`public` < `unlisted` < `gated`; a collection may be as strict or stricter
than its project, never looser). Reading or writing a collection requires
passing **both** gates — the stricter of project and collection wins. This is
validated at create-collection (422 on a looser setting) and clamped at the
registry mirror. Project/collection visibility does not relax the §11
per-branch `read_public` controls *within* a collection.

### 22.6 Roles: one vocabulary, attached at a scope

There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
levels" is literal — the same two words at every tier.

| Role | Capabilities within its scope's subtree |
|---|---|
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project. (A *collection*-scope grant cannot create sibling collections — creating one is a project-level action.) |

**Schema.** `users.role` continues to carry the **deployment admission** tier
(`owner` / `admin` / `contributor`, the §6 gate). Scope grants live in a single
polymorphic **`memberships(scope_type ∈ {global, project, collection},
scope_id, user_id, role, granted_by, granted_at)`** table; the role enum is
`{owner, contributor}`. The prior `project_members` three-role set
(`viewer`/`contributor`/`admin`) collapsed: `admin → owner`, `contributor →
contributor`, and `viewer` is deferred (a read grant folded into visibility,
not a membership role this pass). When the richer set returns it **re-splits
out of** Owner / re-adds a tier; the unified roles are not aliases.

> **Keystone reinterpretation (v0.42.0, S3 — reconciles the role mapping).**
> An earlier draft equated "deployment `contributor`" with "global RFC
> Contributor." That contradicted the open-by-default baseline. The binding
> reading: a **plain granted account** (`users.role='contributor'`, no
> membership row) is a granted *account* — it can sign in and read — **not** a
> write-everywhere global role. **"Global RFC Contributor"** is an **explicit
> `memberships(scope_type='global')` grant** (the cleo case, §22.6a). The one
> carve-out preserving N=1: the pre-multi-project **implicit-public write
> baseline is grandfathered onto the migration-seeded `default` collection
> only** — a granted `contributor` keeps its historical write capability there
> with no membership row. Every **explicitly created** collection (and any
> second project) requires an explicit scope grant to write. Deployment
> `owner`/`admin` remain superusers everywhere.

Membership is still gated by the deployment-level
`users.permission_state='granted'` (§6): a pending account has no write
capability at any scope regardless of its `memberships` rows.

### 22.6a Role & invitation scenarios

The behavioral spec for role usage, invitation, and empty-state experiences is
the BDD scenario set in
`docs/design/2026-06-05-three-tier-projects-collections.md` Part C (C.1 role
usage / inheritance / most-permissive union; C.2 invitation — who may invite
whom, at which scope; C.3 empty states at each tier). They are written so they
can also seed a `bdd`-type collection (the framework dogfooding its own model).
Each scenario carries the slice tag (`@S1`–`@S6`) that makes it pass.

### 22.7 How the four tiers compose

Effective authority on an entry is the **most permissive** union of four
layers, inheriting **downward**, **additive**, with **no negative override**:

```
effective authority on an entry =
      global role        (memberships scope_type='global'; + users.role owner/admin)
    ∪ project role        (membership at the entry's project)
    ∪ collection role     (membership at the entry's collection)
    ∪ per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
  then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
```

- A grant at **global** covers every project and collection in the deployment.
- A grant at **project** covers every collection in that project — including
  collections added later, with no new grant.
- A grant at **collection** covers just that collection.
- You **cannot** grant at a parent scope and revoke at a child; resolution
  never subtracts a parent grant.

**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12);
the three named scopes apply to a *subtree*. `arbiter` is narrower than Owner
(one entry, not a subtree) and stays distinct. `users.role` now means
deployment level only; no schema change demotes an existing owner/admin —
their powers read as "superuser in every project and collection."

### 22.8 Discovery and joining

Because a gated project or collection is invisible to non-members, joining is
by one of:

- **Invite** — an Owner (at the target scope or any scope above it) grants a
  user a role directly, writing a `memberships` row and fanning a §15
  notification. The grant may name any scope at or beneath the inviter's reach;
  the **broader-scope-supersedes** rule prunes membership rows the new grant
  subsumes (a project grant removes subsumed collection rows of same-or-lower
  rank; a global grant removes subsumed project + collection rows; a *stronger*
  child grant survives).
- **Request to join** — a user who knows a scope exists requests membership
  naming a desired role; the request is recorded and surfaced to the scope's
  Owners across the subtree (the cross-collection inbox), who accept or decline.
  Accepting writes the `memberships` row.

A `public` project/collection needs neither for read; the existing §6 / §12
contribute-grant paths cover write.

### 22.9 Branding is resolved at runtime

`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
cannot serve N projects. Deployment, project, and collection identity are
served at runtime — `GET /api/deployment` (deployment `name`, `tagline`, the
visible projects), `GET /api/projects/:id` (the project's settings + visible
collections), `GET /api/projects/:id/collections/:cid` (the collection's
settings incl. `type`). The frontend reads these instead of
`import.meta.env.VITE_APP_NAME`. Three chrome layers result: **deployment
chrome** (directory, switcher, shared inbox), **project chrome** (the
collection directory, project settings), and **collection chrome** (the §7
catalog, the §8 entry view, the §14 philosophy).

### 22.10 Routing and the landing surfaces

The canonical route gains a collection segment:

```
/p/<project>/c/<collection>/e/<slug>
```

The `c/` segment keeps collection ids from colliding with reserved
project-level segments. Reserved **collection-level** siblings (`proposals`,
`philosophy`) sit under `/p/<project>/c/<collection>/…`. The displayed entry
noun is the collection type's label (§22.4a), not part of the path.

- `/` is the **deployment landing**: a directory of the projects the visitor
  can see (§22.5). Redirects to the sole visible project when there is exactly
  one (the N=1 case).
- `/p/<project>/` is the **project landing**: a directory of the collections
  the visitor can see. Redirects to its sole visible collection when there is
  exactly one.

**Backcompat.** The shipped `/p/<project>/e/<slug>` URLs (v0.35.0)
**308-redirect** to `/p/<project>/c/<default>/e/<slug>`, and the
pre-multi-project `/rfc/<slug>` / `/proposals/<n>` redirect to their
`/p/<default-project>/c/<default-collection>/…` equivalents. Both are handled
in the migration (§22.13).

### 22.11 Notifications span the deployment, one inbox

Accounts are deployment-wide, so the §15 inbox is one inbox across all the
caller's collections. Entry-scoped notification rows carry the entry's
`collection_id` (and a denormalized `project_id`) so the inbox filters by
collection or project and a user can mute an entire collection. Quiet hours,
digest cadence, and email preferences stay per-account at the deployment level
(§5, §15). The **cross-collection inbox** (§22.8) surfaces join requests to
the Owners of the scope they target, aggregated across the subtree.

### 22.12 Per-collection model universe

A collection's `enabled_models` (its `.collection.yaml` manifest, §22.2)
narrows its **project's** `enabled_models` (registry, §22.2), which in turn
overrides the deployment `ENABLED_MODELS` (§18). Resolution order is **funder
universe ∩ §6.6 per-entry list ∩ collection universe ∩ project universe**,
with the collection universe substituting for the deployment universe at the
outermost step. A collection's universe may only narrow, never widen, its
project's; the project's may only narrow the deployment's.

### 22.13 Migration — the default project and default collection (N=1)

A deployment on the shipped two-tier schema (v0.39.0) is migrated so it keeps
running unchanged:

1. The existing `projects` row **stays as the project** (it already owns
   `content_repo` and its config-derived `id` from the §22.13 re-stamp).
2. A **default collection** (`id='default'`, `subfolder` = repo root) is
   created per project, inheriting that project's `type` / `initial_state` /
   visibility; those per-corpus fields are then dropped from `projects`.
3. Every entry-scoped row is re-keyed `(project_id, slug)` →
   `(collection_id, slug)` via the migration-028 rebuild pattern.
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
   the default collection, role-collapsed (§22.6).
5. **308 redirects:** the shipped `/p/<project>/e/<slug>` →
   `/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
   / `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.

Until a second collection is added, the deployment is functionally identical
to before, with one extra path segment. This is the §20.4 upgrade-steps
content for the release.

### 22.14 Amendments to §§1–21 (applied in place)

The single-corpus sections defer to §22; the load-bearing reinterpretations:

- **§1 Repository topology.** Each *project* names one content repo; the
  deployment's registry (§22.2) lists them; collections are subfolders within
  a project's repo (§22.3). The bot and app-owned-authorization paragraphs are
  unchanged and now read org-wide.
- **§2 Schema / §2.3 IDs.** Slugs are unique **per collection**; the entry
  **metadata schema is collection-configured**, not type-driven (§22.4a, as
  amended by v0.46.2) — a `.collection.yaml` `fields:` schema + per-entry
  `<slug>.meta.yaml` sidecars. The `RFC-NNNN` `max+1` allocation is
  **removed** — the slug is the identity (§22.4). New `active`-entry fields:
  `unreviewed` (bool) and the `reviewed_at`/`reviewed_by` provenance pair
  (§22.4c).
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition
  targets the collection's `initial_state` (§22.4b); a new `active
  ─[mark-reviewed, Owner]→ active` self-transition clears the `unreviewed`
  flag (§22.4c).
- **§5 Data model.** `project_id` becomes **`collection_id`** on every
  entry-scoped row (the corpus grain is now the collection); a separate
  `project_id` exists only on the `collections` table and project-scoped rows.
  `cached_rfcs` PK → `(collection_id, slug)` and mirrors the `unreviewed`
  frontmatter flag. New tables: `projects`, `collections` (carrying the
  immutable `type`, §22.4a), and `memberships` (§22.6, replacing
  `project_members`). `users.role` is annotated deployment-scope (§22.7).
- **§6 Permission model.** Deployment roles are the global tier of the §22.7
  four-layer union; a plain `contributor` has no implicit write at any
  explicitly-created scope until a `memberships` grant gives it one (the N=1
  default-collection baseline is the sole carve-out, §22.6 keystone note).
  `project_admin`'s delegation idea is now **Owner** at project/collection
  scope (§22.6), sitting above per-RFC authority (§6.3).
- **§7 / §8.1 / §13.3.** The catalog is per-collection under
  `/p/<project>/c/<collection>/`; the project collection-directory and the
  deployment directory (§22.10) sit above it; the §7 catalog gains the
  unreviewed filter (§22.4c). The §8.1 breadcrumb gains leading project +
  collection segments. §13.3 graduation operates on the collection's content
  subfolder, allocates no number, and is a no-op (replaced by mark-reviewed)
  for collections whose `initial_state` is `active`.
- **§14.1 / §17 / §18 / §20.** The landing splits into deployment directory,
  project collection-directory, and per-collection philosophy/deck. §17 routes
  gain the `/p/<project>/c/<collection>/` scoping plus `GET /api/deployment`,
  `GET /api/projects/:id`, `GET /api/projects/:id/collections[/:cid]`, the
  `memberships` management + request-to-join endpoints, and the mark-reviewed
  endpoint. `ENABLED_MODELS` is the deployment fallback under §22.12.
  `VITE_APP_NAME` is deprecated and `REGISTRY_REPO` is a required env var
  (§20.3); `META_REPO` is legacy, consulted only by the §22.13 migration.

