feat(inception): services/inception.decide() + current_defaults(); the standard Systems vocabulary moves to the service and seeds at inception (#2881, milestone 297 step 3)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / integration (push) Successful in 36s
CI & Build / Python tests (push) Failing after 53s
CI & Build / Build & push image (push) Skipped

- inception.decide(user, project, choices=, via=): owner-only; validates the
  choices (pure) and every target (owned rulebook / always-on for an
  exclusion / readable design system) BEFORE any effect; then, each
  idempotent: exclude always-on rulebooks, subscribe rulebooks, point the
  design system (None = explicitly none), seed the standard Systems if asked
  and the project has none; writes projects.inception LAST. Re-deciding is
  additive for exclusions/subscriptions, replaces the design system, never
  re-seeds.
- inception.current_defaults(): what binds if nobody decides — the ask's
  payload (always-on / other rulebooks, standing exclusions + subscriptions,
  design system + the choices, Systems count).
- services/systems.STANDARD_SYSTEMS (name + generic charter) + seed_standard_systems();
  mcp/tools/systems names the same list in the bootstrap ask — one vocabulary.
- Integration tests: effects land and the record says why; bad targets apply
  nothing; outsiders cannot decide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:03:02 -04:00
co-authored by Claude Fable 5
parent ff5f6438c4
commit 34734bf84a
5 changed files with 317 additions and 8 deletions
+4 -4
View File
@@ -30,10 +30,10 @@ _BOOTSTRAP_TITLES = 6
# design (rule #115): archetypes any codebase could have, never one # design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate # install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl. # guards sprawl.
_STANDARD_SYSTEMS = ( # The standard vocabulary lives with the service (services/systems.
"CI & Release", "Auth & Access", "Data Model & Storage", "API Surface", # STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this
"UI & Design", "Import & Export", "Background Jobs", "Observability", # ask names it, one list for both.
) _STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS)
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None: async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
+159 -4
View File
@@ -18,13 +18,24 @@ NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
projects that existed before the step did (inherit-all / no design system / 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. 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 The shape and its validator are pure; ``decide`` composes the existing
effects (decide / current_defaults) arrive in step 3 and compose the services — always-on exclusions, subscriptions, set_project_design_system,
existing services — subscriptions, exclusions, set_project_design_system, the standard Systems seed — checks every target BEFORE touching anything,
the Systems starter mint — and write the record LAST. 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 __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") INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems") CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
@@ -82,3 +93,147 @@ def normalize_choices(choices: dict | None) -> dict:
def is_decided(project) -> bool: def is_decided(project) -> bool:
"""A project is decided once its inception record exists (any via).""" """A project is decided once its inception record exists (any via)."""
return bool(getattr(project, "inception", None)) 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: <count>}.
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": <record>, "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],
},
}
+35
View File
@@ -18,6 +18,41 @@ from scribe.services import access
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# The standard cross-project vocabulary (#2798): names that mean the same
# thing in every project, so a starter set reads the same everywhere. The
# bootstrap ask (mcp/tools/systems) names them; the inception seed
# (services/inception, milestone 297) mints them. Charters are deliberately
# generic — a project refines them as its own records accrue.
STANDARD_SYSTEMS: tuple[tuple[str, str], ...] = (
("CI & Release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
("Auth & Access", "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
("Data Model & Storage", "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
("API Surface", "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
("UI & Design", "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
("Import & Export", "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
("Background Jobs", "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
("Observability", "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
)
async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
"""Mint the standard starter set for a project that has NO Systems yet
(milestone 297). Idempotent: a project with any System — the vocabulary
already started, standard or not — gets nothing; the duplicate gate and
the project's own judgment take it from there. [] without write access."""
if await list_systems(user_id, project_id, include_archived=True):
return []
out: list[System] = []
for index, (name, charter) in enumerate(STANDARD_SYSTEMS):
system = await create_system(
user_id, project_id, name, description=charter, order_index=index,
)
if system is None:
break
out.append(system)
return out
async def create_system( async def create_system(
user_id: int, user_id: int,
project_id: int, project_id: int,
+8
View File
@@ -55,3 +55,11 @@ def test_normalize_choices_is_canonical_and_complete():
"design_system_id": None, "seed_systems": False} "design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [], assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False} "design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_is_one_list_for_ask_and_seed():
from scribe.mcp.tools.systems import _STANDARD_SYSTEMS
from scribe.services.systems import STANDARD_SYSTEMS
assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS)
assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS)
+111
View File
@@ -0,0 +1,111 @@
"""Real-Postgres integration tests for project inception (milestone 297).
What mocks can't prove: a decision's effects land through the real services
(exclusions filter the always-on set, subscriptions bind, the design system
points, the standard Systems seed once), the record is written last, a bad
target applies nothing.
"""
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@pytest_asyncio.fixture
async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
ordinary rulebook (with a rule)."""
async with async_session() as s:
owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
await s.commit()
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
async with async_session() as s:
rb = await s.get(Rulebook, always.id)
rb.always_on = True
await s.commit()
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
ids.update({"always": always.id, "other": other.id})
return ids
@pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
defaults = await inception_svc.current_defaults(owner, pid)
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["always"]],
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None,
"seed_systems": True,
})
assert out["effects"]["excluded"] == [seeded["always"]]
assert out["effects"]["subscribed"] == [seeded["other"]]
assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_SYSTEMS)
# The exclusion is total: the project's always-on set is empty, the
# departure is named, the subscription binds.
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
# The record, written last, says why.
async with async_session() as s:
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
assert len(await systems_svc.list_systems(owner, pid)) == len(systems_svc.STANDARD_SYSTEMS)
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
@pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
with pytest.raises(ValueError, match="not always-on"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s:
project = await s.get(Project, pid)
assert not inception_svc.is_decided(project)
# An outsider cannot decide someone else's project.
async with async_session() as s:
other = await ensure_user(s, "inception_other")
other_id = other.id
await s.commit()
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(other_id, pid, via="mcp", choices={})