From e9b8f525c86a93ded1ebf45dd095319d7d100301 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 21:57:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(inception):=20projects.inception=20record?= =?UTF-8?q?=20+=20project=5Frulebook=5Fexclusions=20=E2=80=94=20migration?= =?UTF-8?q?=200085=20with=20legacy=20backfill;=20backup=20v10=20(#2879,=20?= =?UTF-8?q?milestone=20297=20step=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- alembic/versions/0085_project_inception.py | 74 +++++++++++++++++++ src/scribe/models/project.py | 10 +++ src/scribe/models/rulebook.py | 13 ++++ src/scribe/services/backup.py | 67 +++++++++++++++-- src/scribe/services/inception.py | 84 ++++++++++++++++++++++ tests/test_inception.py | 57 +++++++++++++++ tests/test_services_backup.py | 4 +- 7 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/0085_project_inception.py create mode 100644 src/scribe/services/inception.py create mode 100644 tests/test_inception.py diff --git a/alembic/versions/0085_project_inception.py b/alembic/versions/0085_project_inception.py new file mode 100644 index 0000000..40e776b --- /dev/null +++ b/alembic/versions/0085_project_inception.py @@ -0,0 +1,74 @@ +"""Project inception: the decision record + always-on rulebook exclusions (milestone 297) + +Revision ID: 0085 +Revises: 0084 +Create Date: 2026-08-22 + +`projects.inception` is the WHY a project inherits what it does — NULL until +someone decides, at which point enter_project stops asking. The new +association `project_rulebook_exclusions` is the opt-out of a whole always-on +rulebook for one project (the sibling of the rule/topic suppressions). + +Backfill: every project that exists when this runs is stamped +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 the step shipped, and nothing a running install relies on changes. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0085" +down_revision = "0084" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "projects", + sa.Column("inception", postgresql.JSONB(), nullable=True), + ) + op.create_table( + "project_rulebook_exclusions", + sa.Column( + "project_id", sa.BigInteger(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + primary_key=True, nullable=False, + ), + sa.Column( + "rulebook_id", sa.BigInteger(), + sa.ForeignKey("rulebooks.id", ondelete="CASCADE"), + primary_key=True, nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False, + ), + ) + # Legacy stamp: what each existing project inherits today, recorded as a + # decision so the inception ask does not fire on a project that has been + # running for months. + op.execute(sa.text(""" + UPDATE projects p SET inception = jsonb_build_object( + 'via', 'legacy', + 'decided_at', to_jsonb(now()), + 'decided_by', NULL, + 'choices', jsonb_build_object( + 'exclude_always_on_rulebooks', '[]'::jsonb, + 'subscribe_rulebooks', COALESCE( + (SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id) + FROM project_rulebook_subscriptions s + WHERE s.project_id = p.id), + '[]'::jsonb), + 'design_system_id', to_jsonb(p.design_system_id), + 'seed_systems', false + ) + ) + WHERE p.inception IS NULL + """)) + + +def downgrade() -> None: + op.drop_table("project_rulebook_exclusions") + op.drop_column("projects", "inception") diff --git a/src/scribe/models/project.py b/src/scribe/models/project.py index 3297106..f9e4869 100644 --- a/src/scribe/models/project.py +++ b/src/scribe/models/project.py @@ -1,5 +1,6 @@ import enum from sqlalchemy import BigInteger, ForeignKey, Integer, Text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso @@ -36,6 +37,14 @@ class Project(Base, TimestampMixin, SoftDeleteMixin): BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"), nullable=True, ) + # The inception record (milestone 297): what this project was decided to + # inherit, when, and through which door — {decided_at, decided_by, via, + # choices: {exclude_always_on_rulebooks, subscribe_rulebooks, + # design_system_id, seed_systems}}. NULL means nobody has decided yet, + # and enter_project asks; the effects themselves live in the subscription + # / exclusion tables, design_system_id and the project's Systems — this is + # the WHY, kept so later surfaces can say it. See services/inception.py. + inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True) def to_dict(self) -> dict: return { @@ -48,6 +57,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin): "color": self.color, "design_system_id": self.design_system_id, "forge_connection_id": self.forge_connection_id, + "inception": self.inception, "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/rulebook.py b/src/scribe/models/rulebook.py index 7a1cdf5..8217999 100644 --- a/src/scribe/models/rulebook.py +++ b/src/scribe/models/rulebook.py @@ -129,6 +129,19 @@ project_rule_suppressions = Table( Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), ) +# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the +# sibling of the two suppression tables below, one level up. Always-on +# rulebooks bind every project implicitly; an inception decision can exclude +# specific ones for this project, and get_applicable_rules / +# list_always_on_rules(project_id) skip them. FKs CASCADE like the others. +project_rulebook_exclusions = Table( + "project_rulebook_exclusions", + Base.metadata, + Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), + Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True), + Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), +) + project_topic_suppressions = Table( "project_topic_suppressions", Base.metadata, diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index d01c0a8..2f8cbff 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -19,6 +19,7 @@ from scribe.models.rulebook import ( Rulebook, RulebookTopic, project_rule_suppressions, + project_rulebook_exclusions, project_rulebook_subscriptions, project_topic_suppressions, ) @@ -45,8 +46,10 @@ logger = logging.getLogger(__name__) # v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870): # judgment-grade edges (agent/audit/import) are operator records; mechanical # ones (reference/hook) travel too, cheaply, and the next refresh refreshes them. +# v10 (2026-08) added projects.inception + project_rulebook_exclusions +# (milestone 297): the WHY a project inherits what it does, and its opt-outs. # Bump when the serialized schema changes. -BACKUP_VERSION = 9 +BACKUP_VERSION = 10 # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # below, these two lists must together account for the entire schema — which is @@ -60,7 +63,7 @@ _BACKED_UP = [ "users", "projects", "milestones", "notes", "task_logs", "note_drafts", "note_versions", "settings", "rulebooks", "rulebook_topics", "rules", "project_rulebook_subscriptions", "project_rule_suppressions", - "project_topic_suppressions", + "project_topic_suppressions", "project_rulebook_exclusions", # v5 (2026-08): the five-year gap this list was written to stop. "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", @@ -112,6 +115,10 @@ def _topic_suppression_rows(rows) -> list[dict]: return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows] +def _rulebook_exclusion_rows(rows) -> list[dict]: + return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows] + + # The v5 sections. Pure row-builders like the join-table helpers above, for the # same reason: CI has no database, so a serialiser that is a plain function is # one that can actually be tested. @@ -219,6 +226,8 @@ def _project_rows(rows) -> list[dict]: "id": p.id, "user_id": p.user_id, "title": p.title, "description": p.description, "goal": p.goal, "status": p.status, "color": p.color, + "design_system_id": p.design_system_id, + "inception": p.inception, "created_at": p.created_at.isoformat(), "updated_at": p.updated_at.isoformat(), } @@ -383,6 +392,9 @@ async def export_full_backup() -> dict: topic_suppressions = (await session.execute( select(project_topic_suppressions) )).all() + rulebook_exclusions = (await session.execute( + select(project_rulebook_exclusions) + )).all() return { "version": BACKUP_VERSION, @@ -407,6 +419,7 @@ async def export_full_backup() -> dict: "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), + "rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions), "systems": _system_rows(systems), "record_systems": _record_system_rows(record_systems), "design_systems": _design_system_rows(design_systems), @@ -532,8 +545,13 @@ async def export_user_backup(user_id: int) -> dict: project_topic_suppressions.c.project_id.in_(project_ids) ) )).all() + rulebook_exclusions = (await session.execute( + select(project_rulebook_exclusions).where( + project_rulebook_exclusions.c.project_id.in_(project_ids) + ) + )).all() else: - subscriptions = rule_suppressions = topic_suppressions = [] + subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = [] return { "version": BACKUP_VERSION, @@ -560,6 +578,7 @@ async def export_user_backup(user_id: int) -> dict: "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), + "rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions), "systems": _system_rows(systems), "record_systems": _record_system_rows(record_systems), "design_systems": _design_system_rows(design_systems), @@ -670,7 +689,7 @@ async def _restore_v2(data: dict) -> dict: "task_logs": 0, "note_drafts": 0, "note_versions": 0, "settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0, "rulebook_subscriptions": 0, "rule_suppressions": 0, - "topic_suppressions": 0, + "topic_suppressions": 0, "rulebook_exclusions": 0, "systems": 0, "record_systems": 0, "design_systems": 0, "design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0, "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0, @@ -933,6 +952,17 @@ async def _restore_v2(data: dict) -> dict: )) stats["topic_suppressions"] += 1 + # 14b. Always-on rulebook exclusions (v10, milestone 297) + for exc in data.get("rulebook_exclusions", []): + mapped_pid = project_id_map.get(exc.get("project_id", 0)) + mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0)) + if mapped_pid is None or mapped_rbid is None: + continue + await session.execute(project_rulebook_exclusions.insert().values( + project_id=mapped_pid, rulebook_id=mapped_rbid, + )) + stats["rulebook_exclusions"] += 1 + # --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4 # payload restores without them rather than failing on an absent key. @@ -1137,6 +1167,35 @@ async def _restore_v2(data: dict) -> dict: )) stats["code_shape_uses"] += 1 + # v10: a project's design-system pointer and its inception record ride + # the project but point at design systems and rulebooks restored AFTER + # it — so they are written last, with ids re-mapped. An id that did + # not survive drops out of the record rather than dangling. + for p_data in data.get("projects", []): + new_pid = project_id_map.get(p_data.get("id") or 0) + if new_pid is None: + continue + proj = await session.get(Project, new_pid) + if proj is None: + continue + old_ds = p_data.get("design_system_id") + if old_ds: + proj.design_system_id = design_system_id_map.get(old_ds) + inception = p_data.get("inception") + if isinstance(inception, dict): + choices = dict(inception.get("choices") or {}) + choices["exclude_always_on_rulebooks"] = [ + rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or [] + if i in rulebook_id_map + ] + choices["subscribe_rulebooks"] = [ + rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or [] + if i in rulebook_id_map + ] + ds = choices.get("design_system_id") + choices["design_system_id"] = design_system_id_map.get(ds) if ds else None + proj.inception = {**inception, "choices": choices} + await session.commit() logger.info("Restored v2/v3 backup: %s", stats) diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py new file mode 100644 index 0000000..2e949c4 --- /dev/null +++ b/src/scribe/services/inception.py @@ -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": "", "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)) diff --git a/tests/test_inception.py b/tests/test_inception.py new file mode 100644 index 0000000..e10e21e --- /dev/null +++ b/tests/test_inception.py @@ -0,0 +1,57 @@ +"""Project inception (milestone 297) — step 1: the record's shape. + +The WHY a project inherits what it does lives on projects.inception; the +opt-out of an always-on rulebook is its own association table. Pure +validation is pinned here; the effects are step 3's integration tests. +""" +from scribe.models import Base +from scribe.models.project import Project +from scribe.models.rulebook import project_rulebook_exclusions +from scribe.services.inception import ( + CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception, +) + + +def test_project_carries_an_inception_record_and_to_dict_shows_it(): + assert "inception" in Project.__table__.c + assert Project.__table__.c.inception.nullable # NULL = undecided + p = Project(user_id=1, title="x", inception=None) + assert p.to_dict()["inception"] is None and not is_decided(p) + p.inception = {"via": "mcp", "decided_at": "2026-08-22T00:00:00+00:00", "decided_by": 1, + "choices": normalize_choices({"seed_systems": True})} + assert is_decided(p) and p.to_dict()["inception"]["via"] == "mcp" + assert INCEPTION_VIAS == ("mcp", "ui", "legacy") + + +def test_exclusions_table_is_the_suppressions_sibling(): + t = Base.metadata.tables["project_rulebook_exclusions"] + assert project_rulebook_exclusions is t + assert {c.name for c in t.primary_key.columns} == {"project_id", "rulebook_id"} + fks = {fk.column.table.name: fk.ondelete for c in t.columns for fk in c.foreign_keys} + assert fks == {"projects": "CASCADE", "rulebooks": "CASCADE"} + + +def test_validate_inception_pins_the_choice_vocabulary(): + assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems") + assert validate_inception({}) is None + assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2], + "design_system_id": 3, "seed_systems": True}) is None + assert validate_inception({"design_system_id": None}) is None + assert "must be an object" in validate_inception([]) + assert "unknown inception choice" in validate_inception({"repo": "x"}) + assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"}) + assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]}) + assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]}) + assert "both excluded and subscribed" in validate_inception( + {"exclude_always_on_rulebooks": [1, 2], "subscribe_rulebooks": [2]}) + assert "positive id or null" in validate_inception({"design_system_id": 0}) + assert "positive id or null" in validate_inception({"design_system_id": True}) + assert "true or false" in validate_inception({"seed_systems": "yes"}) + + +def test_normalize_choices_is_canonical_and_complete(): + out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]}) + assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3], + "design_system_id": None, "seed_systems": False} + assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [], + "design_system_id": None, "seed_systems": False} diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 05b062e..3eaac1c 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -18,7 +18,7 @@ def test_backup_version_is_v8(): point of the test — a payload section added without moving the version produces backups that are structurally different and indistinguishable by inspection.""" - assert backup.BACKUP_VERSION == 9 + assert backup.BACKUP_VERSION == 10 def test_not_included_lists_the_known_gaps(): @@ -116,7 +116,7 @@ async def test_export_full_backup_contains_every_declared_section(): "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", "code_shapes", "code_shape_events", - "code_shape_uses"): + "code_shape_uses", "rulebook_exclusions"): assert key in out, f"missing export section: {key}" assert out[key] == []