"""Project inception — what a project was decided to inherit. A project's inheritance is a decision, not a default. The record lives on ``projects.inception``:: { "decided_at": "", "decided_by": | null, "via": "mcp" | "ui" | "legacy", "choices": { "design_system_id": | null, "seed_systems": bool, "platforms": [, ...] | null } } NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on projects that predate inception records (inherit-all / no design system / no seed), so the ask fires only for projects created since. Rules are not a choice: a rule in a rulebook is global and applies to every project, and a project's own rules are written on it directly. Migration 0101 strips the retired ``exclude_always_on_rulebooks`` and ``subscribe_rulebooks`` keys from stored records. The shape and its validator are pure; ``decide`` composes the existing services — set_project_design_system and the standard Systems seed — checks every target BEFORE touching anything, 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`` 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 from datetime import datetime, timezone from scribe.models import async_session from scribe.models.project import Project INCEPTION_VIAS = ("mcp", "ui", "legacy") CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms") def validate_inception(choices) -> str | None: """The structural error an inception ``choices`` object would earn, or None. Pure and checked BEFORE any effect is applied: a decision either applies whole or errors whole. 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" unknown = sorted(set(choices) - set(CHOICE_KEYS)) if unknown: return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})" ds = choices.get("design_system_id") if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0): return "design_system_id must be a positive id or null" 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: """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 ), } def is_decided(project) -> bool: """A project is decided once its inception record exists (any via).""" return bool(getattr(project, "inception", None)) 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: , 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. `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 project = await projects_svc.get_project(user_id, project_id) if project is None: 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 [], } async def _check_targets(user_id: int, choices: dict) -> None: """Every id a decision names must be the caller's (or readable) BEFORE any 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( user_id: int, project_id: int, *, choices: dict | None, via: str, ) -> dict: """Record a project's inception decision and apply it. Owner-only. Validates the choices (pure) and every target (readable) first; then, each idempotent: point the project at the design system (None = explicitly none), seed the standard Systems if asked and the project has none; then write ``projects.inception`` LAST. Re-deciding replaces the design system and re-seeds nothing a project already has. Returns {"inception": , "effects": {design_system_id, 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 from scribe.services import systems as systems_svc if via not in INCEPTION_VIAS or via == "legacy": raise ValueError("via must be 'mcp' or 'ui' ('legacy' is the migration's stamp)") error = validate_inception(choices or {}) if error: raise ValueError(error) choices = normalize_choices(choices) project = await projects_svc.get_project(user_id, project_id) # owner-scoped if project is None: raise ValueError(f"project {project_id} not found (or not yours)") await _check_targets(user_id, choices) if not await design_systems_svc.set_project_design_system( user_id, project_id, choices["design_system_id"] ): raise ValueError("could not set the design system (no write on the project?)") seeded = ( 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(), "decided_by": user_id, "via": via, "choices": choices, } async with async_session() as session: row = await session.get(Project, project_id) row.inception = record row.updated_at = datetime.now(timezone.utc) await session.commit() return { "inception": record, "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 — the sibling of the systems-bootstrap ask: the project's OWN current defaults, what to ask the operator, and the exact call that answers it. Fail-open: a hint must never break the call it rides on.""" try: defaults = await current_defaults(user_id, project_id) except Exception: return {} designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none" return { "defaults": defaults, "ask": ( "This project has no inception decision: nobody has said what it " "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), 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=, seed_systems=, " "platforms=[, ...])" ), }