Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.
Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
requester, requested_role, message, status, granted_role); one-open-per
(scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
{id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
Accept grants via memberships.grant; the request POST does not require the
scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
_scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.
Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
collection directory + catalog footer; JoinRequestRow in the inbox.
Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.
Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The release wrap for the S6 scope shipped this slice (SPEC merge, per-collection
enabled_models, type-driven entry noun).
- test_s6_two_project_multicollection.py: an integrative pass proving the new
per-collection knobs (§22.12 enabled_models, §22.4a noun) resolve independently
per collection across two projects with no cross-project/-collection bleed.
- CHANGELOG.md 0.45.0: the §20.4 entry + upgrade-steps (migration 031 is
additive + automatic; per-collection enabled_models and typed collections are
optional). Notes the two OPEN S6 items carried to a follow-up slice (per-type
surfaces; request-to-join + cross-collection inbox).
- VERSION + frontend/package.json → 0.45.0.
Gate: backend 530 passed, frontend 30 passed, build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The displayed noun for an entry is a framework concept keyed on the collection's
immutable type — document→"RFC", specification→"Spec", bdd→"Feature" — not
deployment content. The chrome reads it from the API instead of hardcoding "RFC".
- collections.ENTRY_NOUN + entry_noun(type) (unknown type → generic "RFC").
- Surfaced as entry_noun on get_collection, list_collections items, the
/api/deployment directory items, and GET /api/projects/:id.
- Frontend: Catalog reads entry_noun for the "+ Propose New <noun>" control;
ProposeModal fetches the active collection's noun for its title + field copy.
- test_s6_entry_noun_vertical.py: map + API surfacing (collection + directory).
Scope note: this is §22.4a item (2) — terminology. The deeper type-module work
(item 1 per-type frontmatter schemas; item 3 specification release-planning +
bdd scenario/coverage surfaces) is flagged OPEN in the design doc and is handed
off to a follow-up spec+slice.
Backend 528 passed; frontend 30 passed + build green. Part of v0.45.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A collection's .collection.yaml may carry an enabled_models list that NARROWS
its project's universe, which narrows the deployment ENABLED_MODELS. Resolution
(extending §6.6/§6.7): funder ∩ per-entry models ∩ collection ∩ project, with
the operator providers as the ceiling.
- migration 031: additive collections.config_json (parallels projects.config_json).
- registry.parse_collection_manifest: read enabled_models into CollectionEntry.config
(absent = inherit, present incl. [] = narrow; [] opts the collection out of AI);
reject a non-list. _upsert_named_collection persists config_json. The default
collection (from projects.yaml) leaves it NULL — it inherits the project.
- models_resolver: _scope_narrowed_universe narrows the operator universe by the
entry's project then collection enabled_models; the funder universe is bounded
by it too. A collection cannot widen its project (narrowing from the ceiling).
- collections.get_collection surfaces enabled_models; GET
/api/projects/:id/collections/:cid returns it.
- test_s6_collection_models_vertical.py: 9 cases (parse, the narrowing chain
incl. opt-out + cannot-widen, API surfacing).
Backend: 525 passed. Per SPEC §22.12. Releases as part of v0.45.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the global-Owner create-project action and the role-aware deployment
directory, completing slice S5 of the three-tier refactor (release v0.44.0,
minor/non-breaking — no migration). Per
docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5),
Part C.3 (C3.1–C3.2).
Backend:
- auth.can_create_project — global-Owner gate (deployment owner/admin or an
explicit scope_type='global' Owner grant).
- bot.create_project — provision the Gitea content repo (seed README so main
exists), read+append+commit projects.yaml in the registry repo, audit-log
(create_project). The bot stays the only git writer (§1).
- POST /api/projects (api_deployment) — global-Owner gated; validates the id
(slug, not 'default'), name, type, visibility, content_repo; commits via the
bot, then re-mirrors the registry so projects + default collection rows flow
from git (§22.2). GET /api/deployment gains viewer.can_create_project and
default_project_readable. make_router now takes gitea + bot.
Frontend:
- api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable
+ refresh; DeploymentLanding redirects into the default only when readable
(gated/absent default falls through to the directory, no 404 bounce).
- Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA;
C3.2 "Nothing has been shared with you yet") + Owner-only "New project"
control + CreateProjectModal.
Tests: backend test_create_project_vertical.py (vertical + gates + the
deployment empty-state signals); frontend Directory.test.jsx empty-state cases.
Also: ignore the session-local .superpowers/ tooling dir.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).
- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
preserving a stronger child grant — no negative override), revoke, list,
user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
POST keying on optional collection_id so the invite UI's one control maps
to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
contributors refused (C.2.4); a pending grantee's row is recorded but
confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
can_invite, can_contribute, role) on the project/collection GETs to drive
the C.3 role-aware empty states.
Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.
- migration 030: memberships.scope_type += 'global' (the global RFC
Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
most-permissive, no negative override; can_read_collection /
can_contribute_in_collection / is_collection_superuser /
can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
public (404, omitted from the directory) yet visible+listed for a
scope-role holder; a collection may be set only as strict or stricter
than its project (public < unlisted < gated), validated at create and
clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
PR/discussion/contribution moderation) re-pointed from the project grain
to the entry's collection.
- create-collection authority widened to a project/global-scope grant
holder (§B.1), not only a deployment owner/admin.
Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).
Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Anonymous reader of an empty public collection gets a 200 empty catalog and no
propose action (the propose route rejects anonymous); the Catalog footer's
'Sign in to propose' prompt is the existing anonymous affordance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/projects/<id>/collections, owner/admin-gated, commits a
.collection.yaml to the content repo main via bot.create_collection, then
re-mirrors the registry so the collections row appears (§22.2). Adds GET
list/one collection routes. Extends FakeGitea to model directory listings so
the mirror's content-repo walk is exercised end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refactor the project-scoped serve/propose internals into collection-grained
helpers (_list_rfcs_for_collection, _get_rfc_for_collection,
_propose_into_collection); add routes under
/api/projects/<id>/collections/<cid>/rfcs[/<slug>|/propose]. Propose writes
the entry under the target collection's <subfolder>/rfcs via a new rfcs_dir
param on bot.open_idea_pr. Default-collection routes preserved as wrappers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refresh_meta_repo now iterates a project's collections and keys cached_rfcs by
collection_id; the default collection (subfolder '') keeps the shipped rfcs/
root path. N=1 default path unchanged (469 green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discover named collections by walking each project's content-repo root for
<subdir>/.collection.yaml; parse + upsert with immutable-type enforcement
(§22.4a) and project-visibility inheritance. The default collection still
flows from projects.yaml.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
projects.restamp_default_project(config): at startup after the registry mirror,
renames project_id from the M1 bootstrap 'default' to the configured
DEFAULT_PROJECT_ID across every project-scoped table (discovered by column) and
drops the stale 'default' projects row, so a deployment's original corpus lands
at a meaningful /p/<id>/ and 'default' is never a public URL. FK off for the
rename (parent+children move together) + foreign_key_check backstop. Idempotent;
no-op unless DEFAULT_PROJECT_ID is set to a non-'default' value.
test_restamp_default_project.py (3 tests). 450 backend green.
This is the last framework piece for OHM's clean /p/ohm/ cutover.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A new entry can be proposed into a specific project; it lands in that project's
content repo and shows under that project's proposals. A non-default project is
no longer read-only.
- api.py: POST /api/projects/{pid}/rfcs/propose (propose body extracted into a
project-parameterized helper; unscoped /api/rfcs/propose kept as default
compat). Slug uniqueness, idea-PR reservation, landing state, and the
proposed_use_cases row scoped to the target project. GET
/api/projects/{pid}/proposals.
- cache.py: refresh_meta_pulls loops every project's content_repo, stamping
cached_prs.project_id; projects.content_repo(pid) helper.
- frontend: proposeRFC(projectId,…)/listProposals(projectId); ProposeModal
takes projectId; App resolves current project from the /p/<id>/ URL; Catalog
lists that project's proposals.
- tests: test_project_scoped_propose.py (lands scoped + gated 404). 447 backend
+ 11 Vitest green; clean build.
Known limitation: branch/PR/graduation edit flows + default-id re-stamp not yet
scoped (next slice). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Wave 9 follow-up to roadmap item #30 (Session 0017.0 shipped #30 as v0.19.0;
v0.20.0 lands the operator-feedback follow-ups on top).
1. Specs on /docs/specs/<name> (backend docs_specs.py + frontend DocsSpec.jsx
+ DocsSpecsIndex.jsx). Configured via OHM_DOCS_SPECS; framework default
carries OHM's two specs (rfc-app/SPEC.md + flotilla SPEC.md). Runtime
fetch from gitea raw with 5-min TTL cache, mirroring docs_sessions.py.
2. Nested flyout nav hierarchy (DocsLayout.jsx). Sessions render as a tree
with transcripts nested under each session row (labeled by .N ordinal).
New Specs section between User Guide and Sessions.
3. /docs/sessions/<NNNN> body-list removed (DocsSessionIndex.jsx). Body
becomes a session-overview card; navigation lives in the left nav.
19 new pytest cases for docs_specs (332 backend total green). Frontend
build clean. Sync frontend/package-lock.json version drift (0.15.0 → 0.20.0)
alongside the VERSION + package.json bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the four read endpoints the v0.19.0 /docs/sessions/* surface
mounts on top of:
- GET /api/docs/sessions/manifest → sessions.json (title map)
- GET /api/docs/sessions/about → README.md
- GET /api/docs/sessions/<NNNN>/index → per-session transcript list
- GET /api/docs/sessions/<NNNN>/<file> → transcript body
The framework mediates the gitea fetch so the rendered surface
inherits the same chrome as the v0.14.0 /docs route and the browser
makes no cross-origin call. Reads are aggressively cached in-process
(60s for the manifest, 5min for content) so the framework doesn't
hammer git.wiggleverse.org under normal traffic. Negative results
(gitea 404) are also cached at the content TTL to absorb the
expected empty-state at deploy-time (the parallel
ohm-session-history repo restructure ships in driver subsession
0017.2). All four endpoints are anonymous-reachable, sibling to
/api/philosophy and /api/docs.
Path validation gates the network: only /^\d{4}$/ session dirs and
the full SESSION-NNNN.M-TRANSCRIPT-...md filename shape pass to the
upstream. Legacy flat-root names (e.g. SESSION-A-TRANSCRIPT.md) and
path-traversal attempts are rejected 400 before any fetch.
18 new tests cover the happy paths, 404 empty-states, 502 upstream
errors, path-validation rejections, and the cache-hit-within-TTL
contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `POST /api/webhooks/email-bounce` body accepts a new optional
`message_id` field. When supplied, the handler looks up the
matching row in `outbound_emails` and stamps status='bounced' +
appends "bounce (<kind>)" to the error column. The response
gains a `correlated_id` field (nullable: null when no
message_id was provided or no row matched).
Hard-bounce -> `email_opt_out_all = 1` still fires regardless
(the v1 contract from api_notifications.py:486-498); Slice 5
just adds the per-message audit trail on top.
Providers that don't surface Message-ID get the legacy v1
behavior — match by email, flip the global opt-out, leave
correlated_id null. Providers that replay an old bounce with a
message_id the framework no longer has log an INFO line but
still 200 (the bounce path can't refuse just because a row was
pruned).
Test update: `test_bounce_webhook_refuses_unsigned_when_secret_configured`
in test_e2e_smoke.py was asserting on the exact response shape
{ok, matched}; v0.18.0 adds `correlated_id`. The test now asserts
on the new shape; documented in CHANGELOG.
4 new tests covering: bounce with message_id stamps the row;
unknown message_id is logged + does not crash; bounce without
message_id still flips opt-out (backward compat); bounced rows
surface in the admin endpoint with `?status=bounced`.
Full suite: 295 passed.
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.