feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s

A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:

- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects/<id>/platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:51:28 -04:00
co-authored by Claude Opus 5.5
parent e68ccc5884
commit 07d2542479
27 changed files with 1638 additions and 51 deletions
+9
View File
@@ -103,6 +103,15 @@ async def can_admin_project(user_id: int, project_id: int) -> bool:
return perm in ("admin", "owner")
async def is_instance_admin(user_id: int) -> bool:
"""Whether the user administers the INSTANCE (users.role == "admin") —
the gate on the global catalogs (canonical areas, platforms), which belong
to no user and no project, so no share can grant a write to them."""
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
# ---------------------------------------------------------------------------
# Note / task permissions
# ---------------------------------------------------------------------------
+1 -4
View File
@@ -30,7 +30,6 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.system import System
from scribe.models.user import User
from scribe.services import access
logger = logging.getLogger(__name__)
@@ -60,9 +59,7 @@ def _tokens(slug: str) -> frozenset[str]:
async def _is_admin(user_id: int) -> bool:
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
return await access.is_instance_admin(user_id)
async def list_canonical_systems() -> list[CanonicalSystem]:
+22 -1
View File
@@ -660,6 +660,11 @@ class ArchiveScan(NamedTuple):
definitions: list[ArchiveShape]
references: dict[str, dict[str, int]] # path → class token → count
# EVERY file's repo-relative path, scannable or not (milestone 463). The
# platform markers are files this scan otherwise skips — go.mod,
# AndroidManifest.xml, a Dockerfile — so detection reads the names here
# rather than re-walking the tarball.
paths: tuple[str, ...] = ()
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
@@ -678,11 +683,14 @@ def scan_archive(blob: bytes) -> ArchiveScan:
"""
shapes: list[ArchiveShape] = []
references: dict[str, dict[str, int]] = {}
paths: list[str] = []
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "/" not in member.name:
continue
path = member.name.split("/", 1)[1]
if path:
paths.append(path)
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
continue
handle = tar.extractfile(member)
@@ -708,7 +716,7 @@ def scan_archive(blob: bytes) -> ArchiveScan:
)
if refs:
references[path] = refs
return ArchiveScan(shapes, references)
return ArchiveScan(shapes, references, tuple(paths))
# --- matching shapes against recorded locations ------------------------------
@@ -806,6 +814,8 @@ async def compute_coverage(
return None
served: list[tuple[str, str]] = []
# Every file path across the project's repos, for platform detection.
tree_paths: list[str] = []
recorded = await _recorded_locations(user_id, project_id)
# The proposer's canon catalog, read once per refresh and shared across
# the project's repos (#2792).
@@ -822,6 +832,7 @@ async def compute_coverage(
ref = binding.ref or await forge.default_branch(api_repo)
scan = scan_archive(await forge.archive(api_repo, ref))
definitions = scan.definitions
tree_paths.extend(scan.paths)
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
@@ -857,6 +868,16 @@ async def compute_coverage(
if not served:
return None
# Platform detection (milestone 463) rides the same walk: the paths are in
# hand once. It only ever ADDS membership the project has no answer for,
# and it must not be able to fail the refresh it rides on.
try:
from scribe.services import platforms as platforms_svc
await platforms_svc.detect_for_project(project_id, tree_paths)
except Exception:
logger.warning("platform detection failed for project %s", project_id, exc_info=True)
await shape_ledger.mark_canonicals(project_id, recorded)
try:
await shape_ledger.apply_derive_groups(project_id)
+84 -12
View File
@@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on
"via": "mcp" | "ui" | "legacy",
"choices": {
"design_system_id": <id> | null,
"seed_systems": bool
"seed_systems": bool,
"platforms": [<slug>, ...] | null
}
}
@@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
if nobody decides.
``platforms`` (milestone 463) is which platforms the project IS — the answer
that decides which family ideas reach it. A list of catalog SLUGS, not ids:
the record is JSON, and a slug survives a backup restore onto an install whose
ids differ, where an id inside JSON would come back naming another platform.
NULL means the question was not answered here, and the project's memberships
are left exactly as they are; a list is the full answer — every platform in it
is declared, and any platform detection had added that is NOT in it is
recorded as a "no", so the next refresh cannot put it back.
"""
from __future__ import annotations
@@ -38,7 +48,7 @@ from scribe.models import async_session
from scribe.models.project import Project
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("design_system_id", "seed_systems")
CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
def validate_inception(choices) -> str | None:
@@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None:
None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts two keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
Accepts three keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown
keys are an error — a typo, or a choice
the product no longer offers, must not become a silently ignored one."""
if not isinstance(choices, dict):
return "choices must be an object"
@@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None:
seed = choices.get("seed_systems", False)
if not isinstance(seed, bool):
return "seed_systems must be true or false"
platforms = choices.get("platforms")
if platforms is not None and (
not isinstance(platforms, list)
or not all(isinstance(p, str) and p.strip() for p in platforms)
):
return "platforms must be a list of platform slugs (list_platforms), or null"
return None
def normalize_choices(choices: dict | None) -> dict:
"""Both keys, always present, in canonical form — what gets stored
"""Every key, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
platforms = choices.get("platforms")
return {
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
"platforms": (
sorted({p.strip() for p in platforms}) if platforms is not None else None
),
}
@@ -81,11 +102,15 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
{design_system_id, design_systems: [{id,title}], systems: <count>}.
{design_system_id, design_systems: [{id,title}], systems: <count>,
platforms: [{slug,name}], project_platforms: [{slug,name,state}]}.
Instance-agnostic: an install with no design systems shows an empty list,
and the ask says so rather than inventing a default.
and the ask says so rather than inventing a default. `project_platforms`
is what detection has already found (and anything already answered), so
the form can start from it.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import systems as systems_svc
@@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
raise ValueError(f"project {project_id} not found")
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
catalog = await platforms_svc.list_platforms()
return {
"design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems),
"platforms": [{"slug": p.slug, "name": p.name} for p in catalog],
"project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [],
}
@@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None:
effect lands — a decision applies whole or errors whole."""
from scribe.services import access
from scribe.services import platforms as platforms_svc
ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds):
raise ValueError(f"design system {ds} not found (or not readable)")
if choices["platforms"]:
_, unknown = await platforms_svc.resolve_slugs(choices["platforms"])
if unknown:
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async def decide(
@@ -127,7 +161,8 @@ async def decide(
replaces the design system and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {design_system_id,
systems_seeded}}.
systems_seeded, platforms}} — `platforms` is the project's answers after
the decision, or None when the choice was left unstated.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
@@ -152,6 +187,9 @@ async def decide(
await systems_svc.seed_standard_systems(user_id, project_id)
if choices["seed_systems"] else []
)
platforms = None
if choices["platforms"] is not None:
platforms = await _apply_platforms(user_id, project_id, choices["platforms"])
record = {
"decided_at": datetime.now(timezone.utc).isoformat(),
@@ -169,10 +207,41 @@ async def decide(
"effects": {
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
"platforms": platforms,
},
}
async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]:
"""The platforms answer as membership. The list is the WHOLE answer:
every slug in it is declared, and every platform the project was a member
of (declared or detected) that is left out becomes rejected — so the next
refresh cannot detect back what the person just said the project isn't.
Platforms already rejected stay rejected; platforms never answered stay
unanswered."""
from scribe.services import platforms as platforms_svc
named = set(slugs)
current = await platforms_svc.project_platforms(user_id, project_id) or []
updates: dict[str, str | None] = {slug: "declared" for slug in named}
for row in current:
if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES:
updates[row["slug"]] = "rejected"
return await platforms_svc.set_project_platforms(user_id, project_id, updates)
def _platform_line(defaults: dict) -> str:
"""What the ask says about platforms: what detection already found, and
the catalog to choose from."""
found = [
p["slug"] for p in defaults.get("project_platforms", [])
if p["state"] in ("declared", "detected")
]
catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none"
lead = f"detected so far: {', '.join(found)}; " if found else ""
return f"{lead}catalog: {catalog}"
async def inception_ask(user_id: int, project_id: int) -> dict:
"""The enter_project ask for an undecided project (milestone 297) — the
sibling of the systems-bootstrap ask (#2683): the project's OWN current
@@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
"inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which design system (or none), and whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded."
"once: which design system (or none), whether to seed "
"the standard starter Systems, and which platforms the project is "
f"built on or ships as ({_platform_line(defaults)}) — then record the "
"answers. This ask repeats on every enter_project until a decision "
"is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
"design_system_id=<id | -1 for none>, seed_systems=<true|false>, "
"platforms=[<slug>, ...])"
),
}
+335
View File
@@ -0,0 +1,335 @@
"""Platforms — what a project is built on or ships as, and which projects are
which (milestone 463 step 2).
Membership is what makes "is this idea in family for that project?" a lookup
rather than a judgment: a family idea is for some platforms, and it reaches
every project that is a member of one of them.
Three ways a project becomes, or refuses to become, a member:
- **declared** — a person said so: at inception, or in the project's settings.
- **detected** — a marker file in a bound repo said so, found by the coverage
refresh that already walks the repo archive.
- **rejected** — a person said NO. Kept as a row, so the next refresh does not
detect it straight back.
The one invariant everything here protects: **detection only ever ADDS, and
only where nobody has answered.** It never overwrites a declared or rejected
row, and it never removes anything — not even a detected row whose marker has
since disappeared, because membership is what adoption rows hang off and a
platform flickering in and out with a repo's file tree would churn a ledger
of decisions nobody re-made.
The catalog itself is global, like the canonical area catalog, and for the
same reason writes to it are admin-only: a shared vocabulary anyone can extend
stops being shared. Reads are open to any signed-in user.
"""
from __future__ import annotations
import fnmatch
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import Platform, ProjectPlatform
from scribe.services import access
from scribe.services.canonical_systems import canonical_slug
logger = logging.getLogger(__name__)
# The states that make a project a member. `rejected` is an answer, not
# membership.
MEMBER_STATES = ("declared", "detected")
# What a person may set from a door. `detected` is the refresh's to write.
SETTABLE_STATES = ("declared", "rejected")
# --- the catalog -------------------------------------------------------------
async def list_platforms() -> list[Platform]:
"""The whole live catalog, in display order. Global — no owner filter."""
async with async_session() as session:
result = await session.execute(
select(Platform)
.where(Platform.deleted_at.is_(None))
.order_by(Platform.order_index.asc(), Platform.name.asc())
)
return list(result.scalars().all())
def _clean_markers(markers) -> list[str]:
"""Markers as stored: stripped, non-empty strings, de-duplicated in order.
Anything else is refused by the caller before it gets here."""
seen: list[str] = []
for m in markers or []:
m = str(m).strip()
if m and m not in seen:
seen.append(m)
return seen
def validate_markers(markers) -> str | None:
"""The error a markers value would earn, or None. Pure."""
if markers is None:
return None
if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
return "markers must be a list of glob patterns"
if any(m.strip().startswith("/") for m in markers):
return "markers are repo-relative — no leading slash"
return None
async def create_platform(
user_id: int, name: str, *, description: str | None = None,
markers: list[str] | None = None,
) -> Platform | dict | None:
"""Add a platform to the global catalog. Admin only.
Duplicate-gated on the slug, so "Android App" cannot be added beside
"Android app": the existing entry comes back instead of a second spelling
of it. None means not permitted, or no usable name.
"""
if not await access.is_instance_admin(user_id):
return None
slug = canonical_slug(name)
if not slug:
return None
error = validate_markers(markers)
if error:
raise ValueError(error)
async with async_session() as session:
existing = await session.scalar(
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
)
if existing is not None:
return {
"duplicate": True,
"existing_id": existing.id,
"message": (
f"'{existing.name}' (#{existing.id}) is already this platform — "
f"both names reduce to '{slug}'."
),
}
highest = await session.scalar(
select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
)
entry = Platform(
name=" ".join(name.split()),
slug=slug,
description=description,
markers=_clean_markers(markers),
order_index=(highest or 0) + 1,
)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
"""Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
A rename recomputes the slug — the display name and the match key must not
disagree. Changing the slug of a platform projects already belong to is
safe: membership is by id; only a backup carries the slug.
"""
if not await access.is_instance_admin(user_id):
return None
if "markers" in fields:
error = validate_markers(fields["markers"])
if error:
raise ValueError(error)
async with async_session() as session:
entry = await session.get(Platform, platform_id)
if entry is None or entry.deleted_at is not None:
return None
if fields.get("name"):
entry.name = " ".join(str(fields["name"]).split())
entry.slug = canonical_slug(entry.name)
if fields.get("description") is not None:
entry.description = fields["description"] or None
if fields.get("order_index") is not None:
entry.order_index = int(fields["order_index"])
if fields.get("markers") is not None:
entry.markers = _clean_markers(fields["markers"])
entry.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(entry)
return entry
async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
"""{slug: id} for the slugs the live catalog knows, and the ones it does
not. Doors take slugs — readable, stable across installs, and the form an
inception record and a backup both carry."""
wanted = [s for s in dict.fromkeys(slugs or [])]
if not wanted:
return {}, []
async with async_session() as session:
rows = (await session.execute(
select(Platform.slug, Platform.id).where(
Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
)
)).all()
known = {slug: pid for slug, pid in rows}
return known, [s for s in wanted if s not in known]
# --- detection (pure) --------------------------------------------------------
def marker_matches(marker: str, path: str) -> bool:
"""Whether one marker matches one repo-relative path.
A marker with no slash matches a file's BASENAME anywhere in the tree
(`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
matches the whole repo-relative path (`.github/workflows/*`), so a
directory-shaped marker cannot be satisfied by a same-named file
somewhere else.
"""
marker = marker.strip()
if not marker:
return False
if "/" in marker:
return fnmatch.fnmatchcase(path, marker)
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
def detect(catalog: list, paths: list[str]) -> list[int]:
"""The ids of every catalog platform at least one of whose markers matches
at least one path. Pure, so it is testable against a fixture tree with no
forge and no database. A platform with no markers is never detected —
that is what declare-only means."""
hits: list[int] = []
for platform in catalog:
markers = list(getattr(platform, "markers", None) or [])
if markers and any(marker_matches(m, p) for m in markers for p in paths):
hits.append(platform.id)
return hits
# --- membership ----------------------------------------------------------------
async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
"""Every platform the project has an answer for, joined to the catalog.
None when the caller cannot read the project.
Rejected rows are included — a settings screen has to show "no" as well
as "yes", or it cannot let anyone change their mind.
"""
if not await access.can_read_project(user_id, project_id):
return None
async with async_session() as session:
rows = (await session.execute(
select(ProjectPlatform, Platform)
.join(Platform, Platform.id == ProjectPlatform.platform_id)
.where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
.order_by(Platform.order_index.asc(), Platform.name.asc())
)).all()
return [
{"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
for m, p in rows
]
def members(platforms: list[dict]) -> list[dict]:
"""The rows of `project_platforms` that are membership — the brief form
enter_project and get_project carry."""
return [
{"slug": p["slug"], "name": p["name"], "state": p["state"]}
for p in platforms if p["state"] in MEMBER_STATES
]
def validate_updates(updates) -> str | None:
"""The error a set of membership answers would earn, or None. Pure.
`updates` is {slug: "declared" | "rejected" | None}. None withdraws the
answer — the row goes, and detection may add the platform again later.
`detected` is not settable: it is what the refresh writes, and a person
claiming it would erase the difference between the two."""
if not isinstance(updates, dict):
return "platforms must be an object of {slug: state}"
for slug, state in updates.items():
if not isinstance(slug, str) or not slug:
return "each platform is named by its slug"
if state is not None and state not in SETTABLE_STATES:
return (
f"'{state}' is not a state a person sets — use one of "
f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
)
return None
async def set_project_platforms(
user_id: int, project_id: int, updates: dict[str, str | None],
) -> list[dict]:
"""Apply a person's answers about a project's platforms. Write-gated on
the project (rule 78). Platforms not named are left exactly as they are.
Raises ValueError, naming the problem, on a malformed update, an unknown
slug, or no write access — before anything is written, so the update
applies whole or not at all.
"""
error = validate_updates(updates)
if error:
raise ValueError(error)
if not await access.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
known, unknown = await resolve_slugs(list(updates))
if unknown:
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async with async_session() as session:
existing = {
row.platform_id: row for row in (await session.execute(
select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
)).scalars().all()
}
for slug, state in updates.items():
pid = known[slug]
row = existing.get(pid)
if state is None:
if row is not None:
await session.delete(row)
elif row is None:
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
else:
row.state = state
await session.commit()
return await project_platforms(user_id, project_id) or []
async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
"""Add `detected` membership for each platform the project has NO answer
for. Returns the ids actually added.
The refresh's writer, so it takes no user: it runs on the owner's behalf
inside a sync the owner's keyring authorised. It never updates or deletes
a row — see the module docstring for why that is the whole contract.
"""
if not platform_ids:
return []
async with async_session() as session:
answered = set((await session.execute(
select(ProjectPlatform.platform_id).where(
ProjectPlatform.project_id == project_id,
)
)).scalars().all())
added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
for pid in added:
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
await session.commit()
return added
async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
"""Run detection over a refresh's paths and record what is new. The
coverage refresh's single call — it must not be able to fail that
refresh, so the caller wraps it."""
hits = detect(await list_platforms(), paths)
added = await record_detected(project_id, hits)
if added:
logger.info("project %s: detected platform(s) %s", project_id, added)
return added