§22 S6: per-collection enabled_models — the §22.12 model-universe chain

A collection's .collection.yaml may carry an enabled_models list that NARROWS
its project's universe, which narrows the deployment ENABLED_MODELS. Resolution
(extending §6.6/§6.7): funder ∩ per-entry models ∩ collection ∩ project, with
the operator providers as the ceiling.

- migration 031: additive collections.config_json (parallels projects.config_json).
- registry.parse_collection_manifest: read enabled_models into CollectionEntry.config
  (absent = inherit, present incl. [] = narrow; [] opts the collection out of AI);
  reject a non-list. _upsert_named_collection persists config_json. The default
  collection (from projects.yaml) leaves it NULL — it inherits the project.
- models_resolver: _scope_narrowed_universe narrows the operator universe by the
  entry's project then collection enabled_models; the funder universe is bounded
  by it too. A collection cannot widen its project (narrowing from the ceiling).
- collections.get_collection surfaces enabled_models; GET
  /api/projects/:id/collections/:cid returns it.
- test_s6_collection_models_vertical.py: 9 cases (parse, the narrowing chain
  incl. opt-out + cannot-widen, API surfacing).

Backend: 525 passed. Per SPEC §22.12. Releases as part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-06 01:30:16 -07:00
parent 26f3680197
commit 79a27a946b
5 changed files with 252 additions and 14 deletions
@@ -0,0 +1,141 @@
"""§22.12 S6 — per-collection model universe.
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. The resolution chain (extending §6.6/§6.7) is:
funder ∩ per-entry models ∩ collection universe ∩ project universe
(operator providers = the ceiling)
These tests prove (1) the manifest parser reads `enabled_models`, (2) the
mirror stores it on the collection row, (3) the resolver narrows the base
universe by project then collection, with absent = inherit and [] = opt-out,
and (4) the collection API surfaces the collection's own narrowing.
"""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app import db, models_resolver, registry
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
)
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
def _install_two_providers(app) -> None:
app.state.providers.clear()
app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B")
app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H")
def _set_project_models(project_id: str, models) -> None:
cfg = {} if models is None else {"enabled_models": models}
db.conn().execute(
"UPDATE projects SET config_json = ? WHERE id = ?",
(json.dumps(cfg), project_id),
)
def _set_collection_models(collection_id: str, models) -> None:
cfg = None if models is None else json.dumps({"enabled_models": models})
db.conn().execute(
"UPDATE collections SET config_json = ? WHERE id = ?",
(cfg, collection_id),
)
# ── manifest parsing ───────────────────────────────────────────────────────
def test_manifest_parses_enabled_models_into_config():
ce = registry.parse_collection_manifest(
"type: bdd\nname: Features\nenabled_models: [claude, gemini]\n"
)
assert ce.config.get("enabled_models") == ["claude", "gemini"]
def test_manifest_without_enabled_models_has_no_key():
ce = registry.parse_collection_manifest("type: document\nname: Model\n")
assert "enabled_models" not in ce.config
def test_manifest_rejects_non_list_enabled_models():
import pytest
with pytest.raises(registry.RegistryError):
registry.parse_collection_manifest("type: bdd\nenabled_models: claude\n")
# ── resolver narrowing (the §22.12 chain) ──────────────────────────────────
def test_resolver_inherits_operator_universe_when_no_scope_narrowing(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
_install_two_providers(app)
# No project/collection narrowing → full operator universe.
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
assert resolved == ["claude", "gemini"]
def test_resolver_narrows_by_project_universe(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
_install_two_providers(app)
_set_project_models("default", ["gemini"])
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
assert resolved == ["gemini"]
def test_resolver_collection_narrows_within_project(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
_install_two_providers(app)
# Project allows both; the collection narrows to claude only.
_set_project_models("default", ["claude", "gemini"])
_set_collection_models("default", ["claude"])
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
assert resolved == ["claude"]
def test_resolver_collection_empty_list_opts_out(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
_install_two_providers(app)
_set_collection_models("default", []) # opt this collection out of AI
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
assert resolved == []
def test_resolver_collection_cannot_widen_project(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
seed_active_rfc(fake, slug="ohm", title="OHM", body="x")
_install_two_providers(app)
# Project restricts to gemini; the collection naming claude+gemini
# cannot re-add claude (narrowing only).
_set_project_models("default", ["gemini"])
_set_collection_models("default", ["claude", "gemini"])
resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers)
assert resolved == ["gemini"]
# ── API surfacing ──────────────────────────────────────────────────────────
def test_collection_api_surfaces_enabled_models(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
_set_collection_models("default", ["claude"])
r = client.get("/api/projects/default/collections/default")
assert r.status_code == 200, r.text
assert r.json().get("enabled_models") == ["claude"]