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>
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>
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>
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>
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>
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>
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>
Per the v0.18.0 email + webhook hygiene proposal §3:
* `backend/migrations/020_outbound_emails.sql` — new table
capturing every send attempt: to_address, from_address,
subject, kind, sent_at, status ('sent' | 'failed' | 'deferred'
| 'bounced'), error, notification_id (FK), message_id (for
Slice 5 bounce correlation).
* `email.record_outbound()` — best-effort write helper every
send path calls. Status='sent' on SMTP success, 'failed' on
exception (with class + message in `error`), 'deferred' on
the dev-fallback path where SMTP_HOST is unset (the send
didn't happen but the row records the attempt).
* `email._deliver` (watcher notifications + bundles), `digest.py`,
`email_otc.py`, `email_invite.py` — every send path now records.
* `GET /api/admin/outbound-emails` — admin-only listing,
filterable by kind / status / to_address. Answers "did this
person ever get their invite?" without grepping VM logs. No
admin UI in v0.18.0; operator queries via curl + jq for now.
7 new integration tests covering: OTC / invite / notification all
write rows with matching Message-ID; admin endpoint lists,
filters by kind, filters by to_address (case-insensitive),
refuses non-admins.
Full suite: 291 passed.
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.
From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).
Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.
Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.
Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).
Amplitude wiring (inline, #21 Part C):
- USER_INVITED from CreateUserInviteModal { target_user_id,
initial_role, custom_message_chars }
- INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
initial_role, needs_passcode, trust_device }
- identify() BEFORE the claim event with properties:
claim_method 'admin-invite', invited_at (setOnce),
invited_by_admin_id (setOnce), initial_role (setOnce) —
so the Amplitude user record is created with the OHM
user_id from the very first event the invitee fires
Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave 5 / Track B. Roadmap item #12. Folds in #21 Part C Amplitude
wiring inline (operator ask: "best practices from the very get-go").
RFC owners can invite specific users to one of two per-RFC roles:
contributor (open PRs + join discussion) or discussant (discussion
only). Non-invited users keep the v0.6.0 anonymous-read contract.
The per-RFC write gate layers on top of the existing
require_contributor gate; a super-draft with no owners yet falls
through to the platform-granted contract, preserving the
v0.6.0/v0.7.0/v0.8.0 contracts in their domains.
Backend: migration 018_rfc_invitations.sql (auto-applied — two
tables: rfc_invitations + rfc_collaborators); api_invitations.py
with five endpoints + transactional email; auth.py helpers
(is_rfc_owner / is_rfc_collaborator / can_discuss_rfc /
can_contribute_to_rfc / can_invite_to_rfc); api_discussion +
api_branches + api_prs gate composition; api_admin.py additive
rfc_invitations[] per user. 237 backend tests pass (18 new in
test_rfc_invitations_vertical.py).
Frontend: InvitationsModal.jsx (owner surface), AcceptInvitation.jsx
(/invitations/accept route), api.js helpers, RFCView.jsx
Invitations button, App.jsx route registration.
Amplitude wiring (inline, #21 Part C):
- INVITATION_SENT from InvitationsModal { rfc_slug, role_in_rfc }
- INVITATION_ACCEPTED from AcceptInvitation { rfc_slug, role_in_rfc }
- identify() BEFORE the accept event with properties: invited_at
(setOnce), last_invited_to_rfc, last_invite_role_in_rfc,
claim_method: 'rfc-invite'
- EVENTS taxonomy extended with INVITATION_SENT + INVITATION_ACCEPTED
No new secrets, no new overlay keys, no operator gesture beyond the
v0.15.0 overlay-set + restart. Frontend build verified green.
Subagent ν shipped the feature on feature/v0.16.0-owner-invite
(a51beec). Driver-side integration squash-merged into main,
hand-resolved VERSION + package.json + CHANGELOG (strict-descending
0.16.0 → 0.15.0), and added the inline Amplitude wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a successful OTC sign-in, a contributor can set a passcode
(4-20 characters, bcrypt-hashed) and use email + passcode for
subsequent sign-ins. OTC remains the structural fallback: five
consecutive failed verifies lock the passcode path for 15 minutes
(HTTP 423), and a forgotten passcode is recovered by requesting a
fresh code. Migration 015_passcode.sql adds four nullable columns
to the users table; existing rows pass through as OTC-only and can
opt into a passcode from a new Sign-in tab in /settings/notifications.
The /login surface is extended to a five-step flow (email → either
passcode or OTC code → optional post-OTC passcode offer → optional
set-passcode). SPEC corrections per §19.3 rule 2: §6 names the
three auth paths, §14.1 documents the stepped login flow, §17 lists
the four new /auth/passcode/* endpoints, §19.2 surfaces four new
candidates and refreshes the cross-refs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an
admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17).
Any valid email can sign in via OTC; a fresh user lands in
permission_state='pending' with a captured first/last/why profile,
and an admin grant flips them to 'granted' before write endpoints
accept them. Grandfathered users pass through the migration with
the column default 'granted' so existing contributors are unaffected.
The allowed_emails table stays in the schema as a fast-path bypass
pending v0.9.0's admin user-management page (item #7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Roadmap item #11. Ships the non-modal bottom-of-page cookie consent
banner, the default /privacy and /cookies policy pages, the
`cookie_consent` table + two §17 endpoints for server-side persistence,
the localStorage fallback for anonymous viewers, the /settings
"Privacy & cookies" tab for revisiting the choice, and the
`frontend/src/lib/consent.js` helper that roadmap item #13's analytics
SDK (v0.15.0) will gate against. No analytics SDK ships in this release
— the consent infrastructure goes in first so the gate is already in
place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names
the new table in §5, and surfaces four §19.2 candidates (content-repo
file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency).
Two new optional env vars (`VITE_PRIVACY_POLICY_URL`,
`VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub
pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an email allowlist (toggleable per deployment) that restricts
OAuth sign-in to listed emails while keeping read paths public.
Anonymous visitors now see the full app shell in read-only mode
instead of the §14.1 landing wall. Empty allowlist = gate off, so
deployments that don't enable it behave exactly as 0.2.3.
Also fixes single-finger scroll on /philosophy and other .chrome-pane
views on iOS Safari (.app: 100vh → 100dvh).
Renames deploy/nginx/rfc.wiggleverse.org.conf →
ohm.wiggleverse.org.conf to match the deployed-domain rename
(rfc.wiggleverse.org deprovisioned 2026-05-27).
See CHANGELOG.md for full details + upgrade steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional
`funder:` frontmatter field names a single gitea_login; a
`funder_consents` app-db row records funder-side opt-in; both halves
required for the binding to activate (two-key rule). Funder universe
replaces — does not augment — the operator universe per-RFC for
attribution-clean resolution. Funder role grants zero §6.1/§6.3
authority. Three revocation paths each restore the operator-credentials
status quo.
§19.2's credential-delegation entry is split: lighter half marked
settled with a pointer to §6.7; operational-realities half (mid-call
failure, rotation, billing, rate-limit attribution) lives on as its
own entry. Test suite is 125/125 green (106 prior + 19 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First §19.2 candidate settled after v1. The heavier per-RFC-model
topic subdivided into UX (this) and credential delegation + funder
role (still §19.2). New §6.6 carries the rule: an optional `models:`
frontmatter field on the meta-repo RFC entry; absent inherits the
operator universe, populated narrows the picker to the intersection
with provisioned providers, `[]` opts the RFC out of AI entirely.
The first resolved entry is the RFC default. §18's ENABLED_MODELS is
reframed as the operator universe.
Code: migration 009 adds nullable cached_rfcs.models_json (NULL ≠ []
is load-bearing); entry.py grows the optional field with absent-vs-
empty round-tripping in parse/serialize; new models_resolver module
holds the rule; api_branches replaces /api/models with the slug-aware
/api/rfcs/{slug}/models and threads the chat + reask paths through
the resolver; api_prs §10.2 uses the resolver and extends the stub
fallback to the opt-out case; frontend passes slug to listModels.
Tests 106/106 green (96 prior + 10 in test_per_rfc_models.py). No
behavioral change for entries without `models:` — operator universe
preserved as default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the
§5 schema (six numbered migrations), Gitea OAuth + §6 user
provisioning, the §7 catalog left pane, and the propose-to-merge
vertical: propose modal opens an idea PR against the meta repo, an
owner merges from the pending-idea view, the cache picks it up via
webhook or reconciler sweep, and the catalog renders the new
super-draft.
Per §1 the bot is the only Git writer; every commit, branch
creation, and PR merge carries the §6.5 On-behalf-of: trailer and
an `actions` audit row. Per §4 the cache is never written from a
user action — it's webhook+reconciler only.
Covered by `backend/tests/test_propose_vertical.py` against an
in-process Gitea simulator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>