Files
rfc-app/docs/superpowers/specs/2026-06-03-m3-backend-design.md
T
Ben Stull 6f356d3598 docs: M3-backend design spec (§22 registry mirror + data spine + APIs)
Brainstormed design for the backend half of §22 slice M3, split at the
backend/frontend seam. Covers migration 027 (type/initial_state columns,
the 12-table PK rebuilds, default→slug re-stamp), the app/registry.py
mirror over REGISTRY_REPO, GET /api/deployment + /api/projects/:id, and
the initial_state/unreviewed review semantics. Routing, runtime branding,
directory, and switcher are deferred to a later M3-frontend spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:59:20 -07:00

16 KiB
Raw Blame History

M3-backend — §22 multi-project: registry mirror + data spine + APIs

Design spec for the backend half of §22 slice M3 ("Registry mirror + routing + runtime branding"). The roadmap bundles M3 as one slice; this session splits it at the natural backend/frontend seam. M3-backend (this doc) ships the data spine, the registry mirror, the two runtime-config APIs, and the entry-state/review semantics. M3-frontend (a separate spec) ships /p/<project>/ routing, the 308 redirects, the VITE_APP_NAME→runtime-config cut, the per-project theme overlay, the deployment directory at /, and the project switcher — all consuming the APIs defined here.

Section references §22.x point at docs/design/multi-project-spec.md (the draft §22 + slicing plan). The SPEC.md §22 merge itself lands in M7.

Status

  • Date: 2026-06-03
  • Slice: §22 M3 (backend half). M1 + M2 landed and merged to main.
  • Version impact: minor bump, breaking (pre-1.0) — see §8.

Goal

After M3-backend, the framework learns its projects from a git registry (not from META_REPO), the projects cache table and all slug-bearing tables are keyed by (project_id, …) so a second project can exist without collision, the default project's identity is re-stamped to its real slug while no /p/ URL is yet public, and the runtime exposes deployment + project config over two new endpoints. The entry-state/review semantics (initial_state, unreviewed) ship complete even though OHM (a document/super-draft project) does not yet exercise them.

Non-goals (M3-frontend, later): /p/<project>/ routing, 308 redirects off /rfc/<slug>, runtime branding in the UI, theme application, the deployment directory, the project switcher, the catalog's unreviewed-filter UI.

Decisions taken in brainstorming

  1. Scope split — backend spine first; frontend surfaces are a separate spec/session.
  2. Review machinery — build the full initial_state / unreviewed plumbing now (parse + columns + mirror + landing logic + mark-reviewed + catalog filter query side), per the spec's M3 bundle, even though no live project exercises it yet.
  3. Config cut — hard cut. REGISTRY_REPO required (loud fail if unset), META_REPO removed. Upgrade-steps document the manual registry creation.
  4. Re-stamp — rewrite project_id everywhere: rename projects.id default → the config slug and rewrite every child row, folded into the same create-copy-drop-rename rebuild that adds the project_id FK. One identifier; DB and URL agree.
  5. Mirror structure — a self-contained app/registry.py module (not folded into cache.py), driven by the existing webhook dispatcher + the existing Reconciler.sweep().

1. Migration 027_projects_activate.sql

Runs while default is still the sole project and no /p/ URL exists — the safe window for an identity rewrite. One transaction (SQLite DDL is transactional): the deployment either fully advances to 027 or stays on 026.

DEFAULT_PROJECT_ID is read at migration time, so it must be set before the upgrade deploy (documented in §8). The slug it names is referred to below as <slug>; absent the env var, <slug> stays default.

1a. Additive columns

  • projects:
    • type TEXT NOT NULL DEFAULT 'document' CHECK (type IN ('document','specification','bdd'))
    • initial_state TEXT NOT NULL DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active'))
  • cached_rfcs:
    • unreviewed INTEGER NOT NULL DEFAULT 0
    • reviewed_at TEXT
    • reviewed_by TEXT

1b. New deployment singleton table

Holds deployment-level identity mirrored from the registry's deployment: block (rather than overloading projects):

CREATE TABLE IF NOT EXISTS deployment (
    id          INTEGER PRIMARY KEY CHECK (id = 1),
    name        TEXT,
    tagline     TEXT,
    registry_sha TEXT,
    updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO deployment (id) VALUES (1);

1c. PK / uniqueness rebuilds (the 12 deferred tables)

Per migration 026's deferred block, create-copy-drop-rename each table to fold project_id into the key and add project_id … REFERENCES projects(id) ON DELETE CASCADE:

Table Key change
cached_rfcs PK (slug)(project_id, slug)
cached_branches UNIQUE (rfc_slug, branch_name)+project_id
branch_visibility UNIQUE (rfc_slug, branch_name)+project_id
branch_contribute_grants UNIQUE (rfc_slug, branch_name, grantee_user_id)+project_id
stars UNIQUE (user_id, rfc_slug)+project_id
watches UNIQUE (user_id, rfc_slug)+project_id
pr_seen UNIQUE (user_id, rfc_slug, pr_number)+project_id
branch_chat_seen UNIQUE (user_id, rfc_slug, branch_name)+project_id
funder_consents PK (user_id, rfc_slug)+project_id
rfc_collaborators UNIQUE INDEX (rfc_slug, user_id)+project_id
contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending+project_id
proposed_use_cases UNIQUE (scope, pr_number)+project_id

cached_prs is already globally unique (repo is the full org/repo string, distinct per project) — no rebuild.

The copy step writes <slug> in place of default for project_id, so these 12 tables are re-stamped for free (§1d).

1d. Re-stamp default<slug>

  • UPDATE projects SET id = '<slug>' WHERE id = 'default' (only when <slug>default).
  • The 12 rebuilt tables: re-stamped during their copy (§1c).
  • The remaining 7 tables that carry project_id but need no key change get a plain rewrite: UPDATE <t> SET project_id = '<slug>' WHERE project_id = 'default' for threads, changes, notifications, actions, pr_resolution_branches, rfc_invitations, cached_prs.

End state: a single project_id value DB-wide.

1e. Notes

  • Migration 016 is absent from the on-disk sequence (015 → 017); the runner already tolerates the gap (the app runs today). Do not renumber. New file is 027.
  • PRAGMA foreign_keys is honored going forward; the FK lands on the 12 rebuilt tables. The other 7 keep app-layer integrity (matching today's posture for non-rebuilt tables).

2. Registry format + app/registry.py

2a. projects.yaml (root of REGISTRY_REPO)

deployment:
  name: Open Human Model         # replaces VITE_APP_NAME (M3-frontend consumes)
  tagline: ...
projects:
  - id: ohm                      # url-stable slug, unique in the deployment
    name: Open Human Model
    type: document               # document | specification | bdd — immutable
    content_repo: ohm            # repo under the deployment's Gitea org
    visibility: public           # gated | public | unlisted
    initial_state: super-draft   # optional; defaults from type
    enabled_models: [claude, gemini]   # optional; falls back to ENABLED_MODELS
    theme: { accent: "#5b5bd6" }       # optional; M3-frontend consumes

2b. refresh_registry(config, gitea) -> RegistryResult

The config-side analogue of cache.refresh_meta_repo. Fetches projects.yaml from REGISTRY_REPO at HEAD, parses, validates, and upserts:

  • Each project → projects row: id, name, type, content_repo, visibility, initial_state, plus config_json (JSON blob for theme, enabled_models), plus registry_sha (commit SHA, provenance).
  • The deployment: block → the deployment singleton (name, tagline, registry_sha).

Projects present in the table but absent from the registry are not deleted in M3-backend (archival semantics are out of scope; a removed entry simply stops being refreshed). This is noted as a known limitation; revisit if/when project archival is specced.

2c. Validation (loud)

Per the framework's separation-of-concerns rule, malformed config fails visibly rather than shipping wrong content silently:

  • Each project requires id, name, type, content_repo.
  • type ∈ {document,specification,bdd}; visibility ∈ {gated,public,unlisted}.
  • initial_state defaults from type when omitted: document/specificationsuper-draft, bddactive. When present it must be a valid §2.4 super-draft entry-state value.
  • id values unique and slug-shaped (^[a-z0-9][a-z0-9-]*$).
  • type is immutable: an incoming type differing from the stored row's is rejected (the entry is skipped, the rest proceed; logged loudly).
  • Default-id consistency: the re-stamped default id (DEFAULT_PROJECT_ID, else default) MUST appear as an id in the registry, or the registry is inconsistent with config → surfaced loudly.

2d. Wiring (Option A)

  • Webhook (app/webhooks.py): add a branch — if the pushed repo full_name matches REGISTRY_REPO, call registry.refresh_registry(...). Same HMAC-verified /api/webhooks/gitea dispatcher; no new endpoint. Add REGISTRY_REPO to the set of repos the dispatcher recognizes.
  • Sweep (cache.Reconciler.sweep()): add one await registry.refresh_registry(...) at the top of each pass, so the safety-net loop keeps projects in sync if a webhook is missed.
  • Startup (main.py lifespan): run refresh_registry once after migrations. This replaces M1's seed_default_project (which is removed).

2e. Failure posture

  • Startup / first boot: if REGISTRY_REPO is unset/unreachable, or projects.yaml is missing or fails validation, the app fails loudly (refuses to start) — there is no last-known-good to serve.
  • Running deployment: a malformed projects.yaml pushed in a later PR is logged and skipped, leaving the last-good projects rows intact — a bad config PR must not take the deployment down. This mirrors how the corpus reconciler tolerates a bad content push today.

3. Config (app/config.py)

  • registry_repo: required — construction fails loudly if unset (matching the other required vars). Add registry_repo_full{gitea_org}/{registry_repo}.
  • meta_repo and meta_repo_full: removed.
  • default_project_id: optional; consumed by migration 027 (re-stamp) and by refresh_registry (the §2c consistency gate).
  • enabled_models: unchanged — the deployment-level fallback for a project's optional enabled_models.
  • app/projects.py: seed_default_project retired (superseded by the mirror). DEFAULT_PROJECT_ID constant and resolution helpers retained as needed.

4. APIs — app/api_deployment.py

A new sub-router mounted in app/api.py.

GET /api/deployment

Returns { name, tagline, projects: [...] }. The deployment name/tagline come from the deployment singleton. The project list is filtered by caller visibility (§22.5):

  • public projects → visible to everyone (incl. anonymous).
  • gated projects → only when the caller is a member (visible_project_ids from M2's resolver).
  • unlisted projects → omitted entirely (reachable only by direct id).

Each item: { id, name, type, visibility } — enough for the M3-frontend directory + switcher. (Theme is fetched per project.)

GET /api/projects/:id

Returns { id, name, tagline, type, visibility, initial_state, theme }. Guarded by require_project_readable(user, id) — 404 for a non-member of a gated project, reusing the M2 resolver. unlisted is readable here by direct id (it is hidden only from enumeration).


5. Entry-state & review semantics

5a. Frontmatter (app/entry.py)

Parse three new fields, lenient (default unreviewed=false, nulls): unreviewed: bool, reviewed_at, reviewed_by. Add to the Entry dataclass. These are git-truth (§2 frontmatter) so they survive a cache rebuild, exactly like state.

5b. Cache mirror (app/cache.py)

_upsert_cached_rfc writes the three fields into cached_rfcs (unreviewed, reviewed_at, reviewed_by).

5c. Entry-landing path

When a creating idea-PR merges (§2.4), resolve the project's initial_state:

  • super-draft → today's behavior unchanged (propose → super-draft → graduate).
  • active → land the entry active with unreviewed = true, skipping the §13 graduate gate. The exact merge-handling site (in the PR-merge reconcile path) is located during implementation.

5d. Mark-reviewed

POST /api/projects/:pid/rfcs/:slug/mark-reviewed. Authority: is_project_superuser (project_admin or deployment owner/admin) — the same tier that graduates an entry. Effect: the bot writes unreviewed: false + reviewed_at/reviewed_by into the entry frontmatter (a git commit, paralleling graduate), which the mirror then reflects into cached_rfcs. Stamps provenance paralleling graduated_at/graduated_by.

5e. Catalog filter (query side)

GET /api/rfcs (and the project-scoped form) gains an unreviewed=true query param that filters on the cached column — the owner's worklist. The UI for it is M3-frontend; only the query side ships here. unreviewed applies to active entries only.


6. Testing

  • Migration 027: seed a 026-shaped DB with rows under project_id='default'; run 027; assert: new columns present; the 12 tables rebuilt with composite keys + FK; all 19 project_id-bearing tables re-stamped to <slug>; row counts preserved; FK integrity on; idempotent re-run is a no-op.
  • registry.py: valid projects.yaml upserts all fields + registry_sha; each validation failure rejected (bad enum, missing field, dup id, type mutation, missing default id); initial_state type-default applied; startup-strict vs running-tolerant posture.
  • APIs: /api/deployment visibility filtering across gated/public/unlisted × member/non-member/anonymous; /api/projects/:id 404 gate for gated non-member, 200 for unlisted-by-id.
  • Review flow: initial_state: active lands unreviewed=true; mark-reviewed authority (allow superuser, deny others) + frontmatter write + mirror reflection; catalog unreviewed filter returns the worklist.
  • Two-project isolation: extend test_multi_project_authz_vertical.py with a genuine second registry project sharing a slug with the first, proving the PK rebuilds isolate them (the core point of M3's activation).

7. File-touch summary

New

  • backend/migrations/027_projects_activate.sql
  • backend/app/registry.py
  • backend/app/api_deployment.py
  • tests: backend/tests/test_migration_027.py, test_registry.py, test_api_deployment.py, test_review_flow.py; extend test_multi_project_authz_vertical.py

Modified

  • backend/app/config.py (registry_repo required; meta_repo removed; default_project_id)
  • backend/app/webhooks.py (registry-repo branch)
  • backend/app/cache.py (Reconciler.sweep registry call; _upsert_cached_rfc review fields)
  • backend/app/entry.py (frontmatter fields)
  • backend/app/api.py (mount api_deployment; unreviewed filter on /api/rfcs)
  • backend/app/main.py (startup refresh_registry; drop seed_default_project)
  • backend/app/projects.py (retire seed_default_project)
  • backend/.env.example (REGISTRY_REPO, DEFAULT_PROJECT_ID; remove META_REPO)

8. Versioning & upgrade

Minor bump, breaking (pre-1.0). VERSION + frontend/package.json#version move together (§20). CHANGELOG.md gets a breaking entry with an upgrade-steps block:

  1. Create a registry repo under the deployment's Gitea org.
  2. Author projects.yaml: a deployment: block (name, tagline) and one projects: entry for the existing corpus — id: <slug>, name, type: document, content_repo: <old META_REPO value>, visibility: public.
  3. Set env: REGISTRY_REPO=<registry repo name>, DEFAULT_PROJECT_ID=<slug> (must equal the entry's id); remove META_REPO.
  4. Deploy. Migration 027 runs the rebuilds + re-stamp; refresh_registry reconciles the registry into projects. Verify /api/deployment returns the project and /api/health is green.

The SPEC.md §22 merge stays in M7 per the slicing plan; this slice references the draft at docs/design/multi-project-spec.md.

Known limitations / deferred

  • Project archival/deletion from the registry is not handled (a removed entry stops refreshing but its rows persist). Defer to a future archival spec.
  • All routing, redirects, runtime branding, theme application, directory, and switcher are M3-frontend.