"""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. Step 1 (this module's first cut) holds the shape and its validator; the effects (decide / current_defaults) arrive in step 3 and compose the existing services — subscriptions, exclusions, set_project_design_system, the Systems starter mint — and write the record LAST. """ from __future__ import annotations 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))