"""Project inception — what a project was decided to inherit (milestone 297). 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": { "subscribe_rulebooks": [rulebook ids], "design_system_id": | null, "seed_systems": bool } } NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on projects that existed before the step did (inherit-all / no design system / no seed), so the ask fires only for projects created after this shipped. ``exclude_always_on_rulebooks`` was a fourth choice until milestone 394. It let a project decline to inherit an always-on rulebook, and with no always-on tier there is nothing to decline — a rulebook now reaches a project by subscription, which is opt-IN, so declining is expressed by not subscribing. The shape and its validator are pure; ``decide`` composes the existing services — subscriptions, set_project_design_system, 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. """ from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import select from scribe.models import async_session from scribe.models.project import Project from scribe.models.rulebook import Rulebook INCEPTION_VIAS = ("mcp", "ui", "legacy") CHOICE_KEYS = ("subscribe_rulebooks", "design_system_id", "seed_systems") def _is_id_list(value) -> bool: return isinstance(value, list) and all( isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value ) 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 (the StrictArgs lesson, #2709). Accepts the four keys, each optional: two id lists (positive ints, no duplicates between exclude and subscribe), ``design_system_id`` an int or None, ``seed_systems`` a bool. Unknown keys are an error — a typo must not become a silently ignored choice.""" 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)})" subs = choices.get("subscribe_rulebooks") or [] if not _is_id_list(subs): return "subscribe_rulebooks must be a list of rulebook ids" 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" return None def normalize_choices(choices: dict | None) -> dict: """The three keys, always present, in canonical form — what gets stored and what the UI/agent reads back. Call after validate_inception.""" choices = choices or {} return { "subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])), "design_system_id": choices.get("design_system_id"), "seed_systems": bool(choices.get("seed_systems", False)), } 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. {rulebooks: [{id,title}], subscribed_rulebooks: [...], design_system_id, design_systems: [{id,title}], systems: }. Instance-agnostic: an install with no rulebooks / design systems shows empty lists, and the ask says so rather than inventing a default. """ from scribe.services import design_systems as design_systems_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_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") async with async_session() as session: rows = ( await session.execute( select(Rulebook.id, Rulebook.title) .where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None)) .order_by(Rulebook.title) ) ).all() applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1) designs = await design_systems_svc.list_design_systems(user_id) systems = await systems_svc.list_systems(user_id, project_id, include_archived=True) return { # ONE list since milestone 394. This was split into always-on and # "other" because the first bound the project whether it asked or not; # with the tier gone every rulebook is opt-in, so the split named a # difference that no longer exists. "rulebooks": [{"id": i, "title": t} for i, t in rows], "subscribed_rulebooks": applicable.get("subscribed_rulebooks", []), "design_system_id": project.design_system_id, "design_systems": [{"id": d.id, "title": d.title} for d in designs], "systems": len(systems), } 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 wanted = set(choices["subscribe_rulebooks"]) if wanted: async with async_session() as session: rows = ( await session.execute( select(Rulebook.id).where( Rulebook.id.in_(wanted), Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None), ) ) ).all() found = {rid for (rid,) in rows} missing = sorted(wanted - found) if missing: raise ValueError(f"rulebook(s) {missing} not found (or not yours)") 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)") async def decide( user_id: int, project_id: int, *, choices: dict | None, via: str, ) -> dict: """Record a project's inception decision and apply it (milestone 297). Owner-only. Validates the choices (pure) and every target (owned / readable) first; then, each idempotent: subscribe the named rulebooks, 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 is additive for subscriptions (nothing is silently dropped — unsubscribe is an explicit call), replaces the design system, and re-seeds nothing a project already has. Returns {"inception": , "effects": {excluded, subscribed, design_system_id, systems_seeded}}. """ from scribe.services import design_systems as design_systems_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_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) for rb in choices["subscribe_rulebooks"]: await rulebooks_svc.subscribe_project(project_id, rb, user_id) 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 [] ) 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": { "subscribed": choices["subscribe_rulebooks"], "design_system_id": choices["design_system_id"], "systems_seeded": [sy.name for sy in seeded], }, } 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 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 {} books = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["rulebooks"]) or "none" 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 " f"inherits. Rulebooks it could subscribe to — {books}; 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 rulebooks to subscribe (default: none — a rulebook binds " "a project only when it opts in), 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." ), "call": ( f"decide_project_inception(project_id={project_id}, " "subscribe_rulebooks=[...], " "design_system_id=, seed_systems=)" ), }