12 KiB
Session 0065.0 — Transcript
Date: 2026-06-03 Goal: Build M2 of the rfc-app §22 multi-project series — project-scoped authorization + the §22.7 resolver — then finalize (merge to main, publish).
Outcome: M2 shipped. The §22.7 three-tier resolver + §22.5 visibility gate landed across all eight API modules, behavior-preserving on the public default project (full suite 401 green). Merged to
mainon top of v0.32.0 (retire) that had advanced main meanwhile — required a migration renumber and aget_rfcconflict resolution.feat/multi-projectdeleted; no release/deploy (the §22 series releases as one minor at M7).Highlights:
- Two operator design decisions settled mid-build (see Arc 2): implicit-on- public and preserve-curation. Both are a deliberate, documented liberalization of §22.5's literal "grant required on public," chosen to keep the N=1 case whole and the v0.16.0 per-RFC invite contract intact.
- A latent migration-numbering collision surfaced at merge time: M1's
025_projects.sqlvs v0.32.0's025_retired_state.sql. See §19.2 candidate 1.
Pre-session state
- rfc-app (
~/git/wiggleverse.org/ben.stull/rfc-app, origin =git.wiggleverse.org:ben.stull/rfc-app): branchfeat/multi-projectcarried the §22 design drafts + M1 (the schema spine, commit6f901e3:projects/project_memberstables,project_idon all 19 slug-bearing tables, migration025_projects.sql).mainwas at0062510locally but had in fact advanced on origin (see Arc 3). The §22 slicing plan (M1–M7,docs/design/multi-project-spec.mdPart C) targets one pre-1.0 minor at M7; intermediate slices accumulate on the feature branch and bump nothing. - Backend: FastAPI + SQLite (
backend/), test suitecd backend && python -m pytest -qvia the local.venv. Baseline at session start: 381 passed. - OHM live: untouched this session (no deploy, no pin bump).
Turn-by-turn arc
Arc 1 — Locating the work
"Build RFC App M2" was terse. Traced it from ohm-infra references → the
rfc-app framework → docs/design/multi-project-spec.md §22, Part C slicing
plan. M1 was confirmed landed on feat/multi-project; M2 = "Project-scoped
authorization + the §22.7 resolver." Surveyed the existing auth machinery
(app/auth.py: deployment roles §6.1, per-RFC helpers §6.3/§12), the M1 schema
(migration 025), and — via an Explore subagent — a full inventory of every
RFC-scoped endpoint and its current gate. Key architectural find: every
RFC-scoped endpoint funnels through a _require_rfc*() helper that does
SELECT ... FROM cached_rfcs WHERE slug = ?, and M1 already put project_id on
cached_rfcs — a single, uniform hook point for both the resolver and the
visibility gate.
Arc 2 — Two design forks (operator decisions)
Entered plan mode. Two genuine forks surfaced that change behavior and so went to the operator via AskUserQuestion:
-
Public-write gate. §22.5/§22.7 literally require an explicit
project_contributorgrant to write even on a public project. But the live default project is public and the membership UI isn't due until M6 — a literal reading would break onboarding. Decision: implicit-on-public — a granted deployment contributor keeps its pre-M2 write baseline on a public project with no membership row (no backfill; N=1 stays whole). -
Per-RFC curation. §22.7 says project roles are supersets of per-RFC roles, which — combined with implicit-on-public — would erase the v0.16.0 per-RFC discussion/contribution invite curation on the public project (and break 4 shipped tests). Decision: preserve-curation — the implicit-public baseline does NOT override per-RFC owner curation; only an explicit project grant (or deployment owner/admin) does.
Plan approved. These two decisions are what make M2 behavior-preserving on the public default project while still fully implementing the tier for gated/unlisted projects and explicit grants.
Arc 3 — Implementation
Pure app-layer (no migration — M1 shipped the tables; no version bump — mid-feature-branch).
app/auth.py— the §22.7 resolver primitives:project_visibility,project_member_role,project_of_rfc,is_project_superuser,can_read_project,can_contribute_in_project/can_discuss_in_project(with the implicit-public_has_write_baseline),require_project_readable,visible_project_ids. The three existing per-RFC helpers (can_discuss_rfc/can_contribute_to_rfc/can_invite_to_rfc) were rewritten to compose all three tiers (signatures unchanged, so ~20 call sites inherited M2 for free), with the old "no owners yet → open" fallthrough replaced by the curation-preserving baseline.- Read pass — threaded
viewer+require_project_readableinto every_require_rfc*/_require_super_draft/_require_rfc_readableacrossapi.py,api_branches,api_prs,api_graduation,api_discussion,api_contributions,api_invitations; filtered the catalog (GET /api/rfcs) and proposals listings byvisible_project_ids. A gated project's entries now 404 to non-members (indistinguishable from absent, §22.5). - Write pass — folded
is_project_superuser(project_admin) into the deep gates (_can_read_branch,_can_contribute,_require_branch_owner,_can_resolve_thread,_can_edit_metadata,_capabilities,_can_merge,_can_withdraw,_can_graduate, discussion_can_resolve); routed the branch "any-contributor" mode throughcan_contribute_in_project; gatedpropose+claimon project contribute standing. A self-correction mid-pass: initially addedcan_contribute_to_rfctopr-draft/review/resolution-branch, then reverted it — those were open to any contributor pre-M2, so per-RFC gating there would tighten public behavior; the threaded visibility gate is the correct M2 change for them. Deployment-level surfaces (admin idea-PR merge/decline, account notification-mute) left deployment- scoped after audit. - Tests —
backend/tests/test_multi_project_authz_vertical.py(9 vertical assertions: resolver tiers, public-unchanged regression, gated 404 read gate, gated contribution gate, union + subtractive visibility, revocation). - Docs — Part C M2 marked "(landed)" with both operator decisions recorded.
Result: 390 passed (381 + 9). Committed as 503689b
("feat(projects): M2 — project-scoped authorization + the §22.7 resolver").
Arc 4 — Finalize round 1: the diverged-main surprise
Operator: "you can push/merge." Survey revealed origin/main had advanced
to 34a65e0 (v0.32.0 — the retired soft-delete state + §13 retire/unretire),
3 commits the branch didn't have; local main was stale. A trial merge
(git merge-tree) reported:
- A content conflict in
app/api.py(get_rfc— both sides addedrequest: Requestand top-of-handler logic). - A migration-number collision: M1's
025_projects.sqlvs v0.32.0's025_retired_state.sql. Worse than a name clash —025_retired_state.sqlrebuildscached_rfcs(create-copy-drop-rename to widen a CHECK constraint) and predatesproject_id, so on a fresh DB (filename-sorted order:projects<retired) the rebuild would dropproject_id, breaking every M2 gate.
Surfaced this to the operator (who confirmed single-user, land it on main).
Resolution, all on main via a --no-ff merge:
- Renamed
025_projects.sql→026_projects.sqlso projects'ADD COLUMNruns after the retire rebuild (the runner applies*.sqlin filename order, tracked inschema_migrations); bumped the comment/doc references. - Resolved
get_rfc: computevieweronce, apply the §22.5 visibility gate, then v0.32.0's §13.7 retired-entry owner-only check. api_graduation.py/api_discussion.pyauto-merged cleanly (M2's threaded viewer/visibility coexists with the new retire endpoints + state).
Full suite on the merged tree (fresh DB — exercises the migration order):
401 passed. Merge committed 1dab24e; pushed main + feat/multi-project
to origin.
Arc 5 — Finalize round 2: housekeeping
Ran the wgl-coding-session-finalize flow. Operator chose: delete
feat/multi-project (local + origin — fully contained in main; M3 branches
fresh) and leave the secondary benstull remote alone. Done. Updated the
project memory note (project_rfc_app_multiproject.md) and refreshed it for the
deleted branch.
Cut state (end of session)
| rfc-app | main @ 1dab24e (merge: §22 M1+M2 over v0.32.0), pushed to origin. M2 = 503689b. Working tree clean. |
| rfc-app branches | feat/multi-project deleted (local + origin). |
| Test suite | backend 401 passed (fresh DB). |
| Version / release | None — §22 releases as one minor at M7; no VERSION/CHANGELOG change, no tag. |
| OHM live | Untouched (no deploy this session). |
| ohm-rfc pin | Unchanged. |
| §22 slice ledger | Status |
|---|---|
| M1 — schema spine | ✅ landed (on main via merge) |
| M2 — project-scoped authz + §22.7 resolver | ✅ shipped this session |
| M3 — registry mirror + routing + runtime branding | ⏭ next |
| M4–M7 | ⏸ pending |
§19.2 candidates surfaced
- Migration-number collisions across parallel branches. M1 and v0.32.0 both
grabbed
025_*; only a manual rename at merge time kept a fresh DB working. The numbering scheme is collision-prone whenever two branches add migrations concurrently. Worth a convention (reserve ranges per workstream, or a monotonic claim like the session-ID gesture) so the next collision isn't silent. The immediate guard: new migrations must be ≥ 027. - Fold the M2 authz decisions into SPEC at M7. implicit-on-public and
preserve-curation currently live only in
docs/design/multi-project-spec.mdPart C +app/auth.pycomments. They are deliberate liberalizations of §22.5's literal text and must be reconciled into the binding SPEC §22 at the M7 merge, not left as drift. - Per-endpoint contributor-vs-viewer granularity.
pr-draft/review/resolution-branchare visibility-gated but not contribute-gated (preserving public behavior). On a gated project a project_viewer can therefore reach them. Acceptable for M2; revisit in the M4 acceptance pass if it matters.
What lands on the operator's plate
- Nothing blocking. M2 is on
mainand pushed; no secret, deploy, or gitea admin gesture was required or left open. - Optional: the
sessions.jsontitle for 0065 (set by this session at publish). - When M3 starts: branch fresh from
main(it now contains M1+M2+v0.32.0).
Prompt the operator can paste into the next Claude Code session
Build RFC App M3 (multi-project: registry mirror + routing + runtime branding).
This session reserves its own ID — don't trust any number in this prompt.
Context: ~/git/wiggleverse.org/ben.stull/rfc-app (origin = git.wiggleverse.org,
SSH). §22 M1+M2 are merged to main (see ~/.claude memory
project_rfc_app_multiproject.md). Read that note, then docs/design/
multi-project-spec.md Part C "M3" + §22.2/§22.9/§22.10/§22.13. Branch fresh
from main.
M3 covers: the registry mirror (webhook + reconciler over REGISTRY_REPO
populating `projects`), the default-project bootstrap-id re-stamp (default →
config-derived slug, before any /p/<id>/ URL is public), the /p/<project>/ route
prefix with generic /e/<slug> + 308 redirects off the old corpus-root URLs, GET
/api/deployment + /api/projects/:id, the frontend cut from VITE_APP_NAME to
runtime config + per-project theme, the deployment directory + project switcher,
AND the deferred slug-keyed uniqueness/PK rebuilds enumerated in
026_projects.sql's header (must land before a 2nd project can collide).
New migrations must be numbered >= 027 (M1's projects migration is 026 after a
collision with v0.32.0's 025_retired_state). Tests: cd backend && python -m
pytest -q via the .venv there. Plan before building.