§22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)

- collections.py resolution helpers (default_collection_id, type, initial_state)
- registry mirror writes project grouping fields + default-collection corpus fields
- auth.project_of_rfc joins collections; project_member_role reads memberships
- cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised
  project_id tags unchanged); api_deployment reads type/initial_state from the default collection
- projects.py restamp detects bootstrap via collections; initial_state via collection
- tests updated to the three-tier schema; test_migration_028 retired (superseded by 029)
- add @S1 acceptance test (collection grain + N=1 serving)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 08:26:14 -07:00
parent 867f2504d6
commit 9ca07a3f81
27 changed files with 475 additions and 301 deletions
@@ -64,39 +64,55 @@ def _set_visibility(project_id: str, visibility: str) -> None:
)
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import db
# §22 three-tier (§B.3): M2's three project roles collapse to {owner,
# contributor} in the unified `memberships` table at the project's default
# collection. The read-only `viewer` tier is deferred (folded into contributor
# for this pass), so the legacy role names map: admin→owner, contributor and
# viewer→contributor.
_ROLE_MAP = {
"project_admin": "owner",
"project_contributor": "contributor",
"project_viewer": "contributor",
}
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute(
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)",
(project_id, user_id, role),
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('collection', ?, ?, ?)",
(cid, user_id, _ROLE_MAP[role]),
)
def _remove_member(project_id: str, user_id: int) -> None:
from app import db
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user_id),
"DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
(cid, user_id),
)
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
project_id). project_id defaults to 'default' via migration 026 but we set
it explicitly for clarity."""
collection grain). The entry lands in the project's default collection
(id == project_id for the single 'default' project under test)."""
import json
from app import db
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, project_id)
(slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
VALUES (?, ?, ?, ?, '[]', '[]', ?)
""",
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
(slug, slug.capitalize(), state, json.dumps(owners or []), cid),
)
@@ -154,18 +170,16 @@ def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
assert auth.can_read_project(owner, "default") is True
assert auth.is_project_superuser(owner, "default") is True
# project_viewer → read + discuss, but not contribute.
_add_member("default", 1, "project_viewer")
# §22 three-tier (§B.3): the read-only viewer tier is deferred — the
# smallest grant is `contributor`, which grants read + discuss +
# contribute across the subtree.
_add_member("default", 1, "project_contributor")
assert auth.can_read_project(contributor, "default") is True
assert auth.can_discuss_in_project(contributor, "default") is True
assert auth.can_contribute_in_project(contributor, "default") is False
# project_contributor → contribute.
_add_member("default", 1, "project_contributor")
assert auth.can_contribute_in_project(contributor, "default") is True
assert auth.is_project_superuser(contributor, "default") is False
# project_admin → superuser within the project.
# project_admin → owner → superuser within the project.
_add_member("default", 1, "project_admin")
assert auth.is_project_superuser(contributor, "default") is True
@@ -270,7 +284,7 @@ def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
assert client.post("/api/rfcs/propose", json=body).status_code != 403
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
@@ -285,14 +299,11 @@ def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
# project_viewer: can discuss (200) but cannot contribute (resolver).
_add_member("default", 2, "project_viewer")
# §22 three-tier (§B.3): a `contributor` member can both discuss and
# contribute (the viewer-only read tier is deferred this pass).
_add_member("default", 2, "project_contributor")
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
assert r.status_code == 200, r.text
assert auth.can_contribute_to_rfc(bob, "spec") is False
# project_contributor: can contribute.
_add_member("default", 2, "project_contributor")
assert auth.can_contribute_to_rfc(bob, "spec") is True