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
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:
@@ -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))
|
||||
Reference in New Issue
Block a user