§22 three-tier spec Part E: decided migration-029 strategy (collection grain beneath project)

Operator chose (session 0072) to add the collection grain beneath today's
project: projects table stays the group tier (keeps content_repo), a new
collections table holds the per-corpus fields, entries re-key to
(collection_id, slug), one default collection per project on migrate, breaking
/p/<project>/e/<slug> -> /p/<project>/c/<collection>/e/<slug> with 308s.
Re-sloted slices N1-N6. Correction banners updated from pending -> decided.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 03:46:51 -07:00
parent 9c0e3b60ac
commit 87279fc545
@@ -19,9 +19,11 @@
> the live `/p/<project>/e/<slug>` URLs; v0.37.0/0.38.0 shipped per-project
> read + propose. Inserting the third tier is therefore an **evolution of a
> shipped system**, not a revision of unshipped designs. The migration strategy
> (Part E) is **pending re-decision** with correct facts; the structural model
> (Parts AD) is unaffected. Target release: a further pre-1.0 minor with
> breaking changes + upgrade steps (§20.2).
> (Part E) was **re-decided on these corrected facts** (session 0072): a new
> **migration 029** adds a *collection* grain *beneath* today's project, plus a
> breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>` URL
> change with 308s. The structural model (Parts AD) is unaffected. Target
> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2).
---
@@ -41,9 +43,9 @@ the deployment level is unchanged.
⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt
the slug PK to `(project_id, slug)`, and `/p/<project>/e/<slug>` URLs are live
(v0.35.0). So inserting the third tier evolves a shipped system — see the
corrected Part E for the real migration cost (a new migration 029 + a breaking
URL change with 308s), pending re-decision.
(v0.35.0). So inserting the third tier evolves a shipped system — see Part E
for the decided strategy (a new migration 029 adding a collection grain beneath
today's project, + a breaking URL change with 308s).
---
@@ -479,83 +481,91 @@ Applied in place when §22 is rewritten; listed here as the change surface.
# Part E — Revised slicing plan (the roadmap re-slot)
> ⚠️ **PENDING RE-DECISION (session 0072).** The strategy below assumed Plan B
> and M3-frontend were unshipped. They are shipped (v0.35.00.39.0). The "fold
> into not-yet-shipped work" framing is void; the actual work is a **new
> migration 029** that evolves the shipped two-tier schema to three tiers, plus
> a **breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>`
> URL change with 308s**. The two live mapping options (relabel today's
> project → collection + insert a group tier above, vs. add a collection
> sub-grain beneath today's project) are being put back to the operator. The
> text below is retained only as the structural target, not the sequencing.
**Strategy (session 0072, decided on corrected facts).** The two-tier model is
shipped end-to-end (v0.39.0): migration 028 keyed entries `(project_id, slug)`;
v0.35.0 shipped `/p/<project>/` routing + live `/p/<project>/e/<slug>` URLs;
v0.37.0/0.38.0 shipped per-project read + propose. Inserting the third tier is
therefore an **evolution of a shipped system**. The chosen mapping **adds a
collection grain *beneath* today's project** — the shipped `projects` table
stays the grouping tier (it already owns `content_repo`, where §A.2 wants it),
a new `collections` table holds the per-corpus fields, and entries re-key to the
finer `(collection_id, slug)`. The migrated default keeps every deployment
running; the new `/c/<collection>/` URL segment is added with 308s off the
now-legacy two-tier URLs.
Strategy (ORIGINAL, premise now false): fold the third tier into the
not-yet-shipped work rather than ship two-tier and migrate again. M1, M2, and
M3-backend Plan A are additive and already merged (v0.39.0); they keep running.
The tier goes in at the **first breaking migration (Plan B / 028)** and the
**first public routing (M3-frontend)**, before either ships.
- **Landed, unchanged (v0.39.0):** M1M2, M3-backend Plan A **and** Plan B
(read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13
re-stamp. None of this is rebuilt; it is *evolved* by migration 029 below.
- **Landed, unchanged (v0.39.0):** M1 (additive `project_id` spine, mig 026),
M2 (authz resolver), M3-backend Plan A (registry mirror, APIs, propose /
mark-reviewed, mig 027). These read fine under the new model: today's single
`projects` row is re-read as the **default collection** after the §E migration
relabels it.
- **N1 — migration 029: add the collection grain.** A new
`029_collections.sql` that: (1) **adds a `collections` table**
`(id, project_id, type, subfolder, initial_state, visibility, name,
registry_sha)`; (2) **moves the per-corpus fields** (`type`, `initial_state`,
visibility) from the shipped `projects` table **down** to `collections`,
leaving `projects` as `(id, content_repo, visibility, name, tagline, theme,
enabled_models, …)`; (3) **creates one default collection per existing
project** (id `default`, `subfolder` = repo root, inheriting that project's
`type`/`initial_state`); (4) **re-keys every entry-scoped table** from
`(project_id, slug)` to `(collection_id, slug)` via the same SQLite
rebuild procedure migration 028 used (the `028_project_scoped_keys.sql`
pattern — `__new` table, copy, drop, rename, FK-off + `foreign_key_check`),
stamping the default `collection_id`; (5) **generalizes `project_members`
`memberships(scope_type ∈ {project, collection}, scope_id, …)`**, migrating
existing rows to `scope_type='collection'` on the default collection and
collapsing the role enum to `{owner, contributor}` (§B.3).
- **M3-backend Plan B → "insert the project tier + re-key to per-collection"**
(revised). Migration 028 now: (1) add the `projects` (grouping) table with
`content_repo`; (2) rename the landed `projects` table to `collections`, drop
its `content_repo`, add `project_id` + `subfolder`; (3) re-key every
entry-scoped `project_id``collection_id`; (4) the slug-PK rebuild targets
`(collection_id, slug)`; (5) generalize `project_members` → the polymorphic
`memberships` table and collapse the role enum (§B.3); (6) the §22.13 default
project wraps the default collection (§A.6). One breaking migration, one slug
rebuild.
- **N2 — backend collection scoping.** Thread `collection_id` through the
resolution gates that migration 028/Plan B threaded `project_id` through
(`app/auth.py`, `app/projects.py`, `app/cache.py`, the `api_*` writers);
generalize the §22.7 union to the four-layer resolver (§B.2); add the
`GET /api/projects/:id/collections` and
`GET /api/projects/:id/collections/:cid` surfaces; scope propose/serve/PR
endpoints to `(project, collection)`.
- **M3-backend Plan B+ — manifests & in-app create.** Teach the mirror to read
`.collection.yaml`; add the bot-commit-wrapped **create-project** and
**create-collection** endpoints; mirror new collections.
- **N3 — manifests & in-app create.** Teach the registry mirror to read
`.collection.yaml` from each project's content repo; add the
bot-commit-wrapped **create-collection** (project Owner / RFC Contributor)
and **create-project** (global Owner) endpoints (§A.2); mirror new
collections into `collections` rows.
- **M3-frontend three-tier routing.** `/p/<project>/c/<collection>/e/<slug>`;
the deployment directory at `/`, the project (collection) directory at
`/p/<project>/`; the single-project / single-collection redirects (§A.3); the
308s off `/rfc/<slug>`; runtime branding at both project and collection.
- **N4 — frontend three-tier routing.** Add the `/c/<collection>/` segment:
`/p/<project>/c/<collection>/e/<slug>`; the project (collection) directory at
`/p/<project>/`; the single-collection redirect (§A.3); **308** the shipped
`/p/<project>/e/<slug>``/p/<project>/c/<default>/e/<slug>`; per-collection
chrome layered under the existing per-project chrome.
- **M4second-collection + second-project acceptance.** End-to-end pass: a
second collection in a project, and a second project, each with disjoint
membership, full propose→graduate lifecycle under the per-collection slug
namespace.
- **N5roles surface (this pass's scope).** The `{owner, contributor}` ×
`{global, project, collection}` grants (Part B) with the invite surfaces and
empty states of Part C as acceptance tests. (Richer roles deferred, §B.1.)
- **M5 — type modules (unchanged in intent).** Per-type frontmatter + surfaces,
now selected on the **collection's** `type`.
- **N6 — type modules / membership lifecycle / hardening.** Carries the original
§22 M5M7 forward, now selecting on the **collection's** `type`: per-type
frontmatter + surfaces; request-to-join + cross-collection inbox; per-collection
`enabled_models`; the registry + manifest format in `docs/DEPLOYMENTS.md`;
two-project / multi-collection e2e; the §20.4 changelog + upgrade-steps; the
SPEC merge (Part A applied, Part D amendments in place).
- **M6 — membership lifecycle.** Invite + request-to-join at all three scopes
(Part C.2); the cross-collection inbox; the roster admin surface. The Part C
scenarios are this slice's acceptance tests.
## E.1 (= §A.6) Migration — the default collection (the N=1 case)
- **M7 — hardening + SPEC merge.** Per-collection `enabled_models`; the registry
+ manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e;
the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D
amendments in place).
A deployment on the shipped two-tier schema (v0.39.0) is migrated by 029 so it
keeps running unchanged:
## E.1 (= §A.6) Migration — the default project and default collection
1. The existing `projects` row **stays as the project** (it already owns
`content_repo` and its config-derived `id` from §22.13 step 1).
2. A **default collection** (`id='default'`, `subfolder` = repo root) is created
per project, inheriting that project's `type` / `initial_state` / visibility;
those fields are then dropped from `projects`.
3. Every entry-scoped `project_id` row is re-keyed with the default
`collection_id` (PK `(project_id, slug)``(collection_id, slug)`).
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
the default collection, role-collapsed (§B.3).
5. **308 redirects:** the shipped `/p/<project>/e/<slug>`
`/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
/ `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.
A pre-three-tier deployment (already on v0.39.0's single `projects` row, or
pre-multi-project) is migrated so it keeps running:
1. The landed single `projects` row becomes the **default collection**
(`type=document`, its `initial_state`/`unreviewed` preserved).
2. A **default project** is generated to wrap it: `content_repo` moves from the
(old project / now collection) onto the project; `id` is the config-derived
slug already re-stamped in §22.13 step 1 (v0.39.0).
3. Every entry-scoped `project_id` is re-keyed to the default `collection_id`.
4. M2's `project_members` rows migrate to `memberships(scope_type='collection')`
on the default collection, role-collapsed (§B.3).
5. `/rfc/<slug>` and `/proposals/<n>` 308-redirect to
`/p/<default-project>/c/<default-collection>/…`.
Until a second project or collection is added, the deployment is functionally
identical to before, with two extra path segments. This is the §20.4
upgrade-steps content for the release.
Until a second collection is added, the deployment is functionally identical to
before, with one extra path segment. This is the §20.4 upgrade-steps content for
the release.
## E.2 Scope of the first implementation pass