Files
FabledScribe/src/scribe/services/inception.py
T
bvandeusenandClaude Opus 5 0bcd4b5540
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped
feat(rules)!: retire rulebook subscriptions and per-project suppressions (#4052)
A rule's home is its scope now: a rule in a rulebook topic is global, a rule on
a project applies to that project, and retrieval reads that directly (#4074).
A subscription had stopped changing anything a session received; a suppression
muted rules from a subscription. Operator, 2026-09-15: "we have global and
project scoped rules, we don't need the subscriptions now."

What goes, whole (rule 22):
- Migration 0101 drops project_rulebook_subscriptions, project_rule_suppressions
  and project_topic_suppressions, and strips subscribe_rulebooks (and 394's
  leftover exclude_always_on_rulebooks) from stored inception choices.
- Service, MCP and REST: subscribe/unsubscribe and the four suppress/unsuppress
  operations. The Subscribers checklist, the subscribe chips, the skip buttons
  and the Suppressed section in the rules UI.
- Inception asks two questions (design system, seed Systems). create_project and
  decide_project_inception lose subscribe_rulebooks.
- Backup v15 stops exporting the three sections; older archives still restore,
  the keys simply unread. Trash no longer hard-deletes suppression rows.

What changes meaning:
- get_applicable_rules is a project's LISTING: its own rules, plus the global
  rules tagged to an area it works in. Untagged global rules apply everywhere
  and arrive by retrieval, so they are not listed. A co_surfaces partner on a
  different project is not dragged in.
- list_rules(project_id) lists that project's own rules.
- rules_payload drops subscribed_rulebooks and suppressed_*; the handshake's
  brief form is project_rules alone.
- using-scribe's "Where a new rule goes" and inception sections, tool
  docstrings and docs say global vs project. Plugin 2026.09.15.1620.

Milestone 414 step 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-15 12:20:57 -04:00

203 lines
8.2 KiB
Python

"""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": {
"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.
Rules are not a choice any more. ``exclude_always_on_rulebooks`` went with the
always-on tier (milestone 394), and ``subscribe_rulebooks`` went with
subscriptions (milestone 414): a rule in a rulebook is global and applies to
every project, and a project's own rules are written on it directly. Migration
0101 strips both keys from stored records.
The shape and its validator are pure; ``decide`` composes the existing
services — set_project_design_system and the standard Systems seed — checks
every target BEFORE touching anything,
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 datetime import datetime, timezone
from scribe.models import async_session
from scribe.models.project import Project
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("design_system_id", "seed_systems")
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 two keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
the product no longer offers, must not become a silently ignored one."""
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)})"
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:
"""Both keys, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
return {
"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))
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
{design_system_id, design_systems: [{id,title}], systems: <count>}.
Instance-agnostic: an install with no design systems shows an empty list,
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 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")
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
return {
"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
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 (readable)
first; then, each idempotent: 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
replaces the design system and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {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 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)
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": {
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
},
}
async def inception_ask(user_id: int, project_id: int) -> dict:
"""The enter_project ask for an undecided project (milestone 297) — the
sibling of the systems-bootstrap ask (#2683): the project's OWN current
defaults, what to ask the operator, and the exact call that answers it.
Fail-open: a hint must never break the call it rides on."""
try:
defaults = await current_defaults(user_id, project_id)
except Exception:
return {}
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
return {
"defaults": defaults,
"ask": (
"This project has no inception decision: nobody has said what it "
"inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which design system (or none), and whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
),
}