From 0bcd4b5540a0300466294c5ec86c48eac9881f42 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 12:20:57 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- .../0101_drop_rulebook_subscriptions.py | 82 +++ docs/api-keys-and-mcp.md | 2 +- docs/api-reference.md | 7 +- docs/features.md | 7 +- frontend/src/api/inception.ts | 7 +- frontend/src/api/rulebooks.ts | 50 +- frontend/src/components/InceptionCard.vue | 42 +- .../src/components/rules/ProjectRulesTab.vue | 211 +------ .../components/rules/RuleEditorSlideOver.vue | 2 +- .../components/rules/RulebookDetailPane.vue | 77 +-- frontend/src/types/task.ts | 1 - frontend/src/views/ProjectView.vue | 3 - plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/using-scribe/SKILL.md | 44 +- src/scribe/mcp/tools/milestones.py | 2 +- src/scribe/mcp/tools/notes.py | 4 +- src/scribe/mcp/tools/projects.py | 67 +-- src/scribe/mcp/tools/rulebooks.py | 151 +---- src/scribe/mcp/tools/tasks.py | 5 +- src/scribe/models/__init__.py | 3 +- src/scribe/models/project.py | 9 +- src/scribe/models/rulebook.py | 40 +- src/scribe/routes/projects.py | 3 +- src/scribe/routes/rulebooks.py | 80 +-- src/scribe/services/backup.py | 100 +--- src/scribe/services/inception.py | 105 +--- src/scribe/services/planning.py | 2 +- src/scribe/services/rulebooks.py | 527 ++++-------------- src/scribe/services/trash.py | 16 - tests/test_inception.py | 24 +- tests/test_integration_inception.py | 56 +- tests/test_integration_rule_surfacing.py | 113 ++-- tests/test_mcp_tool_milestones.py | 2 +- tests/test_mcp_tool_planning.py | 7 +- tests/test_mcp_tool_projects.py | 39 +- tests/test_mcp_tool_rulebooks.py | 82 +-- tests/test_milestone_summary_brief.py | 9 +- tests/test_routes_rulebooks.py | 50 +- tests/test_rule_usage_wiring.py | 13 +- tests/test_services_backup.py | 17 +- tests/test_services_planning.py | 5 +- tests/test_services_rulebooks.py | 112 ++-- tests/test_services_trash.py | 9 +- 43 files changed, 579 insertions(+), 1610 deletions(-) create mode 100644 alembic/versions/0101_drop_rulebook_subscriptions.py diff --git a/alembic/versions/0101_drop_rulebook_subscriptions.py b/alembic/versions/0101_drop_rulebook_subscriptions.py new file mode 100644 index 0000000..f13ce3b --- /dev/null +++ b/alembic/versions/0101_drop_rulebook_subscriptions.py @@ -0,0 +1,82 @@ +"""drop rulebook subscriptions and per-project suppressions + +Revision ID: 0101 +Revises: 0100 +Create Date: 2026-09-15 + +Milestone 414. A rule lives in a rulebook topic, where it is GLOBAL, or on one +project, and retrieval reads that home directly (step 1). Subscriptions were +the last thing that pretended a rulebook reached some projects and not others, +and after milestone 394 they changed nothing a session received — only what a +project's rule LISTING showed. Operator, 2026-09-15: "we have global and +project scoped rules, we don't need the subscriptions now." + +WHAT GOES + + - ``project_rulebook_subscriptions`` (migration 0058). + - ``project_rule_suppressions`` and ``project_topic_suppressions``. They let a + project mute rules from a rulebook it subscribed to. With no subscription + there is nothing to mute; a project that departs from a global rule writes + a project rule with an ``overrides`` relation, which says why. + - The ``subscribe_rulebooks`` key inside ``projects.inception.choices``, and + the ``exclude_always_on_rulebooks`` key milestone 394 left behind in the + same place. Both describe decisions that can no longer be made; a stored + record carrying them would be read back as a choice the product offers. + +IRREVERSIBLE, AND THE DOWNGRADE SAYS SO + +The downgrade recreates the three tables empty. Which projects subscribed to +which rulebooks, and what they muted, is in what this drops. Restore a backup +taken before this ran if the prior state matters. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0101" +down_revision = "0100" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_table("project_topic_suppressions") + op.drop_table("project_rule_suppressions") + op.drop_table("project_rulebook_subscriptions") + op.execute( + """ + UPDATE projects + SET inception = jsonb_set( + inception, '{choices}', + (inception->'choices') - 'subscribe_rulebooks' - 'exclude_always_on_rulebooks' + ) + WHERE inception IS NOT NULL + AND jsonb_typeof(inception->'choices') = 'object' + """ + ) + + +def _join_table(name: str, other: str, other_table: str) -> None: + op.create_table( + name, + sa.Column( + "project_id", sa.BigInteger(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + primary_key=True, nullable=False, + ), + sa.Column( + other, sa.BigInteger(), + sa.ForeignKey(f"{other_table}.id", ondelete="CASCADE"), + primary_key=True, nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=True, + ), + ) + + +def downgrade() -> None: + """Structure only. See the module docstring — the rows are gone.""" + _join_table("project_rulebook_subscriptions", "rulebook_id", "rulebooks") + _join_table("project_rule_suppressions", "rule_id", "rules") + _join_table("project_topic_suppressions", "topic_id", "rulebook_topics") diff --git a/docs/api-keys-and-mcp.md b/docs/api-keys-and-mcp.md index 4157fe0..ad4aa47 100644 --- a/docs/api-keys-and-mcp.md +++ b/docs/api-keys-and-mcp.md @@ -89,7 +89,7 @@ table here. The tools are grouped by family: | Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes | | Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from | | Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas | -| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules | +| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `relate_rules`, … | Engineering/workflow rules | | Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows | | Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes | | Admin | `get_app_logs` (write/admin key) | Diagnostics | diff --git a/docs/api-reference.md b/docs/api-reference.md index 1679231..c5d9bad 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes. |--------|------|-------------| | GET / POST | `/api/projects` | List (owned + shared) / create | | GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete | -| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) | +| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) | | GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload | | GET | `/api/projects/:id/notes` | Notes + tasks in this project | | GET / POST | `/api/projects/:id/milestones` | List / create milestones | @@ -115,11 +115,8 @@ endpoint at `/mcp`, not these REST routes. | GET | `/api/rules` | List rules | | POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic | | GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule | -| POST | `/api/projects/:id/rulebook-subscriptions` | Subscribe a project to a rulebook | -| GET | `/api/projects/:id/rules` | Applicable rules for a project | +| GET | `/api/projects/:id/rules` | A project's own rules, and the global rules tagged to its areas | | POST | `/api/projects/:id/rules` | Create a project-scoped rule | -| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule | -| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic | ## Sharing diff --git a/docs/features.md b/docs/features.md index d45fcfa..24a2238 100644 --- a/docs/features.md +++ b/docs/features.md @@ -64,8 +64,11 @@ across sessions. session when what the agent is about to do matches its trigger: a command, a file being written, or the operator's own message. `when_to_apply` is therefore the field that decides whether a rule is ever seen. -- **Per-project scope** — A project subscribes to rulebooks, and can add - project-scoped rules or suppress individual inherited rules/topics. +- **Global or project scope** — A rule in a rulebook is global: it applies in + every project. A project rule applies to that project only. Retrieval honours + the difference, so a session sees global rules plus its own project's, never + another project's. A project that departs from a global rule writes its own + and links it with an `overrides` relation. ## Stored Processes diff --git a/frontend/src/api/inception.ts b/frontend/src/api/inception.ts index 0321c78..ece4263 100644 --- a/frontend/src/api/inception.ts +++ b/frontend/src/api/inception.ts @@ -2,7 +2,6 @@ import { apiGet, apiPost } from "@/api/client"; export interface InceptionChoices { - subscribe_rulebooks: number[]; design_system_id: number | null; seed_systems: boolean; } @@ -15,8 +14,6 @@ export interface InceptionRecord { } export interface InceptionDefaults { - rulebooks: { id: number; title: string }[]; - subscribed_rulebooks: { id: number; title: string }[]; design_system_id: number | null; design_systems: { id: number; title: string }[]; systems: number; @@ -25,11 +22,11 @@ export interface InceptionDefaults { export interface InceptionDecision { project_id: number; inception: InceptionRecord; - effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] }; + effects: { design_system_id: number | null; systems_seeded: string[] }; } export const emptyChoices = (): InceptionChoices => ({ - subscribe_rulebooks: [], design_system_id: null, seed_systems: false, + design_system_id: null, seed_systems: false, }); export const fetchInceptionDefaults = (projectId: number) => diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 7b0fca1..5690a0c 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -108,23 +108,7 @@ export interface ApplicableRules { rulebook_title: string; })[]; project_rules: RuleHeader[]; - suppressed_rules: { - id: number; - title: string; - topic_id: number; - topic_title: string; - rulebook_id: number; - rulebook_title: string; - }[]; - suppressed_topics: { - id: number; - title: string; - rulebook_id: number; - rulebook_title: string; - }[]; truncated: boolean; - subscribed_rulebooks: { id: number; title: string }[]; - /** Always-on rulebooks this project opted out of at inception (milestone 297). */ } // ── Rulebooks ─────────────────────────────────────────────────────── @@ -272,15 +256,7 @@ export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); } -// ── Subscriptions ────────────────────────────────────────────────── - -export async function subscribeProject(projectId: number, rulebookId: number): Promise { - await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId }); -} - -export async function unsubscribeProject(projectId: number, rulebookId: number): Promise { - return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`); -} +// ── A project's rules ────────────────────────────────────────────── export async function getProjectApplicableRules(projectId: number): Promise { return apiGet(`/api/projects/${projectId}/rules`); @@ -293,24 +269,6 @@ export async function createProjectRule( return apiPost(`/api/projects/${projectId}/rules`, data); } -// ── Suppressions ─────────────────────────────────────────────────── - -export async function suppressRuleForProject(projectId: number, ruleId: number): Promise { - await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {}); -} - -export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise { - return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`); -} - -export async function suppressTopicForProject(projectId: number, topicId: number): Promise { - await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {}); -} - -export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise { - return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`); -} - /** * One row of the staleness sweep. Unlike RuleHeader this carries the CHECK @@ -337,9 +295,9 @@ export interface RuleVerificationRow { * first, never-checked at the top. Rules without a check never appear: * they are decisions, and there is nothing to go and check. * - * Not filterable by project — a project reaches rules through project - * scope, subscriptions, always-on rulebooks and exclusions, and a filter - * missing one of those paths would under-report. + * Not filterable by project — a project is bound by its own rules and by + * every global rule, and a filter that dropped the global ones would + * under-report. */ export async function listRulesDueForVerification(opts: { olderThanDays?: number; diff --git a/frontend/src/components/InceptionCard.vue b/frontend/src/components/InceptionCard.vue index f775f6f..1192996 100644 --- a/frontend/src/components/InceptionCard.vue +++ b/frontend/src/components/InceptionCard.vue @@ -14,7 +14,6 @@ import { decideInception, emptyChoices, fetchInceptionDefaults, type InceptionChoices, type InceptionDecision, type InceptionDefaults, } from "@/api/inception"; -import { listRulebooks } from "@/api/rulebooks"; const props = withDefaults(defineProps<{ mode: "create" | "decide"; @@ -28,7 +27,6 @@ const emit = defineEmits<{ }>(); const local = ref(props.choices ? { ...props.choices } : emptyChoices()); -const others = ref<{ id: number; title: string }[]>([]); const designSystems = ref<{ id: number; title: string }[]>([]); const systemsCount = ref(0); const loading = ref(true); @@ -46,18 +44,12 @@ async function load() { try { if (props.mode === "decide" && props.projectId) { const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId); - others.value = d.rulebooks; designSystems.value = d.design_systems; systemsCount.value = d.systems; - // Start from what stands today so "record" without changes is a true inherit-all. - local.value = { - subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id), - design_system_id: d.design_system_id, - seed_systems: false, - }; + // Start from what stands today, so "record" without changes keeps it. + local.value = { design_system_id: d.design_system_id, seed_systems: false }; } else { - const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]); - others.value = rulebooks.map((r) => ({ id: r.id, title: r.title })); + const ds = await fetchDesignSystems(); designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title })); } } catch (e: unknown) { @@ -67,17 +59,7 @@ async function load() { } } -function subscribed(id: number): boolean { - return local.value.subscribe_rulebooks.includes(id); -} -function toggleSubscribe(id: number) { - const list = local.value.subscribe_rulebooks; - local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id]; -} - -const nothingToDecide = computed( - () => !others.value.length && !designSystems.value.length, -); +const nothingToDecide = computed(() => !designSystems.value.length); async function record() { if (!props.projectId) return; @@ -101,22 +83,12 @@ onMounted(load);

What does this project inherit?

A project's inheritance is a decision, not a default. Until it is recorded, - every always-on rulebook binds, nothing is subscribed, and there is no design - system or Systems. + there is no design system and no Systems. Rules aren't part of this: global + rules apply to every project, and a project's own rules are added on it.

Loading…

{{ error }}