feat(inception): projects.inception record + project_rulebook_exclusions — migration 0085 with legacy backfill; backup v10 (#2879, milestone 297 step 1)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 36s

A project's inheritance becomes a decision, not a default (milestone 297).
- projects.inception (JSONB, NULL = undecided): {decided_at, decided_by, via
  mcp|ui|legacy, choices {exclude_always_on_rulebooks, subscribe_rulebooks,
  design_system_id, seed_systems}}; on to_dict.
- project_rulebook_exclusions: a project's opt-out of a whole always-on
  rulebook — the sibling of the rule/topic suppressions, CASCADE both ways.
- services/inception.py (first cut): the vocabulary, validate_inception
  (pure, all-or-nothing), normalize_choices, is_decided. Effects come in
  step 3.
- Migration 0085 backfills every existing project via="legacy" with its
  current standing (no exclusions, its subscriptions, its design_system_id,
  no seed) so the ask fires only for projects created after this ships.
- Backup v10: rulebook_exclusions section; project rows carry inception and
  design_system_id, restored in a post-pass once rulebooks/design systems are
  mapped (design_system_id was not restored before — fixed in passing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:57:02 -04:00
co-authored by Claude Fable 5
parent 5415bff85c
commit e9b8f525c8
7 changed files with 303 additions and 6 deletions
+84
View File
@@ -0,0 +1,84 @@
"""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": "<iso>", "decided_by": <user id> | null,
"via": "mcp" | "ui" | "legacy",
"choices": {
"exclude_always_on_rulebooks": [rulebook ids],
"subscribe_rulebooks": [rulebook ids],
"design_system_id": <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))