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>
16 KiB
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, theVITE_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.xpoint atdocs/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
- Scope split — backend spine first; frontend surfaces are a separate spec/session.
- Review machinery — build the full
initial_state/unreviewedplumbing 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. - Config cut — hard cut.
REGISTRY_REPOrequired (loud fail if unset),META_REPOremoved. Upgrade-steps document the manual registry creation. - Re-stamp — rewrite
project_ideverywhere: renameprojects.iddefault→ the config slug and rewrite every child row, folded into the same create-copy-drop-rename rebuild that adds theproject_idFK. One identifier; DB and URL agree. - Mirror structure — a self-contained
app/registry.pymodule (not folded intocache.py), driven by the existing webhook dispatcher + the existingReconciler.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 0reviewed_at TEXTreviewed_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_idbut need no key change get a plain rewrite:UPDATE <t> SET project_id = '<slug>' WHERE project_id = 'default'forthreads,changes,notifications,actions,pr_resolution_branches,rfc_invitations,cached_prs.
End state: a single project_id value DB-wide.
1e. Notes
- Migration
016is absent from the on-disk sequence (015 → 017); the runner already tolerates the gap (the app runs today). Do not renumber. New file is027. PRAGMA foreign_keysis 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 →
projectsrow:id, name, type, content_repo, visibility, initial_state, plusconfig_json(JSON blob fortheme,enabled_models), plusregistry_sha(commit SHA, provenance). - The
deployment:block → thedeploymentsingleton (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_statedefaults fromtypewhen omitted:document/specification→super-draft,bdd→active. When present it must be a valid §2.4 super-draft entry-state value.idvalues unique and slug-shaped (^[a-z0-9][a-z0-9-]*$).typeis immutable: an incomingtypediffering 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, elsedefault) MUST appear as anidin 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 repofull_namematchesREGISTRY_REPO, callregistry.refresh_registry(...). Same HMAC-verified/api/webhooks/giteadispatcher; no new endpoint. AddREGISTRY_REPOto the set of repos the dispatcher recognizes. - Sweep (
cache.Reconciler.sweep()): add oneawait registry.refresh_registry(...)at the top of each pass, so the safety-net loop keepsprojectsin sync if a webhook is missed. - Startup (
main.pylifespan): runrefresh_registryonce after migrations. This replaces M1'sseed_default_project(which is removed).
2e. Failure posture
- Startup / first boot: if
REGISTRY_REPOis unset/unreachable, orprojects.yamlis missing or fails validation, the app fails loudly (refuses to start) — there is no last-known-good to serve. - Running deployment: a malformed
projects.yamlpushed in a later PR is logged and skipped, leaving the last-goodprojectsrows 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). Addregistry_repo_full→{gitea_org}/{registry_repo}.meta_repoandmeta_repo_full: removed.default_project_id: optional; consumed by migration027(re-stamp) and byrefresh_registry(the §2c consistency gate).enabled_models: unchanged — the deployment-level fallback for a project's optionalenabled_models.app/projects.py:seed_default_projectretired (superseded by the mirror).DEFAULT_PROJECT_IDconstant 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):
publicprojects → visible to everyone (incl. anonymous).gatedprojects → only when the caller is a member (visible_project_idsfrom M2's resolver).unlistedprojects → 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 entryactivewithunreviewed = 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 a026-shaped DB with rows underproject_id='default'; run027; assert: new columns present; the 12 tables rebuilt with composite keys + FK; all 19project_id-bearing tables re-stamped to<slug>; row counts preserved; FK integrity on; idempotent re-run is a no-op. registry.py: validprojects.yamlupserts all fields +registry_sha; each validation failure rejected (bad enum, missing field, dup id,typemutation, missing default id);initial_statetype-default applied; startup-strict vs running-tolerant posture.- APIs:
/api/deploymentvisibility filtering across gated/public/unlisted × member/non-member/anonymous;/api/projects/:id404 gate for gated non-member, 200 for unlisted-by-id. - Review flow:
initial_state: activelandsunreviewed=true; mark-reviewed authority (allow superuser, deny others) + frontmatter write + mirror reflection; catalogunreviewedfilter returns the worklist. - Two-project isolation: extend
test_multi_project_authz_vertical.pywith 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.sqlbackend/app/registry.pybackend/app/api_deployment.py- tests:
backend/tests/test_migration_027.py,test_registry.py,test_api_deployment.py,test_review_flow.py; extendtest_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.sweepregistry call;_upsert_cached_rfcreview fields)backend/app/entry.py(frontmatter fields)backend/app/api.py(mountapi_deployment;unreviewedfilter on/api/rfcs)backend/app/main.py(startuprefresh_registry; dropseed_default_project)backend/app/projects.py(retireseed_default_project)backend/.env.example(REGISTRY_REPO,DEFAULT_PROJECT_ID; removeMETA_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:
- Create a registry repo under the deployment's Gitea org.
- Author
projects.yaml: adeployment:block (name,tagline) and oneprojects:entry for the existing corpus —id: <slug>,name,type: document,content_repo: <old META_REPO value>,visibility: public. - Set env:
REGISTRY_REPO=<registry repo name>,DEFAULT_PROJECT_ID=<slug>(must equal the entry'sid); removeMETA_REPO. - Deploy. Migration
027runs the rebuilds + re-stamp;refresh_registryreconciles the registry intoprojects. Verify/api/deploymentreturns the project and/api/healthis 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.