diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9aea8..463ac15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,77 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.45.0 — 2026-06-06 + +**Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge + +per-collection model universe + the type-driven entry noun.* The three-tier +model (deployment → project → RFC collection) is now written into the binding +`SPEC.md` as a canonical §22, so the spec finally reflects the shipped S1–S5 +behavior; a collection may narrow its project's model universe; and the entry +noun ("RFC" / "Spec" / "Feature") follows the collection's type. Purely additive +over S5 — one additive column (no rebuild), docs, and two small features; no +change to existing read or write semantics.** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part E slice S6; Parts A/B/D, merged into `SPEC.md` §22). With S6 the §22 +three-tier model is realized in the binding contract. **Two S6 items remain +open and are carried to a follow-up slice** (they want a discovery/spec pass +first, lacking BDD scenarios in Part C): the per-type *surfaces* — per-type +frontmatter schemas, the `specification` release-planning data model, the `bdd` +scenario/coverage views (§22.4a items 1 & 3, flagged "first proposals" in the +design doc) — and **request-to-join + the cross-collection inbox** (§22.8; S4 +shipped the invite half). + +Added: + +- **§22 merged into `SPEC.md` (the keystone)** — a canonical §22.1–§22.14 writes + the three-tier model into the binding spec: the tiers + collection-grain + isolation, the registry (`projects.yaml`) + `.collection.yaml` manifests, + one-content-repo-per-project, per-collection slug identity, collection + `type`/`initial_state`/`unreviewed`, two-tier (narrow-only) visibility, the + unified `{owner, contributor}` role vocabulary at `{global, project, + collection}`, the four-layer most-permissive union, discovery/joining, + runtime branding, `/p//c//` routing, the one inbox, the + per-collection model universe, and the default-project+collection migration. + The **S3 keystone reinterpretation of §B.1/§B.3** lands here: a plain granted + account is a granted *account*, not a global write role; "global RFC + Contributor" is an explicit `scope_type='global'` grant; the implicit-public + write baseline is grandfathered onto the migration-seeded default collection + only. Forward-pointer amendment notes added at §1/§2/§5/§6. The registry + + manifest formats are documented for operators in + [`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md). +- **Per-collection model universe (§22.12)** — a collection's `.collection.yaml` + may carry an `enabled_models` list that narrows its project's universe, which + in turn narrows the deployment `ENABLED_MODELS`. Resolution (extending + §6.6/§6.7) is `funder ∩ per-entry models ∩ collection ∩ project`, with the + operator providers as the ceiling — a collection can only narrow, never widen. + An absent list inherits the parent; an empty list opts the collection out of + AI. Surfaced as `enabled_models` on `GET /api/projects/:id/collections/:cid`. +- **Type-driven entry noun (§22.4a)** — the displayed noun for an entry follows + the collection type (`document` → "RFC", `specification` → "Spec", `bdd` → + "Feature"), defined once in the framework and read from the API + (`entry_noun` on the collection, directory, and project surfaces) rather than + hardcoded. The catalog's propose control and the propose modal name entries + accordingly. + +Schema: + +- **Migration `031_collection_enabled_models.sql`** — additive + `collections.config_json` (paralleling `projects.config_json`), holding the + per-collection `enabled_models`. No table rebuild; `NULL` means "inherit the + project's universe." + +Upgrade steps (from 0.44.0): + +1. Rebuild and redeploy as usual; the framework **SHALL** apply migration + `031_collection_enabled_models.sql` automatically on start (additive column, + no data movement, no operator action). +2. A deployment **MAY** now add an `enabled_models` list to any + `.collection.yaml` to narrow that collection's model universe, and **MAY** + create `specification`- or `bdd`-typed collections to get the corresponding + entry noun. Both are optional; absent them every collection behaves exactly + as before. + ## 0.44.0 — 2026-06-06 **Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app diff --git a/VERSION b/VERSION index a8ab6c9..bcce5d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.44.0 +0.45.0 diff --git a/backend/tests/test_s6_two_project_multicollection.py b/backend/tests/test_s6_two_project_multicollection.py new file mode 100644 index 0000000..511e63b --- /dev/null +++ b/backend/tests/test_s6_two_project_multicollection.py @@ -0,0 +1,90 @@ +"""§22 S6 — two-project / multi-collection integrative pass. + +Proves the S6 surface holds across a deployment with two projects, each owning +distinct collections, with the new per-collection knobs (§22.12 enabled_models, +§22.4a type noun) resolving independently per collection — no cross-project or +cross-collection bleed. + +Slug note: the resolver is slug-keyed, so this test uses distinct slugs across +collections (the documented limitation — same-slug-across-collections model +resolution is part of the broader collection-id-threading follow-up). +""" +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from app import collections, db, models_resolver +from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401 +from test_rfc_view_vertical import FakeProvider # noqa: F401 + + +def _project(pid: str, visibility: str = "public") -> None: + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, config_json, updated_at) " + "VALUES (?, ?, ?, ?, ?, datetime('now'))", + (pid, pid.capitalize(), f"{pid}-content", visibility, None), + ) + + +def _collection(cid: str, project_id: str, *, ctype: str, enabled_models=None) -> None: + cfg = None if enabled_models is None else json.dumps({"enabled_models": enabled_models}) + db.conn().execute( + "INSERT OR REPLACE INTO collections " + "(id, project_id, type, subfolder, initial_state, visibility, name, config_json, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'super-draft', 'public', ?, ?, datetime('now'), datetime('now'))", + (cid, project_id, ctype, cid, cid.capitalize(), cfg), + ) + + +def _entry(slug: str, collection_id: str) -> None: + db.conn().execute( + "INSERT OR REPLACE INTO cached_rfcs (slug, title, state, collection_id) " + "VALUES (?, ?, 'active', ?)", + (slug, slug.upper(), collection_id), + ) + + +def _three_providers(app) -> None: + app.state.providers.clear() + for k in ("claude", "gemini", "gpt"): + app.state.providers[k] = FakeProvider("TITLE: A\nDESCRIPTION: B") + + +def test_two_projects_multicollection_model_universe_isolated(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app): + _three_providers(app) + # alpha: a document collection narrowed to claude. + _project("alpha") + _collection("alpha-docs", "alpha", ctype="document", enabled_models=["claude"]) + _entry("alpha-intro", "alpha-docs") + # beta: two collections — a spec narrowed to gemini, a bdd left open. + _project("beta") + _collection("beta-specs", "beta", ctype="specification", enabled_models=["gemini"]) + _collection("beta-features", "beta", ctype="bdd") # no narrowing → all three + _entry("beta-runtime", "beta-specs") + _entry("beta-login", "beta-features") + + # Each entry resolves against ITS collection's universe — no bleed. + assert models_resolver.resolve_models_for_rfc("alpha-intro", app.state.providers) == ["claude"] + assert models_resolver.resolve_models_for_rfc("beta-runtime", app.state.providers) == ["gemini"] + assert models_resolver.resolve_models_for_rfc("beta-login", app.state.providers) == [ + "claude", "gemini", "gpt" + ] + + +def test_two_projects_multicollection_type_noun_isolated(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app): + _project("alpha") + _collection("alpha-docs", "alpha", ctype="document") + _project("beta") + _collection("beta-specs", "beta", ctype="specification") + _collection("beta-features", "beta", ctype="bdd") + + assert collections.get_collection("alpha-docs")["entry_noun"] == "RFC" + assert collections.get_collection("beta-specs")["entry_noun"] == "Spec" + assert collections.get_collection("beta-features")["entry_noun"] == "Feature" diff --git a/frontend/package.json b/frontend/package.json index e262972..3046176 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.44.0", + "version": "0.45.0", "type": "module", "scripts": { "dev": "vite",