Commit Graph

112 Commits

Author SHA1 Message Date
Ben Stull 9275348c45 fix(§22/registry): prune projects absent from the registry on reconcile (v0.55.0)
The registry mirror was additive-only — it upserted the projects/collections a
deployment's projects.yaml declares but never removed ones it had dropped.
Re-pinning to a different registry stranded the old project's collections + its
cached entries as dead rows that, at scale, starved writes (PPE accumulated
~1.2k orphaned entry rows and had to be reset by hand).

`registry.refresh_registry(prune=True)` now calls `projects.prune_absent_projects`,
deleting a removed project with its collections and every project-scoped row
(the 7 project_id-keyed tables + 13 collection_id-keyed entry-corpus tables +
the non-keyed thread_messages descendant) in one FK-off transaction with a
`PRAGMA foreign_key_check` backstop that rolls back rather than leave a dangling
ref — mirroring restamp_default_project / reconcile_default_collection_id.

Scoped for safety: whole-project granularity (a present project is untouched);
the default project is never pruned; prune runs ONLY on the full-reconcile
triggers (startup + the periodic sweep), never on incidental refreshes (collection
create, webhook), so an in-app refresh can't wipe a project. Reached only after a
successful parse of a non-empty registry (parse_registry rejects empty; the read
raises on transport error), so a transient read can't trigger a wipe.

Second of the two §9-surfaced framework fragilities. No migration. backend 685
green (+5: prune removes-all/no-op/never-default/empty-rejected/incidental-no-prune
with FK-integrity backstop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 05:28:50 -07:00
Ben Stull f0533dc073 fix(§6/auth): reconcile by email in provision_user so OTC→OAuth doesn't 500 (v0.54.1)
The Gitea-OAuth callback's `auth.provision_user` matched an existing `users` row
only by `gitea_id`. A human who signed in first via OTC owns an email-only row
(`gitea_id` NULL); a later Gitea-OAuth sign-in with the same email missed that
row and INSERTed a new one, colliding on the `idx_users_email` case-insensitive
unique index → IntegrityError → 500 callback.

Fix (mirror of `otc.provision_or_link_user`): when no `gitea_id` row exists,
reconcile by email and link the OAuth identity (gitea_id/gitea_login/profile)
onto the existing email row. Owner-zero (§6.1) bootstrap applied on link
(matching the fresh-insert path); `permission_state` preserved so linking never
changes admission status as a side effect.

One §9-surfaced framework fragility of two. backend 680 green (+3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 05:01:02 -07:00
Ben Stull 24596842ea feat(§8.12/§8.3): main-view Ask cuts an edit branch and runs the question there (v0.54.0)
The AI "Ask" affordance (selection tooltip + prompt bar) had no response
surface on an entry's canonical `main` view: RFCView renders the human-
discussion panel there, not the chat panel, so a chat turn posted from the
reading view had nowhere to render — asking-while-reading silently did
nothing. AI chat is an editing activity (a turn can emit <change> proposals)
and only runs on an edit branch.

Option B: when Ask is invoked from main, transparently cut an edit branch via
the same dispatch as Start Contributing (promote-to-branch for active,
start-edit-branch for super-draft; idempotent — reuse an existing edit
branch), navigate onto it so the chat panel mounts, and run the question
(text + selected quote) as the branch's first chat turn once its view and
main_thread_id resolve. The turn fires from the message-load effect's
continuation (not a parallel effect) so a late message load can't clobber the
optimistic turn; a live ref keeps the latest submitChatTurn closure in reach.

Degrades gracefully: signed-out → login; a viewer who can't contribute has the
cut rejected server-side and the error surfaced (no spurious branch); asking
from a branch already, and Flag on main (human discussion thread), unchanged.

Frontend-only; the branch chat endpoints already work on a branch. SPEC §8.3
records the behavior; new RFCView unit tests + an e2e spec cover the flow.
backend 677 / frontend 66 / e2e 5 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:19:20 -07:00
Ben Stull 4e7410f90b fix(§22/G-15): make the branch/edit/body subsystem three-tier aware (v0.53.0)
§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.

- New single resolver: projects.content_repo_for_collection() +
  projects.entry_location(config, cid, slug) -> (org, repo, md_path)
  (collection -> project -> content_repo + subfolder; falls back to the
  default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
  branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
  resolution-branch), api_graduation (graduate/claim/retire/unretire +
  orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
  withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
  bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
  the webhook corpus-refresh dispatch, and the hygiene branch-delete now
  span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
  GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
  .../branches/{branch} disambiguate a slug across collections (G-5) and read
  the entry's own repo. Slug-only routes kept (now collection-aware via the
  cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
  threads them (mirrors the v0.52.1 entry-detail fix).

No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:39:46 -07:00
Ben Stull 948ee88160 fix(routing): render a 404 page for unmatched routes (v0.52.3)
The top-level catch-all silently redirected unknown paths to "/", and the
nested /admin/*, /p/:projectId/*, and /docs/* route groups had no catch-all
(invalid subpaths rendered a blank pane). Add a shared NotFound component and
wire it into all four route groups so a bad/typo'd URL shows a clear 404 with
a link home. Client-side 404 UI (SPA still serves HTTP 200).

Patch 0.52.2 → 0.52.3; CHANGELOG updated. Caught by the operator on PPE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:14:25 -07:00
Ben Stull 8e207a60e6 fix(admin): absolute sidebar nav links so /admin URLs don't accumulate (v0.52.2)
The /admin left-rail NavLinks used relative targets (to="users", etc.). Under
the /admin/* nested route a relative link resolves against the current URL, so
each click appended a segment — Users→Allowlist→Graduation yielded
/admin/users/allowlist/graduation instead of /admin/graduation. Use absolute
targets (/admin/<tab>) + `end` for exact active matching.

Patch 0.52.1 → 0.52.2; CHANGELOG updated. Caught by the operator on PPE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:44 -07:00
Ben Stull ba37da927a fix(§22.4a): scope RFCView entry-detail fetch to its collection (v0.52.1)
The §9 deployed-environment E2E harness (0.52.0), run against a PPE host
with per-collection-isolated content, surfaced a latent multi-collection
bug: RFCView computed the collection id from the route but called
getRFC(pid, slug) without it, so a named-collection entry was always
fetched via the project default-collection route — which 404s for an entry
that exists only in a named collection ("Error: Not found"; metadata panel
absent). Local/Tier-1 stacks masked it (same slug also reachable via the
default collection). Thread cid through all three getRFC call sites; re-run
the load effect on collection change.

Harness/test-infra (not in the deployed artifact):
- e2e: pre-record cookie consent via addInitScript (lib/fixtures.js) so the
  bottom-fixed consent banner can't intercept catalog row-select clicks on
  the slower deployed edge.
- testing/seed-ppe.sh: fail loudly on any non-2xx Gitea response (a
  swallowed 403 org-repo create had reached the deploy as a 502).
- testing/ppe-deploy-and-test.sh: seed via the Keychain admin token
  (write:organization needed to create the PPE repos); store the E2E secret
  newline-free; read EXPECT_VERSION from VERSION.

Patch bump 0.52.0 → 0.52.1; CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:45:01 -07:00
Ben Stull 83eafe72ee feat(e2e): deployed-environment E2E harness + gated test-auth (v0.52.0)
The §9 pipeline's PPE+E2E stage was unreachable: e2e/metadata.spec.js was
bound to Tier-1-only scaffolding (docker-seeded faceted collection,
SQLite-injected owner, Mailpit OTC sink). This makes the same suite run
against a deployed host.

- backend: POST /auth/test/login — fail-closed, secret-gated, single-
  identity owner test-login (404 unless E2E_TEST_AUTH_SECRET +
  E2E_TEST_AUTH_EMAIL both set; constant-time compare; loud startup warn).
  6 vertical tests; backend 665 green. Documented in backend/.env.example.
- e2e/lib/auth.js: branch on E2E_TEST_AUTH_SECRET (deployed test-login vs
  local Mailpit OTC); OWNER_EMAIL from E2E_OWNER_EMAIL. Spec unchanged so
  the localhost Tier-1 path keeps working.
- testing/seed-ppe.sh: seed a dedicated, prod-untouching PPE registry +
  content repo (faceted bdd collection) — real OHM content never touched.
- docs/design/2026-06-07-deployed-env-e2e-harness.md; CHANGELOG; VERSION
  + frontend/package.json -> 0.52.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:53 -07:00
Ben Stull 52f465b4dd release: v0.51.1 — collection-id divergence + faceted-catalog scoping fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:30:09 -07:00
Ben Stull cbaba76345 fix(§22): catalog scopes to the named collection in the URL (E2E-caught)
useCollectionId read useParams().collectionId, but the Catalog renders at
/p/:projectId/* — outside the c/:collectionId route — so it always fell back
to the default collection. The faceted filter (SLICE-3) and bulk action bar
(SLICE-5) therefore never scoped to a named (fields-bearing) collection in the
deployed app; the Catalog unit test had mocked useCollectionId, hiding it.
Resolve the /c/<id>/ segment from the pathname when the route param isn't in
scope. Caught by the new Playwright metadata suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:28:57 -07:00
Ben Stull 7886840362 fix(slice5): validate raw metadata input + prune stale bulk selections
Code-review follow-ups:
- Validate the raw submitted value before apply_values coerces it, in both
  the single-edit and bulk endpoints — a scalar set onto a tags field now
  rejects (422 / rejected) instead of silently char-splitting into a list.
- Catalog prunes bulk selections to the currently-visible (filtered) entries
  after each list fetch, so the bulk bar can't act on rows the user has
  filtered out of view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:03:29 -07:00
Ben Stull a2b55f94ce release(slice5): v0.51.0 — bulk tag/untag metadata (§22.4a PUC-2 SLICE-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:52:16 -07:00
Ben Stull edbf68909a feat(slice5): catalog multi-select + bulk action bar (§22.4a PUC-2)
Faceted catalog rows are selectable for contributors; a sticky bulk bar
(BulkActionBar) drives set/add/remove gestures from the collection fields
schema, calls bulkEntryMeta (one commit), toasts applied/skipped counts,
and re-fetches. Non-contributors and legacy (no-fields) collections see
no selection (INV-5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:51:48 -07:00
Ben Stull 0d2fdfacf2 release(slice4): v0.50.0 — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:17:25 -07:00
Ben Stull abd17a6cc8 feat(slice4): schema-driven metadata edit panel in RFCView (§22.4a PUC-1 §5.2)
MetadataFieldsPanel renders one control per declared field (enum→select,
tags→chips, text→input), read-only without contribute access, saving changed
values via saveEntryMeta (direct sidecar commit). Wired into the canonical
main view, gated on the collection declaring fields (INV-5). saveEntryMeta
API client added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:14:13 -07:00
Ben Stull 6bb6d654fa release(slice3): v0.49.0 — faceted left-pane filtering (§22.4a SLICE-3) 2026-06-07 17:43:56 -07:00
Ben Stull 276a625997 test(slice3): Catalog faceted-pane component test (§22.4a PUC-3) 2026-06-07 17:41:04 -07:00
Ben Stull ae3afe2f48 feat(slice3): faceted catalog pane gated on collection fields (§22.4a §5.1) 2026-06-07 17:40:29 -07:00
Ben Stull ae083bfcaa feat(slice3): FacetGroups faceted-pane component (§22.4a §5.1) 2026-06-07 17:38:35 -07:00
Ben Stull bcce40d2cb feat(slice3): listRFCs accepts facet selections, returns {items,facets} (§22.4a) 2026-06-07 17:38:16 -07:00
Ben Stull e336e31812 v0.48.0 — SLICE-2: collection field schema + central validation (§22.4a)
Collections declare a `fields:` schema in `.collection.yaml`; entries carry
typed metadata (enum/tags/text). New central `metadata_schema` module parses
the schema leniently (INV-3) and validates entry values — advisory at read
(corpus mirror flags violations as `metadata_malformed` without hard-failing),
the enforcement point for the write boundary (edit endpoints land SLICE-4/5).

- app/metadata_schema.py: parse_fields (lenient/normalizing) + validate
- registry.parse_collection_manifest reads `fields:` into collection config
- collections.get_collection unpacks `fields`; served by the collection API
- cache._refresh_collection_corpus validates each entry advisory-only

Non-breaking, opt-in: no `fields:` → unchanged (INV-5); default `document`
collection declares none (N=1 unchanged). No DB migration — schema rides in
collections.config_json. SLICE-2 of
docs/design/2026-06-06-configurable-collection-metadata.md §7.2.

Backend suite green (601 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:06:04 -07:00
Ben Stull f05ee59763 v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:24:27 -07:00
Ben Stull 5cb5f4a4a2 v0.46.2 — SLICE-0: §22.4a contract amendment (entry metadata is collection-configured, not type-driven)
Reframes binding SPEC.md §22.4a so a collection's entry metadata schema is
collection-configured — a `fields:` schema in `.collection.yaml` plus per-entry
`<slug>.meta.yaml` sidecars — rather than a frontmatter schema hard-wired to the
collection's `type`. Item 3's type-specific surfaces (release planning;
bdd scenario/coverage views) are deferred to a future design; bdd coverage is
recorded as a future `ref`-field surface rendered as hyperlinks (no cross-
collection corpus fusion). `type` still selects terminology (entry noun,
v0.45.0) + default initial_state/review posture (§22.4b-c).

SLICE-0 of docs/design/2026-06-06-configurable-collection-metadata.md (§7.2);
supersedes the per-type-surfaces draft (D11). Doc-only: per-type frontmatter
validation was never implemented, so no operator action, no schema/behavior
change. Sidecar storage + validation + UI arrive in SLICE-1+.

- SPEC.md §22.4a reframed; document/specification/bdd bullets updated; metadata
  amendment blockquote added.
- SPEC.md §2 and §22 forward-pointer blockquotes: "type-dependent frontmatter
  schema" -> "collection-configured, not type-driven (§22.4a, as amended)".
- CHANGELOG 0.46.2; VERSION + frontend/package.json -> 0.46.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:32:27 -07:00
Ben Stull 2f507e5721 v0.46.1 — fix migration 029 for the §22.13 re-stamp aftermath (OHM deploy fault)
Deploying the three-tier series onto OHM crash-looped on migration 029:
`NOT NULL constraint failed: cached_branches__new.collection_id`, then a UNIQUE
collision. Root cause: the v0.39.0 default→ohm re-stamp updated
cached_rfcs.project_id but NOT the entry-satellite tables, so ~1.3k
cached_branches rows were stranded at project_id='default' — which 029's
per-project collection backfill can't map (NULL), some of which also duplicate
freshly-re-mirrored 'ohm' rows (UNIQUE), and some of which reference entries
that no longer exist.

Fix — a repair prologue at the top of 029 (no schema change):
- drop stale rows that duplicate an already-correctly-stamped row (keep the
  fresh copy) for the branch-keyed tables;
- re-derive each satellite's project_id from its entry (cached_rfcs, by slug);
- drop rows whose entry no longer exists (stale cache, rebuildable from gitea).
A no-op on clean/fresh deployments (empty or consistent satellites).

Validated against a snapshot of the live OHM DB: 029→032 apply cleanly, zero
NULL collection_id, FK check clean, watches/RFCs/branch_visibility preserved,
cached_branches 1397 → 1291 (−67 no-RFC, −39 dups). Fresh-install path
unchanged: existing 029 suite green + a new regression test for the
stale/dup/orphan shape. Full backend suite 547 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:02:53 -07:00
Ben Stull fcc3c84d76 §22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0
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>
2026-06-06 02:11:16 -07:00
Ben Stull b392fa923c §22 S6: release v0.45.0 — two-project/multi-collection test + §20.4 changelog
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>
2026-06-06 01:43:09 -07:00
Ben Stull 839404da0c §22 S6: type-driven entry noun (§22.4a) — backend source of truth + chrome
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>
2026-06-06 01:35:57 -07:00
Ben Stull 33212c71e4 §22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)
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>
2026-06-06 01:02:16 -07:00
Ben Stull ff54632657 §22 S4: release v0.43.0 — invitation surfaces + role-aware empty states (@S4)
Bump VERSION + frontend/package.json to 0.43.0, add the CHANGELOG entry
(minor, non-breaking — additive endpoints + UI, no migration, no change to
existing authz outcomes), and mark Part E slice S4 shipped in the design doc.

Completes @S4 (C2.1–C2.7 invitation + C3.3–C3.5 role-aware empty states).
Next: S5 (in-app create-project + the global directory, C3.1–C3.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:30:12 -07:00
Ben Stull 93cf506059 §22 S4: invitation modal + role-aware empty states (@S4 C.2, C.3)
The frontend surfaces for the S4 backend (Part E S4 / Part C.2, C.3):

- ScopeMembersModal — the Owner-only scope-role invitation surface (sibling of
  the per-RFC InvitationsModal): grant {owner, contributor} by email at the
  project or a single collection, with a scope picker bounded to the inviter's
  reach (no parent-grant-child-exclude option, C.2.5), plus a current-members
  list with revoke. Opened from the directory's owner-only "Members" control;
  contributors never see it (C.2.4).
- CreateCollectionModal — surfaces the S2 create-collection endpoint (was
  UI-less), gated on the viewer's can_create_collection capability.
- CollectionDirectory — reads the new `viewer` capability block to render the
  role-aware empty states: a project Owner sees "Create your first collection"
  (C3.3); a contributor without create rights sees the bare empty directory
  (C3.4); an Owner with management reach sees the "Members" control.
- Catalog — the empty collection shows "Propose the first entry" to a viewer
  who may contribute here (C3.5), and the propose control is gated on the
  collection's can_contribute flag (anon keeps the sign-in prompt, S2).
- api.js — getCollection, listScopeMembers, grantScopeMember, revokeScopeMember.

Frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:27:49 -07:00
Ben Stull c2f566512a §22 S3: scope-role enforcement + collection-grain visibility (@S3) — v0.42.0
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>
2026-06-05 18:07:51 -07:00
Ben Stull 55d04ce4ca §22 S2: release v0.41.0 — create & navigate a second collection (@S2)
Minor, non-breaking: named collections via .collection.yaml, create-collection
endpoint, collection-scoped serve/propose, and the /p/<project>/ collection
directory. Completes acceptance @S2 (C3.6). 478 backend + 26 frontend green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:17:31 -07:00
Ben Stull 17bdd5fd9a §22 S2: collection directory at /p/<project>/ (1 → redirect, 2+ → list)
Replace DefaultCollectionRedirect with a CollectionDirectory that lists the
project's visible collections, or redirects into the sole one when there is
exactly one (preserving the S1 C3.7/C3.8 single-collection UX). The
create-first-collection empty state is S4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:14:18 -07:00
Ben Stull 2b32e124ab §22 S2: Catalog + propose scoped to the active collection
Catalog reads the /c/:collectionId/ segment via useCollectionId and fetches the
collection-scoped catalog, building entry/proposal links with the active
collection; the propose modal threads the active collection so a propose from a
named collection targets it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:12:45 -07:00
Ben Stull 98eea3e2d6 §22 S2: collection-scoped frontend path + API helpers
useCollectionId() hook; listRFCs/getRFC/proposeRFC take an optional collection
id and target the /collections/<cid>/ routes; add listCollections +
createCollection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:11:27 -07:00
Ben Stull aaf7b09bbe §22 S1: release v0.40.0 — three-tier collection grain (breaking URL + migration 029)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:31:43 -07:00
Ben Stull 4f72aa31e0 §22 S1: frontend /c/<collection>/ route layer + C3.7 + legacy-URL redirects
- entryPaths builders carry the /c/<collection>/ segment (default collection in S1)
- /p/:projectId/* gains c/:collectionId/ corpus routes; serving stays project-scoped
- DefaultCollectionRedirect (C3.7: project landing -> default collection)
- LegacyCorpusRedirect (v0.35.0 /p/<p>/e/<slug> bookmarks -> /c/default/, query preserved)
- entryPaths unit test; build + vitest green (18 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:30:18 -07:00
Ben Stull 33c67ccc09 §22.13 step 1: default-project-id re-stamp (v0.39.0)
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>
2026-06-04 07:45:10 -07:00
Ben Stull fec51bdbb6 §22 M3-backend Plan B (write path, propose): project-scoped propose (v0.38.0)
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>
2026-06-04 07:02:19 -07: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 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 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 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 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 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 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 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 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 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