Merge feat/multi-project (§22 M1+M2) into main
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>
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
# Draft spec — §22 Multi-project deployments + amendments + slicing plan
|
||||
|
||||
> Status: **draft for review.** Binding voice, but not yet merged into
|
||||
> `SPEC.md`. When accepted: §22 below is appended after §21; the amendment
|
||||
> notes in Part B are applied in place; the slicing plan in Part C seeds a
|
||||
> new `docs/DEV.md` build section. Rationale and the decisions behind this
|
||||
> live in [`multi-project.md`](./multi-project.md). Target release: the next
|
||||
> minor (a pre-1.0 minor carrying breaking changes with upgrade steps, §20.2).
|
||||
|
||||
---
|
||||
|
||||
# Part A — New canonical section
|
||||
|
||||
## 22. Projects: multiple corpora per deployment
|
||||
|
||||
A **deployment** hosts one or more **projects**. A project is a single
|
||||
corpus: one content repository (§1) holding entries under `rfcs/`, with
|
||||
its own per-project slug namespace (the slug is the identity — §22.4), a
|
||||
declared **type** (§22.4a), catalog, philosophy, branding,
|
||||
member roster, and model universe. The deployment is the substrate the
|
||||
projects share — one Gitea org, one bot, one account system, one inbox, one
|
||||
running process — and the surface a visitor first lands on.
|
||||
|
||||
Everything §§1–21 describe about *a corpus* is now *a project*. Everything
|
||||
they describe about *a deployment* that is not corpus-specific — accounts,
|
||||
the §6 admission gate, the §15 inbox, the §1 bot — stays at the deployment
|
||||
level and is shared across projects. The numbered sections that assume a
|
||||
single corpus are amended in Part B; §22 is the binding model they defer to.
|
||||
|
||||
> **Multi-project change (target: next minor — supersedes the original
|
||||
> single-corpus model).** §1 originally said "for a deployment, this single
|
||||
> repository is its content repository." A deployment now has a **registry**
|
||||
> (§22.2) naming N content repositories, one per project. The single-corpus
|
||||
> deployment is the **N=1 case** and continues to run after migration via a
|
||||
> generated default project (§22.13); no deployment is forced to adopt more
|
||||
> than one project. Where earlier sections say "the meta repo" or "the
|
||||
> corpus," read "the project's content repo" and "the project's corpus."
|
||||
|
||||
### 22.1 The deployment ⇄ project relation
|
||||
|
||||
One deployment, N projects (N ≥ 1). A project belongs to exactly one
|
||||
deployment and never moves between deployments. Projects within a deployment
|
||||
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
|
||||
belongs to exactly one project, and no app surface joins across projects
|
||||
except the per-account ones the deployment owns (the §15 inbox, the §6
|
||||
account roster, sign-in).
|
||||
|
||||
### 22.2 The registry — git is still truth
|
||||
|
||||
Which projects exist, and their configuration, is declared in git, mirrored
|
||||
into a `projects` cache table the same way content is mirrored into
|
||||
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
|
||||
at the root of a dedicated **registry repo** under the deployment's Gitea
|
||||
org. The framework learns the registry repo's location from a required env
|
||||
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
|
||||
*name* is the deployment's choice, not the framework's, per the
|
||||
separation-of-concerns rule, and the framework fails loudly at startup if the
|
||||
var is unset. Adding, reconfiguring, or archiving a project is a PR against
|
||||
that file; the §4 webhook + reconciler keep the `projects` table in sync,
|
||||
recording the merged `registry_sha` on each row for provenance.
|
||||
|
||||
The registry is a **deployment-side repo the framework reads**, in exactly
|
||||
the sense `META_REPO` is today — not operator-tooling config. Where a
|
||||
deployment is assembled by an external operator tool, that tool supplies the
|
||||
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
|
||||
now) and is otherwise unaffected: project definitions live in git, edited by
|
||||
PR, and the framework knows nothing about the tool that wrote the env var.
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse # deployment display name (replaces VITE_APP_NAME)
|
||||
tagline: ... # deployment landing deck (§22.10)
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique within the deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd — immutable (§22.4a)
|
||||
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
|
||||
visibility: gated # gated | public | unlisted (§22.5)
|
||||
initial_state: super-draft # super-draft | active — landing state of a new
|
||||
# entry; defaults from type (§22.4b)
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
|
||||
```
|
||||
|
||||
Project **definition and configuration** live in the registry (git). Project
|
||||
**membership** lives in the app db (§22.6) — it churns at user speed and is
|
||||
app state, not document state, exactly as `rfc_collaborators` is (§5).
|
||||
`projects` rows are never written from user actions; they flow from the
|
||||
registry mirror only.
|
||||
|
||||
### 22.3 Content repositories — one per project
|
||||
|
||||
Each project names one content repo under the deployment's single Gitea org
|
||||
(naming convention `<project-id>-content`). The §1 bot service account
|
||||
operates org-wide across every content repo and the registry repo; nothing
|
||||
about the bot, the §6 app-owned authorization, or the "app is the only
|
||||
contribution surface" stance changes. There are no per-project Gitea orgs and
|
||||
no per-project bot accounts.
|
||||
|
||||
### 22.4 The slug namespace is per-project; the slug is the identity
|
||||
|
||||
An entry's slug (§2) is unique **within its project**, not across the
|
||||
deployment: `ohm` and `specs` may each have an `intro`. The slug **is** the
|
||||
identity — a fully qualified reference is `(project_id, slug)`, and nothing
|
||||
more. There is no type prefix and **no per-project numeric ID**: within a
|
||||
project the slug alone is unambiguous, and the project context (its `/p/<id>/`
|
||||
URL prefix and chrome) carries everything the old `RFC-NNNN` label used to.
|
||||
|
||||
This retires the per-project numbering of earlier drafts. The §13 graduation
|
||||
flip still happens — it moves an entry from proposal to graduated state and
|
||||
into the content repo — but it no longer allocates a number; the
|
||||
`max(integer IDs)+1` allocator (§2.3, the old `api_graduation.py` path) is
|
||||
removed. The displayed *noun* around a slug ("RFC", "Spec", "Feature") is a
|
||||
presentation concern driven by the project's type (§22.4a), not part of the
|
||||
identity.
|
||||
|
||||
**Legacy numbers.** Entries graduated *before* this change keep their existing
|
||||
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label** —
|
||||
preserved and shown in the UI so external "RFC-0001"-style citations still
|
||||
resolve, but never used for routing or lookup (the slug is). New entries are
|
||||
never assigned one, and graduation does not write `id`. The field is read-only
|
||||
provenance from here on.
|
||||
|
||||
### 22.4a Project type
|
||||
|
||||
Every project declares a `type` in the registry (§22.2), chosen at creation
|
||||
and **immutable**: one of `document`, `specification`, or `bdd`. Type does not
|
||||
change the engine — every type uses the same content repo (§22.3), the same
|
||||
propose→branch→PR→discuss→graduate lifecycle (§§9–13), the same threads,
|
||||
flags, and chat. Type selects exactly three things:
|
||||
|
||||
1. the **entry frontmatter schema** the project validates entries against (§2);
|
||||
2. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
|
||||
labels);
|
||||
3. the set of **type-specific surfaces** layered on top of the shared §7
|
||||
catalog.
|
||||
|
||||
Type-specific behavior is implemented as a per-type module the framework
|
||||
selects on `project.type`; the engine itself treats every entry as markdown +
|
||||
frontmatter regardless of type. `type` is an **open set** in shape — a future
|
||||
type is a new module plus a new allowed enum value, no schema rebuild — and
|
||||
the three below are what is defined now. The type names and their behavior are
|
||||
framework concepts (like role names), not deployment content: a deployment
|
||||
picks which type each project is, but does not define or rename types.
|
||||
|
||||
- **`document`** — long-form normative prose (OHM: a model of principles and
|
||||
definitions). Frontmatter is the §2 baseline (title, status,
|
||||
owners/arbiters, tags). No type-specific surfaces. The §22.13 generated
|
||||
default project is a `document` project, so the N=1 case is unchanged.
|
||||
- **`specification`** — a versioned technical specification (this framework's
|
||||
own `SPEC.md` is the archetype: numbered normative sections, upgrade steps).
|
||||
Frontmatter adds spec metadata (`version`, lifecycle `status` of
|
||||
draft/active/superseded, `supersedes`). **Type-specific surface — release
|
||||
planning:** group entries/changes into versioned releases, carry a
|
||||
changelog + §20-style upgrade-steps per release, and surface "what is in the
|
||||
next release."
|
||||
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
|
||||
Given/When/Then scenarios with acceptance criteria. Frontmatter adds feature
|
||||
metadata and an optional link to the `specification` entries a feature
|
||||
verifies. **Type-specific surface:** a scenario/acceptance view, and — where
|
||||
a deployment runs a BDD project alongside a specification project — a
|
||||
coverage view mapping features to the spec sections they exercise.
|
||||
|
||||
> **Draft note.** The shared-engine boundary is locked; the per-type
|
||||
> *schemas and surfaces* above (notably the specification release-planning
|
||||
> data model and whether BDD scenarios are free-form markdown or a parsed
|
||||
> Given/When/Then structure) are first proposals, to be pinned in the
|
||||
> type-surface slice (Part C, M5).
|
||||
|
||||
### 22.4b Initial state of a new entry
|
||||
|
||||
A project sets the **landing state** a new entry takes when its creating
|
||||
idea-PR merges (§2.4) — the `initial_state` registry field, one of the §2.4
|
||||
super-draft entry-states:
|
||||
|
||||
- **`super-draft`** (default for `document` and `specification`) — a new entry
|
||||
lands as a super-draft and must be explicitly graduated (§13) to reach
|
||||
`active`. This is today's flow: propose → super-draft → review → graduate.
|
||||
- **`active`** (default for `bdd`) — a new entry lands `active` on the idea-PR
|
||||
merge, with the **`unreviewed` flag set** (§22.4c). The §13 graduate gate is
|
||||
not surfaced — the entry is already active — but because nothing reviewed
|
||||
it, the flag marks it as not-yet-vetted until an owner clears it. A behavior
|
||||
spec is captured fact, not a proposal under deliberation, so it skips the
|
||||
review-then-promote ceremony; branches, PRs, and discussion work afterward
|
||||
exactly as on any `active` entry.
|
||||
|
||||
The default comes from the project's **type** (§22.4a) — each type module
|
||||
supplies it — but `initial_state` is an independent registry knob: a
|
||||
deployment may run a `bdd` project with `super-draft` if it wants a review
|
||||
gate, or (less commonly) a `document` project that auto-actives. It changes
|
||||
only the *landing state and whether graduation is required*; the underlying
|
||||
propose→branch→PR→discuss engine is unchanged (shared-engine rule, §22.4a).
|
||||
The §22.13 default project keeps `super-draft`, preserving the N=1 flow.
|
||||
|
||||
### 22.4c The `unreviewed` flag
|
||||
|
||||
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
|
||||
`state`. It records whether a human gate has vetted the entry:
|
||||
|
||||
- An entry that reaches `active` by the normal **graduate** path (§13) is
|
||||
**never** flagged — the graduate action, taken by an owner/admin, *is* the
|
||||
review.
|
||||
- An entry that **skips straight to `active`** via `initial_state: active`
|
||||
(§22.4b) lands with `unreviewed = true`, because nothing reviewed it.
|
||||
|
||||
A **project owner** — `project_admin`, or a deployment `owner`/`admin` (§22.7)
|
||||
— clears the flag with a **mark-reviewed** action (§17), the same authority
|
||||
that graduates an entry. Clearing stamps `reviewed_at`/`reviewed_by` for
|
||||
provenance, paralleling `graduated_at`/`graduated_by`. The flag is a property
|
||||
of the entry (frontmatter, §2 amendment), so it is git-truth and survives a
|
||||
cache rebuild, exactly like `state`.
|
||||
|
||||
The §7 catalog gains an **unreviewed filter** so an owner can find the entries
|
||||
awaiting review; it is the natural worklist for the mark-reviewed action.
|
||||
`unreviewed` only applies to `active` entries — a `super-draft` is pre-review
|
||||
by definition, and `withdrawn` is out of scope.
|
||||
|
||||
### 22.5 Project visibility
|
||||
|
||||
Each project carries a `visibility`, defaulting to **gated**:
|
||||
|
||||
- **`gated`** (default) — the project is invisible to non-members. It does
|
||||
not appear in the directory (§22.10), its RFCs return 404 to non-members,
|
||||
and reading or writing anything in it requires membership (§22.6). This is
|
||||
the baseline because a deployment may host specs and vision docs it is not
|
||||
ready to publish.
|
||||
- **`public`** — any visitor may read the project's RFCs under the §6.1
|
||||
anonymous-read contract; the project appears in the directory; contributing
|
||||
still requires a `project_contributor` grant. This is the mode that
|
||||
preserves the pre-multi-project open-by-default behavior, and the mode a
|
||||
generated default project (§22.13) is seeded into.
|
||||
- **`unlisted`** — readable by anyone with a direct link, but not shown in
|
||||
the directory and not enumerated by `GET /api/deployment`.
|
||||
|
||||
Visibility is the project's; it does not relax the §11 per-branch
|
||||
`read_public` controls *within* a project, which continue to apply on top.
|
||||
|
||||
### 22.6 Project membership and roles
|
||||
|
||||
Membership is a `project_members(project_id, user_id, role, granted_by,
|
||||
granted_at)` table, one role per (user, project). The role is a **new middle
|
||||
tier** between the §6.1 deployment roles and the §6.3 per-RFC authority:
|
||||
|
||||
1. **`project_viewer`** — read the project's RFCs and participate in
|
||||
discussion (chat, flags) on anything readable. No propose/branch/PR. The
|
||||
discuss-only counterpart of the §12 per-RFC `discussant`, at project scope.
|
||||
2. **`project_contributor`** — everything a viewer can do, plus the §6.1
|
||||
contributor capabilities *within this project*: propose RFCs into it,
|
||||
create branches, open PRs, claim unclaimed super-drafts.
|
||||
3. **`project_admin`** — everything a contributor can do, plus the §6.1
|
||||
admin capabilities *within this project*: manage its membership, act on
|
||||
any RFC in it (merge on behalf of arbiters, graduate, set branch
|
||||
visibility, withdraw/reopen), and edit per-RFC delegated authority.
|
||||
`project_admin` is the §6.3 delegated-authority idea lifted from per-RFC
|
||||
to per-project: an admin scoped to one corpus, not the deployment.
|
||||
|
||||
Membership and role are still gated by the deployment-level
|
||||
`users.permission_state='granted'` (§6): a pending account has no write
|
||||
capability in any project regardless of its `project_members` rows.
|
||||
|
||||
### 22.7 How the three tiers compose
|
||||
|
||||
Authorization for an action on an RFC resolves by taking the **most
|
||||
permissive** of:
|
||||
|
||||
- the actor's **deployment role** (§6.1) — `owner`/`admin` are superusers in
|
||||
every project; a plain authenticated `contributor` has, by itself, only
|
||||
anonymous-equivalent access to a project until §22.6 grants it a role
|
||||
(subject to §22.5 visibility);
|
||||
- the actor's **project role** in that RFC's project (§22.6);
|
||||
- the actor's **per-RFC authority** in that RFC (§6.3 `owners`/`arbiters`,
|
||||
§12 `rfc_collaborators`).
|
||||
|
||||
Concretely: deployment `owner`/`admin` ⊇ `project_admin` ⊇ RFC
|
||||
`owners`/`arbiters`; deployment `contributor` + `project_contributor` ⊇ RFC
|
||||
`rfc_collaborators(contributor)`; `project_viewer` ⊇ `discussant`. The §6.2
|
||||
write-mute and the §22.5 visibility gate are subtractive on top of whatever
|
||||
the union grants.
|
||||
|
||||
`users.role` (§5) now means *deployment* level only. No schema change demotes
|
||||
an existing owner/admin; their powers simply read as "superuser in every
|
||||
project" rather than "superuser in the corpus."
|
||||
|
||||
### 22.8 Discovery and joining a gated project
|
||||
|
||||
Because a gated project is invisible to non-members, joining is by one of:
|
||||
|
||||
- **Invite** — a `project_admin` (or deployment admin/owner) adds a user
|
||||
directly, writing a `project_members` row and fanning a §15 notification.
|
||||
This reuses the §12 per-RFC invitation machinery, re-scoped to the project.
|
||||
- **Request to join** — a surface analogous to §28's contribution-requests:
|
||||
a user who knows a project exists (e.g. by direct link to an `unlisted`
|
||||
project, or by out-of-band referral) can request membership; a
|
||||
`project_admin` accepts or declines from the inbox. The request names the
|
||||
desired role (defaulting to `project_viewer`).
|
||||
|
||||
A `public` project needs neither: read is open, and the existing §6 / §12
|
||||
contribute-grant paths cover write access.
|
||||
|
||||
### 22.9 Branding is resolved at runtime
|
||||
|
||||
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
|
||||
cannot serve N projects. Deployment and project identity are served at
|
||||
runtime:
|
||||
|
||||
- `GET /api/deployment` — the deployment `name`, `tagline`, and the list of
|
||||
projects the caller can see (gated projects filtered by the caller's
|
||||
membership; `unlisted` omitted).
|
||||
- `GET /api/projects/:id` — that project's `name`, `tagline`, philosophy
|
||||
pointer, and optional `theme` token overrides applied over the §-default
|
||||
`tokens.css`.
|
||||
|
||||
The frontend reads these instead of `import.meta.env.VITE_APP_NAME`. Two
|
||||
chrome layers result: **deployment chrome** (the directory/landing, the
|
||||
project switcher, the shared inbox) and **project chrome** (the §7 catalog,
|
||||
the §8 RFC view, the §14 philosophy — all per project).
|
||||
|
||||
### 22.10 Routing and the deployment landing
|
||||
|
||||
Every corpus-scoped route gains a project segment: `/p/<project>/…` carries
|
||||
the §7 catalog, the §8 entry view at `/p/<project>/e/<slug>`, the §9/§10
|
||||
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/philosophy`. The entry
|
||||
segment is the **generic `/e/`** for every type — the displayed noun ("RFC",
|
||||
"Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so
|
||||
routing stays a single type-agnostic path and avoids colliding with the
|
||||
reserved sibling segments (`proposals`, `philosophy`, …). The root `/` is the
|
||||
**deployment landing**: a directory of the projects the visitor can see (per
|
||||
§22.5), plus sign-in. An anonymous or non-member visitor sees only `public`
|
||||
projects there. The §8.1 breadcrumb gains a leading project segment:
|
||||
`OHM / Human › main` (slug, with the type-driven noun as its label).
|
||||
|
||||
### 22.11 Notifications span projects, one inbox
|
||||
|
||||
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
|
||||
caller's projects. `notifications` and `watches` carry `project_id` (§5
|
||||
amendment) so the inbox filters by project and a user can mute an entire
|
||||
project. Quiet hours, digest cadence, and email preferences stay per-account
|
||||
at the deployment level (§5, §15).
|
||||
|
||||
### 22.12 Per-project model universe
|
||||
|
||||
A project's `enabled_models` (registry, §22.2) defines its operator universe,
|
||||
overriding the deployment `ENABLED_MODELS` (§18) when present and falling
|
||||
back to it when absent. The §6.6 per-RFC `models:` list and the §6.7 funder
|
||||
universe resolve *within* the project's universe — the resolution order
|
||||
becomes funder universe ∩ §6.6 list ∩ project universe, with the project
|
||||
universe substituting for the deployment universe at the outermost step.
|
||||
|
||||
### 22.13 Migration — the default project (the N=1 case)
|
||||
|
||||
A deployment upgrading from a pre-multi-project version is migrated to a
|
||||
single **default project** so it keeps running unchanged:
|
||||
|
||||
1. The migration generates a `projects` row from current config:
|
||||
`META_REPO → content_repo`, `VITE_APP_NAME → name`, `visibility = public`
|
||||
(preserving the deployment's current open-by-default posture),
|
||||
`type = document` (every pre-multi-project corpus is a document corpus),
|
||||
and the `id` a **config-derived slug**: `DEFAULT_PROJECT_ID` if set, else a
|
||||
slug of the deployment name, falling back to the literal `default`. (M1's
|
||||
migration 026 seeds the bootstrap id `default`; the §C-M3 step re-stamps it
|
||||
to the config-derived slug before any `/p/<id>/` route is public, so the id
|
||||
is meaningful — e.g. `/p/ohm/…` — and never renamed after URLs go live. The
|
||||
id stays framework-generic: the framework supplies no deployment name.)
|
||||
2. Every existing RFC-scoped row (§5 amendment list) is stamped with that
|
||||
`project_id`.
|
||||
3. Old corpus-root URLs (`/rfc/<slug>`, `/proposals/<n>`) 308-redirect to
|
||||
their `/p/<default-id>/…` equivalents — the entry view to
|
||||
`/p/<default-id>/e/<slug>` (§22.10) — so existing links survive.
|
||||
4. The operator creates the registry repo (§22.2) declaring the default
|
||||
project; until they add a second project, the deployment is functionally
|
||||
identical to before, with one extra path segment.
|
||||
|
||||
This is the §20.4 upgrade-steps content for the release.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Amendments to existing sections
|
||||
|
||||
Applied in place, in the established amendment-note style (cf. §1's
|
||||
"Topology change (v0.31.0)").
|
||||
|
||||
- **§1 Repository topology.** Add the §22 amendment note (above). "This
|
||||
single repository is its content repository" → "each *project* names one
|
||||
content repository; the deployment's registry (§22.2) lists them." The bot
|
||||
and app-owned-authorization paragraphs are unchanged and now read
|
||||
org-wide.
|
||||
- **§2 Meta schema / §2.3 IDs.** Slugs are unique within a project; entry
|
||||
filenames are unchanged (per content repo). §2.3's `RFC-NNNN` `max+1`
|
||||
allocation is **removed** — the slug is the identity (§22.4); there is no
|
||||
per-project number. Entries graduated before this change keep their `id`
|
||||
as a frozen, read-only legacy display label (§22.4), never used for lookup.
|
||||
The entry frontmatter schema becomes type-dependent
|
||||
(§22.4a): `document` keeps today's fields, `specification` and `bdd` add
|
||||
their type metadata. New `active`-entry fields: `unreviewed` (bool) and the
|
||||
`reviewed_at`/`reviewed_by` provenance pair (§22.4c), paralleling
|
||||
`graduated_at`/`graduated_by`.
|
||||
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition now
|
||||
targets the project's `initial_state` (§22.4b): `super-draft` as today, or
|
||||
straight to `active` when the project (e.g. a `bdd` project) lands entries
|
||||
there — in which case the entry is stamped `unreviewed` (§22.4c). A new
|
||||
`active ─[mark-reviewed, owner/admin]→ active` self-transition clears the
|
||||
flag. The rest of the machine is unchanged.
|
||||
- **§5 Data model.** Add `project_id` to: `branch_visibility`,
|
||||
`branch_contribute_grants`, `stars`, `threads`, `changes`, `watches`,
|
||||
`notifications`, `rfc_invitations`, `rfc_collaborators`,
|
||||
`contribution_requests`, `funder_consents`, the `*_seen` cursors, `actions`,
|
||||
and the §4 cache tables `cached_rfcs` (PK → `(project_id, slug)`;
|
||||
also mirrors the `unreviewed` frontmatter flag, §22.4c, so the §7 catalog
|
||||
filter can query it without reading every entry file),
|
||||
`cached_branches`, `cached_prs`, `pr_resolution_branches`,
|
||||
`proposed_use_cases`. Add the new tables `projects` (carrying the immutable
|
||||
`type`, §22.4a) and `project_members` (§22.2, §22.6). `users.role` is
|
||||
annotated as deployment-scope (§22.7).
|
||||
- **§6.1 Roles.** Add the §22.7 composition note: deployment roles are now
|
||||
one of three tiers; a plain `contributor` has no implicit access to a
|
||||
project until §22.6 grants a project role (subject to §22.5).
|
||||
- **§6.3 Per-RFC delegated authority.** Note that `project_admin` (§22.6) is
|
||||
the same delegation idea at project scope, sitting above per-RFC authority.
|
||||
- **§7 Left pane.** The catalog is per-project, under `/p/<project>/`. The
|
||||
deployment directory (§22.10) is a new surface above it. The catalog gains
|
||||
an **unreviewed filter** (§22.4c) — the owner's worklist of `active` entries
|
||||
that landed unreviewed.
|
||||
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). The entry is
|
||||
named by its slug, not a number (§22.4); the noun shown around it ("RFC",
|
||||
"Spec", "Feature") is the project type's label (§22.4a).
|
||||
- **§13.3 Graduation flip.** Operates on the project's content repo. It flips
|
||||
status and moves the entry, but **allocates no number** — the slug is the
|
||||
identity throughout (§22.4); the old per-project `RFC-NNNN` allocation is
|
||||
gone. Graduation is also **conditional on the project's `initial_state`
|
||||
(§22.4b)**: a project that lands entries `active` has no super-draft phase,
|
||||
so the graduate action is a no-op there and is not surfaced. For those
|
||||
entries the **mark-reviewed** action (§22.4c) takes graduation's place as
|
||||
the owner/admin vetting step — it clears `unreviewed` instead of flipping
|
||||
state.
|
||||
- **§14.1 Pre-login landing.** Splits into deployment landing (the directory,
|
||||
§22.10) and per-project philosophy/deck (§14 under `/p/<project>/`).
|
||||
Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`.
|
||||
- **§17 Backend surface.** RFC routes gain the `/p/<project>` /
|
||||
`project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`
|
||||
(returns the project's `type`, §22.4a), and the `project_members`
|
||||
management + request-to-join endpoints (§22.6, §22.8). Add a
|
||||
**mark-reviewed** endpoint clearing an entry's `unreviewed` flag (§22.4c,
|
||||
owner/admin), and an `unreviewed` filter param on the catalog list. Type-
|
||||
specific surfaces (§22.4a) add their own routes, mounted only for projects of
|
||||
the matching type — e.g. the `specification` release-planning endpoints and
|
||||
the `bdd` scenario/coverage endpoints.
|
||||
- **§18 Stack.** `ENABLED_MODELS` is the deployment fallback; per-project
|
||||
`enabled_models` overrides it (§22.12).
|
||||
- **§20 Versioning / surface.** `VITE_APP_NAME` deprecated in favor of the
|
||||
registry + `GET /api/deployment` (§22.9). New required backend env var
|
||||
`REGISTRY_REPO` (the §20.3 env contract); `META_REPO` becomes legacy,
|
||||
consulted only by the §22.13 migration to seed the default project's
|
||||
`content_repo`, then unused. The registry file shape and the
|
||||
`projects`/`project_members` schema join the §20.3 versioned surface. The
|
||||
release is a pre-1.0 minor with a §22.13 upgrade-steps block. Note for the
|
||||
changelog: a deployment assembled by an external operator tool upgrades
|
||||
through the same pinned-version path as any other — the only deploy-surface
|
||||
change is swapping the `META_REPO` overlay value for `REGISTRY_REPO`; the
|
||||
framework's versioned contracts (`/api/health`, `VERSION`, the pin file)
|
||||
are unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Part C — Slicing plan
|
||||
|
||||
Seven slices carry §22 and its amendments end-to-end. The ordering mirrors
|
||||
DEV.md's original principle — foundations and the cache/permission spine
|
||||
first, the surfaces that consume them after, hardening last. Each slice is
|
||||
shippable: a deployment can stop at any slice boundary and still run (the
|
||||
default project keeps the N=1 case whole throughout). The project `type`
|
||||
(§22.4a) rides M3 (config) and M5 (its surfaces); M1–M4 are type-agnostic
|
||||
because the engine is.
|
||||
|
||||
**M1 — The project spine (schema + default-project migration).** *(landed)*
|
||||
The `projects` and `project_members` tables; `project_id` threaded
|
||||
additively onto every slug-bearing §5 table (migration 026); the §22.13
|
||||
default project generated and every existing row backfilled to it; the
|
||||
startup backfill that fills the default project's `content_repo` from
|
||||
`META_REPO`; `REGISTRY_REPO` wired into config (consumed in M3). No UI, no
|
||||
routing change, no registry mirror yet — the app runs exactly as before,
|
||||
single project, with the spine underneath. Additive only: no table rebuilds
|
||||
(the slug-keyed uniqueness/PK rework is deferred to the slice that activates
|
||||
project #2, enumerated in migration 026's header). This is the foundation
|
||||
everything after builds on.
|
||||
|
||||
**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)*
|
||||
The three-tier composition: `project_members` roles, the most-permissive
|
||||
union with deployment role and per-RFC authority, the §22.5 visibility gate as
|
||||
a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in
|
||||
§6.1's audit re-checked under the project axis. Still single visible project;
|
||||
verifiable by granting/revoking roles on the default project and flipping its
|
||||
visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure
|
||||
app-layer — the resolver primitives live in `app/auth.py`
|
||||
(`project_visibility`, `project_member_role`, `is_project_superuser`,
|
||||
`can_read_project`, `can_contribute_in_project`, `require_project_readable`,
|
||||
`visible_project_ids`), composed into the existing per-RFC capability helpers
|
||||
and threaded into every RFC-resolution gate (`_require_rfc*`,
|
||||
`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep
|
||||
gates). No migration (M1 shipped the tables) and no behavior change on the
|
||||
public default project.
|
||||
|
||||
Two operator decisions pin how the implicit grant behaves on a `public`
|
||||
project: **implicit-on-public** — a granted deployment `contributor` keeps its
|
||||
pre-multi-project write baseline with no `project_members` row, so the N=1 case
|
||||
stays whole (no backfill); and **preserve curation** — that implicit baseline
|
||||
does *not* override per-RFC owner curation (only an explicit
|
||||
project_contributor/admin or a deployment owner/admin does), so the v0.16.0
|
||||
per-RFC invite contract is unchanged on public. Explicit `project_members`
|
||||
rows and `gated`/`unlisted` visibility are where the new tier bites. This is a
|
||||
deliberate liberalization of §22.5's literal "contributing still requires a
|
||||
grant" for the public case; gated/unlisted honor the grant model exactly.
|
||||
|
||||
**M3 — Registry mirror + routing + runtime branding.** The §4 registry
|
||||
mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`
|
||||
rows beyond the default); the **re-stamp of the default project's bootstrap
|
||||
`id`** (`default` → the config-derived slug, §22.13 step 1) which must land
|
||||
here, before any `/p/<id>/` URL is public; the `/p/<project>/` route prefix
|
||||
with the generic `/e/<slug>` entry segment (§22.10) and the 308 redirects off
|
||||
the old corpus-root URLs (`/rfc/<slug>` → `/p/<default-id>/e/<slug>`); `GET
|
||||
/api/deployment` and `GET /api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config,
|
||||
per-project `theme` token overlay. The deployment directory at `/` and the
|
||||
project switcher in deployment chrome. This slice also adds the additive
|
||||
`type` and `initial_state` columns to `projects` (a small migration — M1
|
||||
shipped `projects` without them), mirrors both from the registry, returns
|
||||
them on `GET /api/projects/:id`, and drives the entry-noun terminology off
|
||||
`type` (§22.4a). It also teaches the shared creation path to honor
|
||||
`initial_state` (§22.4b) — land a new entry `active` instead of `super-draft`,
|
||||
stamping `unreviewed` and skipping the graduate gate when the project says so
|
||||
— plus the `unreviewed` frontmatter fields (§22.4c) mirrored into
|
||||
`cached_rfcs`, the owner/admin mark-reviewed action, and the §7 catalog
|
||||
unreviewed filter that queries that cached column. But no type
|
||||
*surfaces* yet; beyond their label, landing state, and review flag, all three
|
||||
types still look the same here. After M3 a deployment with two registry projects is
|
||||
fully navigable — which makes this the slice that must also land the deferred
|
||||
slug-keyed uniqueness/PK rebuilds (migration 026 header) before a second
|
||||
project can collide with the first.
|
||||
|
||||
**M4 — Per-project corpus surfaces (the second-project acceptance pass).** The
|
||||
§7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14
|
||||
philosophy all confirmed working under project scope with the per-project,
|
||||
slug-only namespace (§22.4). This is mostly *inherited* from M1–M3 — the work
|
||||
is an end-to-end pass that proves a second `document` project's full lifecycle
|
||||
(propose → super-draft → graduate, identified by slug *in that project*), not
|
||||
new build. Naming it explicitly as the acceptance slice keeps scope that
|
||||
belongs in M3 from leaking in.
|
||||
|
||||
**M5 — Type modules + type-specific surfaces.** The per-type layer of §22.4a:
|
||||
type-specific entry-frontmatter validation (`specification`/`bdd` metadata);
|
||||
the `specification` **release-planning** surface; the `bdd` scenario/coverage
|
||||
surface. Type-scoped routes mounted only for matching projects (§17). The
|
||||
shared engine is untouched — this slice only adds the layers on top, so a
|
||||
`document` project is unaffected and the M4 acceptance still holds. (The
|
||||
per-type schema/surface details are the §22.4a draft note's open work.)
|
||||
|
||||
**M6 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and
|
||||
request-to-join (re-scoped §28); the inbox surfacing of join requests; the
|
||||
§22.11 cross-project inbox with `project_id` filtering and project-level
|
||||
mute. The admin surface for managing a project's roster.
|
||||
|
||||
**M7 — Hardening + operator path.** Per-project `enabled_models` resolution
|
||||
(§22.12) including funder/§6.6 intersection; the registry's place in
|
||||
`docs/DEPLOYMENTS.md` and the flotilla operator tooling; end-to-end tests
|
||||
spanning two projects with disjoint membership; the §20.4 changelog +
|
||||
upgrade-steps block; the SPEC merge (Part A appended, Part B applied).
|
||||
|
||||
## Open items folded into the slices
|
||||
|
||||
- **Registry repo vs. file-in-existing-repo** — *resolved:* a dedicated
|
||||
registry repo the framework reads via the `REGISTRY_REPO` env var (§22.2).
|
||||
Confirmed against the flotilla operator-tooling spec: the registry is
|
||||
deployment-side git content (like the corpus), not operator config, so the
|
||||
operator tool's only change is swapping the `META_REPO` overlay value for
|
||||
`REGISTRY_REPO`. No flotilla architectural change; no new framework⇄tool
|
||||
contract. With OHM becoming one project among several, the registry sits
|
||||
*above* any single project's content repo, so a file inside one project's
|
||||
repo is wrong — a dedicated repo is the right home.
|
||||
- **Request-to-join vs. invite-only** — drafted with both (§22.8); M6
|
||||
(membership lifecycle) may ship invite-only first and add request-to-join
|
||||
second if scope demands.
|
||||
- **Per-type schemas and surfaces** — the §22.4a draft note's open work:
|
||||
the `specification` release-planning data model and whether `bdd` scenarios
|
||||
are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.
|
||||
@@ -0,0 +1,365 @@
|
||||
# Design sketch — multi-project deployments
|
||||
|
||||
> Status: **draft / sketch.** Not binding. This precedes the SPEC edits it
|
||||
> describes. Decisions captured here were made interactively; open questions
|
||||
> are flagged inline. When this stabilizes it folds into `SPEC.md` (§1, §2,
|
||||
> §5, §6, §7, §8, §13, §14, §17, §20) and ships as a pre-1.0 minor with
|
||||
> upgrade steps.
|
||||
|
||||
## The reframe
|
||||
|
||||
Today the framework hardcodes **deployment : corpus = 1 : 1**. One deployment
|
||||
is one Gitea content repo (`META_REPO`), one global slug namespace, one
|
||||
`VITE_APP_NAME` baked into the build, one flat catalog. SPEC §1 says it
|
||||
plainly: "this single repository is its content repository."
|
||||
|
||||
This change makes it **deployment : project = 1 : N**, where *today's entire
|
||||
deployment becomes the N=1 case*. A **project** is what a corpus is now: a
|
||||
content repo, its own slug namespace, a declared **type** (§ "Project types"
|
||||
below), its own catalog, philosophy, branding, member roster, and
|
||||
enabled-models universe. The **deployment** (the subdomain — e.g. Wiggleverse)
|
||||
becomes a thin shell hosting a directory of projects plus a shared
|
||||
account/notification layer.
|
||||
|
||||
OHM — a *document* project — becomes one project among several: specs
|
||||
(*specification* projects), behavior suites (*BDD* projects), vision docs, …
|
||||
all under one deployment.
|
||||
|
||||
The value of this framing: the migration stays mechanical. Everything
|
||||
deployment-scoped today splits cleanly into:
|
||||
|
||||
- **stays deployment-scoped** — accounts, the beta/permission gate, the inbox,
|
||||
the bot service account, the Gitea org;
|
||||
- **becomes project-scoped** — the corpus, branding, roles, catalog, philosophy,
|
||||
enabled models.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Project registry lives in git.** A registry (a `projects.yaml` / registry
|
||||
repo) declares which projects exist and their config; adding a project is a
|
||||
PR. Mirrored into a `projects` cache table. Keeps the git-is-truth invariant.
|
||||
2. **Projects are membership-gated by default** (private). A non-member does
|
||||
not see a private project exists. `visibility` is still a per-project field
|
||||
with `public` and `unlisted` escape hatches (see §3) — gated is the default,
|
||||
not the only mode.
|
||||
3. **Entries are identified by slug, scoped to the project.** A fully
|
||||
qualified reference is `(project_id, slug)`. There is *no* type prefix and
|
||||
*no* per-project numeric ID: inside a project the slug alone is
|
||||
unambiguous, and the project (its URL prefix, its chrome) supplies all the
|
||||
surrounding context. This **supersedes** the earlier per-project `RFC-NNNN`
|
||||
allocation idea — graduation (§13) still flips an entry's status, but no
|
||||
longer mints a number.
|
||||
4. **Each project declares a `type`** — `document`, `specification`, or `bdd`.
|
||||
Type is chosen when the project is created (in the registry), is immutable,
|
||||
and selects the project's entry frontmatter schema, its terminology/labels,
|
||||
and any type-specific surfaces (e.g. release planning for specifications).
|
||||
All types ride the *same* propose→branch→PR→graduate engine, threads,
|
||||
flags, and chat; type layers on top — it does not fork the lifecycle.
|
||||
|
||||
## Project types
|
||||
|
||||
A project's `type` is a registry field, fixed at creation. It does **not**
|
||||
change the engine — every type uses the same content repo, the same
|
||||
propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It
|
||||
selects three things: the **entry frontmatter schema** the project validates
|
||||
against, the **terminology** the chrome uses for an entry, and the set of
|
||||
**type-specific surfaces** the project exposes on top of the shared catalog.
|
||||
The engine treats every entry as markdown + frontmatter regardless of type;
|
||||
type-specific behavior is a layer, implemented as a per-type module the
|
||||
framework selects on `project.type`.
|
||||
|
||||
> These three definitions — especially the specification release-planning
|
||||
> surface and the BDD scenario model — are first drafts. The schema/surface
|
||||
> details below are proposals to refine, not yet locked.
|
||||
|
||||
- **`document`** — long-form normative prose (OHM is the archetype: a model
|
||||
of principles and definitions). Frontmatter is today's entry schema
|
||||
(title, status, owners/arbiters, tags). **No** type-specific surfaces; this
|
||||
is the baseline, and the N=1 default project (§7) is a `document` project.
|
||||
- **`specification`** — a versioned technical specification (this app's own
|
||||
`SPEC.md`, with numbered normative sections and upgrade steps, is the
|
||||
archetype). Frontmatter adds spec metadata (e.g. `version`, lifecycle
|
||||
`status` of draft/active/superseded, `supersedes`). Type-specific surface:
|
||||
**release planning** — group entries/changes into versioned releases, track
|
||||
the changelog + upgrade-steps for each, and show "what's in the next
|
||||
release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG +
|
||||
§20 upgrade steps.)
|
||||
- **`bdd`** — behavior-driven feature specs: each entry describes a feature
|
||||
as scenarios in Given/When/Then form with acceptance criteria. Frontmatter
|
||||
adds feature metadata and an optional link to the `specification` entries a
|
||||
feature verifies. Type-specific surface: a scenario/acceptance view, and
|
||||
(where a deployment pairs a BDD project with a specification project) a
|
||||
coverage view linking features back to the spec sections they exercise.
|
||||
|
||||
Types are an **open set** in shape — a new type is a new module plus a new
|
||||
allowed `type` value; it needs no schema migration beyond the enum. Document,
|
||||
specification, and BDD are the three defined now.
|
||||
|
||||
**Landing state (`initial_state`).** A project also sets what state a new
|
||||
entry lands in when its idea-PR merges — `super-draft` (the normal
|
||||
propose→review→graduate flow) or `active` (graduated on submission). The
|
||||
default comes from the type: `document` and `specification` default to
|
||||
`super-draft`; **`bdd` defaults to `active`** — a behavior spec is captured
|
||||
fact, not a proposal under deliberation, so it skips the review-then-promote
|
||||
gate. It's an independent registry knob, so a deployment can override the
|
||||
default per project. This changes only the landing state and whether
|
||||
graduation is required; the propose→branch→PR engine is unchanged.
|
||||
|
||||
**The `unreviewed` flag.** Skipping straight to `active` means nothing vetted
|
||||
the entry, so it lands with an **`unreviewed`** flag set. An entry that reaches
|
||||
`active` the normal way — super-draft → graduate — is never flagged, because
|
||||
graduation *is* the review. A project owner clears the flag with a
|
||||
**mark-reviewed** action (same authority as graduate), and the catalog has an
|
||||
**unreviewed filter** so owners can find the entries still awaiting review. The
|
||||
flag is an entry property (frontmatter, git-truth like `state`), orthogonal to
|
||||
the `active` state and only meaningful on `active` entries.
|
||||
|
||||
The separation-of-concerns rule (CLAUDE.md) is satisfied: the *type names*
|
||||
and their behavior are framework concepts (like role names), not
|
||||
deployment-specific content. A deployment chooses *which* type each of its
|
||||
projects is; it does not define new types or rename them.
|
||||
|
||||
## 1. Git topology — one content repo per project
|
||||
|
||||
One Gitea org for the deployment, **N content repos** (`ohm-content`,
|
||||
`specs-content`, `vision-content`, …). The bot already operates org-wide; it
|
||||
gains more repos and one registry repo. Slug uniqueness becomes naturally
|
||||
per-repo = per-project (and the slug is the whole identity — see Decision 3).
|
||||
|
||||
Rejected alternatives: subdirectories in one repo (`projects/<id>/rfcs/…`)
|
||||
churns every path / branch-name / graduation code path and can't carry
|
||||
git-layer access control per project; prefixed slugs pollute the namespace.
|
||||
|
||||
### The registry repo
|
||||
|
||||
A small repo (or a top-level file in a deployment repo) the bot reads and
|
||||
mirrors. Sketch shape:
|
||||
|
||||
```yaml
|
||||
# projects.yaml
|
||||
deployment:
|
||||
name: Wiggleverse
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm # url slug, stable, unique in deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd (immutable)
|
||||
content_repo: ohm-content # repo under the deployment's Gitea org
|
||||
visibility: gated # gated | public | unlisted
|
||||
initial_state: super-draft # super-draft | active — landing state of a
|
||||
# new entry; defaults from type
|
||||
philosophy_repo_path: PHILOSOPHY.md
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides
|
||||
```
|
||||
|
||||
Project **definition/config** is in git; project **membership** is in the DB
|
||||
(it churns like `rfc_collaborators` and is app-state, not document state). The
|
||||
registry gets the same webhook + reconciler treatment as content repos.
|
||||
|
||||
> Open: does the registry get its own repo, or is it a file in an existing
|
||||
> deployment/meta repo? Ties into the `ohm-rfc-app-flotilla` operator tooling,
|
||||
> which already owns deploy orchestration and could own registry edits.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
Introduce a **`projects`** cache table (mirrored from the registry, like
|
||||
`cached_rfcs` is mirrored from content). Then thread `project_id` through
|
||||
every RFC-scoped table.
|
||||
|
||||
Hard constraint: once slugs are unique only *within* a project, any table
|
||||
keyed on `rfc_slug` alone is ambiguous, so `project_id` must ride along on all
|
||||
of them:
|
||||
|
||||
- `cached_rfcs` — PK becomes `(project_id, slug)`; `rfc_id` unique per project
|
||||
- `cached_branches`, `cached_prs`, `pr_resolution_branches`, `proposed_use_cases`
|
||||
- `threads`, `changes`, `branch_visibility`, `branch_contribute_grants`
|
||||
- `stars`, `watches`, `pr_seen`, `branch_chat_seen`
|
||||
- `rfc_invitations`, `rfc_collaborators`, `contribution_requests`, `funder_consents`
|
||||
- `notifications`, `actions`
|
||||
|
||||
~15-table migration, all backfillable to a single default project (see §7).
|
||||
With slug-only identity (Decision 3) there is no per-project number to
|
||||
allocate: `api_graduation.py`'s `RFC-NNNN` allocator is **retired**, and
|
||||
graduation reduces to the status flip + content-repo move, keyed on
|
||||
`(project_id, slug)`.
|
||||
|
||||
New tables:
|
||||
|
||||
```
|
||||
projects(
|
||||
id TEXT PRIMARY KEY, -- 'ohm'
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL -- document | specification | bdd (immutable)
|
||||
CHECK (type IN ('document','specification','bdd')),
|
||||
initial_state TEXT NOT NULL -- super-draft | active (landing state, §types)
|
||||
DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
|
||||
content_repo TEXT NOT NULL,
|
||||
visibility TEXT CHECK (visibility IN ('gated','public','unlisted')),
|
||||
config_json TEXT, -- theme, tagline, enabled_models, …
|
||||
registry_sha TEXT, -- provenance of the mirrored row
|
||||
updated_at TEXT
|
||||
)
|
||||
|
||||
project_members(
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT CHECK (role IN ('project_admin','project_contributor','project_viewer')),
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
granted_at TEXT,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Roles — three tiers
|
||||
|
||||
A **middle tier** slots between today's deployment roles and per-RFC authority.
|
||||
|
||||
| Tier | Who | Powers |
|
||||
|---|---|---|
|
||||
| **Deployment** (unchanged, narrowed) | `owner` / `admin` | Create/archive projects, manage all accounts, act in any project. The beta/`permission_state` gate stays here — it gates *having an account*, not project access. A plain authenticated user has an account but no implicit project powers. |
|
||||
| **Project** (NEW — `project_members`) | `project_admin` / `project_contributor` / `project_viewer` | `project_admin` = today's app-admin, scoped to one project (manage its membership, settings, graduate, act on any RFC in it). `project_contributor` = propose/branch/PR/chat. `project_viewer` = read + discuss only. |
|
||||
| **RFC** (unchanged) | frontmatter `owners`/`arbiters`; `rfc_collaborators` (`contributor`/`discussant`) | Same as today, now scoped within their project. |
|
||||
|
||||
This is the existing owner → admin → contributor delegation pattern with a
|
||||
project axis added. `users.role` reverts to meaning *deployment*-level only.
|
||||
|
||||
**Visibility interaction:**
|
||||
|
||||
- **gated** (default) — invisible to non-members; must be a member to see it
|
||||
exists. Read and write both require membership.
|
||||
- **public** — any authenticated user can read; contributing requires a
|
||||
`project_contributor` grant.
|
||||
- **unlisted** — readable by direct link, not shown in the directory.
|
||||
|
||||
> Philosophy tension to resolve in SPEC: today the app is open-by-default
|
||||
> (anonymous read, §11.1; admission gates only writing). Gated-by-default
|
||||
> reverses that for the common case. The `public`/`unlisted` modes preserve the
|
||||
> old behavior for projects that want it, and the deployment can choose its own
|
||||
> default posture — but §11 and §14 need rewriting to make "gated" the baseline
|
||||
> and anonymous read a per-project opt-in.
|
||||
|
||||
**Discovery for gated projects:** since a stranger sees an empty directory,
|
||||
there must be a join path — invite-only (a `project_admin` adds you), or a
|
||||
request-to-join surface analogous to the existing §28 contribution-request
|
||||
flow. (Open — pick one.)
|
||||
|
||||
## 4. Branding & frontend (forced change)
|
||||
|
||||
The one *forced* change. `VITE_APP_NAME` is baked at build time; you cannot
|
||||
bake N project names into one bundle. Branding moves to **runtime config
|
||||
served by the backend**:
|
||||
|
||||
- `GET /api/deployment` → deployment name/tagline + the list of projects the
|
||||
caller can see (gated ones filtered by membership).
|
||||
- `GET /api/projects/:id` → that project's name, tagline, philosophy, theme
|
||||
tokens.
|
||||
- Frontend reads these instead of `import.meta.env.VITE_APP_NAME`
|
||||
(`App.jsx:208`, `Landing.jsx:16`, `BetaPending.jsx:17`).
|
||||
|
||||
`VITE_APP_NAME` is deprecated → deployment name comes from the registry. This
|
||||
is a documented config change with upgrade steps per CLAUDE.md.
|
||||
|
||||
Two chrome layers result:
|
||||
|
||||
- **Deployment chrome** — the Wiggleverse header, the project directory /
|
||||
landing, a project switcher.
|
||||
- **Project chrome** — the current header / catalog / philosophy, now per
|
||||
project; theme tokens (`tokens.css`) overridable per project at runtime.
|
||||
|
||||
## 5. Routing & UX
|
||||
|
||||
- Entry routes gain a project prefix with a **generic `/e/` segment**:
|
||||
`/p/<project>/e/<slug>`, `/p/<project>/proposals/<n>`,
|
||||
`/p/<project>/philosophy`, etc. The segment is the same for every type; the
|
||||
noun shown around the slug is a type-driven label, not part of the path.
|
||||
- Root `/` becomes the **deployment landing = project directory** (the
|
||||
Wiggleverse home). For an anonymous or non-member visitor under gated
|
||||
default, that's only public/unlisted-by-link projects.
|
||||
- Breadcrumb gains a segment: `Wiggleverse / OHM / Human › main` (the entry
|
||||
is named by its slug; the entry-noun the chrome uses around it is
|
||||
type-driven — "RFC", "Spec", "Feature").
|
||||
- The left-pane catalog (§7) becomes per-project; a project switcher lives in
|
||||
deployment chrome.
|
||||
|
||||
## 6. Cross-cutting — notifications, inbox, watches
|
||||
|
||||
Accounts are deployment-wide, so there is **one inbox spanning projects**
|
||||
(§15). `notifications` and `watches` carry `project_id` so the inbox is
|
||||
filterable and a user can mute an entire project. Quiet hours / digest prefs
|
||||
stay per-account (deployment level).
|
||||
|
||||
## 7. Backward compatibility & migration
|
||||
|
||||
The N=1 path keeps existing single-project deployments working:
|
||||
|
||||
1. Migration creates one **default project** from current config (`META_REPO`
|
||||
→ `content_repo`, `VITE_APP_NAME` → `name`, visibility seeded to match the
|
||||
deployment's current open posture, likely `public`). Its `id` is a
|
||||
config-derived slug (`DEFAULT_PROJECT_ID`, else slug of the deployment
|
||||
name, else `default`); M1's `default` bootstrap id is re-stamped to it in
|
||||
M3 before any `/p/` URL is public.
|
||||
2. Every existing row's `project_id` is stamped to that default project.
|
||||
3. An optional default-project redirect keeps old `/rfc/<slug>` URLs alive
|
||||
(308 → `/p/<default-id>/e/<slug>`).
|
||||
|
||||
This is the SPEC §20 upgrade-steps block.
|
||||
|
||||
## 8. SPEC & versioning impact
|
||||
|
||||
Touches §1, §2, §5, §6, §7, §8, §13, §14, §17, §20. Pre-1.0 minor with
|
||||
breaking changes spelled out (schema migration, `VITE_APP_NAME` deprecation,
|
||||
URL change, gated-default philosophy shift). Single-process SQLite stays fine —
|
||||
`project_id` is just a column; no DB-per-project, no Postgres forced.
|
||||
|
||||
## Operator-tooling integration (flotilla)
|
||||
|
||||
Checked against the `ohm-rfc-app-flotilla` spec (the OHM deployment's operator
|
||||
control panel). It assembles a deployment from `{rfc-app@pin} + {non-secret
|
||||
overlay} + {secret pulls} + {corpus}`, **does not host the corpus** ("the
|
||||
corpus lives in a deployment-side repo"), and **depends on the framework only
|
||||
through versioned contracts** (`/api/health`, `VERSION`, CHANGELOG
|
||||
upgrade-steps, the `.rfc-app-version` pin). The framework knows nothing about
|
||||
flotilla.
|
||||
|
||||
This confirms the registry decision and fixes how it integrates:
|
||||
|
||||
- The registry is **deployment-side git content the framework reads** — same
|
||||
category as the corpus — *not* a flotilla-owned config blob. Putting
|
||||
project definitions in operator tooling would re-bake deployment specifics
|
||||
into the assembly layer and add a new framework⇄tool contract.
|
||||
- The framework reads the registry via a `REGISTRY_REPO` env var, the
|
||||
multi-project successor to `META_REPO`. Flotilla's overlay simply swaps one
|
||||
ref; its four versioned contracts are untouched.
|
||||
- Multi-project therefore ships to OHM as an **ordinary pinned-version
|
||||
upgrade**: bump the pin, run the §22.13 default-project migration, change
|
||||
`META_REPO`→`REGISTRY_REPO` in the overlay, deploy, verify via
|
||||
`/api/health`. No flotilla architectural change.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Gated discovery: invite-only vs. request-to-join surface? (drafted with
|
||||
both; M5 can ship invite-only first.)
|
||||
- Does the deployment landing itself need branding config, or is it derived
|
||||
entirely from the registry `deployment:` block?
|
||||
- Per-project `ENABLED_MODELS` resolution vs. deployment universe (§18, §6.6,
|
||||
§6.7 funder) — confirm fallback order.
|
||||
- Slicing plan for the build (mirrors DEV.md's original slice approach).
|
||||
- **Type surfaces — depth of each.** What concretely is in the
|
||||
`specification` *release-planning* surface (its own tables? a release =
|
||||
a tag + a changelog entry + a set of graduated entries?), and does the
|
||||
`bdd` scenario model stay free-form markdown or get a structured
|
||||
Given/When/Then schema the app parses? Drafted shallow; pin before the
|
||||
type-surface slice.
|
||||
- **Entry-noun in URLs/labels** — *resolved 2026-06-02:* generic route
|
||||
segment `/p/<project>/e/<slug>` for every type; the displayed noun
|
||||
("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path
|
||||
(§22.10, §22.4a).
|
||||
- **Existing graduated numbers** — *resolved 2026-06-02:* pre-change graduated
|
||||
entries keep their `RFC-NNNN` `id` in frontmatter as a frozen, read-only
|
||||
legacy display label (preserves citations); never used for lookup, never
|
||||
assigned to new entries (§22.4).
|
||||
- **Default project `id`** — *resolved 2026-06-02:* a config-derived slug
|
||||
(`DEFAULT_PROJECT_ID`, else slug of the deployment name, else `default`);
|
||||
M1's `default` bootstrap id is re-stamped in M3 before any `/p/` URL is
|
||||
public, so it's meaningful (e.g. `/p/ohm/`) and never renamed live (§22.13).
|
||||
Reference in New Issue
Block a user