Compare commits

...

83 Commits

Author SHA1 Message Date
Ben Stull 455ef33b29 Merge pull request '§22 M3-backend Plan B (2/2, read): per-project RFC serving (v0.37.0)' (#15) from feat/m3-backend-planb2 into main 2026-06-04 13:45:52 +00:00
Ben Stull 9f548a340d §22 M3-backend Plan B (2/2, read path): per-project RFC serving (v0.37.0)
A second project's corpus now renders under /p/<id>/, isolated by its own
slug namespace. Completes step (1) "RFC app supports multiple projects" for
the read path.

- api.py: GET /api/projects/{pid}/rfcs (scoped catalog) + /{slug} (entry by
  (project_id,slug)), behind the §22.5 read gate. Unscoped /api/rfcs stay as
  default-project compat.
- cache.py: refresh_meta_repo iterates every projects row, mirroring each
  content_repo into cached_rfcs stamped with its project_id (_upsert_cached_rfc
  gains a project_id arg).
- frontend: api.listRFCs(projectId)/getRFC(projectId,slug); Catalog + RFCView
  pass useProjectId(); ProjectLayout's NotServedPlaceholder guard removed (every
  project serves), 404/not-readable branch kept; NotServedPlaceholder deleted.
- tests: test_project_scoped_serving.py (catalog scoping, entry isolation,
  gated 404); ProjectLayout.test updated (non-default renders). 445 backend +
  11 Vitest green; clean build.

Known limitation (next slice): write path + default-id re-stamp not yet scoped
(non-default project read-only); no live impact (no 2nd project in prod yet).
Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:45:42 -07:00
Ben Stull 569066ef48 Merge pull request '§22 M3-backend Plan B (1/2): slug-keyed PK rebuilds (v0.36.0)' (#14) from feat/m3-backend-planb into main 2026-06-04 13:30:48 +00:00
Ben Stull a117fbb521 §22 M3-backend Plan B (1/2): slug-keyed PK rebuilds activate project #2 (v0.36.0)
Lands the table rebuilds migration 026 deferred until a second project exists,
shipped separately from per-project serving for migration hygiene. No behavior
change (deployments stay single-project 'default').

- migration 028_project_scoped_keys.sql: folds project_id into the slug-keyed
  PK/UNIQUE of 13 tables (cached_rfcs PK (slug)->(project_id,slug); the
  UNIQUE/PK on cached_branches, branch_visibility, branch_contribute_grants,
  stars, watches, pr_seen, branch_chat_seen, funder_consents, rfc_collaborators,
  contribution_requests, proposed_use_cases gain project_id) and makes the FKs
  to cached_rfcs on rfc_invitations/rfc_collaborators/contribution_requests
  composite (project_id, rfc_slug) -> cached_rfcs(project_id, slug).
- db.run_migrations: a `-- migrate:no-foreign-keys` migration runs with
  PRAGMA foreign_keys toggled OFF around it (required by SQLite's table-rebuild
  procedure) + foreign_key_check after, failing loudly on dangling refs.
- ON CONFLICT upsert targets for the rebuilt tables gain project_id so they
  match the new composite indexes (value still defaults to 'default').
- test_migration_028_project_scoped_keys.py: proves two-project same-slug
  coexistence, within-project uniqueness, composite-FK enforcement. 442 pass.
- design doc §2 marked shipped.

Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:30:37 -07:00
Ben Stull d5213fc2da Merge pull request 'docs(design): M3-backend Plan B design (§22)' (#13) from docs/m3-backend-planb-design into main 2026-06-04 13:12:47 +00:00
Ben Stull 380e1f9782 docs(design): M3-backend Plan B — per-project serving, default-id re-stamp, PK rebuilds (§22)
The blueprint for the next §22 slice after M3-frontend (v0.35.0): make a
second project's corpus actually serve + render. Pins the three things that
land together — the default-project-id re-stamp (§22.13 step 1), the deferred
slug-keyed PK/UNIQUE rebuilds (migration 026 header, 12 tables), and
per-project RFC serving (path-scoped endpoints + per-project cache/bot/webhook
dispatch + scoped frontend calls + guard removal). Notes the Tier-1 registry
seed as the shared unblock for both this slice's and M3-frontend's deferred
e2e. Target v0.36.0 (possibly split B-1 read / B-2 write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:12:38 -07:00
Ben Stull db57caf8a1 Merge pull request '§22 M3 frontend: routing, runtime branding, directory, 308s (v0.35.0)' (#12) from feat/m3-frontend-impl into main 2026-06-04 12:59:41 +00:00
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00
Ben Stull 0252e40527 v0.34.0: containerize for per-PR preview environments (flotilla SPEC §15) 2026-06-04 01:52:50 -07:00
Ben Stull 97ba3ae9b5 M3-frontend design spec (#10)
§22 M3-frontend design: routing, runtime branding, directory/switcher, 308s. Doc only; impl plan to follow against the merged Plan A APIs.
2026-06-04 08:47:38 +00:00
Ben Stull 6c2bdb3c0a §22 M3 backend Plan A: registry mirror + runtime config (v0.33.0) (#11)
Registry mirror, GET /api/deployment + /api/projects/:id, initial_state/unreviewed semantics, migration 027 (additive), hard-cut META_REPO -> REGISTRY_REPO. Full suite 434 green; version 0.33.0.
2026-06-04 07:34:47 +00:00
Ben Stull 0f6b2b464b chore: wire always-on Wiggleverse org-context import into CLAUDE.md
Adds the @~/.claude/wiggleverse.md import so the org-wide agent context
auto-loads for future sessions in this repo (session-init step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:28:27 -07:00
Ben Stull f114af8ce0 docs(changelog): correct initial_state default (super-draft) + API field lists for 0.33.0 2026-06-04 00:19:56 -07:00
Ben Stull a2dc29af9c release: 0.33.0 — §22 M3 backend Plan A (registry mirror + runtime config)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:13:42 -07:00
Ben Stull 8004b2a123 refactor(review): sibling-consistent naming + module import; assert mark-reviewed git round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:10:37 -07:00
Ben Stull 69fd0cb2f0 fix(review): None-safe default_content_repo in mark-reviewed endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:04:08 -07:00
Ben Stull 87ddb845f4 docs(design): M3-frontend design — routing, runtime branding, directory/switcher, 308s (§22)
Frontend half of §22 M3, paired with the M3-backend spec. /p/<project>/ routing
(BrowserRouter + nested Routes + DeploymentProvider/ProjectLayout), N=1 redirect,
the VITE_APP_NAME->runtime hard cut (brandTitle fallback), per-project theme
overlay, real server-side 308 redirects, deployment directory + project switcher.
Scope boundary: corpus calls stay unscoped (default project only) with a guard
placeholder for non-served projects; second-project content is gated on Plan B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:59:17 -07:00
Ben Stull 76207bbb62 feat(review): mark-reviewed action + §7 catalog unreviewed filter (§22.4c)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:56:58 -07:00
Ben Stull 2fe2a719ac refactor(propose): resolve landing-state target via resolved_default_id 2026-06-03 23:52:37 -07:00
Ben Stull fe47eefdd9 feat(propose): honor project initial_state (§22.4b active landing + unreviewed) 2026-06-03 23:48:08 -07:00
Ben Stull e8ce3cd228 test(api): gated-project member-read positive path; guard config_json parse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:44:51 -07:00
Ben Stull 07e003e5fc fix(api): /api/projects/:id returns 404 (not 500) for unknown id incl. superusers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:39:27 -07:00
Ben Stull c386b05960 feat(api): GET /api/deployment + /api/projects/:id (§22.9 runtime config)
Adds two read endpoints that replace the build-time VITE_APP_NAME with a
runtime-config surface: /api/deployment returns deployment name/tagline plus
the projects visible to the caller (gated filtered by membership, unlisted
omitted from enumeration per §22.5); /api/projects/:id returns one project's
config and optional theme overlay, gated behind the §22.5 read gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:34:08 -07:00
Ben Stull 48fd6f9675 fix(registry): clean content_repo failure in hygiene; retire META_REPO from .env.example; clarity + idempotency test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:28:08 -07:00
Ben Stull cecc6c0b41 feat(registry): hard-cut META_REPO -> REGISTRY_REPO; wire mirror into startup/webhook/sweep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:16:51 -07:00
Ben Stull f1b03dffef Merge pull request 'M3-0: §10.3 Tier-1 test foundation (Vitest + Docker stack + Playwright e2e)' (#9) from feat/m3-0-test-foundation into main 2026-06-04 06:11:35 +00:00
Ben Stull 759a42e589 test(tier1): add e2e-install target; clarify limiter docs; label brand sample
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:04:54 -07:00
Ben Stull 2746242022 docs(testing): how to run Tier-1 local Docker and Tier-2 PPE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:00:50 -07:00
Ben Stull f7bd466f31 fix(registry): RegistryError on non-dict entry; atomic apply; tighten type guard + slug regex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:57:33 -07:00
Ben Stull 539d063c22 test(e2e): smoke spec — app loads and OTC sign-in succeeds via Mailpit
The first end-to-end smoke spec, run live against the Tier-1 stack
(§22 M3-0 Task 7). Drives the §6.2 email OTC sign-in: loads the app,
requests a code, reads it from Mailpit, verifies it, and asserts the
verify returns ok:true and sets the rfc_session cookie.

Supporting harness fixes:
- Makefile: add .PHONY so `make e2e` actually runs (the e2e/ directory
  was shadowing the target, making it a no-op).
- mailpit.js: guard the per-message detail fetch (if !full.ok continue)
  and scan the plain-text part before HTML so the \d{6} match can't
  latch onto a stray number.
- spec uses a unique per-run email to avoid the per-email request
  cooldown (OTC_REQUEST_COOLDOWN_SECONDS) on re-runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:57:02 -07:00
Ben Stull 27a0a0443b feat(registry): §22.2 projects.yaml parse/validate/apply module 2026-06-03 22:47:19 -07:00
Ben Stull 7d05125381 test(e2e): Playwright harness parameterized by BASE_URL + Mailpit mail sink
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:45:13 -07:00
Ben Stull 8f21dc5f9c feat(cache): mirror §22.4c review fields into cached_rfcs 2026-06-03 22:42:05 -07:00
Ben Stull 49b741243e test(tier1): docker compose stack (gitea seed + backend + web + mailpit)
Wires the four-service Tier-1 integration stack and brings it up green.

Fixes found during live bring-up:
- gitea-admin-init: run as user 'git'; the gitea CLI refuses to run as
  root and our custom entrypoint bypasses the image's s6 init that
  normally drops privileges.
- backend.Dockerfile: COPY VERSION to /VERSION; health.py reads the
  canonical VERSION file from the repo root (parents[2]) at import, so
  it must exist in the image or the backend crashes on startup.

env_file cold-start: generated/.env.tier1.generated is created as an
empty placeholder (gitignored) so the backend env_file path always
exists; the seeder overwrites it before backend starts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:41:49 -07:00
Ben Stull 2d9022b19e feat(db): migration 027 — additive §22 M3 schema (type/initial_state, deployment, review cols) 2026-06-03 22:35:39 -07:00
Ben Stull 0bccae1260 test(tier1): idempotent Gitea seed script (bot token, org, content repo, OAuth app, webhook)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:33:48 -07:00
Ben Stull d8661d5025 test(tier1): nginx web image serving the built SPA
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:31:03 -07:00
Ben Stull d73a9e2860 test(tier1): backend Docker image + env contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:28:39 -07:00
Ben Stull 597f6bc92b feat(entry): §22.4c unreviewed/reviewed_at/reviewed_by frontmatter fields 2026-06-03 22:28:11 -07:00
Ben Stull 3a3104f4c6 test(frontend): add Vitest unit-test harness with first brand helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:24:46 -07:00
Ben Stull dd72f913a3 docs: M3-backend Plan A implementation plan + spec refinements
Adds the bite-sized TDD implementation plan for Plan A (registry mirror +
runtime-config APIs + initial_state/unreviewed semantics; additive only).
Refines the spec with two planning discoveries: the re-stamp must be a
Python startup step (pure SQL can't read DEFAULT_PROJECT_ID), and the
PK-rebuild blast radius justifies splitting M3-backend into Plan A (this)
and Plan B (rebuild + project_id threading + re-stamp, before M4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:23:55 -07:00
Ben Stull 1b011ed483 docs(plan): M3-0 test & local-env foundation implementation plan (§22 / handbook §10.3)
Bite-sized TDD plan for the Tier-1 local-Docker test foundation: Vitest
frontend-unit setup, a four-service docker compose stack (seeded real
disposable Gitea + backend + nginx SPA + Mailpit), and a Playwright e2e
harness (BASE_URL + Mailpit mail-sink) with one OTC-login smoke spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:22:38 -07:00
Ben Stull 2cf7db4bff docs(plan): M3-0 test & local-env foundation implementation plan (§22 / handbook §10.3)
Bite-sized TDD plan for the Tier-1 local-Docker test foundation: Vitest
frontend-unit setup, a four-service docker compose stack (seeded real
disposable Gitea + backend + nginx SPA + Mailpit), and a Playwright e2e
harness (BASE_URL + Mailpit mail-sink) with one OTC-login smoke spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:07:50 -07:00
Ben Stull 6f356d3598 docs: M3-backend design spec (§22 registry mirror + data spine + APIs)
Brainstormed design for the backend half of §22 slice M3, split at the
backend/frontend seam. Covers migration 027 (type/initial_state columns,
the 12-table PK rebuilds, default→slug re-stamp), the app/registry.py
mirror over REGISTRY_REPO, GET /api/deployment + /api/projects/:id, and
the initial_state/unreviewed review semantics. Routing, runtime branding,
directory, and switcher are deferred to a later M3-frontend spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:59:20 -07:00
Ben Stull 7703fa233a docs(design): M3 implementation design — decomposition, decisions, test strategy (§22)
Brainstormed design for §22 slice M3 (registry mirror + routing + runtime
branding). Decomposes M3 into a §10.3 Tier-1 test/local-env foundation
(M3-0) plus four sequential feature sub-plans (M3a migration+restamp, M3b
registry mirror, M3c routing+redirects+branding, M3d landing-state/review).
Records resolved decisions: hard-cut branding, §4.1 reconciler pattern,
Stage-1 forward-only migration latitude with a future expand/contract note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:02:40 -07:00
Ben Stull 1dab24eef0 Merge feat/multi-project (§22 M1+M2) into main
Integrates the multi-project work so far — the §22 design drafts, M1 (the
schema spine), and M2 (project-scoped authorization + the §22.7 resolver) —
on top of the v0.32.0 retire/§13 changes that landed on main meanwhile.

Integration decisions:
- Renumbered the M1 projects migration 025_projects.sql -> 026_projects.sql.
  v0.32.0 shipped 025_retired_state.sql, which rebuilds cached_rfcs and
  predates the project_id column; running projects *after* the retire rebuild
  is required so project_id survives on a fresh database (the runner applies
  *.sql in filename order). Comment/doc references bumped to match.
- Resolved the get_rfc conflict in api.py by composing both gates: compute the
  viewer once, apply the §22.5 visibility gate, then v0.32.0's §13.7
  retired-entry owner-only check.
- api_graduation.py / api_discussion.py auto-merged cleanly (M2's threaded
  viewer/visibility gate coexists with the new retire endpoints + retired
  state).

Full suite 401 passed on a fresh DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 04:56:39 -07:00
Ben Stull 503689bf1a feat(projects): M2 — project-scoped authorization + the §22.7 resolver (§22)
Builds the three-tier authorization resolver on M1's schema spine: the
most-permissive union of deployment role (§6.1), project role (§22.6), and
per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on
top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no
behavior change on the public default project. Verifiable on the single
default project by flipping its visibility and granting/revoking roles.

- app/auth.py: the §22.7 resolver primitives — project_visibility,
  project_member_role, project_of_rfc, is_project_superuser, can_read_project,
  effective write/discuss standing (can_contribute_in_project /
  can_discuss_in_project), require_project_readable, visible_project_ids.
  The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc
  / can_invite_to_rfc) now compose all three tiers, so the ~20 call sites
  inherit M2 unchanged.
- Read pass: the §22.5 visibility gate (404 to non-members) threaded into
  every RFC-resolution helper across api.py / api_branches / api_prs /
  api_graduation / api_discussion / api_contributions / api_invitations, plus
  the catalog + proposals listings filtered by visible_project_ids.
- Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/
  edit, graduation, discussion resolve) fold in project_admin via
  is_project_superuser; the "any-contributor" branch mode routes through
  can_contribute_in_project; propose + claim gate on project contribute
  standing. Deployment-level surfaces (admin idea-PR merge/decline, account
  notification-mute) stay deployment-scoped.
- Operator decisions: implicit-on-public (a granted deployment contributor
  keeps its pre-M2 write baseline on a public project, no membership row, so
  the N=1 case stays whole) and preserve-curation (that baseline does not
  override per-RFC owner curation — only an explicit project grant or a
  deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract
  intact on public.
- tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the
  resolver tiers, public-unchanged regression, the gated 404 read gate, the
  gated contribution gate, union + subtractive visibility, and revocation.
  Full suite 390 passed.
- docs: mark Part C M2 "(landed)" with the two operator decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:02:34 -07:00
Ben Stull ad2ece18fa docs(design): resolve the three M3-blocking decisions (§22 draft)
- Default project id: config-derived slug (DEFAULT_PROJECT_ID > slug of
  deployment name > 'default'); M1's 'default' bootstrap is re-stamped in M3
  before any /p/ URL is public, so it's meaningful (/p/ohm/) and never renamed
  live. §22.13.
- Entry-noun in URLs: generic /p/<project>/e/<slug> for every type; the noun
  (RFC/Spec/Feature) is a type-driven UI label, not in the path. §22.10.
- Existing RFC-NNNN: kept as a frozen, read-only legacy display label in
  frontmatter id (preserves citations); never used for lookup, never assigned
  to new entries. §22.4, §2.3 amended.

Threads through §22.4/22.10/22.13, the §2.3 amendment, and the M3 slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:03:15 -07:00
Ben Stull 57b2fc5205 docs(design): project types, slug-only identity, initial_state + unreviewed flag (§22 draft)
Extends the §22 multi-project draft along four axes decided this session:

- Project type — each project declares document | specification | bdd
  (immutable, registry-set). One shared propose→branch→PR→graduate engine;
  type only layers entry schema, terminology, and type-specific surfaces
  (spec release-planning, BDD scenarios/coverage). §22.4a.
- Slug-only identity — an entry is (project_id, slug); the per-project
  RFC-NNNN numbering and its allocator are retired. §22.4, §2.3/§13 amended.
- initial_state — per-project landing state for a new entry: super-draft
  (document/spec default) or active (bdd default). §22.4b.
- unreviewed flag — entries that skip straight to active land unreviewed;
  graduation implies review so the normal path is never flagged. Owner
  mark-reviewed clears it; §7 catalog gains an unreviewed filter; mirrored
  into cached_rfcs. §22.4c.

Slicing plan re-sliced six→seven: type/initial_state/unreviewed config ride
M3, type surfaces become M5, membership→M6, hardening→M7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:26:52 -07:00
Ben Stull 34a65e099e deploy(nginx): terminate TLS with the *.wiggleverse.org wildcard, drop per-host certbot
Restructure the ohm vhost from the pre-certbot template into an explicit
80->443 redirect + hand-managed 443 TLS block, pointed at the wildcard cert
installed on the VM (/etc/ssl/certs/wiggleverse-wildcard.crt + the key under
/etc/ssl/private). Adds modern ssl_protocols/session settings that previously
came from certbot's managed snippet. All CSP/HSTS/security headers, root, and
locations preserved verbatim. Enables flipping ohm to the Cloudflare orange
cloud (SSL mode Full strict). Cert files must be installed before reload;
retire the old cert with `certbot delete` once cut over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:57:02 -07:00
Ben Stull 848de4cd8a Merge pull request '§13: make graduation's RFC number optional; add retire (soft delete)' (#7) from feature/v0.32.0-optional-grad-id-retire into main
Reviewed-on: https://git.wiggleverse.org/ben.stull/rfc-app/pulls/7
2026-06-02 05:29:19 +00:00
Ben Stull 49ba06e0c2 §13: make graduation's RFC number optional; add retire (soft delete)
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).

Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).

Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:27:46 -07:00
Ben Stull 6f901e3e2c feat(projects): M1 — the multi-project schema spine (§22)
Lays the additive foundation for hosting N projects per deployment, with
today's single corpus as the N=1 case. No behavior change: the app runs
exactly as before, single project, with the spine underneath.

- migration 025: `projects` + `project_members` tables; seed the `default`
  project (visibility=public, preserving open-by-default); thread
  `project_id NOT NULL DEFAULT 'default'` onto all 19 slug-bearing tables,
  backfilling existing rows. Additive — no table rebuilds; the slug-keyed
  uniqueness/PK rework is enumerated in the migration header and deferred to
  the slice that activates project #2.
- app/projects.py: §22.13 startup backfill of the default project's
  content_repo from META_REPO (idempotent — never clobbers a value the
  future registry mirror sets).
- config: REGISTRY_REPO wired (optional through M1; consumed by the M3
  mirror), documented in .env.example as META_REPO's successor.
- tests: 6 vertical assertions on the spine (seed, backfill, column shape,
  role CHECK, idempotency, config). Full suite 381 passed.
- docs: align Part C M1/M3 boundaries with the landed code (registry mirror
  + redirect move to M3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:14:53 -07:00
Ben Stull 7aba89655d docs(design): sketch multi-project deployments + draft §22 spec & slicing plan
Design for hosting N projects (corpora) per deployment, with today's
single-corpus deployment as the N=1 case. Captures the locked decisions
(git registry, gated-by-default visibility, per-project RFC numbering),
the three-tier role model (deployment / project / per-RFC), and the
forced runtime-branding shift off VITE_APP_NAME.

- multi-project.md: rationale, decisions, operator (flotilla) integration.
- multi-project-spec.md: draft binding §22, in-place amendments to
  §§1,2,5,6,7,8,13,14,17,18,20, and a six-slice (M1-M6) build plan.

Registry lives in a deployment-side repo the framework reads via a new
REGISTRY_REPO env var (META_REPO's multi-project successor); confirmed
against the ohm-rfc-app-flotilla spec — no operator-tooling change needed.

Not yet merged into SPEC.md or versioned; folds in when M1 lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:05:13 -07:00
Ben Stull 0062510a4e Merge pull request 'docs(deploy): correct stale infra facts after GCP name-alignment' (#6) from docs/deploy-infra-realignment into main 2026-06-01 17:16:27 +00:00
Ben Stull 714c2aed86 docs(deploy): correct stale infra facts after GCP name-alignment
The deploy docs predated the GCP name-alignment and described
infrastructure that no longer exists. Discovered during the v0.31.4
deploy. Corrections:

- Project wiggleverse-rfc -> wiggleverse-ohm; VM rfc-app -> ohm-rfc-app;
  install path /opt/rfc-app -> /opt/ohm-rfc-app; system user + service
  rfc-app -> ohm-rfc-app; external IP 34.132.29.41 -> 136.116.40.66.
- SSH is now IAP-only (direct port 22 times out): document
  --tunnel-through-iap on every gcloud compute ssh.
- Meta repo wiggleverse/meta -> wiggleverse/ohm-content.
- Two-remote reality: the VM's git origin is git.benstull.org/benstull/
  rfc-app, a SEPARATE Gitea from the release one (git.wiggleverse.org)
  that does not auto-mirror — so a release must be pushed to the
  benstull remote before the VM can fetch it. The VM tracks a detached
  release TAG, not a branch.
- Frontend-only changes are live once dist/ is rebuilt (nginx serves it
  directly); pip install only when requirements.txt changed.

Docs-only; no version bump (cf. the docs-only commit after v0.31.3).
The First-Time Deployment blocks keep the structural commands with a
substitution note rather than unvalidated rewrites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:15:58 -07:00
Ben Stull 551d240967 Merge pull request 'Release v0.31.4: fix invisible light-surface btn-link + harmonize RFC breadcrumb action bar' (#5) from release/v0.31.4-button-ux into main 2026-06-01 15:20:33 +00:00
Ben Stull 76c82a5e96 v0.31.4: fix invisible light-surface btn-link + harmonize RFC breadcrumb action bar
`.btn-link` was a dark-header utility (white text on translucent white)
reused on light surfaces — the RFC breadcrumb action bar, PR diff toggle,
modals, discussion panel — where it rendered white-on-white and looked
like missing buttons (reported: "Metadata"/"Claim ownership"/"Invitations"
absent on a super-draft header). Root-cause fix:

- Base .btn-link is now a light-surface secondary button (white fill,
  hairline border, dark label); the dark-header "Sign out" keeps the
  translucent-on-dark treatment via an .app-header .btn-link scope.
- .breadcrumb-actions normalizes the toggle, filled CTAs, and secondary
  buttons to one height/radius/type as a single control group, and
  flex-wraps instead of clipping off the right edge.
- Diff-mode active toggle now reads as clearly selected (filled ink).

CSS-only; patch release, plain frontend rebuild applies it. No upgrade
steps. Driver session 0059.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:22:19 -07:00
Ben Stull e8e555d8a4 docs(invites): correct stale last_seen_at NULL claim
The provisioning docstring claimed the invitee row gets
`last_seen_at = NULL` as the "not yet arrived" discriminator. It does
not: the column is NOT NULL and the INSERT omits it, so it defaults to
datetime('now') — the longer note below already explained this, but the
bullet contradicted it. Rewrite the bullet to state the real behavior
(both timestamps default to now; the pending-invite state lives in the
unclaimed user_invite_tokens row) and note that consumers must treat a
pending-invite row as never-seen. Comment-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:04:11 -07:00
Ben Stull 7e595b6e5e v0.31.3: admin Users "Last seen" = Never for unclaimed invites
An admin-created invite row showed a Last-seen timestamp identical to
Signed-up, implying the invitee had visited. users.last_seen_at is
NOT NULL DEFAULT (datetime('now')) and the invite INSERT sets neither
timestamp, so both default to row-creation time; last_seen_at only
advances on real authentication. An unclaimed invite has provably never
authenticated (the unclaimed state drives the PENDING INVITE badge), so
the Users tab now renders "Never" for the Last-seen cell of a pending
row. Signed-up (invite-created date) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:00:31 -07:00
Ben Stull 1d716d0cb8 v0.31.2: landing welcome panel spacing
Visual-only patch. The "/" welcome read-view was jammed against the
catalog divider with no top offset and loose paragraph rhythm: the
.main-pane §8 override (padding:0; display:flex) shadows the padded
read-view rule, so the pane gives no padding, and .welcome (max-width
only) never compensated. The welcome surface now owns its breathing
room — 56px top / 48px side gutters, a 680px measure, a text-3xl hero,
and even --space-8 paragraph spacing at --leading-relaxed. Covers both
the signed-out and signed-in welcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:54:29 -07:00
Ben Stull f96883506e v0.31.1: admin Users tab + header UX polish
Visual-only patch atop v0.31.0. CSS plus markup/structure in Admin.jsx;
no API, schema, config, overlay, or secret change — a plain frontend
rebuild applies it.

Two latent CSS defects fixed:
- .invite-badge had no rule, so "(pending invite)" rendered as bare
  parenthetical text; it's now a quiet amber pill.
- .btn-link-quiet never reset native <button> chrome, so the admin
  Revoke/Grant/Remove buttons, the modal close ×, and Login/BetaPending
  link-buttons kept the browser's grey button box. The reset the
  .otc-login scope already carried is folded into the base rule.

Users tab: table headers no longer wrap (WRITE-MUTED), timestamps
render as a date-over-time stack, the duplicated subline email is
de-duped, the Create-user-+-invite action moves flush-right beside the
title, and intro DB-column refs read as quiet chips.

Header: the Inbox (§15.2) trigger was styled for a light surface
(gray-200 border, gray-50 hover) and rendered as a pale box that went
white-on-white on hover; restyled to the nav-link vocabulary
(borderless, gray-300 icon → white on a faint translucent hover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:32:47 -07:00
Ben Stull 0c972c8af5 v0.31.0: meta-only repository topology (ROADMAP #36)
Retire the per-RFC-repo model. RFCs now live in their meta-repo entry
(rfcs/<slug>.md) for their whole life; graduation is an in-place
super-draft → active state flip that keeps the body in the entry — no
repo creation, no body-strip, no five-step transaction, no rollback.

SPEC: §1 topology rewritten (one meta/content repository, no per-RFC
repos) with a deployer-facing "single content repository" framing; §2
(repo: always-null), §3 (active is in-place), §4, §9.8 (handoff
frictions dissolve), and §13 fully rewritten (two-field dialog, "The
flip", §13.6 RFC-0001 fold-back record).

Code: graduation collapses to open+merge one frontmatter PR; branch/PR/
chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs
edit on the meta repo exactly as super-drafts do; the two "RFC has no
repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's
auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution;
refresh_meta_branches + hygiene branch-resolution include meta-resident
actives; the §9.8 read-only guard + pre_graduation_history scoped to
legacy per-repo only; dead bot primitives + GraduateDialog repo field +
blocking-PR popover removed. The repo: frontmatter field and the
/blocking-prs endpoint are retained (schema stability / informational).

Tests: graduation suite rewritten to the flip model; e2e + hygiene
updated. Full backend suite 375 passed; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:12:06 -07:00
Ben Stull d581010063 v0.30.2: header nav label "Philosophy" -> "About"
Revert the §14.3 persistent chrome link's display text from "Philosophy"
back to "About" (relabeled in v0.21.0). Display text only — route
/philosophy, the header-about class, and the page are unchanged. Patch
per SPEC §20.2: cosmetic, no deployment action beyond a frontend rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:56:14 -07:00
Ben Stull 732b23b156 v0.30.1: fix phantom pending-idea after merge-with-branch-delete
refresh_meta_pulls / refresh_rfc_repo recover a PR's slug from its
Gitea head.ref, which collapses to the refs/pull/<N>/head sentinel
once a merged PR's branch is deleted. The slug then parsed to None,
the row was skipped, and cached_prs.state froze at 'open' — so the
entry showed as both a super-draft and a pending idea. Recover the
real branch name from the stored cached_prs row when Gitea reports an
empty or sentinel ref.

Surfaced via the ROADMAP #35 operator authoring lane (CLI merge with
--delete-branch); the web UX leaves branches in place so it never hit
this. Regression test added; full suite 375 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 05:26:00 -07:00
Ben Stull 1558cc3a8b Release v0.30.0: sync user guide (DOCS.md) with the shipped app
Documentation-only minor. The guide had drifted since it was first
written; brought back in sync with v0.7.0–v0.29.0:

- "Signing in" rewritten: email + one-time-code, optional passcode,
  trust-device 30d, optional Turnstile, and the beta-request → pending
  → admin-grant gate, plus admin-create + invite-claim. The vestigial
  email allowlist is no longer described as the gate.
- "Proposing a new RFC": four → five fields (optional use-case #26) +
  AI tag-suggestion disclosure (#27).
- "Roles & permissions": documents the pending state.
- New "Invitations, cross-references, and contribution requests"
  section (#12 owner invites; #28 auto-link / create-RFC / ask-to-
  contribute).
- New "Privacy and cookies" section (#11/#13).

No code/schema/API/config/overlay/secret change — DOCS.md is served
verbatim by /api/docs. VERSION + frontend/package.json bumped to 0.30.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:12:18 -07:00
Ben Stull 8a94e26f75 Merge pull request 'Release v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests' (#3) from feature/v0.29.0-pr-rfc-create-contribute into main 2026-05-29 03:11:02 +00:00
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00
Ben Stull 019c8a9185 Merge pull request 'Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile siteverify)' (#2) from feature/v0.28.0-email-turnstile-async into main 2026-05-29 02:42:05 +00:00
Ben Stull 79a447c77b Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile)
Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth. No operator action: no migration,
no schema/config/overlay change, no deployment-facing surface.

- I3: guard the dead text/html branch in email_envelope.build_envelope.
  No send path passes body_html; the unused branch would emit HTML built
  from possibly-unescaped user content (C1 stored-XSS class in the mail
  channel). Passing body_html now raises NotImplementedError; the arg is
  kept for documented future symmetry, enabling HTML mail becomes a
  deliberate escape-then-unguard change.

- I4: make turnstile.verify_token async. The sync httpx.post ran inside
  the async /auth/otc/request handler, blocking the event loop up to the
  10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient
  via a narrow _siteverify_post seam (tests patch the seam, not the
  shared AsyncClient). The sole caller (main.py) now awaits it.

Tests: full backend suite 365 passed. Added a coroutine-contract unit
test for verify_token and flipped the email_envelope HTML test to assert
the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 19:12:16 -07:00
Ben Stull fe044ed3db Merge pull request 'Release v0.27.0: security hardening (audit 0026)' (#1) from feature/v0.27.0-security-hardening into main 2026-05-29 01:28:42 +00:00
Ben Stull bd3ef269d4 Release v0.27.0: security hardening (audit 0026)
Remediates the rfc-app application + deploy-config findings from the
Session 0026 security audit. Cut as the "v0.25.0-security-hardening"
branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since
v0.26.0 (#28) shipped while this was in flight.

- C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every
  marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview,
  ProposalView x2, Editor); rel=noopener hook on target=_blank links.
- H1: per-account OTC-verify lockout (migration 023, auto-applied) +
  per-IP throttle via new ratelimit.py; wired on otc verify/request +
  passcode check/verify.
- M1: device_trust.lookup() single indexed-row read — cookie value is now
  "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan.
  (Behavior change: existing device-trust cookies re-prompt once.)
- M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx.
- M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out).
- M5: bounce webhook fails CLOSED (503) when secret unset, instead of open;
  RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in.
- L2/L3: per-IP cooldown + check-endpoint throttle.
- L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out.

VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade
steps (incl. the out-of-band nginx + systemd apply, which the flotilla
deploy gesture does not perform).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:10 -07:00
Ben Stull 698821f065 Merge feature/v0.26.0-pr-rfc-links (#28 Part 1: auto-link accepted RFCs in PR text + comments) 2026-05-28 16:21:26 -07:00
Ben Stull e794523079 v0.26.0: auto-link referenced RFCs in PR text + comments (roadmap #28 Part 1)
Part 1 of item #28: references to existing accepted RFCs inside PR
descriptions and comment text now render as inline links to the
referenced RFC. Parts 2 (offer-to-create) and 3 (offer-to-contribute-
to-pending) are deliberately deferred — Part 1 ships first as the easy
win, per the roadmap row.

Shipped in parallel with the v0.25.0 security-hardening session; this
took the next free version slot (0.26.0) per the roadmap's
"claims the next available version number" rule. Expect a top-of-file
CHANGELOG/VERSION merge with 0.25.0 — distinct concerns, trivial to
resolve.

Backend:
- rfc_links.py (new): builds a term index from the live accepted
  (state='active') RFC corpus and segments plain text into text /
  rfc-link segments. Conservative matching — links only rfc_id tokens
  (RFC-0001), multi-word titles (Open Human Model), and hyphenated
  slugs (open-human-model); a single common-word title/slug is NOT
  linked (would turn every prose "human" into a link). Case-insensitive,
  word-boundary-anchored, longest-match-wins, self-reference suppressed.
- api_prs.py get_pr(): enriches the PR description (description_segments)
  and every PR comment (text_segments).
- api_discussion.py: enriches PR-less discussion comments (text_segments).

Read-time, not submit-time: the roadmap says "at submit time" but the
intent it names is "not as live compose preview", which read-time
honors. Chosen for correctness (links track the live active set —
newly-accepted RFCs start linking, withdrawn ones stop), zero migration,
and cheapness (small cache-resident corpus). Recorded as a §19.3-rule-2
note in the session transcript.

Frontend:
- LinkedText.jsx (new): maps backend segments onto React text nodes +
  anchors. No dangerouslySetInnerHTML — XSS-safe by construction,
  independent of any HTML-sanitization layer. Falls back to raw text
  when segments are absent.
- PRView.jsx: description + PR conversation comment bodies render via
  LinkedText.
- RFCDiscussionPanel.jsx: discussion comment bodies render via LinkedText.
- App.css: .rfc-autolink (subtle accent + dotted underline, tokenized).

Tests: 12 new (test_rfc_links_vertical.py) — 9 scanner units + 3
end-to-end (PR description / review comment / discussion comment all
surface *_segments; self-reference suppression). Full suite 363 green;
frontend builds clean.

No upgrade steps: additive, no migration, no secret, no config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 16:17:01 -07:00
Ben Stull 28015ed1a2 Release v0.24.0: Claude Haiku tag suggestions on propose-RFC (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:28:12 -07:00
Ben Stull 376a6daddc Merge feature/v0.24.0-haiku-tags (#27: Claude Haiku tag suggestions on propose-RFC) 2026-05-28 13:25:50 -07:00
Ben Stull fb9b4fa422 v0.24.0: Claude Haiku tag suggestions on propose-RFC (roadmap #27)
The §9.1 Slice-2 AI-suggested tag chips, deferred since the propose
modal landed, now wired up. As the propose-RFC draft fills in, the
backend asks Claude Haiku for tags drawn ONLY from the corpus's
existing tag set (the de-facto taxonomy — v1 tags are free-form chip
input, there is no curated list), surfaced as clickable suggestion
chips. Nothing auto-applies; the user clicks to add.

Backend:
- tag_suggest.py: universe gather (distinct corpus tags, most-common
  first), Haiku prompt + tolerant reply parser (drops invented tags,
  dedupes, clamps confidence), in-process per-user rate limit.
- providers.construct_haiku(): dedicated Haiku provider from the
  operator key, independent of ENABLED_MODELS — tag suggestion always
  uses the cheap+fast model. No RFC slug at propose time, so the §6.7
  funder path does not apply.
- POST /api/rfcs/suggest-tags: contributor-gated, rate-limited.
  Degrades to an empty list (never an error) when no Anthropic key is
  bound, the corpus has no tags, or the draft is empty.

Frontend:
- ProposeModal: debounced suggestion fetch (700ms, stale-response
  guarded), clickable suggestion chips, and the required inline
  disclosure that the draft text is sent to Anthropic.
- api.suggestTags(): forgiving — any non-OK resolves to [].

Tests: 11 new (vertical contributor-gating / filtering / no-key /
empty-corpus / 429, plus units for gather, parser tolerance, max,
short-circuit, provider-failure). Full suite 351 green.

New secret on deploy: ANTHROPIC_API_KEY (see CHANGELOG Upgrade steps).
Disclosure copy wants a counsel pass before deploy, per #22 discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:25:39 -07:00
Ben Stull daebb54f47 Release v0.23.0: server-side sign-in state resume (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:21 -07:00
Ben Stull a598221812 Merge feature/v0.23.0-signin-resume (#29: server-side sign-in state resume) 2026-05-28 12:35:46 -07:00
Ben Stull bada72f87e v0.23.0: server-side sign-in state resume (roadmap #29)
Track each authenticated user's last-viewed route + light view state
server-side, and on next sign-in redirect them to that state, falling
back to the empty-state home only when there's no recorded state.

Backend:
- migration 022_user_session_state.sql: one row per user
  (user_id PK/FK, last_route, last_route_state JSON-as-TEXT,
  resume_enabled default 1, last_updated_at).
- PUT /api/me/last-state (require_user): upserts route + light state;
  no-ops when resume_enabled=0. Localized in the /me region.
- /api/auth/me payload now carries resume_enabled + last_route +
  decoded last_route_state (no extra round-trip).
- test_session_resume_vertical.py: auth-required, upsert/read-back,
  per-user isolation, resume_enabled=0 disable.

Frontend:
- lib/useLastState.js: debounced (~1s) route-change PUT for
  authenticated users; one-time resume redirect on sign-in, gated on
  identify having fired (preserves #21 Part C identify-then-track).
- api.js: putLastState() client call.
- App.jsx: import + call the hook; set identifyReady after identify.
  Header region untouched.

Per-user (not per-device); profile-settings opt-out toggle UI
deferred (column + default-on behavior ship now). Stored state is
route + light view state ONLY, never draft buffers — documented in
SPEC §6.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:34:19 -07:00
Ben Stull 7d8371dea1 Release v0.22.0: optional propose use-case field (#26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:32:34 -07:00
Ben Stull 3a51425ec7 Merge feature/v0.22.0-usecase-field (#26: optional propose use-case field) 2026-05-28 12:30:36 -07:00
Ben Stull 7c6c906db2 #26: optional proposed-use-case field on propose-RFC + propose-PR
Adds an optional "What will you be using this for?" capture as a sibling
to the required justification on both propose surfaces, per roadmap #26.

- propose-RFC modal (ProposeModal): optional textarea below the required
  "Why is this RFC needed?" pitch, labeled "What will you be using this
  RFC for? (optional)".
- propose-PR modal (PRModal): optional textarea below the required
  description, labeled "What will you be using this change for?".
- Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case`
  (NULL/omitted accepted, no min, 8000-char cap matching the existing
  free-text bound). Persisted to a new canonical side table
  `proposed_use_cases` keyed by PR number, mirrored onto the cache
  columns added by migration 021. Returned on the proposal list/detail,
  RFC detail (by slug), and PR detail endpoints.
- Display: ProposalView, RFCView (main only), and PRView render the
  captured use case with a muted "left blank" treatment when NULL.
- migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs
  plus the reconcile-proof `proposed_use_cases` truth table.
- New vertical test_proposed_use_case_vertical: persists+returns when
  supplied, accepted as NULL/omitted, for both surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:52 -07:00
153 changed files with 18133 additions and 1647 deletions
+15
View File
@@ -0,0 +1,15 @@
# Keep the preview build context lean + reproducible.
.git
.gitea
**/__pycache__/
**/*.pyc
backend/.venv/
backend/data/
frontend/node_modules/
frontend/dist/
e2e/
mockups/
docs/
*.md
!VERSION
.pytest_cache/
+967
View File
@@ -23,6 +23,973 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.37.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
namespace. No upgrade steps; single-project deployments are unaffected.**
This is the slice that makes "multiple projects" *observable*: M3-frontend
(v0.35.0) shipped the shell with a "not served" guard for any non-default
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
#2's corpus.
Added:
- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
(catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
(entry by `(project_id, slug)`), both behind the §22.5 read gate
(gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
the default-project compat path.
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
iterates every `projects` row and mirrors each project's `content_repo` into
`cached_rfcs` stamped with that `project_id` (was: the default project only).
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
isolation (same slug under two projects resolves distinctly; a slug present
only in one 404s under the other), gated-project 404. 445 backend green.
Changed:
- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
`getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
**M3-frontend `NotServedPlaceholder` guard is removed** — every registry
project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.
Known limitation (next slice): the **write** path (propose / branch / PR /
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
project-scoped — write affordances still target the default project, so a
non-default project is effectively read-only until Plan B's write slice. (No
live impact: no deployment runs a second project yet.) Per
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).
## 0.36.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that
activates project #2. No behavior change (deployments are still single-project
`default`); migration `028` runs automatically on deploy.**
This lands the table rebuilds migration 026 deliberately deferred "until a
second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY /
UNIQUE constraints so two projects can hold the same slug without colliding.
Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration
hygiene: the schema change deploys and is verified on its own, smaller blast
radius.
Added:
- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite
slug keys: `cached_rfcs` PK `(slug)``(project_id, slug)`; the `UNIQUE`/PK
on `cached_branches`, `branch_visibility`, `branch_contribute_grants`,
`stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`,
`rfc_collaborators`, `contribution_requests`, `proposed_use_cases` all gain
`project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`,
`rfc_collaborators`, `contribution_requests` become composite
`(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is
unchanged — `(repo, pr_number)` is already globally unique.)
- **Migration-runner capability** (`db.run_migrations`): a migration whose
first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys`
toggled OFF around it (required by SQLite's table-rebuild procedure, and a
no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails
the migration loudly on any dangling reference.
Changed:
- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id`
(`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`,
`api.py`, `funder.py`) so they continue to match the new composite indexes.
The inserted `project_id` still defaults to `default`, so behavior is
identical for a single-project deployment.
Upgrade steps:
1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites
the listed tables in one transaction with FK enforcement off and verifies
`foreign_key_check` after. No data is dropped (rows already carry
`project_id` from migration 026, M1). No config change.
2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the
default-id re-stamp + the frontend guard removal) is the next slice (Plan B
2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`.
## 0.35.0 — 2026-06-04
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
branding (the `VITE_APP_NAME` hard cut), the deployment directory + project
switcher, and server-side 308 redirects off the old corpus-root URLs.
Completes the runtime-config cut 0.33.0 began.**
Added:
- **`DeploymentProvider`** (`src/context/DeploymentProvider.jsx`) — boots
`GET /api/deployment` once and provides `{ name, tagline,
defaultProjectId, projects[], loading }` to the tree. The neutral
`brandTitle()` fallback (`'RFC'`) paints during the pre-fetch frame.
- **`/p/:projectId/*` routing** with the generic `/e/<slug>` entry segment
(§22.10). **`ProjectLayout`** (`src/components/ProjectLayout.jsx`) fetches
`GET /api/projects/:id`, applies the project's `theme` as `:root` CSS
custom-property overrides (reset on switch/unmount so accents never bleed),
provides `ProjectContext`, and sets the tab title to the project name.
- **The §4 guard** — `ProjectLayout` renders the corpus only for the
corpus-served (default) project; any other id renders a "content not yet
served" placeholder (`NotServedPlaceholder`), so per-project serving (the
next backend slice, Plan B) can land without a wrong-content footgun.
- **Deployment directory** at `/` (`Directory.jsx`) — renders the
caller-visible projects as cards when **2+** are visible; the **N=1** case
redirects straight into the single project (`/p/<id>/`), preserving OHM's
"land in the corpus" UX. A **project switcher** (`ProjectSwitcher.jsx`)
rides deployment chrome when 2+ projects are visible.
- **Entry-noun terminology** (RFC / Spec / Feature) driven by `project.type`
(§22.4a); the route segment stays the generic `/e/`.
- **`GET /api/deployment`** now returns **`default_project_id`** — the
corpus-served project the frontend guard keys on.
- **Server-side 308 redirects** (`app/api_deployment.py`): `GET /rfc/<slug>`,
`/rfc/<slug>/pr/<n>`, and `/proposals/<n>` permanently redirect to
`/p/<default>/e/<slug>[…]` / `/p/<default>/proposals/<n>` (§5/§22.10), so
external "RFC-0001"-style links and bookmarks keep working.
Breaking:
- **`VITE_APP_NAME` is removed.** The build no longer reads it and no longer
fails without it; the deployment name comes from the registry
(`deployment.name` in `projects.yaml`) served at runtime via
`GET /api/deployment`. The same build now serves any deployment. The
build-time `%VITE_APP_NAME%` HTML token and the `inject-app-name` Vite
plugin are gone; `index.html` ships a static `<title>RFC</title>` and JS
sets the real title after config loads.
- **Entry/proposal URLs moved under `/p/<project>/`.** The old SPA routes
`/rfc/:slug`, `/rfc/:slug/pr/:n`, `/proposals/:n` are removed from the SPA
and served as backend 308s instead — so nginx must route `/rfc/` and
`/proposals/` to the backend rather than the SPA `index.html`.
Scope boundary (informational, not breaking): RFC *data* is still served
unscoped for the default project this slice — per-project corpus serving is
the next backend slice (Plan B). Non-default projects show the placeholder.
Upgrade steps:
1. **MUST** update nginx: add `location /rfc/` and `location /proposals/`
blocks that `proxy_pass` to the backend, **before** the SPA `location /`
fallback. The framework's `deploy/nginx/ohm.wiggleverse.org.conf` (and
`testing/web.nginx.conf`) already carry them; a deployment running a custom
vhost MUST add them, or old corpus-root URLs 404 against the SPA instead of
redirecting.
2. **MUST** ensure the registry `projects.yaml` `deployment.name` is set — it
is now the header brand and tab title (already required since 0.33.0).
`tagline` shows on the directory.
3. **SHOULD** remove `VITE_APP_NAME` from `frontend/.env`; it is no longer
read (no error if left — simply ignored).
4. **MUST** rebuild the frontend and deploy. Verify: the header shows the
deployment name from `/api/deployment`; `/` lands in your project (N=1) or
shows the directory (2+); an old `/rfc/<slug>` URL **308**-redirects to
`/p/<default>/e/<slug>`; `/api/health` is green.
5. **MAY** note: the Tier-1 Playwright e2e for these flows lands once the
Tier-1 Docker stack seeds a registry repo + `projects.yaml` (`REGISTRY_REPO`
is unset in `testing/.env.tier1` today, so the dockerized backend can't boot
there post-0.33.0). This slice is covered by Vitest unit tests
(`DeploymentProvider`, `ProjectLayout` theme/guard, `Directory`) + the
backend redirect tests (`backend/tests/test_api_deployment.py`).
## 0.34.0 — 2026-06-04
**Minor — containerize rfc-app for per-PR preview environments (flotilla
SPEC §15). Additive: production is unaffected — it still deploys via the
pin-based on-VM gesture. No upgrade steps for existing deployments.**
Adds a `Dockerfile` (+ `deploy/preview/`) so the operator's flotilla can build
a PR's tree and run it as an ephemeral, scale-to-zero Cloud Run preview with a
seeded **synthetic** database and **zero real secrets** (test-secret env only):
- `Dockerfile` — multi-stage: Vite SPA build → Python runtime serving the SPA
via nginx on `$PORT` and reverse-proxying `/api/`,`/auth/` to a single-process
uvicorn on `127.0.0.1:8000` (mirrors the prod nginx + systemd split, minus
TLS, minus prod secrets). Single process, single SQLite file (§4.2).
- `deploy/preview/entrypoint.sh` — renders nginx against Cloud Run's `$PORT`,
boots uvicorn (which runs migrations), and applies the synthetic seed on a
fresh DB.
- `deploy/preview/seed.sql` — version-controlled synthetic fixture (no PII).
- `deploy/preview/preview.env.example` — the test-secret env shape (Cloudflare
always-pass Turnstile keys, a Mailpit SMTP sink, analytics no-op'd) the
operator loads into flotilla's preview overlay layer.
The framework still knows nothing about flotilla — the image is a generic
container of rfc-app; flotilla is one possible orchestrator of it.
## 0.33.0 — 2026-06-04
**Minor (breaking) — §22 M3 backend Plan A: project registry mirror +
runtime config. The framework now learns its projects from
`REGISTRY_REPO/projects.yaml`; `META_REPO` is retired. Migration `027`
runs automatically on deploy.**
Added:
- **Project registry mirror** (`app/registry.py`): the framework reads
`projects.yaml` from `REGISTRY_REPO` and mirrors its `deployment:`
block and `projects:` entries into the `projects` table + a new
`deployment` singleton. The mirror runs at startup (reconciler) and
on every §4 webhook push to the registry repo.
- **`GET /api/deployment`** — returns `name`, `tagline`, and the list
of visible projects (each item carries `id`, `name`, `type`,
`visibility`). Replaces the build-time `VITE_APP_NAME` as the
authoritative runtime config source (the frontend cut lands in
M3-frontend).
- **`GET /api/projects/:id`** — returns `id`, `name`, `tagline`, `type`,
`visibility`, `initial_state`, and `theme` for a single project.
- **§22.4b `initial_state`** honored at propose time: new RFCs enter
the state named by the project's `initial_state` field (default:
`super-draft`; `bdd` projects default to `active`).
- **§22.4c `unreviewed` flag**: newly proposed RFCs are flagged
`unreviewed = true`. Owners clear it via
`POST /api/projects/:id/rfcs/:slug/mark-reviewed`. The catalog
accepts `?unreviewed=true` to filter to the review queue.
- **Migration `027`** (additive): adds `projects.type` /
`projects.initial_state`, the `deployment` table, and
`cached_rfcs.unreviewed` / `reviewed_at` / `reviewed_by`.
Breaking:
- `META_REPO` is retired. The app **refuses to start** if `REGISTRY_REPO`
is unset. The corpus mirror now reads each project's `content_repo`
from `projects.yaml` rather than from the `META_REPO` env var.
Upgrade steps:
1. **MUST** create a registry repo under your deployment's Gitea org.
2. **MUST** author `projects.yaml` at its root with a `deployment:` block
(`name`, `tagline`) and one `projects:` entry for your existing corpus:
```yaml
deployment:
name: <your deployment name>
tagline: <your tagline>
projects:
- id: default
name: <your project name>
type: document
content_repo: <your old META_REPO value>
visibility: public
```
Keep `id: default` for this release; the pretty-slug re-stamp lands in
the next backend slice, before any `/p/` URL is public.
3. **MUST** set `REGISTRY_REPO=<your registry repo name>` and **MUST**
remove `META_REPO` (or leave it unset — it is ignored but its presence
may cause confusion).
4. **MUST** add a Gitea webhook on the registry repo pointing at
`/api/webhooks/gitea` (same secret as the corpus webhook).
5. **MUST** deploy. Migration `027` runs automatically at startup; the
registry mirror reconciles immediately after. Verify
`GET /api/deployment` returns your project and `/api/health` is green.
6. **SHOULD** rebuild the frontend (no new env var is required until
M3-frontend lands, but the frontend currently still reads
`VITE_APP_NAME` for the display name).
## 0.32.0 — 2026-06-01
**Minor — graduation's integer RFC number is now optional, and RFC
owners + site owners can retire (soft-delete) RFCs. Schema migration
`025_retired_state.sql` runs automatically on deploy; a frontend rebuild
applies the UI.**
Two changes to the §13 lifecycle:
1. **Optional number at graduation (§13.2/§13.3).** Graduation no longer
hard-requires a valid `RFC-NNNN`. The Graduate dialog's integer-ID
field is now optional: it is still pre-filled with the next free
number as a *suggestion*, but the graduating owner may clear it and
graduate with **no number**. When blank, the entry flips to `active`
with `id: null` and the **slug remains the canonical identifier**
(§2.3). `GET …/graduate/check` treats a blank id as valid (`ok:true`);
`POST …/graduate` accepts a blank/absent `rfc_id` and only validates
the `^RFC-\d{4,}$` regex + collision check when a number *is* supplied.
The catalog and RFC view render number-less active entries by their
slug/title (no "RFC-undefined"). RFC-0001's existing number is
grandfathered — `cached_rfcs.rfc_id` was already nullable, so no data
migration was needed for this part.
2. **Retire / un-retire — soft delete (§3, §3.1, §13.7).** A fourth entry
state, `retired`, is added. An RFC's own owners (frontmatter) and site
`owner`-role holders — **not** app admins — may retire an entry
(`POST /api/rfcs/<slug>/retire`); it flips to `retired` via an
auto-merged meta-repo PR, leaving the body and every other field
(including any integer id) intact. A retired entry is removed from
**every** browsing surface: the catalog, the RFC/discussion/branch
views (404), and link pickers/search. It is *not* hard-deleted — the
entry stays in `rfcs/` (git is truth). Un-retire
(`POST /api/rfcs/<slug>/unretire`) restores the prior state and is
**site-owner-only**, so a soft-delete is always recoverable by the
operator but an RFC owner cannot reverse their own retirement. Site
owners find retired entries via a new owner-gated admin surface
(`GET /api/admin/retired-rfcs`, the "Retired" tab) and un-retire from
there or by navigating directly to the entry.
Upgrade steps:
- **MUST** run the migration. `025_retired_state.sql` rebuilds
`cached_rfcs` to widen its `state` CHECK constraint to include
`retired` (SQLite cannot alter a CHECK in place). It runs automatically
at startup via the forward-only migration runner; existing rows are
preserved, and the table is reconstructible from Gitea by the
reconciler regardless (§4). No operator action beyond a normal deploy.
- **SHOULD** rebuild the frontend so the optional-id Graduate dialog, the
Retire affordance, and the owner-only "Retired" admin tab are present.
- No config or secret changes.
## 0.31.4 — 2026-06-01
**Patch — bug fix + UI polish: secondary buttons that were invisible on
light surfaces now render legibly, and the RFC view's breadcrumb action
bar is harmonized into one coherent control group. CSS-only
(`frontend/src/App.css`); no schema, API, config, overlay, or secret
change — a plain frontend rebuild applies it. No upgrade steps. Shipped
from driver session 0059.0.**
`.btn-link` was authored as a *dark-header* utility — white text on a
translucent-white fill (`rgba(255,255,255,0.15)`), the established
on-dark pattern for the app header's "Sign out". But the same class is
reused on **light** surfaces: the RFC breadcrumb action bar
(`RFCView.jsx`), the PR view's diff-mode toggle and "Edit title" control
(`PRView.jsx`), the invitations and inbox modals, and the discussion
panel. On those near-white backgrounds the buttons were white-on-white —
present in the DOM, fully functional, but visually invisible. The
reported symptom: on a super-draft's header, "Metadata", "Claim
ownership", and "Invitations" looked *missing*, while the filled CTAs
("Start Contributing", "Graduate to RFC repo") rendered fine because
their fill carried them.
The fix is root-cause, not a per-site patch:
- The base `.btn-link` rule is now a proper light-surface secondary
button (white fill, hairline `--c-gray-300` border, `--c-gray-700`
label, hover darkens both). This corrects every light-surface reuse at
once.
- The original translucent-on-dark treatment is preserved for the one
legitimate dark-surface use via an `.app-header .btn-link` scope, so
the header "Sign out" is unchanged.
- The breadcrumb action bar (`.breadcrumb-actions`) normalizes every
action — the discuss/contribute toggle, the filled CTAs, and the
secondary buttons — to one height, radius, and type scale, so the row
reads as a single intentional control group. The bar now `flex-wrap`s
instead of clipping buttons off the right edge when the set is wide.
- The diff-mode toggle's active option now reads as clearly selected
(filled ink) rather than relying on a weight change alone.
Smooth hover transitions and the keyboard focus ring were already
provided globally by the v0.21.0 interaction-polish layer, so this
change adds no new motion or focus rules — it only corrects resting-state
color/contrast and harmonizes sizing.
## 0.31.3 — 2026-05-30
**Patch — admin Users tab: "Last seen" reads "Never" for unclaimed
invites. Visual/logic only in `Admin.jsx`. A plain frontend rebuild
applies it.**
An admin-created invite row showed a real-looking "Last seen" timestamp
identical to "Signed up," implying the invitee had visited when they
hadn't. Cause: `users.last_seen_at` is `NOT NULL DEFAULT (datetime('now'))`
(`migrations/001_users_and_audit.sql`) and the invite INSERT
(`invites.py`) sets neither timestamp, so both default to the
row-creation instant; `last_seen_at` only advances on a real
authentication. Since an unclaimed invite has provably never
authenticated (that unclaimed state is exactly what drives the
"PENDING INVITE" badge), the Users tab now renders **"Never"** for the
Last-seen cell of a pending-invite row instead of the misleading
default. Signed-up (the invite-created date) is unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.2 — 2026-05-29
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
The welcome read-view was jammed against the catalog divider with no
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
carries a bare `.main-pane { padding: 0; display: flex }` override (the
§8 three-column RFC shell) that shadows the earlier padded read-view
rule, so the pane provides no padding — and `.welcome` (just
`max-width`) never compensated. The welcome surface now owns its own
breathing room: 56px top / 48px side gutters, a capped 680px measure,
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
rhythm at `--leading-relaxed`. Applies to both the signed-out and
signed-in welcome (same `.welcome` class).
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.1 — 2026-05-29
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
secret change). A plain frontend rebuild applies it.**
Two latent CSS defects fixed:
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
admin-created-but-unclaimed user rows rendered as bare parenthetical
text. It's now a quiet amber pill, consistent with the other status
badges.
- **`.btn-link-quiet` never reset native button chrome.** Used as a
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
close ×, and link-buttons in Login / BetaPending), it kept the
browser's default grey button box. The reset that the `.otc-login`
scope already carried is folded into the base rule, so every
`btn-link-quiet` is now a true quiet link.
Users-tab cleanups, all token-based:
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
two lines); timestamps render as an intentional date-over-time stack
instead of a ragged mid-value wrap; the duplicated email in a row's
subline (the handle already *is* the email when there's no Gitea
login) is de-duplicated; the "Create user + invite" action moves
flush-right beside the title; inline DB-column references in the
intro copy read as quiet chips.
Header:
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
light-surface treatment — a `gray-200` border and a `gray-50` hover —
that rendered as a pale box in the nav and went white-background /
white-icon (invisible) on hover. It now speaks the nav-link
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
brightening to white on a faint translucent hover, unread badge
unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.0 — 2026-05-29
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
longer graduate into their own Gitea repositories; every RFC lives in
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
graduation is an in-place `super-draft → active` state flip that keeps
the body in the entry. The per-RFC-repo machinery — repo creation,
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
five-step transactional sequence and its rollback — is removed. This is
the framework change behind a much simpler deployer story: one content
repository, every RFC under `rfcs/`.**
What changed, concretely:
- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
opens one meta-repo PR that re-serializes the entry with
`state: active`, the integer `id`, and `graduated_at`/`graduated_by`
— **body unchanged, `repo` left null** — then auto-merges it. No repo
is created, nothing is seeded, and there is no rollback (an open- or
merge-failure leaves the entry a super-draft; a failed merge's PR and
branch are cleaned up). The Graduate dialog drops the **Repo name**
field (two fields now: integer ID + owners) and the progress stack is
two steps (`open_pr`, `merge_pr`).
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
so an active RFC's branches, body-edit PRs, threads, flags, and
`changes` all live on the meta repo exactly as a super-draft's do.
`promote-to-branch` names an active RFC's auto-branch
`edit-<slug>-<hex>` so the shared-repo cache can attribute it.
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
kept, so they coexist with the flip.
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
meta-only entries (the reconciler only sweeps entries with a non-null
`repo`, of which there are none after the fold-back below).
**Upgrade steps:**
- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
into its meta entry before/with this deploy: restore the per-RFC
`RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
`state: active` and the integer `id`), and archive the per-RFC repo.
An entry left with a non-null `repo` keeps using the retained legacy
read path, but **no new** per-RFC repos are ever created. For OHM,
RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
- No schema migration, no new config, no new secret, no overlay change.
A plain code deploy applies it; the running reconciler reconciles the
catalog on its next sweep (≤5 min).
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
endpoint are **retained** (the field for schema stability + legacy
entries; the endpoint as an informational, non-blocking surface), so
no client contract is removed — `graduate/check` simply no longer
returns a `repo` field and never reports `blocking_prs` as a gate.
## 0.30.2 — 2026-05-29
**Patch — header nav label: the persistent chrome link reverts from
"Philosophy" back to "About." Display text only — the route
(`/philosophy`), the `header-about` class, and the page itself are
unchanged. A plain frontend rebuild applies it; no schema, API, config,
overlay, or secret change.**
The §14.3 persistent link was relabeled "About" → "Philosophy" in
v0.21.0. This restores "About" as the neutral, framework-native label
(the CSS class `header-about` and the surrounding comment already call
it "the About link"). A deployment that wants a more pointed framing
can title its own About page in the `PHILOSOPHY.md` content the
`PHILOSOPHY_PATH` override serves.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.30.1 — 2026-05-29
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
lingers as a phantom "pending idea." No schema, API, config, overlay,
or secret change — a plain code deploy applies it, and the running
reconciler clears any existing ghost on the next sweep (≤5 min) once
deployed. Shipped from driver session 0040.0.**
`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
deleted**, Gitea stops reporting the real branch name and returns the
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
stayed frozen at `open` forever — so the entry showed as **both** a
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
recovers the original branch name from the already-stored `cached_prs`
row (which retains the real `head_branch` from when the PR was open;
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
ref. Regression test added in `test_propose_vertical.py`
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).
Surfaced through the ROADMAP #35 operator authoring lane, which merges
idea PRs from the CLI with branch-deletion enabled — a path the web UX
never exercises (it leaves branches in place, so
`default_delete_branch_after_merge` stays false). The framework should
not depend on branches outliving their merge, hence the framework-level
fix rather than a tooling workaround.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
periodic reconciler self-heals any existing phantom on its next sweep.
## 0.30.0 — 2026-05-29
**Minor — documentation: the user guide (`DOCS.md`, served at
`/docs/user-guide` via `/api/docs`) brought back in sync with the
shipped app. No code, schema, API, config, overlay, or secret change —
a plain code deploy serves the updated guide. Shipped from driver
session 0037.0.**
The guide had drifted since it was first written: it still described
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
and predated several shipped surfaces. Updated to match v0.7.0v0.29.0:
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
longer described as the gate.
- **Proposing a new RFC** now lists five fields, adding the optional
"What will you be using this RFC for?" use-case field (#26) and noting
the AI tag-suggestion disclosure (#27).
- **Roles & permissions** documents the `pending` state.
- New **Invitations, cross-references, and contribution requests**
section covers owner invitations (#12) and the RFC auto-link /
create-RFC / ask-to-contribute affordances (#28).
- New **Privacy and cookies** section covers the consent banner and
consent-gated analytics (#11 / #13).
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
file served verbatim by `/api/docs`. A plain code deploy at this tag
serves it. A deployment that overrides the guide via `DOCS_PATH`
supplies its own copy and is unaffected.
## 0.29.0 — 2026-05-28
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
applied migration (024, additive: a new `contribution_requests` table).
No config/overlay/secret change; no nginx/systemd change. A plain code
deploy + the auto-migration picks it up. Shipped from driver session
0033.0.**
Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
(`backend/app/rfc_links.py`) and its renderer
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
matched term into one of three buckets — active link (Part 1, unchanged),
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
pass, with precedence active > pending > candidate at any position. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.
- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
**multi-word tag** from the #27 tag taxonomy that has no defining RFC
(no active or super-draft RFC whose slug/title is that term) — renders,
for a viewer with create rights (`permission_state='granted'`), as an
inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
with the term pre-filled as the title (`ProposeModal` gained an
`initialTitle`; the affordance routes via `?propose=<term>`, read in
`App.jsx`). The heuristic is deliberately conservative — multi-word is
the same false-positive guard the title rule uses, so a single common
tag word (`identity`) is never offered. Broader candidate detection
(capitalized phrases mined from text, terms repeated across recent PRs,
or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
deferred extension.
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
with a contribution surface, not yet graduated) — renders, for a
signed-in non-owner, as an inline "ask to contribute" affordance
carrying the owner's display name ("<owner> is working on an RFC for
'<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
with three fields — **who I am** (required), **why I'm asking**
(required), **what I'd use it for** (optional, mirroring #26). Submitting
lands a `contribution_requests` row and one actionable §15 inbox
notification per owner (new kind `contribution_request_on_pending_rfc`,
category `personal-direct` — so it reuses the existing
`email_personal_direct` preference, no new toggle). In the inbox the
owner sees the requester's who/why/use-case inline with **Accept** /
**Decline**. Accept fires #12's owner-invite flow with the requester as
the invitee (a `contributor` `rfc_invitations` row + the existing invite
email) and echoes a notification back to the requester; Decline closes
the request and notifies the requester. Pre-merge idea PRs (not yet in
`cached_rfcs`, no contribution surface) are deliberately out of scope —
a documented future extension.
New endpoints (all under the existing `/api` router):
`GET /api/rfcs/{slug}/contribution-target`,
`POST /api/rfcs/{slug}/contribution-requests`,
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.
The owner-invite issue path was refactored into one reusable chokepoint,
`api_invitations.issue_invitation(...)`, shared by the manual invite
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
and transactional email stay identical.
Upgrade steps:
1. Deployments **MUST** apply the auto-run migration `024` (additive: the
new `contribution_requests` table; no existing table or row is
touched). The standard deploy path runs pending migrations on start —
no manual step beyond deploying the new code.
2. No config, overlay, or secret change is required. The Part 2 candidate
affordance reuses #27's tag taxonomy; it surfaces only when the corpus
carries multi-word tags without a defining RFC, and the create
affordance renders only for beta-granted viewers. Part 3's email reuse
sends through the existing invitation SMTP path — no new key.
## 0.28.0 — 2026-05-28
**Minor — security-hardening follow-up (Session-0026 audit, informational
findings I3 + I4). No operator action required: no migration, no schema
change, no config/overlay change, no API/behavior change for any caller.
A plain code deploy picks it up. Shipped from driver session 0032.0.**
Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth:
- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
accepted a `body_html=` argument that built a `multipart/alternative`
body, but no send path ever passed it — every rfc-app mail is plain
text. An unused branch that would emit HTML built from (potentially
unescaped) user content is the C1 stored-XSS class waiting in the mail
channel. The branch is now a loud guard: passing `body_html` raises
`NotImplementedError`. The argument is kept in the signature for
documented future symmetry; enabling HTML mail becomes a deliberate
change that MUST HTML-escape user content at the call site and remove
the guard in the same commit.
- **I4 — Turnstile siteverify no longer blocks the event loop.**
`turnstile.verify_token` was a synchronous function issuing a blocking
`httpx.post` from inside the async `/auth/otc/request` handler, so a
slow CloudFlare response stalled the single worker for up to the 10s
timeout. It is now `async` and awaits the call on an
`httpx.AsyncClient` (matching the codebase's existing async-httpx
pattern), isolated behind a narrow `_siteverify_post` seam. The sole
caller (`main.py`) now `await`s it.
Upgrade steps: **none.** Both changes are internal. The `verify_token`
signature changed from sync to `async` (callers must `await`), but the
only caller is in-tree (`main.py`) and is updated in this release; no
deployment-facing surface, config key, or migration is affected.
## 0.27.0 — 2026-05-28
**Minor — security-hardening release (Session-0026 audit remediation).
One auto-applied migration (023); one behavior change that re-prompts
device-trust; deployments MUST re-apply the nginx + systemd files.**
This is the work cut as the "v0.25.0 security-hardening" branch; it
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
was in flight. Shipped from driver session 0030.0.
- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
(DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
`MarkdownPreview`, both `ProposalView` sinks (entry body +
`proposed_use_case`), and `Editor`. A hook adds
`rel="noopener noreferrer"` to `target=_blank` links. `marked` no
longer passes raw HTML / `javascript:` URIs to the DOM, so a
contributor can no longer plant a payload that runs in an admin/owner
session during review.
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
(per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
and the passcode check/verify paths; a per-account OTC-verify lockout
(migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
- **M1 — device-trust lookup no longer table-scans.** The device-trust
cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
reads the one indexed row and bcrypt-checks only it, instead of
bcrypt-checking every row in the table on each unauthenticated
`/auth/device-trust/start`.
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
X-Content-Type-Options, Referrer-Policy) added to the nginx server
block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
defaults on; a dev box on plain http sets it `false`).
- **M5 — bounce webhook fails closed.** An unset
`WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
(503) instead of leaving it open; a dev opts back in with
`RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
`RestrictAddressFamilies`, `SystemCallFilter`, …).
Upgrade steps:
1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
at startup via `db.run_migrations`.
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
so existing "trusted device" cookies no longer match; affected users
are re-prompted for device verification once. No data migration; old
rows are simply never matched and age out.
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
**not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
`deploy/systemd/rfc-app.service`. After deploying the code, copy both
to their system locations, then `nginx -t && systemctl reload nginx`
and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
and L4 sandboxing do not take effect until this is done.)
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
returns 503 (closed). No legitimate bounce source is wired today, so
503 is the safe default.
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
won't be sent. Production over HTTPS leaves it unset (Secure on).
## 0.26.0 — 2026-05-28
**Minor — no schema migration, no new secret, no config, no upgrade
steps. Additive read-time enrichment; deployments inherit it on deploy
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
**accepted** RFCs inside PR descriptions and comment text now render as
inline links to the referenced RFC. Shipped from driver session 0029.0,
in parallel with the v0.25.0 security-hardening session — hence the
version-slot gap (0.25.0 is that session's; this took the next free slot
per the roadmap's "claims the next available version number" rule).
Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
in this release — Part 1 ships first as the easy win, exactly as the
roadmap row anticipates.
- **Where it links.** The PR review page's description and review
comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
Branch-chat (the AI-collaboration surface) is intentionally out of
scope for Part 1 — a noted follow-up.
- **Read-time, not submit-time.** The roadmap row phrases the scan as
happening "at submit/post time"; this ships it as **read-time**
enrichment instead. The intent the row actually names — "not as live
compose preview" — holds (drafts are never scanned, only submitted
content on read). Read-time was chosen because (a) links track the
*live* accepted-RFC set — a newly-accepted RFC starts linking in older
comments, a withdrawn one stops linking everywhere — rather than
freezing stale at submit; (b) it needs **no migration** (no derived
data to persist); (c) the active-RFC corpus is small and cache-resident,
so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
the session transcript.)
- **XSS-safe by construction.** The backend returns structured *segments*
(a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
never HTML. The new `LinkedText` frontend component maps segments onto
React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
comment author cannot inject markup through this path. Every enriched
field keeps its raw `text`/`description` alongside the `*_segments`, so
any non-segment-aware caller is unaffected.
- **Conservative matching (false-positive-averse).** A reference links
only when it is unlikely to be coincidental: an `rfc_id` token
(`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
**hyphenated** slug (`open-human-model`). A single common-word title or
slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
would turn every prose "human" into a link. Matching is case-insensitive,
word-boundary-anchored, longest-match-wins, and suppresses an RFC's
self-references inside its own PR/discussion. The roadmap's "curated
canonical-terms list" remains an explicit future opt-in rather than a
guessed-at default. New module: `backend/app/rfc_links.py`.
Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
rules, word boundaries, longest-match, case-insensitivity, casing
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
(PR description + review comment + discussion comment all surface
`*_segments`; self-reference suppression). Full suite 363 green; frontend
builds clean.
Upgrade steps:
1. None. This release is purely additive: no migration, no new
environment variable, no secret, no overlay. A deployment picks up RFC
auto-linking the moment it deploys this version. (RFCs only link once
they are in the `active` state — proposed/super-draft and withdrawn
RFCs are never link targets, matching the §11.3 universal-public read
rule.)
## 0.24.0 — 2026-05-28
**Minor — one new secret required before this version serves tag
suggestions; no schema migration; no behavior change for deployments
that do not bind the key.** Roadmap item #27: as a contributor fills in
the propose-RFC form, the backend asks Claude Haiku to recommend tags
drawn from the collection's existing tag set, surfaced as clickable
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
"Slice 2" AI-suggested chips that the propose modal has carried a
deferred placeholder for since Slice 1.
- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
rate-limited). Takes the partial draft (`title`, `pitch`,
`use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
…] }`. The model is constrained to the corpus's existing tags — v1
tags are free-form chip input, so "the taxonomy" is the de-facto set
of distinct tags the existing RFCs carry. The model MUST NOT invent
tags; taxonomy extension is a deliberate out-of-scope follow-up.
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
regardless of the `ENABLED_MODELS` chat-picker universe, via the new
`providers.construct_haiku()` factory. There is no RFC slug at propose
time, so the §6.7 per-RFC funder credential path does not apply — the
surface runs on the operator's own `ANTHROPIC_API_KEY`.
- **Degrades to silence, never error.** No key bound, an empty corpus,
an empty draft, a rate-limited caller, or an unparseable model reply
all yield an empty list; the propose modal hides its suggestion row,
and the rest of the app is unaffected. So a deployment that does not
set the key sees no change at all.
- **Privacy disclosure.** The modal carries an inline note that the
draft text is sent to Anthropic to generate the suggestions. This is
required for honesty (the text leaves the deployment before the RFC
is submitted); cookie-consent does not gate it because the user has
actively typed into a draft surface. The exact wording wants a
counsel pass before OHM's deploy, per the #22 drafting discipline.
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
stale-response guard); suggestion chips are clickable and add to the
tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
any non-OK response resolves to `[]`.
Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
universe-constrained filtering, no-key / empty-corpus / rate-limit
paths, plus units for the universe gather, the tolerant reply parser,
the max cap, the empty-draft short-circuit, and provider-failure
fallback. Full suite 351 green.
Upgrade steps:
1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
work. On OHM this is an operator gesture — the key never touches the
conversation. Set it from your own terminal via stdin (so the bytes
never land in shell history):
```bash
printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
secret set ohm-rfc-app ANTHROPIC_API_KEY
```
(The §18 chat stack already reads this same key, so a deployment
that has chat configured **MAY** already have it bound — confirm with
`flotilla secret list ohm-rfc-app`.) If the key is absent the app
still boots and serves normally; tag suggestions are simply
unavailable until it is set.
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
(default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
defaults apply when unset.
3. You **SHOULD** have counsel review the modal's disclosure wording
before exposing the surface to users, per the #22 drafting
discipline — the copy is honest as written, but the legal review is
the right call for any "your text is sent to a third party" notice.
## 0.23.0 — 2026-05-28
Roadmap item #29: signing in lands the user on their most recently
viewed app state instead of the empty-state home view. Shipped from
driver session 0022.0. Per-user, server-side, on by default; first-ever
sign-ins still land on home.
1. **Server-side last-state.** A new `user_session_state` table holds one
row per user: `last_route TEXT`, `last_route_state TEXT` (light view
state encoded as JSON — SQLite has no native JSONB — never draft-buffer
contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
`last_updated_at`. Per-user (not per-device), matching the
"go to where I was last" intent.
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
useLastState.js`) debounce-posts the current route + light state to
`PUT /api/me/last-state` for authenticated users only; anonymous
sessions are a no-op. The handler upserts the user's row and no-ops
when `resume_enabled` is 0.
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
`resume_enabled`) is folded onto the existing `/api/auth/me` payload
(no new GET endpoint), so the frontend reads it on boot. On a hard
sign-in landing (app booted on `/`), the app redirects to the stored
route. The redirect is gated on the #21-Part-C Amplitude `identify`
having fired (`identifyReady`), preserving identify-then-track
ordering — the first event on the resumed route carries the user_id.
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
unreadable) are a graceful no-op — existing routing falls through to
the catalog/empty-state. Opt-out ships at the column level
(`resume_enabled`); the profile-settings toggle UI is a follow-up.
Stored state is route + light view state only, never draft contents
(documented in `SPEC.md` §6.8).
Migration `022_user_session_state.sql` creates the table. No new
environment variables, overlay keys, or secrets.
Upgrade steps:
1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
runs automatically on next boot (the migration runner globs
`backend/migrations/*.sql` and applies any not yet recorded in
`schema_migrations`). The migration is additive — a new table — and
requires no data backfill.
2. No new environment variables, overlay keys, or secrets. No operator
action beyond the standard `flotilla deploy ohm-rfc-app`.
## 0.22.0 — 2026-05-28
Roadmap item #26: an optional **"What will you be using this for?"**
field on both propose surfaces. Shipped from driver session 0022.0.
Additive and backward-compatible — no behavior changes for anyone who
leaves the field blank.
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
field (`pitch`) sits a new optional **"What will you be using this
RFC for?"** textarea. "Needed" is the abstract justification; "using
it for" is the concrete ground-truth use case — captured without
being forced.
2. **Propose-PR modal.** Below the required "Why is this change needed?"
field (`description`) sits a new optional **"What will you be using
this change for?"** textarea.
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
view (`PRView`) render the captured use case alongside the existing
"why" text, with a muted "left blank" treatment when absent.
4. **Persistence (deployment note).** In this deployment the framework's
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
path is endpoint → Gitea → reconcile (and the reconciler doesn't
carry the new field), the durable home is a canonical, reconcile-proof
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
the propose/open endpoints write directly and the view endpoints read
back. The literal nullable `proposed_use_case` columns named by the
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
any future reconciler that learns to carry the field. NULL/blank use
cases simply never write a side-table row — absence is "left blank".
Migration `021_proposed_use_case.sql` adds the two cache columns
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
`proposed_use_cases` canonical table, and its lookup indexes. The
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
so the added cache columns survive reconciles. Backend validators accept
the field as NULL/omitted (no required-validation) with an 8000-char cap
matching the existing PR `description` bound; blank/whitespace is treated
as absent.
Upgrade steps:
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
runs automatically on next boot (the migration runner globs
`backend/migrations/*.sql` and applies any not yet recorded in
`schema_migrations`). The migration is additive — nullable cache
columns plus a new table — and requires no data backfill.
2. No new environment variables, overlay keys, or secrets. No operator
action beyond the standard `flotilla deploy ohm-rfc-app`.
## 0.21.0 — 2026-05-28
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
+2
View File
@@ -1,3 +1,5 @@
@~/.claude/wiggleverse.md
# Working in rfc-app
This is the framework — the software that hosts an RFC standardization
+105 -12
View File
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
## Signing in
While the framework is in private beta, only invited email addresses
can complete sign-in. If your email is on the allowlist, the
"Sign in" button in the header completes the flow and lands you on
the catalog with full read and write access. If your email is not on
the allowlist, you'll be sent to a short "pending" page explaining
the gate.
Anyone can start the sign-in flow with their own email address — there
is no invite-only allowlist. Sign-in is passwordless:
Once you have an account, you're a **contributor** by default — the
role that grants every write affordance the app exposes, scoped by
the per-RFC and per-branch rules described below.
1. **Enter your email.** If the deployment has human verification
enabled (a Cloudflare Turnstile challenge), you complete it here.
2. **Enter the one-time code.** The app emails you a short numeric
code; entering it signs you in. Codes expire after a few minutes,
and repeated wrong entries briefly lock the email.
3. **Set a passcode (optional).** After your first code sign-in you
can set a passcode. On later visits you sign in with email +
passcode, with the one-time code as the forgot-passcode fallback.
4. **Trust this device (optional).** You can mark a device trusted for
30 days to skip the code/passcode step on it. Trusted devices are
listed in your settings and can be revoked individually or all at
once.
### Getting write access
Signing in gives you an account, but write access is gated. The first
time you sign in you're asked for your first name, last name, and a
short note on why you'd like access; you then land on a "request in
review" page. While your account is **pending**, you can read
everything an anonymous visitor can but cannot write — no chat,
propose, branch, PR, or discussion post. Once an admin **grants** your
account you become a **contributor**, the role that carries every
write affordance the app exposes, scoped by the per-RFC and per-branch
rules described below.
An admin can also create your account ahead of time and email you an
invite link. Clicking it claims the account and signs you in with the
role the admin assigned, skipping the one-time-code step.
---
## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at
the bottom of the catalog opens a small modal that collects four
the bottom of the catalog opens a small modal that collects five
things:
- **Title.** The word, concept, or topic this RFC would define.
@@ -65,8 +86,13 @@ things:
inline.
- **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can
accept, dismiss, or type your own.
- **Use case.** Optional. *What will you be using this RFC for?*
the concrete application driving the proposal, as distinct from the
abstract case for it. Leaving it blank is fine.
- **Tags.** Optional. If the deployment has AI tag suggestion
enabled, suggested tags appear as you fill the form (with an inline
note that the text you've entered is sent to the model that
generates them); you can accept, dismiss, or type your own.
Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new
@@ -167,6 +193,51 @@ either requires a contributor account.
---
## Invitations, cross-references, and contribution requests
Three connected surfaces help the right people find and join the
right RFC.
### Owner invitations
An RFC's owner (or an app-wide admin or owner) can invite a specific
person to that RFC from the "Invitations" control in the RFC header.
The invite names an email and a role for *this RFC*:
- **contributor** — can open PRs and join the discussion;
- **discussant** — can join the discussion only.
The invitee gets an email with an accept link; accepting adds them as
a collaborator on that RFC. The invitations panel lists every invite
with its status (pending / accepted / expired / revoked); pending
invites can be revoked. An invitation is per-RFC — it does not change
the invitee's app-wide role, and it cannot lift the pending gate: the
invitee still needs a granted account to write.
### RFC cross-links in PRs and comments
When a PR description or a comment mentions an existing active RFC —
by its ID, its multi-word title, or its slug — the framework renders
that mention as a link to the RFC. The matching is conservative by
design (single common words are never auto-linked), and the links are
computed at read time, so nothing is rewritten in what you typed.
### "Create" and "ask to contribute" offers
The same scan surfaces two affordances inline:
- If a term looks like it should have an RFC but none exists yet, a
reader who has create rights sees a **"create RFC for '<term>'"**
link that opens the propose modal with the title pre-filled.
- If a term matches a *pending* RFC (a super-draft someone already
owns), a signed-in reader sees an **"ask to contribute"** offer
naming the owner. It opens a short request form — who you are, why
you're asking, and optionally what you'd use the RFC for. The
request lands in the owner's inbox; the owner can **accept** (which
sends you an owner invitation) or **decline** (which notifies you).
---
## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
entirely. The framework names a single "owner zero" at
bootstrap.
Between anonymous and contributor sits one transient state:
**pending**. A freshly signed-in account that hasn't been granted
access yet (see [Signing in](#signing-in)) reads everything an
anonymous visitor can, but no write affordance unlocks until an admin
grants it. Granting promotes the account to contributor; an admin can
also revoke a granted account back to a no-write state. These
transitions are recorded in the `permission_events` log.
The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that
@@ -594,6 +673,20 @@ review.
---
## Privacy and cookies
A consent banner appears on your first visit and lets you choose
which cookie categories to allow — essential always, with analytics
and other categories opt-in. The choice is remembered and can be
changed any time from the privacy/cookies controls in settings.
Analytics only load if you opt in: the framework defers the analytics
SDK behind your consent, so declining means it is never initialized.
The `/privacy` and `/cookies` pages describe what's collected and
why; a deployment can point those pages at its own fuller policy.
---
## Where to learn more
- The framework's *why* lives in [the philosophy
+62
View File
@@ -0,0 +1,62 @@
# rfc-app container image — the Cloud Run keystone for per-PR preview
# environments (flotilla SPEC §15). NOT used by production: prod still deploys
# via the pin-based on-VM gesture (flotilla §8). This image exists so flotilla
# `preview up --pr=N` can build a PR's tree and run it as an ephemeral,
# scale-to-zero Cloud Run service with a SEEDED SYNTHETIC database and ZERO real
# secrets (test-secret env only — flotilla §15 / §3 invariant 1).
#
# Single container, single port: nginx serves the built SPA on $PORT (Cloud Run
# injects it) and reverse-proxies /api/ + /auth/ to a single-process uvicorn on
# 127.0.0.1:8000 — mirroring the prod nginx + systemd split
# (deploy/nginx/ohm.wiggleverse.org.conf, deploy/systemd/rfc-app.service), so a
# preview behaves like prod minus the secrets. Single uvicorn process + single
# SQLite file, per §4.2 (never scale workers).
#
# Build context is the repo root: docker build -t <image> .
# ---- stage 1: build the Vite SPA -------------------------------------------
FROM node:20-slim AS web
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# VITE_* values are baked into the bundle at build time (intentionally public —
# §8 phase-5 note). VITE_APP_NAME is REQUIRED by vite.config.js (the framework
# ships no default — each deployment names itself); previews self-name. Turnstile
# uses Cloudflare's always-pass test SITE key so the widget renders + auto-passes.
ARG VITE_APP_NAME="RFC App (preview)"
ARG VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
ARG VITE_AMPLITUDE_API_KEY=
RUN VITE_APP_NAME="$VITE_APP_NAME" \
VITE_TURNSTILE_SITE_KEY="$VITE_TURNSTILE_SITE_KEY" \
VITE_AMPLITUDE_API_KEY="$VITE_AMPLITUDE_API_KEY" \
npm run build
# ---- stage 2: runtime (backend + nginx) ------------------------------------
FROM python:3.12-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx gettext-base sqlite3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/rfc-app
# Backend deps first for layer caching.
COPY backend/requirements.txt backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ backend/
COPY deploy/preview/ deploy/preview/
# health.py reads VERSION at parents[2] (== /opt/rfc-app/VERSION).
COPY VERSION ./
COPY --from=web /app/frontend/dist/ frontend/dist/
RUN chmod +x deploy/preview/entrypoint.sh
# Cloud Run injects $PORT (default 8080); the entrypoint renders nginx against
# it. The synthetic preview DB lives on the container's ephemeral filesystem and
# dies with the instance (zero create/seed/drop lifecycle — §15).
ENV PORT=8080 \
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
EXPOSE 8080
ENTRYPOINT ["deploy/preview/entrypoint.sh"]
+19
View File
@@ -0,0 +1,19 @@
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install
tier1-up:
docker compose -f testing/docker-compose.yml up --build -d
tier1-down:
docker compose -f testing/docker-compose.yml down -v
tier1-logs:
docker compose -f testing/docker-compose.yml logs -f
fe-unit:
cd frontend && npm run test:run
e2e-install:
cd e2e && npm ci && npx playwright install chromium
e2e:
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
+327 -184
View File
@@ -30,13 +30,44 @@ providers (Anthropic, Google, OpenAI / GitHub Copilot).
## 1. Repository topology
Each RFC is its own Gitea repository. There is in addition exactly one **meta
repository** that serves as the authoritative directory of all RFCs in the
system — drafts, active work, and retired entries alike.
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**.
All Git operations across all repositories are performed by a single **bot
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.
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 repos themselves; their accounts exist for OAuth identity
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.
@@ -67,8 +98,8 @@ The meta repo's `main` branch contains:
arriving at the meta-repo via Git rather than via the app. The **index
below the header is regenerated by CI on every merge to main** and lists
active RFCs, super-drafts, and (eventually) retired entries with links
into the corresponding entry files and, when present, the RFC's own
repository.
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.
@@ -84,7 +115,9 @@ slug: human
title: Human
state: super-draft # super-draft | active | withdrawn
id: null # null until graduated; then "RFC-0042"
repo: null # null until graduated; then "wiggleverse/rfc-0042-human"
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
@@ -103,8 +136,9 @@ tags: [identity, schema]
## Why this RFC is needed
(One- or two-paragraph pitch from the proposer. While the entry is a
super-draft, the body may grow into the actual draft document. On
graduation, this body migrates to RFC.md in the new repo; see §13.)
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
@@ -126,16 +160,31 @@ an idea costs nothing in identifier space.
## 3. RFC states
There are three canonical states stored in entry frontmatter:
There are four canonical states stored in entry frontmatter:
- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone
can claim ownership; an owner is required before graduation.
- **`active`** — the entry has been graduated. A dedicated RFC repo
exists, `repo:` points to it, and real branches/PRs/conversation happen
there.
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
@@ -155,28 +204,47 @@ 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
For now, changing state in the meta repo entry is the *only* required
operation for a state transition. Graduation has additional side effects
(creating the new repo, seeding it); those are covered in §13. We
deliberately do not tag commits, lock branches, or post notices on
state change for now — the entry frontmatter is the single source of
truth and any further automation is a later refinement.
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, RFC repo content, branches, PRs, commits. Nothing in this system
overrides Gitea on those concerns.
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:
@@ -187,10 +255,11 @@ process, that serves three purposes:
assignments, per-branch grants, branch visibility settings, chat
history, audit logs. This data is canonical; it is not cached, it
is owned by the app.
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and
each super-draft's entry body) is cached for left-pane previews and
read-without-roundtrip. Branch bodies are *not* cached; the editor
fetches them live from Gitea when opened.
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
@@ -201,7 +270,7 @@ Two paths keep the cache current, running in parallel:
A webhook handler does a focused re-read of just what changed.
Typical latency: sub-second.
- **A periodic reconciler** runs every five minutes and does a full
sweep — list meta-repo entries, list each RFC repo's branches and
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.
@@ -714,6 +783,47 @@ 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
@@ -1566,42 +1676,43 @@ contributor — identical to an active RFC's main chat per §11.4 plus
### 9.8 Graduation handoff additions
§13's graduation sequence was written before this section's
machinery existed. The mechanics §13 needs to absorb fold inline
into §13.2 and §13.4 in their respective sections; the substantive
additions are captured here for cross-reference:
> **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.
- **Open body-edit PRs block graduation.** §13.3's step 3 removes
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. The Graduate dialog disables the confirm
button if any meta-repo PR is open against `rfcs/<slug>.md`. The
precondition is enforced before the bot starts §13.3's sequence,
so §13.3's rollback complexity does not grow.
- **Bare edit branches survive graduation.** Edit branches without
an open PR are not blocked. They remain on the meta repo subject
to §12's hygiene timers. The contributor can re-cut against the
new RFC repo's main if they still want the work. The branch chat
persists per §8.4 as historical record even after auto-close, so
the argument that produced the work is preserved regardless of
whether the work itself merges.
- **Chat migration includes range and paragraph sub-threads.**
§13.4's chat-follows-the-work rule covers the whole-doc main
thread; it extends to range and paragraph sub-threads on the
super-draft's main view, which migrate as part of the same
movement. Anchors re-resolve against `RFC.md` on the new repo;
since §13.3's step 2 seeds `RFC.md` from the super-draft body
verbatim, anchors typically locate the same content. Where they
do not, §8.12's stale mechanic engages.
- **Pre-graduation history surfaces from the new RFC view.**
Meta-repo edit-branch chats, flag threads, and `changes` rows
stay attached to their original `branch_name` on the meta repo;
they do not migrate. A **"Pre-graduation history"** affordance on
the new RFC view surfaces these — the slug remains the canonical
key per §2.3, so the query is a straightforward lookup of
`threads` and `changes` rows where `rfc_slug = <slug>` and
`branch_name` begins with `edit/<slug>/`. UI affordance; no data
movement, no rollback cost.
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.
---
@@ -1912,16 +2023,23 @@ email request to an owner.
---
## 13. The graduation flow (super-draft → active RFC repo)
## 13. The graduation flow (super-draft → active, in place)
Graduation is initiated by an owner or admin clicking "Graduate to RFC
repo" on a super-draft's page. The button is disabled with a tooltip
when the super-draft has no owners (see §13.1) or when any meta-repo
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open
body-edit PRs would attempt to re-introduce a body to a frontmatter-
only entry after step 3 of §13.3). Bare edit branches without an open
PR do not block graduation; they remain on the meta repo subject to
§12's hygiene timers.
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)
@@ -1942,137 +2060,162 @@ broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog
Clicking "Graduate to RFC repo" opens a small dialog with three
editable fields:
Clicking "Graduate" opens a small dialog with two editable fields:
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
formatted as `RFC-NNNN`. Editable to allow gap reservations but the
default is just the next number.
- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but
constrained to valid Gitea repo names.
- **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 integer-ID
collision against existing IDs, repo-name pattern against valid
Gitea name rules, the at-least-one-owner constraint on the picker.
Errors render as a short line of text beneath the offending field.
The repo-name collision check is re-issued atomically server-side
on confirm, since a concurrent graduation could land between
dialog-open and submit. While any field is invalid, the confirm
button is disabled and its tooltip names the first blocker
specifically — "Integer ID 42 is already taken," "Repo name must be
lowercase letters, digits, and dashes," "Add at least one initial
owner" — the same grammar the precondition popover below uses, so
the dialog and the gate read as one surface rather than two
competing styles.
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 preconditions
from §13's opening paragraph fail — no owners on the entry, or any
open meta-repo PR against `rfcs/<slug>.md`. The disabled button
opens a small popover on hover or click that lists each failing
precondition as its own line item with an inline remediation
affordance per item. "No owners claimed yet" surfaces a "Copy share
link" affordance for surfacing the super-draft to a would-be
claimer, plus a secondary "Claim ownership yourself" — admins are
contributors per §6.1, so they can claim if they intend to graduate
solo. "N open body-edit PRs" expands inline within the popover to a
list of the offending PRs, one per row, carrying each PR's title,
author, and last-activity timestamp plus inline merge, withdraw,
and open-in-new-tab affordances; admins hold §6.3 authority on
those PRs and can resolve the precondition from the popover without
leaving the Graduate context.
The 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.
The preconditions are enforced before the bot starts §13.3's
sequence, so §13.3's rollback complexity is unchanged.
### 13.3 The flip
### 13.3 The transactional sequence
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).
Confirming the dialog runs this sequence as the bot:
```
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned OR null
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
```
1. Create the new Gitea repo.
2. Seed it with an initial commit on `main` containing:
- `README.md` (header pointing at the meta-repo entry, plus the
super-draft's pitch body migrated over).
- `RFC.md` (the actual document, starting from the super-draft body
or a template if the body is empty).
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for
future tooling.
3. Open a PR against the meta repo updating the entry: `state: active`,
`id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`,
`graduated_by: <admin username>`. The meta-repo entry's body field
is removed (frontmatter only, plus a generated "see the full RFC at
<repo>" link).
4. Auto-merge the PR (the same admin who clicked the button is the
merge actor).
5. Webhook flow updates the SQLite cache; left pane reflects the new
state immediately.
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 dialog renders the sequence in flight as a stack of the five
named steps with per-step states — `pending`, `running`, `done`,
`failed`, `not reached` — and a one-line caption beneath the current
step naming the concrete operation ("Creating repository
wiggleverse/rfc-0042-human…"). The stack streams from
the server via the SSE surface in §17, one event per step
transition. On success, a brief "Graduation complete" frame holds
for a moment before the dialog closes and the catalog row
transitions per §7.2.
> ~~**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.~~
If any step fails partway, the app rolls back: deletes the
half-created repo, abandons the unmerged PR, surfaces a clear error
to the admin. The rollback is itself a visible step appended to the
stack on failure — the admin sees that cleanup ran, not just that
the act failed. The failed step turns red, later original-sequence
steps mark "not reached," and a "What happened" panel renders below
the stack explaining what was rolled back, what wasn't (if anything
is unrecoverable), and what to do next. The panel persists until the
admin dismisses it — a failure surface is not auto-dismissed.
Graduation is rare enough to afford this level of care.
### 13.4 Chat, branches, and history stay put
### 13.4 Chat history follows the work
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.
The chat thread attached to the super-draft moves to the new repo's
main-branch chat at graduation. This covers both the whole-doc main
thread per §8.4 and any range or paragraph sub-threads per §8.12
anchored to the super-draft's main view; anchors re-resolve against
`RFC.md` on the new repo and, where they fail, §8.12's stale
mechanic engages. The meta-repo entry retains a generated link
"Conversation continues at <repo URL>." The chat is about the RFC,
not the meta-repo entry, and it should travel with the work.
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.
Meta-repo edit-branch chats, flag threads, and `changes` rows from
the super-draft phase **do not migrate**. They stay attached to
their original `branch_name` on the meta repo and surface from the
new RFC view via a **"Pre-graduation history"** affordance — a
straightforward lookup of `threads` and `changes` rows where
`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/`
(the slug remains the canonical key per §2.3, before and after
graduation). UI affordance; no data movement, no rollback cost.
### 13.5 Reversing graduation
The affordance renders as a section in the §8.1 breadcrumb dropdown
on the new RFC view, alongside `main`, open branches, and open PRs,
headed "Pre-graduation history (N)" with each pre-graduation edit
branch listed as its own row. Selecting a row swaps the center
column to a read-only render of that branch's body at its last
commit and the right column to that branch's chat, with associated
change-cards and flags inline — the same machinery a closed branch
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation
threads resolve against the pre-graduation body, not against
`RFC.md` on the new repo. The pre-graduation set is kept distinct
from the post-graduation "Show closed branches" filter in the same
dropdown — "branches that closed normally on this repo" and
"branches that lived on the meta repo before this repo existed" are
semantically different sets, and conflating them would obscure the
graduation hop.
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.5 Graduation is not reversible
### 13.6 RFC-0001 fold-back (migration record)
Once an entry is graduated to `active`, the path forward is
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly
is operationally messy (existing commits in the new repo, etc.) and
the cost of not having it is low — withdraw and re-graduate as a
fresh idea if needed.
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).
---
+1 -1
View File
@@ -1 +1 @@
0.21.0
0.37.0
+23 -3
View File
@@ -9,10 +9,18 @@ GITEA_URL=http://localhost:3000
GITEA_BOT_USER=rfc-bot
GITEA_BOT_TOKEN=
# The Gitea org or user that owns the meta repo and every RFC repo
# the bot will create on graduation.
# The Gitea org or user that owns every RFC repo the bot will create on
# graduation.
GITEA_ORG=wiggleverse
META_REPO=meta
# §22.2 — the project registry repo (REQUIRED). The framework reads
# `projects.yaml` at its root to learn which projects exist. The repo name is
# the deployment's choice; the app fails to start if this is unset.
REGISTRY_REPO=registry
# §22.13 — optional id for the bootstrap/default project. Reserved for the
# Plan B re-stamp; leave unset in Plan A (the default project id stays
# `default`). When set, it must match an `id` in projects.yaml.
# DEFAULT_PROJECT_ID=ohm
# --- OAuth (Gitea) ---
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
@@ -38,10 +46,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
# Comma-separated list of provider keys to enable. Per the §19.2
# per-RFC-model topic, this is app-wide until that topic lands.
ENABLED_MODELS=claude
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
# (roadmap #27) — that surface always uses Claude Haiku for cost,
# independent of ENABLED_MODELS. With no key set, tag suggestions are
# simply unavailable (the modal hides the row); the rest of the app is
# unaffected.
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
# --- Tag suggestions (§9.1 / roadmap #27) ---
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
# modal debounces and the endpoint is contributor-gated). Optional —
# these defaults apply when unset.
TAG_SUGGEST_RATE_MAX=30
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
# --- Email (§15.4) ---
# Leave SMTP_HOST unset to use the stdout fallback — the integration
# tests rely on it, and a dev environment without a real SMTP provider
+363 -15
View File
@@ -21,12 +21,15 @@ from pydantic import BaseModel, Field
from . import (
api_admin,
api_branches,
api_contributions,
api_deployment,
api_discussion,
api_graduation,
api_invitations,
api_notifications,
api_prs,
auth,
projects as projects_mod,
db,
device_trust as device_trust_mod,
docs as docs_mod,
@@ -39,6 +42,7 @@ from . import (
notify,
philosophy,
providers as providers_mod,
tag_suggest,
)
from .bot import Bot
from .config import Config
@@ -51,6 +55,22 @@ class ProposeBody(BaseModel):
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
# Roadmap #26: optional "What will you be using this RFC for?" — the
# concrete ground-truth use case, distinct from the `pitch`'s abstract
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
# generous cap matching the pitch's free-text body bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class SuggestTagsBody(BaseModel):
# Roadmap #27: the partial propose-RFC draft, sent as the user types
# (debounced on the frontend). All fields optional — suggestions
# refine as the draft fills in. `pitch` is the "why is this needed"
# rationale; `use_case` is the #26 optional ground-truth field.
# Bounds mirror the propose body's free-text caps.
title: str = Field(default="", max_length=200)
pitch: str = Field(default="", max_length=8000)
use_case: str = Field(default="", max_length=8000)
class DeclineBody(BaseModel):
@@ -62,6 +82,18 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class LastStateBody(BaseModel):
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
# `route` is a frontend pathname the user was last on (bounded so a
# hostile client can't stuff arbitrary blobs through). `state` is an
# optional bag of *light* view state (scroll anchors, open tab,
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
# the frontend only ever sends ephemeral view state, and the column
# comment in migration 022 + SPEC §6.2 are the binding contract.
route: str = Field(min_length=1, max_length=2048)
state: dict[str, Any] | None = None
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
@@ -112,6 +144,13 @@ def make_router(
# invited users keep read access but cannot write (v0.6.0
# contract extended to per-RFC scope).
router.include_router(api_invitations.make_router())
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
# on accept; lands the request + owner notifications via §15 notify.
router.include_router(api_contributions.make_router())
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
# VITE_APP_NAME) + the old-URL 308 redirects.
router.include_router(api_deployment.make_router(config))
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
@@ -349,6 +388,28 @@ def make_router(
)
has_passcode = bool(row and row["passcode_hash"])
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
# v0.23.0 / item #29: fold the sign-in-resume state onto the
# same round-trip the frontend already makes on boot. When
# resume is disabled (resume_enabled = 0) we hand back a null
# route so the client never redirects; the stored row stays put
# so re-enabling later resumes the last-known route.
state_row = db.conn().execute(
"SELECT last_route, last_route_state, resume_enabled "
"FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
last_route = (
state_row["last_route"]
if (state_row and resume_enabled)
else None
)
last_route_state = None
if state_row and resume_enabled and state_row["last_route_state"]:
try:
last_route_state = json.loads(state_row["last_route_state"])
except (ValueError, TypeError):
last_route_state = None
return {
"authenticated": True,
"user": {
@@ -365,6 +426,10 @@ def make_router(
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
# v0.23.0 / item #29 — sign-in state resume.
"resume_enabled": resume_enabled,
"last_route": last_route,
"last_route_state": last_route_state,
},
}
@@ -434,6 +499,52 @@ def make_router(
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
# resume. The frontend debounce-posts the user's current route +
# a small bag of light view state here on every route change; the
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
#
# Per-user (NOT per-device) — one row per user, keyed on user_id.
# `resume_enabled` is the opt-out flag (default on); when it's 0
# this endpoint no-ops so a user who turned resume off doesn't keep
# silently rewriting their stored route. PRIVACY: the body carries
# route + light state ONLY, never draft-buffer contents (migration
# 022 column comment + SPEC §6.2 are the binding contract).
#
# `require_user` (not `require_contributor`) — a pending/granted
# distinction is irrelevant for "remember where I was", and a
# pending user navigating read-only surfaces should still resume.
# Anonymous callers get the 401 `require_user` raises.
# ---------------------------------------------------------------
@router.put("/api/me/last-state")
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
# Respect the opt-out: if a row already exists with resume
# disabled, leave it untouched and report the no-op. A first-
# ever POST (no row yet) defaults to enabled and stores.
existing = db.conn().execute(
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
if existing is not None and not existing["resume_enabled"]:
return {"ok": True, "stored": False}
state_json = json.dumps(body.state) if body.state is not None else None
db.conn().execute(
"""
INSERT INTO user_session_state
(user_id, last_route, last_route_state, last_updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
last_route = excluded.last_route,
last_route_state = excluded.last_route_state,
last_updated_at = excluded.last_updated_at
""",
(user.user_id, body.route, state_json),
)
return {"ok": True, "stored": True}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
@@ -501,25 +612,40 @@ def make_router(
# ---------------------------------------------------------------
@router.get("/api/rfcs")
async def list_rfcs(request: Request) -> dict[str, Any]:
async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]:
"""§7's left pane data.
The chip-filter / sort / search combinatorics live on the
client — the server returns the full set and lets the chips
narrow it. The set is small (hundreds, not thousands) for the
foreseeable future, so paginating here would buy nothing.
§22.4c: pass `?unreviewed=true` to narrow to active entries
still awaiting owner review.
"""
viewer = auth.current_user(request)
viewer_id = viewer.user_id if viewer else None
# §22.5: a gated project's entries never surface in a non-member's
# catalog. For the single public default project this is the full set.
visible = auth.visible_project_ids(viewer)
if not visible:
return {"items": []}
placeholders = ",".join("?" for _ in visible)
params = list(visible)
unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute(
"""
f"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
AND project_id IN ({placeholders}){unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
"""
""",
params,
).fetchall()
starred = set()
@@ -551,27 +677,183 @@ def make_router(
return {"items": items}
@router.get("/api/rfcs/{slug}")
async def get_rfc(slug: str) -> dict[str, Any]:
async def get_rfc(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
return _serialize_rfc(row)
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
# §13.7: a retired entry is removed from every browsing surface. The
# sole exception is a site owner, so the un-retire affordance has
# somewhere to live; everyone else gets a plain 404.
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in
# `proposed_use_cases` persists; look it up by slug (the latest
# 'rfc'-scope row for this slug). NULL == "left blank".
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ?
ORDER BY id DESC LIMIT 1
""",
(slug,),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
# ---------------------------------------------------------------
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
# scoped to one project, identified by its own slug namespace
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
# default-project compat path; the frontend reads these scoped routes so a
# second project's corpus renders under /p/<id>/.
# ---------------------------------------------------------------
@router.get("/api/projects/{project_id}/rfcs")
async def list_project_rfcs(
project_id: str, request: Request, unreviewed: str | None = None
) -> dict[str, Any]:
viewer = auth.current_user(request)
# §22.5 read gate: a gated project's catalog 404s to a non-member.
auth.require_project_readable(viewer, project_id)
viewer_id = viewer.user_id if viewer else None
unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute(
f"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
AND project_id = ?{unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
""",
(project_id,),
).fetchall()
starred = set()
if viewer_id is not None:
starred = {
r["rfc_slug"]
for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?",
(viewer_id, project_id),
)
}
items = [
{
"slug": r["slug"],
"title": r["title"],
"state": r["state"],
"id": r["rfc_id"],
"repo": r["repo"],
"owners": json.loads(r["owners_json"] or "[]"),
"arbiters": json.loads(r["arbiters_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
"starred_by_me": r["slug"] in starred,
"has_open_prs": False,
}
for r in rows
]
return {"items": items}
@router.get("/api/projects/{project_id}/rfcs/{slug}")
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
(project_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
ORDER BY id DESC LIMIT 1
""",
(slug, project_id),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
# ---------------------------------------------------------------
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
# ---------------------------------------------------------------
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
§22.7 project superuser (project_admin or deployment owner/admin)."""
viewer = auth.require_user(request)
auth.require_project_readable(viewer, project_id)
if not auth.is_project_superuser(viewer, project_id):
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
row = db.conn().execute(
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?",
(slug, project_id),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
if row["state"] != "active" or not row["unreviewed"]:
raise HTTPException(409, "Entry is not an unreviewed active entry")
try:
await bot.mark_entry_reviewed(
viewer.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
slug=slug,
reviewed_by=viewer.gitea_login,
reviewed_at=entry_mod.today(),
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_repo(config, gitea)
return {"ok": True}
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
def _proposal_use_case(pr_number: int) -> str | None:
"""Roadmap #26: read the optional use case for an idea PR from the
canonical side table. Returns None when none was supplied (the
"left blank" sentinel the frontend renders tastefully)."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
async def list_proposals(request: Request) -> dict[str, Any]:
# §22.5: idea PRs in a gated project never surface to non-members.
visible = auth.visible_project_ids(auth.current_user(request))
if not visible:
return {"items": []}
placeholders = ",".join("?" for _ in visible)
rows = db.conn().execute(
"""
f"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open'
AND project_id IN ({placeholders})
ORDER BY opened_at DESC
"""
""",
visible,
).fetchall()
return {
"items": [
@@ -582,6 +864,7 @@ def make_router(
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
@@ -605,10 +888,12 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not a proposal PR")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(auth.current_user(request), row["project_id"])
# Read the proposed entry file from the head branch.
slug = row["rfc_slug"]
head = row["head_branch"]
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head)
entry_payload: dict[str, Any] | None = None
if result:
text, _sha = result
@@ -630,6 +915,7 @@ def make_router(
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
"proposed_use_case": _proposal_use_case(pr_number),
}
# ---------------------------------------------------------------
@@ -639,6 +925,12 @@ def make_router(
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
# §22.6/§22.7: proposing a new entry requires project-level contribute
# standing. Through M2 every entry lands in the default project; M3's
# routing carries the target project. On the public default project the
# implicit-public baseline preserves the pre-multi-project flow.
if not auth.can_contribute_in_project(user, auth.DEFAULT_PROJECT_ID):
raise HTTPException(403, "You do not have contribute access to this project")
slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
@@ -659,10 +951,16 @@ def make_router(
if idea_clash:
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
# §22.4b: the target project's landing state. Through Plan A every
# entry lands in the default project; M3-frontend routing carries a
# non-default target later.
target_project = projects_mod.resolved_default_id(config)
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
entry = entry_mod.Entry(
slug=slug,
title=payload.title.strip(),
state="super-draft",
state=landing_state,
id=None,
repo=None,
proposed_by=user.email or user.gitea_login,
@@ -677,6 +975,7 @@ def make_router(
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
unreviewed=(landing_state == "active"),
)
contents = entry_mod.serialize(entry)
pr_title = f"Propose: {entry.title}"
@@ -691,7 +990,7 @@ def make_router(
pr = await bot.open_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
slug=slug,
file_contents=contents,
pr_title=pr_title,
@@ -706,8 +1005,55 @@ def make_router(
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the idea PR number. NULL/
# blank simply writes no row (absence == "left blank"). Done after
# the refresh so the cache row exists; the mirror onto cached_prs
# keeps the cache column in parity for any read that uses it.
use_case = (payload.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('rfc', ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
(use_case, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
# propose-RFC fields fill in. The modal debounce-posts the partial
# draft; we constrain Haiku to the corpus's existing tag set and
# return a short ranked list of clickable chips. Gated to
# contributors (same gate as propose) so the cost surface is bounded
# to people who can actually file an RFC; rate-limited per user as a
# backstop. Degrades to an empty list (no error) when no Anthropic
# key is bound, the corpus has no tags yet, or the draft is empty —
# so the modal simply shows nothing extra.
# ---------------------------------------------------------------
@router.post("/api/rfcs/suggest-tags")
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
if not tag_suggest.rate_limit_ok(user.user_id):
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
provider = tag_suggest.haiku_provider(config)
if provider is None:
return {"suggestions": []}
universe = tag_suggest.gather_tag_universe()
draft = tag_suggest.Draft(
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
)
suggestions = tag_suggest.suggest(provider, draft, universe)
return {"suggestions": suggestions}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
@@ -720,7 +1066,7 @@ def make_router(
await bot.merge_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
)
@@ -740,7 +1086,7 @@ def make_router(
await bot.decline_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
comment=body.comment,
@@ -764,7 +1110,7 @@ def make_router(
await bot.withdraw_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
)
@@ -816,10 +1162,12 @@ def make_router(
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
rfc = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(user, rfc["project_id"])
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
+51
View File
@@ -829,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
items_blocked.append(payload)
return {"ready": items_ready, "blocked": items_blocked}
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
@router.get("/api/admin/retired-rfcs")
async def retired_rfcs(request: Request) -> dict[str, Any]:
# Retired entries are hidden from every browsing surface (§13.7);
# this owner-gated list is how a site owner discovers them to
# un-retire. Admins do not get this surface — un-retire authority is
# site-owner-only, so neither is the list that feeds it.
viewer = auth.require_admin(request)
if viewer.role != "owner":
raise HTTPException(403, "Site owner role required")
rows = db.conn().execute(
"""
SELECT slug, title, rfc_id, owners_json, tags_json,
proposed_at, updated_at
FROM cached_rfcs
WHERE state = 'retired'
ORDER BY updated_at DESC
"""
).fetchall()
items = []
for r in rows:
# The state it would return to on un-retire, mirroring
# api_graduation._prior_state_before_retire's audit lookup.
prior = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = ? AND action_kind = 'retire'
ORDER BY id DESC LIMIT 1
""",
(r["slug"],),
).fetchone()
restored_state = None
if prior and prior["details"]:
try:
restored_state = json.loads(prior["details"]).get("prior_state")
except (ValueError, TypeError):
restored_state = None
if restored_state not in ("super-draft", "active"):
restored_state = "active" if r["rfc_id"] else "super-draft"
items.append({
"slug": r["slug"],
"title": r["title"],
"id": r["rfc_id"],
"owners": json.loads(r["owners_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"retired_at": r["updated_at"],
"restores_to": restored_state,
})
return {"items": items}
# ----- User search (typeahead for §15.8 mute add) -----
@router.get("/api/users/search")
+123 -79
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -120,7 +120,9 @@ def make_router(
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/models")
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]:
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
_require_rfc(slug, auth.current_user(request))
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
return {
"models": [
@@ -140,7 +142,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc(slug)
rfc = _require_rfc(slug, viewer)
if rfc["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {rfc['state']}")
@@ -150,7 +152,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'.
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
@@ -180,7 +182,7 @@ def make_router(
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute(
f"""
@@ -204,17 +206,18 @@ def make_router(
for r in pr_rows
]
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
# For meta-resident entries the cached body is entry.body already
# (see cache._upsert_cached_rfc), so no extraction is needed.
# Pre-graduation history is a LEGACY-only affordance: under the
# meta-only topology (§1, §13.4) graduation moves nothing, so an
# active RFC's edit branches are its *current* branches and already
# surface in `branches` above — there is no separate pre-graduation
# set. The disclosure is therefore computed only for a legacy
# per-RFC-repo active entry (`repo` set), where edit branches on the
# meta repo genuinely predate the per-RFC repo and would otherwise
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
@@ -288,12 +291,24 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
_validate_branch_name(new_branch)
# Meta-only topology (§1): an active RFC's branches live on the
# shared meta repo, so the auto name must embed the slug for the
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
# the slug-free `<login>-draft-<hex>` since every branch there
# belongs to the one RFC. The auto name is trusted (it carries a
# reserved `edit-` prefix by design); only a user-supplied name
# is validated, mirroring `start_edit_branch`.
new_branch = (
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
else _auto_branch_name(viewer.gitea_login)
)
else:
_validate_branch_name(new_branch)
try:
await bot.cut_branch_from_main(
viewer.as_actor(),
@@ -326,8 +341,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop.
await cache.refresh_rfc_repo(config, gitea, slug)
# the new branch without waiting for the webhook hop. Meta-resident
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
# its own — `_refresh_cache_for` dispatches on residency.
await _refresh_cache_for(rfc)
return {"branch_name": new_branch, "slug": slug}
@@ -348,7 +365,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
@@ -393,7 +410,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/metadata")
async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# Permission: super-draft owners/arbiters per §6.3, plus app-wide
# admins/owners per §6.1. Until claim, that collapses to admin/owner.
if not _can_edit_metadata(rfc, viewer):
@@ -466,7 +483,7 @@ def make_router(
request: Request,
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -553,7 +570,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline")
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -571,7 +588,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask")
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -638,7 +655,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush")
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
@@ -715,7 +732,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility")
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
current = _branch_vis(slug, branch)
@@ -725,7 +742,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode
""",
@@ -736,7 +753,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/grants")
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -757,7 +774,7 @@ def make_router(
@router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}")
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -777,7 +794,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads")
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
rows = db.conn().execute(
@@ -795,7 +812,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads")
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if body.thread_kind == "flag" and not body.label:
raise HTTPException(422, "Flag threads require a label")
cur = db.conn().execute(
@@ -825,7 +842,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
thread = _require_thread(slug, branch, thread_id)
@@ -850,7 +867,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
_require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -871,7 +888,7 @@ def make_router(
to this (slug, branch) on or before the new cursor is marked read.
"""
viewer = auth.require_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
last_seen = int(body.get("last_seen_message_id") or 0) or None
@@ -879,7 +896,7 @@ def make_router(
"""
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
""",
@@ -894,7 +911,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve")
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
thread = _require_thread(slug, branch, thread_id)
creator = _branch_creator(slug, branch)
if not _can_resolve_thread(rfc, thread, creator, viewer):
@@ -917,7 +934,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
):
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1003,7 +1020,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1074,31 +1091,34 @@ def make_router(
# Permission + state helpers (closures, share `config` etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members — indistinguishable from an unknown slug.
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
required. For super-drafts, the meta repo is the implicit target
no per-RFC repo check needed."""
row = _require_rfc(slug)
def _require_rfc_with_repo(slug: str, viewer):
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
entry super-draft and active alike so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug, viewer)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _require_active_rfc(slug: str):
row = _require_rfc_with_repo(slug)
def _require_active_rfc(slug: str, viewer):
row = _require_rfc_with_repo(slug, viewer)
if row["state"] != "active":
raise HTTPException(409, f"RFC is {row['state']}, not active")
return row
def _require_super_draft(slug: str):
row = _require_rfc(slug)
def _require_super_draft(slug: str, viewer):
row = _require_rfc(slug, viewer)
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
@@ -1106,28 +1126,34 @@ def make_router(
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6)."""
return not rfc["repo"]
def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
Retained for the legacy per-RFC-repo read path; under meta-only
every entry is already a meta target via `_is_meta_resident`."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc):
"""A meta-resident entry (super-draft or active-in-place, §1)
targets the meta repo for every branch. The branch-name fallback
covers the retired per-RFC-repo case for any legacy entry that
still carries a `repo:`."""
if _is_meta_resident(rfc):
return True
return _is_meta_branch_name(branch)
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
if _is_meta_target(rfc, branch):
return config.gitea_org, config.meta_repo
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
@@ -1161,7 +1187,7 @@ def make_router(
return entry_mod.serialize(entry)
async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
else:
@@ -1238,6 +1264,12 @@ def make_router(
return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
# §22.5 visibility gate first (subtractive, §22.7): in a gated project
# nothing — not even main or a read_public branch — is readable by a
# non-member.
pid = auth.project_of_rfc(slug)
if not auth.can_read_project(viewer, pid):
return False
if branch == "main":
return True
vis = _branch_vis(slug, branch)
@@ -1245,7 +1277,7 @@ def make_router(
return True
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_project_superuser(viewer, pid):
return True
creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator:
@@ -1270,14 +1302,20 @@ def make_router(
return False
if branch == "main":
return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
# read-only on the post-graduation surface of a per-RFC-repo active
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
# `edit-<slug>-…` branches are its *current* editable branches, not
# a frozen pre-graduation set, so this guard applies only when a
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"):
pid = auth.project_of_rfc(slug)
# §22.5 visibility gate (subtractive): no contribute in an unreadable
# project.
if not auth.can_read_project(viewer, pid):
return False
if auth.is_project_superuser(viewer, pid):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1288,7 +1326,10 @@ def make_router(
return True
vis = _branch_vis(slug, branch)
if vis["contribute_mode"] == "any-contributor":
return True
# "any contributor" means anyone with project-level write standing
# (§22.6/§22.7) — the implicit-public baseline on a public project,
# or an explicit project_contributor/admin elsewhere.
return auth.can_contribute_in_project(viewer, pid)
if vis["contribute_mode"] == "specific":
row = db.conn().execute(
"""
@@ -1302,14 +1343,16 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
raise HTTPException(403, "You do not have contribute access to this branch")
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
if viewer.role in ("owner", "admin"):
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
# to project scope, so it (and a deployment owner/admin) clears here.
if auth.is_project_superuser(viewer, rfc["project_id"]):
return
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1320,11 +1363,12 @@ def make_router(
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
def _can_edit_metadata(rfc, viewer) -> bool:
"""§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners.
Until §13.1's claim runs, the super-draft has no owners, so the set
collapses to app admins/owners only sensible because admin oversight
is the only path to canonicalizing edits on an unclaimed entry."""
if viewer.role in ("owner", "admin"):
"""§9.5: super-draft owners/arbiters per §6.3 plus project_admin /
app admins/owners (§22.6). Until §13.1's claim runs, the super-draft
has no owners, so the set collapses to the superuser tier only
sensible because admin oversight is the only path to canonicalizing
edits on an unclaimed entry."""
if auth.is_project_superuser(viewer, rfc["project_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1337,7 +1381,7 @@ def make_router(
"can_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and (
viewer.role in ("owner", "admin")
auth.is_project_superuser(viewer, rfc["project_id"])
or (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (owners + arbiters)
),
@@ -1383,7 +1427,7 @@ def make_router(
def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_project_superuser(viewer, rfc["project_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
+316
View File
@@ -0,0 +1,316 @@
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
PR/comment text to a **pending** RFC a super-draft
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
contribution surface, but not yet graduated to an active RFC) the
reader is offered an "ask to contribute" popover. This module is the
backend for that flow:
* ``GET /api/rfcs/{slug}/contribution-target`` what the
contribute form needs (RFC title, owner display, the viewer's
eligibility + whether they already have a pending ask).
* ``POST /api/rfcs/{slug}/contribution-requests`` submit the ask
(who-I-am / why / optional use-case); lands a row + one §15
notification per owner.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` owner:
accept, which fires #12's owner-invite flow with the requester as the
invitee (opening the RFC's discussion/contribution surface), then
echoes a notification back to the requester.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` owner:
decline; the request closes and the requester is notified.
"Pending" is scoped to a super-draft because that is the state with an
owner to route to, a contribution surface to open, and a row in
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
Pre-merge idea PRs are deliberately out of scope (no contribution
surface yet) a documented future extension, mirroring the
conservative scoping in ``rfc_links.py``.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_invitations, auth, db, notify
# Field caps — generous for free text, bounded so a request row (and the
# notification payload that carries it) can't be used to store unbounded
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
_WHO_MAX = 2000
_WHY_MAX = 4000
_USE_CASE_MAX = 4000
_TERM_MAX = 200
class ContributionRequestBody(BaseModel):
# The term in the PR/comment text that surfaced the offer (the
# super-draft's title/slug). Carried for the owner's context line.
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
why: str = Field(min_length=1, max_length=_WHY_MAX)
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
def _require_super_draft(slug: str, viewer):
"""The contribute surface only operates on a *pending* RFC. 404 on
unknown; 409 on a state that isn't a super-draft (active RFCs use the
Part-1 link, not a contribute offer; withdrawn is closed). The §22.5
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] != "super-draft":
raise HTTPException(409, "RFC is not a pending super-draft")
return row
def _require_request(slug: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
use_case, status
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
""",
(request_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Contribution request not found")
return row
def _viewer_relationship(viewer, slug: str) -> str | None:
"""Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request."""
if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)):
return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC."
return None
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the contribute form needs to render + gate itself.
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
from . import rfc_links # local import: avoid a module import cycle
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
eligible = True
reason: str | None = None
already_requested = False
if row["state"] != "super-draft":
eligible, reason = False, "This RFC is no longer pending."
elif viewer is None:
eligible, reason = False, "Sign in to ask to contribute."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
else:
reason = _viewer_relationship(viewer, slug)
if reason is not None:
eligible = False
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM contribution_requests
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
LIMIT 1
""",
(slug, viewer.user_id),
).fetchone()
)
return {
"slug": row["slug"],
"title": row["title"],
"owner": owner,
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
}
# ---------------------------------------------------------------
# POST — submit a contribute request.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests")
async def create_contribution_request(
slug: str, body: ContributionRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug, viewer)
reason = _viewer_relationship(viewer, slug)
if reason is not None:
raise HTTPException(409, reason)
who_i_am = body.who_i_am.strip()
why = body.why.strip()
use_case = (body.use_case or "").strip() or None
matched_term = body.matched_term.strip()
if not who_i_am or not why:
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
try:
cur = db.conn().execute(
"""
INSERT INTO contribution_requests
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
VALUES (?, ?, ?, ?, ?, ?)
""",
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (RFC, user).
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
request_id = cur.lastrowid
# One actionable notification per owner; stamp the first onto the
# row as the inbox-action handle (any owner can act on the request).
notif_ids = notify.fan_out_contribution_request(
rfc_slug=slug,
requester_user_id=viewer.user_id,
request_id=request_id,
matched_term=matched_term,
who_i_am=who_i_am,
why=why,
use_case=use_case,
)
if notif_ids:
db.conn().execute(
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
# ---------------------------------------------------------------
# POST — owner accepts → fire #12's invite flow.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
async def accept_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
requester = db.conn().execute(
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
).fetchone()
if requester is None or not (requester["email"] or "").strip():
raise HTTPException(422, "The requester has no email address on file to invite.")
# Fire #12's owner-invite flow with the requester as the invitee.
# If a pending contributor invitation already exists (the owner
# invited them out-of-band first), reuse it rather than failing.
try:
invitation = api_invitations.issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=requester["email"],
role_in_rfc="contributor",
rfc_title=rfc["title"],
)
invitation_id = invitation["id"]
except HTTPException as exc:
if exc.status_code != 409:
raise
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = 'contributor' AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(slug, requester["email"].strip()),
).fetchone()
invitation_id = existing["id"] if existing else None
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, invitation_id = ?
WHERE id = ?
""",
(viewer.user_id, invitation_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=True,
)
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
# ---------------------------------------------------------------
# POST — owner declines.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
async def decline_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router
+106
View File
@@ -0,0 +1,106 @@
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
old-URL 308 redirects.
GET /api/deployment the deployment name/tagline + the projects the caller can
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
GET /api/projects/:id one project's runtime config + optional theme overlay,
gated behind the §22.5 read gate (404 for a non-member of a gated project).
GET /rfc/{slug}, /proposals/{n} §22.10 server-side 308s onto the new
`/p/<default>/` routes (the SPA no longer owns these paths; nginx proxies them
to the backend instead of serving index.html).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from . import auth, db, projects as projects_mod
from .config import Config
def make_router(config: Config) -> APIRouter:
router = APIRouter()
@router.get("/api/deployment")
async def get_deployment(request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
dep = db.conn().execute(
"SELECT name, tagline FROM deployment WHERE id = 1"
).fetchone()
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
visible = set(auth.visible_project_ids(viewer))
rows = db.conn().execute(
"SELECT id, name, type, visibility FROM projects "
"WHERE visibility != 'unlisted' ORDER BY name"
).fetchall()
projects = [
{"id": r["id"], "name": r["name"], "type": r["type"], "visibility": r["visibility"]}
for r in rows
if r["id"] in visible
]
return {
"name": (dep["name"] if dep else "") or "",
"tagline": (dep["tagline"] if dep else "") or "",
# §22.10 / M3-frontend guard contract: which project the backend
# serves the corpus for (the default, until Plan B serves per
# project). The frontend renders corpus routes only for this id and
# shows a "content not yet served" placeholder for any other.
"default_project_id": projects_mod.resolved_default_id(config),
"projects": projects,
}
@router.get("/api/projects/{project_id}")
async def get_project(project_id: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
# §22.5 read gate: a gated project 404s to a non-member (shape matches
# an unknown id). unlisted is readable by direct id.
auth.require_project_readable(viewer, project_id)
row = db.conn().execute(
"SELECT id, name, type, visibility, initial_state, config_json "
"FROM projects WHERE id = ?",
(project_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
try:
cfg = json.loads(row["config_json"] or "{}")
except (ValueError, TypeError):
cfg = {}
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
return {
"id": row["id"],
"name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "",
"type": row["type"],
"visibility": row["visibility"],
"initial_state": row["initial_state"],
"theme": cfg.get("theme") or {},
}
# §22.10 / §5 — server-side 308s off the old corpus-root URLs onto the
# `/p/<default>/…` routes. 308 (not 301/302) preserves method + body and
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
# before the SPA's index.html fallback.
@router.get("/rfc/{slug}")
async def redirect_old_rfc(slug: str) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308)
@router.get("/rfc/{slug}/pr/{pr_number}")
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(
url=f"/p/{default_id}/e/{slug}/pr/{pr_number}", status_code=308
)
@router.get("/proposals/{pr_number}")
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308)
return router
+28 -13
View File
@@ -40,7 +40,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, chat as chat_layer, db
from . import auth, chat as chat_layer, db, rfc_links
log = logging.getLogger(__name__)
@@ -84,7 +84,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/discussion/threads")
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# Ensure the default whole-doc discussion thread exists. We mint
# it on first read regardless of viewer (anonymous viewers can
# trigger the creation — the row's `created_by` is null in that
@@ -115,7 +115,7 @@ def make_router() -> APIRouter:
slug: str, body: DiscussionThreadCreateBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
# gated surface. The platform-level `require_contributor` above
# ensures the user is signed in + admin-granted; this layer
@@ -155,8 +155,8 @@ def make_router() -> APIRouter:
async def get_discussion_thread_messages(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
_viewer = auth.current_user(request)
_require_rfc_readable(slug)
viewer = auth.current_user(request)
_require_rfc_readable(slug, viewer)
thread = _require_discussion_thread(slug, thread_id)
rows = db.conn().execute(
"""
@@ -171,9 +171,17 @@ def make_router() -> APIRouter:
""",
(thread_id,),
).fetchall()
# Roadmap #28 Part 1: enrich each discussion comment with RFC
# auto-link segments scanned against the live accepted-RFC corpus
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
# to this RFC inside its own discussion.
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
messages = [_serialize_message(r) for r in rows]
for m in messages:
m["text_segments"] = link_index.segment(m["text"])
return {
"thread": _serialize_thread(thread),
"messages": [_serialize_message(r) for r in rows],
"messages": messages,
}
# -------------------------------------------------------------------
@@ -185,7 +193,7 @@ def make_router() -> APIRouter:
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
if not auth.can_discuss_rfc(viewer, slug):
raise HTTPException(
@@ -210,7 +218,7 @@ def make_router() -> APIRouter:
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_readable(slug)
rfc = _require_rfc_readable(slug, viewer)
thread = _require_discussion_thread(slug, thread_id)
if not _can_resolve(rfc, thread, viewer):
raise HTTPException(
@@ -237,17 +245,24 @@ def make_router() -> APIRouter:
# ---------------------------------------------------------------------------
def _require_rfc_readable(slug: str):
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
by anyone. Withdrawn entries refuse reads of every shape same rule
`_require_rfc_with_repo` in `api_branches.py` follows."""
def _require_rfc_readable(slug: str, viewer):
"""Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable*
project is readable by anyone. The §22.5 visibility gate is subtractive on
top (§22.7): a gated project's entries 404 to non-members. Withdrawn
entries refuse reads of every shape same rule `_require_rfc_with_repo`
in `api_branches.py` follows."""
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
if row["state"] == "retired":
raise HTTPException(404, "RFC not found")
return row
@@ -297,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_project_superuser(viewer, rfc["project_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
File diff suppressed because it is too large Load Diff
+86 -65
View File
@@ -126,76 +126,22 @@ def make_router() -> APIRouter:
@router.post("/api/rfcs/{slug}/invitations")
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc(slug)
rfc = _require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can invite collaborators",
)
invitee_email = body.invitee_email.strip()
role_in_rfc = body.role_in_rfc
# Refuse re-inviting an email that already has a pending
# invitation on this RFC at the same role. Different-role
# re-invite is allowed (upgrade discussant → contributor)
# — the new row supersedes the old in the UI listing's
# natural ordering, and acceptance of either picks up the
# corresponding role.
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(
slug,
viewer.user_id,
invitee_email,
role_in_rfc,
token,
f"+{INVITATION_TTL_DAYS} days",
),
)
invitation_id = cur.lastrowid
# Send the email — synchronous. A send failure logs and
# returns; the row stays so the owner can recover via the
# listing (which carries the token for an out-of-band share).
_send_invitation_email(
to_address=invitee_email,
return issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=body.invitee_email,
role_in_rfc=body.role_in_rfc,
rfc_title=rfc["title"],
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
# ---------------------------------------------------------------
# GET /api/rfcs/<slug>/invitations
# The owner's listing of every invitation on the RFC, regardless
@@ -205,7 +151,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/invitations")
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
_require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -261,7 +207,7 @@ def make_router() -> APIRouter:
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
_require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -427,15 +373,18 @@ def make_router() -> APIRouter:
# ---------------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
"""The invitation surface only operates on a known, non-withdrawn
RFC. We refuse 404 on unknown and 409 on withdrawn mirrors the
discussion endpoints' `_require_rfc_readable` shape."""
discussion endpoints' `_require_rfc_readable` shape. The §22.5
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
"SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
@@ -473,6 +422,78 @@ def _effective_status(row) -> str:
return "expired" if is_past else "pending"
def issue_invitation(
*,
slug: str,
inviter_user_id: int,
inviter_display: str,
invitee_email: str,
role_in_rfc: str,
rfc_title: str,
) -> dict:
"""Mint + persist + email one ``rfc_invitations`` row.
The single chokepoint for issuing an invitation: the owner's manual
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
accept path both route through here, so the dup-guard, token mint,
insert, and transactional email stay identical.
Refuses (409) re-inviting an email that already has a pending
invitation on this RFC at the same role. A different-role re-invite is
allowed (the discussant contributor upgrade) the new row
supersedes the old in the listing's natural ordering, and acceptance
of either picks up the corresponding role.
Returns the new row's dict (including the raw token, for the owner's
out-of-band share / the caller's record-keeping). A send failure logs
and returns; the row stays so the owner can recover via the listing.
"""
invitee_email = invitee_email.strip()
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
)
invitation_id = cur.lastrowid
_send_invitation_email(
to_address=invitee_email,
inviter_display=inviter_display,
rfc_title=rfc_title,
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
def _mint_token() -> str:
"""A 256-bit URL-safe token. The token shape is opaque to the
consumer; the email link encodes it as a query param."""
+24 -3
View File
@@ -213,14 +213,16 @@ def make_router(config: Config) -> APIRouter:
@router.post("/api/rfcs/{slug}/watch")
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(viewer, rfc["project_id"])
db.conn().execute(
"""
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
ON CONFLICT(project_id, user_id, rfc_slug) DO UPDATE SET
state = excluded.state,
set_by = 'explicit',
set_at = excluded.set_at
@@ -557,7 +559,26 @@ def make_router(config: Config) -> APIRouter:
# stays unauthenticated for dev (the v1 contract).
import os as _os
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
if expected:
# v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
# leave this endpoint fully unauthenticated — anyone could suppress
# any user's mail by POSTing their address (email_opt_out_all flip
# below). Now an unset secret DISABLES the endpoint (503) instead
# of opening it. A dev that genuinely wants it open opts in
# explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
# RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
if not expected:
if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
log.warning(
"email-bounce webhook running UNAUTHENTICATED "
"(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
)
else:
log.error(
"email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
"(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
)
raise HTTPException(503, "Bounce webhook not configured")
else:
received = request.headers.get("X-Webhook-Secret", "")
import hmac as _hmac
if not received or not _hmac.compare_digest(expected, received):
+87 -29
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
class OpenPRBody(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(max_length=8000)
# Roadmap #26: optional "What will you be using this change for?" —
# the concrete ground-truth use case sibling to the required
# "why is this change needed" (the `description`). Optional, generous
# cap matching the description bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class PRDescriptionBody(BaseModel):
@@ -80,7 +85,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
if not _branch_has_commits_ahead(slug, branch):
@@ -123,7 +128,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
if branch == "main":
raise HTTPException(409, "PRs open from non-main branches")
owner, repo = _owner_repo(rfc)
@@ -148,7 +153,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me')
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
""",
(slug, branch),
)
@@ -173,6 +178,26 @@ def make_router(
raise HTTPException(502, f"Gitea: {e.detail}")
await _refresh_after_pr_write(rfc)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the PR number. Blank/omitted
# writes no row (absence == "left blank"). The mirror onto
# cached_prs keeps the cache column in parity.
use_case = (body.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
(use_case, slug, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
# -------------------------------------------------------------------
@@ -182,12 +207,18 @@ def make_router(
@router.get("/api/rfcs/{slug}/prs/{pr_number}")
async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
head_branch = pr_row["head_branch"]
# Roadmap #28 Part 1: build the RFC auto-link index once for this
# PR view (read-time enrichment against the live accepted-RFC
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
# this RFC inside its own PR.
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
# §11.3: PRs are always public; no visibility check.
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
@@ -234,6 +265,13 @@ def make_router(
for r in msg_rows:
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
# segments (read-time; see rfc_links.py). The description is
# enriched alongside it in the return dict below.
for _msgs in messages_by_thread.values():
for _m in _msgs:
_m["text_segments"] = link_index.segment(_m["text"])
# Per-user seen cursor per §10.3. Anonymous viewers get no
# cursor — they always see "everything new" but cannot advance
# the cursor (no row to write to).
@@ -300,6 +338,8 @@ def make_router(
"pr_number": pr_number,
"title": pr_row["title"],
"description": pr_row["description"],
"description_segments": link_index.segment(pr_row["description"]),
"proposed_use_case": _pr_use_case(pr_number),
"state": pr_row["state"],
"opened_by": pr_row["opened_by"],
"opened_at": pr_row["opened_at"],
@@ -333,7 +373,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/seen")
async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_active_rfc(slug, viewer)
_require_pr(slug, pr_number)
# Take the max of stored and incoming for both cursors so a
# stale tab firing a seen-cursor advance after a fresher tab
@@ -358,7 +398,7 @@ def make_router(
INSERT INTO pr_seen
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET
ON CONFLICT(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET
last_seen_commit_sha = excluded.last_seen_commit_sha,
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
@@ -380,7 +420,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/review")
async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
head_branch = pr_row["head_branch"]
cur = db.conn().execute(
@@ -407,7 +447,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/merge")
async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_merge(rfc, viewer):
raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge")
@@ -439,7 +479,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw")
async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_withdraw(rfc, pr_row, viewer):
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw")
@@ -470,7 +510,7 @@ def make_router(
slug: str, pr_number: int, body: PRDescriptionBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_edit_pr_text(rfc, pr_row, viewer):
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit")
@@ -495,7 +535,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch")
async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if pr_row["state"] != "open":
raise HTTPException(409, f"PR is {pr_row['state']}, not open")
@@ -563,7 +603,7 @@ def make_router(
repo=repo,
slug=slug,
file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc),
is_super_draft=_is_meta_resident(rfc),
original_branch=original_branch,
resolution_branch=resolution_branch,
)
@@ -621,42 +661,49 @@ def make_router(
# Helpers (closures over config/gitea/etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
# public" yields to a gated project: non-members get 404.
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_active_rfc(slug: str):
def _require_active_rfc(slug: str, viewer):
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical."""
row = _require_rfc(slug)
surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug, viewer)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc):
return config.gitea_org, config.meta_repo
if _is_meta_resident(rfc):
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full
"""For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc):
if not _is_meta_resident(rfc):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -720,7 +767,7 @@ def make_router(
return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
@@ -736,10 +783,10 @@ def make_router(
def _can_merge(rfc, viewer) -> bool:
"""§6.1 admin/owner OR §6.3 RFC owners/arbiters."""
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_project_superuser(viewer, rfc["project_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -762,6 +809,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
return _can_withdraw(rfc, pr_row, viewer)
def _pr_use_case(pr_number: int) -> str | None:
"""Roadmap #26: the optional propose-PR use case from the canonical
side table, or None when the change was opened without one ("left
blank")."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
return {
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
+202 -15
View File
@@ -19,6 +19,7 @@ from fastapi import HTTPException, Request
from . import db
from .bot import Actor
from .config import Config
from .projects import DEFAULT_PROJECT_ID
@dataclass
@@ -290,6 +291,163 @@ def require_admin(request: Request) -> SessionUser:
return user
# ===========================================================================
# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier).
#
# A deployment hosts N projects (§22). Authorization for an action on an RFC is
# the *most-permissive union* of three tiers — the actor's deployment role
# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) —
# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top
# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`,
# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the
# ~20 endpoint call sites inherit multi-project behavior unchanged.
#
# Through Slice M2 the only project is the migration-seeded `default` one (the
# N=1 case, §22.13); a project is resolved from an RFC slug via
# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror
# lets a deployment declare a second project; these gates already hold then.
#
# OPERATOR DECISIONS (M2):
# * implicit-on-public — on a `public` project a granted deployment
# `contributor` keeps the pre-multi-project write *baseline* (propose
# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0
# per-RFC invite). No project_members row is needed and no backfill runs,
# so the N=1 case stays whole. Explicit project_members rows and
# gated/unlisted visibility are where the new tier actually bites.
# * preserve curation — the implicit-public baseline does NOT override per-RFC
# owner curation; only an *explicit* project_contributor/project_admin grant
# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor
# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain
# granted contributor on public behaves exactly as it did before M2.
# ===========================================================================
_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin")
def project_visibility(project_id: str) -> str:
"""The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A
missing row reads as 'gated' the safe default: an unknown project is
invisible rather than open."""
row = db.conn().execute(
"SELECT visibility FROM projects WHERE id = ?", (project_id,)
).fetchone()
if row is None:
return "gated"
return row["visibility"] or "gated"
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
"""The user's *explicit* §22.6 project_members role in this project, or
None. This is the stored row only it does not fold in the deployment tier
or the implicit-on-public baseline (those live in the helpers below)."""
if user is None:
return None
row = db.conn().execute(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user.user_id),
).fetchone()
return row["role"] if row else None
def project_of_rfc(rfc_slug: str) -> str:
"""The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to
the default project when the slug isn't cached or the column is unset — the
same N=1 default migration 026 backfills."""
row = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
).fetchone()
if row is None:
return DEFAULT_PROJECT_ID
return row["project_id"] or DEFAULT_PROJECT_ID
def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
"""Maximal authority within a project: a deployment owner/admin (superuser
in every project, §22.7) or an explicit `project_admin` (§22.6). Both
subsume the per-RFC owners/arbiters tier."""
if user is None:
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
return project_member_role(user, project_id) == "project_admin"
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone
(anonymous included `unlisted` is link-only but the link still reads);
`gated` is readable only by a deployment owner/admin or a granted project
member of any role. Used as the subtractive read gate (a gated project's
entries 404 to non-members)."""
vis = project_visibility(project_id)
if vis in ("public", "unlisted"):
return True
# gated — members + superusers only, subject to the §6 admission floor.
if user is None or user.permission_state != "granted":
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
return project_member_role(user, project_id) is not None
def require_project_readable(user: SessionUser | None, project_id: str) -> None:
"""Raise 404 when the project is not readable by this viewer (§22.5: a
gated project is invisible to non-members indistinguishable from absent,
so the shape matches an unknown slug)."""
if not can_read_project(user, project_id):
raise HTTPException(status_code=404, detail="RFC not found")
def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool:
"""The implicit-on-public write baseline: a granted deployment
`contributor` on a `public` project carries the pre-multi-project
write standing (still subject to per-RFC curation). Deployment
owner/admin are handled by `is_project_superuser`; on gated/unlisted a
contributor has no baseline and needs an explicit project role."""
if user is None or user.permission_state != "granted":
return False
return user.role == "contributor" and project_visibility(project_id) == "public"
def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool:
"""May the user contribute *new* content to the project (propose an entry)
the project-level (not RFC-specific) contribute standing. The union of
the override grants (superuser / explicit project_contributor) and the
implicit-public baseline, subject to the visibility gate."""
if user is None or user.permission_state != "granted":
return False
if not can_read_project(user, project_id):
return False
if is_project_superuser(user, project_id):
return True
if project_member_role(user, project_id) == "project_contributor":
return True
return _has_write_baseline(user, project_id)
def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool:
"""May the user participate in discussion in the project at all — the
project-level discuss standing (project_viewer discussant, §22.7).
A superset of `can_contribute_in_project` (a contributor can discuss)."""
if user is None or user.permission_state != "granted":
return False
if not can_read_project(user, project_id):
return False
if is_project_superuser(user, project_id):
return True
if project_member_role(user, project_id) in ("project_viewer", "project_contributor"):
return True
return _has_write_baseline(user, project_id)
def visible_project_ids(user: SessionUser | None) -> list[str]:
"""Project ids whose entries may surface in a listing for this viewer — the
§22.5 read gate applied to the catalog/idea lists. (The directory's
`unlisted`-omission and the per-project routing are M3 concerns; for the M2
listing filter we include every project the viewer can read.)"""
rows = db.conn().execute("SELECT id FROM projects").fetchall()
return [r["id"] for r in rows if can_read_project(user, r["id"])]
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
#
# These don't replace `require_contributor` — they layer on top of it for
@@ -386,18 +544,30 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
pid = project_of_rfc(rfc_slug)
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project
# the viewer cannot even read.
if not can_read_project(user, pid):
return False
# §22.7 union, override grants first — these bypass per-RFC curation
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all).
if is_project_superuser(user, pid):
return True
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
return True
# per-RFC authority (union term).
owners = _rfc_owners_set(rfc_slug)
if not owners:
# No owner to gate the invite-list — fall through to the
# platform-granted contract. The first §13.1 claim engages
# the gate; before that, anyone platform-granted can
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
return True
# implicit-public baseline (curation preserved): a granted deployment
# contributor on a public project may discuss only while the RFC is
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
# pre-multi-project v0.16.0 contract.
if not owners and _has_write_baseline(user, pid):
return True
return False
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
@@ -419,16 +589,27 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
pid = project_of_rfc(rfc_slug)
if not can_read_project(user, pid):
return False
# §22.7 union, override grants first (project_contributor ⊇
# rfc_collaborators(contributor); project_admin / superuser ⊇ all).
if is_project_superuser(user, pid):
return True
if project_member_role(user, pid) == "project_contributor":
return True
# per-RFC authority (union term). A 'discussant' row is NOT sufficient —
# PRs are the higher-privilege surface.
owners = _rfc_owners_set(rfc_slug)
if not owners:
# Same fall-through as can_discuss_rfc: until an owner exists,
# the gate is open.
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
return True
# implicit-public baseline (curation preserved): until an owner exists, a
# granted deployment contributor on a public project may contribute.
if not owners and _has_write_baseline(user, pid):
return True
return False
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
@@ -439,7 +620,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
pid = project_of_rfc(rfc_slug)
if not can_read_project(user, pid):
return False
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise
# only the RFC's frontmatter owner. Per-RFC collaborators and the
# implicit-public baseline do not get the invite-others power.
if is_project_superuser(user, pid):
return True
return is_rfc_owner(user, rfc_slug)
+158 -131
View File
@@ -27,7 +27,7 @@ import json
import logging
from dataclasses import dataclass
from . import db, notify
from . import db, entry as entry_mod, notify
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
@@ -695,111 +695,7 @@ class Bot:
)
return sha
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
# ----- §13 graduation (meta-only): open + merge the flip PR -----
async def open_graduation_pr(
self,
@@ -810,12 +706,15 @@ class Bot:
slug: str,
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
rfc_id: str | None,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the graduation stamps
and **optionally** the integer `id`, **keeping the body
unchanged** (§1 meta-only topology; no repo is created and no body
is stripped). When `rfc_id` is None the entry graduates without a
number (id stays null, slug is canonical per §2.3, §13.2). Branch
name uses the `graduate-<slug>-<6hex>` shape dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""
@@ -824,7 +723,7 @@ class Bot:
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug}{rfc_id}"
commit_subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
@@ -839,16 +738,17 @@ class Bot:
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}"
pr_title = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
owners_str = ", ".join(owners) if owners else "(none)"
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"{id_line}"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
@@ -862,7 +762,7 @@ class Bot:
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
details={"pr_title": pr_title, "rfc_id": rfc_id},
)
return pr
@@ -875,7 +775,7 @@ class Bot:
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
rfc_id: str | None,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
@@ -891,7 +791,7 @@ class Bot:
reports the transient state (belt-and-suspenders against the same
race appearing in a different shape).
"""
subject = f"Graduate {slug}{rfc_id}"
subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
body = _trailer(actor)
try:
@@ -912,27 +812,106 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
async def delete_rfc_repo(
async def open_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
repo_name: str,
meta_repo: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
new_file_contents: str,
prior_sha: str,
verb: str,
target_state: str,
) -> dict:
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
<target_state>` for retire (`verb='retire'`, target `retired`)
or un-retire (`verb='unretire'`, target the restored prior state).
Only the frontmatter `state` changes; the body and every other
field (including the integer `id`) are kept, so an un-retire
restores the entry exactly. Branch shape mirrors graduation's
`<verb>-<slug>-<6hex>`.
"""
import secrets
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
verb_title = "Retire" if verb == "retire" else "Un-retire"
commit_subject = f"{verb_title} {slug}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"{verb_title} {slug}"
pr_body_text = (
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
f"only `state` changes."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
"graduate_repo_delete",
f"{verb}_pr_open",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "target_state": target_state},
)
return pr
async def merge_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
verb: str,
) -> None:
"""§13.7: auto-merge the retire / un-retire flip PR (same
mergeable-wait + retry shape as `merge_graduation_pr`)."""
verb_title = "Retire" if verb == "retire" else "Un-retire"
subject = f"{verb_title} {slug}"
body = _trailer(actor)
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,
f"{verb}_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(
self,
@@ -1090,6 +1069,54 @@ class Bot:
)
return pr
# ----- §22.4c: mark-reviewed (direct main write) -----
async def mark_entry_reviewed(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
reviewed_by: str,
reviewed_at: str,
) -> None:
"""Clear §22.4c unreviewed on an active entry by rewriting its
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
trailer and writes an actions-log row, mirroring the graduation
stamp's bot-write shape."""
path = f"rfcs/{slug}.md"
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
if result is None:
raise GiteaError(404, f"{path} not found")
text, sha = result
e = entry_mod.parse(text)
e.unreviewed = False
e.reviewed_at = reviewed_at
e.reviewed_by = reviewed_by
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
result = await self._gitea.update_file(
org, meta_repo, path,
content=entry_mod.serialize(e),
sha=sha,
message=commit_message,
branch="main",
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
_log(
actor,
"mark_reviewed",
rfc_slug=slug,
bot_commit_sha=commit_sha,
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
)
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
async def ensure_rfc_repo_seed(
+104 -28
View File
@@ -27,7 +27,7 @@ import asyncio
import json
import logging
from . import db, entry as entry_mod
from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod
from .config import Config
from .gitea import Gitea, GiteaError
@@ -35,16 +35,29 @@ log = logging.getLogger(__name__)
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
Idempotent. Safe to call on every meta-repo webhook and on every
reconciler sweep.
§22 (Plan B): a deployment has N projects (§22.1), each with its own
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
safe on every content-repo webhook and reconciler sweep.
"""
org, repo = config.gitea_org, config.meta_repo
org = config.gitea_org
rows = db.conn().execute(
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
).fetchall()
if not rows:
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
return
for prow in rows:
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e:
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e)
return
seen_slugs: set[str] = set()
@@ -58,24 +71,28 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
try:
entry = entry_mod.parse(text)
except Exception as parse_err:
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err)
continue
if not entry.slug:
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
continue
seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha)
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id)
# Mark entries removed from the meta repo as withdrawn-without-trace.
# In practice the spec keeps withdrawn entries in rfcs/ as historical
# record (§3), so this branch fires only for entries deleted out of
# band. We leave the row but flag it for reconciler attention.
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
# as historical record (§3), so this fires only for out-of-band deletes;
# leave the row, scoped to this project, for reconciler attention.
existing = {
row["slug"]
for row in db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,)
)
}
for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None
@@ -87,9 +104,11 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
models_json, funder_login, body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
models_json, funder_login, body, body_sha,
unreviewed, reviewed_at, reviewed_by, project_id,
last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(project_id, slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
rfc_id = excluded.rfc_id,
@@ -105,6 +124,9 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
funder_login = excluded.funder_login,
body = excluded.body,
body_sha = excluded.body_sha,
unreviewed = excluded.unreviewed,
reviewed_at = excluded.reviewed_at,
reviewed_by = excluded.reviewed_by,
last_entry_commit_at = datetime('now'),
updated_at = datetime('now')
""",
@@ -125,6 +147,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
funder_login,
entry.body,
body_sha,
1 if entry.unreviewed else 0,
entry.reviewed_at,
entry.reviewed_by,
project_id,
),
)
@@ -184,7 +210,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -219,6 +245,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
open_pulls, closed_pulls = [], []
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
# the slug is known (param), so state still updates correctly and
# no ghost forms — but blindly storing the sentinel would clobber
# the real branch name api_prs.py relies on as a fallback ref when
# the merge commit is gone. Recover it from the stored row.
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
@@ -307,7 +346,11 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
structurally `edit/<slug>/<auto-name>` per §9.5, with dashes in place
of slashes per the §19.2 path-routing candidate.
"""
org, repo = config.gitea_org, config.meta_repo
org = config.gitea_org
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
return
try:
branches = await gitea.list_branches(org, repo)
except GiteaError as e:
@@ -329,16 +372,20 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug:
continue
rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not rfc or rfc["state"] != "super-draft":
# Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -352,14 +399,15 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
"SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall()
for r in super_drafts:
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at
""",
@@ -374,7 +422,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name
FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft'
WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted'
AND b.branch_name != 'main'
"""
@@ -418,7 +467,11 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
login as last resort.
"""
org, repo = config.gitea_org, config.meta_repo
org = config.gitea_org
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning("refresh_meta_pulls: default project has no content_repo yet; skipping")
return
repo_full = f"{org}/{repo}"
try:
open_pulls = await gitea.list_pulls(org, repo, state="open")
@@ -431,6 +484,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# A merged-and-deleted PR's branch is no longer reported by Gitea
# as its real name — the `head.ref` collapses to the synthetic
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
# derive from the branch name, so a deleted branch would parse to
# slug=None and the row would be skipped forever, freezing the
# cached_prs row at its last-seen `state='open'` — a permanent
# ghost "pending idea" for an entry that has actually merged
# (caught when the operator authoring lane in ROADMAP #35 merged
# an idea PR with the branch deleted; the web UX leaves branches
# in place so it never tripped this). Recover the original branch
# from the row we already stored when the PR was open — that row
# retains the real `head_branch` (migration 002).
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
slug = _slug_from_head_branch(head_branch)
if slug is None:
continue
@@ -625,6 +697,10 @@ class Reconciler:
async def sweep(self) -> None:
log.info("reconciler: starting sweep")
try:
try:
await registry_mod.refresh_registry(self._config, self._gitea)
except Exception:
log.exception("reconciler: registry refresh failed; keeping last-good projects")
await refresh_meta_repo(self._config, self._gitea)
await refresh_meta_branches(self._config, self._gitea)
await refresh_meta_pulls(self._config, self._gitea)
+6 -4
View File
@@ -32,7 +32,7 @@ class Config:
gitea_bot_user: str
gitea_bot_token: str
gitea_org: str
meta_repo: str
registry_repo: str
oauth_client_id: str
oauth_client_secret: str
app_url: str
@@ -44,14 +44,15 @@ class Config:
anthropic_api_key: str = ""
google_api_key: str = ""
openai_api_key: str = ""
default_project_id: str = ""
@property
def redirect_uri(self) -> str:
return f"{self.app_url}/auth/callback"
@property
def meta_repo_full(self) -> str:
return f"{self.gitea_org}/{self.meta_repo}"
def registry_repo_full(self) -> str:
return f"{self.gitea_org}/{self.registry_repo}"
def load_config() -> Config:
@@ -79,7 +80,7 @@ def load_config() -> Config:
gitea_bot_user=_required("GITEA_BOT_USER"),
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
gitea_org=_required("GITEA_ORG"),
meta_repo=_optional("META_REPO", "meta"),
registry_repo=_required("REGISTRY_REPO"),
oauth_client_id=_required("OAUTH_CLIENT_ID"),
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
@@ -91,4 +92,5 @@ def load_config() -> Config:
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
openai_api_key=_optional("OPENAI_API_KEY"),
default_project_id=_optional("DEFAULT_PROJECT_ID"),
)
+23 -1
View File
@@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None:
if version in applied:
continue
sql = path.read_text()
conn.executescript("BEGIN; " + sql + "; COMMIT;")
if "-- migrate:no-foreign-keys" in sql:
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
# folding project_id into a composite key) follow the official
# 12-step ALTER procedure, which requires FK enforcement OFF —
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
# it must be toggled here, around the script. The connection is
# in autocommit mode (isolation_level=None), so the PRAGMA takes
# effect immediately. We re-enable and run foreign_key_check
# after, failing the migration loudly if the rebuild left any
# dangling reference.
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
raise RuntimeError(
f"migration {version} left foreign-key violations: "
f"{[tuple(v) for v in violations]}"
)
finally:
conn.execute("PRAGMA foreign_keys = ON")
else:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
finally:
conn.close()
+32 -21
View File
@@ -97,6 +97,13 @@ class IssueOutcome:
raw_token: str
row_id: int
@property
def cookie_value(self) -> str:
"""The value to put in the `rfc_device_trust` cookie: the row-id
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
selector lets `lookup` read one indexed row instead of scanning."""
return f"{self.row_id}.{self.raw_token}"
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
@@ -173,33 +180,37 @@ def lookup(raw_token: str) -> LookupOutcome:
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# The unique index on `device_token_hash` would let us SELECT by
# hash if bcrypt were a stable hash, but bcrypt incorporates a
# per-row salt — equal tokens produce different hashes. We walk
# the candidate set instead. In practice the set is small (a
# human has a handful of trusted devices) and bcrypt is cheap on
# the order of milliseconds; the walk is bounded by the user's
# active device count.
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
# parse the row-id selector and read exactly ONE row by its indexed
# primary key, then bcrypt-check the token against that single row.
#
# We don't pre-filter by `revoked_at IS NULL` here so that a
# token presented for a recently-revoked row produces a
# 'revoked' outcome (the endpoint surfaces a different shape).
# Same for expired: we let the walk hit and classify after.
rows = db.conn().execute(
# The previous shape read EVERY device_trust row (all users, including
# revoked/expired) and bcrypt-checked each — an unauthenticated
# CPU-amplification DoS reachable at /auth/device-trust/start that
# grew without bound as the table accumulated. bcrypt's per-row salt
# is why we can't SELECT by hash; carrying the row-id in the cookie is
# the standard fix (the id is not secret; the token still is).
selector, sep, token = raw.partition(".")
if not sep or not selector.isdigit() or not token:
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
# here. We refuse rather than fall back to a full-table scan, so
# the amplification path is fully closed; affected users simply
# re-authenticate once via OTC/passcode and get a new cookie.
return LookupOutcome(ok=False, user=None, reason="invalid")
matched = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
ORDER BY id DESC
WHERE id = ?
""",
).fetchall()
(int(selector),),
).fetchone()
matched = None
for row in rows:
if _check(raw, row["device_token_hash"]):
matched = row
break
if matched is None:
# One bcrypt check, against the selected row only. A wrong/forged token
# for a real id reads as 'unknown' (cookie cleared), same as a missing
# row — a probing client can't distinguish the two.
if matched is None or not _check(token, matched["device_token_hash"]):
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
+27 -15
View File
@@ -60,12 +60,17 @@ def build_envelope(
`from_name` is the display label that goes through `formataddr`
so spaces / commas in the display string are encoded correctly.
`body_plain` is mandatory. `body_html`, if supplied, lands as the
second part of a `multipart/alternative` body mail clients
that prefer HTML render it; clients that don't fall back to the
plain part. The text/plain part comes first per RFC 2046, so a
plain-text client that picks the first body gets the readable
text.
`body_plain` is mandatory. `body_html` is **reserved and not yet
enabled** (security-audit-0026 I3): no send path supplies it today
every rfc-app mail is plain text and passing it raises
`NotImplementedError`. The parameter is kept in the signature for
documented future symmetry: when HTML mail is enabled it will land
as the second part of a `multipart/alternative` body (text/plain
first per RFC 2046, so a plain-text client picking the first part
still gets the readable text). Enabling it is a deliberate act the
caller MUST HTML-escape any user content into `body_html` first (cf.
the C1 stored-XSS class: a mail client renders the HTML) and remove
the guard below in the same change.
`reply_to`, when set, lets a send path point replies at a
different mailbox than the From line (e.g., a watcher
@@ -131,13 +136,20 @@ def build_envelope(
# idempotent and not require auth. See
# `api_notifications.py` for the receiver.
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
if body_html:
# multipart/alternative: text/plain first, text/html second.
# `set_content` sets the first part (and the message's main
# body); `add_alternative` adds the second part and
# restructures the message as multipart/alternative.
msg.set_content(body_plain)
msg.add_alternative(body_html, subtype="html")
else:
msg.set_content(body_plain)
if body_html is not None:
# I3 (security-audit-0026): the multipart/alternative HTML path
# is intentionally NOT enabled. No send path passes `body_html`
# today, and emitting an HTML body built from user-supplied
# content without escaping it first would reintroduce the C1
# stored-XSS class in the mail channel (the recipient's client
# renders the HTML). Fail loudly here rather than silently
# shipping HTML: enabling HTML mail is a deliberate change that
# MUST HTML-escape user content at the call site and remove this
# guard together. The text/plain path below is the only live one.
raise NotImplementedError(
"HTML email is not enabled (security-audit-0026 I3): do not "
"pass body_html until user content is HTML-escaped at the "
"call site and this guard is intentionally removed."
)
msg.set_content(body_plain)
return msg
+20 -1
View File
@@ -30,7 +30,7 @@ _ABSENT = object()
class Entry:
slug: str
title: str
state: str = "super-draft" # super-draft | active | withdrawn
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None
proposed_by: str = ""
@@ -50,6 +50,13 @@ class Entry:
# operator credentials per §18 are used. The binding is inert until
# the named user has a funder_consents row (the hybrid two-key rule).
funder: str | None = None
# §22.4c: an `active` entry that landed without a human review gate
# carries unreviewed=True until an owner clears it. Orthogonal to
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
# are the provenance of the clear, paralleling graduated_at/by.
unreviewed: bool = False
reviewed_at: str | None = None
reviewed_by: str | None = None
body: str = ""
@@ -66,6 +73,7 @@ def parse(text: str) -> Entry:
models = [str(m) for m in raw_models]
raw_funder = fm.get("funder")
funder = str(raw_funder).strip() if raw_funder else None
unreviewed = bool(fm.get("unreviewed") or False)
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
@@ -81,6 +89,9 @@ def parse(text: str) -> Entry:
tags=list(fm.get("tags") or []),
models=models,
funder=funder,
unreviewed=unreviewed,
reviewed_at=fm.get("reviewed_at") or None,
reviewed_by=fm.get("reviewed_by") or None,
body=body,
)
@@ -110,6 +121,14 @@ def serialize(entry: Entry) -> str:
# second meaning here as with `models:`; one set of semantics.
if entry.funder:
fm["funder"] = entry.funder
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
# active entry leaves the key absent → frontmatter stays minimal).
if entry.unreviewed:
fm["unreviewed"] = True
if entry.reviewed_at:
fm["reviewed_at"] = entry.reviewed_at
if entry.reviewed_by:
fm["reviewed_by"] = entry.reviewed_by
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
+1 -1
View File
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
db.conn().execute(
"""
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
ON CONFLICT(user_id, rfc_slug) DO NOTHING
ON CONFLICT(project_id, user_id, rfc_slug) DO NOTHING
""",
(user_id, slug),
)
+17 -8
View File
@@ -30,7 +30,7 @@ import logging
import os
from datetime import datetime, timedelta, timezone
from . import db
from . import db, projects as projects_mod
from .bot import Bot
from .config import Config
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str,
) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and
graduation branches live on the meta repo; active-RFC branches
live on the per-RFC repo named by `cached_rfcs.repo`.
`(org, repo)` pair from the slug: under the meta-only topology (§1)
every meta-resident entry's edit branches and graduation branches
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
Returns True on a clean delete; False if the rfc row is missing
(we leave the branch row in place a subsequent reconciler sweep
@@ -297,12 +298,20 @@ async def _delete_branch_via_bot(
if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False
if rfc["state"] == "super-draft":
owner, repo = config.gitea_org, config.meta_repo
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
if not rfc["repo"]:
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning(
"hygiene: default project has no content_repo; skipping branch delete for %s/%s",
slug, branch,
)
return False
owner = config.gitea_org
elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1)
else:
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
slug, rfc["state"], rfc["repo"])
return False
try:
await bot.delete_branch(
+10 -4
View File
@@ -144,10 +144,16 @@ def create_invite(
The invitee `users` row is provisioned with:
* `permission_state='granted'` the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` the discriminator for "invited but
not yet arrived" per the §16 / roadmap design. Every sign-in
path stamps `last_seen_at` to now, so a NULL value means the
invited user has not clicked through yet.
* `created_at` / `last_seen_at` NOT set here, so both fall
through to the column default `datetime('now')` (the column is
`NOT NULL`; see `migrations/001_users_and_audit.sql` and the
longer note below). The "invited but not yet arrived" state is
therefore NOT carried on the user row it is the existence of
an unclaimed `user_invite_tokens` row, surfaced as the listing's
`pending_invite` field. Consumers that want a truthful
last-seen MUST treat a pending-invite row as never-seen rather
than trusting `last_seen_at` (every real sign-in path stamps it
to now, but an unclaimed invite has never hit one).
* `gitea_id = NULL`, `gitea_login = NULL` same as a v0.7.0
OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path.
+80 -20
View File
@@ -7,6 +7,7 @@ no need for a separate worker.
from __future__ import annotations
import logging
import os
import secrets
from contextlib import asynccontextmanager
@@ -27,7 +28,10 @@ from . import (
invites as invites_mod,
otc,
passcode as passcode_mod,
projects,
providers as providers_mod,
ratelimit,
registry as registry_mod,
turnstile,
webhooks,
)
@@ -98,6 +102,22 @@ async def lifespan(app: FastAPI):
db.run_migrations(config)
db.init(config)
gitea = Gitea(config)
# §22.2: mirror the registry before anything reads projects/content_repo.
# First boot has no last-good rows, so a missing/invalid registry is fatal
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
# fresh thereafter and tolerates a later bad PR.
try:
await registry_mod.refresh_registry(config, gitea)
except Exception as e:
raise RuntimeError(
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
) from e
if projects.default_content_repo(config) is None:
raise RuntimeError(
f"registry does not describe the default project "
f"{projects.resolved_default_id(config)!r} (no content_repo). "
f"Add it to {config.registry_repo_full}/projects.yaml."
)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
digest_sched = digest.DigestScheduler()
@@ -126,7 +146,7 @@ async def lifespan(app: FastAPI):
reconciler.start()
digest_sched.start()
hygiene_sched.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
log.info("RFC app started — registry %s", config.registry_repo_full)
try:
yield
finally:
@@ -142,12 +162,20 @@ def create_app() -> FastAPI:
# eagerly via load_config(). Everything else waits for lifespan.
config = load_config()
app = FastAPI(lifespan=lifespan)
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
# auth credential and must carry `Secure` in production so it never
# travels cleartext. Default to Secure; a dev box serving over plain
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
"0", "false", "no", "off",
)
app.add_middleware(
SessionMiddleware,
secret_key=config.secret_key,
session_cookie="rfc_session",
max_age=60 * 60 * 24 * 30,
https_only=False,
https_only=session_secure,
)
return app
@@ -155,24 +183,25 @@ def create_app() -> FastAPI:
app = create_app()
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
"""Attach the v0.11.0 device-trust cookie to the response.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
cookie value is the raw token; server-side storage is the hash.
The cookie is "essential" per the v0.13.0 cookie-consent contract
(it is part of authentication), so we set it regardless of the
user's analytics / other-cookies choice.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value`
"<row_id>.<raw_token>" so `device_trust.lookup` can read one indexed
row instead of scanning; server-side storage remains the bcrypt hash
of the token half only. The cookie is "essential" per the v0.13.0
cookie-consent contract (it is part of authentication), so we set it
regardless of the user's analytics / other-cookies choice.
Secure=True means the cookie is only ever sent over HTTPS. The
SessionMiddleware in `create_app` keeps `https_only=False` for
dev parity, but the device-trust cookie holds a 30-day credential
and must not travel cleartext production deployments serve over
HTTPS, so Secure on the device-trust cookie is non-negotiable.
Secure=True means the cookie is only ever sent over HTTPS the
device-trust cookie holds a 30-day credential and must never travel
cleartext. (The session cookie now also defaults to Secure; see M4 in
`create_app`.)
"""
response.set_cookie(
key=device_trust_mod.COOKIE_NAME,
value=raw_token,
value=cookie_value,
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
path="/",
secure=True,
@@ -245,6 +274,10 @@ def _oauth_router(config) -> APIRouter:
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody, request: Request):
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
# before the Turnstile network call or any bcrypt/SMTP work.
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
raise HTTPException(429, "Too many requests; please wait a few minutes")
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
@@ -252,7 +285,7 @@ def _oauth_router(config) -> APIRouter:
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
@@ -278,9 +311,25 @@ def _oauth_router(config) -> APIRouter:
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
# plus the per-email lockout enforced inside otc.verify_code.
ip = ratelimit.client_key(request)
if not ratelimit.verify_limiter.allow(ip):
raise HTTPException(429, "Too many attempts; please wait a few minutes")
result = otc.verify_code(body.email, body.code)
if result.reason == "locked":
raise HTTPException(
423,
{
"detail": "Too many failed attempts; wait a few minutes or request a new code",
"locked_until": result.locked_until,
},
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
# Legit sign-in: clear this IP's window so a user who fat-fingered
# a couple of codes isn't left throttled.
ratelimit.verify_limiter.reset(ip)
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
@@ -313,7 +362,7 @@ def _oauth_router(config) -> APIRouter:
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
return {
"ok": True,
"user": {
@@ -337,12 +386,17 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.get("/auth/passcode/check")
async def passcode_check(email: str = ""):
async def passcode_check(request: Request, email: str = ""):
"""Does this email have a passcode set? Anonymous endpoint —
the Login.jsx flow calls this after the user types their email
to decide whether to render a passcode input or fall back to
OTC. We surface only the boolean; lockout state, the hash, and
the set-at stamp are not leaked here."""
the set-at stamp are not leaked here.
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
boolean can't be bulk-harvested to enumerate accounts."""
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
raise HTTPException(429, "Too many requests; please wait a few minutes")
status = passcode_mod.passcode_status(email)
return {"has_passcode": status.has_passcode}
@@ -376,6 +430,11 @@ def _oauth_router(config) -> APIRouter:
v0.11.0: the body's `trust_device` flag, if true, mints a
fresh device-trust row and sets the long-lived cookie. Same
opt-in contract as `/auth/otc/verify`."""
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
# passcode lockout, so fan-out across emails is throttled too.
ip = ratelimit.client_key(request)
if not ratelimit.verify_limiter.allow(ip):
raise HTTPException(429, "Too many attempts; please wait a few minutes")
result = passcode_mod.verify_passcode(body.email, body.passcode)
if result.reason == "locked":
raise HTTPException(
@@ -387,11 +446,12 @@ def _oauth_router(config) -> APIRouter:
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid passcode")
ratelimit.verify_limiter.reset(ip)
auth.store_session(request, result.user)
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
return {
"ok": True,
"user": {
@@ -459,7 +519,7 @@ def _oauth_router(config) -> APIRouter:
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
# Has the user already set a passcode? (Could only happen via
# an admin pre-population path that doesn't exist yet, but
+95
View File
@@ -270,6 +270,86 @@ def fan_out_new_beta_request(
)
def fan_out_contribution_request(
*,
rfc_slug: str,
requester_user_id: int,
request_id: int,
matched_term: str,
who_i_am: str,
why: str,
use_case: str | None,
) -> list[int]:
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
(super-draft) RFC. Land one actionable notification per owner and
return their ids (the caller stamps the first onto the request row as
the inbox-action handle).
Personal-direct: the owner is the named subject of the request, so the
§15.4 email gate consults `email_personal_direct` exactly as for the
other owner-facing personal events no new preference column is
needed. The request's three free-text fields ride along in the payload
so the inbox row can show the full ask inline without a second fetch.
Actor is the requester per §15.9.
"""
requester = db.conn().execute(
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
).fetchone()
display = (requester["display_name"] if requester else None) or "Someone"
details = {
"request_id": request_id,
"matched_term": matched_term,
"requester_user_id": requester_user_id,
"requester_display": display,
"who_i_am": who_i_am,
"why": why,
"use_case": use_case or "",
}
notif_ids: list[int] = []
for recipient_id in _entry_owner_user_ids(rfc_slug):
if recipient_id == requester_user_id:
continue
notif_ids.append(
_emit_one(
recipient_user_id=recipient_id,
event_kind="contribution_request_on_pending_rfc",
category=CATEGORY_PERSONAL,
actor_user_id=requester_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details=details,
)
)
return notif_ids
def notify_contribution_decided(
*,
rfc_slug: str,
requester_user_id: int,
decider_user_id: int,
request_id: int,
accepted: bool,
) -> None:
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
their contribute request. On accept the requester also receives the
#12 invitation email out-of-band; this inbox row is the in-app echo
that points them at it."""
_emit_one(
recipient_user_id=requester_user_id,
event_kind=(
"contribution_request_accepted" if accepted else "contribution_request_declined"
),
category=CATEGORY_PERSONAL,
actor_user_id=decider_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details={"request_id": request_id},
)
def fan_out_chat_message(
*,
actor_user_id: int,
@@ -769,6 +849,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}."
if event_kind == "contribution_request_on_pending_rfc":
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
# super-draft reference that surfaced the offer; the inbox row
# renders Accept/Decline beneath this line.
term = extras.get("matched_term") or title
return f"{actor} wants to contribute to your pending RFC for '{term}'."
if event_kind == "contribution_request_accepted":
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
if event_kind == "contribution_request_declined":
return f"{actor} declined your request to contribute to {title}."
if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as
@@ -884,6 +974,11 @@ def list_inbox(
"read_at": row["read_at"],
"category": extras.get("category"),
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
# The row's payload, surfaced for kinds that render inline
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
# + Accept/Decline). Safe to expose: a recipient only ever sees
# their own notifications.
"extras": extras,
})
if bundled:
+76
View File
@@ -85,6 +85,15 @@ def _cooldown_seconds() -> int:
return 60
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
# ratelimit.py is the primary brute-force brake; this is the durable,
# passcode-parity layer.
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
@@ -206,6 +215,61 @@ class VerifyOutcome:
ok: bool
user: SessionUser | None
reason: str
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
locked_until: str | None = None
def _verify_lockout_until(email: str) -> str | None:
"""Return the active lockout stamp for `email`, or None if not locked.
Clears an elapsed lockout (and resets the counter) as a side effect so
the next failure starts a fresh budget mirrors passcode.verify_passcode.
"""
row = db.conn().execute(
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
(email,),
).fetchone()
if row is None or not row["locked_until"]:
return None
still_locked = db.conn().execute(
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
).fetchone()["locked"]
if still_locked:
return row["locked_until"]
db.conn().execute(
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
(email,),
)
return None
def _record_verify_failure(email: str) -> None:
"""Increment the per-email failure counter; stamp a lockout once it
crosses the threshold. Mirrors the passcode lockout shape."""
db.conn().execute(
"""
INSERT INTO otc_verify_state (email, failed_attempts)
VALUES (?, 1)
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
""",
(email,),
)
count = db.conn().execute(
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
).fetchone()["failed_attempts"]
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
db.conn().execute(
f"""
UPDATE otc_verify_state
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
WHERE email = ?
""",
(email,),
)
def _clear_verify_state(email: str) -> None:
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
def verify_code(email: str, code: str) -> VerifyOutcome:
@@ -214,6 +278,13 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
# its OTC-verify lockout window. The passcode path is unaffected — a
# locked-out OTC user can still set/use a passcode, and vice versa.
locked_until = _verify_lockout_until(email)
if locked_until:
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
@@ -237,6 +308,9 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
break
if matched is None:
# A genuine wrong guess against this email — the brute-force
# signal. Count it toward the lockout threshold (H1).
_record_verify_failure(email)
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
@@ -255,6 +329,8 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
# Success wipes the per-email failure counter (H1).
_clear_verify_state(email)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
+42
View File
@@ -0,0 +1,42 @@
"""Project registry — the §22 multi-project layer.
A deployment hosts one or more projects (§22.1). The git registry mirror
that lets a deployment declare projects lands in M3 and drives this module.
`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3;
the registry mirror (`registry.refresh_registry`) is authoritative.
"""
from __future__ import annotations
from . import db
from .config import Config
DEFAULT_PROJECT_ID = "default"
def resolved_default_id(config: Config) -> str:
"""The id of the deployment's bootstrap/default project. Plan A: always
'default' (the re-stamp to a config slug rides Plan B). The config knob is
read here so Plan B can flip the resolution without touching call sites."""
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
def default_content_repo(config: Config) -> str | None:
"""The content repo the single-corpus mirror reads, from the default
project's row (filled by the registry mirror). Replaces the retired
META_REPO. None until the registry mirror has run."""
row = db.conn().execute(
"SELECT content_repo FROM projects WHERE id = ?",
(resolved_default_id(config),),
).fetchone()
return row["content_repo"] if row and row["content_repo"] else None
def project_initial_state(project_id: str) -> str:
"""§22.4b landing state for new entries in a project. Defaults to
'super-draft' for an unknown/unset row (the safe, today's-flow default)."""
row = db.conn().execute(
"SELECT initial_state FROM projects WHERE id = ?", (project_id,)
).fetchone()
if row is None or not row["initial_state"]:
return "super-draft"
return row["initial_state"]
+15
View File
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
return providers
def construct_haiku(api_key: str) -> AnthropicProvider:
"""A dedicated Claude Haiku provider, independent of the
`ENABLED_MODELS` chat-picker universe.
The §9.1 tag-suggestion surface (roadmap #27) always wants the
cheap + fast model regardless of which models the operator surfaces
in the §8.12 picker, so it constructs Haiku directly from the
operator's Anthropic key rather than going through `load_providers`.
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
picker uses, so a model-string bump lands in one place.
"""
model, name = _CLAUDE_VARIANTS["claude-haiku"]
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
+92
View File
@@ -0,0 +1,92 @@
"""In-process per-IP sliding-window rate limiter (security audit 0026, H1).
The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`)
had no per-IP brake, so an attacker could fan out guesses against a
target identity bounded only by bcrypt cost. This module is the brake.
It is deliberately tiny: §4.2 says the app is a single process with a
colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]`
is sufficient and needs no shared store. State resets on restart, which
fails *open* for a brief window acceptable because the per-email OTC
lockout (`otc_verify_state`) and the passcode lockout both persist in the
database and carry the durable guarantee; this limiter is the
anti-fan-out layer on top.
Chosen over a per-identity lockout *as the primary control* because a
per-IP window throttles the attacker without letting them grief a victim
by locking that victim's account (the known downside of identity
lockouts). Both layers run together.
"""
from __future__ import annotations
import threading
import time
from collections import defaultdict, deque
class SlidingWindowLimiter:
"""Allow at most `max_events` per `window_seconds` per key.
`allow(key)` records an event and returns True if the key is still
within budget, False if it has exceeded it. Timestamps use a
monotonic clock so the limiter is immune to wall-clock jumps.
"""
def __init__(self, max_events: int, window_seconds: float) -> None:
self.max_events = max_events
self.window_seconds = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def allow(self, key: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_seconds
with self._lock:
q = self._events[key]
while q and q[0] < cutoff:
q.popleft()
if len(q) >= self.max_events:
return False
q.append(now)
# Opportunistic cleanup so idle keys don't accumulate forever.
if not q:
self._events.pop(key, None)
return True
def reset(self, key: str) -> None:
"""Drop a key's window — e.g. after a successful sign-in so a
legitimate user who fat-fingered a few times isn't throttled."""
with self._lock:
self._events.pop(key, None)
# Module-level limiters shared across requests (one process, so module
# state is the natural home). Tunables are intentionally generous enough
# not to bother a human retyping a code, tight enough to kill fan-out:
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
# this is defense in depth against a solved-challenge replay loop).
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
# It's a legitimate Login-flow affordance, so the budget is generous —
# enough for a human typing emails, tight enough to stop bulk scraping.
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
def _reset_all_for_tests() -> None:
"""Clear every module-level limiter's window. Test support only — the
limiters are process-global singletons, so without a per-test reset
one test's requests bleed into the next and later tests trip the
budget (429). Not called in production."""
for lim in (verify_limiter, otc_request_limiter, check_limiter):
with lim._lock:
lim._events.clear()
def client_key(request) -> str:
"""Best-effort client identity for limiting. Behind nginx the app is
started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host`
reflects the real client IP via Uvicorn's ProxyHeaders handling."""
client = getattr(request, "client", None)
return client.host if client and client.host else "unknown"
+185
View File
@@ -0,0 +1,185 @@
"""§22.2 project registry mirror — the config-side analogue of
cache.refresh_meta_repo.
A deployment declares its projects in a `projects.yaml` at the root of
REGISTRY_REPO. This module mirrors that file into the `projects` cache table
and the `deployment` singleton. Per §22.2, `projects` rows flow from the
registry only never from user actions. The mirror runs on the registry-repo
webhook and on every reconciler sweep (Option A wiring, Task 5).
"""
from __future__ import annotations
import base64
import json
import logging
import re
from dataclasses import dataclass, field
import yaml
from . import db
from .config import Config
from .gitea import Gitea
log = logging.getLogger(__name__)
VALID_TYPES = {"document", "specification", "bdd"}
VALID_VISIBILITY = {"gated", "public", "unlisted"}
VALID_INITIAL_STATE = {"super-draft", "active"}
# §22.4b: per-type default landing state.
_TYPE_DEFAULT_INITIAL_STATE = {
"document": "super-draft",
"specification": "super-draft",
"bdd": "active",
}
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
class RegistryError(Exception):
"""A registry document that fails validation, or a missing registry file.
Raised by parse_registry/refresh_registry. The caller decides severity:
fatal at startup (no last-good to serve), tolerated on a running deployment
(keep the last-good projects rows). See Task 5 wiring.
"""
@dataclass
class ProjectEntry:
id: str
name: str
type: str
content_repo: str
visibility: str
initial_state: str
config: dict = field(default_factory=dict) # theme, enabled_models
@dataclass
class RegistryDoc:
deployment_name: str
deployment_tagline: str
projects: list[ProjectEntry]
def parse_registry(text: str) -> RegistryDoc:
"""Parse + validate projects.yaml. Pure (no I/O). Raises RegistryError."""
raw = yaml.safe_load(text) or {}
dep = raw.get("deployment") or {}
projects_raw = raw.get("projects") or []
if not isinstance(projects_raw, list) or not projects_raw:
raise RegistryError("registry must declare at least one project")
seen: set[str] = set()
entries: list[ProjectEntry] = []
for p in projects_raw:
if not isinstance(p, dict):
raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}")
pid = str(p.get("id") or "").strip()
if not _SLUG_RE.match(pid):
raise RegistryError(f"project id {pid!r} is not a valid slug")
if pid in seen:
raise RegistryError(f"duplicate project id {pid!r}")
seen.add(pid)
name = str(p.get("name") or "").strip()
if not name:
raise RegistryError(f"project {pid!r} missing name")
ptype = str(p.get("type") or "").strip()
if ptype not in VALID_TYPES:
raise RegistryError(f"project {pid!r} has invalid type {ptype!r}")
content_repo = str(p.get("content_repo") or "").strip()
if not content_repo:
raise RegistryError(f"project {pid!r} missing content_repo")
vis = str(p.get("visibility") or "gated").strip()
if vis not in VALID_VISIBILITY:
raise RegistryError(f"project {pid!r} has invalid visibility {vis!r}")
initial_state = str(
p.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ptype]
).strip()
if initial_state not in VALID_INITIAL_STATE:
raise RegistryError(
f"project {pid!r} has invalid initial_state {initial_state!r}"
)
cfg: dict = {}
if p.get("theme") is not None:
cfg["theme"] = p["theme"]
if p.get("enabled_models") is not None:
cfg["enabled_models"] = [str(m) for m in p["enabled_models"]]
entries.append(
ProjectEntry(pid, name, ptype, content_repo, vis, initial_state, cfg)
)
return RegistryDoc(
deployment_name=str(dep.get("name") or "").strip(),
deployment_tagline=str(dep.get("tagline") or "").strip(),
projects=entries,
)
def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
"""Upsert the parsed registry into projects + deployment. Idempotent.
§22.4a: `type` is immutable a change against an existing row is rejected
(skip + log), never applied. Projects absent from the registry are left in
place (archival is out of scope for M3; they simply stop refreshing).
"""
with db.tx() as conn:
for e in doc.projects:
existing = conn.execute(
"SELECT type FROM projects WHERE id = ?", (e.id,)
).fetchone()
if existing is not None and existing["type"] != e.type:
log.error(
"registry: refusing immutable type change on project %s (%s -> %s)",
e.id, existing["type"], e.type,
)
continue
conn.execute(
"""
INSERT INTO projects
(id, name, type, content_repo, visibility, initial_state,
config_json, registry_sha, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
type = excluded.type,
content_repo = excluded.content_repo,
visibility = excluded.visibility,
initial_state = excluded.initial_state,
config_json = excluded.config_json,
registry_sha = excluded.registry_sha,
updated_at = datetime('now')
""",
(
e.id, e.name, e.type, e.content_repo, e.visibility,
e.initial_state, json.dumps(e.config), registry_sha,
),
)
conn.execute(
"""
UPDATE deployment
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
WHERE id = 1
""",
(doc.deployment_name, doc.deployment_tagline, registry_sha),
)
async def refresh_registry(config: Config, gitea: Gitea) -> None:
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
on transport failure; the caller chooses fatal-vs-tolerated.
"""
item = await gitea.get_contents(
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
)
if not item or item.get("type") != "file":
raise RegistryError(
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
)
text = base64.b64decode(item["content"]).decode("utf-8")
# Prefer the file's last commit sha for provenance (production Gitea
# includes it on the contents response); fall back to the blob sha.
sha = item.get("last_commit_sha") or item.get("sha") or ""
doc = parse_registry(text)
apply_registry(doc, sha)
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
+333
View File
@@ -0,0 +1,333 @@
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
The scanner splits a plain-text PR description / comment body into a list
of *segments* the frontend renders: plain-text runs interleaved with
typed link segments. The backend never emits HTML the frontend maps
each segment onto a React node so the surface is XSS-safe by
construction and independent of any HTML-sanitization layer.
Three buckets, one scan (Parts 13):
* ``{"type": "rfc", ...}`` Part 1. The term matches an
**accepted** (``state='active'``) RFC; renders as a link to it.
* ``{"type": "rfc-pending", ...}`` Part 3. The term matches a
**pending** RFC a super-draft (``state='super-draft'``: accepted
as an idea but not yet graduated to an active RFC) which has an
owner and a contribution surface. Renders as an "ask to contribute"
affordance carrying the owner's display name.
* ``{"type": "rfc-candidate", ...}`` Part 2. The term is a
strong-candidate that does **not** yet have a defining RFC. Renders
(for a viewer with create rights) as a "create RFC for '<term>'"
affordance that pre-fills the propose flow.
Precedence at any position is active > pending > candidate, then
longest-match-first an active link always wins over a contribute offer
which always wins over a create offer for the same span.
**Read-time enrichment, not submit-time persistence** (unchanged from
Part 1): drafts are never scanned, only submitted content on the read
paths, so links/offers track the *live* corpus. The active-RFC corpus,
super-draft corpus, and tag taxonomy are all small and cache-resident,
so building the index and scanning a 20k-char body per read is cheap.
**Matching stays conservative by design.** A reference links/offers only
when it is unlikely to be coincidental:
* ``rfc_id`` tokens (e.g. ``RFC-0001``) inherently specific.
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
Single common-word titles/slugs are deliberately NOT matched they
would turn every prose occurrence into an affordance.
**Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
from the #27 tag taxonomy (the de-facto set of tags the corpus already
carries) that has no defining RFC (no active or super-draft RFC whose
slug or title is that term). Multi-word is the same false-positive guard
the title rule uses: a single common tag word (``identity``) would be
far too noisy. Broader candidate detection capitalized multi-word
phrases mined from the text, terms repeated across recently-touched PRs,
or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but
deferred extension; the conservative tag-taxonomy heuristic is chosen
here to match Part 1's false-positive-averse philosophy.
"""
from __future__ import annotations
import json
import re
from typing import Any, Iterable, NamedTuple
class Term(NamedTuple):
"""One match key plus what to emit when it hits.
``key`` is the lowercase span to match (word-boundary, longest-first).
``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the
emitted segment shape. ``slug``/``title`` carry the target RFC (active
+ pending); ``owner`` is the pending RFC's owner display name;
``term`` is the candidate's canonical display spelling.
"""
key: str
kind: str = "active"
slug: str = ""
title: str = ""
owner: str = ""
term: str = ""
# Lower number = higher precedence when two keys of equal length match at
# the same position. A real link beats a contribute offer beats a create
# offer.
_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2}
def _coerce(t: Term | tuple) -> Term:
"""Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an
active term) alongside :class:`Term`, so direct unit-test callers and
older call sites keep working."""
if isinstance(t, Term):
return t
key, slug, title = t # legacy active 3-tuple
return Term(key=key, kind="active", slug=slug, title=title)
def _is_word_char(c: str) -> bool:
"""Word-boundary test. Hyphen and underscore count as word chars so a
match can't begin or end in the middle of a kebab/snake token."""
return c.isalnum() or c in ("-", "_")
def _emit(term: Term, label: str) -> dict[str, Any]:
"""The segment dict for a matched ``term``; ``label`` preserves source
casing."""
if term.kind == "pending":
return {
"type": "rfc-pending",
"slug": term.slug,
"label": label,
"title": term.title,
"owner": term.owner,
}
if term.kind == "candidate":
return {"type": "rfc-candidate", "label": label, "term": term.term}
return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title}
def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
"""Split ``text`` into text / link segments against ``terms``.
``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)``
active 3-tuples). Matching is case-insensitive, respects word
boundaries on both ends, and prefers the longest key then higher
:data:`_KIND_PRIORITY` at any position.
Always returns at least one segment; for empty/None input that is a
single empty text segment, so callers can render uniformly.
"""
ordered = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
if not text:
return [{"type": "text", "text": text or ""}]
out: list[dict[str, Any]] = []
buf: list[str] = []
low = text.lower()
n = len(text)
i = 0
while i < n:
match: tuple[Term, int] | None = None
for term in ordered:
klen = len(term.key)
if klen == 0 or not low.startswith(term.key, i):
continue
before = text[i - 1] if i > 0 else ""
after = text[i + klen] if i + klen < n else ""
if _is_word_char(before) or _is_word_char(after):
continue
match = (term, klen)
break
if match is not None:
term, klen = match
if buf:
out.append({"type": "text", "text": "".join(buf)})
buf = []
out.append(_emit(term, text[i:i + klen]))
i += klen
else:
buf.append(text[i])
i += 1
if buf:
out.append({"type": "text", "text": "".join(buf)})
return out
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
"""The match keys an RFC contributes. See the module docstring for why
each gate exists (conservative, false-positive-averse)."""
if rfc_id:
rid = rfc_id.strip()
if len(rid) >= 2:
yield rid.lower()
if title:
t = title.strip()
# Multi-word titles only — a single common word is too noisy.
if len(t) >= 2 and (" " in t or "\t" in t):
yield t.lower()
if slug:
s = slug.strip()
# Hyphenated slugs only — a single-token slug is a bare word.
if len(s) >= 2 and "-" in s:
yield s.lower()
def _slugify(term: str) -> str:
"""Deterministic kebab-case — mirrors the propose modal's slugify so a
tag's would-be slug compares correctly against existing RFC slugs."""
return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip())))
class LinkIndex:
"""A reusable term index built once per request and applied to many
bodies (a PR's description plus every comment on it)."""
def __init__(self, terms: Iterable[Term | tuple]):
# Coerce + order once; segment_text re-sorts defensively but a
# pre-sorted list keeps the per-body cost to the scan itself.
self._terms: list[Term] = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
def __bool__(self) -> bool:
return bool(self._terms)
def segment(self, text: str | None) -> list[dict[str, Any]]:
return segment_text(text, self._terms)
def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
"""The display name to show for a pending RFC's owner. First entry of
``owners_json`` resolved to its user row's display name, falling back
to the bare login, then ``proposed_by``, then a neutral noun."""
login = None
try:
owners = json.loads(owners_json or "[]")
if isinstance(owners, list):
login = next((o for o in owners if isinstance(o, str) and o.strip()), None)
except (ValueError, TypeError):
login = None
if login:
row = conn.execute(
"SELECT display_name FROM users WHERE gitea_login = ?", (login,)
).fetchone()
if row and row["display_name"]:
return row["display_name"]
return login
return (proposed_by or "").strip() or "the proposer"
def _tag_universe(conn) -> list[str]:
"""Distinct tags across the cached corpus (the #27 de-facto taxonomy),
preserving original spelling; case-deduped."""
rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
out: list[str] = []
seen: set[str] = set()
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
low = tag.lower()
if tag and low not in seen:
seen.add(low)
out.append(tag)
return out
def build_index(
conn,
*,
exclude_slug: str | None = None,
include_pending: bool = True,
include_candidates: bool = True,
) -> LinkIndex:
"""Build a :class:`LinkIndex` over the three buckets.
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
to, so an RFC's own title/id/slug don't self-link (or self-offer)
inside its own PR or discussion. Precedence is enforced by insertion
order active keys are added first and a later bucket never overrides
an already-claimed key.
"""
terms: list[Term] = []
seen: set[str] = set()
def add(key: str, term: Term) -> None:
if key in seen:
return
seen.add(key)
terms.append(term)
# --- Part 1: accepted (active) RFCs. ORDER BY slug makes key
# de-duplication deterministic when two RFCs would contribute the
# same key (first slug wins). ---
active_rows = conn.execute(
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
).fetchall()
# Track every slug + title that *has* a defining RFC, so Part 2 never
# offers to create one that already exists (active or pending).
defined_slugs: set[str] = set()
defined_titles: set[str] = set()
for r in active_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="active", slug=slug, title=title))
# --- Part 3: pending (super-draft) RFCs. ---
pending_rows = conn.execute(
"""
SELECT slug, title, rfc_id, owners_json, proposed_by
FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug
"""
).fetchall()
for r in pending_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if not include_pending:
continue
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
owner = _owner_display(conn, r["owners_json"], r["proposed_by"])
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner))
# --- Part 2: strong-candidate terms with no defining RFC. ---
if include_candidates:
for tag in _tag_universe(conn):
low = tag.lower()
# Conservative: multi-word tags only (same guard as titles).
if " " not in tag and "\t" not in tag:
continue
if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
continue
add(low, Term(key=low, kind="candidate", term=tag))
return LinkIndex(terms)
+244
View File
@@ -0,0 +1,244 @@
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
A cheap, fast assist: given the partial RFC draft a user is typing, ask
Claude Haiku to recommend tags drawn ONLY from the tags already in use
across the corpus. v1 has no curated tag list tags are free-form chip
input (§9.1) so "the taxonomy" is the de-facto set of distinct tags
the existing RFCs already carry. The model is constrained to that set
and MUST NOT invent new tags; taxonomy extension (letting the model
propose genuinely new tags) is a deferred follow-up per the roadmap.
Why Haiku specifically: cost. Picking a few reasonable tags from a known
set is well within Haiku's range, and the modal fires this repeatedly as
the user types, so the per-call price has to stay small.
The whole surface degrades to silence rather than error: no Anthropic
key, no provider, an empty corpus, a rate-limited caller, an empty
draft, or an unparseable model reply all yield an empty suggestion list.
The propose modal hides its suggestion row on an empty list, so the
fallback is simply the existing free-form chip input with nothing extra
shown.
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from . import db
from .providers import BaseProvider, construct_haiku
log = logging.getLogger(__name__)
# Bound the universe handed to the model so a large corpus can't blow up
# the prompt size (and the cost). The most-common tags matter most.
_UNIVERSE_CAP = 200
# How many suggestions we ever return to the modal.
DEFAULT_MAX_SUGGESTIONS = 6
@dataclass
class Draft:
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
rationale; `use_case` is the #26 optional ground-truth field."""
title: str = ""
pitch: str = ""
use_case: str = ""
def is_empty(self) -> bool:
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
def haiku_provider(config) -> BaseProvider | None:
"""Construct a dedicated Claude Haiku provider from the operator's
Anthropic key, or return None when no key is configured.
None means "suggestions unavailable" the caller returns an empty
list and the modal shows nothing. This is the seam tests monkeypatch
to inject a fake provider without a real key. There is no RFC slug at
propose time, so the §6.7 per-RFC funder path deliberately does not
apply: tag suggestion always runs on the operator's own key.
"""
key = getattr(config, "anthropic_api_key", "") or ""
if not key:
return None
return construct_haiku(key)
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
"""Every distinct tag in use across the cached corpus, most-common
first (ties broken alphabetically for determinism), capped.
This is the universe the model is constrained to. An empty corpus
yields an empty list, which short-circuits suggestion to silence.
"""
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
counts: dict[str, int] = {}
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
if tag:
counts[tag] = counts.get(tag, 0) + 1
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
return ranked[:cap]
# ---------------------------------------------------------------------------
# Rate limiting — in-process, per-user sliding window.
#
# Cost control, not security: the `require_contributor` gate already
# bounds callers to authenticated beta users, and the modal debounces.
# This is the backstop against a stuck/abusive client hammering the
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
# and resets on restart, which is fine for a cost guard.
# ---------------------------------------------------------------------------
_CALLS: dict[int, list[float]] = {}
def _rate_config() -> tuple[int, float]:
try:
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
except ValueError:
max_calls = 30
try:
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
except ValueError:
window = 60.0
return max_calls, window
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
"""True if this call is within the per-user window; records the call.
`now` is injectable for tests (defaults to a monotonic clock)."""
max_calls, window = _rate_config()
t = time.monotonic() if now is None else now
calls = _CALLS.setdefault(user_id, [])
cutoff = t - window
calls[:] = [c for c in calls if c > cutoff]
if len(calls) >= max_calls:
return False
calls.append(t)
return True
def reset_rate_limits() -> None:
"""Test seam — clear the in-process window state."""
_CALLS.clear()
# ---------------------------------------------------------------------------
# Prompt + parse.
# ---------------------------------------------------------------------------
_SYSTEM = (
"You suggest topic tags for a draft RFC — a structured proposal "
"document in a collection. You are given the draft's title, its "
"rationale, an optional use case, and the exact set of tags already "
"in use across the collection. Choose the tags from that set that "
"best fit the draft.\n"
"Rules:\n"
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
"- Return at most {max} tags.\n"
"Respond with ONLY a JSON array, no prose, of the form:\n"
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
"If no tag in the set fits the draft, return []."
)
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
system = _SYSTEM.format(max=max_suggestions)
parts: list[str] = []
if draft.title.strip():
parts.append(f"Title: {draft.title.strip()[:300]}")
if draft.pitch.strip():
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
if draft.use_case.strip():
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
parts.append(
"Tags already in use (choose only from these):\n" + ", ".join(universe)
)
return system, [{"role": "user", "content": "\n\n".join(parts)}]
def _extract_json_array(text: str):
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end < start:
return None
try:
return json.loads(text[start : end + 1])
except ValueError:
return None
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
"""Parse the model reply into a clean, universe-constrained list.
Tolerant of the model returning bare strings or objects, extra prose
around the JSON, unknown/invented tags (dropped), duplicate tags
(deduped), and missing/garbage confidences (defaulted + clamped).
"""
data = _extract_json_array(text or "")
if not isinstance(data, list):
return []
# Map back to the canonical spelling in the universe, case-insensitively,
# so a model that lowercases a tag still resolves to the real one.
canonical = {t.lower(): t for t in universe}
out: list[dict] = []
seen: set[str] = set()
for item in data:
if isinstance(item, dict):
raw = item.get("tag")
conf = item.get("confidence")
elif isinstance(item, str):
raw, conf = item, None
else:
continue
if not isinstance(raw, str):
continue
tag = canonical.get(raw.strip().lower())
if tag is None or tag in seen:
continue
try:
c = float(conf) if conf is not None else 0.5
except (ValueError, TypeError):
c = 0.5
c = max(0.0, min(1.0, c))
out.append({"tag": tag, "confidence": round(c, 3)})
seen.add(tag)
if len(out) >= max_suggestions:
break
return out
def suggest(
provider: BaseProvider,
draft: Draft,
universe: list[str],
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
) -> list[dict]:
"""Orchestrate one suggestion call. Returns [] for an empty draft or
empty universe (no model call), and for any provider/parse failure."""
if draft.is_empty() or not universe:
return []
system, history = _build_messages(draft, universe, max_suggestions)
try:
text = provider.send(system, history)
except Exception as exc: # provider/network failure → silent empty
log.warning("tag-suggest provider failed: %s", exc)
return []
return parse_reply(text, universe, max_suggestions)
+23 -5
View File
@@ -73,6 +73,19 @@ def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
"""Perform the siteverify POST on an `httpx.AsyncClient`.
Isolated as a narrow seam (I4, security-audit-0026): the call is
awaited so a slow CloudFlare response can't block the event loop,
and tests patch *this function* rather than the shared
`httpx.AsyncClient` (which other modules gitea, docs also
construct, so a global patch would break app boot).
"""
async with httpx.AsyncClient(timeout=10.0) as client:
return await client.post(url, data=data)
@dataclass
class VerifyOutcome:
"""Result of a Turnstile siteverify call.
@@ -98,7 +111,7 @@ class VerifyOutcome:
reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint
@@ -108,9 +121,14 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
+ a MockTransport client) to avoid touching the real CloudFlare
endpoint. No real keys are ever embedded in tests.
Async (I4, security-audit-0026): the siteverify call is awaited on an
`httpx.AsyncClient` so a slow CloudFlare response can't block the
event loop (the prior synchronous `httpx.post` stalled the single
worker for up to the 10s timeout). Callers must `await` it.
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
touching the real CloudFlare endpoint and to keep the patch off the
shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
"""
secret = _secret()
required = _required()
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
data["remoteip"] = client_ip
try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
response = await _siteverify_post(_siteverify_url(), data)
payload = response.json()
except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc)
+16 -8
View File
@@ -16,7 +16,7 @@ import os
from fastapi import APIRouter, Header, HTTPException, Request
from . import cache, db
from . import cache, db, projects as projects_mod, registry as registry_mod
from .config import Config
from .gitea import Gitea
@@ -79,9 +79,22 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
except Exception:
payload = {}
repo_full = (payload.get("repository") or {}).get("full_name") or ""
meta_full = f"{config.gitea_org}/{config.meta_repo}"
registry_full = f"{config.gitea_org}/{config.registry_repo}"
content_repo = projects_mod.default_content_repo(config)
if not content_repo:
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
try:
if repo_full == meta_full or not repo_full:
if repo_full == registry_full:
# §22.2: a registry-repo push re-mirrors the projects table.
# Tolerate a malformed projects.yaml (keep last-good rows); let a
# transport error bubble to the outer 500 so an unreachable Gitea
# on a registry push is loud rather than silently dropped.
try:
await registry_mod.refresh_registry(config, gitea)
except registry_mod.RegistryError:
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
elif content_full and (repo_full == content_full or not repo_full):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
@@ -90,11 +103,6 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
if slug:
await cache.refresh_rfc_repo(config, gitea, slug)
else:
# v0.18.0: the proposal's "unknown-repo logging"
# gesture — a hook on a fork or a stale repo binding
# used to silently 200-OK here, hiding the
# misconfiguration. Now the operator sees it in
# the log.
log.info(
"webhook received for unknown repo: repo_full=%s event=%s "
"(no cached_rfcs row matched; hook may be on a fork or stale)",
@@ -0,0 +1,47 @@
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
-- this for?" capture on the two propose surfaces.
--
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
-- (002_cache.sql). We add the nullable column to each, matching the
-- existing naming convention (no NOT NULL, no default — NULL is the
-- "left blank" sentinel the view surfaces render tastefully).
--
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
-- sets only the columns it knows about, so an unlisted column is
-- preserved on the update path — yet a propose/open never *writes* the
-- column through the cache (the write path is endpoint -> Gitea ->
-- reconcile, and the reconciler does not carry this field). So the cache
-- column alone would always read NULL.
--
-- The durable home is therefore a dedicated app-truth table the propose
-- /open endpoints write directly (keyed by the PR number, which is the
-- stable identity for both idea PRs and rfc_branch PRs) and the view
-- endpoints read back. This is not cache — it is canonical and survives
-- any reconcile. The cache columns are added too for parity with the
-- roadmap's literal shape and for any future reconciler that learns to
-- carry the field, but the side table is the source of truth read at
-- view time.
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
-- Canonical, reconcile-proof store. One row per propose/open that
-- supplied a use case. `scope` distinguishes the propose-RFC surface
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
-- is the join key the endpoints already have in hand. NULL/omitted use
-- cases simply never write a row here, so absence == "left blank".
CREATE TABLE proposed_use_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope, pr_number)
);
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
@@ -0,0 +1,54 @@
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
--
-- Track each authenticated user's last-viewed route + a small bag of
-- "light" component state so that the *next* sign-in can land the user
-- back where they left off, rather than always dropping them on the
-- empty-state home view.
--
-- Storage shape (one row per user — per-user, NOT per-device, per the
-- #29 "safe default"):
--
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
-- AUTOINCREMENT). A deleted user automatically loses their stored
-- resume state. One row per user means a later sign-in on any
-- device resumes the most-recently-recorded route — the per-user
-- model the roadmap asks for.
--
-- * `last_route` — the frontend pathname the user was last on
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
-- route-change POST lands. NEVER contains draft-buffer contents —
-- it is a route only. See SPEC §6.2 "Sign-in state resume
-- (privacy)".
--
-- * `last_route_state` — a JSON-encoded bag of *light* component
-- state (scroll anchors, open-tab selection, filter chips, etc.).
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
-- cf. permission_events.details, actions.details). Nullable.
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
-- PR bodies, comment drafts, or any user-typed content — only
-- ephemeral view state safe to replay. The PUT handler is the
-- enforcement point; the column comment is the contract.
--
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
-- "resume me where I left off"; 0 means "always land on home". The
-- PUT handler no-ops the upsert when this is 0, and the read path
-- refuses to hand back a stored route when this is 0. A
-- profile-settings toggle UI to flip this is a follow-up (the
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
--
-- * `last_updated_at` — TEXT timestamp, app convention
-- `datetime('now')`, matching device_trust.last_seen_at /
-- users.last_seen_at. Refreshed on every successful upsert.
--
-- No new env vars. The debounce interval for the frontend route-change
-- POST is a frontend constant (~1s), not a server knob.
CREATE TABLE user_session_state (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_route TEXT,
last_route_state TEXT, -- JSON-encoded light state, nullable
resume_enabled INTEGER NOT NULL DEFAULT 1,
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -0,0 +1,18 @@
-- v0.25.0 / security audit 0026, finding H1.
--
-- The OTC verify path had no attempt-limit or lockout, unlike the
-- passcode path (015_passcode.sql gave users.passcode_failed_attempts +
-- passcode_locked_until). This table gives the OTC verify endpoint the
-- same per-identity lockout shape. It is keyed by email rather than
-- user_id because an OTC sign-in may not have a users row yet — the row
-- is provisioned only on a *successful* verify, so the lockout state has
-- to survive independently of it.
--
-- The per-IP rate limiter (app/ratelimit.py) is the primary brute-force
-- defense; this table is the parity layer that mirrors the passcode
-- lockout and persists across restarts.
CREATE TABLE IF NOT EXISTS otc_verify_state (
email TEXT PRIMARY KEY,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
@@ -0,0 +1,59 @@
-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
--
-- When the #28 scanner matches a term in submitted PR/comment text to a
-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated
-- to an active RFC), the reader is offered a "ask to contribute" popover.
-- Submitting it lands a row here AND a notification in each owner's §15
-- inbox; the owner can accept (which fires #12's owner-invite flow with
-- the requester as the invitee) or decline (the requester is notified and
-- the request closes).
--
-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state =
-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the
-- accept path reuses resolves), it carries owners (owners_json) to route
-- the request to, and it already has a discussion/contribution surface to
-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution
-- surface) are deliberately out of scope — see backend/app/rfc_links.py.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.
CREATE TABLE IF NOT EXISTS contribution_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL
REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
requester_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE CASCADE,
-- The term in the PR/comment text that surfaced the offer (e.g. the
-- super-draft's title). Carried for the owner's context line and the
-- requester's "what RFC" anchor; not a foreign key.
matched_term TEXT NOT NULL,
-- The three contribute-request fields (§15 / #26 vocabulary).
-- `who_i_am` and `why` are required; `use_case` mirrors #26's
-- optional ground-truth field.
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- The rfc_invitations row minted on accept (the #12 reuse), and the
-- owner-facing notification row that carries the Accept/Decline action.
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc
ON contribution_requests(rfc_slug, status);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester
ON contribution_requests(requester_user_id, status);
-- At most one open (pending) request per (RFC, requester): a second ask
-- while one is still pending is a 409, not a duplicate row. A decided
-- request (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open
ON contribution_requests(rfc_slug, requester_user_id)
WHERE status = 'pending';
+61
View File
@@ -0,0 +1,61 @@
-- §3 / §13.7: add the `retired` soft-delete state to cached_rfcs.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table
-- with the expanded constraint and copy the rows across. cached_rfcs is a
-- §4 cache (reconstructible from Gitea by the reconciler), and nothing
-- holds a foreign key into it, so the rebuild is safe; we preserve the
-- existing rows anyway to avoid a needless full re-read on upgrade.
--
-- The rebuilt table must carry EVERY column cached_rfcs has accumulated,
-- including the ones added by later migrations via ALTER TABLE ADD COLUMN:
-- 009_per_rfc_models -> models_json
-- 010_funder -> funder_login
-- 021_proposed_use_case -> proposed_use_case
-- They are appended last (matching the live column order) and copied
-- across explicitly so nothing is dropped.
--
-- The migration runner wraps this file in a single BEGIN/COMMIT, so the
-- swap is atomic.
CREATE TABLE cached_rfcs_new (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT, -- 'RFC-NNNN' or NULL (NULL is also valid for an active RFC graduated without a number, §13.2)
repo TEXT, -- 'org/repo' or NULL; always NULL under the meta-only topology (§1)
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT, -- 009_per_rfc_models
funder_login TEXT, -- 010_funder
proposed_use_case TEXT -- 021_proposed_use_case
);
INSERT INTO cached_rfcs_new
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
models_json, funder_login, proposed_use_case)
SELECT
slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
models_json, funder_login, proposed_use_case
FROM cached_rfcs;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs_new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
+105
View File
@@ -0,0 +1,105 @@
-- §22 (multi-project deployments) — Slice M1: the project spine.
--
-- A deployment now hosts one or more *projects*, each a corpus with its own
-- content repo, slug/RFC-NNNN namespace, catalog, roster, and branding. The
-- pre-multi-project single-corpus deployment is the N=1 case: this migration
-- generates one 'default' project and stamps every existing RFC-scoped row
-- to it, so the app keeps running exactly as before with the spine
-- underneath (see docs/design/multi-project-spec.md §22.13).
--
-- STRATEGY — additive, no table rebuilds. Every slug-bearing table gets a
-- `project_id TEXT NOT NULL DEFAULT 'default'` column. The constant default
-- means every existing INSERT in the codebase that does not yet mention
-- project_id keeps working and lands rows in the default project; no query
-- breaks because, with a single project, slugs remain globally unique. The
-- column carries no inline REFERENCES clause: SQLite's ALTER TABLE ADD
-- COLUMN forbids a FK column with a non-NULL default. project_id referential
-- integrity is therefore enforced at the app layer for now; the FK lands
-- with the table rebuilds below.
--
-- ============================================================================
-- DEFERRED to the slice that activates project #2 (M3/M4). Until a second
-- project exists these are correct as-is; the moment two projects can share a
-- slug or a Gitea PR number they become cross-project collision bugs and MUST
-- be rebuilt (SQLite needs a create-copy-drop-rename per table) to fold
-- project_id into the key, and to add the project_id FK:
-- * cached_rfcs PRIMARY KEY (slug) -> (project_id, slug)
-- * cached_branches UNIQUE (rfc_slug, branch_name) -> +project_id
-- * branch_visibility UNIQUE (rfc_slug, branch_name) -> +project_id
-- * branch_contribute_grants UNIQUE (rfc_slug, branch_name, grantee_user_id) -> +project_id
-- * stars UNIQUE (user_id, rfc_slug) -> +project_id
-- * watches UNIQUE (user_id, rfc_slug) -> +project_id
-- * pr_seen UNIQUE (user_id, rfc_slug, pr_number) -> +project_id
-- * branch_chat_seen UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
-- * funder_consents PRIMARY KEY (user_id, rfc_slug) -> +project_id
-- * rfc_collaborators UNIQUE INDEX (rfc_slug, user_id) -> +project_id
-- * contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending -> +project_id
-- * proposed_use_cases UNIQUE (scope, pr_number) -> +project_id
-- (PR numbers are per-content-repo = per-project)
-- cached_prs UNIQUE (repo, pr_number) is already globally unique (repo is the
-- full 'org/repo' string, distinct per project) and needs no rebuild.
-- ============================================================================
-- The project registry cache (mirrored from the git registry by the M3
-- reconciler; rows are never written from user actions). content_repo is
-- NULL until the mirror — or the M1 startup backfill (§22.13 step 1) — sets
-- it from the deployment's configured repo.
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
content_repo TEXT,
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
config_json TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- The default project. visibility='public' preserves the pre-multi-project
-- open-by-default posture (§22.5, §22.13). name is a placeholder the startup
-- backfill / registry overwrites with the deployment's display name.
INSERT OR IGNORE INTO projects (id, name, visibility)
VALUES ('default', 'default', 'public');
-- Per-(user, project) membership and the §22.6 middle-tier role. This is the
-- new tier between the §6.1 deployment role (users.role, now deployment-scope
-- only) and the §6.3 per-RFC authority.
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'project_viewer'
CHECK (role IN ('project_admin', 'project_contributor', 'project_viewer')),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id);
-- The project_id spine across every slug-bearing table. Backfills existing
-- rows to 'default' via the constant column default.
ALTER TABLE cached_rfcs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE cached_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE cached_prs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_visibility ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_contribute_grants ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE stars ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE threads ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE changes ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE pr_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_chat_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE watches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE notifications ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE actions ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE pr_resolution_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE funder_consents ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE rfc_invitations ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE rfc_collaborators ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE proposed_use_cases ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE contribution_requests ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
-- The catalog/directory lookup the M3 surfaces will make (RFCs in a project).
-- The per-table composite-key rebuilds in the DEFERRED block above will add
-- their own (project_id, …) indexes when they land.
CREATE INDEX IF NOT EXISTS idx_cached_rfcs_project ON cached_rfcs(project_id);
@@ -0,0 +1,31 @@
-- §22 M3 (Plan A) — additive registry/runtime-config schema.
--
-- This is the additive half of M3's backend. It adds the project `type` and
-- `initial_state` columns (mirrored from the registry), the deployment
-- singleton (deployment name/tagline mirrored from the registry), and the
-- §22.4c review columns on cached_rfcs. NO table rebuilds: the §22.13 PK
-- rebuilds and the default->slug re-stamp ride a later migration (Plan B),
-- just before a second project can collide (see migration 026's DEFERRED
-- block and docs/superpowers/specs/2026-06-03-m3-backend-design.md §1/§6).
ALTER TABLE projects ADD COLUMN type TEXT NOT NULL DEFAULT 'document'
CHECK (type IN ('document', 'specification', 'bdd'));
ALTER TABLE projects ADD COLUMN initial_state TEXT NOT NULL DEFAULT 'super-draft'
CHECK (initial_state IN ('super-draft', 'active'));
-- Deployment-level identity (name, tagline) mirrored from the registry's
-- `deployment:` block. A singleton: the CHECK pins it to one row.
CREATE TABLE IF NOT EXISTS deployment (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
tagline TEXT,
registry_sha TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO deployment (id) VALUES (1);
-- §22.4c review flag + provenance. unreviewed is git-truth (mirrored from
-- entry frontmatter); it survives a cache rebuild like `state` does.
ALTER TABLE cached_rfcs ADD COLUMN unreviewed INTEGER NOT NULL DEFAULT 0;
ALTER TABLE cached_rfcs ADD COLUMN reviewed_at TEXT;
ALTER TABLE cached_rfcs ADD COLUMN reviewed_by TEXT;
@@ -0,0 +1,276 @@
-- migrate:no-foreign-keys
--
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
-- constraints that migration 026 deliberately left global, so a *second*
-- project can hold an entry with the same slug as the first. M1 (026) added
-- project_id additively (no rebuild); this is the rebuild that activates
-- project #2, enumerated in 026's header.
--
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
-- above tells the runner to toggle it and run foreign_key_check after).
--
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
-- their FK at its new composite (project_id, slug) key.
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_id, slug) ──────────────────
CREATE TABLE cached_rfcs__new (
slug TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT,
repo TEXT,
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT,
funder_login TEXT,
proposed_use_case TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
unreviewed INTEGER NOT NULL DEFAULT 0,
reviewed_at TEXT,
reviewed_by TEXT,
PRIMARY KEY (project_id, slug)
);
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
CREATE INDEX idx_cached_rfcs_project ON cached_rfcs(project_id);
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
CREATE TABLE rfc_invitations__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
DROP TABLE rfc_invitations;
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
-- ── cached_branches: UNIQUE (rfc_slug, branch_name) -> +project_id ─────────
CREATE TABLE cached_branches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name)
);
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
DROP TABLE cached_branches;
ALTER TABLE cached_branches__new RENAME TO cached_branches;
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- ── branch_visibility: UNIQUE (rfc_slug, branch_name) -> +project_id ───────
CREATE TABLE branch_visibility__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name)
);
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
DROP TABLE branch_visibility;
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
CREATE TABLE branch_contribute_grants__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
);
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
DROP TABLE branch_contribute_grants;
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- ── stars: UNIQUE (user_id, rfc_slug) -> +project_id ───────────────────────
CREATE TABLE stars__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug)
);
INSERT INTO stars__new SELECT * FROM stars;
DROP TABLE stars;
ALTER TABLE stars__new RENAME TO stars;
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
-- ── watches: UNIQUE (user_id, rfc_slug) -> +project_id ─────────────────────
CREATE TABLE watches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug)
);
INSERT INTO watches__new SELECT * FROM watches;
DROP TABLE watches;
ALTER TABLE watches__new RENAME TO watches;
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
-- ── pr_seen: UNIQUE (user_id, rfc_slug, pr_number) -> +project_id ──────────
CREATE TABLE pr_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug, pr_number)
);
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
DROP TABLE pr_seen;
ALTER TABLE pr_seen__new RENAME TO pr_seen;
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
CREATE TABLE branch_chat_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug, branch_name)
);
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
DROP TABLE branch_chat_seen;
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
CREATE TABLE funder_consents__new (
user_id INTEGER NOT NULL,
rfc_slug TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
PRIMARY KEY (project_id, user_id, rfc_slug),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
DROP TABLE funder_consents;
ALTER TABLE funder_consents__new RENAME TO funder_consents;
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
-- ── rfc_collaborators: UNIQUE idx (rfc_slug, user_id) -> +project_id;
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
CREATE TABLE rfc_collaborators__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
DROP TABLE rfc_collaborators;
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
CREATE TABLE contribution_requests__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
matched_term TEXT NOT NULL,
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
DROP TABLE contribution_requests;
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
CREATE UNIQUE INDEX idx_contribution_requests_one_open
ON contribution_requests(project_id, rfc_slug, requester_user_id)
WHERE status = 'pending';
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
CREATE TABLE proposed_use_cases__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, scope, pr_number)
);
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
DROP TABLE proposed_use_cases;
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
+19
View File
@@ -0,0 +1,19 @@
"""Shared pytest fixtures for the backend suite.
Added in v0.27.0 (security audit 0026) alongside the new per-IP rate
limiter. The limiters in `app.ratelimit` are process-global singletons,
so their state survives across tests within a run; without a reset, the
accumulated requests from earlier tests exhaust the budget and later
tests see spurious 429s. This autouse fixture gives every test a clean
limiter window.
"""
import pytest
from app import ratelimit
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
ratelimit._reset_all_for_tests()
yield
ratelimit._reset_all_for_tests()
+132
View File
@@ -0,0 +1,132 @@
"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _add_project(pid, name, vis, typ="document"):
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, ?, ?, ?, 'super-draft')",
(pid, name, typ, pid, vis),
)
def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("pub", "Public", "public")
_add_project("gat", "Gated", "gated")
_add_project("unl", "Unlisted", "unlisted")
r = client.get("/api/deployment")
assert r.status_code == 200
body = r.json()
assert body["name"] == "Test Deployment"
ids = {p["id"] for p in body["projects"]}
assert "pub" in ids and "default" in ids # both public
assert "gat" not in ids # gated, anon not a member
assert "unl" not in ids # unlisted never enumerated
def test_projects_id_404_for_gated_non_member(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("gat", "Gated", "gated")
assert client.get("/api/projects/gat").status_code == 404
def test_projects_id_returns_config_for_public(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
from app import db
db.conn().execute(
"UPDATE projects SET config_json = ? WHERE id = 'default'",
('{"theme": {"accent": "#5b5bd6"}}',),
)
r = client.get("/api/projects/default")
assert r.status_code == 200
body = r.json()
assert body["id"] == "default"
assert body["type"] == "document"
assert body["visibility"] == "public"
assert body["theme"] == {"accent": "#5b5bd6"}
def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("unl", "Unlisted", "unlisted")
assert client.get("/api/projects/unl").status_code == 200
def test_projects_id_unknown_returns_404_even_for_superuser(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert client.get("/api/projects/does-not-exist").status_code == 404
def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
assert client.get("/api/projects/nope").status_code == 404
def test_deployment_includes_default_project_id(app_with_fake_gitea):
# §22.10 / M3-frontend guard contract: the frontend learns which project
# is the corpus-served (default) one from the deployment payload.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_id"] == "default"
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human"
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
# The old per-RFC PR deep link is preserved too.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human/pr/7", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human/pr/7"
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/proposals/42", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/proposals/42"
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("teamx", "Team X", "gated")
provision_user_row(user_id=5, login="mia", role="contributor")
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')"
)
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
# member sees the gated project in the deployment directory
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
assert "teamx" in ids
# member can read it directly
r = client.get("/api/projects/teamx")
assert r.status_code == 200
assert r.json()["id"] == "teamx"
+24
View File
@@ -0,0 +1,24 @@
"""§22.4c — _upsert_cached_rfc mirrors the review fields into cached_rfcs."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
def test_upsert_writes_review_fields(app_with_fake_gitea):
from app import cache, db, entry as entry_mod
app, _ = app_with_fake_gitea
with TestClient(app):
e = entry_mod.Entry(
slug="rev", title="Rev", state="active",
unreviewed=True, reviewed_at="2026-06-03", reviewed_by="ben",
)
cache._upsert_cached_rfc(e, body_sha="sha-rev")
row = db.conn().execute(
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug = 'rev'"
).fetchone()
assert row["unreviewed"] == 1
assert row["reviewed_at"] == "2026-06-03"
assert row["reviewed_by"] == "ben"
@@ -0,0 +1,236 @@
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
pending requests.
Two layers, mirroring test_rfc_links_vertical.py:
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
(Part 2) segments alongside Part 1's `rfc` links.
* The contribute-request flow end-to-end: a non-owner asks, each owner
gets an actionable §15 notification, accept fires #12's invite flow,
decline notifies the requester.
Reuses the FakeGitea + seed/session helpers from the existing suites.
"""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
from test_super_draft_vertical import seed_super_draft
from test_rfc_links_vertical import _open_pr_on
def _set_owner(slug: str, login: str) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
(json.dumps([login]), slug),
)
def _set_tags(slug: str, tags: list[str]) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
(json.dumps(tags), slug),
)
def _segs(segments, kind):
return [s for s in segments if s["type"] == kind]
# ---------------------------------------------------------------------------
# Part 2 + Part 3 — scanner surfaces on the PR view
# ---------------------------------------------------------------------------
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# Host active RFC (OHM — single word, contributes no keys itself)
# carrying a multi-word tag with no defining RFC: the Part 2
# candidate. And a pending super-draft owned by alice: the Part 3
# contribute target.
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_set_tags("ohm", ["memory model", "identity"])
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework for representing humans.", proposed_by="alice")
_set_owner("open-human-model", "alice")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="ohm",
description="This builds on the Open Human Model and the memory model.",
)
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
segs = pr["description_segments"]
pending = _segs(segs, "rfc-pending")
assert len(pending) == 1
assert pending[0]["slug"] == "open-human-model"
assert pending[0]["label"] == "Open Human Model"
assert pending[0]["owner"] == "Alice" # display_name of the owner
candidate = _segs(segs, "rfc-candidate")
assert len(candidate) == 1
assert candidate[0]["term"] == "memory model"
# "identity" is a single-word tag — deliberately NOT a candidate.
assert all("identity" not in s.get("term", "") for s in candidate)
# ---------------------------------------------------------------------------
# Part 3 — the contribute-request flow
# ---------------------------------------------------------------------------
def _seed_pending_owned_by_alice(fake):
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework.", proposed_by="alice")
_set_owner("open-human-model", "alice")
_REQUEST = {
"matched_term": "Open Human Model",
"who_i_am": "Bob, a researcher",
"why": "I have relevant prior work to bring.",
"use_case": "Building an identity tool.",
}
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Bob asks to contribute.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 200, r.text
request_id = r.json()["id"]
assert r.json()["status"] == "pending"
# A second ask while pending is a 409, not a duplicate row.
assert client.post("/api/rfcs/open-human-model/contribution-requests",
json=_REQUEST).status_code == 409
# Alice (owner) sees the actionable notification with full detail.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
inbox = client.get("/api/notifications").json()
reqs = [i for i in inbox["items"]
if i["event_kind"] == "contribution_request_on_pending_rfc"]
assert len(reqs) == 1
assert "wants to contribute" in reqs[0]["summary"]
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
assert reqs[0]["extras"]["request_id"] == request_id
# Alice accepts → #12 invitation minted for bob's email.
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
assert acc.status_code == 200, acc.text
assert acc.json()["status"] == "accepted"
assert acc.json()["invitation_id"]
inv = db.conn().execute(
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
"WHERE rfc_slug = 'open-human-model'"
).fetchone()
assert inv["invitee_email"] == "bob@test"
assert inv["role_in_rfc"] == "contributor"
assert inv["status"] == "pending"
# The request is settled — re-accepting is a 409.
assert client.post(
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
).status_code == 409
# Bob gets the accepted echo in his inbox.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_accepted" in bob_kinds
def test_decline_notifies_requester(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
request_id = client.post(
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
).json()["id"]
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
assert dec.status_code == 200, dec.text
assert dec.json()["status"] == "declined"
row = db.conn().execute(
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
).fetchone()
assert row["status"] == "declined"
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_declined" in bob_kinds
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 409
assert "own" in r.json()["detail"].lower()
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
# The contribute offer only exists for pending super-drafts; an active
# RFC uses the Part-1 link instead.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
assert r.status_code == 409
def test_contribution_target_eligibility(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Anonymous: not eligible, told to sign in.
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert t["eligible"] is False
assert "sign in" in (t["reason"] or "").lower()
assert t["owner"] == "Alice"
# Bob: eligible until he has a pending ask, then not.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert after["already_requested"] is True
assert after["eligible"] is False
+18 -13
View File
@@ -70,27 +70,32 @@ class _UpstreamHandler:
@pytest.fixture
def patched_httpx(monkeypatch):
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
"""Provide a hook the test can call to install a MockTransport.
Returns a closure: `install(handler)` patches
`app.docs_sessions.httpx.AsyncClient` so every constructed client
uses the handler's transport.
Returns a closure: `install(handler)` patches `httpx.AsyncClient`
(via `app.docs_sessions.httpx.AsyncClient`) so every constructed
client uses the handler's transport.
NB: the upstream `app_with_fake_gitea` fixture also patches
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
and because `httpx` is a single shared module, that patch mutates
the *same* `AsyncClient` attribute we're about to overwrite. We
therefore import the unpatched class directly from the
`httpx._client` module so our install path can construct a fresh
real client around our MockTransport without going through the
FakeGitea wrapper.
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
via the gitea transport. Since `httpx` is a module singleton, installing
the docs transport would clobber the FakeGitea mock already installed
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
other requests go to the test-specific handler.
"""
from httpx._client import AsyncClient as RealAsyncClient
_fake = app_with_fake_gitea[1]
def install(handler):
def composite(request: httpx.Request) -> httpx.Response:
if "gitea.test" in str(request.url):
return _fake.handle(request)
return handler(request)
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
kwargs["transport"] = httpx.MockTransport(composite)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
+15 -6
View File
@@ -79,19 +79,28 @@ class _UpstreamHandler:
@pytest.fixture
def patched_httpx(monkeypatch):
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
"""Provide a hook the test can call to install a MockTransport.
Same shape as the docs_sessions fixture `app_with_fake_gitea`
monkeypatches `httpx.AsyncClient` for the gitea side, so we
construct from the unpatched class directly to avoid the
FakeGitea wrapper.
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
via the gitea transport. Since `httpx` is a module singleton, installing
the docs transport would clobber the FakeGitea mock already installed
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
other requests go to the test-specific handler.
"""
from httpx._client import AsyncClient as RealAsyncClient
_fake = app_with_fake_gitea[1]
def install(handler):
def composite(request: httpx.Request) -> httpx.Response:
if "gitea.test" in str(request.url):
return _fake.handle(request)
return handler(request)
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
kwargs["transport"] = httpx.MockTransport(composite)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
+8 -7
View File
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
# --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
# in its meta entry, `repo` stays null.
assert d["repo"] is None
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
)
assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,),
+17 -11
View File
@@ -11,6 +11,8 @@ from __future__ import annotations
from email.utils import parsedate_to_datetime
import pytest
from app.email_envelope import build_envelope
@@ -160,14 +162,18 @@ def test_envelope_plain_only_body_is_text_plain():
assert msg.get_content().strip() == "Hello, world."
def test_envelope_with_html_is_multipart_alternative():
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
assert msg.get_content_type() == "multipart/alternative"
# Two parts: text/plain first (so plain-text clients picking the
# first part get the readable text), text/html second.
parts = list(msg.iter_parts())
assert len(parts) == 2
assert parts[0].get_content_type() == "text/plain"
assert parts[1].get_content_type() == "text/html"
assert "Hello, world." in parts[0].get_content()
assert "<b>world</b>" in parts[1].get_content()
def test_envelope_html_body_is_guarded_not_enabled():
# I3 (security-audit-0026): the HTML/multipart-alternative path is
# intentionally not enabled — passing body_html must fail loudly so
# a future caller can't silently ship unescaped user HTML (the C1
# stored-XSS class in the mail channel). When HTML mail is enabled
# deliberately, this test flips to assert the multipart shape.
with pytest.raises(NotImplementedError):
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
def test_envelope_html_none_is_plain_only():
# The guard keys on `is not None`, so the default (None) stays the
# live plain-text path — exercised here to lock the boundary.
msg = build_envelope(**_base_kwargs(body_html=None))
assert msg.get_content_type() == "text/plain"
+42
View File
@@ -0,0 +1,42 @@
"""§22.4c — the unreviewed/reviewed_at/reviewed_by entry frontmatter fields."""
from __future__ import annotations
from app import entry as entry_mod
def test_parse_defaults_unreviewed_false_when_absent():
text = "---\nslug: ohm\ntitle: OHM\nstate: active\n---\n\nBody.\n"
e = entry_mod.parse(text)
assert e.unreviewed is False
assert e.reviewed_at is None
assert e.reviewed_by is None
def test_parse_reads_review_fields():
text = (
"---\nslug: ohm\ntitle: OHM\nstate: active\n"
"unreviewed: true\nreviewed_at: '2026-06-03'\nreviewed_by: ben\n---\n\nBody.\n"
)
e = entry_mod.parse(text)
assert e.unreviewed is True
assert e.reviewed_at == "2026-06-03"
assert e.reviewed_by == "ben"
def test_serialize_emits_review_fields_only_when_meaningful():
e = entry_mod.Entry(slug="a", title="A", state="super-draft")
assert "unreviewed" not in entry_mod.serialize(e)
assert "reviewed_at" not in entry_mod.serialize(e)
e2 = entry_mod.Entry(slug="b", title="B", state="active", unreviewed=True)
assert "unreviewed: true" in entry_mod.serialize(e2)
def test_round_trip_preserves_review_fields():
e = entry_mod.Entry(
slug="b", title="B", state="active",
unreviewed=False, reviewed_at="2026-06-03", reviewed_by="ben",
)
back = entry_mod.parse(entry_mod.serialize(e))
assert back.reviewed_at == "2026-06-03"
assert back.reviewed_by == "ben"
assert back.unreviewed is False
+285 -198
View File
@@ -1,29 +1,29 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
"""End-to-end integration tests for the §13 graduation flow under the
meta-only topology (SPEC §1, ROADMAP #36).
Walks the §13.3 transactional sequence end-to-end against the in-process
FakeGitea from test_propose_vertical.py:
Graduation is an in-place state flip on the meta entry no per-RFC repo
is created, the body is kept, and there is no multi-step transaction or
rollback (§13.3). These tests walk it against the in-process FakeGitea
from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
round-trips already proven by Slice 1 and exercised in
test_claim_opens_meta_pr below for the §13.1 surface itself).
* Seed an owned super-draft (the §13.1 claim flow is exercised
separately in test_claim_opens_meta_pr).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog.
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
inline. On success: per-RFC repo exists with RFC.md / README.md /
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
graduated, cached_rfcs.state is 'active'.
* §9.8 precondition gate refuses the start when a body-edit PR is open.
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
graduation unchanged the rfc_slug is the canonical key per §2.3,
so no data movement is needed.
* §9.8 pre-graduation history: the new RFC's /main response surfaces
edit-branch threads under `pre_graduation_history`.
the two-field dialog (integer id + owners; no repo name).
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
inline. On success: NO per-RFC repo, the meta entry is `state:
active` with the body KEPT and `repo` null, cached_rfcs.state flips
to 'active'.
* An open body-edit PR no longer blocks graduation (§9.8) they
coexist.
* An open-PR failure leaves the entry a super-draft (nothing created);
a merge failure cleans up the half-open PR/branch and leaves the
entry a super-draft.
* §13.4: chat threads + edit branches stay put the slug is the
canonical key per §2.3, so nothing moves at the flip.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
test can assert post-conditions on the same event loop tick. Production
clients use the spec-described SSE shape via `/graduate/progress`.
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
assert post-conditions on the same event loop tick.
"""
from __future__ import annotations
@@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
# ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
"""Two-field dialog under meta-only: integer id + owners. No repo
name to validate (§13.2)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
@@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
# Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
assert r.status_code == 200, r.text
d = r.json()
assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
d = r.json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo."""
flips false even with a valid id."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
d = r.json()
assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge
PR, refresh cache. End state: cached_rfcs.state='active', the meta
entry's body is stripped, the per-RFC repo has RFC.md, the audit
log carries graduate_start graduate_complete bracketing the
per-step rows."""
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The meta-only flip: open + merge a frontmatter PR. End state:
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
null, NO per-RFC repo exists, and the audit log carries graduate_start
graduate_pr_open graduate_pr_merge graduate_complete."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
@@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
# No repo in the response, no per-RFC repo on Gitea.
assert "repo" not in d
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
assert not any(
k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}"
# 1. Per-RFC repo exists on Gitea.
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
# 2. Seed files landed on main.
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
assert "Open Human Model is a framework" in rfc_md
# 3. Meta entry body is stripped + frontmatter graduated.
# Meta entry on main: state flipped, body KEPT, repo null.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active"
assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
assert graduated.repo is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == ""
# 5. cached_rfcs.state flipped to active via the inline refresh.
assert "Open Human Model is a framework" in graduated.body
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
# cached body now mirrors RFC.md from the per-RFC repo.
assert cached["repo"] is None
assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [
r["action_kind"]
for r in db.conn().execute(
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
for needed in ("graduate_start", "graduate_repo_create",
"graduate_repo_seed", "graduate_pr_open",
for needed in ("graduate_start", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}"
# The retired per-repo steps must NOT appear.
for gone in ("graduate_repo_create", "graduate_repo_seed",
"graduate_repo_delete", "graduate_rollback"):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
graduation before the bot starts the sequence §13.3's rollback
complexity does not grow."""
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation the body is kept, so they coexist. /check stays
submittable and the flip succeeds."""
from fastapi.testclient import TestClient
from app import db
@@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
# Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
@@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"]
assert pr_number # PR is open
# /blocking-prs surfaces it.
# /check stays submittable despite the open body-edit PR.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["pr_number"] == pr_number
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
assert "blocking_prs" not in d
assert d["can_submit"] is True
# /check refuses can_submit.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
# The flip succeeds — coexists with the open body-edit PR.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
assert r.json()["succeeded"] is True
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
assert cached["state"] == "active"
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
1 (deleting the repo, which reclaims the seed commits at the same
time). The meta-repo entry is untouched."""
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""An open-PR failure creates nothing — the entry stays a super-draft
with its body intact and no graduation PR on the meta repo."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
)
finally:
Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
# Meta entry's body still has the pitch (not stripped).
# Entry untouched: still super-draft, body intact on main.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
"""A merge failure leaves the flip PR open on its dash-suffixed
branch; the orchestrator closes the PR and deletes the branch so
failed attempts don't accumulate. The entry stays a super-draft —
the flip PR's commit was on a branch, not on main."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Entry stays super-draft on main (the flip never merged).
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "state: super-draft" in meta_text
# The dash-suffixed graduation branch was cleaned up.
grad_branches = [
name for (o, repo), branches in fake.branches.items()
if (o, repo) == ("wiggleverse", "meta")
for name in branches
if name.startswith("graduate-ohm-")
]
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_pr_open" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused."""
@@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
)
st.finished = False
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
finally:
@@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
"""§13.4: chat threads on the super-draft's canonical-body view
(`branch_name='main'`) are interpreted as the new RFC's main-thread
after graduation. The rows don't move — the rfc_slug is canonical
per §2.3 so the same thread surfaces from both before and after
the graduation."""
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
stay put across the flip the rfc_slug is canonical per §2.3 so the
same thread surfaces from /branches/main before and after graduation."""
from fastapi.testclient import TestClient
from app import db
@@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone()
assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay
attached to their original branch_name and surface from the new
RFC's /main response under `pre_graduation_history`."""
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§13.4 (meta-only): after graduation an edit branch is a *current*
branch of the now-active RFC it surfaces in the normal `branches`
list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient
from app import db
@@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -513,30 +493,139 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
VALUES (?, 'user', 2, 'note on an edit branch')
""",
(thread_id,),
)
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
d = client.get("/api/rfcs/ohm/main").json()
assert d["state"] == "active"
hist = d["pre_graduation_history"]
assert len(hist) >= 1
assert any(h["branch_name"] == branch for h in hist)
target = next(h for h in hist if h["branch_name"] == branch)
assert target["message_count"] >= 1
# The edit branch is a current branch; no pre-graduation hop.
assert d["pre_graduation_history"] == []
assert any(b["name"] == branch for b in d["branches"]), \
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
"""§13.2 (optional number): a blank id is VALID — it means "graduate
without a number." `can_submit` stays true (owners are set), and an
absent `id` param behaves the same as an explicit empty string."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Explicit empty id.
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
assert d["id"]["ok"] is True
assert d["id"]["error"] is None
assert d["can_submit"] is True
# No id param at all — same default-accepted shape.
d = client.get("/api/rfcs/ohm/graduate/check").json()
assert d["id"]["ok"] is True
assert d["can_submit"] is True
# A malformed (non-blank) id is still rejected.
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
entry to `active` with `id: null`. The slug is the canonical identifier;
the catalog shows the entry as active with no number, and the audit row
records rfc_id null."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
pitch=PITCH, owners=["ben"], arbiters=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Blank rfc_id → graduate without a number.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["succeeded"] is True
assert d["rfc_id"] is None
# Meta entry: active, id null, body kept, graduation stamped.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active"
assert graduated.id is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at
assert "Open Human Model is a framework" in graduated.body
# Cache flipped to active with a null rfc_id.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] is None
# Catalog: present as active, identified by slug (no number).
items = client.get("/api/rfcs").json()["items"]
ohm = next(i for i in items if i["slug"] == "ohm")
assert ohm["state"] == "active"
assert ohm["id"] is None
# Audit: graduate_complete with rfc_id null.
complete = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
ORDER BY id DESC LIMIT 1
"""
).fetchone()
assert complete is not None
assert _json.loads(complete["details"])["rfc_id"] is None
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
"""Omitting the rfc_id field entirely is treated the same as blank —
graduates without a number so older clients that drop the field don't
break, and the supplied-number path stays exactly as before."""
from fastapi.testclient import TestClient
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
assert r.status_code == 200, r.text
assert r.json()["rfc_id"] is None
graduated = entry_mod.parse(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert graduated.state == "active"
assert graduated.id is None
def test_claim_opens_meta_pr(app_with_fake_gitea):
@@ -560,12 +649,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json()
assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text)
assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone()
+9 -10
View File
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
"""§19.2 candidate Slice 8 settles: when graduation rolls back
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
deleted alongside the PR close so failed-graduation branches
don't accumulate on the meta repo across retries."""
"""Meta-only (§13.3): when the flip's merge fails after the PR is
open, the orchestrator closes the PR and deletes its
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
on the meta repo across retries."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
# already landed and the rollback exercises the branch cleanup.
# Force a merge_pr failure so the flip PR (open_pr) has already
# landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure for rollback test")
raise GiteaError(502, "simulated merge failure for cleanup test")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# The dash-suffixed graduation branch was deleted on rollback.
# The dash-suffixed graduation branch was deleted on cleanup.
meta_branches = fake.branches[("wiggleverse", "meta")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], (
@@ -0,0 +1,43 @@
"""§22.4b — a project with initial_state='active' lands new entries active +
unreviewed; the default 'super-draft' project is unchanged."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _propose(client):
return client.post("/api/rfcs/propose", json={
"title": "Active Lander", "slug": "active-lander",
"pitch": "Lands active.", "tags": [],
})
def test_super_draft_default_unchanged(app_with_fake_gitea):
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert _propose(client).status_code == 200
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
e = entry_mod.parse(f["content"])
assert e.state == "super-draft"
assert e.unreviewed is False
def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute("UPDATE projects SET initial_state='active' WHERE id='default'")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert _propose(client).status_code == 200
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
e = entry_mod.parse(f["content"])
assert e.state == "active"
assert e.unreviewed is True
+65
View File
@@ -0,0 +1,65 @@
"""§22.4c — owner/admin mark-reviewed clears the flag; catalog unreviewed filter."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _seed_unreviewed_active(fake, slug="feat"):
"""Put an active+unreviewed entry on the meta repo main + cache."""
from app import cache, entry as entry_mod
body = entry_mod.serialize(entry_mod.Entry(
slug=slug, title="Feat", state="active", unreviewed=True,
owners=["ben"], proposed_by="ben",
))
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": body, "sha": "s1"}
cache._upsert_cached_rfc(entry_mod.parse(body), body_sha="s1")
def test_catalog_unreviewed_filter(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_unreviewed_active(fake, "feat")
from app import cache, entry as entry_mod
ok = entry_mod.serialize(entry_mod.Entry(slug="ok", title="OK", state="active", owners=["ben"]))
fake.files[("wiggleverse", "meta", "main", "rfcs/ok.md")] = {"content": ok, "sha": "s2"}
cache._upsert_cached_rfc(entry_mod.parse(ok), body_sha="s2")
r = client.get("/api/rfcs", params={"unreviewed": "true"})
slugs = {i["slug"] for i in r.json()["items"]}
assert slugs == {"feat"}
def test_mark_reviewed_clears_flag(app_with_fake_gitea):
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_unreviewed_active(fake, "feat")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
assert r.status_code == 200
row = db.conn().execute(
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug='feat'"
).fetchone()
assert row["unreviewed"] == 0
assert row["reviewed_by"] == "ben"
assert row["reviewed_at"] # provenance stamped
# git-side: the entry file on main was rewritten with the cleared flag.
from app import entry as entry_mod
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
e = entry_mod.parse(written)
assert e.unreviewed is False
assert e.reviewed_by == "ben"
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_unreviewed_active(fake, "feat")
provision_user_row(user_id=2, login="carol", role="contributor")
sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor")
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
assert r.status_code == 403
+54
View File
@@ -0,0 +1,54 @@
"""Migration 027 — additive §22 M3 schema (projects.type/initial_state, the
deployment singleton, cached_rfcs review columns). No table rebuilds in Plan A."""
from __future__ import annotations
import tempfile
from pathlib import Path
from app import db
from app.config import Config
def _fresh_config() -> Config:
tmp = Path(tempfile.mkdtemp(prefix="mig027-")) / "t.db"
return Config(
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
registry_repo="registry",
oauth_client_id="x", oauth_client_secret="x", app_url="x",
secret_key="x", database_path=tmp, owner_gitea_login="x",
webhook_secret="x",
)
def test_027_adds_project_type_and_initial_state():
cfg = _fresh_config()
db.run_migrations(cfg)
conn = db.connect(cfg.database_path)
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")}
assert "type" in cols and cols["type"]["dflt_value"] == "'document'"
assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'"
conn.close()
def test_027_creates_deployment_singleton():
cfg = _fresh_config()
db.run_migrations(cfg)
conn = db.connect(cfg.database_path)
rows = list(conn.execute("SELECT id FROM deployment"))
assert [r["id"] for r in rows] == [1]
try:
conn.execute("INSERT INTO deployment (id) VALUES (2)")
raised = False
except Exception:
raised = True
assert raised
conn.close()
def test_027_adds_review_columns_to_cached_rfcs():
cfg = _fresh_config()
db.run_migrations(cfg)
conn = db.connect(cfg.database_path)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(cached_rfcs)")}
assert {"unreviewed", "reviewed_at", "reviewed_by"} <= cols
conn.close()
@@ -0,0 +1,95 @@
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
project #2. Proves two projects can hold the same slug, that (project_id, slug)
is still unique within a project, that the rebuilt FK is composite + enforced,
and that the no-foreign-keys migration runner left no dangling references."""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app import db
class _Cfg:
def __init__(self, path):
self.database_path = path
def _fresh_db():
d = tempfile.mkdtemp()
path = Path(d) / "t.db"
db.run_migrations(_Cfg(str(path)))
return db.connect(str(path))
def _seed_two_projects(conn):
for pid in ("default", "ecomm"):
conn.execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
(pid, pid.title(), pid + "-content"),
)
def test_same_slug_coexists_across_projects():
conn = _fresh_db()
_seed_two_projects(conn)
for pid in ("default", "ecomm"):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', ?)",
(pid,),
)
rows = conn.execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
).fetchall()
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
def test_slug_still_unique_within_a_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'default')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Dup', 'active', 'default')"
)
def test_rfc_collaborators_composite_fk_enforced():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
)
# Matching (project_id, slug) — FK holds.
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'ecomm')"
)
# Same slug but a project with no such entry — composite FK must reject.
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'default')"
)
def test_stars_unique_now_scoped_by_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
@@ -0,0 +1,347 @@
"""Slice M2 — project-scoped authorization + the §22.7 resolver.
M1 laid the schema spine (the `projects` / `project_members` tables and the
`project_id` column on every slug-bearing table). M2 builds the resolver on top:
the most-permissive union of the deployment role (§6.1), the project role
(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate
subtractive on top (§22.7).
Two operator decisions are pinned here as executable expectations:
* implicit-on-public a granted deployment `contributor` keeps its
pre-multi-project write *baseline* on a `public` project (propose freely;
an owned RFC's discuss/contribute still needs the per-RFC invite) with no
project_members row. So the single public default project behaves exactly
as it did before M2 (verified across the rest of the suite, and the
`*_public_*` tests below).
* preserve curation the implicit-public baseline does NOT override per-RFC
owner curation; only an *explicit* project_contributor/admin grant (or a
deployment owner/admin) bypasses it.
Everything is exercised on the single `default` project by flipping its
visibility and granting/revoking `project_members` roles the M2 slice is
verifiable without a second project (which arrives with M3's registry mirror).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _su(user_id: int, login: str, role: str, *, state: str = "granted"):
"""A SessionUser handle for direct resolver calls (the endpoint tests use
sign_in_as instead)."""
from app import auth
return auth.SessionUser(
user_id=user_id,
gitea_id=user_id,
gitea_login=login,
display_name=login.capitalize(),
email=f"{login}@test",
avatar_url="",
role=role,
permission_state=state,
)
def _set_visibility(project_id: str, visibility: str) -> None:
from app import db
db.conn().execute(
"UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id)
)
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)",
(project_id, user_id, role),
)
def _remove_member(project_id: str, user_id: int) -> None:
from app import db
db.conn().execute(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user_id),
)
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
project_id). project_id defaults to 'default' via migration 026 but we set
it explicitly for clarity."""
import json
from app import db
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, project_id)
VALUES (?, ?, ?, ?, '[]', '[]', ?)
""",
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
)
# ---------------------------------------------------------------------------
# 1. The §22.7 resolver — tier composition
# ---------------------------------------------------------------------------
def test_resolver_public_project_tiers(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="ben", role="owner")
contributor = _su(1, "alice", "contributor")
owner = _su(2, "ben", "owner")
# default is public — read open to everyone incl. anonymous.
assert auth.can_read_project(None, "default") is True
assert auth.can_read_project(contributor, "default") is True
# implicit-on-public: a granted deployment contributor carries the
# write baseline without a project_members row.
assert auth.can_contribute_in_project(contributor, "default") is True
assert auth.can_discuss_in_project(contributor, "default") is True
# ... but it is NOT project_admin (curation/override authority).
assert auth.is_project_superuser(contributor, "default") is False
# a deployment owner/admin is a superuser in every project.
assert auth.is_project_superuser(owner, "default") is True
# a pending contributor has no write standing (the §6 admission floor).
pending = _su(1, "alice", "contributor", state="pending")
assert auth.can_contribute_in_project(pending, "default") is False
def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="ben", role="owner")
_set_visibility("default", "gated")
contributor = _su(1, "alice", "contributor")
owner = _su(2, "ben", "owner")
# gated: no membership → invisible and no standing.
assert auth.can_read_project(None, "default") is False
assert auth.can_read_project(contributor, "default") is False
assert auth.can_contribute_in_project(contributor, "default") is False
# deployment owner is a superuser regardless of membership.
assert auth.can_read_project(owner, "default") is True
assert auth.is_project_superuser(owner, "default") is True
# project_viewer → read + discuss, but not contribute.
_add_member("default", 1, "project_viewer")
assert auth.can_read_project(contributor, "default") is True
assert auth.can_discuss_in_project(contributor, "default") is True
assert auth.can_contribute_in_project(contributor, "default") is False
# project_contributor → contribute.
_add_member("default", 1, "project_contributor")
assert auth.can_contribute_in_project(contributor, "default") is True
assert auth.is_project_superuser(contributor, "default") is False
# project_admin → superuser within the project.
_add_member("default", 1, "project_admin")
assert auth.is_project_superuser(contributor, "default") is True
# ---------------------------------------------------------------------------
# 2. Public default unchanged — the regression floor (curation preserved)
# ---------------------------------------------------------------------------
def test_public_per_rfc_curation_preserved(app_with_fake_gitea):
"""On the public default project a granted contributor still cannot
discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract);
but an unclaimed (no-owners) entry stays open. This is the implicit-public
baseline with curation preserved."""
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
bob = _su(2, "bob", "contributor")
_seed_rfc("owned", state="active", owners=["alice"])
assert auth.can_discuss_rfc(bob, "owned") is False
assert auth.can_contribute_to_rfc(bob, "owned") is False
_seed_rfc("draft", state="super-draft", owners=[])
assert auth.can_discuss_rfc(bob, "draft") is True
assert auth.can_contribute_to_rfc(bob, "draft") is True
def test_public_read_open_write_gated_endpoints(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="bob", role="contributor")
_seed_rfc("ohm", state="active", owners=["alice"])
# anonymous can read the entry on a public project.
r = client.get("/api/rfcs/ohm")
assert r.status_code == 200, r.text
# anonymous cannot open a discussion thread (401, the §6 write floor).
r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"})
assert r.status_code == 401
# ---------------------------------------------------------------------------
# 3. The §22.5 visibility gate — 404 to non-members on read
# ---------------------------------------------------------------------------
def test_gated_project_404s_non_members(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
provision_user_row(user_id=3, login="ben", role="owner")
_seed_rfc("sekret", state="active", owners=["alice"])
_set_visibility("default", "gated")
# anonymous → 404 (indistinguishable from an unknown slug).
assert client.get("/api/rfcs/sekret").status_code == 404
# signed-in non-member → 404 on the entry and its discussion.
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
assert client.get("/api/rfcs/sekret").status_code == 404
assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404
# the gated entry never surfaces in the non-member's catalog.
assert client.get("/api/rfcs").json()["items"] == []
# a project_viewer member can read again.
_add_member("default", 2, "project_viewer")
assert client.get("/api/rfcs/sekret").status_code == 200
assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"]
# a deployment owner can always read.
sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner")
assert client.get("/api/rfcs/sekret").status_code == 200
# ---------------------------------------------------------------------------
# 4. The contribution gate on a gated project (401/403)
# ---------------------------------------------------------------------------
def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
"""Propose checks project-level contribute standing before any Gitea
work, so the gate is observable as a 403 (gated, non-member) without
seeding the success path."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="bob", role="contributor")
_set_visibility("default", "gated")
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []}
assert client.post("/api/rfcs/propose", json=body).status_code == 403
# grant project_contributor — the project gate now passes (the request
# proceeds past the gate; we assert only that it is no longer 403).
_add_member("default", 2, "project_contributor")
assert client.post("/api/rfcs/propose", json=body).status_code != 403
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="bob", role="contributor")
_seed_rfc("spec", state="super-draft", owners=[])
_set_visibility("default", "gated")
bob = _su(2, "bob", "contributor")
# non-member: discussion thread create → 404 (visibility, before the
# write gate even applies).
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
# project_viewer: can discuss (200) but cannot contribute (resolver).
_add_member("default", 2, "project_viewer")
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
assert r.status_code == 200, r.text
assert auth.can_contribute_to_rfc(bob, "spec") is False
# project_contributor: can contribute.
_add_member("default", 2, "project_contributor")
assert auth.can_contribute_to_rfc(bob, "spec") is True
# ---------------------------------------------------------------------------
# 5. Union of tiers + subtractive visibility gate
# ---------------------------------------------------------------------------
def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea):
from app import auth
from test_propose_vertical import grant_rfc_collaborator
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=2, login="bob", role="contributor")
_seed_rfc("owned", state="active", owners=["alice"])
bob = _su(2, "bob", "contributor")
# public + per-RFC collaborator(contributor) → contribute (union term).
grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor")
assert auth.can_contribute_to_rfc(bob, "owned") is True
# flip to gated: the §22.5 gate is subtractive — the per-RFC grant no
# longer suffices without project membership.
_set_visibility("default", "gated")
assert auth.can_contribute_to_rfc(bob, "owned") is False
# restore read via project membership → the per-RFC union applies again.
_add_member("default", 2, "project_viewer")
assert auth.can_contribute_to_rfc(bob, "owned") is True
# ---------------------------------------------------------------------------
# 6. Revocation takes effect on the next call
# ---------------------------------------------------------------------------
def test_revoking_membership_revokes_access(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=2, login="bob", role="contributor")
_set_visibility("default", "gated")
bob = _su(2, "bob", "contributor")
_add_member("default", 2, "project_contributor")
assert auth.can_contribute_in_project(bob, "default") is True
_remove_member("default", 2)
assert auth.can_read_project(bob, "default") is False
assert auth.can_contribute_in_project(bob, "default") is False
@@ -0,0 +1,139 @@
"""Slice M1+M3 — the §22 multi-project spine.
Migration 026 introduces the `projects` and `project_members` tables, seeds
the single `default` project (the N=1 case, §22.13), and threads a
`project_id` column onto every slug-bearing table, backfilled to `default`.
M3 retires the META_REPO startup backfill; content_repo now comes from the
registry mirror (projects.yaml in REGISTRY_REPO). These tests prove the
spine lands without disturbing the single-project app.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# The 19 tables migration 026 threads project_id onto (docs/design/
# multi-project-spec.md §5 amendment list).
SLUG_TABLES = [
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
"branch_chat_seen", "watches", "notifications", "actions",
"pr_resolution_branches", "funder_consents", "rfc_invitations",
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
]
def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
rows = list(db.conn().execute(
"SELECT id, name, visibility, content_repo FROM projects"
))
assert len(rows) == 1
row = rows[0]
assert row["id"] == "default"
# public preserves the pre-multi-project open-by-default posture.
assert row["visibility"] == "public"
# M3: content_repo now comes from the registry mirror (projects.yaml),
# not the retired META_REPO startup backfill.
assert row["content_repo"] == "meta"
def test_project_id_on_every_slug_table(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
for table in SLUG_TABLES:
cols = {r["name"]: r for r in db.conn().execute(
f"PRAGMA table_info({table})"
)}
assert "project_id" in cols, f"{table} missing project_id"
col = cols["project_id"]
# NOT NULL with the constant 'default' backfill default.
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
def test_existing_row_backfills_to_default(app_with_fake_gitea):
"""A row inserted the old way (no project_id) lands in the default
project the trick that keeps every pre-multi-project INSERT working."""
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)",
("human", "Human", "active"),
)
got = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
).fetchone()["project_id"]
assert got == "default"
def test_project_members_table_shape(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
cols = {r["name"] for r in db.conn().execute(
"PRAGMA table_info(project_members)"
)}
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
# The role CHECK rejects an unknown role.
db.conn().execute(
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
)
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'project_admin')"
)
import sqlite3
try:
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'nonsense')"
)
assert False, "CHECK should reject an unknown project role"
except sqlite3.IntegrityError:
pass
def test_registry_mirror_is_idempotent(app_with_fake_gitea):
"""Re-running the registry mirror is safe — it upserts (overwrites) the
projects row from projects.yaml each time without raising. M3 retirement
of seed_default_project: the registry mirror is now the sole authority."""
import asyncio
from app import db, registry as registry_mod
app, _ = app_with_fake_gitea
with TestClient(app):
before = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
cfg = app.state.config
gitea = app.state.gitea
asyncio.run(registry_mod.refresh_registry(cfg, gitea))
after = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
assert after == before
row = db.conn().execute(
"SELECT content_repo FROM projects WHERE id='default'"
).fetchone()
assert row["content_repo"] == "meta"
def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch):
from app.config import load_config
monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry")
assert load_config().registry_repo == "wiggleverse-registry"
# M3: REGISTRY_REPO is now required — absent raises RuntimeError.
monkeypatch.delenv("REGISTRY_REPO", raising=False)
import pytest
with pytest.raises(RuntimeError, match="REGISTRY_REPO"):
load_config()
@@ -0,0 +1,67 @@
"""§22.4 (Plan B) — per-project RFC serving. A second project's corpus renders
under its own slug namespace via /api/projects/{pid}/rfcs[/{slug}], isolated
from the default project and gated by §22.5 visibility."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _add_project(pid, name, vis="public"):
from app import db
db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
(pid, name, pid + "-content", vis),
)
def _add_rfc(slug, title, pid, state="active"):
from app import db
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)",
(slug, title, state, pid),
)
def test_catalog_scoped_to_one_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("ecomm", "Ecomm")
_add_rfc("intro", "Default Intro", "default")
_add_rfc("intro", "Ecomm Intro", "ecomm")
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
d = client.get("/api/projects/default/rfcs").json()["items"]
e = client.get("/api/projects/ecomm/rfcs").json()["items"]
d_slugs = {i["slug"] for i in d}
e_slugs = {i["slug"] for i in e}
assert "intro" in d_slugs and "only-ecomm" not in d_slugs
assert {"intro", "only-ecomm"} <= e_slugs
def test_entry_is_isolated_by_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("ecomm", "Ecomm")
_add_rfc("intro", "Default Intro", "default")
_add_rfc("intro", "Ecomm Intro", "ecomm")
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
assert client.get("/api/projects/default/rfcs/intro").json()["title"] == "Default Intro"
assert client.get("/api/projects/ecomm/rfcs/intro").json()["title"] == "Ecomm Intro"
# a slug that exists only in ecomm 404s under default
assert client.get("/api/projects/ecomm/rfcs/only-ecomm").status_code == 200
assert client.get("/api/projects/default/rfcs/only-ecomm").status_code == 404
def test_gated_project_catalog_404s_for_anon(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("secret", "Secret", vis="gated")
_add_rfc("hush", "Hush", "secret")
assert client.get("/api/projects/secret/rfcs").status_code == 404
assert client.get("/api/projects/secret/rfcs/hush").status_code == 404
+122 -1
View File
@@ -55,6 +55,24 @@ class FakeGitea:
self._pr_counter = 0
self._commit_counter = 0
self._seed_repo("wiggleverse", "meta")
# §22 M3: the deployment's project registry. Startup refresh_registry
# reads projects.yaml here; the single 'default' project's content_repo
# points back at the seeded meta repo so the corpus mirror is unchanged.
self._seed_repo("wiggleverse", "registry")
self.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
"content": (
"deployment:\n"
" name: Test Deployment\n"
" tagline: A test deployment\n"
"projects:\n"
" - id: default\n"
" name: Test Deployment\n"
" type: document\n"
" content_repo: meta\n"
" visibility: public\n"
),
"sha": "regsha0001",
}
def _seed_repo(self, owner, repo):
self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
@@ -431,7 +449,7 @@ def tmp_env(monkeypatch):
"GITEA_BOT_USER": "rfc-bot",
"GITEA_BOT_TOKEN": "bot-token",
"GITEA_ORG": "wiggleverse",
"META_REPO": "meta",
"REGISTRY_REPO": "registry",
"OAUTH_CLIENT_ID": "cid",
"OAUTH_CLIENT_SECRET": "csec",
"APP_URL": "http://localhost:8000",
@@ -444,6 +462,18 @@ def tmp_env(monkeypatch):
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
"ENABLED_MODELS": "claude",
# v0.27.0 (audit 0026 M4): the session cookie now defaults to
# Secure. The TestClient talks plain http://testserver, so a
# Secure cookie is never sent back and every authenticated flow
# would fail. Tests opt out explicitly, exactly as a dev box on
# plain http does.
"SESSION_COOKIE_SECURE": "false",
# v0.27.0 (audit 0026 M5): the bounce webhook fails closed (503)
# when its secret is unset. Tests exercise the legacy behavioral
# path via the documented dev opt-in, mirroring the
# RFC_APP_INSECURE_WEBHOOKS bypass above. Tests that assert the
# fail-closed default delenv this key themselves.
"RFC_APP_INSECURE_BOUNCE_WEBHOOK": "1",
}
for k, v in env.items():
monkeypatch.setenv(k, v)
@@ -565,6 +595,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
assert ("merge_proposal", "ben") in kinds
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
"""Regression: a merged idea PR whose branch was deleted must not
linger as a 'pending idea' ghost.
Found via the ROADMAP #35 operator authoring lane: merging an idea
PR from the CLI with `--delete-branch` makes Gitea report the PR's
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
branch name, so the sentinel parsed to slug=None, the row was skipped,
and `cached_prs.state` stayed frozen at 'open' leaving the entry
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
AND a pending idea (cached_prs never updated). The fix recovers the
original branch name from the already-stored cached_prs row.
The web UX never tripped this because it leaves the branch in place
(the repo's default_delete_branch_after_merge is false).
The bug only manifests on an out-of-band merge (the PR merged +
branch deleted directly in Gitea, with the in-app merge endpoint never
reconciling the row while the branch still existed) -- which is exactly
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
'merged' before the branch is gone, so it never trips this; the test
therefore drives the Gitea state directly to reproduce the CLI path.
"""
from fastapi.testclient import TestClient
from app import db, cache, gitea as gitea_mod
from app.config import load_config
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Informed Consent",
"slug": "informed-consent",
"pitch": "A first-class definition of consent in OHM.",
"tags": [],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The proposal is cached as an open idea PR.
items = client.get("/api/proposals").json()["items"]
assert any(i["pr_number"] == pr_number for i in items)
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
# its branch deleted directly in Gitea, WITHOUT the in-app merge
# endpoint ever running. So cached_prs still says state='open' and
# Gitea now reports the merged PR's head.ref as the sentinel. This
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
# leaves behind.
for pr in fake.pulls[("wiggleverse", "meta")]:
if pr["number"] == pr_number:
# land the file on main (the push side already reconciles
# cached_rfcs into a super-draft via the webhook/sweep)
for (o, rp, br, p), data in list(fake.files.items()):
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-29T12:13:00Z"
pr["closed_at"] = "2026-05-29T12:13:00Z"
pr["merge_commit_sha"] = fake._next_sha()
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
import asyncio
cfg = load_config()
gclient = gitea_mod.Gitea(cfg)
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
# The bug: this used to still list informed-consent (frozen 'open'
# row, slug unparseable from the sentinel). The fix recovers the
# stored branch name, so the row reconciles to merged and the ghost
# is gone.
assert client.get("/api/proposals").json()["items"] == []
# And the cached_prs row is correctly merged, not a frozen 'open'.
row = db.conn().execute(
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
assert row["state"] == "merged", f"expected merged, got {row['state']}"
# The super-draft itself is unaffected — still in the catalog.
items = client.get("/api/rfcs").json()["items"]
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
def test_slug_uniqueness_enforced(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
@@ -0,0 +1,161 @@
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
"What will you be using this for?" capture on the two propose surfaces.
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
the active-RFC seed from test_rfc_view_vertical.py. Proves:
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
and the value survives onto the merged super-draft's RFC view;
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
(c) propose-PR persists and returns `proposed_use_case` when supplied,
and accepts a NULL / omitted one.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_pr_flow_vertical import _cut_branch_and_accept_change
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# propose-RFC
# ---------------------------------------------------------------------------
def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "open-human-model",
"pitch": "A shared definition of what we mean by *human*.",
"tags": ["identity"],
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The pending-idea list carries the use case.
items = client.get("/api/proposals").json()["items"]
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
# The pending-idea detail view carries it too.
proposal = client.get(f"/api/proposals/{pr_number}").json()
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
# Merge as owner; the use case survives onto the RFC view (looked
# up by slug from the canonical side table, since the idea PR
# closes on merge).
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 200, r.text
view = client.get("/api/rfcs/open-human-model").json()
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
def test_propose_rfc_use_case_optional(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="carol", role="contributor")
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
# Omitted entirely.
r = client.post("/api/rfcs/propose", json={
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
})
assert r.status_code == 200, r.text
pr_a = r.json()["pr_number"]
# Explicit null.
r = client.post("/api/rfcs/propose", json={
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
"tags": [], "proposed_use_case": None,
})
assert r.status_code == 200, r.text
pr_b = r.json()["pr_number"]
# Blank/whitespace — treated as "left blank", no row written.
r = client.post("/api/rfcs/propose", json={
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
"tags": [], "proposed_use_case": " ",
})
assert r.status_code == 200, r.text
pr_c = r.json()["pr_number"]
for pr in (pr_a, pr_b, pr_c):
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
# ---------------------------------------------------------------------------
# propose-PR (against an active RFC)
# ---------------------------------------------------------------------------
def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
original="Open Human Model is a framework for representing humans.",
proposed="Open Human Model is a framework for representing humans across systems.",
)
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={
"title": "Tighten the opening",
"description": "Scope to systems.",
"proposed_use_case": "Building a cross-system consent registry.",
},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
def test_propose_pr_use_case_optional(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
original="It defines consent, trait, and agency in compatible terms.",
proposed="It defines consent, trait, harm, and agency in compatible terms.",
)
# No proposed_use_case key at all.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Name harm explicitly."},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert pr["proposed_use_case"] is None
+98
View File
@@ -0,0 +1,98 @@
"""§22.2 registry parse + apply: validation, type-immutability, upsert."""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from app import db, registry
from app.config import Config
def _db():
cfg = Config(
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
registry_repo="registry", oauth_client_id="x",
oauth_client_secret="x", app_url="x", secret_key="x",
database_path=Path(tempfile.mkdtemp(prefix="reg-")) / "t.db",
owner_gitea_login="x", webhook_secret="x",
)
db.run_migrations(cfg)
if db._CONN is not None:
db._CONN.close()
db._CONN = None
db.init(cfg)
return cfg
VALID = """
deployment:
name: Open Human Model
tagline: A model of human flourishing
projects:
- id: default
name: Open Human Model
type: document
content_repo: meta
visibility: public
"""
def test_parse_valid_registry():
doc = registry.parse_registry(VALID)
assert doc.deployment_name == "Open Human Model"
assert doc.deployment_tagline == "A model of human flourishing"
assert len(doc.projects) == 1
p = doc.projects[0]
assert (p.id, p.type, p.content_repo, p.visibility) == ("default", "document", "meta", "public")
assert p.initial_state == "super-draft"
def test_parse_initial_state_defaults_per_type():
doc = registry.parse_registry(
"projects:\n - {id: a, name: A, type: bdd, content_repo: a}\n"
)
assert doc.projects[0].initial_state == "active" # bdd default
@pytest.mark.parametrize("bad,msg", [
("projects: []\n", "at least one"),
("projects:\n - just-a-string\n", "must be a mapping"),
("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"),
("projects:\n - {id: a, name: A, type: nope, content_repo: a}\n", "invalid type"),
("projects:\n - {id: a, name: A, type: document}\n", "content_repo"),
("projects:\n - {id: a, name: A, type: document, content_repo: a, visibility: x}\n", "visibility"),
("projects:\n - {id: a, name: A, type: document, content_repo: a}\n - {id: a, name: B, type: document, content_repo: b}\n", "duplicate"),
])
def test_parse_rejects_invalid(bad, msg):
with pytest.raises(registry.RegistryError) as e:
registry.parse_registry(bad)
assert msg in str(e.value)
def test_apply_upserts_projects_and_deployment():
_db()
doc = registry.parse_registry(VALID)
registry.apply_registry(doc, registry_sha="regsha1")
prow = db.conn().execute(
"SELECT name, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'"
).fetchone()
assert prow["name"] == "Open Human Model"
assert prow["content_repo"] == "meta"
assert prow["registry_sha"] == "regsha1"
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
assert drow["name"] == "Open Human Model"
assert drow["tagline"] == "A model of human flourishing"
def test_apply_rejects_type_change_on_existing_project():
_db()
registry.apply_registry(registry.parse_registry(VALID), "s1")
changed = VALID.replace("type: document", "type: specification")
registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"]
assert t == "document" # immutable — unchanged
# The deployment row IS still advanced even though the project upsert was skipped.
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
assert drow["registry_sha"] == "s2"
+50
View File
@@ -0,0 +1,50 @@
"""Startup mirrors the registry; the registry webhook re-mirrors it."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
prow = db.conn().execute(
"SELECT content_repo, type, initial_state FROM projects WHERE id='default'"
).fetchone()
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
assert prow["type"] == "document"
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
assert drow["name"] # deployment name mirrored from the registry
def test_registry_webhook_remirrors(app_with_fake_gitea):
import hashlib
import hmac
import json as _json
app, fake = app_with_fake_gitea
with TestClient(app) as client:
from app import db
new_yaml = (
"deployment:\n name: OHM\n tagline: Edited tagline\n"
"projects:\n - id: default\n name: OHM\n type: document\n"
" content_repo: meta\n visibility: public\n"
)
fake.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
"content": new_yaml, "sha": "regsha2",
}
body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode()
secret = "test-webhook-secret-for-signature-verification"
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig,
"Content-Type": "application/json"},
)
assert r.status_code == 200
tagline = db.conn().execute("SELECT tagline FROM deployment WHERE id=1").fetchone()["tagline"]
assert tagline == "Edited tagline"
+257
View File
@@ -0,0 +1,257 @@
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
Retire is an in-place frontmatter flip on the meta entry (state
`retired`) committed via an auto-merged PR the same machinery as
graduation, reused. The distinguishing rules under test:
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
holders may retire; app admins may NOT. Un-retire is site-owners-only.
* Visibility (§13.7): a retired entry drops out of the catalog
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
a site owner (so the un-retire affordance has a surface).
* Reversibility: a site owner can un-retire, restoring the prior state
(and keeping the integer id intact); an RFC owner cannot.
These walk against the in-process FakeGitea from test_propose_vertical.py,
reusing the super-draft seed + sync graduation seam from the graduation
suite.
"""
from __future__ import annotations
import json as _json
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
def _catalog_slugs(client) -> set[str]:
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
"""An RFC owner (frontmatter, role contributor) retires their own
super-draft. The entry flips to `retired`, drops out of the catalog,
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="carol", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"], arbiters=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert "ohm" in _catalog_slugs(client)
r = client.post("/api/rfcs/ohm/retire")
assert r.status_code == 200, r.text
assert r.json()["state"] == "retired"
# Meta entry on main: state retired, body + fields kept.
meta = entry_mod.parse(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert meta.state == "retired"
assert "carol" in meta.owners
# Cache flipped; gone from the catalog.
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "retired"
assert "ohm" not in _catalog_slugs(client)
# The RFC owner is NOT a site owner → 404 on the entry read.
assert client.get("/api/rfcs/ohm").status_code == 404
# Audit row records the prior state for un-retire.
row = db.conn().execute(
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert _json.loads(row["details"])["prior_state"] == "super-draft"
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
(so the un-retire UI has a surface); an admin, a non-owner contributor,
and an anonymous viewer all get 404."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="carol", role="contributor")
provision_user_row(user_id=3, login="dave", role="admin")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# Site owner: 200.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm")
assert r.status_code == 200, r.text
assert r.json()["state"] == "retired"
# Admin (not site owner): 404.
sign_in_as(client, user_id=3, gitea_login="dave",
display_name="Dave", role="admin")
assert client.get("/api/rfcs/ohm").status_code == 404
# Non-owner contributor: 404.
provision_user_row(user_id=4, login="erin", role="contributor")
sign_in_as(client, user_id=4, gitea_login="erin",
display_name="Erin", role="contributor")
assert client.get("/api/rfcs/ohm").status_code == 404
def test_admin_cannot_retire(app_with_fake_gitea):
"""§3.1: retire authority excludes app admins. An admin who is not an
RFC owner gets 403 the one lifecycle action where admin authority
does not apply."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="dave", role="admin")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=3, gitea_login="dave",
display_name="Dave", role="admin")
r = client.post("/api/rfcs/ohm/retire")
assert r.status_code == 403, r.text
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
"""A signed-in contributor who is not an RFC owner gets 403."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=4, login="erin", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=4, gitea_login="erin",
display_name="Erin", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 403
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
"""Round-trip on an active RFC: graduate (with a number) → retire →
un-retire. The integer id survives, and un-retire restores `active`."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"], arbiters=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Graduate with a number.
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
# Retire (site owner).
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
assert "ohm" not in _catalog_slugs(client)
# Un-retire (site owner) restores active, id intact.
r = client.post("/api/rfcs/ohm/unretire")
assert r.status_code == 200, r.text
assert r.json()["state"] == "active"
meta = entry_mod.parse(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert meta.state == "active"
assert meta.id == "RFC-0042"
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert "ohm" in _catalog_slugs(client)
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
bring it back (the soft-delete is recoverable only by the operator)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="carol", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# Same RFC owner tries to un-retire → 403.
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
an admin (who lacks un-retire authority) gets 403."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="carol", role="contributor")
provision_user_row(user_id=3, login="dave", role="admin")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# Admin: 403.
sign_in_as(client, user_id=3, gitea_login="dave",
display_name="Dave", role="admin")
assert client.get("/api/admin/retired-rfcs").status_code == 403
# Site owner: 200, sees ohm with its restore target.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/admin/retired-rfcs")
assert r.status_code == 200, r.text
items = r.json()["items"]
ohm = next(i for i in items if i["slug"] == "ohm")
assert ohm["restores_to"] == "super-draft"
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
"""A retired entry refuses content reads of every shape (§13.7) — the
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="carol", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# The /main branch surface no longer serves it.
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)
+273
View File
@@ -0,0 +1,273 @@
"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments.
Two layers:
* Unit tests over the pure scanner (`rfc_links.segment_text` /
`_keys_for` / `LinkIndex`) the matching rules and their
false-positive guards, no DB.
* End-to-end tests that the PR description, PR review comments, and
PR-less discussion comments all surface `*_segments` enriched against
the live accepted-RFC corpus, with self-references suppressed.
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
the active-RFC seed from test_rfc_view_vertical.py.
"""
from __future__ import annotations
from app import rfc_links
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
from test_pr_flow_vertical import _cut_branch_and_accept_change
# ---------------------------------------------------------------------------
# Unit — the pure scanner
# ---------------------------------------------------------------------------
def _idx(*terms):
"""Build a LinkIndex from raw (key, slug, title) tuples (keys lower)."""
return rfc_links.LinkIndex(list(terms))
def test_empty_text_is_single_empty_segment():
assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}]
assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}]
def test_no_terms_returns_plain_text():
out = rfc_links.segment_text("hello world", [])
assert out == [{"type": "text", "text": "hello world"}]
def test_multiword_title_links_and_preserves_casing():
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
out = idx.segment("See the Open Human Model for details.")
assert out == [
{"type": "text", "text": "See the "},
{"type": "rfc", "slug": "open-human-model", "label": "Open Human Model",
"title": "Open Human Model"},
{"type": "text", "text": " for details."},
]
def test_match_is_case_insensitive():
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
out = idx.segment("see the OPEN HUMAN MODEL")
assert out[-1] == {"type": "rfc", "slug": "open-human-model",
"label": "OPEN HUMAN MODEL", "title": "Open Human Model"}
def test_word_boundary_prevents_substring_match():
# "harm" must not match inside "charming" / "harmless".
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
out = idx.segment("a charming rfc-00012 not real")
# rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char,
# so no match — the whole string stays plain text.
assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}]
def test_rfc_id_token_links():
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
out = idx.segment("as established in RFC-0001.")
assert out[1] == {"type": "rfc", "slug": "open-human-model",
"label": "RFC-0001", "title": "Open Human Model"}
def test_longest_match_wins():
# A bare "Open" term and the full title both present; the full title
# (longer) must win at the position.
idx = _idx(
("open", "open", "Open"),
("open human model", "open-human-model", "Open Human Model"),
)
out = idx.segment("the Open Human Model")
assert out[-1]["slug"] == "open-human-model"
assert out[-1]["label"] == "Open Human Model"
def test_pending_term_emits_contribute_segment():
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
# owner display name, not a plain link.
idx = rfc_links.LinkIndex([
rfc_links.Term(key="open human model", kind="pending",
slug="open-human-model", title="Open Human Model", owner="Alice"),
])
out = idx.segment("see Open Human Model please")
assert out[1] == {
"type": "rfc-pending", "slug": "open-human-model",
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
}
def test_candidate_term_emits_create_segment():
# Part 2: a candidate term carries its canonical spelling for the
# propose pre-fill; no slug (no RFC exists yet).
idx = rfc_links.LinkIndex([
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
])
out = idx.segment("the memory model is unspecified")
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
def test_kind_precedence_active_beats_pending_beats_candidate():
# All three buckets contribute the same key; the highest-precedence
# kind (active) must win at the position.
key = "open human model"
idx = rfc_links.LinkIndex([
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
])
out = idx.segment("the Open Human Model")
assert out[-1]["type"] == "rfc"
assert out[-1]["slug"] == "open-human-model"
def test_keys_for_gating():
keys = lambda **kw: set(rfc_links._keys_for(**kw))
# rfc_id always contributes.
assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001")
# multi-word title contributes; single common word does NOT.
assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None)
assert keys(slug="human", title="Human", rfc_id=None) == set()
# hyphenated slug contributes; single-token slug does NOT.
assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None)
assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None)
# ---------------------------------------------------------------------------
# End-to-end — enrichment surfaces on the read paths
# ---------------------------------------------------------------------------
def _open_pr_on(client, fake, *, host_slug: str, description: str):
"""Seed branch + accepted change on host_slug and open a PR. Returns
the pr_number."""
# `original` must exist verbatim in SEED_BODY or the accept is "stale".
branch, _ = _cut_branch_and_accept_change(
client, fake, slug=host_slug,
original="It defines consent, trait, and agency in compatible terms.",
proposed="It defines consent, trait, harm, and agency in compatible terms.",
)
r = client.post(
f"/api/rfcs/{host_slug}/branches/{branch}/open-pr",
json={"title": "A change", "description": description},
)
assert r.status_code == 200, r.text
return r.json()["pr_number"]
def _rfc_segments(segments):
return [s for s in segments if s["type"] == "rfc"]
def test_pr_description_autolinks_other_rfc(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# Two accepted RFCs: a host for the PR + a referenceable target.
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="ohm",
description="This builds on the Open Human Model definition.",
)
r = client.get(f"/api/rfcs/ohm/prs/{pr_number}")
assert r.status_code == 200, r.text
pr = r.json()
links = _rfc_segments(pr["description_segments"])
assert len(links) == 1
assert links[0]["slug"] == "open-human-model"
assert links[0]["label"] == "Open Human Model"
# The plain text is still present for non-segment callers (the bot
# appends a §6.5 On-behalf-of trailer, so this is a containment check).
assert "This builds on the Open Human Model definition." in pr["description"]
def test_pr_review_comment_autolinked(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(client, fake, host_slug="ohm", description="plain.")
r = client.post(
f"/api/rfcs/ohm/prs/{pr_number}/review",
json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}},
)
assert r.status_code == 200, r.text
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs]
review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")]
assert review_msgs, "review comment not found in payload"
links = _rfc_segments(review_msgs[0]["text_segments"])
# Both "Open Human Model" (title) and "RFC-0001" (id) point to the
# one referenceable RFC.
assert {s["slug"] for s in links} == {"open-human-model"}
assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"}
def test_discussion_comment_autolinked(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
# alice is the seeded owner of ohm (owners=["alice"]); grant the
# per-RFC collaborator row explicitly so the #12 discuss gate passes.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "Compare with the Open Human Model."},
)
assert r.status_code == 200, r.text
thread_id = r.json()["thread_id"]
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
assert r.status_code == 200, r.text
msgs = r.json()["messages"]
assert msgs and "text_segments" in msgs[0]
links = _rfc_segments(msgs[0]["text_segments"])
assert len(links) == 1
assert links[0]["slug"] == "open-human-model"
def test_self_reference_not_linked(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# The host RFC has a multi-word title, so absent exclude_slug it
# WOULD self-link. exclude_slug must suppress it.
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="open-human-model",
description="Refines the Open Human Model definition.",
)
pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json()
assert _rfc_segments(pr["description_segments"]) == []
@@ -0,0 +1,140 @@
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
vertical (§6.2, roadmap item #29).
New behavior: each authenticated user's last-viewed route + a small bag
of light view state is tracked server-side, so the next sign-in can land
them back where they left off rather than on the empty-state home view.
The tests below prove:
* `PUT /api/me/last-state` requires auth an anonymous client gets
401, and nothing is stored.
* An authenticated PUT upserts the route, and the stored route is
read back for that user off `GET /api/auth/me` (`last_route`).
* A second PUT overwrites (upsert, one row per user) the latest
route wins.
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
* Per-user isolation: user A's stored route is not visible to user B.
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
rewrite the stored route) and `/api/auth/me` hands back a null
`last_route` even though a stored row exists.
The fakes from `test_propose_vertical` give us a working app harness +
the `sign_in_as` / `provision_user_row` seams.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_put_last_state_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Anonymous — no session cookie set.
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
assert r.status_code == 401
# Nothing landed in the table.
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
assert row["n"] == 0
def test_put_last_state_upserts_and_reads_back(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
# First POST stores a route + light state.
r = client.put(
"/api/me/last-state",
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
)
assert r.status_code == 200
assert r.json()["stored"] is True
# /api/auth/me hands the route + decoded state back.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["resume_enabled"] is True
assert me["user"]["last_route"] == "/rfc/open-human-model"
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
# A later POST overwrites — one row per user, latest wins.
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
assert r.status_code == 200
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/proposals/7"
# state was omitted on the second POST → cleared to null.
assert me["user"]["last_route_state"] is None
def test_last_state_is_per_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
# Switch to bob — he has no stored route yet.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] is None
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/rfc/bob-route"
# Back to alice — her route is untouched by bob's write.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/rfc/alice-route"
def test_resume_disabled_no_ops_put_and_hides_route(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
# Seed a stored row, then flip resume_enabled off directly (the
# profile-settings toggle UI to do this from the client is a
# follow-up; the column + behavior ship now).
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
db.conn().execute(
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
(2,),
)
# /api/auth/me reports resume off and hands back a null route
# even though a stored row exists.
me = client.get("/api/auth/me").json()
assert me["user"]["resume_enabled"] is False
assert me["user"]["last_route"] is None
# A PUT while disabled no-ops: stored=False and the stored route
# is NOT rewritten.
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
assert r.status_code == 200
assert r.json()["stored"] is False
row = db.conn().execute(
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
).fetchone()
assert row["last_route"] == "/rfc/before-disable"
+264
View File
@@ -0,0 +1,264 @@
"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
Haiku tag suggestions on the propose-RFC modal.
Reuses the FakeGitea + session helpers from test_propose_vertical.py.
The Anthropic call is never made for real tests monkeypatch the
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
returns canned text, so the HTTP contract is exercised without a key.
Proves:
(a) the endpoint is contributor-gated (anon 401);
(b) a contributor gets suggestions, filtered to the corpus tag
universe, with invented tags dropped;
(c) no Anthropic key bound empty list, not an error;
(d) an empty corpus empty list (model is never even called);
(e) the per-user rate limit surfaces as a 429;
plus unit coverage of the universe gather, the reply parser's tolerance,
and the suggest() orchestration short-circuits.
"""
from __future__ import annotations
import json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class StubProvider:
"""A BaseProvider stand-in whose send() returns a fixed string (or
raises, to exercise the graceful-failure path)."""
def __init__(self, reply: str = "[]", *, raises: bool = False):
self.reply = reply
self.raises = raises
self.calls: list[tuple[str, list]] = []
def send(self, system, history):
self.calls.append((system, history))
if self.raises:
raise RuntimeError("boom")
return self.reply
def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
(slug, title, state, json.dumps(tags)),
)
def _use_provider(monkeypatch, provider) -> None:
monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)
@pytest.fixture(autouse=True)
def _reset_rate_limits():
from app import tag_suggest
tag_suggest.reset_rate_limits()
yield
tag_suggest.reset_rate_limits()
# ---------------------------------------------------------------------------
# Endpoint (vertical)
# ---------------------------------------------------------------------------
def test_anonymous_cannot_suggest(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
assert r.status_code == 401
def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
_seed_tags("other", "Other", ["identity", "governance"])
# Model returns two real tags (one lowercased to test canonical
# mapping is exact-set anyway), plus one invented tag that MUST
# be dropped.
reply = json.dumps([
{"tag": "identity", "confidence": 0.9},
{"tag": "consent", "confidence": 0.7},
{"tag": "totally-invented", "confidence": 0.99},
])
stub = StubProvider(reply=reply)
_use_provider(monkeypatch, stub)
r = client.post("/api/rfcs/suggest-tags", json={
"title": "Consent and identity",
"pitch": "We need a shared definition of consent tied to identity.",
})
assert r.status_code == 200, r.text
tags = [s["tag"] for s in r.json()["suggestions"]]
assert tags == ["identity", "consent"]
# the model was actually invoked
assert len(stub.calls) == 1
# the universe (deduped distinct tags) was handed to the model
user_msg = stub.calls[0][1][0]["content"]
assert "identity" in user_msg and "governance" in user_msg
def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity"])
# No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
# (Explicitly assert the seam returns None given the test config.)
from app import tag_suggest
assert tag_suggest.haiku_provider(app.state.config) is None
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
assert r.status_code == 200, r.text
assert r.json()["suggestions"] == []
def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
_use_provider(monkeypatch, stub)
# No cached_rfcs rows → empty universe → suggest() short-circuits.
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
assert r.status_code == 200, r.text
assert r.json()["suggestions"] == []
assert stub.calls == [] # model never invoked on an empty universe
def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity"])
_use_provider(monkeypatch, StubProvider(reply="[]"))
body = {"title": "X", "pitch": "y"}
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
# Third call inside the window trips the limit.
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429
# ---------------------------------------------------------------------------
# Units
# ---------------------------------------------------------------------------
def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import tag_suggest
app, _fake = app_with_fake_gitea
# The db is initialized in the app's lifespan; enter the client
# context so cached_rfcs exists before we seed it directly.
with TestClient(app):
_seed_tags("a", "A", ["identity", "schema"])
_seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty
_seed_tags("c", "C", ["identity"])
universe = tag_suggest.gather_tag_universe()
# identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
# empties dropped.
assert universe == ["identity", "schema", "consent"]
def test_parse_reply_tolerates_junk():
from app import tag_suggest
universe = ["identity", "schema", "consent"]
# Prose around the JSON, an invented tag, a bare string, a dup, a
# missing confidence, and a garbage confidence.
text = (
"Sure! Here are the tags:\n"
'[{"tag": "identity", "confidence": 0.9}, '
'{"tag": "invented", "confidence": 1}, '
'"schema", '
'{"tag": "identity", "confidence": 0.5}, '
'{"tag": "consent"}, '
'{"tag": "consent", "confidence": "high"}]\n'
"Hope that helps!"
)
out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
tags = [s["tag"] for s in out]
assert tags == ["identity", "schema", "consent"] # invented dropped, deduped
by_tag = {s["tag"]: s["confidence"] for s in out}
assert by_tag["identity"] == 0.9
assert by_tag["schema"] == 0.5 # bare string defaults to 0.5
assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5
def test_parse_reply_empty_on_unparseable():
from app import tag_suggest
assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
assert tag_suggest.parse_reply("", ["a"], 6) == []
assert tag_suggest.parse_reply("[]", ["a"], 6) == []
def test_parse_reply_respects_max():
from app import tag_suggest
universe = ["a", "b", "c", "d", "e"]
text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
assert [s["tag"] for s in out] == ["a", "b", "c"]
def test_suggest_short_circuits_empty_draft():
from app import tag_suggest
stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
draft = tag_suggest.Draft(title=" ", pitch="", use_case="")
assert tag_suggest.suggest(stub, draft, ["a"]) == []
assert stub.calls == [] # never called for an empty draft
def test_suggest_returns_empty_on_provider_failure():
from app import tag_suggest
stub = StubProvider(raises=True)
draft = tag_suggest.Draft(title="Real title", pitch="a reason")
assert tag_suggest.suggest(stub, draft, ["identity"]) == []
+38 -7
View File
@@ -55,13 +55,19 @@ def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `httpx.post` inside `app.turnstile` with a stub that
"""Replace `turnstile._siteverify_post` with an async stub that
returns the requested success shape. The stub does not touch the
real CloudFlare endpoint and never sees a real secret.
I4 (security-audit-0026): the siteverify call is now awaited on an
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
Patching that narrow function (rather than the shared
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
intact.
"""
captured = {}
def fake_post(url, *, data=None, timeout=None, **kwargs):
async def fake_post(url, data):
captured["url"] = url
captured["data"] = data
body = {"success": bool(success)}
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
return captured
@@ -170,14 +176,15 @@ def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_git
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The httpx.post inside turnstile must not be called in this path —
# patch it to a sentinel that explodes if it ever runs.
# The siteverify call inside turnstile must not be made in this path —
# patch the seam to a sentinel that explodes if it ever runs
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
from app import turnstile as turnstile_mod
def must_not_be_called(*a, **kw):
async def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
@@ -218,3 +225,27 @@ def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea,
)
assert r.status_code == 500, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
# the synchronous event-loop-blocking shape fails here, not just in the
# integration paths.
# ---------------------------------------------------------------------------
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
import asyncio
from app import turnstile as turnstile_mod
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
coro = turnstile_mod.verify_token("any-token")
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
outcome = asyncio.run(coro)
# No secret + not required → the gate stays open without any network call.
assert outcome.ok is True
assert outcome.reason == "skipped"
+2
View File
@@ -73,6 +73,7 @@ def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("REGISTRY_REPO", "registry")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
@@ -92,6 +93,7 @@ def test_config_loads_with_secret_set(monkeypatch, tmp_path):
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("REGISTRY_REPO", "registry")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
+79 -30
View File
@@ -20,15 +20,24 @@ The v1 build is complete (8 slices shipped, 125 passing integration tests). New
The RFC app runs on its own dedicated GCP VM in a separate project from the Gitea VM. The two coexist under `wiggleverse.org` but are otherwise unrelated infrastructure.
> **⚠️ Infrastructure was realigned (GCP name-alignment, ~2026-05).** The
> GCP project, VM, install path, system user, and systemd unit were all
> renamed, the static IP changed, and SSH is now **IAP-only** (direct
> port-22 connections time out). The tables below are the current truth;
> if you find an older clone of this doc naming `wiggleverse-rfc` /
> `rfc-app` / `/opt/rfc-app` / `34.132.29.41`, it predates the alignment.
| Property | Value |
|----------|-------|
| GCP project | `wiggleverse-rfc` |
| VM name | `rfc-app` |
| GCP project | `wiggleverse-ohm` |
| VM name | `ohm-rfc-app` |
| VM type | e2-small |
| Zone | us-central1-a |
| OS | Debian 12 (bookworm) |
| Static IP | 34.132.29.41 |
| Linux user (OS Login) | `benstull` |
| External IP | `136.116.40.66` |
| SSH | **IAP-only**`gcloud compute ssh … --tunnel-through-iap` (port 22 is firewalled off the public internet) |
| OS Login SSH user | `ben_wiggleverse_org` (auto-derived; you don't type it) |
| App system user | `ohm-rfc-app` |
For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.55.46.221.
@@ -36,7 +45,7 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
| Record | Type | Value | Proxy |
|--------|------|-------|-------|
| `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
| `ohm.wiggleverse.org` | A | `136.116.40.66` | DNS-only (gray cloud) |
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
> Note: `ohm.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
@@ -48,24 +57,25 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
| Component | Details |
|-----------|---------|
| Backend | Python 3.11, FastAPI, uvicorn (single process) |
| Database | SQLite in WAL mode at `/opt/rfc-app/backend/data/rfc-app.db` |
| Database | SQLite in WAL mode at `/opt/ohm-rfc-app/backend/data/rfc-app.db` |
| Frontend | React 19, Vite 8, Tiptap 3, React Router 7 |
| Web server | nginx — serves `frontend/dist/` as static SPA, proxies `/api/` and `/auth/` to uvicorn on `127.0.0.1:8000` |
| Process manager | systemd unit `rfc-app.service`, runs as `rfc-app` system user |
| Process manager | systemd unit `ohm-rfc-app.service`, runs as `ohm-rfc-app` system user |
| TLS | Let's Encrypt via certbot |
| Git backend | Gitea at `git.wiggleverse.org`, bot service account `rfc-bot` |
| Content Gitea (the bot's writes) | Gitea at `git.wiggleverse.org`, org `wiggleverse`, meta repo `ohm-content` (`wiggleverse/ohm-content`), bot service account `rfc-bot` |
| Code-deploy source (the VM's git origin) | **`https://git.benstull.org/benstull/rfc-app.git`** — a *different* Gitea from the content one. See the two-remote note under "Deploying a New Version." |
| Email | Google Workspace SMTP relay (`smtp-relay.gmail.com:587`), AUTH'd as `ben@wiggleverse.org`, From `notifications@wiggleverse.org` |
### Key Paths on the VM
| Path | Contents |
|------|---------|
| `/opt/rfc-app/` | App root (owned by `rfc-app` user) |
| `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) |
| `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database |
| `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
| `/opt/ohm-rfc-app/` | App root (owned by `ohm-rfc-app` user) |
| `/opt/ohm-rfc-app/backend/.env` | All secrets and config (mode 0600) |
| `/opt/ohm-rfc-app/backend/data/rfc-app.db` | SQLite database |
| `/opt/ohm-rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
| `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
| `/etc/systemd/system/rfc-app.service` | systemd unit |
| `/etc/systemd/system/ohm-rfc-app.service` | systemd unit |
---
@@ -91,7 +101,7 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, `
### Meta repo
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
`wiggleverse/ohm-content` (the `META_REPO` value is `ohm-content`) — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
### OAuth2 app
@@ -167,23 +177,52 @@ HYGIENE_TICK_SECONDS=3600
## Deploying a New Version
SSH into the VM:
> **⚠️ Two-remote step — do this FIRST.** The framework's release flow
> (branches, PRs, version tags) happens on **`git.wiggleverse.org`**
> (`ben.stull/rfc-app`). But the VM's git origin is a *separate* Gitea,
> **`git.benstull.org/benstull/rfc-app`**, which does **not** auto-mirror
> from the release Gitea. So after a release is merged + tagged on
> `git.wiggleverse.org`, you must push `main` and the new tag to the
> `benstull` remote before the VM can fetch them:
> ```bash
> # from your local rfc-app clone, on main at the merged release tip:
> git push benstull main vX.Y.Z # benstull = git@git.benstull.org:benstull/rfc-app.git
> ```
> If `git ls-remote benstull vX.Y.Z` comes back empty, the VM cannot see
> the release yet — push it first. (Wiring the two Gitea instances to
> mirror would remove this step; until then it's manual.)
SSH into the VM (IAP-only — the `--tunnel-through-iap` flag is required):
```bash
gcloud compute ssh rfc-app --zone=us-central1-a --project=wiggleverse-rfc
gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap
```
Pull the latest code, reinstall deps, restart:
Fetch + check out the release tag (the VM tracks a **detached** tag, not
a branch), then restart. Backend deps only need reinstalling when
`requirements.txt` changed:
```bash
sudo -u rfc-app git -C /opt/rfc-app pull
sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
-r /opt/rfc-app/backend/requirements.txt
sudo systemctl restart rfc-app
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app fetch origin --tags
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app checkout vX.Y.Z
# only if backend deps changed:
sudo -u ohm-rfc-app /opt/ohm-rfc-app/backend/.venv/bin/pip install \
-r /opt/ohm-rfc-app/backend/requirements.txt
sudo systemctl restart ohm-rfc-app
```
For frontend changes, build on the VM directly (Node 20+ is already there):
```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build
cd /opt/ohm-rfc-app/frontend && sudo -u ohm-rfc-app npm ci
sudo -u ohm-rfc-app npm run build
```
Smoke test (asset hashes change on every rebuild, so a match proves the
new bundle is live):
```bash
# public:
curl -s -o /dev/null -w '%{http_code} %{remote_ip}\n' https://ohm.wiggleverse.org/
curl -s https://ohm.wiggleverse.org/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.(js|css)'
# backend startup line, on the VM:
sudo journalctl -u ohm-rfc-app -n 20 --no-pager | grep 'RFC app started'
```
`npm ci` installs strictly from the committed `package-lock.json` and
@@ -192,7 +231,7 @@ to rewrite the lockfile in place (notably stripping `libc` fields on
optional rollup native packages), which then conflicts with `git
checkout <tag>` on the next deploy.
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
The output lands in `/opt/ohm-rfc-app/frontend/dist/` owned by `ohm-rfc-app` — nginx serves it directly, no copy step needed.
(Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.)
@@ -202,6 +241,15 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
## First-Time Deployment (new server)
> **Note on names:** the command blocks in this section predate the GCP
> name-alignment and still spell the pre-alignment project/VM/path/user
> (`wiggleverse-rfc` / `rfc-app` / `/opt/rfc-app` / `rfc-app` /
> `34.132.29.41`). They're kept as the structural reference. When standing
> up a box today, substitute the current values from the Host/Paths tables
> at the top: project `wiggleverse-ohm`, VM `ohm-rfc-app`, install path
> `/opt/ohm-rfc-app`, system user `ohm-rfc-app`, service `ohm-rfc-app`, IP
> `136.116.40.66`, and SSH via `--tunnel-through-iap`.
### 1. Add DNS record
Add `ohm.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
@@ -307,7 +355,7 @@ Paste the following into a new Claude session to continue development:
---
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`ohm-rfc-app` in the `wiggleverse-ohm` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
>
> **Stack:**
> - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode
@@ -323,11 +371,12 @@ Paste the following into a new Claude session to continue development:
> - 10 append-only schema migrations in `backend/migrations/`
>
> **Deployment:**
> - SSH: `gcloud compute ssh rfc-app --zone=us-central1-a --project=wiggleverse-rfc`
> - Code at `/opt/rfc-app/` on the VM, owned by `rfc-app` system user
> - `.env` at `/opt/rfc-app/backend/.env` (mode 0600)
> - Frontend built **on the VM** (Node 20 is installed there) with `npm run build` directly into `/opt/rfc-app/frontend/dist/`
> - Restart to deploy: `sudo systemctl restart rfc-app`
> - SSH (IAP-only): `gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap`
> - Code at `/opt/ohm-rfc-app/` on the VM, owned by `ohm-rfc-app` system user; tracks a detached release **tag**
> - The VM's git origin is `git.benstull.org/benstull/rfc-app` — a *different* Gitea from the release one (`git.wiggleverse.org`); push `main` + the new tag to the `benstull` remote before deploying
> - `.env` at `/opt/ohm-rfc-app/backend/.env` (mode 0600)
> - Frontend built **on the VM** (Node 20 is installed there) with `npm run build` directly into `/opt/ohm-rfc-app/frontend/dist/`
> - Restart to deploy: `sudo systemctl restart ohm-rfc-app`
> - Migrations run automatically on startup
>
> **Source is at `~/git/rfc-app/`.**
+45 -10
View File
@@ -1,9 +1,26 @@
# Runbook
Single-host deployment of the RFC app at `ohm.wiggleverse.org`, sharing
infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx,
same Let's Encrypt). The shape matches §4.2: one process, one SQLite file,
no separate worker.
Single-host deployment of the RFC app at `ohm.wiggleverse.org`. The shape
matches §4.2: one process, one SQLite file, no separate worker.
> **⚠️ Current deployment names (post GCP name-alignment, ~2026-05).**
> The command blocks below were written with the original names and use
> `/opt/rfc-app`, system user `rfc-app`, and service `rfc-app`. The live
> OHM box uses the realigned names — substitute throughout:
>
> | Was | Now |
> | --- | --- |
> | GCP project `wiggleverse-rfc` | `wiggleverse-ohm` |
> | VM `rfc-app` | `ohm-rfc-app` |
> | install path `/opt/rfc-app` | `/opt/ohm-rfc-app` |
> | system user `rfc-app` | `ohm-rfc-app` |
> | service `rfc-app.service` | `ohm-rfc-app.service` |
> | SSH | IAP-only: `gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap` |
> | meta repo `wiggleverse/meta` | `wiggleverse/ohm-content` |
>
> The VM's git origin is **`git.benstull.org/benstull/rfc-app`** — a
> *different* Gitea from the release one (`git.wiggleverse.org`). See §2.5.
> The full current infra table lives in `DEPLOY-NEW-SESSION-PROMPT.md`.
Bring-up order: host prep → Gitea side (bot, OAuth, meta repo) → app side
(code, venv, build, .env) → web server side (nginx, certbot) → systemd →
@@ -345,16 +362,34 @@ pinned = 1 WHERE rfc_slug = ? AND branch_name = ?`).
### 2.5 Updating after a push
The live OHM box uses the realigned names (see the callout at the top)
and deploys by checking out a **release tag** (detached HEAD), not by
pulling a branch. Its git origin is `git.benstull.org/benstull/rfc-app`,
which does **not** auto-mirror from the release Gitea
(`git.wiggleverse.org`) — so first push `main` + the new tag there:
```sh
sudo -u rfc-app git -C /opt/rfc-app pull
sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
-r /opt/rfc-app/backend/requirements.txt
# Rebuild the frontend locally and rsync dist/ as in 1.3.2.
sudo systemctl restart rfc-app
# from your local rfc-app clone, on main at the merged release tip:
git push benstull main vX.Y.Z # benstull = git@git.benstull.org:benstull/rfc-app.git
```
Then on the VM (SSH is IAP-only):
```sh
gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app fetch origin --tags
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app checkout vX.Y.Z
# only when backend deps changed:
sudo -u ohm-rfc-app /opt/ohm-rfc-app/backend/.venv/bin/pip install \
-r /opt/ohm-rfc-app/backend/requirements.txt
# frontend changes: build on the VM (Node 20+ is there) — output is served directly by nginx:
cd /opt/ohm-rfc-app/frontend && sudo -u ohm-rfc-app npm ci && sudo -u ohm-rfc-app npm run build
sudo systemctl restart ohm-rfc-app
```
The §5 schema migrations run on startup and are append-only. A restart
is the entire deploy.
is the entire backend deploy; a frontend-only change is live as soon as
the new `dist/` is built (nginx serves it directly).
---
+105 -4
View File
@@ -8,15 +8,94 @@
# /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# Then add the Let's Encrypt cert:
# sudo certbot --nginx -d ohm.wiggleverse.org
# Certbot will rewrite this file to add the 443 listener and certificate
# directives; the rest of the config below stays as written.
# TLS: this vhost terminates with the *.wiggleverse.org wildcard cert installed
# on the VM (see the ssl_* directives in the 443 block below). This REPLACES the
# old per-host certbot cert — the live file certbot previously rewrote on the VM
# is superseded once you install this one. Put the cert files in place first:
# sudo install -m 600 -o root -g root privkey.pem /etc/ssl/private/wiggleverse-wildcard.key
# sudo install -m 644 fullchain.pem /etc/ssl/certs/wiggleverse-wildcard.crt
# then `sudo nginx -t && sudo systemctl reload nginx`. Once cut over, retire the
# old cert: `sudo certbot delete --cert-name ohm.wiggleverse.org`.
#
# Cloudflare: with an origin wildcard in place, set SSL/TLS mode to Full (strict)
# and flip ohm to the orange cloud (proxied). If this is a Cloudflare Origin CA
# cert it ONLY validates behind the proxy — so cut the cert over and proxy in the
# same change; do not leave ohm grey-cloud with an Origin CA cert.
# HTTP → HTTPS redirect. Cloudflare also redirects at the edge once proxied, but
# keep the origin honest for direct hits.
server {
listen 80;
listen [::]:80;
server_name ohm.wiggleverse.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ohm.wiggleverse.org;
# TLS — *.wiggleverse.org wildcard installed on the VM (see top comment).
# These files must exist before `nginx -t` / reload, or nginx fails to start.
ssl_certificate /etc/ssl/certs/wiggleverse-wildcard.crt;
ssl_certificate_key /etc/ssl/private/wiggleverse-wildcard.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSLwiggle:10m;
ssl_session_timeout 1d;
# NOTE: once proxied behind Cloudflare, $remote_addr is a Cloudflare edge IP.
# To restore the real client IP (for logging / Turnstile / rate limits), add a
# real_ip block (set_real_ip_from <CF ranges>; real_ip_header CF-Connecting-IP;).
# Left out here to avoid baking stale CF ranges into the repo — see follow-up.
# v0.25.0 security hardening (audit 0026 M2/L8)
#
# NOTE: these response headers live in the HTTPS (443) server block. They use
# `add_header ... always` so they also apply to nginx-generated error
# responses (4xx/5xx), not just 200s.
#
# `server_tokens off` (L8) — suppress the nginx version in the
# Server header and on error pages so we don't advertise the
# build to scanners.
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
# - default-src 'self': everything not called out below is same-origin.
# - script-src 'self' + challenges.cloudflare.com: the only external
# <script> tag the app injects is the CloudFlare Turnstile widget
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
# from 'self'), so they need no extra script origin — *.amplitude.com
# is listed defensively in case a future SDK build script-injects.
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
# is used, so we keep XSS-via-inline-script blocked.
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
# JSX uses inline `style={...}` attributes throughout and mermaid
# injects <style> blocks at render time. Removing it would break
# layout; tightening this is a future build-side change (nonce/hash).
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
# images and data: URIs; svg/mermaid output uses data: too.
# - font-src 'self' data: — bundled fonts plus data: webfonts.
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
# Analytics + Session Replay (shipped at sampleRate 1) POST to
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
# Web Worker from a blob: URL for capture/compression; without
# blob: here session replay breaks for every consenting user.
# - frame-src challenges.cloudflare.com — the Turnstile challenge
# renders in an iframe from that origin.
# - frame-ancestors 'none' — clickjacking defense, pairs with
# X-Frame-Options DENY for older agents.
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
# Static SPA assets live in the Vite build output. The systemd unit
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
@@ -49,6 +128,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# §22.10: the old corpus-root URLs (/rfc/<slug>, /rfc/<slug>/pr/<n>,
# /proposals/<n>) are 308-redirected to the project-scoped /p/<default>/…
# routes by the backend, so route them to FastAPI rather than serving the
# SPA index.html. Must precede the `location /` SPA fallback.
location /rfc/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /proposals/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback — any non-asset path falls back to index.html so
# React Router can take over.
location / {
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Preview container entrypoint (flotilla SPEC §15).
#
# 1. Render nginx against Cloud Run's $PORT.
# 2. Start the single-process uvicorn (which runs DB migrations on startup via
# the app lifespan — §4.2: one process, one SQLite file, never scale workers).
# 3. On a brand-new DB, apply the SYNTHETIC seed fixture so the preview shows
# realistic content with no real user PII (§15).
# 4. Hand the foreground to nginx.
#
# This is preview-only plumbing — production runs uvicorn under systemd and nginx
# under the OS, never this script.
set -euo pipefail
: "${PORT:=8080}"
: "${DATABASE_PATH:=/opt/rfc-app/backend/data/rfc-app.db}"
export PORT DATABASE_PATH
mkdir -p "$(dirname "$DATABASE_PATH")"
# Render the nginx config with the injected port.
envsubst '${PORT}' \
< /opt/rfc-app/deploy/preview/nginx.conf.template \
> /etc/nginx/nginx.conf
fresh=0
[ -f "$DATABASE_PATH" ] || fresh=1
# Start uvicorn (backgrounded); the app's lifespan runs migrations on boot.
cd /opt/rfc-app/backend
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
UVICORN_PID=$!
# Seed synthetic data once the schema exists (only on a fresh DB).
if [ "$fresh" = "1" ]; then
for _ in $(seq 1 30); do
if [ -f "$DATABASE_PATH" ] \
&& sqlite3 "$DATABASE_PATH" "SELECT 1 FROM schema_migrations LIMIT 1;" >/dev/null 2>&1; then
break
fi
sleep 1
done
if sqlite3 "$DATABASE_PATH" < /opt/rfc-app/deploy/preview/seed.sql; then
echo "[preview-entrypoint] applied synthetic seed" >&2
else
# Best-effort: a seed that drifts from the schema must not block the
# preview from booting (it still serves /api/health + an empty app).
echo "[preview-entrypoint] WARNING: synthetic seed failed (schema drift?) — continuing" >&2
fi
fi
# nginx in the foreground becomes the container's main process. (uvicorn is a
# reparented child; for an ephemeral preview the hard stop on instance teardown
# is fine — a process supervisor is a follow-up if graceful drain matters.)
exec nginx -g 'daemon off;'
+64
View File
@@ -0,0 +1,64 @@
# nginx config TEMPLATE for the preview container (flotilla SPEC §15).
# `${PORT}` is substituted by deploy/preview/entrypoint.sh (envsubst) with the
# port Cloud Run injects. Mirrors the prod vhost
# (deploy/nginx/ohm.wiggleverse.org.conf) minus TLS (Cloud Run terminates TLS)
# and minus the prod security headers tuned for the public host.
worker_processes 1;
pid /run/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
server_tokens off;
server {
listen ${PORT};
listen [::]:${PORT};
server_name _;
root /opt/rfc-app/frontend/dist;
index index.html;
# API + auth proxy to the single-process uvicorn. SSE chat streams need
# buffering off so chunks reach the browser immediately.
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
location /auth/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback so React Router can take over.
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
client_max_body_size 4M;
}
}
+49
View File
@@ -0,0 +1,49 @@
# Preview environment shape (flotilla SPEC §15) — TEST values only.
#
# These are the env vars a per-PR preview boots with. The operator loads them
# into flotilla's PREVIEW overlay layer (NOT the base overlay, NOT secrets):
#
# while IFS='=' read -r k v; do
# [ -n "$k" ] && case "$k" in \#*) ;; *) \
# ohm-rfc-app-flotilla overlay set ohm-rfc-app "$k=$v" --preview ;; esac
# done < deploy/preview/preview.env.example
#
# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes.
# Every value here is synthetic or a documented public test value. None is a
# real credential. Do NOT bind real `secret_refs` for previews.
# --- Turnstile: Cloudflare's documented ALWAYS-PASS test keys (public) -------
# Site key is also baked into the image at build time (Dockerfile ARG); the
# secret key here makes server-side verification always succeed.
CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
# --- Email: a catch-all SMTP sink (run Mailpit/Inbucket as a sidecar/service)-
# No mail leaves the preview; everything lands in the sink's web UI.
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_STARTTLS=0
SMTP_PASSWORD=preview-sink-no-auth
EMAIL_FROM=preview@preview.invalid
EMAIL_FROM_NAME=RFC App (preview)
# --- Analytics: no-op'd -------------------------------------------------------
VITE_AMPLITUDE_API_KEY=
# --- App identity / required env (synthetic) ---------------------------------
# These satisfy backend/app/config.py's required vars with non-secret test
# values. SECRET_KEY is a throwaway — sessions in an ephemeral preview need a
# key, not a SECRET one. GITEA_* point at whatever read surface the preview
# uses; for a content-light framework-PR preview the seeded synthetic DB
# carries the visible state and Gitea need not be reachable.
SECRET_KEY=preview-throwaway-not-a-secret-0000000000
GITEA_URL=https://git.wiggleverse.org
GITEA_BOT_USER=preview-bot
GITEA_BOT_TOKEN=preview-not-a-real-token
GITEA_ORG=preview
GITEA_WEBHOOK_SECRET=preview-webhook-not-a-secret
OAUTH_CLIENT_ID=preview-oauth-client
OAUTH_CLIENT_SECRET=preview-oauth-not-a-secret
# APP_URL: set to the Cloud Run service URL once `preview up` reports it, if the
# app's OAuth redirect / absolute-URL building needs it for your review flow.
APP_URL=http://localhost:8080
+20
View File
@@ -0,0 +1,20 @@
-- Synthetic seed for per-PR preview environments (flotilla SPEC §15).
--
-- SYNTHETIC DATA ONLY. This file is version-controlled and reviewable, carries
-- NO real user PII, and is applied by deploy/preview/entrypoint.sh onto a
-- brand-new preview DB AFTER the app's own migrations have created the schema.
-- It must never be applied to a production database.
--
-- Applied best-effort: if a future migration changes a table shape this seed
-- references, the entrypoint logs a warning and the preview still boots (it
-- just shows less content). Keep the inserts conservative and schema-stable;
-- grow richer fixtures (RFCs, branches, threads, PRs) here as the preview's
-- review value warrants — they are reproducible because they live in git.
-- A small synthetic user set covering each role (§6.1 owner/admin/contributor).
-- Negative gitea_ids keep these clear of any real Gitea account id space.
INSERT OR IGNORE INTO users (gitea_id, gitea_login, email, display_name, role) VALUES
(-1, 'preview-owner', 'owner@preview.invalid', 'Preview Owner', 'owner'),
(-2, 'preview-admin', 'admin@preview.invalid', 'Preview Admin', 'admin'),
(-3, 'preview-alice', 'alice@preview.invalid', 'Alice Preview', 'contributor'),
(-4, 'preview-bob', 'bob@preview.invalid', 'Bob Preview', 'contributor');
+27
View File
@@ -42,5 +42,32 @@ ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/rfc-app/backend/data
# v0.25.0 security hardening (audit 0026 L4) — defense-in-depth.
# The service binds 127.0.0.1:8000 and runs plain CPython
# (FastAPI/uvicorn + sqlite + bcrypt + httpx), so it needs no
# capabilities and no exotic syscalls.
CapabilityBoundingSet=
AmbientCapabilities=
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
# MemoryDenyWriteExecute=true blocks W^X memory — safe for stock
# CPython (no JIT) and the pure-Python/C-extension stack here, but
# would break a JIT or a C-ext that mmaps W+X. Watch the first
# restart's journal for a crash; if uvicorn fails to come up,
# comment this one line out and reload.
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
UMask=0077
[Install]
WantedBy=multi-user.target
+583
View File
@@ -0,0 +1,583 @@
# Draft spec — §22 Multi-project deployments + amendments + slicing plan
> Status: **draft for review.** Binding voice, but not yet merged into
> `SPEC.md`. When accepted: §22 below is appended after §21; the amendment
> notes in Part B are applied in place; the slicing plan in Part C seeds a
> new `docs/DEV.md` build section. Rationale and the decisions behind this
> live in [`multi-project.md`](./multi-project.md). Target release: the next
> minor (a pre-1.0 minor carrying breaking changes with upgrade steps, §20.2).
---
# Part A — New canonical section
## 22. Projects: multiple corpora per deployment
A **deployment** hosts one or more **projects**. A project is a single
corpus: one content repository (§1) holding entries under `rfcs/`, with
its own per-project slug namespace (the slug is the identity — §22.4), a
declared **type** (§22.4a), catalog, philosophy, branding,
member roster, and model universe. The deployment is the substrate the
projects share — one Gitea org, one bot, one account system, one inbox, one
running process — and the surface a visitor first lands on.
Everything §§121 describe about *a corpus* is now *a project*. 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 across projects. The numbered sections that assume a
single corpus are amended in Part B; §22 is the binding model they defer to.
> **Multi-project change (target: next minor — supersedes the original
> single-corpus model).** §1 originally said "for a deployment, this single
> repository is its content repository." A deployment now has a **registry**
> (§22.2) naming N content repositories, one per project. The single-corpus
> deployment is the **N=1 case** and continues to run after migration via a
> generated default project (§22.13); no deployment is forced to adopt more
> than one project. Where earlier sections say "the meta repo" or "the
> corpus," read "the project's content repo" and "the project's corpus."
### 22.1 The deployment ⇄ project relation
One deployment, N projects (N ≥ 1). A project belongs to exactly one
deployment and never moves between deployments. Projects within a deployment
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
belongs to exactly one project, and no app surface joins across projects
except the per-account ones the deployment owns (the §15 inbox, the §6
account roster, sign-in).
### 22.2 The registry — git is still truth
Which projects exist, and their configuration, is declared in git, mirrored
into a `projects` cache table the same way content is mirrored into
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
at the root of a dedicated **registry repo** under the deployment's Gitea
org. The framework learns the registry repo's location from a required env
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
*name* is the deployment's choice, not the framework's, per the
separation-of-concerns rule, and the framework fails loudly at startup if the
var is unset. Adding, reconfiguring, or archiving a project is a PR against
that file; the §4 webhook + reconciler keep the `projects` table in sync,
recording the merged `registry_sha` on each row for provenance.
The registry is a **deployment-side repo the framework reads**, in exactly
the sense `META_REPO` is today — not operator-tooling config. Where a
deployment is assembled by an external operator tool, that tool supplies the
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
now) and is otherwise unaffected: project definitions live in git, edited by
PR, and the framework knows nothing about the tool that wrote the env var.
```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
type: document # document | specification | bdd — immutable (§22.4a)
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
visibility: gated # gated | public | unlisted (§22.5)
initial_state: super-draft # super-draft | active — landing state of a new
# entry; defaults from type (§22.4b)
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
```
Project **definition and configuration** live in the registry (git). Project
**membership** lives in the app db (§22.6) — it churns at user speed and is
app state, not document state, exactly as `rfc_collaborators` is (§5).
`projects` rows are never written from user actions; they flow from the
registry mirror only.
### 22.3 Content repositories — one per project
Each project names one content repo under the deployment's single Gitea org
(naming convention `<project-id>-content`). 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-project; the slug is the identity
An entry's slug (§2) is unique **within its project**, not across the
deployment: `ohm` and `specs` may each have an `intro`. The slug **is** the
identity — a fully qualified reference is `(project_id, slug)`, and nothing
more. There is no type prefix and **no per-project numeric ID**: within a
project the slug alone is unambiguous, and the project context (its `/p/<id>/`
URL prefix and chrome) carries everything the old `RFC-NNNN` label used to.
This retires the per-project numbering of earlier drafts. The §13 graduation
flip still happens — it moves an entry from proposal to graduated state and
into the content repo — but it no longer allocates a number; the
`max(integer IDs)+1` allocator (§2.3, the old `api_graduation.py` path) is
removed. The displayed *noun* around a slug ("RFC", "Spec", "Feature") is a
presentation concern driven by the project'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 in the UI so external "RFC-0001"-style citations still
resolve, but never used for routing or lookup (the slug is). New entries are
never assigned one, and graduation does not write `id`. The field is read-only
provenance from here on.
### 22.4a Project type
Every project declares a `type` in the registry (§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 (§§913), the same threads,
flags, and chat. Type selects exactly three things:
1. the **entry frontmatter schema** the project validates entries against (§2);
2. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
labels);
3. the set of **type-specific surfaces** layered on top of the shared §7
catalog.
Type-specific behavior is implemented as a per-type module the framework
selects on `project.type`; the engine itself treats every entry as markdown +
frontmatter 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 — and
the three below are what is defined now. The type names and their behavior are
framework concepts (like role names), not deployment content: a deployment
picks which type each project is, but does not define or rename types.
- **`document`** — long-form normative prose (OHM: a model of principles and
definitions). Frontmatter is the §2 baseline (title, status,
owners/arbiters, tags). No type-specific surfaces. The §22.13 generated
default project is a `document` project, so the N=1 case is unchanged.
- **`specification`** — a versioned technical specification (this framework's
own `SPEC.md` is the archetype: numbered normative sections, upgrade steps).
Frontmatter adds spec metadata (`version`, lifecycle `status` of
draft/active/superseded, `supersedes`). **Type-specific surface — release
planning:** group entries/changes into versioned releases, carry a
changelog + §20-style upgrade-steps per release, and surface "what is in the
next release."
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
Given/When/Then scenarios with acceptance criteria. Frontmatter adds feature
metadata and an optional link to the `specification` entries a feature
verifies. **Type-specific surface:** a scenario/acceptance view, and — where
a deployment runs a BDD project alongside a specification project — a
coverage view mapping features to the spec sections they exercise.
> **Draft note.** The shared-engine boundary is locked; the per-type
> *schemas and surfaces* above (notably the specification release-planning
> data model and whether BDD scenarios are free-form markdown or a parsed
> Given/When/Then structure) are first proposals, to be pinned in the
> type-surface slice (Part C, M5).
### 22.4b Initial state of a new entry
A project sets the **landing state** a new entry takes when its creating
idea-PR merges (§2.4) — the `initial_state` registry field, one of the §2.4
super-draft 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: propose → super-draft → review → graduate.
- **`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. A behavior
spec is captured fact, not a proposal under deliberation, so it skips the
review-then-promote ceremony; branches, PRs, and discussion work afterward
exactly as on any `active` entry.
The default comes from the project's **type** (§22.4a) — each type module
supplies it — but `initial_state` is an independent registry knob: a
deployment may run a `bdd` project with `super-draft` if it wants a review
gate, or (less commonly) a `document` project that auto-actives. It changes
only the *landing state and whether graduation is required*; the underlying
propose→branch→PR→discuss engine is unchanged (shared-engine rule, §22.4a).
The §22.13 default project keeps `super-draft`, preserving the N=1 flow.
### 22.4c The `unreviewed` flag
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
`state`. It records whether a human gate has vetted the entry:
- An entry that reaches `active` by the normal **graduate** path (§13) is
**never** flagged — the graduate action, taken by an owner/admin, *is* the
review.
- An entry that **skips straight to `active`** via `initial_state: active`
(§22.4b) lands with `unreviewed = true`, because nothing reviewed it.
A **project owner**`project_admin`, or a deployment `owner`/`admin` (§22.7)
— clears the flag with a **mark-reviewed** action (§17), the same authority
that graduates an entry. Clearing stamps `reviewed_at`/`reviewed_by` for
provenance, paralleling `graduated_at`/`graduated_by`. The flag is a property
of the entry (frontmatter, §2 amendment), so it is git-truth and survives a
cache rebuild, exactly like `state`.
The §7 catalog gains an **unreviewed filter** so an owner can find the entries
awaiting review; it is the natural worklist for the mark-reviewed action.
`unreviewed` only applies to `active` entries — a `super-draft` is pre-review
by definition, and `withdrawn` is out of scope.
### 22.5 Project visibility
Each project carries a `visibility`, defaulting to **gated**:
- **`gated`** (default) — the project is invisible to non-members. It does
not appear in the directory (§22.10), its RFCs return 404 to non-members,
and reading or writing anything in it requires membership (§22.6). This is
the baseline because a deployment may host specs and vision docs it is not
ready to publish.
- **`public`** — any visitor may read the project's RFCs under the §6.1
anonymous-read contract; the project appears in the directory; contributing
still requires a `project_contributor` grant. This is the mode that
preserves the pre-multi-project open-by-default behavior, and the mode a
generated default project (§22.13) is seeded into.
- **`unlisted`** — readable by anyone with a direct link, but not shown in
the directory and not enumerated by `GET /api/deployment`.
Visibility is the project's; it does not relax the §11 per-branch
`read_public` controls *within* a project, which continue to apply on top.
### 22.6 Project membership and roles
Membership is a `project_members(project_id, user_id, role, granted_by,
granted_at)` table, one role per (user, project). The role is a **new middle
tier** between the §6.1 deployment roles and the §6.3 per-RFC authority:
1. **`project_viewer`** — read the project's RFCs and participate in
discussion (chat, flags) on anything readable. No propose/branch/PR. The
discuss-only counterpart of the §12 per-RFC `discussant`, at project scope.
2. **`project_contributor`** — everything a viewer can do, plus the §6.1
contributor capabilities *within this project*: propose RFCs into it,
create branches, open PRs, claim unclaimed super-drafts.
3. **`project_admin`** — everything a contributor can do, plus the §6.1
admin capabilities *within this project*: manage its membership, act on
any RFC in it (merge on behalf of arbiters, graduate, set branch
visibility, withdraw/reopen), and edit per-RFC delegated authority.
`project_admin` is the §6.3 delegated-authority idea lifted from per-RFC
to per-project: an admin scoped to one corpus, not the deployment.
Membership and role are still gated by the deployment-level
`users.permission_state='granted'` (§6): a pending account has no write
capability in any project regardless of its `project_members` rows.
### 22.7 How the three tiers compose
Authorization for an action on an RFC resolves by taking the **most
permissive** of:
- the actor's **deployment role** (§6.1) — `owner`/`admin` are superusers in
every project; a plain authenticated `contributor` has, by itself, only
anonymous-equivalent access to a project until §22.6 grants it a role
(subject to §22.5 visibility);
- the actor's **project role** in that RFC's project (§22.6);
- the actor's **per-RFC authority** in that RFC (§6.3 `owners`/`arbiters`,
§12 `rfc_collaborators`).
Concretely: deployment `owner`/`admin``project_admin` ⊇ RFC
`owners`/`arbiters`; deployment `contributor` + `project_contributor` ⊇ RFC
`rfc_collaborators(contributor)`; `project_viewer``discussant`. The §6.2
write-mute and the §22.5 visibility gate are subtractive on top of whatever
the union grants.
`users.role` (§5) now means *deployment* level only. No schema change demotes
an existing owner/admin; their powers simply read as "superuser in every
project" rather than "superuser in the corpus."
### 22.8 Discovery and joining a gated project
Because a gated project is invisible to non-members, joining is by one of:
- **Invite** — a `project_admin` (or deployment admin/owner) adds a user
directly, writing a `project_members` row and fanning a §15 notification.
This reuses the §12 per-RFC invitation machinery, re-scoped to the project.
- **Request to join** — a surface analogous to §28's contribution-requests:
a user who knows a project exists (e.g. by direct link to an `unlisted`
project, or by out-of-band referral) can request membership; a
`project_admin` accepts or declines from the inbox. The request names the
desired role (defaulting to `project_viewer`).
A `public` project needs neither: read is open, and the existing §6 / §12
contribute-grant paths cover write access.
### 22.9 Branding is resolved at runtime
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
cannot serve N projects. Deployment and project identity are served at
runtime:
- `GET /api/deployment` — the deployment `name`, `tagline`, and the list of
projects the caller can see (gated projects filtered by the caller's
membership; `unlisted` omitted).
- `GET /api/projects/:id` — that project's `name`, `tagline`, philosophy
pointer, and optional `theme` token overrides applied over the §-default
`tokens.css`.
The frontend reads these instead of `import.meta.env.VITE_APP_NAME`. Two
chrome layers result: **deployment chrome** (the directory/landing, the
project switcher, the shared inbox) and **project chrome** (the §7 catalog,
the §8 RFC view, the §14 philosophy — all per project).
### 22.10 Routing and the deployment landing
Every corpus-scoped route gains a project segment: `/p/<project>/…` carries
the §7 catalog, the §8 entry view at `/p/<project>/e/<slug>`, the §9/§10
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/philosophy`. The entry
segment is the **generic `/e/`** for every type — the displayed noun ("RFC",
"Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so
routing stays a single type-agnostic path and avoids colliding with the
reserved sibling segments (`proposals`, `philosophy`, …). The root `/` is the
**deployment landing**: a directory of the projects the visitor can see (per
§22.5), plus sign-in. An anonymous or non-member visitor sees only `public`
projects there. The §8.1 breadcrumb gains a leading project segment:
`OHM / Human main` (slug, with the type-driven noun as its label).
### 22.11 Notifications span projects, one inbox
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
caller's projects. `notifications` and `watches` carry `project_id` (§5
amendment) so the inbox filters by project and a user can mute an entire
project. Quiet hours, digest cadence, and email preferences stay per-account
at the deployment level (§5, §15).
### 22.12 Per-project model universe
A project's `enabled_models` (registry, §22.2) defines its operator universe,
overriding the deployment `ENABLED_MODELS` (§18) when present and falling
back to it when absent. The §6.6 per-RFC `models:` list and the §6.7 funder
universe resolve *within* the project's universe — the resolution order
becomes funder universe ∩ §6.6 list ∩ project universe, with the project
universe substituting for the deployment universe at the outermost step.
### 22.13 Migration — the default project (the N=1 case)
A deployment upgrading from a pre-multi-project version is migrated to a
single **default project** so it keeps running unchanged:
1. The migration generates a `projects` row from current config:
`META_REPO → content_repo`, `VITE_APP_NAME → name`, `visibility = public`
(preserving the deployment's current open-by-default posture),
`type = document` (every pre-multi-project corpus is a document corpus),
and the `id` a **config-derived slug**: `DEFAULT_PROJECT_ID` if set, else a
slug of the deployment name, falling back to the literal `default`. (M1's
migration 026 seeds the bootstrap id `default`; the §C-M3 step re-stamps it
to the config-derived slug before any `/p/<id>/` route is public, so the id
is meaningful — e.g. `/p/ohm/…` — and never renamed after URLs go live. The
id stays framework-generic: the framework supplies no deployment name.)
2. Every existing RFC-scoped row (§5 amendment list) is stamped with that
`project_id`.
3. Old corpus-root URLs (`/rfc/<slug>`, `/proposals/<n>`) 308-redirect to
their `/p/<default-id>/…` equivalents — the entry view to
`/p/<default-id>/e/<slug>` (§22.10) — so existing links survive.
4. The operator creates the registry repo (§22.2) declaring the default
project; until they add a second project, the deployment is functionally
identical to before, with one extra path segment.
This is the §20.4 upgrade-steps content for the release.
---
# Part B — Amendments to existing sections
Applied in place, in the established amendment-note style (cf. §1's
"Topology change (v0.31.0)").
- **§1 Repository topology.** Add the §22 amendment note (above). "This
single repository is its content repository" → "each *project* names one
content repository; the deployment's registry (§22.2) lists them." The bot
and app-owned-authorization paragraphs are unchanged and now read
org-wide.
- **§2 Meta schema / §2.3 IDs.** Slugs are unique within a project; entry
filenames are unchanged (per content repo). §2.3's `RFC-NNNN` `max+1`
allocation is **removed** — the slug is the identity (§22.4); there is no
per-project number. Entries graduated before this change keep their `id`
as a frozen, read-only legacy display label (§22.4), never used for lookup.
The entry frontmatter schema becomes type-dependent
(§22.4a): `document` keeps today's fields, `specification` and `bdd` add
their type metadata. New `active`-entry fields: `unreviewed` (bool) and the
`reviewed_at`/`reviewed_by` provenance pair (§22.4c), paralleling
`graduated_at`/`graduated_by`.
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition now
targets the project's `initial_state` (§22.4b): `super-draft` as today, or
straight to `active` when the project (e.g. a `bdd` project) lands entries
there — in which case the entry is stamped `unreviewed` (§22.4c). A new
`active ─[mark-reviewed, owner/admin]→ active` self-transition clears the
flag. The rest of the machine is unchanged.
- **§5 Data model.** Add `project_id` to: `branch_visibility`,
`branch_contribute_grants`, `stars`, `threads`, `changes`, `watches`,
`notifications`, `rfc_invitations`, `rfc_collaborators`,
`contribution_requests`, `funder_consents`, the `*_seen` cursors, `actions`,
and the §4 cache tables `cached_rfcs` (PK → `(project_id, slug)`;
also mirrors the `unreviewed` frontmatter flag, §22.4c, so the §7 catalog
filter can query it without reading every entry file),
`cached_branches`, `cached_prs`, `pr_resolution_branches`,
`proposed_use_cases`. Add the new tables `projects` (carrying the immutable
`type`, §22.4a) and `project_members` (§22.2, §22.6). `users.role` is
annotated as deployment-scope (§22.7).
- **§6.1 Roles.** Add the §22.7 composition note: deployment roles are now
one of three tiers; a plain `contributor` has no implicit access to a
project until §22.6 grants a project role (subject to §22.5).
- **§6.3 Per-RFC delegated authority.** Note that `project_admin` (§22.6) is
the same delegation idea at project scope, sitting above per-RFC authority.
- **§7 Left pane.** The catalog is per-project, under `/p/<project>/`. The
deployment directory (§22.10) is a new surface above it. The catalog gains
an **unreviewed filter** (§22.4c) — the owner's worklist of `active` entries
that landed unreviewed.
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). The entry is
named by its slug, not a number (§22.4); the noun shown around it ("RFC",
"Spec", "Feature") is the project type's label (§22.4a).
- **§13.3 Graduation flip.** Operates on the project's content repo. It flips
status and moves the entry, but **allocates no number** — the slug is the
identity throughout (§22.4); the old per-project `RFC-NNNN` allocation is
gone. Graduation is also **conditional on the project's `initial_state`
(§22.4b)**: a project that lands entries `active` has no super-draft phase,
so the graduate action is a no-op there and is not surfaced. For those
entries the **mark-reviewed** action (§22.4c) takes graduation's place as
the owner/admin vetting step — it clears `unreviewed` instead of flipping
state.
- **§14.1 Pre-login landing.** Splits into deployment landing (the directory,
§22.10) and per-project philosophy/deck (§14 under `/p/<project>/`).
Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`.
- **§17 Backend surface.** RFC routes gain the `/p/<project>` /
`project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`
(returns the project's `type`, §22.4a), and the `project_members`
management + request-to-join endpoints (§22.6, §22.8). Add a
**mark-reviewed** endpoint clearing an entry's `unreviewed` flag (§22.4c,
owner/admin), and an `unreviewed` filter param on the catalog list. Type-
specific surfaces (§22.4a) add their own routes, mounted only for projects of
the matching type — e.g. the `specification` release-planning endpoints and
the `bdd` scenario/coverage endpoints.
- **§18 Stack.** `ENABLED_MODELS` is the deployment fallback; per-project
`enabled_models` overrides it (§22.12).
- **§20 Versioning / surface.** `VITE_APP_NAME` deprecated in favor of the
registry + `GET /api/deployment` (§22.9). New required backend env var
`REGISTRY_REPO` (the §20.3 env contract); `META_REPO` becomes legacy,
consulted only by the §22.13 migration to seed the default project's
`content_repo`, then unused. The registry file shape and the
`projects`/`project_members` schema join the §20.3 versioned surface. The
release is a pre-1.0 minor with a §22.13 upgrade-steps block. Note for the
changelog: a deployment assembled by an external operator tool upgrades
through the same pinned-version path as any other — the only deploy-surface
change is swapping the `META_REPO` overlay value for `REGISTRY_REPO`; the
framework's versioned contracts (`/api/health`, `VERSION`, the pin file)
are unchanged.
---
# Part C — Slicing plan
Seven slices carry §22 and its amendments end-to-end. The ordering mirrors
DEV.md's original principle — foundations and the cache/permission spine
first, the surfaces that consume them after, hardening last. Each slice is
shippable: a deployment can stop at any slice boundary and still run (the
default project keeps the N=1 case whole throughout). The project `type`
(§22.4a) rides M3 (config) and M5 (its surfaces); M1M4 are type-agnostic
because the engine is.
**M1 — The project spine (schema + default-project migration).** *(landed)*
The `projects` and `project_members` tables; `project_id` threaded
additively onto every slug-bearing §5 table (migration 026); the §22.13
default project generated and every existing row backfilled to it; the
startup backfill that fills the default project's `content_repo` from
`META_REPO`; `REGISTRY_REPO` wired into config (consumed in M3). No UI, no
routing change, no registry mirror yet — the app runs exactly as before,
single project, with the spine underneath. Additive only: no table rebuilds
(the slug-keyed uniqueness/PK rework is deferred to the slice that activates
project #2, enumerated in migration 026's header). This is the foundation
everything after builds on.
**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)*
The three-tier composition: `project_members` roles, the most-permissive
union with deployment role and per-RFC authority, the §22.5 visibility gate as
a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in
§6.1's audit re-checked under the project axis. Still single visible project;
verifiable by granting/revoking roles on the default project and flipping its
visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure
app-layer — the resolver primitives live in `app/auth.py`
(`project_visibility`, `project_member_role`, `is_project_superuser`,
`can_read_project`, `can_contribute_in_project`, `require_project_readable`,
`visible_project_ids`), composed into the existing per-RFC capability helpers
and threaded into every RFC-resolution gate (`_require_rfc*`,
`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep
gates). No migration (M1 shipped the tables) and no behavior change on the
public default project.
Two operator decisions pin how the implicit grant behaves on a `public`
project: **implicit-on-public** — a granted deployment `contributor` keeps its
pre-multi-project write baseline with no `project_members` row, so the N=1 case
stays whole (no backfill); and **preserve curation** — that implicit baseline
does *not* override per-RFC owner curation (only an explicit
project_contributor/admin or a deployment owner/admin does), so the v0.16.0
per-RFC invite contract is unchanged on public. Explicit `project_members`
rows and `gated`/`unlisted` visibility are where the new tier bites. This is a
deliberate liberalization of §22.5's literal "contributing still requires a
grant" for the public case; gated/unlisted honor the grant model exactly.
**M3 — Registry mirror + routing + runtime branding.** The §4 registry
mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`
rows beyond the default); the **re-stamp of the default project's bootstrap
`id`** (`default` → the config-derived slug, §22.13 step 1) which must land
here, before any `/p/<id>/` URL is public; the `/p/<project>/` route prefix
with the generic `/e/<slug>` entry segment (§22.10) and the 308 redirects off
the old corpus-root URLs (`/rfc/<slug>``/p/<default-id>/e/<slug>`); `GET
/api/deployment` and `GET /api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config,
per-project `theme` token overlay. The deployment directory at `/` and the
project switcher in deployment chrome. This slice also adds the additive
`type` and `initial_state` columns to `projects` (a small migration — M1
shipped `projects` without them), mirrors both from the registry, returns
them on `GET /api/projects/:id`, and drives the entry-noun terminology off
`type` (§22.4a). It also teaches the shared creation path to honor
`initial_state` (§22.4b) — land a new entry `active` instead of `super-draft`,
stamping `unreviewed` and skipping the graduate gate when the project says so
— plus the `unreviewed` frontmatter fields (§22.4c) mirrored into
`cached_rfcs`, the owner/admin mark-reviewed action, and the §7 catalog
unreviewed filter that queries that cached column. But no type
*surfaces* yet; beyond their label, landing state, and review flag, all three
types still look the same here. After M3 a deployment with two registry projects is
fully navigable — which makes this the slice that must also land the deferred
slug-keyed uniqueness/PK rebuilds (migration 026 header) before a second
project can collide with the first.
**M4 — Per-project corpus surfaces (the second-project acceptance pass).** The
§7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14
philosophy all confirmed working under project scope with the per-project,
slug-only namespace (§22.4). This is mostly *inherited* from M1M3 — the work
is an end-to-end pass that proves a second `document` project's full lifecycle
(propose → super-draft → graduate, identified by slug *in that project*), not
new build. Naming it explicitly as the acceptance slice keeps scope that
belongs in M3 from leaking in.
**M5 — Type modules + type-specific surfaces.** The per-type layer of §22.4a:
type-specific entry-frontmatter validation (`specification`/`bdd` metadata);
the `specification` **release-planning** surface; the `bdd` scenario/coverage
surface. Type-scoped routes mounted only for matching projects (§17). The
shared engine is untouched — this slice only adds the layers on top, so a
`document` project is unaffected and the M4 acceptance still holds. (The
per-type schema/surface details are the §22.4a draft note's open work.)
**M6 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and
request-to-join (re-scoped §28); the inbox surfacing of join requests; the
§22.11 cross-project inbox with `project_id` filtering and project-level
mute. The admin surface for managing a project's roster.
**M7 — Hardening + operator path.** Per-project `enabled_models` resolution
(§22.12) including funder/§6.6 intersection; the registry's place in
`docs/DEPLOYMENTS.md` and the flotilla operator tooling; end-to-end tests
spanning two projects with disjoint membership; the §20.4 changelog +
upgrade-steps block; the SPEC merge (Part A appended, Part B applied).
## Open items folded into the slices
- **Registry repo vs. file-in-existing-repo***resolved:* a dedicated
registry repo the framework reads via the `REGISTRY_REPO` env var (§22.2).
Confirmed against the flotilla operator-tooling spec: the registry is
deployment-side git content (like the corpus), not operator config, so the
operator tool's only change is swapping the `META_REPO` overlay value for
`REGISTRY_REPO`. No flotilla architectural change; no new framework⇄tool
contract. With OHM becoming one project among several, the registry sits
*above* any single project's content repo, so a file inside one project's
repo is wrong — a dedicated repo is the right home.
- **Request-to-join vs. invite-only** — drafted with both (§22.8); M6
(membership lifecycle) may ship invite-only first and add request-to-join
second if scope demands.
- **Per-type schemas and surfaces** — the §22.4a draft note's open work:
the `specification` release-planning data model and whether `bdd` scenarios
are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.
+365
View File
@@ -0,0 +1,365 @@
# Design sketch — multi-project deployments
> Status: **draft / sketch.** Not binding. This precedes the SPEC edits it
> describes. Decisions captured here were made interactively; open questions
> are flagged inline. When this stabilizes it folds into `SPEC.md` (§1, §2,
> §5, §6, §7, §8, §13, §14, §17, §20) and ships as a pre-1.0 minor with
> upgrade steps.
## The reframe
Today the framework hardcodes **deployment : corpus = 1 : 1**. One deployment
is one Gitea content repo (`META_REPO`), one global slug namespace, one
`VITE_APP_NAME` baked into the build, one flat catalog. SPEC §1 says it
plainly: "this single repository is its content repository."
This change makes it **deployment : project = 1 : N**, where *today's entire
deployment becomes the N=1 case*. A **project** is what a corpus is now: a
content repo, its own slug namespace, a declared **type** (§ "Project types"
below), its own catalog, philosophy, branding, member roster, and
enabled-models universe. The **deployment** (the subdomain — e.g. Wiggleverse)
becomes a thin shell hosting a directory of projects plus a shared
account/notification layer.
OHM — a *document* project — becomes one project among several: specs
(*specification* projects), behavior suites (*BDD* projects), vision docs, …
all under one deployment.
The value of this framing: the migration stays mechanical. Everything
deployment-scoped today splits cleanly into:
- **stays deployment-scoped** — accounts, the beta/permission gate, the inbox,
the bot service account, the Gitea org;
- **becomes project-scoped** — the corpus, branding, roles, catalog, philosophy,
enabled models.
## Decisions (locked)
1. **Project registry lives in git.** A registry (a `projects.yaml` / registry
repo) declares which projects exist and their config; adding a project is a
PR. Mirrored into a `projects` cache table. Keeps the git-is-truth invariant.
2. **Projects are membership-gated by default** (private). A non-member does
not see a private project exists. `visibility` is still a per-project field
with `public` and `unlisted` escape hatches (see §3) — gated is the default,
not the only mode.
3. **Entries are identified by slug, scoped to the project.** A fully
qualified reference is `(project_id, slug)`. There is *no* type prefix and
*no* per-project numeric ID: inside a project the slug alone is
unambiguous, and the project (its URL prefix, its chrome) supplies all the
surrounding context. This **supersedes** the earlier per-project `RFC-NNNN`
allocation idea — graduation (§13) still flips an entry's status, but no
longer mints a number.
4. **Each project declares a `type`**`document`, `specification`, or `bdd`.
Type is chosen when the project is created (in the registry), is immutable,
and selects the project's entry frontmatter schema, its terminology/labels,
and any type-specific surfaces (e.g. release planning for specifications).
All types ride the *same* propose→branch→PR→graduate engine, threads,
flags, and chat; type layers on top — it does not fork the lifecycle.
## Project types
A project's `type` is a registry field, fixed at creation. It does **not**
change the engine — every type uses the same content repo, the same
propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It
selects three things: the **entry frontmatter schema** the project validates
against, the **terminology** the chrome uses for an entry, and the set of
**type-specific surfaces** the project exposes on top of the shared catalog.
The engine treats every entry as markdown + frontmatter regardless of type;
type-specific behavior is a layer, implemented as a per-type module the
framework selects on `project.type`.
> These three definitions — especially the specification release-planning
> surface and the BDD scenario model — are first drafts. The schema/surface
> details below are proposals to refine, not yet locked.
- **`document`** — long-form normative prose (OHM is the archetype: a model
of principles and definitions). Frontmatter is today's entry schema
(title, status, owners/arbiters, tags). **No** type-specific surfaces; this
is the baseline, and the N=1 default project (§7) is a `document` project.
- **`specification`** — a versioned technical specification (this app's own
`SPEC.md`, with numbered normative sections and upgrade steps, is the
archetype). Frontmatter adds spec metadata (e.g. `version`, lifecycle
`status` of draft/active/superseded, `supersedes`). Type-specific surface:
**release planning** — group entries/changes into versioned releases, track
the changelog + upgrade-steps for each, and show "what's in the next
release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG +
§20 upgrade steps.)
- **`bdd`** — behavior-driven feature specs: each entry describes a feature
as scenarios in Given/When/Then form with acceptance criteria. Frontmatter
adds feature metadata and an optional link to the `specification` entries a
feature verifies. Type-specific surface: a scenario/acceptance view, and
(where a deployment pairs a BDD project with a specification project) a
coverage view linking features back to the spec sections they exercise.
Types are an **open set** in shape — a new type is a new module plus a new
allowed `type` value; it needs no schema migration beyond the enum. Document,
specification, and BDD are the three defined now.
**Landing state (`initial_state`).** A project also sets what state a new
entry lands in when its idea-PR merges — `super-draft` (the normal
propose→review→graduate flow) or `active` (graduated on submission). The
default comes from the type: `document` and `specification` default to
`super-draft`; **`bdd` defaults to `active`** — a behavior spec is captured
fact, not a proposal under deliberation, so it skips the review-then-promote
gate. It's an independent registry knob, so a deployment can override the
default per project. This changes only the landing state and whether
graduation is required; the propose→branch→PR engine is unchanged.
**The `unreviewed` flag.** Skipping straight to `active` means nothing vetted
the entry, so it lands with an **`unreviewed`** flag set. An entry that reaches
`active` the normal way — super-draft → graduate — is never flagged, because
graduation *is* the review. A project owner clears the flag with a
**mark-reviewed** action (same authority as graduate), and the catalog has an
**unreviewed filter** so owners can find the entries still awaiting review. The
flag is an entry property (frontmatter, git-truth like `state`), orthogonal to
the `active` state and only meaningful on `active` entries.
The separation-of-concerns rule (CLAUDE.md) is satisfied: the *type names*
and their behavior are framework concepts (like role names), not
deployment-specific content. A deployment chooses *which* type each of its
projects is; it does not define new types or rename them.
## 1. Git topology — one content repo per project
One Gitea org for the deployment, **N content repos** (`ohm-content`,
`specs-content`, `vision-content`, …). The bot already operates org-wide; it
gains more repos and one registry repo. Slug uniqueness becomes naturally
per-repo = per-project (and the slug is the whole identity — see Decision 3).
Rejected alternatives: subdirectories in one repo (`projects/<id>/rfcs/…`)
churns every path / branch-name / graduation code path and can't carry
git-layer access control per project; prefixed slugs pollute the namespace.
### The registry repo
A small repo (or a top-level file in a deployment repo) the bot reads and
mirrors. Sketch shape:
```yaml
# projects.yaml
deployment:
name: Wiggleverse
tagline: ...
projects:
- id: ohm # url slug, stable, unique in deployment
name: Open Human Model
type: document # document | specification | bdd (immutable)
content_repo: ohm-content # repo under the deployment's Gitea org
visibility: gated # gated | public | unlisted
initial_state: super-draft # super-draft | active — landing state of a
# new entry; defaults from type
philosophy_repo_path: PHILOSOPHY.md
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional per-project token overrides
```
Project **definition/config** is in git; project **membership** is in the DB
(it churns like `rfc_collaborators` and is app-state, not document state). The
registry gets the same webhook + reconciler treatment as content repos.
> Open: does the registry get its own repo, or is it a file in an existing
> deployment/meta repo? Ties into the `ohm-rfc-app-flotilla` operator tooling,
> which already owns deploy orchestration and could own registry edits.
## 2. Data model
Introduce a **`projects`** cache table (mirrored from the registry, like
`cached_rfcs` is mirrored from content). Then thread `project_id` through
every RFC-scoped table.
Hard constraint: once slugs are unique only *within* a project, any table
keyed on `rfc_slug` alone is ambiguous, so `project_id` must ride along on all
of them:
- `cached_rfcs` — PK becomes `(project_id, slug)`; `rfc_id` unique per project
- `cached_branches`, `cached_prs`, `pr_resolution_branches`, `proposed_use_cases`
- `threads`, `changes`, `branch_visibility`, `branch_contribute_grants`
- `stars`, `watches`, `pr_seen`, `branch_chat_seen`
- `rfc_invitations`, `rfc_collaborators`, `contribution_requests`, `funder_consents`
- `notifications`, `actions`
~15-table migration, all backfillable to a single default project (see §7).
With slug-only identity (Decision 3) there is no per-project number to
allocate: `api_graduation.py`'s `RFC-NNNN` allocator is **retired**, and
graduation reduces to the status flip + content-repo move, keyed on
`(project_id, slug)`.
New tables:
```
projects(
id TEXT PRIMARY KEY, -- 'ohm'
name TEXT NOT NULL,
type TEXT NOT NULL -- document | specification | bdd (immutable)
CHECK (type IN ('document','specification','bdd')),
initial_state TEXT NOT NULL -- super-draft | active (landing state, §types)
DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
content_repo TEXT NOT NULL,
visibility TEXT CHECK (visibility IN ('gated','public','unlisted')),
config_json TEXT, -- theme, tagline, enabled_models, …
registry_sha TEXT, -- provenance of the mirrored row
updated_at TEXT
)
project_members(
project_id TEXT NOT NULL REFERENCES projects(id),
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT CHECK (role IN ('project_admin','project_contributor','project_viewer')),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT,
PRIMARY KEY (project_id, user_id)
)
```
## 3. Roles — three tiers
A **middle tier** slots between today's deployment roles and per-RFC authority.
| Tier | Who | Powers |
|---|---|---|
| **Deployment** (unchanged, narrowed) | `owner` / `admin` | Create/archive projects, manage all accounts, act in any project. The beta/`permission_state` gate stays here — it gates *having an account*, not project access. A plain authenticated user has an account but no implicit project powers. |
| **Project** (NEW — `project_members`) | `project_admin` / `project_contributor` / `project_viewer` | `project_admin` = today's app-admin, scoped to one project (manage its membership, settings, graduate, act on any RFC in it). `project_contributor` = propose/branch/PR/chat. `project_viewer` = read + discuss only. |
| **RFC** (unchanged) | frontmatter `owners`/`arbiters`; `rfc_collaborators` (`contributor`/`discussant`) | Same as today, now scoped within their project. |
This is the existing owner → admin → contributor delegation pattern with a
project axis added. `users.role` reverts to meaning *deployment*-level only.
**Visibility interaction:**
- **gated** (default) — invisible to non-members; must be a member to see it
exists. Read and write both require membership.
- **public** — any authenticated user can read; contributing requires a
`project_contributor` grant.
- **unlisted** — readable by direct link, not shown in the directory.
> Philosophy tension to resolve in SPEC: today the app is open-by-default
> (anonymous read, §11.1; admission gates only writing). Gated-by-default
> reverses that for the common case. The `public`/`unlisted` modes preserve the
> old behavior for projects that want it, and the deployment can choose its own
> default posture — but §11 and §14 need rewriting to make "gated" the baseline
> and anonymous read a per-project opt-in.
**Discovery for gated projects:** since a stranger sees an empty directory,
there must be a join path — invite-only (a `project_admin` adds you), or a
request-to-join surface analogous to the existing §28 contribution-request
flow. (Open — pick one.)
## 4. Branding & frontend (forced change)
The one *forced* change. `VITE_APP_NAME` is baked at build time; you cannot
bake N project names into one bundle. Branding moves to **runtime config
served by the backend**:
- `GET /api/deployment` → deployment name/tagline + the list of projects the
caller can see (gated ones filtered by membership).
- `GET /api/projects/:id` → that project's name, tagline, philosophy, theme
tokens.
- Frontend reads these instead of `import.meta.env.VITE_APP_NAME`
(`App.jsx:208`, `Landing.jsx:16`, `BetaPending.jsx:17`).
`VITE_APP_NAME` is deprecated → deployment name comes from the registry. This
is a documented config change with upgrade steps per CLAUDE.md.
Two chrome layers result:
- **Deployment chrome** — the Wiggleverse header, the project directory /
landing, a project switcher.
- **Project chrome** — the current header / catalog / philosophy, now per
project; theme tokens (`tokens.css`) overridable per project at runtime.
## 5. Routing & UX
- Entry routes gain a project prefix with a **generic `/e/` segment**:
`/p/<project>/e/<slug>`, `/p/<project>/proposals/<n>`,
`/p/<project>/philosophy`, etc. The segment is the same for every type; the
noun shown around the slug is a type-driven label, not part of the path.
- Root `/` becomes the **deployment landing = project directory** (the
Wiggleverse home). For an anonymous or non-member visitor under gated
default, that's only public/unlisted-by-link projects.
- Breadcrumb gains a segment: `Wiggleverse / OHM / Human main` (the entry
is named by its slug; the entry-noun the chrome uses around it is
type-driven — "RFC", "Spec", "Feature").
- The left-pane catalog (§7) becomes per-project; a project switcher lives in
deployment chrome.
## 6. Cross-cutting — notifications, inbox, watches
Accounts are deployment-wide, so there is **one inbox spanning projects**
(§15). `notifications` and `watches` carry `project_id` so the inbox is
filterable and a user can mute an entire project. Quiet hours / digest prefs
stay per-account (deployment level).
## 7. Backward compatibility & migration
The N=1 path keeps existing single-project deployments working:
1. Migration creates one **default project** from current config (`META_REPO`
`content_repo`, `VITE_APP_NAME``name`, visibility seeded to match the
deployment's current open posture, likely `public`). Its `id` is a
config-derived slug (`DEFAULT_PROJECT_ID`, else slug of the deployment
name, else `default`); M1's `default` bootstrap id is re-stamped to it in
M3 before any `/p/` URL is public.
2. Every existing row's `project_id` is stamped to that default project.
3. An optional default-project redirect keeps old `/rfc/<slug>` URLs alive
(308 → `/p/<default-id>/e/<slug>`).
This is the SPEC §20 upgrade-steps block.
## 8. SPEC & versioning impact
Touches §1, §2, §5, §6, §7, §8, §13, §14, §17, §20. Pre-1.0 minor with
breaking changes spelled out (schema migration, `VITE_APP_NAME` deprecation,
URL change, gated-default philosophy shift). Single-process SQLite stays fine —
`project_id` is just a column; no DB-per-project, no Postgres forced.
## Operator-tooling integration (flotilla)
Checked against the `ohm-rfc-app-flotilla` spec (the OHM deployment's operator
control panel). It assembles a deployment from `{rfc-app@pin} + {non-secret
overlay} + {secret pulls} + {corpus}`, **does not host the corpus** ("the
corpus lives in a deployment-side repo"), and **depends on the framework only
through versioned contracts** (`/api/health`, `VERSION`, CHANGELOG
upgrade-steps, the `.rfc-app-version` pin). The framework knows nothing about
flotilla.
This confirms the registry decision and fixes how it integrates:
- The registry is **deployment-side git content the framework reads** — same
category as the corpus — *not* a flotilla-owned config blob. Putting
project definitions in operator tooling would re-bake deployment specifics
into the assembly layer and add a new framework⇄tool contract.
- The framework reads the registry via a `REGISTRY_REPO` env var, the
multi-project successor to `META_REPO`. Flotilla's overlay simply swaps one
ref; its four versioned contracts are untouched.
- Multi-project therefore ships to OHM as an **ordinary pinned-version
upgrade**: bump the pin, run the §22.13 default-project migration, change
`META_REPO``REGISTRY_REPO` in the overlay, deploy, verify via
`/api/health`. No flotilla architectural change.
## Open questions
- Gated discovery: invite-only vs. request-to-join surface? (drafted with
both; M5 can ship invite-only first.)
- Does the deployment landing itself need branding config, or is it derived
entirely from the registry `deployment:` block?
- Per-project `ENABLED_MODELS` resolution vs. deployment universe (§18, §6.6,
§6.7 funder) — confirm fallback order.
- Slicing plan for the build (mirrors DEV.md's original slice approach).
- **Type surfaces — depth of each.** What concretely is in the
`specification` *release-planning* surface (its own tables? a release =
a tag + a changelog entry + a set of graduated entries?), and does the
`bdd` scenario model stay free-form markdown or get a structured
Given/When/Then schema the app parses? Drafted shallow; pin before the
type-surface slice.
- **Entry-noun in URLs/labels***resolved 2026-06-02:* generic route
segment `/p/<project>/e/<slug>` for every type; the displayed noun
("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path
(§22.10, §22.4a).
- **Existing graduated numbers***resolved 2026-06-02:* pre-change graduated
entries keep their `RFC-NNNN` `id` in frontmatter as a frozen, read-only
legacy display label (preserves citations); never used for lookup, never
assigned to new entries (§22.4).
- **Default project `id`***resolved 2026-06-02:* a config-derived slug
(`DEFAULT_PROJECT_ID`, else slug of the deployment name, else `default`);
M1's `default` bootstrap id is re-stamped in M3 before any `/p/` URL is
public, so it's meaningful (e.g. `/p/ohm/`) and never renamed live (§22.13).
@@ -0,0 +1,823 @@
# M3-0 — Test & Local-Env Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up the handbook §10.3 **Tier-1 local-Docker test foundation** for rfc-app — a `docker compose` stack (backend + nginx-served SPA + seeded real Gitea + Mailpit), a Vitest frontend-unit setup, and an environment-agnostic Playwright e2e harness with one passing smoke spec — so every later M3 sub-plan can be verified at unit/integration/functional/e2e levels on localhost (and against PPE later by changing `BASE_URL`).
**Architecture:** A four-service compose stack. The backend (FastAPI, single uvicorn process, SQLite, migrations on startup) and an nginx container serving the built SPA + proxying `/api` and `/auth` — mirroring prod. A **real, disposable Gitea** container, seeded fresh each run by a one-shot seed service (admin + bot token + OAuth app + org + content repo + webhook), chosen so the SAME e2e suite behaves identically in Tier 1 and Tier 2/PPE (§10.3). **Mailpit** as the mail sink; e2e logs in via the email **OTC** flow (`/auth/otc/request` → read code from Mailpit's API → `/auth/otc/verify`), which needs no Gitea OAuth consent scripting. Playwright is parameterized by `BASE_URL` + `MAILSINK_URL` so the unchanged suite later targets PPE.
**Tech Stack:** Docker Compose, Gitea (pinned image), Mailpit, nginx, Python 3.11/uvicorn, Vite/React 19, Vitest + @testing-library/react, Playwright (@playwright/test).
**Conventions (Wiggleverse):** SSH git transport; no inline comments trailing CLI commands; commit messages end with the `Co-Authored-By` trailer. Branch off `main` — do **not** work on `main`. Suggested branch: `feat/m3-0-test-foundation`.
**Pre-req:** This plan creates a new directory `testing/` at repo root for harness assets and `frontend/src/**/*.test.jsx` for unit tests. It does not touch backend app code except adding a Dockerfile.
---
## File Structure
Files created/modified, by responsibility:
- `testing/docker-compose.yml` — the four-service Tier-1 stack (backend, web/nginx, gitea, mailpit) + the one-shot `gitea-seed` service.
- `testing/backend.Dockerfile` — builds the backend image (Python 3.11 + requirements + app).
- `testing/web.Dockerfile` — builds the SPA (node build stage) and serves it via nginx (runtime stage).
- `testing/web.nginx.conf` — nginx config for the web container (SPA fallback + `/api` `/auth` proxy to backend). Adapted from `deploy/nginx/ohm.wiggleverse.org.conf`, TLS stripped.
- `testing/seed-gitea.sh` — idempotent seed script: admin user, bot user + token, OAuth app, org, content repo (seeded with `rfcs/`), webhook.
- `testing/.env.tier1` — the env values the compose stack injects into the backend.
- `testing/README.md` — how to run Tier 1 locally and how to point the suite at PPE.
- `frontend/vitest.config.js` — Vitest config (jsdom env).
- `frontend/src/test/setup.js` — testing-library/jsdom setup.
- `frontend/src/lib/brand.js` + `frontend/src/lib/brand.test.js` — a tiny first unit-tested module (proves Vitest wiring; reused by M3c).
- `frontend/package.json` — add devDeps + `test`, `test:run` scripts (modify).
- `e2e/playwright.config.js` — Playwright config; `baseURL` from `BASE_URL`, mail sink from `MAILSINK_URL`.
- `e2e/lib/mailpit.js` — helper to read the latest OTC email from Mailpit's API.
- `e2e/smoke.spec.js` — the one smoke spec (OTC login → landing renders).
- `e2e/package.json` — Playwright dep + `e2e` script (kept separate from the app frontend deps).
- `Makefile` (repo root) — `tier1-up`, `tier1-down`, `e2e`, `fe-unit` convenience targets (modify or create).
---
## Task 1: Frontend unit testing (Vitest) — independent quick win
**Files:**
- Modify: `frontend/package.json`
- Create: `frontend/vitest.config.js`
- Create: `frontend/src/test/setup.js`
- Create: `frontend/src/lib/brand.js`
- Test: `frontend/src/lib/brand.test.js`
- [ ] **Step 1: Add Vitest dev dependencies and scripts**
Modify `frontend/package.json` — add to `devDependencies`:
```json
"vitest": "^3.0.0",
"jsdom": "^25.0.0",
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.0"
```
Add to `scripts`:
```json
"test": "vitest",
"test:run": "vitest run"
```
- [ ] **Step 2: Install**
Run: `cd frontend && npm install`
Expected: lockfile updates, `node_modules/.bin/vitest` exists.
- [ ] **Step 3: Create the Vitest config**
Create `frontend/vitest.config.js`:
```js
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js'],
include: ['src/**/*.test.{js,jsx}'],
},
})
```
- [ ] **Step 4: Create the test setup file**
Create `frontend/src/test/setup.js`:
```js
import '@testing-library/jest-dom'
```
- [ ] **Step 5: Write the failing unit test**
Create `frontend/src/lib/brand.test.js`:
```js
import { describe, it, expect } from 'vitest'
import { brandTitle } from './brand.js'
describe('brandTitle', () => {
it('returns the deployment name when set', () => {
expect(brandTitle('Wiggleverse')).toBe('Wiggleverse')
})
it('falls back to a neutral placeholder when name is empty', () => {
expect(brandTitle('')).toBe('RFC')
expect(brandTitle(undefined)).toBe('RFC')
})
})
```
- [ ] **Step 6: Run it to verify it fails**
Run: `cd frontend && npm run test:run -- src/lib/brand.test.js`
Expected: FAIL — `Failed to resolve import "./brand.js"` (module does not exist yet).
- [ ] **Step 7: Implement the minimal module**
Create `frontend/src/lib/brand.js`:
```js
export function brandTitle(name) {
const trimmed = (name || '').trim()
return trimmed || 'RFC'
}
```
- [ ] **Step 8: Run it to verify it passes**
Run: `cd frontend && npm run test:run -- src/lib/brand.test.js`
Expected: PASS — 2 tests pass.
- [ ] **Step 9: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/vitest.config.js frontend/src/test/setup.js frontend/src/lib/brand.js frontend/src/lib/brand.test.js
git commit -m "test(frontend): add Vitest unit-test harness with first brand helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Backend Docker image
**Files:**
- Create: `testing/backend.Dockerfile`
- Create: `testing/.env.tier1`
- [ ] **Step 1: Write the backend Dockerfile**
Create `testing/backend.Dockerfile`:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt uvicorn
COPY backend/ /app/
RUN mkdir -p /data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Note: build context is the repo root (set in compose), so `COPY backend/...` resolves.
- [ ] **Step 2: Write the backend env file**
Create `testing/.env.tier1`:
```
GITEA_URL=http://gitea:3000
GITEA_BOT_USER=rfc-bot
GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER
GITEA_ORG=wiggleverse
META_REPO=ohm-content
REGISTRY_REPO=
OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER
OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER
APP_URL=http://localhost:8080
SECRET_KEY=tier1-not-secret
DATABASE_PATH=/data/rfc-app.db
OWNER_GITEA_LOGIN=owner
GITEA_WEBHOOK_SECRET=tier1-webhook-secret
ENABLED_MODELS=claude
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_STARTTLS=false
EMAIL_FROM=rfc@example.test
EMAIL_FROM_NAME=RFC Tier1
EMAIL_ENABLED=true
TURNSTILE_REQUIRED=false
```
The `*-PLACEHOLDER` token/oauth values are overwritten at runtime by the seed step (Task 4) which writes the real values into `testing/.env.tier1.generated`; compose loads both files (Task 5), generated last so it wins. Leaving the placeholders here documents the full contract and lets the backend start to fail loudly if seeding was skipped.
- [ ] **Step 3: Verify the image builds**
Run: `docker build -f testing/backend.Dockerfile -t rfc-backend:tier1 .`
Expected: image builds, no errors.
- [ ] **Step 4: Commit**
```bash
git add testing/backend.Dockerfile testing/.env.tier1
git commit -m "test(tier1): backend Docker image + env contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Web (nginx + built SPA) Docker image
**Files:**
- Create: `testing/web.Dockerfile`
- Create: `testing/web.nginx.conf`
- [ ] **Step 1: Write the nginx config (adapted from prod, TLS stripped)**
Create `testing/web.nginx.conf`:
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location /auth/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
- [ ] **Step 2: Write the web Dockerfile (multi-stage build → nginx)**
Create `testing/web.Dockerfile`:
```dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json /app/
RUN npm ci
COPY frontend/ /app/
ENV VITE_APP_NAME="RFC Tier1"
RUN npm run build
FROM nginx:1.27-alpine
COPY testing/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
```
Note: `VITE_APP_NAME` is still build-required until M3c does the hard cut (`frontend/vite.config.js` throws without it). Supplying a test value keeps the build green now; M3c removes this line.
- [ ] **Step 3: Verify the image builds**
Run: `docker build -f testing/web.Dockerfile -t rfc-web:tier1 .`
Expected: build succeeds; the SPA compiles with the test brand.
- [ ] **Step 4: Commit**
```bash
git add testing/web.Dockerfile testing/web.nginx.conf
git commit -m "test(tier1): nginx web image serving the built SPA
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: Gitea seed script
**Files:**
- Create: `testing/seed-gitea.sh`
This script runs inside a small `curl`+`git`-capable container (the `gitea-seed` service, Task 5). It assumes Gitea is reachable at `http://gitea:3000` with install-lock on and a known admin password from env. It is idempotent: every create tolerates "already exists".
- [ ] **Step 1: Write the seed script**
Create `testing/seed-gitea.sh`:
```bash
#!/usr/bin/env sh
set -eu
GITEA="${GITEA_URL:-http://gitea:3000}"
ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}"
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}"
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.test}"
ORG="${GITEA_ORG:-wiggleverse}"
BOT_USER="${GITEA_BOT_USER:-rfc-bot}"
BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}"
CONTENT_REPO="${META_REPO:-ohm-content}"
APP_URL="${APP_URL:-http://localhost:8080}"
WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}"
OUT="${SEED_OUT:-/seed/.env.tier1.generated}"
echo "seed: waiting for gitea at $GITEA"
i=0
while ! curl -sf "$GITEA/api/healthz" >/dev/null 2>&1; do
i=$((i+1)); [ "$i" -gt 60 ] && echo "gitea never came up" && exit 1
sleep 2
done
auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; }
echo "seed: ensuring bot user"
auth_admin -X POST "$GITEA/api/v1/admin/users" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$BOT_USER\",\"email\":\"$BOT_USER@example.test\",\"password\":\"$BOT_PASS\",\"must_change_password\":false}" \
|| echo "seed: bot user exists, continuing"
echo "seed: ensuring owner user (for OWNER_GITEA_LOGIN)"
auth_admin -X POST "$GITEA/api/v1/admin/users" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \
|| echo "seed: owner exists, continuing"
echo "seed: minting bot access token"
TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \
-H 'Content-Type: application/json' \
-d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')
[ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; }
echo "seed: ensuring org $ORG (owned by bot)"
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$ORG\"}" || echo "seed: org exists, continuing"
echo "seed: ensuring content repo $ORG/$CONTENT_REPO"
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs/$ORG/repos" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$CONTENT_REPO\",\"auto_init\":true,\"default_branch\":\"main\"}" \
|| echo "seed: content repo exists, continuing"
echo "seed: seeding one entry under rfcs/ so the catalog is non-empty"
B64=$(printf '%s' '---
title: Intro
status: graduated
id: RFC-0001
owners: [owner]
---
# Intro
Seed entry for Tier-1 e2e.
' | base64 | tr -d '\n')
curl -s -H "Authorization: token $TOKEN" -X POST \
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/contents/rfcs/intro.md" \
-H 'Content-Type: application/json' \
-d "{\"message\":\"seed intro\",\"content\":\"$B64\",\"branch\":\"main\"}" \
|| echo "seed: intro.md exists, continuing"
echo "seed: registering OAuth application"
OAUTH_JSON=$(curl -sf -u "$ADMIN_USER:$ADMIN_PASS" -X POST "$GITEA/api/v1/user/applications/oauth2" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}")
CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')
CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')
echo "seed: registering webhook on content repo -> backend"
curl -s -H "Authorization: token $TOKEN" -X POST \
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/hooks" \
-H 'Content-Type: application/json' \
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" \
|| echo "seed: webhook exists, continuing"
echo "seed: writing generated env to $OUT"
cat > "$OUT" <<EOF
GITEA_BOT_TOKEN=$TOKEN
OAUTH_CLIENT_ID=$CLIENT_ID
OAUTH_CLIENT_SECRET=$CLIENT_SECRET
EOF
echo "seed: done"
```
- [ ] **Step 2: Make it executable**
Run: `chmod +x testing/seed-gitea.sh`
- [ ] **Step 3: Commit**
```bash
git add testing/seed-gitea.sh
git commit -m "test(tier1): idempotent Gitea seed script (bot token, org, content repo, OAuth app, webhook)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
> Verification of this script happens in Task 5 against the real Gitea image. The Gitea admin user is created by the gitea service's own init env (Task 5), so the script can authenticate as admin from its first call. If a Gitea-version API mismatch appears (e.g. the token `scopes` vocabulary, or the OAuth-app endpoint path), fix it against the pinned image `gitea/gitea:1.22` and keep the script idempotent.
---
## Task 5: Compose the stack and bring it up
**Files:**
- Create: `testing/docker-compose.yml`
- Create/modify: `Makefile`
- [ ] **Step 1: Write the compose file**
Create `testing/docker-compose.yml`:
```yaml
name: rfc-tier1
services:
gitea:
image: gitea/gitea:1.22
environment:
GITEA__security__INSTALL_LOCK: "true"
GITEA__server__ROOT_URL: "http://gitea:3000/"
GITEA__server__HTTP_PORT: "3000"
GITEA__database__DB_TYPE: "sqlite3"
GITEA__webhook__ALLOWED_HOST_LIST: "*"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/healthz"]
interval: 5s
timeout: 3s
retries: 30
ports:
- "3001:3000"
gitea-admin-init:
image: gitea/gitea:1.22
depends_on:
gitea:
condition: service_healthy
volumes_from:
- gitea
entrypoint: ["/bin/sh", "-c"]
command:
- >
gitea admin user create --admin --username giteaadmin
--password giteaadmin-pass --email admin@example.test
--must-change-password=false || true
restart: "no"
gitea-seed:
image: alpine:3.20
depends_on:
gitea-admin-init:
condition: service_completed_successfully
env_file:
- .env.tier1
environment:
GITEA_ADMIN_USER: giteaadmin
GITEA_ADMIN_PASSWORD: giteaadmin-pass
GITEA_BOT_PASSWORD: rfc-bot-pass
SEED_OUT: /seed/.env.tier1.generated
volumes:
- ./seed-gitea.sh:/seed-gitea.sh:ro
- ./generated:/seed
entrypoint: ["/bin/sh", "-c"]
command:
- apk add --no-cache curl >/dev/null && sh /seed-gitea.sh
restart: "no"
backend:
build:
context: ..
dockerfile: testing/backend.Dockerfile
depends_on:
gitea-seed:
condition: service_completed_successfully
env_file:
- .env.tier1
- ./generated/.env.tier1.generated
volumes:
- backend-data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health').status==200 else 1)"]
interval: 5s
timeout: 3s
retries: 30
web:
build:
context: ..
dockerfile: testing/web.Dockerfile
depends_on:
backend:
condition: service_healthy
ports:
- "8080:80"
mailpit:
image: axllent/mailpit:latest
ports:
- "8025:8025"
- "1025:1025"
volumes:
backend-data:
```
Notes: the backend reads `.env.tier1` then `./generated/.env.tier1.generated` (seed-written), so the real bot token / OAuth client overwrite the placeholders. `APP_URL=http://localhost:8080` matches the `web` published port and the OAuth redirect URI the seed registers. Mailpit API is on `8025`, SMTP on `1025`.
- [ ] **Step 2: Create the generated dir placeholder**
Run: `mkdir -p testing/generated && touch testing/generated/.gitkeep`
Create `testing/generated/.gitignore`:
```
.env.tier1.generated
```
- [ ] **Step 3: Add Makefile targets**
Create (or append to) `Makefile` at repo root:
```makefile
tier1-up:
docker compose -f testing/docker-compose.yml up --build -d
tier1-down:
docker compose -f testing/docker-compose.yml down -v
tier1-logs:
docker compose -f testing/docker-compose.yml logs -f
fe-unit:
cd frontend && npm run test:run
e2e:
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
```
(Use real tabs for Makefile recipes, not spaces.)
- [ ] **Step 4: Bring the stack up**
Run: `make tier1-up`
Expected: gitea → admin-init → seed → backend (healthy) → web come up in order. `docker compose -f testing/docker-compose.yml ps` shows backend healthy.
- [ ] **Step 5: Verify the app is reachable through nginx**
Run: `curl -sf http://localhost:8080/api/health`
Expected: HTTP 200 with the version JSON (proves web→backend proxy + migrations-on-startup worked).
Run: `curl -sf http://localhost:8080/ | grep -i "<title"`
Expected: the SPA `index.html` is served (title present).
- [ ] **Step 6: Verify seeding produced real credentials**
Run: `cat testing/generated/.env.tier1.generated`
Expected: non-placeholder `GITEA_BOT_TOKEN=`, `OAUTH_CLIENT_ID=`, `OAUTH_CLIENT_SECRET=` lines.
- [ ] **Step 7: Tear down and commit**
Run: `make tier1-down`
```bash
git add testing/docker-compose.yml testing/generated/.gitignore Makefile
git commit -m "test(tier1): docker compose stack (gitea seed + backend + web + mailpit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: Playwright harness + mail-sink helper
**Files:**
- Create: `e2e/package.json`
- Create: `e2e/playwright.config.js`
- Create: `e2e/lib/mailpit.js`
- [ ] **Step 1: Create the e2e package**
Create `e2e/package.json`:
```json
{
"name": "rfc-e2e",
"private": true,
"type": "module",
"scripts": {
"e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}
```
- [ ] **Step 2: Install Playwright + its browser**
Run: `cd e2e && npm install && npx playwright install chromium`
Expected: `@playwright/test` installed; chromium downloaded.
- [ ] **Step 3: Write the Playwright config**
Create `e2e/playwright.config.js`:
```js
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: '.',
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: process.env.BASE_URL || 'http://localhost:8080',
trace: 'on-first-retry',
},
reporter: [['list']],
})
```
- [ ] **Step 4: Write the Mailpit helper**
Create `e2e/lib/mailpit.js`:
```js
const MAILSINK = process.env.MAILSINK_URL || 'http://localhost:8025'
export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 } = {}) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(`${MAILSINK}/api/v1/messages`)
if (res.ok) {
const data = await res.json()
const msg = (data.messages || []).find(
(m) => (m.To || []).some((t) => t.Address === toAddress),
)
if (msg) {
const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`)
const body = await full.json()
const text = `${body.Text || ''} ${body.HTML || ''}`
const code = text.match(/\b(\d{6})\b/)
if (code) return code[1]
}
}
await new Promise((r) => setTimeout(r, delayMs))
}
throw new Error(`no OTC email for ${toAddress} arrived in Mailpit`)
}
export async function clearMailpit() {
await fetch(`${MAILSINK}/api/v1/messages`, { method: 'DELETE' })
}
```
Note: the `\d{6}` pattern assumes the OTC code is a 6-digit number. Confirm against `app/otc.py` / the OTC email template; adjust the regex if the real code shape differs (this is the one detail to verify when the spec first runs).
- [ ] **Step 5: Commit**
```bash
git add e2e/package.json e2e/package-lock.json e2e/playwright.config.js e2e/lib/mailpit.js
git commit -m "test(e2e): Playwright harness parameterized by BASE_URL + Mailpit mail sink
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: The smoke e2e spec (OTC login → landing renders)
**Files:**
- Create: `e2e/smoke.spec.js`
- [ ] **Step 1: Write the smoke spec**
Create `e2e/smoke.spec.js`:
```js
import { test, expect } from '@playwright/test'
import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js'
const EMAIL = 'e2e-user@example.test'
test('app loads and an OTC sign-in succeeds', async ({ page, request }) => {
await clearMailpit()
await page.goto('/')
await expect(page).toHaveTitle(/.+/)
const reqRes = await request.post('/auth/otc/request', {
data: { email: EMAIL },
})
expect(reqRes.ok()).toBeTruthy()
const code = await waitForLatestOtc(EMAIL)
const verifyRes = await request.post('/auth/otc/verify', {
data: { email: EMAIL, code },
})
expect(verifyRes.ok()).toBeTruthy()
})
```
Note: payload field names (`email`, `code`) must match `app/main.py`'s `/auth/otc/request` and `/auth/otc/verify` request models. Read those two handlers (around `app/main.py:259` and `:296`) and align field names before running. If OTC sign-in requires the account to be pre-provisioned or "granted", seed that state in the spec's setup (an admin call) or document the precondition; the e2e must end with an authenticated session cookie set on `page`'s context.
- [ ] **Step 2: Bring the stack up**
Run: `make tier1-up`
Wait until `curl -sf http://localhost:8080/api/health` returns 200.
- [ ] **Step 3: Run the smoke spec — verify it passes**
Run: `make e2e`
Expected: 1 passed. (If it fails on field names / OTC code shape / provisioning, fix per the notes in Step 1 and `e2e/lib/mailpit.js`, then re-run.)
- [ ] **Step 4: Tear down**
Run: `make tier1-down`
- [ ] **Step 5: Commit**
```bash
git add e2e/smoke.spec.js
git commit -m "test(e2e): smoke spec — app loads and OTC sign-in succeeds via Mailpit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: Documentation — running the tiers
**Files:**
- Create: `testing/README.md`
- [ ] **Step 1: Write the harness README**
Create `testing/README.md`:
```markdown
# Test harness (handbook §10.3 two-tier testing)
One environment-agnostic suite, two targets.
## Tier 1 — local Docker (every PR)
```sh
make tier1-up # build + start: gitea(seeded) + backend + web(nginx) + mailpit
make e2e # run Playwright against http://localhost:8080
make fe-unit # run Vitest frontend unit tests
make tier1-down # stop + wipe volumes
```
- App (SPA + API): http://localhost:8080
- Mailpit UI / API: http://localhost:8025
- Gitea (disposable): http://localhost:3001
The stack is hermetic and disposable — fresh SQLite + fresh seeded Gitea each
`tier1-up`. e2e signs in via the email OTC flow, reading the code back from
Mailpit, so no real OAuth provider is needed.
## Tier 2 — PPE (deploy gate)
The SAME suite, pointed at the PPE instance (once `rfc-app-ppe.<base>` is stood
up via flotilla — see the engineering handbook §10.1/§10.3):
```sh
cd e2e && BASE_URL=https://rfc-app-ppe.<base> MAILSINK_URL=<ppe-mailpit-api> npm run e2e
```
PPE provides the real nginx/systemd/SQLite topology + its own isolated Gitea +
always-pass Turnstile keys. Standing up the PPE VM is an operator task, not part
of this repo.
```
- [ ] **Step 2: Commit**
```bash
git add testing/README.md
git commit -m "docs(testing): how to run Tier-1 local Docker and Tier-2 PPE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Final verification
- [ ] **Frontend unit:** `make fe-unit` → all pass.
- [ ] **Stack health:** `make tier1-up` then `curl -sf http://localhost:8080/api/health` → 200.
- [ ] **E2e:** `make e2e` → smoke spec passes.
- [ ] **Disposability:** `make tier1-down && make tier1-up` → second bring-up is green from scratch (seed is idempotent / fresh-volume clean).
- [ ] **Teardown:** `make tier1-down` leaves no running containers (`docker ps` clean).
When all five pass, M3-0 is complete and every later M3 sub-plan (M3aM3d) can add unit/integration/functional tests under `backend/tests/` and e2e specs under `e2e/`, runnable on localhost now and against PPE by setting `BASE_URL`.
---
## Notes for the executor
- **Verify-against-reality points** (flagged inline, not placeholders): the Gitea `1.22` API specifics in `seed-gitea.sh` (token scopes vocabulary, OAuth-app endpoint), the OTC request/verify field names in `app/main.py`, the OTC code regex in `mailpit.js`, and whether OTC sign-in needs a pre-granted account. Each has a concrete first guess and a one-line "confirm against X" instruction.
- **Stay off `main`.** Branch `feat/m3-0-test-foundation`.
- **Do not** modify backend app logic in this plan — only `testing/` assets, `frontend/` test tooling, and `e2e/`. The one app-adjacent file is `testing/backend.Dockerfile`, which only packages existing code.
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,413 @@
# M3-backend — §22 multi-project: registry mirror + data spine + APIs
> Design spec for the backend half of §22 slice **M3** ("Registry mirror +
> routing + runtime branding"). The roadmap bundles M3 as one slice; this
> session splits it at the natural backend/frontend seam. **M3-backend** (this
> doc) ships the data spine, the registry mirror, the two runtime-config APIs,
> and the entry-state/review semantics. **M3-frontend** (a separate spec) ships
> `/p/<project>/` routing, the 308 redirects, the `VITE_APP_NAME`→runtime-config
> cut, the per-project theme overlay, the deployment directory at `/`, and the
> project switcher — all consuming the APIs defined here.
>
> Section references `§22.x` point at `docs/design/multi-project-spec.md` (the
> draft §22 + slicing plan). The SPEC.md §22 merge itself lands in M7.
## Status
- **Date:** 2026-06-03
- **Slice:** §22 M3 (backend half). M1 + M2 landed and merged to `main`.
- **Version impact:** minor bump, breaking (pre-1.0) — see §8.
## Goal
After M3-backend, the framework learns its projects from a git **registry**
(not from `META_REPO`), the `projects` cache table and all slug-bearing tables
are keyed by `(project_id, …)` so a second project can exist without collision,
the default project's identity is re-stamped to its real slug while no `/p/`
URL is yet public, and the runtime exposes deployment + project config over two
new endpoints. The entry-state/review semantics (`initial_state`, `unreviewed`)
ship complete even though OHM (a `document`/`super-draft` project) does not yet
exercise them.
Non-goals (M3-frontend, later): `/p/<project>/` routing, 308 redirects off
`/rfc/<slug>`, runtime branding in the UI, theme application, the deployment
directory, the project switcher, the catalog's unreviewed-filter **UI**.
## Decisions taken in brainstorming
1. **Scope split** — backend spine first; frontend surfaces are a separate
spec/session.
2. **Review machinery** — build the full `initial_state` / `unreviewed`
plumbing now (parse + columns + mirror + landing logic + mark-reviewed +
catalog filter query side), per the spec's M3 bundle, even though no live
project exercises it yet.
3. **Config cut** — hard cut. `REGISTRY_REPO` required (loud fail if unset),
`META_REPO` removed. Upgrade-steps document the manual registry creation.
4. **Re-stamp** — rewrite `project_id` everywhere: rename `projects.id`
`default` → the config slug and rewrite every child row, folded into the
same create-copy-drop-rename rebuild that adds the `project_id` FK. One
identifier; DB and URL agree.
5. **Mirror structure** — a self-contained `app/registry.py` module (not folded
into `cache.py`), driven by the existing webhook dispatcher + the existing
`Reconciler.sweep()`.
6. **Two execution plans (found during planning).** The 12-table PK rebuild is
not self-contained: folding `project_id` into keys + FK forces every
`ON CONFLICT` upsert target to gain `project_id` (~10 sites across 6 modules)
and — because the rebuilt tables can no longer default `project_id` to a live
value once the default is re-stamped — forces **every RFC/branch writer** to
be threaded to supply the real `project_id`. That activation is larger and
riskier than the rest of M3-backend combined, and it is only required *before
a second project can collide* (i.e. right before M4). So M3-backend is split
into two plans at that seam:
- **Plan A (ships first):** registry mirror + the two APIs + `initial_state`/
`unreviewed` semantics. Migration `027` is **additive only** (no rebuilds).
Operates entirely on the `default`-id project — no re-stamp, no rebuild, no
writer threading. `cached_rfcs` keeps its `slug` PK, so no upsert breakage.
- **Plan B (before M4 / before public `/p/` URLs):** the 12-table PK rebuild
(migration `028`), `project_id` threading through every writer, the
`default`→slug **re-stamp** (which rides here because it is only correct
once the rebuild's column-default fix + threading land), and two-project
isolation tests.
The §1 sections below describe the **full** backend (both plans); §1c (the
rebuilds) and §1d (the re-stamp) are **Plan B**. Everything else is Plan A.
---
## 1. Migration `027_projects_activate.sql`
Runs while `default` is still the sole project and no `/p/` URL exists — the
safe window for an identity rewrite. One transaction (SQLite DDL is
transactional): the deployment either fully advances to `027` or stays on `026`.
`DEFAULT_PROJECT_ID` is read at migration time, so it **must be set before the
upgrade deploy** (documented in §8). The slug it names is referred to below as
`<slug>`; absent the env var, `<slug>` stays `default`.
### 1a. Additive columns
- `projects`:
- `type TEXT NOT NULL DEFAULT 'document' CHECK (type IN ('document','specification','bdd'))`
- `initial_state TEXT NOT NULL DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active'))`
- `cached_rfcs`:
- `unreviewed INTEGER NOT NULL DEFAULT 0`
- `reviewed_at TEXT`
- `reviewed_by TEXT`
### 1b. New `deployment` singleton table
Holds deployment-level identity mirrored from the registry's `deployment:`
block (rather than overloading `projects`):
```sql
CREATE TABLE IF NOT EXISTS deployment (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
tagline TEXT,
registry_sha TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO deployment (id) VALUES (1);
```
> **Re-stamp split (correctness, found during planning).** The migration
> runner executes pure-SQL files and **cannot read `DEFAULT_PROJECT_ID` from the
> environment** — the same constraint that forced M1's `seed_default_project`
> into Python. So the re-stamp is **not** in the `.sql` file. Migration `027`
> (pure SQL) does §1a–§1c with `project_id` copied **verbatim** (`default` stays
> `default`); the rebuilt FKs are declared `ON UPDATE CASCADE ON DELETE
> CASCADE`. The re-stamp (§1d) is a **Python startup step** in `app/projects.py`,
> run after migrations and before the registry mirror, that issues a single
> `UPDATE projects SET id = <slug>` (cascading to the 12 FK tables) plus a plain
> `UPDATE` of the 7 non-FK `project_id` tables. Idempotent: a no-op once no
> `default` row remains.
### 1c. PK / uniqueness rebuilds (the 12 deferred tables)
Per migration 026's deferred block, create-copy-drop-rename each table to fold
`project_id` into the key and add `project_id … REFERENCES projects(id) ON
UPDATE CASCADE ON DELETE CASCADE` (the `ON UPDATE CASCADE` is what lets the
§1d Python re-stamp move all child rows with one parent UPDATE):
| Table | Key change |
| --- | --- |
| `cached_rfcs` | PK `(slug)``(project_id, slug)` |
| `cached_branches` | UNIQUE `(rfc_slug, branch_name)``+project_id` |
| `branch_visibility` | UNIQUE `(rfc_slug, branch_name)``+project_id` |
| `branch_contribute_grants` | UNIQUE `(rfc_slug, branch_name, grantee_user_id)``+project_id` |
| `stars` | UNIQUE `(user_id, rfc_slug)``+project_id` |
| `watches` | UNIQUE `(user_id, rfc_slug)``+project_id` |
| `pr_seen` | UNIQUE `(user_id, rfc_slug, pr_number)``+project_id` |
| `branch_chat_seen` | UNIQUE `(user_id, rfc_slug, branch_name)``+project_id` |
| `funder_consents` | PK `(user_id, rfc_slug)``+project_id` |
| `rfc_collaborators` | UNIQUE INDEX `(rfc_slug, user_id)``+project_id` |
| `contribution_requests` | UNIQUE INDEX `(rfc_slug, requester_user_id) WHERE pending``+project_id` |
| `proposed_use_cases` | UNIQUE `(scope, pr_number)``+project_id` |
`cached_prs` is already globally unique (`repo` is the full `org/repo` string,
distinct per project) — **no rebuild**.
The copy step writes `<slug>` in place of `default` for `project_id`, so these
12 tables are re-stamped for free (§1d).
### 1d. Re-stamp `default``<slug>` (Python startup step)
In `app/projects.py`, run at startup after `db.init` and before
`refresh_registry`. When `DEFAULT_PROJECT_ID` is set and a `default` project row
still exists, in one `db.tx()`:
- `UPDATE projects SET id = <slug>, updated_at = datetime('now') WHERE id =
'default'` — cascades `project_id` across the 12 FK tables via `ON UPDATE
CASCADE`.
- For the 7 `project_id`-bearing tables with **no** FK (`threads`, `changes`,
`notifications`, `actions`, `pr_resolution_branches`, `rfc_invitations`,
`cached_prs`): `UPDATE <t> SET project_id = <slug> WHERE project_id =
'default'`.
End state: a single `project_id` value DB-wide. Idempotent — a no-op once no
`default` row remains, so it is safe on every boot.
### 1e. Notes
- Migration `016` is absent from the on-disk sequence (`015 → 017`); the runner
already tolerates the gap (the app runs today). **Do not renumber.** New file
is `027`.
- `PRAGMA foreign_keys` is honored going forward; the FK lands on the 12 rebuilt
tables. The other 7 keep app-layer integrity (matching today's posture for
non-rebuilt tables).
---
## 2. Registry format + `app/registry.py`
### 2a. `projects.yaml` (root of `REGISTRY_REPO`)
```yaml
deployment:
name: Open Human Model # replaces VITE_APP_NAME (M3-frontend consumes)
tagline: ...
projects:
- id: ohm # url-stable slug, unique in the deployment
name: Open Human Model
type: document # document | specification | bdd — immutable
content_repo: ohm # repo under the deployment's Gitea org
visibility: public # gated | public | unlisted
initial_state: super-draft # optional; defaults from type
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional; M3-frontend consumes
```
### 2b. `refresh_registry(config, gitea) -> RegistryResult`
The config-side analogue of `cache.refresh_meta_repo`. Fetches `projects.yaml`
from `REGISTRY_REPO` at HEAD, parses, **validates**, and upserts:
- Each project → `projects` row: `id, name, type, content_repo, visibility,
initial_state`, plus `config_json` (JSON blob for `theme`, `enabled_models`),
plus `registry_sha` (commit SHA, provenance).
- The `deployment:` block → the `deployment` singleton (`name`, `tagline`,
`registry_sha`).
Projects present in the table but absent from the registry are **not** deleted
in M3-backend (archival semantics are out of scope; a removed entry simply
stops being refreshed). This is noted as a known limitation; revisit if/when
project archival is specced.
### 2c. Validation (loud)
Per the framework's separation-of-concerns rule, malformed config fails
visibly rather than shipping wrong content silently:
- Each project requires `id`, `name`, `type`, `content_repo`.
- `type` ∈ {`document`,`specification`,`bdd`}; `visibility`
{`gated`,`public`,`unlisted`}.
- `initial_state` defaults from `type` when omitted: `document`/`specification`
`super-draft`, `bdd``active`. When present it must be a valid §2.4
super-draft entry-state value.
- `id` values unique and slug-shaped (`^[a-z0-9][a-z0-9-]*$`).
- **`type` is immutable:** an incoming `type` differing from the stored row's is
rejected (the entry is skipped, the rest proceed; logged loudly).
- **Default-id consistency:** the re-stamped default id (`DEFAULT_PROJECT_ID`,
else `default`) MUST appear as an `id` in the registry, or the registry is
inconsistent with config → surfaced loudly.
### 2d. Wiring (Option A)
- **Webhook** (`app/webhooks.py`): add a branch — if the pushed repo
`full_name` matches `REGISTRY_REPO`, call `registry.refresh_registry(...)`.
Same HMAC-verified `/api/webhooks/gitea` dispatcher; no new endpoint. Add
`REGISTRY_REPO` to the set of repos the dispatcher recognizes.
- **Sweep** (`cache.Reconciler.sweep()`): add one `await
registry.refresh_registry(...)` at the top of each pass, so the safety-net
loop keeps `projects` in sync if a webhook is missed.
- **Startup** (`main.py` lifespan): run `refresh_registry` once after
migrations. This **replaces** M1's `seed_default_project` (which is removed).
### 2e. Failure posture
- **Startup / first boot:** if `REGISTRY_REPO` is unset/unreachable, or
`projects.yaml` is missing or fails validation, the app **fails loudly**
(refuses to start) — there is no last-known-good to serve.
- **Running deployment:** a malformed `projects.yaml` pushed in a later PR is
logged and **skipped**, leaving the last-good `projects` rows intact — a bad
config PR must not take the deployment down. This mirrors how the corpus
reconciler tolerates a bad content push today.
---
## 3. Config (`app/config.py`)
- `registry_repo`: **required** — construction fails loudly if unset (matching
the other required vars). Add `registry_repo_full``{gitea_org}/{registry_repo}`.
- `meta_repo` and `meta_repo_full`: **removed**.
- `default_project_id`: optional; consumed by migration `027` (re-stamp) and by
`refresh_registry` (the §2c consistency gate).
- `enabled_models`: unchanged — the deployment-level fallback for a project's
optional `enabled_models`.
- `app/projects.py`: `seed_default_project` retired (superseded by the mirror).
`DEFAULT_PROJECT_ID` constant and resolution helpers retained as needed.
---
## 4. APIs — `app/api_deployment.py`
A new sub-router mounted in `app/api.py`.
### `GET /api/deployment`
Returns `{ name, tagline, projects: [...] }`. The deployment `name`/`tagline`
come from the `deployment` singleton. The project list is filtered by caller
visibility (§22.5):
- `public` projects → visible to everyone (incl. anonymous).
- `gated` projects → only when the caller is a member (`visible_project_ids`
from M2's resolver).
- `unlisted` projects → **omitted entirely** (reachable only by direct id).
Each item: `{ id, name, type, visibility }` — enough for the M3-frontend
directory + switcher. (Theme is fetched per project.)
### `GET /api/projects/:id`
Returns `{ id, name, tagline, type, visibility, initial_state, theme }`.
Guarded by `require_project_readable(user, id)` — 404 for a non-member of a
gated project, reusing the M2 resolver. `unlisted` is readable here by direct
id (it is hidden only from enumeration).
---
## 5. Entry-state & review semantics
### 5a. Frontmatter (`app/entry.py`)
Parse three new fields, lenient (default `unreviewed=false`, nulls):
`unreviewed: bool`, `reviewed_at`, `reviewed_by`. Add to the `Entry`
dataclass. These are git-truth (§2 frontmatter) so they survive a cache
rebuild, exactly like `state`.
### 5b. Cache mirror (`app/cache.py`)
`_upsert_cached_rfc` writes the three fields into `cached_rfcs`
(`unreviewed`, `reviewed_at`, `reviewed_by`).
### 5c. Entry-landing path
When a creating idea-PR merges (§2.4), resolve the project's `initial_state`:
- `super-draft` → today's behavior unchanged (propose → super-draft → graduate).
- `active` → land the entry `active` with `unreviewed = true`, skipping the §13
graduate gate. The exact merge-handling site (in the PR-merge reconcile path)
is located during implementation.
### 5d. Mark-reviewed
`POST /api/projects/:pid/rfcs/:slug/mark-reviewed`. Authority:
`is_project_superuser` (project_admin or deployment owner/admin) — the same tier
that graduates an entry. Effect: the bot writes `unreviewed: false` +
`reviewed_at`/`reviewed_by` into the entry frontmatter (a git commit, paralleling
graduate), which the mirror then reflects into `cached_rfcs`. Stamps provenance
paralleling `graduated_at`/`graduated_by`.
### 5e. Catalog filter (query side)
`GET /api/rfcs` (and the project-scoped form) gains an `unreviewed=true` query
param that filters on the cached column — the owner's worklist. The UI for it
is M3-frontend; only the query side ships here. `unreviewed` applies to
`active` entries only.
---
## 6. Testing
- **Migration `027`:** seed a `026`-shaped DB with rows under
`project_id='default'`; run `027`; assert: new columns present; the 12 tables
rebuilt with composite keys + FK; all 19 `project_id`-bearing tables
re-stamped to `<slug>`; row counts preserved; FK integrity on; idempotent
re-run is a no-op.
- **`registry.py`:** valid `projects.yaml` upserts all fields + `registry_sha`;
each validation failure rejected (bad enum, missing field, dup id, `type`
mutation, missing default id); `initial_state` type-default applied;
startup-strict vs running-tolerant posture.
- **APIs:** `/api/deployment` visibility filtering across
gated/public/unlisted × member/non-member/anonymous; `/api/projects/:id` 404
gate for gated non-member, 200 for unlisted-by-id.
- **Review flow:** `initial_state: active` lands `unreviewed=true`;
mark-reviewed authority (allow superuser, deny others) + frontmatter write +
mirror reflection; catalog `unreviewed` filter returns the worklist.
- **Two-project isolation:** extend `test_multi_project_authz_vertical.py` with
a genuine **second** registry project sharing a slug with the first, proving
the PK rebuilds isolate them (the core point of M3's activation).
---
## 7. File-touch summary
**New**
- `backend/migrations/027_projects_activate.sql`
- `backend/app/registry.py`
- `backend/app/api_deployment.py`
- tests: `backend/tests/test_migration_027.py`, `test_registry.py`,
`test_api_deployment.py`, `test_review_flow.py`; extend
`test_multi_project_authz_vertical.py`
**Modified**
- `backend/app/config.py` (registry_repo required; meta_repo removed;
default_project_id)
- `backend/app/webhooks.py` (registry-repo branch)
- `backend/app/cache.py` (`Reconciler.sweep` registry call;
`_upsert_cached_rfc` review fields)
- `backend/app/entry.py` (frontmatter fields)
- `backend/app/api.py` (mount `api_deployment`; `unreviewed` filter on
`/api/rfcs`)
- `backend/app/main.py` (startup `refresh_registry`; drop `seed_default_project`)
- `backend/app/projects.py` (retire `seed_default_project`)
- `backend/.env.example` (`REGISTRY_REPO`, `DEFAULT_PROJECT_ID`; remove
`META_REPO`)
---
## 8. Versioning & upgrade
Minor bump, breaking (pre-1.0). `VERSION` + `frontend/package.json#version` move
together (§20). `CHANGELOG.md` gets a breaking entry with an **upgrade-steps**
block:
1. Create a registry repo under the deployment's Gitea org.
2. Author `projects.yaml`: a `deployment:` block (`name`, `tagline`) and one
`projects:` entry for the existing corpus — `id: <slug>`, `name`, `type:
document`, `content_repo: <old META_REPO value>`, `visibility: public`.
3. Set env: `REGISTRY_REPO=<registry repo name>`, `DEFAULT_PROJECT_ID=<slug>`
(must equal the entry's `id`); remove `META_REPO`.
4. Deploy. Migration `027` runs the rebuilds + re-stamp; `refresh_registry`
reconciles the registry into `projects`. Verify `/api/deployment` returns the
project and `/api/health` is green.
The SPEC.md §22 merge stays in M7 per the slicing plan; this slice references the
draft at `docs/design/multi-project-spec.md`.
## Known limitations / deferred
- **Project archival/deletion** from the registry is not handled (a removed
entry stops refreshing but its rows persist). Defer to a future archival spec.
- All routing, redirects, runtime branding, theme application, directory, and
switcher are **M3-frontend**.
@@ -0,0 +1,147 @@
# M3 — Registry mirror + routing + runtime branding — design
**Date:** 2026-06-03
**Spec basis:** `docs/design/multi-project-spec.md` §22, Part C slice **M3**
**Status:** design, pending plan
## Goal
Carry §22 slice **M3** end-to-end: turn the framework from single-project
(the N=1 default at the legacy corpus root) into genuinely multi-project —
projects declared in a registry, reachable at `/p/<project>/`, branded at
runtime. M3 is the slice that makes a *second, named* project reachable by
URL at all.
**Driving objective:** stand up the deployment's `bdd` "planner" project,
reachable at `https://rfc.wiggleverse.org/p/bdd/` as an **`unlisted`**
project (readable by direct link; not in the directory). After **M3c** the
planner is navigable; after **M3d** its entries land `active` (the behavior a
planner wants). The planner's `bdd`-specific scenario/coverage *surface* is
**M5**, out of scope here.
> Note on the original URL ask `/bdd/{PLANNER_TOKEN}/`: resolved to the
> existing §22 model — `bdd` is an `unlisted` project at `/p/bdd/`; the
> "token" is just the shareable link (the unguessable slug). No token-gate
> feature is built; `unlisted` visibility already exists (gated in M2).
## Decisions
### Already resolved upstream (commit `ad2ece1`, §22.13/§22.10/§22.4)
1. **Default project `id`** = config-derived slug
(`DEFAULT_PROJECT_ID` > slug(deployment name) > `default`). M1's `default`
bootstrap id is **re-stamped** to it in M3 before any `/p/` URL is public.
2. **Entry segment** = generic `/p/<project>/e/<slug>` for every type; the
noun (RFC/Spec/Feature) is a type-driven UI label, not in the path.
3. **Legacy `RFC-NNNN`** = frozen, read-only display label in frontmatter;
never used for routing/lookup; never assigned to new entries.
### Resolved in this brainstorm
4. **Plan slicing** — M3 is delivered as a **foundation plan (M3-0) + four
sequential feature sub-plans (M3aM3d)**, each its own branch off `main`,
its own review checkpoint, its own merge. Lower risk for the live OHM
deployment than one long-running branch.
5. **Branding cutover****hard cut + loud failure.** Frontend reads the
deployment/project name only from `GET /api/deployment`; `VITE_APP_NAME`
is removed from the frontend; `REGISTRY_REPO` becomes **required** at
startup (per CLAUDE.md's loud-failure rule).
6. **Registry mirror mechanism** — follow the **existing §4.1 cache pattern**
(`Reconciler.sweep()` + webhook writer, reading via the Gitea API), not a
new mechanism (no local clone, no separate service).
7. **Test strategy** — adopt the handbook's **§10.3 two-tier testing**
standard. Build the **Tier-1 local-Docker suite first** as M3-0; each
feature sub-plan adds unit/integration/functional/e2e coverage on top.
Tier-2 (PPE) is a separate flotilla/Stage-2 task (below).
8. **Deployment stage** — OHM is **Stage 1** (pre-v1, single prod VM,
forward-only migrations). So M3a's table-rebuild migration is allowed as
**one forward-only migration**. The §10.2 expand/contract rule is recorded
as a **future Stage-2 obligation**, not a constraint on M3a today.
## Decomposition
Order is dependency-driven: **M3-0 → M3a → M3b → M3c → M3d.**
### M3-0 — Test & local-env foundation (the §10.3 Tier-1 suite)
Greenfield; lands before feature work so every sub-plan can be verified at
all levels. Reuses the existing in-process `app_with_fake_gitea` fixture seam
where possible.
- **Docker local stack** (`docker compose`): backend (FastAPI) + built
frontend + **fresh SQLite** + **Mailpit** (mail sink for OTC/invite flows)
+ a disposable/stub Gitea for content **and** registry repos.
- *Open design point for the M3-0 plan:* promote the in-process
`fake_gitea` double into a small standalone HTTP stub vs. run real Gitea
in a container. Decide in M3-0's own plan.
- **Frontend unit:** add **Vitest** + testing-library to
`frontend/package.json` (none today).
- **E2E:** add **Playwright**, suite **environment-agnostic**
parameterized by `BASE_URL` + the **mail-sink API URL** (§10.3). Default
target = localhost Docker; PPE host when `BASE_URL` is set to `ppe.<host>`.
One smoke spec: load `/` directory → open a project → view an entry.
- **Tier-2 / PPE is NOT built here.** Standing up `rfc-app-ppe.<base>` (its
own micro VM, own gcloud project, own isolated Gitea, always-pass Turnstile
keys) is a **flotilla operator task** in the deployment tooling, tracked
separately. The suite is written to point at it once it exists.
### M3a — Schema migration + default-id restamp *(foundation; runs against live OHM data)*
- **Migration `027`** (next number after `026`): create-copy-drop-rename for
the 12 tables enumerated in the `026_projects.sql` header, folding
`project_id` into each PK/UNIQUE key and adding the `project_id` FK →
`projects(id)`. (`cached_prs` needs no rebuild — already globally unique.)
- Additive `type` + `initial_state` columns on `projects`.
- **Restamp** (§22.13 step 1): default project `id` `default`
config-derived slug, cascaded across every `project_id` FK. Idempotent.
- **Live-data safety (Stage 1):** back up prod SQLite; rehearse on a copy of
prod; assert per-table row-count parity; FK enforcement on. Forward-only;
brief restart blip acceptable per §9.
- *Gate: until this lands, a second project can collide with the first.*
### M3b — Registry mirror
- `REGISTRY_REPO` **required** at startup (loud failure if unset).
- `refresh_registry()` in `cache.py`: read `projects.yaml` from
`REGISTRY_REPO` via the Gitea API; upsert `projects` rows (`id`, `name`,
`content_repo`, `type`, `visibility`, `initial_state`,
`enabled_models`/`theme``config_json`, `registry_sha`); cache deployment
`name`/`tagline`. Rows never written from user actions.
- Webhook branch in `webhooks.py` on push to `REGISTRY_REPO`;
`Reconciler.sweep()` calls `refresh_registry()`.
### M3c — Routing + redirects + runtime branding *(planner becomes navigable)*
- Backend: `GET /api/deployment` (name, tagline, visible projects per §22.5),
`GET /api/projects/:id` (name, tagline, type, philosophy pointer, theme);
`/p/<project>` scoping on RFC routes.
- Frontend: `/p/<project>/` prefix + `/e/<slug>` segment; **308 redirects**
off old corpus-root URLs (`/rfc/<slug>``/p/<default-id>/e/<slug>`, etc.);
**hard cut** off `VITE_APP_NAME` to runtime branding; per-project `theme`
token overlay; deployment **directory** at `/`; project **switcher**.
### M3d — Landing-state + review behavior
- `initial_state=active` creation path: land a new entry `active`, stamp
`unreviewed`, skip the graduate gate when the project says so.
- `unreviewed` frontmatter fields mirrored into `cached_rfcs`; owner/admin
**mark-reviewed** action; §7 catalog **unreviewed filter** querying the
cached column; type-driven entry noun in chrome.
## Test coverage per sub-plan (all four levels)
- **M3a** — migration unit/integration: fresh DB **and** copy-of-prod
fixture; row-count parity; FK enforcement; restamp idempotence.
- **M3b** — reconciler unit (yaml parse, upsert, `registry_sha`) +
integration (webhook → `projects` rows) on the Docker stub Gitea.
- **M3c** — backend functional (`/api/deployment`, `/api/projects/:id`, 308
redirects, `/p` scoping) + frontend unit (branding/theme from API) + **e2e**
(directory → project → entry nav; old-URL redirect resolves).
- **M3d** — backend functional (active landing, `unreviewed`, mark-reviewed,
filter) + **e2e** (unreviewed filter, mark-reviewed action).
## Out of scope / dependencies
- **M4** (second-project acceptance pass), **M5** (`bdd` scenario/coverage
surface — the planner's type-specific UI), **M6/M7**.
- **PPE standup** (`rfc-app-ppe.<base>`) — flotilla operator task; prerequisite
to running the suite's Tier-2 gate, not framework code.
- **Org-wide testing-standard activation** — §10.3 already *is* the canonical
standard; it should not be duplicated into per-repo memory. Recommended
follow-up (separate, in the engineering repo + dev plugin): have
`wgl-coding-session-init` read app.json and, for apps that deploy the §8
standard stack, surface §10.3 + check whether the Tier-1 suite exists. Not
part of M3.
@@ -0,0 +1,85 @@
# M3-frontend — §22 multi-project: routing, runtime branding, directory, switcher — design
**Date:** 2026-06-03
**Slice:** §22 M3 (frontend half). Pairs with **M3-backend** (`2026-06-03-m3-backend-design.md`) which ships the data spine, registry mirror, and the two runtime-config APIs this slice consumes.
**Status:** design, pending plan. *Implementation is gated on M3-backend Plan A's APIs (see §7).*
## Goal
Ship the §22.9/§22.10 frontend: `/p/<project>/` routing, the deployment **directory** + **project switcher**, the `VITE_APP_NAME`→runtime-config **hard cut**, per-project **theme** overlay, and real **308 redirects** off the old corpus-root URLs. After this slice the framework presents deployment chrome (directory/switcher/brand) over project chrome (catalog/entry view), branded entirely at runtime.
**Honest scope boundary.** The RFC *data* calls stay **unscoped** (`/api/rfcs/...`) this slice, so a project's actual corpus renders only for the backend's **corpus-served (default) project**. A *second* project's entries (the `bdd` "planner") need the backend to serve per-project RFCs — that is **M3-backend Plan B**, not this slice. M3-frontend therefore delivers the **shell**: directory, switcher, runtime branding, theme, `/p/<default>/` fully working, and the 308s. `projectId` is threaded through context so a later slice swaps unscoped calls for scoped ones with minimal churn.
## Decisions (from brainstorming)
1. **Routing architecture** — keep `<BrowserRouter>` + nested `<Routes>` (no migration to a data-router). Add a `DeploymentProvider` context (boots `/api/deployment`) and a `ProjectLayout`/`ProjectProvider` for the `/p/:projectId/*` subtree (`/api/projects/:id`). Extract the catalog+main-pane composition out of `App.jsx` (480 lines) into `ProjectLayout`.
2. **N=1 landing** — when exactly **one** project is visible to the caller, `/` redirects to `/p/<that-id>/`; the `<Directory>` renders only when 2+ are visible. Preserves OHM's "land in the corpus" UX; the directory appears when a second *public* project exists. (Visibility is per-caller, §22.5; the `unlisted` planner never counts toward the directory.)
3. **Redirects****real HTTP 308**, server-side (§5), not client-side `<Navigate>`.
4. **Runtime branding****hard cut**: remove `VITE_APP_NAME` from the build; name/tagline come from `/api/deployment` at runtime; `brandTitle()` (from M3-0) is the neutral `'RFC'` fallback during the pre-fetch paint.
5. **The guard** — a non-corpus-served `projectId` renders a deliberate "content not yet served" placeholder, never mislabeled default-project content (decouples this slice from Plan B without a wrong-content footgun).
6. **Philosophy stays deployment-level** this slice — `/api/projects/:id` (per the M3-backend spec) returns no philosophy pointer, so the per-project philosophy split (§14.1) is deferred until the backend serves one.
## 1. Routing & layout
`main.jsx` keeps `<BrowserRouter>`. New route table in `App.jsx`:
| Path | Renders |
| --- | --- |
| `/` | `<DeploymentLanding>` — if exactly one visible project → `<Navigate replace to="/p/<id>/">`; else `<Directory>` (cards from `/api/deployment`) |
| `/p/:projectId/*` | `<ProjectLayout>` (fetch `/api/projects/:id`, apply theme, provide `ProjectContext`) wrapping the Catalog left pane + nested routes below |
| `/p/:projectId/` | catalog home (today's `<Welcome>`) |
| `/p/:projectId/e/:slug` | `<RFCView>` — generic `/e/` segment; noun ("RFC/Spec/Feature") from `project.type` |
| `/p/:projectId/e/:slug/pr/:prNumber` | `<PRView>` |
| `/p/:projectId/proposals/:prNumber` | `<ProposalView>` |
| `/login`, `/docs/*`, `/admin/*`, `/privacy`, `/cookies`, `/settings/*`, `/invitations/accept`, `/invites/claim` | unchanged (top-level, full-width chrome) |
- Old `/rfc/:slug`, `/proposals/:n` are **removed from the SPA** — served as 308s (§5), so they never reach the router.
- `<ProjectLayout>` owns the per-project chrome and the **guard** (§4): corpus routes render only when the project is corpus-served; otherwise a placeholder.
## 2. Runtime branding (the hard cut)
- **`DeploymentProvider`** fetches `GET /api/deployment` once on boot (alongside `getMe()`); provides `{ name, tagline, projects[] }` via context.
- Replace the **6** `import.meta.env.VITE_APP_NAME` reads with the context value wrapped in `brandTitle(deployment?.name)` (neutral `'RFC'` fallback during pre-fetch):
- `App.jsx:208` (header brand), `Landing.jsx:16`, `BetaPending.jsx:25`, `Login.jsx:594`, `pages/Cookies.jsx:32`, `pages/Privacy.jsx:25`.
- **Tab title:** drop the build-time `%VITE_APP_NAME%` token; `index.html` ships static `<title>RFC</title>`; JS sets `document.title` after config loads (`ProjectLayout` → project name; deployment chrome → deployment name).
- **`vite.config.js`:** remove the `VITE_APP_NAME` build-time `throw` and the `inject-app-name` plugin. A build no longer needs the env var (the actual hard cut).
## 3. Theme overlay
`ProjectLayout` applies `project.theme` by setting CSS custom properties on `document.documentElement` (e.g. `style.setProperty('--c-accent', theme.accent)`), riding the existing `tokens.css` `:root` variable system. Properties are reapplied on project switch and **reset on unmount** so one project's accent never bleeds into deployment chrome or another project.
## 4. Chrome split + the guard
- **Deployment chrome:** header brand (→ deployment name), a **project switcher** dropdown (visible projects from `/api/deployment`), and `<Directory>` at `/`.
- **Project chrome:** catalog, entry view — under `ProjectLayout`, scoped to one project.
- **Breadcrumb (§8.1):** gains a leading project segment with the type-driven noun (e.g. `OHM / Human main`); threaded in from `ProjectContext` into the existing inline breadcrumb markup in `RFCView.jsx`/`PRView.jsx` (no component extraction — `RFCView` is 1260 lines; full extraction is out of scope).
- **The guard:** `ProjectLayout` renders the catalog/entry routes only for the corpus-served (default) project; any other `projectId` renders a "content not yet served" placeholder. **Contract detail to settle at implementation time** (against Plan A/B): how the frontend learns which project is corpus-served — a flag on `/api/projects/:id`, or matching the deployment's default id. Deliberately not over-specified now.
## 5. 308 redirects (server-side — coordination with M3-backend)
- **FastAPI:** `GET /rfc/{slug}` → 308 `/p/{default_id}/e/{slug}`; `GET /proposals/{n}` → 308 `/p/{default_id}/proposals/{n}`. `default_id` from config (`DEFAULT_PROJECT_ID`; post-restamp = the project's real id).
- **nginx** (`testing/web.nginx.conf` for Tier-1, and the prod `deploy/nginx/*.conf`): route `/rfc/` and `/proposals/` to the backend (`proxy_pass`) instead of `try_files → index.html`.
- This is the backend/ops layer (it needs `DEFAULT_PROJECT_ID`, backend-owned). **Coordination point:** either the parallel M3-backend session adds it, or this slice contributes the small backend route + nginx rule. Owner to be assigned during planning.
## 6. Testing
- **Vitest unit:** `DeploymentProvider` fallback via `brandTitle`; theme `setProperty` apply/reset; N=1 redirect logic; the guard placeholder; the directory render with 0/1/2+ visible projects.
- **Playwright e2e (M3-0 Tier-1 harness):** N=1 `/``/p/<id>/` redirect; directory with 2+ public projects; **308** from `/rfc/<slug>``/p/<id>/e/<slug>`; branding from `/api/deployment`; theme accent applied; switcher navigation; non-served project placeholder. **e2e lands once M3-backend Plan A (registry + the two APIs) is in the Tier-1 stack** (the seeded Gitea needs a `REGISTRY_REPO` + `projects.yaml`).
## 7. Dependencies & sequencing
- **Design:** now (unblocked).
- **Implementation gated on:** M3-backend **Plan A**`GET /api/deployment`, `GET /api/projects/:id` (`api_deployment.py`, not yet built).
- **Planner's actual content gated on:** M3-backend **Plan B** — per-project RFC serving + scoped frontend calls (a follow-on slice that relaxes the §4 guard).
- **308 piece:** needs the backend route + nginx rule (§5).
- **Tier-1 e2e:** needs Plan A's registry + APIs wired into the M3-0 Docker stack.
## 8. File-touch summary
**New (frontend):** `src/context/DeploymentProvider.jsx`, `src/components/ProjectLayout.jsx` (+ `ProjectContext`), `src/components/Directory.jsx`, `src/components/ProjectSwitcher.jsx`, `src/components/NotServedPlaceholder.jsx`; `src/api.js` additions (`getDeployment`, `getProject`).
**Modified (frontend):** `App.jsx` (route table, brand), `main.jsx` (provider wrap), `index.html` (static title), `vite.config.js` (drop `VITE_APP_NAME`), the 6 brand-read files, `RFCView.jsx`/`PRView.jsx` (breadcrumb project segment), `tokens.css` (no change expected; theme is applied via JS).
**Server (coordination):** a FastAPI redirect route + nginx `/rfc/` `/proposals/` rule (§5).
## 9. Versioning
Per the 2026-06-03 decision (bump per breaking slice), M3-frontend's `VITE_APP_NAME` removal is a build-surface change deployments must act on (set up the registry / runtime config). It rides the same pre-1.0 minor + CHANGELOG upgrade-steps as the M3-backend cut if they land together, or carries its own upgrade-steps block if separate. `VERSION` + `frontend/package.json#version` move together (§20).
@@ -0,0 +1,194 @@
# M3-backend Plan B — §22 multi-project: per-project RFC serving, default-id re-stamp, slug-keyed PK rebuilds — design
**Date:** 2026-06-04
**Slice:** §22 M3 (the backend half that M3-frontend deferred). Pairs with
**M3-frontend** (`2026-06-03-m3-frontend-design.md`, shipped v0.35.0) which
delivered the shell (routing, runtime branding, directory/switcher, 308s) but
kept RFC *data* calls unscoped, so only the corpus-served default project
renders. This slice makes a **second project's corpus actually serve and
render**, and lands the two §22.13/migration-026 items that must precede
project #2.
**Status:** design, pending plan. Authoritative model: `multi-project-spec.md`
(Part A §22, Part C M3) + `multi-project.md`.
**Target release:** the next pre-1.0 minor (breaking: URL/migration). Likely
**v0.36.0**; may split into two minors (B-1 read path, B-2 write path) — see §7.
## Goal
After this slice a deployment with **N≥2** registry projects serves and renders
every project's own corpus under `/p/<id>/`, identified by slug *within* the
project (§22.4). The M3-frontend guard (`NotServedPlaceholder` for any
non-default project) is **removed** — the guard contract (`default_project_id`)
stays on `/api/deployment` only as the redirect target for legacy URLs.
Three things land together because none is safe without the others:
1. **Default-project-id re-stamp** (§22.13 step 1) — `default` → a config slug.
2. **Slug-keyed PK/UNIQUE rebuilds** (migration 026 header) — fold `project_id`
into the composite keys so a second project can't collide on a slug.
3. **Per-project RFC serving** — endpoints, cache mirror, bot, and webhook
routing all dispatch by project; the frontend calls the scoped routes.
## 1. Default-project-id re-stamp (§22.13 step 1)
Today `projects.resolved_default_id()` always returns the literal `"default"`
(Plan A), and M1's backfill stamped every existing row `project_id='default'`.
This slice re-stamps that bootstrap id to a **config-derived slug** so the
deployment's URL is meaningful (`/p/ohm/` not `/p/default/`) and stable.
- **Resolution order** (already drafted in `resolved_default_id`'s docstring):
`DEFAULT_PROJECT_ID` env var → else slug of the deployment name → else
`default`. Decision: keep it config-explicit — OHM sets
`DEFAULT_PROJECT_ID=ohm` in its overlay; the registry's first project `id`
SHOULD match.
- **Why it must precede public `/p/` URLs:** once `/p/<id>/` is linkable, the
id is a permanent URL; renaming it later breaks links. M3-frontend shipped
`/p/<default>/` already, so strictly this re-stamp is now *slightly late*
acknowledge that and treat the re-stamp as a one-time 308-preserving rename
(add `/p/default/* → /p/<slug>/*` 308s for one release if `DEFAULT_PROJECT_ID`
differs from `default`). Open: do we bother, given OHM isn't deployed on the
multi-project stack yet (pinned 0.31.5)? If OHM cuts over *directly* to a
re-stamped slug, no `default` URL was ever public and the extra 308s are
unnecessary. **Recommendation: re-stamp lands in the SAME deploy OHM first
adopts the registry, so `default` is never public for OHM → no legacy 308
needed.** Code the re-stamp as part of the registry-mirror reconcile (rename
rows whose `project_id` is the stale bootstrap value to the resolved id),
idempotent.
- **Mechanics:** a migration (or the reconciler, run-once guarded) `UPDATE`s
`project_id` from the old bootstrap value to the resolved slug across the
`projects` row, `project_members`, and every scoped table (the ~19 from
migration 026). Must run **inside the same transaction** as / before the PK
rebuilds (§2) so FKs stay consistent.
## 2. Slug-keyed PK / UNIQUE rebuilds (migration 026 header)
> **STATUS: SHIPPED — rfc-app v0.36.0 (Session 0071.0).** Migration `028` lands
> the rebuilds (13 tables, incl. composite FKs on `rfc_invitations`/
> `rfc_collaborators`/`contribution_requests``cached_rfcs(project_id, slug)`),
> the migration-runner `-- migrate:no-foreign-keys` capability, and the
> `ON CONFLICT` target updates. 442 backend tests green; two-project same-slug
> coexistence proven (`test_migration_028_project_scoped_keys.py`). No behavior
> change. **Remaining Plan B = the §1 re-stamp + §3–§5 per-project serving.**
SQLite can't `ALTER` a PRIMARY KEY/UNIQUE in place, so each table below is
rebuilt with the create-new → copy → drop-old → rename pattern, inside one
transaction with `PRAGMA foreign_keys=OFF` around it (per the existing
migration-runner convention — confirm in `db.py`). The composite-key targets
(verbatim from `026_projects.sql`):
| Table | old key | new key |
| --- | --- | --- |
| `cached_rfcs` | PK `(slug)` | `(project_id, slug)` |
| `cached_branches` | UNIQUE `(rfc_slug, branch_name)` | `+project_id` |
| `branch_visibility` | UNIQUE `(rfc_slug, branch_name)` | `+project_id` |
| `branch_contribute_grants` | UNIQUE `(rfc_slug, branch_name, grantee_user_id)` | `+project_id` |
| `stars` | UNIQUE `(user_id, rfc_slug)` | `+project_id` |
| `watches` | UNIQUE `(user_id, rfc_slug)` | `+project_id` |
| `pr_seen` | UNIQUE `(user_id, rfc_slug, pr_number)` | `+project_id` |
| `branch_chat_seen` | UNIQUE `(user_id, rfc_slug, branch_name)` | `+project_id` |
| `funder_consents` | PK `(user_id, rfc_slug)` | `+project_id` |
| `rfc_collaborators` | UNIQUE idx `(rfc_slug, user_id)` | `+project_id` |
| `contribution_requests` | UNIQUE idx `(rfc_slug, requester_user_id) WHERE pending` | `+project_id` |
| `proposed_use_cases` | UNIQUE `(scope, pr_number)` | `+project_id` |
`cached_prs` UNIQUE `(repo, pr_number)` needs **no** rebuild — `repo` is the
full `org/repo` string, already distinct per project.
- Every dependent index/trigger/view is recreated against the new table.
- Backfilled rows already carry `project_id` (M1), so the copy is a straight
`INSERT INTO new SELECT * FROM old`.
- Add the FK `project_id REFERENCES projects(id)` on rebuild where it was
deferred.
- **Test:** a migration test that seeds two projects with the *same* slug and
asserts both rows coexist post-rebuild (the collision M1 couldn't allow).
## 3. Per-project RFC serving — endpoints
Decision to pin in planning: **path-scoped** routes, mirroring the frontend's
`/p/<project>/` and §22.4's `(project_id, slug)` identity:
```
GET /api/projects/{pid}/rfcs catalog (replaces GET /api/rfcs)
GET /api/projects/{pid}/rfcs/{slug} entry (replaces /api/rfcs/{slug})
POST /api/projects/{pid}/rfcs/propose propose (already partly there:
mark-reviewed is /api/projects/{pid}/…)
GET /api/projects/{pid}/proposals[/{n}] idea PRs
…branches / prs / discussion / graduation analogously gain the {pid} prefix.
```
- Keep the old unscoped `/api/rfcs*` as **thin shims** that resolve the default
project and delegate, for one release, so a stale frontend bundle mid-deploy
still works; remove in the following minor. (Or hard-cut — decide in planning;
the frontend ships scoped calls in the same release, so the shim is only for
in-flight bundles.)
- Every handler runs the §22.5 read/write gate on `{pid}`
(`require_project_readable`, `can_contribute_in_project`) — the resolver
primitives already exist (M2). The per-RFC lookups change from `WHERE slug=?`
to `WHERE project_id=? AND slug=?`.
- `propose`/graduation already call `projects_mod.resolved_default_id(config)`
(api.py:874) — change to the path `{pid}`.
## 4. Cache mirror, bot, webhook — dispatch by project
- **Mirror:** `cache.refresh_meta_repo()` reads one `content_repo` today
(`projects.default_content_repo`). Generalize to iterate **all** projects'
`content_repo`s, mirroring each into `cached_rfcs` (etc.) stamped with that
project's id. Loop over `projects` rows.
- **Bot:** `bot.open_idea_pr(...)` and the branch/PR helpers take a
`project_id` (or a resolved `content_repo`) instead of the default.
- **Webhook (§4):** `/api/webhooks/gitea` maps the pushed repo →
`projects.content_repo` → project, and refreshes only that project's cache
(the planner does exactly this `match_repo` step — mirror its shape).
- **Registry webhook** already reconciles `projects` (Plan A). No change.
## 5. Frontend — relax the guard, scope the calls
- `api.js`: `listRFCs`/`getRFC`/`listProposals`/`getProposal`/`proposeRFC`/…
gain a `projectId` arg and hit the `/api/projects/{pid}/…` routes. `useProjectId()`
(already added in M3-frontend, `lib/entryPaths.js`) supplies it.
- `ProjectLayout`: **remove the `served`/`NotServedPlaceholder` guard** — every
registry project now serves. Keep `NotServedPlaceholder` only as the 404/not-
readable surface (or delete and reuse the not-found branch).
- `Catalog`, `RFCView`, `PRView`, `ProposalView`, `ProposeModal` read their
data through the scoped api with `useProjectId()`.
- `/api/deployment.default_project_id` stays (the legacy-URL 308 target).
## 6. Testing
- **Migration:** two-project same-slug coexistence (§2); re-stamp idempotence;
FK integrity post-rebuild.
- **Backend vertical:** a second `document` project end-to-end — propose →
super-draft → graduate, identified by slug *in that project*; visibility gate
(gated 2nd project 404s a non-member while the default stays readable);
cross-project isolation (a slug in project A is not found under project B).
- **Frontend unit:** scoped api calls carry the right `pid`; the guard removal
renders a non-default project's catalog.
- **Tier-1 e2e (now unblockable):** seed a **registry repo + `projects.yaml`
with two projects** into the dockerized Gitea and set `REGISTRY_REPO` in
`testing/.env.tier1` (currently empty — this is the blocker M3-frontend's
e2e + this slice's e2e share). Then the M3-frontend Playwright specs (N=1
redirect, directory with 2+, 308s, switcher) AND a second-project corpus
render become runnable. **Land the Tier-1 registry seed as the first task of
this slice** — it pays off both slices' deferred e2e.
## 7. Sequencing & risk
- **Highest risk:** the §2 PK rebuilds (12 table recreates in one migration).
Mitigate with an isolated migration test DB seeded from a realistic dump and
a row-count assertion before/after each table.
- **Possible split:** B-1 = re-stamp + PK rebuilds + read-path serving
(catalog + entry view scoped) — enough for a 2nd project's corpus to *render*
read-only; B-2 = write path (propose/branch/PR/graduate) scoped. Each is
independently shippable behind the N=1 default. Decide in planning; a single
v0.36.0 is fine if the write-path rescope is mechanical.
- **OHM impact:** OHM is pinned 0.31.5 and not yet on the registry stack, so
this slice has **no live deployment to migrate** until the OHM cutover
milestone (ohm-rfc ROADMAP Phase G). Land it on `main`; it deploys to OHM as
part of that cutover.
## 8. Versioning
Pre-1.0 minor, breaking (URL move + migration). `VERSION` +
`frontend/package.json` move together (§20.1). CHANGELOG upgrade-steps:
migration runs automatically; old `/api/rfcs*` shims (if kept) deprecated; the
re-stamp note (`DEFAULT_PROJECT_ID`); the SPEC §22 merge stays for M7.
+2
View File
@@ -0,0 +1,2 @@
test-results/
playwright-report/
+32
View File
@@ -0,0 +1,32 @@
const MAILSINK = process.env.MAILSINK_URL || 'http://localhost:8025'
export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 } = {}) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(`${MAILSINK}/api/v1/messages`)
if (res.ok) {
const data = await res.json()
const msg = (data.messages || []).find(
(m) => (m.To || []).some((t) => t.Address === toAddress),
)
if (msg) {
const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`)
if (!full.ok) continue
const body = await full.json()
// Search the plain-text part first — the OTC mail is plain text
// (see backend/app/email_otc.py), and scanning Text before HTML
// keeps the \d{6} match from latching onto a stray number that a
// future HTML template might carry (style widths, year, etc.).
const text = body.Text || ''
const html = body.HTML || ''
const code = text.match(/\b(\d{6})\b/) || html.match(/\b(\d{6})\b/)
if (code) return code[1]
}
}
await new Promise((r) => setTimeout(r, delayMs))
}
throw new Error(`no OTC email for ${toAddress} arrived in Mailpit`)
}
export async function clearMailpit() {
await fetch(`${MAILSINK}/api/v1/messages`, { method: 'DELETE' })
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "rfc-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-e2e",
"devDependencies": {
"@playwright/test": "^1.49.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "rfc-e2e",
"private": true,
"type": "module",
"scripts": {
"e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: '.',
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: process.env.BASE_URL || 'http://localhost:8080',
trace: 'on-first-retry',
},
reporter: [['list']],
})
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test'
import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js'
// Unique per-run address. The §6.2 request path enforces a per-email
// cooldown (OTC_REQUEST_COOLDOWN_SECONDS, default 60s), so a fixed
// address would 429 on any re-run inside the window. A fresh address per
// run sidesteps that without touching backend config.
const EMAIL = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.test`
// First end-to-end smoke spec (M3-0 Task 7). Validates the whole Tier-1
// harness: the web tier serves the app, the backend's OTC sign-in path
// (§6.2) issues a code, Mailpit captures the outbound mail, and a verify
// of that code succeeds and mints a session.
//
// No precondition/provisioning step is needed: the §6.2 OTC path admits
// any syntactically-valid email and provisions a fresh `pending` user on
// verify (see backend/app/otc.py). Turnstile is open in this stack
// (TURNSTILE_REQUIRED=false, no secret), so the request body carries only
// the email.
test('app loads and an OTC sign-in succeeds', async ({ page, request }) => {
await clearMailpit()
await page.goto('/')
await expect(page).toHaveTitle(/.+/)
const reqRes = await request.post('/auth/otc/request', {
data: { email: EMAIL },
})
expect(reqRes.ok()).toBeTruthy()
const code = await waitForLatestOtc(EMAIL)
expect(code).toMatch(/^\d{6}$/)
const verifyRes = await request.post('/auth/otc/verify', {
data: { email: EMAIL, code },
})
expect(verifyRes.ok()).toBeTruthy()
// Prove the sign-in actually took: the verify handler returns ok:true
// and sets the `rfc_session` session cookie (§ SessionMiddleware,
// backend/app/main.py). Asserting the cookie — not brittle UI text —
// is what distinguishes a real sign-in from a bare 2xx.
const verifyBody = await verifyRes.json()
expect(verifyBody.ok).toBe(true)
const setCookie = verifyRes.headers()['set-cookie'] || ''
expect(setCookie).toContain('rfc_session=')
})
+4 -9
View File
@@ -4,15 +4,10 @@
# and dev (`npm run dev`) time. Real `.env` files are gitignored; only this
# `.env.example` is committed.
# The user-visible name of this deployment. Used as the browser tab title,
# the header brand, and the landing page H1. Required — the framework
# ships no default on purpose so each deployment names itself. If unset,
# `npm run build` fails with a clear message.
#
# Examples:
# VITE_APP_NAME=Wiggleverse RFC
# VITE_APP_NAME=Wiggleverse Open Human Model
VITE_APP_NAME=
# §22.9 (M3, v0.35.0): the deployment name is NO LONGER a build-time var.
# It comes from the registry (`projects.yaml` `deployment.name`) and is served
# at runtime by GET /api/deployment. VITE_APP_NAME has been removed — set the
# name in your registry repo instead, and the same build serves any deployment.
# Optional contact line shown on the /beta-pending page when a deployment
# is in private-beta mode (i.e. the backend's `allowed_emails` table has

Some files were not shown because too many files have changed in this diff Show More