"""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": { "exclude_always_on_rulebooks": [rulebook ids], "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. The shape and its validator are pure; ``decide`` composes the existing services — always-on exclusions, 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 = ("exclude_always_on_rulebooks", "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)})" excl = choices.get("exclude_always_on_rulebooks") or [] subs = choices.get("subscribe_rulebooks") or [] if not _is_id_list(excl): return "exclude_always_on_rulebooks must be a list of rulebook ids" if not _is_id_list(subs): return "subscribe_rulebooks must be a list of rulebook ids" both = sorted(set(excl) & set(subs)) if both: return f"rulebook(s) {both} cannot be both excluded and subscribed" 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 four keys, always present, in canonical form — what gets stored and what the UI/agent reads back. Call after validate_inception.""" choices = choices or {} return { "exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])), "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. {always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}], excluded_always_on: [...], 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, Rulebook.always_on) .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 { "always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on], "other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on], "excluded_always_on": applicable.get("excluded_always_on", []), "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["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"]) if wanted: async with async_session() as session: rows = ( await session.execute( select(Rulebook.id, Rulebook.always_on).where( Rulebook.id.in_(wanted), Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None), ) ) ).all() found = {rid: on for rid, on in rows} missing = sorted(wanted - set(found)) if missing: raise ValueError(f"rulebook(s) {missing} not found (or not yours)") not_always = sorted(r for r in choices["exclude_always_on_rulebooks"] if not found[r]) if not_always: raise ValueError( f"rulebook(s) {not_always} are not always-on — only always-on rulebooks " "can be excluded; a subscribed rulebook is simply not subscribed" ) 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: exclude the named always-on rulebooks, 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 exclusions/subscriptions (nothing is silently dropped — include/unsubscribe are explicit calls), 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["exclude_always_on_rulebooks"]: await rulebooks_svc.exclude_always_on_rulebook_for_project(project_id, rb, user_id) 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": { "excluded": choices["exclude_always_on_rulebooks"], "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 {} always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none" others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_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. Today, by default: always-on rulebooks binding it — {always}; " f"rulebooks it could subscribe to — {others}; 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 always-on rulebooks to EXCLUDE here (default: none), which " "rulebooks to subscribe, 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}, " "exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], " "design_system_id=, seed_systems=)" ), }